merge(dev): sync dev into main

This commit is contained in:
777genius 2026-05-07 02:40:08 +03:00
commit 9a8ea6af08
151 changed files with 11976 additions and 645 deletions

View file

@ -475,13 +475,34 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
});
}
function setTaskOwner(paths, taskRef, owner) {
function normalizeOwnerValue(owner) {
if (owner == null || owner === 'clear' || owner === 'none') {
return undefined;
}
const normalized = String(owner).trim();
return normalized ? normalized : undefined;
}
function setTaskOwner(paths, taskRef, owner, actor) {
return updateTask(paths, taskRef, (task) => {
if (owner == null || owner === 'clear' || owner === 'none') {
delete task.owner;
const previousOwner = normalizeOwnerValue(task.owner);
const nextOwner = normalizeOwnerValue(owner);
if (nextOwner) {
task.owner = nextOwner;
} else {
task.owner = String(owner).trim();
delete task.owner;
}
if (previousOwner !== nextOwner) {
task.historyEvents = appendHistoryEvent(task.historyEvents, {
type: 'owner_changed',
...(previousOwner ? { from: previousOwner } : {}),
...(nextOwner ? { to: nextOwner } : {}),
...(actor ? { actor } : {}),
});
}
return task;
});
}

View file

@ -472,13 +472,13 @@ function restoreTask(context, taskId, actor) {
});
}
function setTaskOwner(context, taskId, owner) {
function setTaskOwner(context, taskId, owner, actor) {
const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
const nextOwner = isClearOwnerValue(owner)
? owner
: assertKnownTaskActor(context, owner, 'task owner');
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner, normalizeActorName(actor) || undefined);
return {
previousTask: before,
updatedTask: after,
@ -707,7 +707,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
- Human-facing summaries should use the short display label like #abcd1234 for readability.
1. If you are about to do implementation/fix work on a task yourself, make sure the owner reflects the actual implementer:
- If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner:
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>" }
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>", actor: "<your-name>" }
- Do this only when you are genuinely taking over the work.
- Reviewing, approving, or leaving comments does NOT require changing ownership.
2. Use MCP tool task_start to mark task started:
@ -911,14 +911,17 @@ async function memberBriefing(context, memberName, options = {}) {
config.projectPath.trim() :
'';
const activeProcesses = processStore
.listProcesses(context.paths)
.filter(
(entry) =>
entry &&
entry.alive &&
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
);
const includeActiveProcesses = options.includeActiveProcesses !== false;
const activeProcesses = includeActiveProcesses ?
processStore
.listProcesses(context.paths)
.filter(
(entry) =>
entry &&
entry.alive &&
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
) :
[];
const taskQueue = await taskBriefing(context, requestedMemberName);
const completionNotifyExample = messagingProtocol.buildLeadMessageExample({

View file

@ -717,6 +717,27 @@ describe('agent-teams-controller API', () => {
]);
});
it('tracks owner assignment history without duplicate same-owner events', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Owner history' });
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
controller.tasks.setTaskOwner(task.id, 'alice', 'team-lead');
controller.tasks.setTaskOwner(task.id, null, 'team-lead');
const ownerEvents = controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'owner_changed');
expect(ownerEvents).toHaveLength(3);
expect(ownerEvents[0]).toMatchObject({ to: 'bob', actor: 'team-lead' });
expect(ownerEvents[1]).toMatchObject({ from: 'bob', to: 'alice', actor: 'team-lead' });
expect(ownerEvents[2]).toMatchObject({ from: 'alice', actor: 'team-lead' });
expect(ownerEvents[2].to).toBeUndefined();
});
it('wraps review instructions in the canonical agent block format used by the UI', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
completeTask(taskId: string, actor?: string): unknown;
softDeleteTask(taskId: string, actor?: string): unknown;
restoreTask(taskId: string, actor?: string): unknown;
setTaskOwner(taskId: string, owner: string | null): unknown;
setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown;
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
memberBriefing(
memberName: string,
options?: { runtimeProvider?: 'native' | 'opencode' }
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
): Promise<string>;
leadBriefing(): Promise<string>;
taskBriefing(memberName: string): Promise<string>;

View file

@ -413,18 +413,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'task_set_owner',
description: 'Assign or clear task owner',
description: 'Assign, reassign, or clear task owner',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
owner: z.string().nullable(),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, owner }) => {
execute: async ({ teamName, claudeDir, taskId, owner, actor }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner, actor) as Record<
string,
unknown
>
@ -622,8 +623,15 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
memberName: z.string().min(1),
runtimeProvider: z.enum(['native', 'opencode']).optional(),
includeActiveProcesses: z.boolean().optional(),
}),
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
execute: async ({
teamName,
claudeDir,
memberName,
runtimeProvider,
includeActiveProcesses,
}) => {
assertConfiguredTeam(teamName, claudeDir);
return {
content: [
@ -631,6 +639,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
...(runtimeProvider ? { runtimeProvider } : {}),
...(includeActiveProcesses !== undefined ? { includeActiveProcesses } : {}),
}),
},
],

View file

@ -601,15 +601,36 @@ describe('agent-teams-mcp tools', () => {
);
expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id);
await getTool('task_set_owner').execute({
claudeDir,
teamName,
taskId: createdTask.id,
owner: null,
actor: 'lead',
});
const owned = parseJsonToolResult(
await getTool('task_set_owner').execute({
claudeDir,
teamName,
taskId: createdTask.id,
owner: 'alice',
actor: 'lead',
})
);
expect(owned.owner).toBe('alice');
const ownedFull = parseJsonToolResult(
await getTool('task_get').execute({
claudeDir,
teamName,
taskId: createdTask.id,
})
);
expect(ownedFull.historyEvents.at(-1)).toMatchObject({
type: 'owner_changed',
to: 'alice',
actor: 'lead',
});
const commented = parseJsonToolResult(
await getTool('task_add_comment').execute({

View file

@ -22,7 +22,11 @@
"dev:web": "node ./scripts/dev-web.mjs",
"dev:kill": "node bin/kill-dev.js",
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",
"opencode:prove-semantic-messaging": "node ./scripts/prove-opencode-semantic-messaging.mjs",
"opencode:prove-semantic-model-matrix": "node ./scripts/prove-opencode-semantic-model-matrix.mjs",
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",

View file

@ -39,7 +39,7 @@ export function drawTasks(
ctx.globalAlpha = opacity;
if (simplify) {
drawTaskPillLod(ctx, x, y, node, isSelected, isHovered);
drawTaskPillLod(ctx, x, y, node, time, isSelected, isHovered);
} else {
drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
}
@ -145,6 +145,10 @@ function drawTaskPill(
ctx.stroke();
}
if (node.hasLiveTaskLogs) {
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time);
}
// Subject (main title — large)
if (node.sublabel) {
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
@ -235,6 +239,7 @@ function drawTaskPillLod(
x: number,
y: number,
node: GraphNode,
time: number,
isSelected: boolean,
isHovered: boolean
): void {
@ -276,6 +281,45 @@ function drawTaskPillLod(
ctx.fill();
}
if (node.hasLiveTaskLogs) {
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time, true);
}
ctx.restore();
}
function drawLiveTaskLogIndicator(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
time: number,
compact = false
): void {
const coreRadius = compact ? 2.5 : 3.4;
const glowRadius = compact ? 7 : 10;
const pulse = 0.55 + 0.25 * Math.sin(time * 6);
const color = COLORS.reviewApproved;
const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius);
glow.addColorStop(0, hexWithAlpha(color, 0.35 + pulse * 0.28));
glow.addColorStop(1, hexWithAlpha(color, 0));
ctx.save();
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = hexWithAlpha(color, 0.95);
ctx.beginPath();
ctx.arc(x, y, coreRadius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = hexWithAlpha(color, pulse);
ctx.lineWidth = compact ? 0.8 : 1;
ctx.beginPath();
ctx.arc(x, y, coreRadius + (compact ? 1.2 : 1.8), 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}

View file

@ -164,6 +164,8 @@ export interface GraphNode {
totalCommentCount?: number;
/** Unread comment count on this task */
unreadCommentCount?: number;
/** Recent live log activity is arriving for this task */
hasLiveTaskLogs?: boolean;
/** Synthetic overflow stack node instead of hidden task tails */
isOverflowStack?: boolean;
/** Number of hidden tasks behind this overflow stack */

View file

@ -283,6 +283,7 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -480,6 +481,7 @@
"supports_vision": true,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_tool_choice": true
},
"global.anthropic.claude-opus-4-7": {
@ -627,6 +629,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -656,6 +659,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -685,6 +689,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -713,6 +718,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -741,6 +747,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -1018,6 +1025,7 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
@ -1141,6 +1149,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -1711,6 +1720,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
@ -1718,6 +1728,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -1853,6 +1864,7 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -1880,6 +1892,7 @@
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -1901,6 +1914,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -1934,6 +1948,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -1967,6 +1982,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -2001,6 +2017,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
@ -2144,6 +2161,7 @@
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_tool_choice": true
},
"databricks/databricks-claude-sonnet-4": {
@ -2655,7 +2673,8 @@
"mode": "chat",
"output_cost_per_token": 0.000025,
"supports_function_calling": true,
"supports_vision": true
"supports_vision": true,
"supports_minimal_reasoning_effort": true
},
"gmi/anthropic/claude-sonnet-4.5": {
"input_cost_per_token": 0.000003,
@ -3304,6 +3323,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 159,
@ -3322,6 +3342,7 @@
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -3343,6 +3364,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -3408,6 +3430,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@ -3786,6 +3809,7 @@
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
@ -3814,6 +3838,7 @@
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
@ -3841,6 +3866,7 @@
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
@ -4107,6 +4133,7 @@
"output_cost_per_token": 0.000025,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_minimal_reasoning_effort": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
@ -4446,6 +4473,7 @@
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
@ -4472,6 +4500,7 @@
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
@ -4638,6 +4667,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
@ -4778,6 +4808,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,

View file

@ -1,27 +1,27 @@
{
"version": "0.0.18",
"sourceRef": "v0.0.18",
"version": "0.0.21",
"sourceRef": "v0.0.21",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.18.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.18.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.18.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.18.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -0,0 +1,43 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const env = {
...process.env,
AGENT_CLI_LAUNCH_LIVE_E2E: '1',
};
console.log('Running agent CLI launch live smoke');
const result = spawnSync(
'pnpm',
[
'exec',
'vitest',
'run',
'--maxWorkers',
'1',
'--minWorkers',
'1',
'test/main/utils/AgentCliLaunch.live-e2e.test.ts',
],
{
cwd: repoRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
}
);
if (result.error) {
console.error(`Failed to run agent CLI launch smoke: ${result.error.message}`);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -0,0 +1,73 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
exitForSkippedPreflight,
preflightOpenCodeLiveEnvironment,
} from './lib/opencode-live-preflight.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
const env = {
...process.env,
OPENCODE_E2E: '1',
OPENCODE_E2E_SEMANTIC_MODEL_GAUNTLET: '1',
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
OPENCODE_E2E_GAUNTLET_RUNS: process.env.OPENCODE_E2E_GAUNTLET_RUNS?.trim() || '1',
OPENCODE_E2E_GAUNTLET_MIN_AVERAGE_SCORE:
process.env.OPENCODE_E2E_GAUNTLET_MIN_AVERAGE_SCORE?.trim() || '80',
OPENCODE_E2E_GAUNTLET_MIN_SUCCESSFUL_RUNS:
process.env.OPENCODE_E2E_GAUNTLET_MIN_SUCCESSFUL_RUNS?.trim() || '1',
OPENCODE_E2E_GAUNTLET_MIN_CONSISTENCY_SCORE:
process.env.OPENCODE_E2E_GAUNTLET_MIN_CONSISTENCY_SCORE?.trim() || '0',
OPENCODE_E2E_GAUNTLET_REQUIRE_RECOMMENDED:
process.env.OPENCODE_E2E_GAUNTLET_REQUIRE_RECOMMENDED?.trim() || '1',
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
}
console.log('Running OpenCode semantic gauntlet live smoke');
console.log(`Models: ${env.OPENCODE_E2E_MODELS?.trim() || env.OPENCODE_E2E_MODEL}`);
console.log(`Runs per model: ${env.OPENCODE_E2E_GAUNTLET_RUNS}`);
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
exitForSkippedPreflight(preflight);
const result = spawnSync(
'pnpm',
[
'exec',
'vitest',
'run',
'--maxWorkers',
'1',
'--minWorkers',
'1',
'test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts',
],
{
cwd: repoRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
}
);
if (result.error) {
console.error(`Failed to run OpenCode semantic gauntlet smoke: ${result.error.message}`);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -0,0 +1,65 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
exitForSkippedPreflight,
preflightOpenCodeLiveEnvironment,
} from './lib/opencode-live-preflight.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
const env = {
...process.env,
OPENCODE_E2E: '1',
OPENCODE_E2E_SEMANTIC_MESSAGING: '1',
OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
}
console.log('Running OpenCode semantic messaging live smoke');
console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
exitForSkippedPreflight(preflight);
const result = spawnSync(
'pnpm',
[
'exec',
'vitest',
'run',
'--maxWorkers',
'1',
'--minWorkers',
'1',
'test/main/services/team/OpenCodeSemanticMessaging.live.test.ts',
],
{
cwd: repoRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
}
);
if (result.error) {
console.error(`Failed to run OpenCode semantic messaging smoke: ${result.error.message}`);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -0,0 +1,63 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
exitForSkippedPreflight,
preflightOpenCodeLiveEnvironment,
} from './lib/opencode-live-preflight.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
const env = {
...process.env,
OPENCODE_E2E: '1',
OPENCODE_E2E_SEMANTIC_MODEL_MATRIX: '1',
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
}
console.log('Running OpenCode semantic model matrix live smoke');
console.log(`Models: ${env.OPENCODE_E2E_MODELS?.trim() || env.OPENCODE_E2E_MODEL}`);
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
exitForSkippedPreflight(preflight);
const result = spawnSync(
'pnpm',
[
'exec',
'vitest',
'run',
'--maxWorkers',
'1',
'--minWorkers',
'1',
'test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts',
],
{
cwd: repoRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
}
);
if (result.error) {
console.error(`Failed to run OpenCode semantic model matrix smoke: ${result.error.message}`);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -107,6 +107,7 @@ export function collapseOverflowStacksWithMeta(
? 'has_changes'
: undefined,
isBlocked: hiddenTasks.some((task) => task.isBlocked),
hasLiveTaskLogs: hiddenTasks.some((task) => task.hasLiveTaskLogs) ? true : undefined,
isOverflowStack: true,
overflowCount: hiddenTasks.length,
overflowTaskIds,

View file

@ -1,27 +1,34 @@
import {
getTeamTaskWorkflowColumn,
isTeamTaskDeleted,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
type TaskColumnInput = Pick<TeamTaskWithKanban, 'status' | 'reviewState' | 'kanbanColumn'>;
type TaskColumnInput = Pick<
TeamTaskWithKanban,
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
>;
type TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>;
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
type TaskBlockState = Pick<TeamTask, 'status'>;
type TaskBlockState = Pick<
TeamTaskWithKanban,
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
>;
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
if (task.reviewState === 'approved') return 'approved';
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return task.kanbanColumn;
}
const workflowColumn = getTeamTaskWorkflowColumn(task);
if (workflowColumn) return workflowColumn;
if (isTeamTaskNeedsFixActionable(task)) return 'review';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'completed') return 'done';
return 'todo';
}
export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
return (
task.reviewState === 'review' ||
task.reviewState === 'needsFix' ||
task.kanbanColumn === 'review'
);
return isTeamTaskNeedsFixActionable(task) || getTeamTaskWorkflowColumn(task) === 'review';
}
export function resolveTaskReviewer(
@ -43,6 +50,6 @@ export function isTaskBlocked(
return blockedBy.some((taskId) => {
const blocker = taskStateById.get(taskId);
return !blocker || (blocker.status !== 'completed' && blocker.status !== 'deleted');
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
});
}

View file

@ -25,6 +25,10 @@ import {
} from '@shared/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
isTeamTaskActivelyWorked,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
import {
@ -41,6 +45,7 @@ import {
import {
isTaskBlocked,
isTaskInReviewCycle,
resolveTaskGraphColumn,
resolveTaskReviewer,
} from '../../core/domain/taskGraphSemantics';
@ -118,7 +123,8 @@ export class TeamGraphAdapter {
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
layoutMode: GraphLayoutMode = 'radial',
gridOwnerOrder?: readonly string[]
gridOwnerOrder?: readonly string[],
activeTaskLogActivity?: Record<string, true>
): GraphDataPort {
if (teamData?.teamName !== teamName) {
return TeamGraphAdapter.#emptyResult(teamName);
@ -203,7 +209,8 @@ export class TeamGraphAdapter {
commentReadState,
memberNodeIdByAlias,
leadId,
leadName
leadName,
activeTaskLogActivity
);
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
@ -539,8 +546,17 @@ export class TeamGraphAdapter {
spawn,
pendingApprovalAgents?.has(member.name) ?? false
);
const currentTask = member.currentTaskId
? data.tasks.find((task) => task.id === member.currentTaskId)
: undefined;
const displayableCurrentTask =
currentTask && isTeamTaskActivelyWorked(currentTask) ? currentTask : undefined;
const presentationMember =
member.currentTaskId && !displayableCurrentTask
? { ...member, currentTaskId: null }
: member;
const launchPresentation = buildMemberLaunchPresentation({
member,
member: presentationMember,
spawnStatus: spawn?.status,
spawnLaunchState: spawn?.launchState,
spawnLivenessSource: spawn?.livenessSource,
@ -577,10 +593,8 @@ export class TeamGraphAdapter {
? (launchPresentation.launchStatusLabel ?? undefined)
: undefined,
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
: undefined,
currentTaskId: displayableCurrentTask?.id,
currentTaskSubject: displayableCurrentTask?.subject,
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
exceptionTone: exception?.exceptionTone,
exceptionLabel: exception?.exceptionLabel,
@ -627,14 +641,23 @@ export class TeamGraphAdapter {
commentReadState?: Record<string, unknown>,
memberNodeIdByAlias?: ReadonlyMap<string, string>,
leadId?: string,
leadName?: string
leadName?: string,
activeTaskLogActivity?: Record<string, true>
): void {
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
const taskStateById = new Map<
string,
Pick<TeamGraphData['tasks'][number], 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
>();
const taskDisplayIds = new Map<string, string>();
const memberColorByName = new Map<string, string>();
for (const t of data.tasks) {
taskStateById.set(t.id, { status: t.status });
taskStateById.set(t.id, {
status: t.status,
...(t.reviewState ? { reviewState: t.reviewState } : {}),
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
});
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
}
for (const member of data.members) {
@ -657,9 +680,19 @@ export class TeamGraphAdapter {
const kanbanTaskState = data.kanbanState.tasks[task.id];
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
const isReviewCycle = isTaskInReviewCycle(task);
const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState);
const graphColumn = resolveTaskGraphColumn(task);
const taskStatus =
graphColumn === 'approved'
? 'completed'
: TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
const reviewState =
graphColumn === 'approved'
? 'approved'
: graphColumn === 'review'
? isTeamTaskNeedsFixActionable(task)
? 'needsFix'
: 'review'
: TeamGraphAdapter.#mapReviewState(task.reviewState);
const blockedByDisplayIds = task.blockedBy?.length
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
@ -683,7 +716,8 @@ export class TeamGraphAdapter {
kind: 'task',
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
sublabel: task.subject,
state: TeamGraphAdapter.#mapTaskStatus(task.status),
state:
graphColumn === 'approved' ? 'complete' : TeamGraphAdapter.#mapTaskStatus(task.status),
taskStatus,
reviewState,
reviewerName: isReviewCycle ? reviewerName : null,
@ -698,6 +732,7 @@ export class TeamGraphAdapter {
blocksDisplayIds,
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
hasLiveTaskLogs: activeTaskLogActivity?.[task.id] === true ? true : undefined,
domainRef: { kind: 'task', teamName, taskId: task.id },
});
}

View file

@ -71,6 +71,7 @@ export function useTeamGraphAdapter(
gridOwnerOrder,
slotAssignments,
graphLayoutSession,
activeTaskLogActivity,
ensureTeamGraphSlotAssignments,
} = useStore(
useShallow((s) => ({
@ -92,6 +93,8 @@ export function useTeamGraphAdapter(
gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
activeTaskLogActivity:
isActive && teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
}))
);
@ -189,7 +192,8 @@ export function useTeamGraphAdapter(
memberSpawnSnapshot,
effectiveSlotAssignments,
graphLayoutMode ?? 'radial',
gridOwnerOrder
gridOwnerOrder,
activeTaskLogActivity
);
}, [
isActive,
@ -208,6 +212,7 @@ export function useTeamGraphAdapter(
effectiveSlotAssignments,
graphLayoutMode,
gridOwnerOrder,
activeTaskLogActivity,
]);
useLayoutEffect(() => {

View file

@ -13,6 +13,7 @@ import {
buildMemberAvatarMap,
buildMemberLaunchPresentation,
} from '@renderer/utils/memberHelpers';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
@ -309,6 +310,17 @@ const MemberPopoverContent = ({
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
const currentTaskCandidate =
member?.currentTaskId && teamData
? (teamData.tasks.find((task) => task.id === member.currentTaskId) ?? null)
: null;
const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate)
? currentTaskCandidate
: null;
const currentTaskIndicatorId =
displayableCurrentTask?.id ?? (!teamData ? node.currentTaskId : undefined);
const currentTaskIndicatorSubject =
displayableCurrentTask?.subject ?? (!teamData ? node.currentTaskSubject : undefined);
const provisioningPresentation =
teamData && teamName
? buildTeamProvisioningPresentation({
@ -320,7 +332,10 @@ const MemberPopoverContent = ({
: null;
const launchPresentation = member
? buildMemberLaunchPresentation({
member,
member:
member.currentTaskId && !displayableCurrentTask
? { ...member, currentTaskId: null }
: member,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource,
@ -444,7 +459,7 @@ const MemberPopoverContent = ({
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
{/* Current task indicator — reuses same pattern as MemberCard */}
{node.currentTaskId && node.currentTaskSubject && (
{currentTaskIndicatorId && currentTaskIndicatorSubject && (
<div className="mt-2 flex items-center gap-1.5 text-[10px]">
<Loader2
className="size-3 shrink-0 animate-spin"
@ -457,13 +472,13 @@ const MemberPopoverContent = ({
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(node.currentTaskId!);
onOpenTask?.(currentTaskIndicatorId);
onClose();
}}
>
{node.currentTaskSubject.length > 30
? `${node.currentTaskSubject.slice(0, 30)}`
: node.currentTaskSubject}
{currentTaskIndicatorSubject.length > 30
? `${currentTaskIndicatorSubject.slice(0, 30)}`
: currentTaskIndicatorSubject}
</button>
</div>
)}

View file

@ -7,6 +7,7 @@
import { useMemo } from 'react';
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
@ -49,7 +50,7 @@ function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap<string, TeamTask>): R
boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)',
};
case 'review':
return task.reviewState === 'needsFix'
return isTeamTaskNeedsFixActionable(task)
? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }
: { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' };
case 'approved':

View file

@ -0,0 +1,58 @@
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts';
export type MemberWorkSyncNudgeActivationReason =
| 'shadow_ready'
| 'opencode_targeted_shadow_collecting'
| 'status_not_nudgeable'
| 'blocking_metrics'
| 'phase2_not_ready';
export interface MemberWorkSyncNudgeActivationDecision {
active: boolean;
reason: MemberWorkSyncNudgeActivationReason;
}
const BLOCKING_PHASE2_REASONS = new Set([
'would_nudge_rate_high',
'fingerprint_churn_high',
'report_rejection_rate_high',
]);
function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean {
return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason));
}
function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean {
return (
status.providerId === 'opencode' &&
status.state === 'needs_sync' &&
status.agenda.items.length > 0 &&
status.shadow?.wouldNudge === true
);
}
export function decideMemberWorkSyncNudgeActivation(input: {
status: MemberWorkSyncStatus;
metrics: MemberWorkSyncTeamMetrics;
}): MemberWorkSyncNudgeActivationDecision {
if (input.status.state !== 'needs_sync' || input.status.agenda.items.length === 0) {
return { active: false, reason: 'status_not_nudgeable' };
}
if (hasBlockingMetrics(input.metrics)) {
return { active: false, reason: 'blocking_metrics' };
}
if (input.metrics.phase2Readiness.state === 'shadow_ready') {
return { active: true, reason: 'shadow_ready' };
}
if (
input.metrics.phase2Readiness.state === 'collecting_shadow_data' &&
isOpenCodeTargetedCandidate(input.status)
) {
return { active: true, reason: 'opencode_targeted_shadow_collecting' };
}
return { active: false, reason: 'phase2_not_ready' };
}

View file

@ -1,8 +1,9 @@
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
import { decideMemberWorkSyncStatus } from '../domain';
import type { MemberWorkSyncOutboxItem } from '../../contracts';
import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
@ -151,6 +152,12 @@ export class MemberWorkSyncNudgeDispatcher {
nowIso,
});
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
await this.scheduleDeliveryWake(
item,
inserted.messageId,
inserted.inserted,
revalidation.providerId
);
return 'delivered';
} catch (error) {
await outbox.markFailed({
@ -188,7 +195,8 @@ export class MemberWorkSyncNudgeDispatcher {
item: MemberWorkSyncOutboxItem,
nowIso: string
): Promise<
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
| { ok: true; providerId?: MemberWorkSyncStatus['providerId'] }
| { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
> {
const teamActive = this.deps.lifecycle
? await this.deps.lifecycle.isTeamActive(item.teamName)
@ -221,6 +229,24 @@ export class MemberWorkSyncNudgeDispatcher {
nowIso,
inactive: source.inactive || !teamActive,
});
const providerId = source.providerId ?? previous.providerId;
const revalidatedStatus: MemberWorkSyncStatus = {
...previous,
state: decision.state,
agenda,
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
shadow: {
...previous.shadow,
reconciledBy: previous.shadow?.reconciledBy ?? 'queue',
wouldNudge: decision.state === 'needs_sync' && agenda.items.length > 0,
fingerprintChanged:
Boolean(previous.agenda.fingerprint) &&
previous.agenda.fingerprint !== agenda.fingerprint,
},
evaluatedAt: nowIso,
diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
...(providerId ? { providerId } : {}),
};
if (
decision.state !== 'needs_sync' ||
agenda.items.length === 0 ||
@ -233,7 +259,11 @@ export class MemberWorkSyncNudgeDispatcher {
return { ok: false, reason: 'metrics_unavailable', retryable: true };
}
const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName);
if (metrics.phase2Readiness.state !== 'shadow_ready') {
const activation = decideMemberWorkSyncNudgeActivation({
status: revalidatedStatus,
metrics,
});
if (!activation.active) {
return { ok: false, reason: 'phase2_not_ready', retryable: true };
}
@ -281,6 +311,37 @@ export class MemberWorkSyncNudgeDispatcher {
return { ok: false, reason: 'watchdog_cooldown_active', retryable: true };
}
return { ok: true };
return { ok: true, ...(providerId ? { providerId } : {}) };
}
private async scheduleDeliveryWake(
item: MemberWorkSyncOutboxItem,
messageId: string,
inserted: boolean,
providerId?: MemberWorkSyncStatus['providerId']
): Promise<void> {
if (!this.deps.nudgeDeliveryWake) {
return;
}
try {
await this.deps.nudgeDeliveryWake.schedule({
teamName: item.teamName,
memberName: item.memberName,
messageId,
...(providerId ? { providerId } : {}),
reason: inserted ? 'member_work_sync_nudge_inserted' : 'member_work_sync_nudge_existing',
delayMs: 500,
});
} catch (error) {
const reason = `nudge_wake_failed:${String(error)}`;
await this.appendDispatchAudit(item, 'nudge_wake_failed', reason);
this.deps.logger?.warn('member work sync nudge delivery wake failed', {
teamName: item.teamName,
memberName: item.memberName,
messageId,
error: String(error),
});
}
}
}

View file

@ -1,6 +1,7 @@
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
import type { MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
@ -38,7 +39,8 @@ export class MemberWorkSyncNudgeOutboxPlanner {
}
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
if (metrics.phase2Readiness.state !== 'shadow_ready') {
const activation = decideMemberWorkSyncNudgeActivation({ status, metrics });
if (!activation.active) {
await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' });
return { planned: false, code: 'phase2_not_ready' };
}

View file

@ -1,6 +1,7 @@
export * from './MemberWorkSyncAudit';
export * from './MemberWorkSyncDiagnosticsReader';
export * from './MemberWorkSyncMetricsReader';
export * from './MemberWorkSyncNudgeActivationPolicy';
export * from './MemberWorkSyncNudgeDispatcher';
export * from './MemberWorkSyncNudgeOutboxPlanner';
export * from './MemberWorkSyncPendingReportIntentReplayer';

View file

@ -82,6 +82,7 @@ export type MemberWorkSyncAuditEventName =
| 'report_rejected'
| 'nudge_planned'
| 'nudge_delivered'
| 'nudge_wake_failed'
| 'nudge_skipped'
| 'nudge_retryable'
| 'nudge_superseded'
@ -181,6 +182,17 @@ export interface MemberWorkSyncBusySignalPort {
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
}
export interface MemberWorkSyncNudgeDeliveryWakePort {
schedule(input: {
teamName: string;
memberName: string;
messageId: string;
providerId?: MemberWorkSyncProviderId | null;
reason: 'member_work_sync_nudge_inserted' | 'member_work_sync_nudge_existing';
delayMs?: number;
}): Promise<void> | void;
}
export interface MemberWorkSyncUseCaseDeps {
clock: MemberWorkSyncClockPort;
hash: MemberWorkSyncHashPort;
@ -191,6 +203,7 @@ export interface MemberWorkSyncUseCaseDeps {
inboxNudge?: MemberWorkSyncInboxNudgePort;
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
busySignal?: MemberWorkSyncBusySignalPort;
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
reportToken?: MemberWorkSyncReportTokenPort;
auditJournal?: MemberWorkSyncAuditJournalPort;
lifecycle?: MemberWorkSyncLifecyclePort;

View file

@ -1,3 +1,10 @@
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
isTeamTaskTerminalForActionableWork,
} from '@shared/utils/teamTaskState';
import {
buildAgendaFingerprintPayload,
canonicalizeAgendaFingerprintPayload,
@ -19,6 +26,7 @@ export interface MemberWorkSyncTaskLike {
status: string;
owner?: string | null;
reviewState?: string | null;
kanbanColumn?: string | null;
needsClarification?: 'lead' | 'user' | null;
blockedBy?: string[];
blocks?: string[];
@ -45,10 +53,6 @@ export interface BuildActionableWorkAgendaInput {
hash: (canonicalPayload: string) => string;
}
function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean {
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
}
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
return new Set(
members
@ -114,7 +118,9 @@ export function buildActionableWorkAgenda(
if (activeMemberNames.has(memberName)) {
for (const task of input.tasks) {
if (!task.id || isCompletedOrDeleted(task)) {
const workflowColumn = getTeamTaskWorkflowColumn(task);
const isReviewWorkflow = workflowColumn === 'review';
if (!task.id || (isTeamTaskTerminalForActionableWork(task) && !isReviewWorkflow)) {
continue;
}
@ -128,7 +134,7 @@ export function buildActionableWorkAgenda(
const dependency = tasksByReference.get(dependencyId) ?? null;
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
brokenDependencyIds.push(dependencyId);
} else if (dependency.status !== 'completed') {
} else if (!isTeamTaskFinishedForDependency(dependency)) {
waitingDependencyIds.push(dependencyId);
}
}
@ -174,11 +180,13 @@ export function buildActionableWorkAgenda(
continue;
}
const reviewOwner = resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
historyEvents: task.historyEvents,
});
const reviewOwner = isReviewWorkflow
? resolveCurrentReviewOwner({
reviewState: workflowColumn,
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
historyEvents: task.historyEvents,
})
: null;
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
items.push({
@ -199,6 +207,10 @@ export function buildActionableWorkAgenda(
continue;
}
if (isReviewWorkflow) {
continue;
}
if (!sameMemberName(owner, memberName)) {
continue;
}
@ -214,18 +226,17 @@ export function buildActionableWorkAgenda(
if (
task.status === 'pending' ||
task.status === 'in_progress' ||
task.reviewState === 'needsFix'
isTeamTaskNeedsFixActionable(task)
) {
items.push({
...base,
kind: 'work',
priority: 'normal',
reason:
task.reviewState === 'needsFix'
? 'review_changes_requested'
: task.status === 'pending'
? 'owned_pending_task'
: 'owned_in_progress_task',
reason: isTeamTaskNeedsFixActionable(task)
? 'review_changes_requested'
: task.status === 'pending'
? 'owned_pending_task'
: 'owned_in_progress_task',
evidence: {
status: task.status,
owner: memberName,

View file

@ -6,6 +6,8 @@ export interface ReviewHistoryEventLike {
timestamp?: string;
actor?: string;
reviewer?: string;
from?: string;
to?: string;
}
export interface CurrentReviewOwner {

View file

@ -1,4 +1,8 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import {
getTeamTaskWorkflowColumn,
isTeamTaskTerminalForActionableWork,
} from '@shared/utils/teamTaskState';
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
@ -20,10 +24,6 @@ export interface MemberWorkSyncTaskImpactResolverResult {
diagnostics: string[];
}
function isTerminalTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
}
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'deleted' || Boolean(task.deletedAt);
}
@ -127,13 +127,6 @@ export class MemberWorkSyncTaskImpactResolver {
addMember(task.owner);
const reviewOwner = resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
historyEvents: task.historyEvents,
});
addMember(reviewOwner?.reviewer);
if (!normalizeMemberName(task.owner)) {
addLead();
addDiagnostic('task_owner_missing');
@ -142,7 +135,23 @@ export class MemberWorkSyncTaskImpactResolver {
addDiagnostic('task_owner_inactive');
}
if (task.reviewState === 'review' && !reviewOwner?.reviewer) {
const taskKanbanColumn = kanban.tasks[task.id]?.column;
const taskWorkflowColumn = getTeamTaskWorkflowColumn({
...task,
...(taskKanbanColumn ? { kanbanColumn: taskKanbanColumn } : {}),
});
const reviewOwner =
taskWorkflowColumn === 'review'
? resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
historyEvents: task.historyEvents,
})
: null;
addMember(reviewOwner?.reviewer);
if (taskWorkflowColumn === 'review' && !reviewOwner?.reviewer) {
addLead();
addDiagnostic('task_reviewer_missing');
}
@ -166,7 +175,14 @@ export class MemberWorkSyncTaskImpactResolver {
}
for (const candidate of tasks) {
if (candidate.id === task.id || isTerminalTask(candidate)) {
const kanbanColumn = kanban.tasks[candidate.id]?.column;
if (
candidate.id === task.id ||
isTeamTaskTerminalForActionableWork({
...candidate,
...(kanbanColumn ? { kanbanColumn } : {}),
})
) {
continue;
}
if (

View file

@ -122,7 +122,13 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
teamName: input.teamName,
memberName: input.memberName,
generatedAt: this.deps.clock.now().toISOString(),
tasks,
tasks: tasks.map((task) => {
const kanbanColumn = kanban.tasks[task.id]?.column;
return {
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
};
}),
members: members.map(toMemberLike),
kanbanReviewersByTaskId: Object.fromEntries(
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])

View file

@ -20,6 +20,7 @@ import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
import { CompositeMemberWorkSyncBusySignal } from '../infrastructure/CompositeMemberWorkSyncBusySignal';
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
@ -46,7 +47,11 @@ import type {
MemberWorkSyncStatusRequest,
MemberWorkSyncTeamMetrics,
} from '../../contracts';
import type { MemberWorkSyncLoggerPort } from '../../core/application';
import type {
MemberWorkSyncBusySignalPort,
MemberWorkSyncLoggerPort,
MemberWorkSyncNudgeDeliveryWakePort,
} from '../../core/application';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
@ -93,6 +98,8 @@ export function createMemberWorkSyncFeature(deps: {
listLifecycleActiveTeamNames?: () => Promise<string[]>;
queueQuietWindowMs?: number;
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
extraBusySignals?: MemberWorkSyncBusySignalPort[];
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter();
@ -138,7 +145,12 @@ export function createMemberWorkSyncFeature(deps: {
});
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
const busySignal = new MemberWorkSyncToolActivityBusySignal();
const toolActivityBusySignal = new MemberWorkSyncToolActivityBusySignal();
const busySignals = [toolActivityBusySignal, ...(deps.extraBusySignals ?? [])];
const busySignal =
busySignals.length === 1
? toolActivityBusySignal
: new CompositeMemberWorkSyncBusySignal(busySignals, deps.logger);
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
const useCaseDeps = {
clock,
@ -150,6 +162,7 @@ export function createMemberWorkSyncFeature(deps: {
inboxNudge,
watchdogCooldown,
busySignal,
...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}),
reportToken,
auditJournal,
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
@ -233,7 +246,7 @@ export function createMemberWorkSyncFeature(deps: {
getMetrics: (request) => metricsReader.execute(request),
report: (request) => reporter.execute(request),
noteTeamChange: (event) => {
busySignal.noteTeamChange(event);
toolActivityBusySignal.noteTeamChange(event);
router.noteTeamChange(event);
},
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),

View file

@ -0,0 +1,38 @@
import type {
MemberWorkSyncBusySignalPort,
MemberWorkSyncLoggerPort,
} from '../../core/application';
export class CompositeMemberWorkSyncBusySignal implements MemberWorkSyncBusySignalPort {
constructor(
private readonly signals: MemberWorkSyncBusySignalPort[],
private readonly logger?: MemberWorkSyncLoggerPort
) {}
async isBusy(input: Parameters<MemberWorkSyncBusySignalPort['isBusy']>[0]) {
for (const signal of this.signals) {
try {
const result = await signal.isBusy(input);
if (result.busy) {
return result;
}
} catch (error) {
this.logger?.warn('member work sync busy signal failed', {
teamName: input.teamName,
memberName: input.memberName,
error: String(error),
});
const nowMs = Date.parse(input.nowIso);
return {
busy: true,
reason: 'busy_signal_error',
retryAfterIso: new Date(
(Number.isFinite(nowMs) ? nowMs : Date.now()) + 60_000
).toISOString(),
};
}
}
return { busy: false };
}
}

View file

@ -6,6 +6,9 @@ import type {
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatusEntry,
OpenCodeAppManagedBootstrapCandidate,
OpenCodeBootstrapEvidenceSource,
OpenCodeBootstrapMode,
PersistedTeamLaunchMemberSources,
PersistedTeamLaunchMemberState,
PersistedTeamLaunchPhase,
@ -43,6 +46,9 @@ export interface MixedSecondaryLaneMemberStateInput {
runtimePid?: number;
runtimeSessionId?: string;
sessionId?: string;
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string;
@ -348,6 +354,9 @@ function createSecondaryLaneMemberState(
? Math.trunc(evidence.runtimePid)
: undefined,
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
bootstrapEvidenceSource: evidence?.bootstrapEvidenceSource,
bootstrapMode: evidence?.bootstrapMode,
appManagedBootstrapCandidate: evidence?.appManagedBootstrapCandidate,
livenessKind: evidence?.livenessKind,
pidSource: evidence?.pidSource,
runtimeDiagnostic: evidence?.runtimeDiagnostic,

View file

@ -91,6 +91,7 @@ import {
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
import { parseInboxJson } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
import { app, BrowserWindow, ipcMain } from 'electron';
import { existsSync } from 'fs';
import { join } from 'path';
@ -470,6 +471,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
const msg = newMessages[i];
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
// Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs,
// not user-visible conversation messages.
if (isTeamInternalControlMessageEnvelope(msg)) continue;
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
@ -1140,6 +1144,9 @@ async function initializeServices(): Promise<void> {
teamDataService = new TeamDataService();
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
teamProvisioningService = new TeamProvisioningService();
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
});
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
@ -1358,6 +1365,24 @@ async function initializeServices(): Promise<void> {
)
.map((team) => team.teamName);
},
extraBusySignals: [
{
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
},
],
nudgeDeliveryWake: {
schedule: (input) => {
if (input.providerId !== 'opencode') {
return;
}
teamProvisioningService.scheduleOpenCodeMemberInboxDeliveryWake({
teamName: input.teamName,
memberName: input.memberName,
messageId: input.messageId,
delayMs: input.delayMs,
});
},
},
logger: createLogger('Feature:MemberWorkSync'),
});
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>

View file

@ -826,6 +826,9 @@ export class NotificationManager extends EventEmitter {
.catch(() => undefined)
.then(() => writeNotificationsFileAtomically(notificationsPath, data))
.catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
logger.error('Error saving notifications:', error);
});
}
@ -1031,7 +1034,7 @@ export class NotificationManager extends EventEmitter {
): void {
const NotificationClass = getNotificationClass();
if (!NotificationClass || !this.isNativeNotificationSupported()) {
logger.warn('[team-toast] native notifications not supported — skipping');
logger.debug('[team-toast] native notifications not supported - skipping');
return;
}

View file

@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState';
import { getReviewStateFromTask } from '@shared/utils/reviewState';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
@ -62,6 +62,7 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState';
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
@ -550,13 +551,7 @@ export class TeamDataService {
const launchIdentity = teamMeta?.launchIdentity;
const leadName = 'team-lead';
const ownedTasks = tasks.filter((task) => task.owner === leadName);
const currentTask =
ownedTasks.find(
(task) =>
task.status === 'in_progress' &&
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null;
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
members.unshift({
name: leadName,
@ -600,12 +595,35 @@ export class TeamDataService {
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
kanbanTaskState?: KanbanState['tasks'][string]
): 'none' | 'review' | 'needsFix' | 'approved' {
return getReviewStateFromTask({
const kanbanColumn = kanbanTaskState?.column;
const kanbanWorkflowColumn = kanbanColumn
? getTeamTaskWorkflowColumn({
status: task.status,
reviewState: 'none',
kanbanColumn,
})
: undefined;
if (kanbanWorkflowColumn) {
return kanbanWorkflowColumn;
}
const reviewState = getReviewStateFromTask({
historyEvents: task.historyEvents,
reviewState: task.reviewState,
status: task.status,
kanbanColumn: kanbanTaskState?.column,
...(kanbanColumn ? { kanbanColumn } : {}),
});
const workflowColumn = getTeamTaskWorkflowColumn({
status: task.status,
reviewState,
...(kanbanColumn ? { kanbanColumn } : {}),
});
if (workflowColumn) {
return workflowColumn;
}
return reviewState;
}
private attachKanbanCompatibility(
@ -614,14 +632,27 @@ export class TeamDataService {
): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
return {
...task,
reviewState,
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
...(kanbanColumn ? { kanbanColumn } : {}),
reviewer,
};
}
private resolveTaskKanbanColumn(
task: Pick<TeamTask, 'status'>,
kanbanTaskState?: KanbanState['tasks'][string],
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = 'none'
): 'review' | 'approved' | undefined {
return getTeamTaskWorkflowColumn({
status: task.status,
reviewState,
...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
});
}
/**
* Extract reviewer name from the current review cycle history.
* For legacy boards that stored reviewer only in kanban state, preserve that
@ -1023,7 +1054,7 @@ export class TeamDataService {
const info = teamInfoMap.get(task.teamName)!;
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
// Return a "light" task object and defer heavy details to team/task detail views.
@ -2137,7 +2168,7 @@ export class TeamDataService {
}
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
this.getController(teamName).tasks.setTaskOwner(taskId, owner, 'user');
this.invalidateGlobalTaskProjectionCache();
}

View file

@ -6,6 +6,7 @@ import type {
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatusEntry,
OpenCodeAppManagedBootstrapCandidate,
PersistedTeamLaunchMemberSources,
PersistedTeamLaunchMemberState,
PersistedTeamLaunchPhase,
@ -176,6 +177,60 @@ function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeOpenCodeAppManagedBootstrapCandidate(
value: unknown
): OpenCodeAppManagedBootstrapCandidate | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.schemaVersion !== 1 || record.source !== 'app_managed_bootstrap') {
return undefined;
}
const teamName = normalizeOptionalString(record.teamName);
const memberName = normalizeOptionalString(record.memberName);
const runId = normalizeOptionalString(record.runId);
const laneId = normalizeOptionalString(record.laneId);
const runtimeSessionId = normalizeOptionalString(record.runtimeSessionId);
const messageID = normalizeOptionalString(record.messageID);
const contextHash = normalizeOptionalString(record.contextHash);
const briefingHash = normalizeOptionalString(record.briefingHash);
const injectionVerifiedAt = normalizeOptionalString(record.injectionVerifiedAt);
const candidateAt = normalizeOptionalString(record.candidateAt);
if (
!teamName ||
!memberName ||
!runId ||
!laneId ||
!runtimeSessionId ||
!messageID ||
!contextHash ||
!briefingHash ||
!injectionVerifiedAt ||
!candidateAt
) {
return undefined;
}
const model = normalizeOptionalString(record.model);
const agent = normalizeOptionalString(record.agent);
return {
schemaVersion: 1,
source: 'app_managed_bootstrap',
teamName,
memberName,
runId,
laneId,
runtimeSessionId,
messageID,
contextHash,
briefingHash,
injectionVerifiedAt,
candidateAt,
...(model ? { model } : {}),
...(agent ? { agent } : {}),
};
}
function decodeJsonStringLiteral(value: string): string {
try {
return JSON.parse(`"${value}"`) as string;
@ -601,6 +656,19 @@ function normalizePersistedMemberState(
runtimePid: normalizeRuntimePid(parsed.runtimePid),
runtimeRunId: normalizeOptionalString(parsed.runtimeRunId),
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
bootstrapEvidenceSource:
parsed.bootstrapEvidenceSource === 'runtime_bootstrap_checkin' ||
parsed.bootstrapEvidenceSource === 'app_managed_bootstrap'
? parsed.bootstrapEvidenceSource
: undefined,
bootstrapMode:
parsed.bootstrapMode === 'model_tool_checkin' ||
parsed.bootstrapMode === 'app_managed_context'
? parsed.bootstrapMode
: undefined,
appManagedBootstrapCandidate: normalizeOpenCodeAppManagedBootstrapCandidate(
parsed.appManagedBootstrapCandidate
),
livenessKind,
pidSource: normalizePidSource(parsed.pidSource),
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),

View file

@ -69,10 +69,22 @@ type DecodedFreshnessTaskId =
| { kind: 'opaque-safe-segment' }
| { kind: 'invalid' };
type TaskFreshnessSignalKind = NonNullable<TeamChangeEvent['taskSignalKind']>;
function isOpaqueSafeTaskIdSegment(segment: string): boolean {
return /^task-id-[0-9a-f]{32}$/.test(segment);
}
function pushUniqueNormalizedPath(paths: string[], candidate: string | undefined): void {
if (!candidate || !path.isAbsolute(candidate)) {
return;
}
const normalized = path.normalize(candidate);
if (!paths.some((existing) => path.normalize(existing) === normalized)) {
paths.push(normalized);
}
}
export function shouldIgnoreLogSourceWatcherPath(
projectDir: string,
watchedPath: string,
@ -368,14 +380,20 @@ export class TeamLogSourceTracker {
return;
}
await this.ensureLogSourceFreshnessDirs(context.projectDir).catch((error) => {
const taskFreshnessRootDirs = this.getTaskFreshnessRootDirs(context);
const taskFreshnessWatchRootDirs = await this.ensureLogSourceFreshnessDirs(
context.projectDir,
taskFreshnessRootDirs
).catch((error) => {
logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`);
return [path.normalize(context.projectDir)];
});
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
context.projectDir,
context.watchSessionIds,
this.getPendingUnknownSessionIds(state)
this.getPendingUnknownSessionIds(state),
taskFreshnessWatchRootDirs
);
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
return;
@ -411,6 +429,18 @@ export class TeamLogSourceTracker {
) {
return;
}
const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext);
pushUniqueNormalizedPath(eventTaskFreshnessRootDirs, current.projectDir);
if (
this.handleTaskFreshnessSignalChangeForRoots(
teamName,
changedPath,
eventTaskFreshnessRootDirs
)
) {
return;
}
const action = classifyLogSourceWatcherEvent({
projectDir: current.projectDir,
changedPath,
@ -420,21 +450,6 @@ export class TeamLogSourceTracker {
});
if (action.kind === 'task-freshness') {
if (
!this.handleTaskFreshnessSignalChange(
teamName,
current.projectDir,
changedPath,
BOARD_TASK_LOG_FRESHNESS_DIRNAME
)
) {
this.handleTaskFreshnessSignalChange(
teamName,
current.projectDir,
changedPath,
BOARD_TASK_CHANGE_FRESHNESS_DIRNAME
);
}
return;
}
@ -458,24 +473,74 @@ export class TeamLogSourceTracker {
});
}
private async ensureLogSourceFreshnessDirs(projectDir: string): Promise<void> {
private getTaskFreshnessRootDirs(context: TeamLogSourceLiveContext | null): string[] {
const roots: string[] = [];
pushUniqueNormalizedPath(roots, context?.projectDir);
pushUniqueNormalizedPath(roots, context?.projectPath);
for (const rootDir of context?.taskFreshnessRootDirs ?? []) {
pushUniqueNormalizedPath(roots, rootDir);
}
return roots;
}
private async ensureLogSourceFreshnessDirs(
transcriptProjectDir: string,
projectDirs: readonly string[]
): Promise<string[]> {
const watchRootDirs: string[] = [];
const normalizedTranscriptProjectDir = path.normalize(transcriptProjectDir);
pushUniqueNormalizedPath(watchRootDirs, normalizedTranscriptProjectDir);
await Promise.all([
fs.mkdir(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { recursive: true }),
fs.mkdir(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true }),
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
recursive: true,
}),
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
recursive: true,
}),
]);
await Promise.all(
projectDirs.map(async (projectDir) => {
try {
const normalizedProjectDir = path.normalize(projectDir);
if (normalizedProjectDir === normalizedTranscriptProjectDir) {
return;
}
if (!(await this.isDirectory(normalizedProjectDir))) {
return;
}
await Promise.all([
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
recursive: true,
}),
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
recursive: true,
}),
]);
pushUniqueNormalizedPath(watchRootDirs, normalizedProjectDir);
} catch (error) {
logger.debug(`Failed to ensure task freshness dirs in ${projectDir}: ${String(error)}`);
}
})
);
return watchRootDirs;
}
private async buildScopedWatchTargets(
projectDir: string,
confirmedSessionIds: readonly string[],
pendingRootSessionIds: readonly string[]
pendingRootSessionIds: readonly string[],
taskFreshnessRootDirs: readonly string[] = [projectDir]
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
const targets = new Set<string>();
const scopedSessionIds = new Set<string>();
targets.add(projectDir);
targets.add(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
targets.add(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
for (const freshnessRootDir of taskFreshnessRootDirs) {
targets.add(path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
targets.add(path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
}
for (const rawSessionId of confirmedSessionIds) {
const sessionId = normalizeLogSourceSessionId(rawSessionId);
@ -664,11 +729,10 @@ export class TeamLogSourceTracker {
private handleTaskFreshnessSignalChange(
teamName: string,
projectDir: string,
changedPath: string,
signalDirName: string
signalDir: string,
taskSignalKind: TaskFreshnessSignalKind
): boolean {
const signalDir = path.join(projectDir, signalDirName);
const relativePath = path.relative(signalDir, changedPath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return path.normalize(changedPath) === path.normalize(signalDir);
@ -687,7 +751,7 @@ export class TeamLogSourceTracker {
return true;
}
if (decoded.kind === 'opaque-safe-segment') {
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath);
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath, taskSignalKind);
return true;
}
@ -695,6 +759,7 @@ export class TeamLogSourceTracker {
type: 'task-log-change',
teamName,
taskId: decoded.taskId,
taskSignalKind,
});
return true;
}
@ -720,7 +785,11 @@ export class TeamLogSourceTracker {
}
}
private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise<void> {
private async emitTaskFreshnessSignalFromFile(
teamName: string,
filePath: string,
taskSignalKind: TaskFreshnessSignalKind
): Promise<void> {
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
@ -733,6 +802,7 @@ export class TeamLogSourceTracker {
type: 'task-log-change',
teamName,
taskId,
taskSignalKind,
});
return;
}
@ -742,6 +812,36 @@ export class TeamLogSourceTracker {
this.emitLogSourceChange(teamName);
}
private handleTaskFreshnessSignalChangeForRoots(
teamName: string,
changedPath: string,
taskFreshnessRootDirs: readonly string[]
): boolean {
for (const freshnessRootDir of taskFreshnessRootDirs) {
if (
this.handleTaskFreshnessSignalChange(
teamName,
changedPath,
path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME),
'log'
)
) {
return true;
}
if (
this.handleTaskFreshnessSignalChange(
teamName,
changedPath,
path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME),
'change'
)
) {
return true;
}
}
return false;
}
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (this.getActiveConsumerCount(state) === 0) {

View file

@ -46,6 +46,7 @@ const SCAN_CONCURRENCY = 15;
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
const DISCOVERY_CACHE_TTL = 30_000;
const MAX_TASK_FRESHNESS_ROOT_DIRS = 64;
/** Signal sources for subagent member attribution, ordered by reliability. */
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
@ -116,6 +117,7 @@ export interface MemberLogFileRef {
export interface TeamLogSourceLiveContext {
projectDir: string;
projectPath?: string;
taskFreshnessRootDirs?: string[];
leadSessionId?: string;
sessionIds: string[];
watchSessionIds: string[];
@ -143,6 +145,30 @@ async function mapLimit<T, R>(
return results;
}
function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[] {
const roots: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (typeof candidate !== 'string') {
continue;
}
const trimmed = candidate.trim();
if (!trimmed || !path.isAbsolute(trimmed)) {
continue;
}
const normalized = path.normalize(trimmed);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
roots.push(normalized);
if (roots.length >= MAX_TASK_FRESHNESS_ROOT_DIRS) {
break;
}
}
return roots;
}
export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>();
private readonly attributionCache = new Map<
@ -286,13 +312,13 @@ export class TeamMemberLogsFinder {
readBootstrapLaunchSnapshot(teamName).catch(() => null),
]);
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
const extraProjectPathCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
const runtimeMemberCwdCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
(member) => member.cwd
);
const base = await this.projectResolver.getLiveBaseContext(teamName, {
forceRefresh: options?.forceRefresh,
extraProjectPathCandidates,
extraProjectPathCandidates: runtimeMemberCwdCandidates,
});
if (!base) {
return null;
@ -308,6 +334,11 @@ export class TeamMemberLogsFinder {
return {
projectDir: base.projectDir,
projectPath: base.config.projectPath,
taskFreshnessRootDirs: collectTaskFreshnessRootDirs([
base.config.projectPath,
...(base.config.members ?? []).map((member) => member.cwd),
...runtimeMemberCwdCandidates,
]),
leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId,
sessionIds: watchSessionIds,
watchSessionIds,

View file

@ -9,6 +9,8 @@ import {
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { selectCurrentActiveTeamTask } from './teamTaskActiveState';
import type {
PersistedTeamLaunchSnapshot,
TeamConfig,
@ -282,13 +284,7 @@ export class TeamMemberResolver {
const members: TeamMemberSnapshot[] = [];
for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask =
ownedTasks.find(
(task) =>
task.status === 'in_progress' &&
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null;
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
const configMember = configMemberMap.get(name);
const metaMember = metaMemberMap.get(name);
const launchMember = launchMemberMap.get(name);

View file

@ -1,7 +1,18 @@
import { createLogger } from '@shared/utils/logger';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs/promises';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import {
createOpenCodePromptDeliveryLedgerStore,
type OpenCodePromptDeliveryLedgerRecord,
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
import {
getOpenCodeLaneScopedRuntimeFilePath,
readOpenCodeRuntimeLaneIndex,
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
@ -29,11 +40,15 @@ const CACHE_TTL_MS = 30_000;
const TAIL_BYTES = 64 * 1024;
const BATCH_WARN_MS = 1_000;
const ADVISORY_FETCH_CONCURRENCY = 2;
const OPENCODE_DELIVERY_ERROR_LOOKBACK_MS = 30 * 60 * 1000;
const QUOTA_EXHAUSTED_TOKENS = [
'exhausted your capacity',
'capacity exceeded',
'quota exceeded',
'quota exhausted',
'insufficient credits',
'key limit exceeded',
'total limit',
];
const RATE_LIMITED_TOKENS = [
'rate limit',
@ -70,7 +85,18 @@ const PROVIDER_OVERLOADED_TOKENS = [
'service unavailable',
'503',
];
const PROTOCOL_PROOF_MISSING_TOKENS = [
'non_visible_tool_without_task_progress',
'visible_reply_still_required',
'visible_reply_ack_only_still_requires_answer',
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
];
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
interface CachedRuntimeAdvisory {
@ -111,9 +137,49 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
return 'provider_overloaded';
}
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
return 'protocol_proof_missing';
}
return 'backend_error';
}
function getRecordTimeMs(record: OpenCodePromptDeliveryLedgerRecord): number {
const candidates = [
record.failedAt,
record.respondedAt,
record.lastObservedAt,
record.updatedAt,
record.createdAt,
];
for (const candidate of candidates) {
const time = Date.parse(candidate ?? '');
if (Number.isFinite(time)) {
return time;
}
}
return 0;
}
function isTerminalSuccessfulRecord(record: OpenCodePromptDeliveryLedgerRecord): boolean {
return (
record.status === 'responded' &&
Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId)
);
}
function isPotentialRuntimeDeliveryError(record: OpenCodePromptDeliveryLedgerRecord): boolean {
if (record.status === 'failed_terminal') {
return true;
}
return (
record.status !== 'responded' &&
(record.responseState === 'session_error' ||
record.responseState === 'tool_error' ||
record.responseState === 'permission_blocked' ||
record.responseState === 'reconcile_failed')
);
}
async function mapLimit<T, R>(
items: readonly T[],
limit: number,
@ -137,8 +203,10 @@ async function mapLimit<T, R>(
}
export class TeamMemberRuntimeAdvisoryService {
private readonly inboxReader = new TeamInboxReader();
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
private readonly cacheGenerationByTeam = new Map<string, number>();
private readonly inFlightBatchRequests = new Map<
string,
Promise<Map<string, MemberRuntimeAdvisory>>
@ -148,6 +216,23 @@ export class TeamMemberRuntimeAdvisoryService {
private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder()
) {}
invalidateMemberAdvisory(teamName: string, memberName: string): void {
const teamKey = this.normalizeToken(teamName);
const memberKey = this.normalizeToken(memberName);
if (!teamKey || !memberKey) {
return;
}
this.cacheGenerationByTeam.set(teamKey, (this.cacheGenerationByTeam.get(teamKey) ?? 0) + 1);
this.memberCache.delete(`${teamKey}::${memberKey}`);
this.teamBatchCacheByTeam.delete(teamKey);
for (const key of this.inFlightBatchRequests.keys()) {
if (key.startsWith(`${teamKey}::`)) {
this.inFlightBatchRequests.delete(key);
}
}
}
async getMemberAdvisories(
teamName: string,
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
@ -187,17 +272,21 @@ export class TeamMemberRuntimeAdvisoryService {
teamName: string,
memberName: string
): Promise<MemberRuntimeAdvisory | null> {
const teamKey = this.normalizeToken(teamName);
const cacheKey = this.getMemberCacheKey(teamName, memberName);
const cached = this.memberCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value ? this.cloneAdvisory(cached.value) : null;
}
const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0;
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
this.memberCache.set(cacheKey, {
value: advisory,
expiresAt: Date.now() + CACHE_TTL_MS,
});
if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) {
this.memberCache.set(cacheKey, {
value: advisory,
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
return advisory ? this.cloneAdvisory(advisory) : null;
}
@ -209,6 +298,7 @@ export class TeamMemberRuntimeAdvisoryService {
): Promise<Map<string, MemberRuntimeAdvisory>> {
const startedAt = performance.now();
const now = Date.now();
const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0;
const result = new Map<string, MemberRuntimeAdvisory>();
const membersToFetch: string[] = [];
let memberCacheHits = 0;
@ -233,23 +323,29 @@ export class TeamMemberRuntimeAdvisoryService {
if (membersToFetch.length > 0) {
const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch);
const fetchedAt = Date.now();
const cacheStillCurrent =
(this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart;
for (const [memberName, advisory] of fetched) {
const normalizedMemberName = this.normalizeToken(memberName);
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
value: advisory,
expiresAt: fetchedAt + CACHE_TTL_MS,
});
if (cacheStillCurrent) {
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
value: advisory,
expiresAt: fetchedAt + CACHE_TTL_MS,
});
}
if (advisory) {
result.set(normalizedMemberName, this.cloneAdvisory(advisory));
}
}
}
this.teamBatchCacheByTeam.set(teamKey, {
membersSignature,
value: this.cloneNormalizedAdvisories(result),
expiresAt: Date.now() + CACHE_TTL_MS,
});
if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) {
this.teamBatchCacheByTeam.set(teamKey, {
membersSignature,
value: this.cloneNormalizedAdvisories(result),
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
const totalMs = performance.now() - startedAt;
if (totalMs >= BATCH_WARN_MS) {
@ -305,6 +401,11 @@ export class TeamMemberRuntimeAdvisoryService {
teamName: string,
memberName: string
): Promise<MemberRuntimeAdvisory | null> {
const openCodeAdvisory = await this.findRecentOpenCodeDeliveryAdvisory(teamName, memberName);
if (openCodeAdvisory) {
return openCodeAdvisory;
}
const summaries = await this.logsFinder.findMemberLogs(
teamName,
memberName,
@ -319,9 +420,33 @@ export class TeamMemberRuntimeAdvisoryService {
teamName: string,
memberNames: readonly string[]
): Promise<readonly (readonly [string, MemberRuntimeAdvisory | null])[]> {
const openCodeAdvisories = await this.findRecentOpenCodeDeliveryAdvisories(
teamName,
memberNames
);
const remainingMemberNames = memberNames.filter(
(memberName) => !openCodeAdvisories.has(memberName)
);
if (remainingMemberNames.length === 0) {
return memberNames.map(
(memberName) => [memberName, openCodeAdvisories.get(memberName) ?? null] as const
);
}
if (this.logsFinder.findRecentMemberLogFileRefsByMember) {
try {
return await this.findRecentMemberAdvisoriesFromBatchRefs(teamName, memberNames);
const logAdvisories = await this.findRecentMemberAdvisoriesFromBatchRefs(
teamName,
remainingMemberNames
);
const logMap = new Map(logAdvisories);
return memberNames.map(
(memberName) =>
[
memberName,
openCodeAdvisories.get(memberName) ?? logMap.get(memberName) ?? null,
] as const
);
} catch (error) {
logger.warn('batch member runtime advisory log lookup failed; falling back', {
teamName,
@ -330,10 +455,226 @@ export class TeamMemberRuntimeAdvisoryService {
}
}
return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => {
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
return [memberName, advisory] as const;
const logAdvisories = await mapLimit(
remainingMemberNames,
ADVISORY_FETCH_CONCURRENCY,
async (memberName) => {
const summaries = await this.logsFinder.findMemberLogs(
teamName,
memberName,
Date.now() - LOOKBACK_MS
);
return [
memberName,
await this.findRecentMemberAdvisoryInFiles(
summaries.flatMap((summary) => summary.filePath ?? [])
),
] as const;
}
);
const logMap = new Map(logAdvisories);
return memberNames.map(
(memberName) =>
[memberName, openCodeAdvisories.get(memberName) ?? logMap.get(memberName) ?? null] as const
);
}
private async findRecentOpenCodeDeliveryAdvisory(
teamName: string,
memberName: string
): Promise<MemberRuntimeAdvisory | null> {
const advisories = await this.findRecentOpenCodeDeliveryAdvisories(teamName, [memberName]);
return advisories.get(memberName) ?? null;
}
private async findRecentOpenCodeDeliveryAdvisories(
teamName: string,
memberNames: readonly string[]
): Promise<Map<string, MemberRuntimeAdvisory>> {
const activeMembersByKey = new Map<string, string>();
for (const memberName of memberNames) {
const normalized = this.normalizeToken(memberName);
if (normalized && !activeMembersByKey.has(normalized)) {
activeMembersByKey.set(normalized, memberName);
}
}
if (activeMembersByKey.size === 0) {
return new Map();
}
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(
() => null
);
if (!laneIndex) {
return new Map();
}
const now = Date.now();
const recordsByMember = new Map<string, OpenCodePromptDeliveryLedgerRecord[]>();
for (const lane of Object.values(laneIndex.lanes)) {
if (lane.state === 'stopped') {
continue;
}
const laneMember = this.getOpenCodeLaneMemberName(lane.laneId);
if (!laneMember || !activeMembersByKey.has(this.normalizeToken(laneMember))) {
continue;
}
const ledger = createOpenCodePromptDeliveryLedgerStore({
filePath: getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath: getTeamsBasePath(),
teamName,
laneId: lane.laneId,
fileName: 'opencode-prompt-delivery-ledger.json',
}),
});
const records = await ledger.list().catch(() => []);
const existing = recordsByMember.get(this.normalizeToken(laneMember)) ?? [];
existing.push(...records);
recordsByMember.set(this.normalizeToken(laneMember), existing);
}
const memberKeysWithRecentErrors = new Set<string>();
for (const [memberKey, records] of recordsByMember) {
if (
records.some((record) => {
const observedAt = getRecordTimeMs(record);
return (
isPotentialRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
})
) {
memberKeysWithRecentErrors.add(memberKey);
}
}
if (memberKeysWithRecentErrors.size === 0) {
return new Map();
}
const visibleRuntimeReplyTimes = await this.readVisibleOpenCodeRuntimeDeliveryReplyTimes(
teamName,
memberKeysWithRecentErrors
);
const result = new Map<string, MemberRuntimeAdvisory>();
for (const [memberKey, records] of recordsByMember) {
if (!memberKeysWithRecentErrors.has(memberKey)) {
continue;
}
const originalName = activeMembersByKey.get(memberKey);
const advisory = originalName
? this.buildOpenCodeDeliveryAdvisoryFromRecords(
originalName,
records,
now,
visibleRuntimeReplyTimes
)
: null;
if (advisory && originalName) {
result.set(originalName, advisory);
}
}
return result;
}
private getOpenCodeLaneMemberName(laneId: string): string | null {
const parts = laneId.split(':');
if (parts.length < 3 || parts[0] !== 'secondary' || parts[1] !== 'opencode') {
return null;
}
return parts.slice(2).join(':').trim() || null;
}
private buildOpenCodeDeliveryAdvisoryFromRecords(
memberName: string,
records: readonly OpenCodePromptDeliveryLedgerRecord[],
now: number,
visibleRuntimeReplyTimes: ReadonlyMap<string, number>
): MemberRuntimeAdvisory | null {
const ordered = records
.slice()
.sort((left, right) => getRecordTimeMs(right) - getRecordTimeMs(left));
const latestSuccess = ordered.find(isTerminalSuccessfulRecord);
const latestError = ordered.find((record) => {
const observedAt = getRecordTimeMs(record);
return (
isPotentialRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
});
if (!latestError) {
return null;
}
if (latestSuccess && getRecordTimeMs(latestSuccess) > getRecordTimeMs(latestError)) {
return null;
}
if (
this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
memberName,
latestError,
visibleRuntimeReplyTimes
)
) {
return null;
}
const message = selectOpenCodeRuntimeDeliveryReason(latestError);
if (!message) {
return null;
}
const observedAt = getRecordTimeMs(latestError);
return {
kind: 'api_error',
observedAt: new Date(Number.isFinite(observedAt) ? observedAt : now).toISOString(),
reasonCode: classifyRetryReason(message),
message,
};
}
private async readVisibleOpenCodeRuntimeDeliveryReplyTimes(
teamName: string,
activeMemberKeys: ReadonlySet<string>
): Promise<Map<string, number>> {
const result = new Map<string, number>();
const inboxNames = await this.inboxReader.listInboxNames(teamName).catch(() => []);
await mapLimit(inboxNames, ADVISORY_FETCH_CONCURRENCY, async (inboxName) => {
const messages = await this.inboxReader.getMessagesFor(teamName, inboxName).catch(() => []);
for (const message of messages) {
if (message.source !== 'runtime_delivery' || !message.relayOfMessageId) {
continue;
}
const memberKey = this.normalizeToken(message.from);
if (activeMemberKeys.has(memberKey)) {
const observedAt = Date.parse(message.timestamp);
if (!Number.isFinite(observedAt)) {
continue;
}
const key = this.getOpenCodeRuntimeReplyKey(memberKey, message.relayOfMessageId);
result.set(key, Math.max(result.get(key) ?? 0, observedAt));
}
}
});
return result;
}
private hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
memberName: string,
record: OpenCodePromptDeliveryLedgerRecord,
visibleRuntimeReplyTimes: ReadonlyMap<string, number>
): boolean {
const relayOfMessageId = record.inboxMessageId?.trim();
if (!relayOfMessageId) {
return false;
}
const replyObservedAt = visibleRuntimeReplyTimes.get(
this.getOpenCodeRuntimeReplyKey(this.normalizeToken(memberName), relayOfMessageId)
);
return typeof replyObservedAt === 'number' && replyObservedAt > getRecordTimeMs(record);
}
private getOpenCodeRuntimeReplyKey(memberKey: string, relayOfMessageId: string): string {
return `${memberKey}::${relayOfMessageId.trim()}`;
}
private async findRecentMemberAdvisoriesFromBatchRefs(

View file

@ -1,6 +1,7 @@
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { createLogger } from '@shared/utils/logger';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
import { createHash } from 'crypto';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
@ -84,7 +85,7 @@ function resolveLeadName(config: TeamConfig): string {
return lead?.name?.trim() || 'team-lead';
}
function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string {
function resolveSyntheticBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string {
const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return new Date(raw).toISOString();
@ -98,46 +99,55 @@ function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfi
return new Date(0).toISOString();
}
function buildOpenCodeBootstrapDisplayPrompt(config: TeamConfig, member: TeamConfigMember): string {
function buildSyntheticBootstrapDisplayPrompt(
config: TeamConfig,
member: TeamConfigMember
): string {
const role = member.role?.trim() || member.agentType?.trim() || 'team member';
const displayName = config.description?.trim() || config.name;
const providerLine = '\nProvider override for this teammate: opencode.';
const providerId = member.providerId?.trim();
const providerLine = providerId ? `\nProvider override for this teammate: ${providerId}.` : '';
const modelLine = member.model?.trim()
? `\nModel override for this teammate: ${member.model.trim()}.`
: '';
const runtimeProviderField = providerId === 'opencode' ? ', runtimeProvider: "opencode"' : '';
return `You are ${member.name}, a ${role} on team "${displayName}" (${config.name}).${providerLine}${modelLine}
The team has already been created and you are being attached as a persistent teammate.
Your FIRST action: call MCP tool member_briefing on the "agent-teams" server with:
{ teamName: "${config.name}", memberName: "${member.name}", runtimeProvider: "opencode" }
{ teamName: "${config.name}", memberName: "${member.name}"${runtimeProviderField} }
Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this step.
After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`;
}
function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessage[] {
function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
const members = Array.isArray(config.members) ? config.members : [];
const leadName = resolveLeadName(config);
const normalizedLeadName = leadName.trim().toLowerCase();
return members
.filter(
(member) =>
member &&
member.name?.trim() &&
member.providerId === 'opencode' &&
member.removedAt == null &&
(member as { isActive?: unknown }).isActive !== false
member.name.trim().toLowerCase() !== normalizedLeadName &&
member.removedAt == null
)
.map((member) => ({
from: leadName,
to: member.name,
text: buildOpenCodeBootstrapDisplayPrompt(config, member),
timestamp: resolveOpenCodeBootstrapTimestamp(config, member),
text: buildSyntheticBootstrapDisplayPrompt(config, member),
timestamp: resolveSyntheticBootstrapTimestamp(config, member),
read: true,
source: 'system_notification' as const,
messageId: `opencode-bootstrap-start:${config.name}:${member.name}`,
messageId: `bootstrap-start:${config.name}:${member.name}`,
}));
}
function isVisibleTeamMessage(message: InboxMessage): boolean {
return !isTeamInternalControlMessageEnvelope(message);
}
function annotateSlashCommandResponses(messages: InboxMessage[]): void {
let pendingSlash = null as InboxMessage['slashCommand'] | null;
@ -498,8 +508,10 @@ export class TeamMessageFeedService {
const sourceMs = Date.now() - sourceStartedAt;
const normalizeStartedAt = Date.now();
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
const syntheticMessages = buildSyntheticBootstrapMessages(config);
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter(
isVisibleTeamMessage
);
messages = dedupeLeadProcessCopies(messages, leadTexts);
messages = ensureEffectiveMessageIds(messages);
messages = dedupeByMessageId(messages);

File diff suppressed because it is too large Load diff

View file

@ -378,11 +378,25 @@ export class TeamTaskWriter {
}
const task = JSON.parse(raw) as TeamTask;
if (owner) {
task.owner = owner;
const previousOwner =
typeof task.owner === 'string' && task.owner.trim() ? task.owner.trim() : undefined;
const nextOwner = typeof owner === 'string' && owner.trim() ? owner.trim() : undefined;
if (nextOwner) {
task.owner = nextOwner;
} else {
delete task.owner;
}
if (previousOwner !== nextOwner) {
task.historyEvents = appendHistoryEvent(
Array.isArray(task.historyEvents) ? task.historyEvents : undefined,
{
type: 'owner_changed',
...(previousOwner ? { from: previousOwner } : {}),
...(nextOwner ? { to: nextOwner } : {}),
actor: 'user',
} as Omit<TaskHistoryEvent, 'id' | 'timestamp'>
);
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}

View file

@ -0,0 +1,225 @@
export const LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE = 'member_briefing_tool_success';
export const NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE =
'native_app_managed_bootstrap_private_turn';
type BootstrapProofField =
| 'source'
| 'bootstrapProofToken'
| 'contextHash'
| 'briefingHash'
| 'runId';
export type BootstrapProofSource =
| typeof LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE
| typeof NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE;
export type BootstrapProofValidationFailureReason =
| 'wrong_event_type'
| 'wrong_team'
| 'stale_timestamp'
| 'unsupported_source'
| 'missing_team'
| 'missing_token'
| 'token_mismatch'
| 'missing_run_id'
| 'run_id_mismatch'
| 'missing_hash'
| 'hash_mismatch'
| 'wrong_proof_mode';
export type BootstrapProofValidationResult =
| { ok: true; source: BootstrapProofSource }
| { ok: false; reason: BootstrapProofValidationFailureReason; diagnostic: string };
export interface BootstrapRuntimeProofEventLike {
type?: unknown;
timestamp?: unknown;
teamName?: unknown;
source?: unknown;
bootstrapProofToken?: unknown;
contextHash?: unknown;
briefingHash?: unknown;
runId?: unknown;
detail?: unknown;
}
export interface BootstrapRuntimeProofExpected {
teamName: string;
boundaryMs: number;
proofToken?: string;
proofMode?: string;
contextHash?: string;
briefingHash?: string;
runId?: string;
}
export function parseBootstrapRuntimeProofDetail(detail: unknown): Record<string, unknown> {
if (typeof detail !== 'string' || detail.trim().length === 0) {
return {};
}
try {
const parsed = JSON.parse(detail) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function readProofField(
event: BootstrapRuntimeProofEventLike,
detail: Record<string, unknown>,
field: BootstrapProofField
): string | undefined {
const direct = event[field];
if (typeof direct === 'string' && direct.trim().length > 0) {
return direct.trim();
}
const nested = detail[field];
return typeof nested === 'string' && nested.trim().length > 0 ? nested.trim() : undefined;
}
function getBootstrapProofSource(
event: BootstrapRuntimeProofEventLike,
detail: Record<string, unknown>
): BootstrapProofSource | undefined {
const source = readProofField(event, detail, 'source');
return source === LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE ||
source === NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE
? source
: undefined;
}
function reject(
reason: BootstrapProofValidationFailureReason,
diagnostic: string
): BootstrapProofValidationResult {
return { ok: false, reason, diagnostic };
}
function validateExpectedProofToken(input: {
event: BootstrapRuntimeProofEventLike;
detail: Record<string, unknown>;
expected: BootstrapRuntimeProofExpected;
}): BootstrapProofValidationResult | null {
if (!input.expected.proofToken) {
return null;
}
const eventToken = readProofField(input.event, input.detail, 'bootstrapProofToken');
if (!eventToken) {
return reject('missing_token', 'Bootstrap proof token is missing');
}
if (eventToken !== input.expected.proofToken) {
return reject('token_mismatch', 'Bootstrap proof token does not match the current attempt');
}
return null;
}
function validateLegacyMemberBriefingProof(input: {
event: BootstrapRuntimeProofEventLike;
detail: Record<string, unknown>;
expected: BootstrapRuntimeProofExpected;
}): BootstrapProofValidationResult {
const tokenFailure = validateExpectedProofToken(input);
return tokenFailure ?? { ok: true, source: LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE };
}
function validateNativeAppManagedProof(input: {
event: BootstrapRuntimeProofEventLike;
detail: Record<string, unknown>;
expected: BootstrapRuntimeProofExpected;
}): BootstrapProofValidationResult {
const eventTeamName = typeof input.event.teamName === 'string' ? input.event.teamName.trim() : '';
if (!eventTeamName) {
return reject('missing_team', 'Native app-managed bootstrap proof is missing teamName');
}
if (eventTeamName !== input.expected.teamName) {
return reject('wrong_team', 'Native app-managed bootstrap proof teamName does not match');
}
if (input.expected.proofMode !== 'native_app_managed_context') {
return reject('wrong_proof_mode', 'Native app-managed bootstrap proof mode is not expected');
}
const tokenFailure = validateExpectedProofToken(input);
if (tokenFailure) {
return tokenFailure;
}
if (!input.expected.proofToken) {
return reject('missing_token', 'Native app-managed bootstrap expected proof token is missing');
}
const runId = readProofField(input.event, input.detail, 'runId');
if (!input.expected.runId || !runId) {
return reject('missing_run_id', 'Native app-managed bootstrap runId is missing');
}
if (runId !== input.expected.runId) {
return reject('run_id_mismatch', 'Native app-managed bootstrap runId does not match');
}
const contextHash = readProofField(input.event, input.detail, 'contextHash');
const briefingHash = readProofField(input.event, input.detail, 'briefingHash');
if (
!input.expected.contextHash ||
!input.expected.briefingHash ||
!contextHash ||
!briefingHash
) {
return reject('missing_hash', 'Native app-managed bootstrap proof hash metadata is missing');
}
if (contextHash !== input.expected.contextHash || briefingHash !== input.expected.briefingHash) {
return reject('hash_mismatch', 'Native app-managed bootstrap proof hashes do not match');
}
return { ok: true, source: NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE };
}
const BOOTSTRAP_PROOF_VALIDATORS: Record<
BootstrapProofSource,
(input: {
event: BootstrapRuntimeProofEventLike;
detail: Record<string, unknown>;
expected: BootstrapRuntimeProofExpected;
}) => BootstrapProofValidationResult
> = {
[LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE]: validateLegacyMemberBriefingProof,
[NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE]: validateNativeAppManagedProof,
};
export function validateBootstrapRuntimeProofEnvelopeDetailed(input: {
event: BootstrapRuntimeProofEventLike;
detail?: Record<string, unknown>;
expected: BootstrapRuntimeProofExpected;
}): BootstrapProofValidationResult {
const { event, expected } = input;
const detail = input.detail ?? parseBootstrapRuntimeProofDetail(event.detail);
if (event.type !== 'bootstrap_confirmed') {
return reject('wrong_event_type', 'Runtime event is not bootstrap_confirmed');
}
if (typeof event.teamName === 'string' && event.teamName.trim() !== expected.teamName) {
return reject('wrong_team', 'Bootstrap proof teamName does not match');
}
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
const eventMs = Date.parse(timestamp);
if (
Number.isFinite(expected.boundaryMs) &&
(!Number.isFinite(eventMs) || eventMs < expected.boundaryMs)
) {
return reject('stale_timestamp', 'Bootstrap proof timestamp is older than the current attempt');
}
const source = getBootstrapProofSource(event, detail);
if (!source) {
return reject('unsupported_source', 'Bootstrap proof source is missing or unsupported');
}
return BOOTSTRAP_PROOF_VALIDATORS[source]({ event, detail, expected });
}
export function validateBootstrapRuntimeProofEnvelope(input: {
event: BootstrapRuntimeProofEventLike;
detail?: Record<string, unknown>;
expected: BootstrapRuntimeProofExpected;
}): boolean {
return validateBootstrapRuntimeProofEnvelopeDetailed(input).ok;
}

View file

@ -0,0 +1,186 @@
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { createHash } from 'crypto';
import type { TeamCreateRequest, TeamProviderId } from '@shared/types';
const { createController } = agentTeamsControllerModule;
export interface NativeAppManagedBootstrapSpec {
schemaVersion: 1;
mode: 'startup_context_file';
contextText: string;
contextHash: string;
briefingHash: string;
generatedAt: string;
}
const MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS = 18_000;
const MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS = 24_000;
const MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS = 96_000;
export function isNativeAppManagedBootstrapProvider(providerId?: TeamProviderId): boolean {
return providerId == null || providerId === 'anthropic' || providerId === 'codex';
}
export function canonicalizeNativeBootstrapContextText(input: string): string {
return input
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.trim();
}
export function hashNativeBootstrapText(input: string): string {
return createHash('sha256').update(canonicalizeNativeBootstrapContextText(input)).digest('hex');
}
function redactNativeBootstrapContextText(input: string): string {
return input
.replace(/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_ANTHROPIC_API_KEY]')
.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY)=\S+/g, '$1=[REDACTED]')
.replace(/Bearer\s+[A-Z0-9._-]+/gi, 'Bearer [REDACTED]');
}
function boundText(input: string, maxChars: number): string {
const canonical = canonicalizeNativeBootstrapContextText(input);
if (canonical.length <= maxChars) {
return canonical;
}
return `${canonical.slice(0, maxChars)}\n[truncated native bootstrap context]`;
}
function buildContextText(params: {
teamName: string;
memberName: string;
providerId?: TeamProviderId;
cwd: string;
briefing: string;
}): string {
const briefing = boundText(
redactNativeBootstrapContextText(params.briefing),
MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS
);
return boundText(
[
'<agent_teams_native_bootstrap_context>',
`Team: ${params.teamName}`,
`Member: ${params.memberName}`,
`Provider: ${params.providerId ?? 'anthropic'}`,
`Project: ${params.cwd}`,
'',
'<member_briefing_context_data>',
briefing,
'</member_briefing_context_data>',
'</agent_teams_native_bootstrap_context>',
].join('\n'),
MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS
);
}
function buildLocalNativeMemberBriefing(params: {
teamName: string;
cwd: string;
providerId?: TeamProviderId;
member: TeamCreateRequest['members'][number];
unavailableReason: string;
}): string {
const member = params.member;
return [
`You are ${member.name}, a teammate in team ${params.teamName}.`,
`Provider: ${params.providerId ?? 'anthropic'}`,
`Project: ${member.cwd?.trim() || params.cwd}`,
member.role ? `Role: ${member.role}` : '',
member.workflow ? `Workflow: ${member.workflow}` : '',
member.model ? `Model: ${member.model}` : '',
member.effort ? `Effort: ${member.effort}` : '',
'',
'The app loaded this startup context from the current team launch request because canonical member_briefing metadata was not available yet.',
`Diagnostic: ${params.unavailableReason}`,
'',
'Startup rules:',
'- Treat yourself as unavailable until the private bootstrap turn succeeds.',
'- Do not call member_briefing for launch readiness in this flow.',
'- Use Agent Teams messaging/task tools only after launch readiness is confirmed.',
]
.filter((line) => line.length > 0)
.join('\n');
}
export async function buildNativeAppManagedBootstrapSpecs(params: {
teamName: string;
cwd: string;
members: TeamCreateRequest['members'];
}): Promise<Map<string, NativeAppManagedBootstrapSpec>> {
const controller = createController({
teamName: params.teamName,
claudeDir: getClaudeBasePath(),
allowUserMessageSender: false,
});
const result = new Map<string, NativeAppManagedBootstrapSpec>();
let totalContextChars = 0;
for (const member of params.members) {
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? 'anthropic';
if (!isNativeAppManagedBootstrapProvider(providerId)) {
continue;
}
let briefing: string;
try {
briefing = String(
await controller.tasks.memberBriefing(member.name, {
runtimeProvider: 'native',
includeActiveProcesses: false,
})
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('Member not found in team metadata or inboxes')) {
throw error;
}
// In createTeam, the orchestrator's canonical config/inboxes may not
// exist until after the lead process runs. Fail-closed would break team
// creation, so use bounded request metadata while keeping readiness tied
// to the private bootstrap proof, never to this context load.
briefing = buildLocalNativeMemberBriefing({
teamName: params.teamName,
cwd: params.cwd,
providerId,
member,
unavailableReason: message,
});
}
const boundedBriefing = boundText(
redactNativeBootstrapContextText(briefing),
MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS
);
if (!boundedBriefing) {
throw new Error(`Native app-managed member briefing was empty for ${member.name}`);
}
const contextText = buildContextText({
teamName: params.teamName,
memberName: member.name,
providerId,
cwd: member.cwd?.trim() || params.cwd,
briefing: boundedBriefing,
});
totalContextChars += contextText.length;
if (totalContextChars > MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS) {
throw new Error('Native app-managed bootstrap context exceeds aggregate size budget');
}
result.set(member.name, {
schemaVersion: 1,
mode: 'startup_context_file',
contextText,
contextHash: hashNativeBootstrapText(contextText),
briefingHash: hashNativeBootstrapText(boundedBriefing),
generatedAt: new Date().toISOString(),
});
}
return result;
}

View file

@ -1,7 +1,14 @@
import { createHash } from 'crypto';
import type {
OpenCodeAppManagedBootstrapCandidate,
OpenCodeBootstrapEvidenceSource,
OpenCodeBootstrapMode,
} from '@shared/types/team';
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const;
export const OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION = 1 as const;
export type OpenCodeBridgeCommandName =
| 'opencode.handshake'
@ -65,6 +72,9 @@ export interface OpenCodeLaunchTeamCommandBody {
export interface OpenCodeTeamMemberLaunchCommandData {
sessionId: string;
launchState: OpenCodeTeamMemberLaunchBridgeState;
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
pendingPermissionRequestIds?: string[];
diagnostics?: string[];
model: string;
@ -166,6 +176,7 @@ export interface OpenCodeSendMessageCommandBody {
| 'slash_command'
| 'slash_command_result'
| 'task_comment_notification'
| 'task_stall_remediation'
| 'member_work_sync_nudge'
| 'agent_error';
taskRefs?: { taskId: string; displayId: string; teamName: string }[];
@ -373,6 +384,7 @@ export interface OpenCodeBridgePeerIdentity {
currentVersion: number;
supportedCommands: OpenCodeBridgeCommandName[];
opencodeTaskLedgerEvidenceContractVersion?: number;
opencodeAppManagedBootstrapContractVersion?: number;
};
runtime: {
providerId: 'opencode';
@ -591,6 +603,26 @@ export function validateOpenCodeBridgeHandshake(input: {
return { ok: false, reason: `Bridge server does not support command ${input.requiredCommand}` };
}
if (input.requiredCommand === 'opencode.launchTeam') {
if (!input.expectedCapabilitySnapshotId) {
return {
ok: false,
reason:
'OpenCode app-managed bootstrap launch requires a fresh capability snapshot before state-changing launch',
};
}
if (
input.handshake.server.bridgeProtocol.opencodeAppManagedBootstrapContractVersion !==
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION
) {
return {
ok: false,
reason:
'OpenCode app-managed bootstrap is required, but the orchestrator does not advertise contract version 1. Update agent_teams_orchestrator and restart the app.',
};
}
}
if (
input.expectedCapabilitySnapshotId &&
input.handshake.server.runtime.capabilitySnapshotId !== input.expectedCapabilitySnapshotId
@ -860,7 +892,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) ||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
(!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) ||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1))
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1)) ||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion !== undefined &&
(!Number.isInteger(bridgeProtocol.opencodeAppManagedBootstrapContractVersion) ||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion as number) < 1))
) {
return false;
}

View file

@ -3,6 +3,10 @@ import type {
OpenCodeBridgeHandshake,
OpenCodeBridgePeerIdentity,
} from './OpenCodeBridgeCommandContract';
import {
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
} from './OpenCodeBridgeCommandContract';
import type {
OpenCodeBridgeCommandExecutor,
OpenCodeBridgeHandshakePort,
@ -96,6 +100,8 @@ export function createOpenCodeBridgeClientIdentity(input: {
'opencode.recoverDeliveryJournal',
'opencode.backfillTaskLedger',
],
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
opencodeAppManagedBootstrapContractVersion: OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
},
runtime: {
providerId: 'opencode',

View file

@ -0,0 +1,320 @@
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
import type {
OpenCodePromptDeliveryLedgerRecord,
OpenCodePromptDeliveryStatus,
} from './OpenCodePromptDeliveryLedger';
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
export type OpenCodePromptDeliveryRepairKind =
| 'none'
| 'no_assistant_response'
| 'visible_answer_required'
| 'missing_visible_reply_correlation'
| 'work_sync_report_required'
| 'progress_proof_required'
| 'app_materialization_pending';
export type OpenCodePromptDeliveryHardFailureKind = 'none' | 'session' | 'permission' | 'unknown';
export interface OpenCodePromptDeliveryRepairDecision {
kind: OpenCodePromptDeliveryRepairKind;
retryable: boolean;
controlText: string | null;
reason: string;
}
export interface OpenCodePromptDeliveryRepairInput {
teamName: string;
memberName: string;
inboxMessageId: string;
replyRecipient: string;
messageKind: InboxMessageKind | null;
actionMode: AgentActionMode | null;
taskRefs: TaskRef[];
status: OpenCodePromptDeliveryStatus;
responseState: OpenCodeDeliveryResponseState;
attempts: number;
maxAttempts: number;
pendingReason: string;
readAllowed: boolean;
inboxReadCommitted: boolean;
visibleReplyFound: boolean;
hasKnownProgressProof: boolean;
toolCallNames: string[];
acceptanceUnknown: boolean;
hardFailureKind: OpenCodePromptDeliveryHardFailureKind;
}
const SIDE_EFFECT_TOOL_NAMES = new Set([
'bash',
'edit',
'write',
'patch',
'apply_patch',
'multiedit',
'multi_edit',
]);
function none(reason: string): OpenCodePromptDeliveryRepairDecision {
return { kind: 'none', retryable: false, controlText: null, reason };
}
function control(
input: OpenCodePromptDeliveryRepairInput,
kind: Exclude<OpenCodePromptDeliveryRepairKind, 'none'>,
reason: string,
lines: string[]
): OpenCodePromptDeliveryRepairDecision {
const attemptNumber = Math.min(Math.max(input.attempts + 1, 1), input.maxAttempts);
return {
kind,
retryable: true,
reason,
controlText: [
'<opencode_delivery_retry>',
`Retry attempt ${attemptNumber}/${input.maxAttempts} for inbound app messageId "${input.inboxMessageId}".`,
...lines,
'</opencode_delivery_retry>',
].join('\n'),
};
}
function normalizeToolName(toolName: string): string {
return toolName
.trim()
.toLowerCase()
.replace(/^mcp__agent[-_]teams__/, '')
.replace(/^agent[-_]teams_/, '')
.replace(/^mcp__agent_teams__/, '')
.replace(/^agent_teams_/, '');
}
function normalizedToolNames(input: OpenCodePromptDeliveryRepairInput): Set<string> {
return new Set(input.toolCallNames.map(normalizeToolName).filter(Boolean));
}
function hasTool(tools: Set<string>, toolName: string): boolean {
return tools.has(toolName);
}
function hasTaskTool(tools: Set<string>): boolean {
for (const tool of tools) {
if (tool.startsWith('task_') || tool === 'runtime_task_event') {
return true;
}
}
return false;
}
function hasSideEffectTool(tools: Set<string>): boolean {
for (const tool of tools) {
if (SIDE_EFFECT_TOOL_NAMES.has(tool)) {
return true;
}
}
return false;
}
function taskIdList(taskRefs: TaskRef[]): string | null {
const ids = [
...new Set(
taskRefs
.map((taskRef) => taskRef.taskId?.trim())
.filter((taskId): taskId is string => Boolean(taskId))
),
];
return ids.length > 0 ? ids.map((id) => `"${id}"`).join(', ') : null;
}
function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const replyRecipient = input.replyRecipient.trim() || 'user';
return [
'The app still has no correlated visible reply proof for this message.',
`Call agent-teams_message_send or mcp__agent-teams__message_send exactly once with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", and relayOfMessageId="${input.inboxMessageId}".`,
'Use a concrete answer in text and summary. Do not reply only with acknowledgement.',
'After the message_send tool succeeds, stop this turn. Do not repeat task/tool work unless the inbound message explicitly asks for new work.',
];
}
function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const taskIds = taskIdList(input.taskRefs);
return [
'This is a member-work-sync control message. A plain acknowledgement is not sufficient proof.',
`Call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with teamName="${input.teamName}" and memberName="${input.memberName}".`,
'Then call agent-teams_member_work_sync_report or mcp__agent-teams__member_work_sync_report using the agendaFingerprint/reportToken returned by status.',
taskIds ? `Include taskIds ${taskIds} when reporting if those tasks are still relevant.` : null,
'Use state "still_working", "blocked", or "caught_up" according to the status result. Do not invent or reuse a raw report token from this retry text.',
].filter((line): line is string => line !== null);
}
function progressControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const taskIds = taskIdList(input.taskRefs);
return [
'The app saw a tool/action response, but no accepted progress proof for this message.',
taskIds
? `Produce concrete task/progress proof for taskIds ${taskIds}, or send a visible status reply with relayOfMessageId="${input.inboxMessageId}".`
: `Send a concrete visible status reply with relayOfMessageId="${input.inboxMessageId}".`,
'Do not repeat side-effectful commands, edits, or writes just because this is a retry.',
'If work is blocked, report the blocker instead of silently ending the turn.',
];
}
function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
return [
'The app saw the prompt but did not observe assistant response proof.',
'You must not end this turn empty.',
input.messageKind === 'member_work_sync_nudge'
? 'Follow the member-work-sync status/report instructions for this message.'
: `Send a concrete reply using message_send with relayOfMessageId="${input.inboxMessageId}", or provide a concrete plain-text answer only if message_send is unavailable.`,
];
}
function toolErrorControl(input: OpenCodePromptDeliveryRepairInput) {
const tools = normalizedToolNames(input);
if (hasTool(tools, 'message_send')) {
return control(
input,
'missing_visible_reply_correlation',
'message_send_tool_error_without_visible_reply_proof',
messageSendControlLines(input)
);
}
if (hasTool(tools, 'member_work_sync_report') || hasTool(tools, 'member_work_sync_status')) {
return control(
input,
'work_sync_report_required',
'member_work_sync_tool_error_without_report_proof',
workSyncControlLines(input)
);
}
if (hasSideEffectTool(tools)) {
return control(
input,
'progress_proof_required',
'side_effect_tool_error_without_progress_proof',
progressControlLines(input)
);
}
if (hasTaskTool(tools)) {
return control(
input,
'progress_proof_required',
'task_tool_error_without_progress_proof',
progressControlLines(input)
);
}
return control(
input,
'progress_proof_required',
'tool_error_without_required_delivery_proof',
progressControlLines(input)
);
}
export function decideOpenCodePromptDeliveryRepair(
input: OpenCodePromptDeliveryRepairInput
): OpenCodePromptDeliveryRepairDecision {
if (input.readAllowed) {
return none('read_commit_allowed');
}
if (input.inboxReadCommitted) {
return none('inbox_read_already_committed');
}
if (input.status === 'failed_terminal') {
return none('terminal_record');
}
if (input.attempts >= input.maxAttempts) {
return none('max_attempts_reached');
}
if (input.hardFailureKind !== 'none') {
return none(`hard_failure:${input.hardFailureKind}`);
}
if (input.status === 'pending' && input.attempts <= 0 && !input.acceptanceUnknown) {
return none('initial_delivery');
}
if (input.acceptanceUnknown) {
return control(input, 'no_assistant_response', 'acceptance_unknown', [
'The app could not confirm whether the previous OpenCode prompt was accepted.',
'Process the inbound message now. If you already completed it, send only the missing proof and do not duplicate side effects.',
input.messageKind === 'member_work_sync_nudge'
? 'For work-sync, use member_work_sync_status then member_work_sync_report.'
: `For visible replies, use relayOfMessageId="${input.inboxMessageId}".`,
]);
}
if (input.messageKind === 'member_work_sync_nudge') {
return control(
input,
'work_sync_report_required',
input.pendingReason,
workSyncControlLines(input)
);
}
if (input.pendingReason === 'plain_text_visible_reply_not_materialized_yet') {
return {
kind: 'app_materialization_pending',
retryable: false,
controlText: null,
reason: input.pendingReason,
};
}
if (
input.pendingReason === 'visible_reply_destination_not_found_yet' ||
input.pendingReason === 'visible_reply_missing_relayOfMessageId' ||
input.pendingReason === 'visible_reply_still_required' ||
(input.responseState === 'responded_visible_message' && !input.visibleReplyFound)
) {
return control(
input,
'missing_visible_reply_correlation',
input.pendingReason,
messageSendControlLines(input)
);
}
if (
input.pendingReason === 'visible_reply_ack_only_still_requires_answer' ||
input.pendingReason === 'plain_text_ack_only_still_requires_answer'
) {
return control(input, 'visible_answer_required', input.pendingReason, [
'The previous response looked like acknowledgement only, not a concrete answer.',
...messageSendControlLines(input),
]);
}
if (input.responseState === 'tool_error') {
return toolErrorControl(input);
}
if (
input.responseState === 'empty_assistant_turn' ||
input.responseState === 'prompt_delivered_no_assistant_message' ||
input.responseState === 'not_observed' ||
input.responseState === 'reconcile_failed'
) {
return control(
input,
'no_assistant_response',
input.pendingReason,
noAssistantControlLines(input)
);
}
if (
(input.responseState === 'responded_non_visible_tool' ||
input.responseState === 'responded_tool_call') &&
!input.hasKnownProgressProof
) {
return control(
input,
'progress_proof_required',
input.pendingReason,
progressControlLines(input)
);
}
return none(input.pendingReason || 'no_repair_needed');
}

View file

@ -0,0 +1,134 @@
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
const SECRET_VALUE_PATTERNS = [
/\bsk-[A-Z0-9_-]{12,}\b/gi,
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi,
/\bauthorization:\s*bearer\s+[^'"\s]+/gi,
] as const;
const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [
'opencode app mcp was reattached before message delivery',
'reattached stale opencode app mcp server',
'opencode session reconcile skipped because the stored session is stale',
'recreated opencode session before message delivery',
'opencode message delivery observe bridge failed',
'opencode bridge command timed out',
'opencode bootstrap mcp did not complete required tools before assistant response',
'existing app mcp config does not expose environment',
'empty_assistant_turn',
'visible_reply_still_required',
'prompt_delivered_no_assistant_message',
'plain_text_ack_only_still_requires_answer',
'visible_reply_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'non_visible_tool_without_task_progress',
] as const;
const ACTION_REQUIRED_DELIVERY_ERROR_TOKENS = [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'api key',
'does not have access',
'please run /login',
'insufficient credits',
'quota exceeded',
'quota exhausted',
'capacity exceeded',
'key limit exceeded',
'total limit',
] as const;
export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
message: string | null | undefined
): string | null {
const scrubbed = SECRET_VALUE_PATTERNS.reduce(
(current, pattern) => current.replace(pattern, '[redacted]'),
message ?? ''
);
const normalized = scrubbed
?.replace(/\s+/g, ' ')
.trim()
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
.replace(/^APIError\s*[-:]\s*/i, '');
return normalized && normalized.length > 0 ? normalized : null;
}
export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boolean {
const normalized = message.trim().toLowerCase();
return GENERIC_DELIVERY_DIAGNOSTIC_TOKENS.some((token) => normalized.includes(token));
}
export function selectOpenCodeRuntimeDeliveryReason(
record: OpenCodePromptDeliveryLedgerRecord
): string | null {
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason];
const normalized = candidates.flatMap((candidate) => {
const message = normalizeOpenCodeRuntimeDeliveryDiagnostic(candidate);
return message ? [message] : [];
});
const specific = normalized.find(
(message) => !isGenericOpenCodeRuntimeDeliveryDiagnostic(message)
);
if (specific) {
return boundOpenCodeRuntimeDeliveryReason(specific);
}
const fallback = getOpenCodeRuntimeDeliveryStateFallback(record);
if (fallback) {
return fallback;
}
return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null;
}
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
message: string | null | undefined
): boolean {
const normalized = normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase();
if (!normalized) {
return false;
}
return ACTION_REQUIRED_DELIVERY_ERROR_TOKENS.some((token) => normalized.includes(token));
}
function getOpenCodeRuntimeDeliveryStateFallback(
record: OpenCodePromptDeliveryLedgerRecord
): string | null {
const state = record.responseState?.trim();
const reason = record.lastReason?.trim();
if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') {
return 'OpenCode returned an empty assistant turn.';
}
if (
reason === 'visible_reply_still_required' ||
reason === 'visible_reply_ack_only_still_requires_answer' ||
reason === 'plain_text_ack_only_still_requires_answer'
) {
return 'OpenCode responded, but did not create a visible message_send reply.';
}
if (
state === 'prompt_delivered_no_assistant_message' ||
reason === 'prompt_delivered_no_assistant_message'
) {
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
}
if (
reason === 'visible_reply_destination_not_found_yet' ||
reason === 'visible_reply_missing_relayOfMessageId'
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (reason === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
return null;
}
function boundOpenCodeRuntimeDeliveryReason(reason: string): string {
return reason.length > 500 ? `${reason.slice(0, 497).trimEnd()}...` : reason;
}

View file

@ -15,6 +15,10 @@ import {
validateRuntimeStoreManifest,
} from './RuntimeStoreManifest';
import type {
OpenCodeAppManagedBootstrapCandidate,
OpenCodeBootstrapEvidenceSource,
} from '@shared/types/team';
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
@ -65,7 +69,8 @@ export interface OpenCodeCommittedBootstrapSessionRecord {
laneId: string;
runId: string | null;
observedAt: string | null;
source: 'runtime_bootstrap_checkin';
source: OpenCodeBootstrapEvidenceSource;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
}
export interface OpenCodeCommittedBootstrapSessionEvidence {
@ -301,10 +306,20 @@ function normalizeOpenCodeBootstrapSessionRecord(
const memberName = normalizeNonEmptyStoreString(record.memberName);
const laneId = normalizeNonEmptyStoreString(record.laneId);
const source = normalizeNonEmptyStoreString(record.source);
if (!id || !teamName || !memberName || !laneId || source !== 'runtime_bootstrap_checkin') {
if (
!id ||
!teamName ||
!memberName ||
!laneId ||
(source !== 'runtime_bootstrap_checkin' && source !== 'app_managed_bootstrap')
) {
return null;
}
const observedAt = normalizeOptionalStoreIso(record.observedAt);
const appManagedBootstrapCandidate =
source === 'app_managed_bootstrap'
? normalizeAppManagedBootstrapCandidate(record.appManagedBootstrapCandidate)
: undefined;
return {
id,
teamName,
@ -312,7 +327,62 @@ function normalizeOpenCodeBootstrapSessionRecord(
laneId,
runId: normalizeNonEmptyStoreString(record.runId),
observedAt,
source: 'runtime_bootstrap_checkin',
source,
...(appManagedBootstrapCandidate ? { appManagedBootstrapCandidate } : {}),
};
}
function normalizeAppManagedBootstrapCandidate(
value: unknown
): OpenCodeAppManagedBootstrapCandidate | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.schemaVersion !== 1 || record.source !== 'app_managed_bootstrap') {
return undefined;
}
const teamName = normalizeNonEmptyStoreString(record.teamName);
const memberName = normalizeNonEmptyStoreString(record.memberName);
const runId = normalizeNonEmptyStoreString(record.runId);
const laneId = normalizeNonEmptyStoreString(record.laneId);
const runtimeSessionId = normalizeNonEmptyStoreString(record.runtimeSessionId);
const messageID = normalizeNonEmptyStoreString(record.messageID);
const contextHash = normalizeNonEmptyStoreString(record.contextHash);
const briefingHash = normalizeNonEmptyStoreString(record.briefingHash);
const injectionVerifiedAt = normalizeNonEmptyStoreString(record.injectionVerifiedAt);
const candidateAt = normalizeNonEmptyStoreString(record.candidateAt);
if (
!teamName ||
!memberName ||
!runId ||
!laneId ||
!runtimeSessionId ||
!messageID ||
!contextHash ||
!briefingHash ||
!injectionVerifiedAt ||
!candidateAt
) {
return undefined;
}
const model = normalizeNonEmptyStoreString(record.model);
const agent = normalizeNonEmptyStoreString(record.agent);
return {
schemaVersion: 1,
source: 'app_managed_bootstrap',
teamName,
memberName,
runId,
laneId,
runtimeSessionId,
messageID,
contextHash,
briefingHash,
injectionVerifiedAt,
candidateAt,
...(model ? { model } : {}),
...(agent ? { agent } : {}),
};
}
@ -560,7 +630,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
}
);
if (sessions.length === 0) {
diagnostics.push('OpenCode session store has no committed bootstrap check-in sessions.');
diagnostics.push('OpenCode session store has no committed bootstrap sessions.');
}
return {
state: 'healthy',

View file

@ -26,7 +26,12 @@ import type {
TeamRuntimeStopInput,
TeamRuntimeStopResult,
} from './TeamRuntimeAdapter';
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
import type {
AgentActionMode,
InboxMessageKind,
OpenCodeAppManagedBootstrapCandidate,
TaskRef,
} from '@shared/types/team';
export interface OpenCodeTeamRuntimeBridgePort {
checkOpenCodeTeamLaunchReadiness(input: {
@ -169,6 +174,15 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
const runtimeSnapshot = skipReadinessPreflight
? null
: (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null);
if (
!skipReadinessPreflight &&
this.bridge.getLastOpenCodeRuntimeSnapshot &&
!runtimeSnapshot?.capabilitySnapshotId
) {
return blockedLaunchResult(input, 'opencode_capability_snapshot_missing', [
'OpenCode app-managed launch requires a fresh capability snapshot before state-changing launch.',
]);
}
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
const data = await this.bridge.launchOpenCodeTeam({
runId: input.runId,
@ -457,18 +471,24 @@ function mapOpenCodeLaunchDataToRuntimeResult(
checkpointNames.has(name)
);
const bridgeReady = data.teamLaunchState === 'ready';
const isExpectedMemberConfirmed = (memberName: string): boolean => {
const bridgeMember = data.members[memberName];
return bridgeMember?.launchState === 'confirmed_alive';
};
const missingExpectedMembers = input.expectedMembers
.map((member) => member.name)
.filter((memberName) => data.members[memberName] == null);
const unconfirmedExpectedMembers = input.expectedMembers
.map((member) => member.name)
.filter((memberName) => data.members[memberName]?.launchState !== 'confirmed_alive');
.filter((memberName) => !isExpectedMemberConfirmed(memberName));
const anyExpectedMemberFailed = input.expectedMembers.some(
(member) => data.members[member.name]?.launchState === 'failed'
);
const allExpectedMembersConfirmed =
input.expectedMembers.length > 0 && unconfirmedExpectedMembers.length === 0;
const success = bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed;
const success =
(bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed) ||
(data.teamLaunchState === 'launching' && allExpectedMembersConfirmed);
const checkpointDiagnostic = success
? []
: bridgeReady && !readyCheckpointsPresent
@ -522,6 +542,12 @@ function mapOpenCodeLaunchDataToRuntimeResult(
bridgeMember?.pendingPermissionRequestIds,
bridgeMember != null,
memberDiagnostics,
input.runId,
input.laneId?.trim() || 'primary',
input.teamName,
bridgeMember?.bootstrapEvidenceSource,
bridgeMember?.bootstrapMode,
bridgeMember?.appManagedBootstrapCandidate,
selectOpenCodeMemberFailureReason({
memberDiagnostics: bridgeMember?.diagnostics ?? [],
bridgeDiagnostics: data.diagnostics,
@ -556,6 +582,61 @@ function mapOpenCodeLaunchDataToRuntimeResult(
};
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function normalizeAppManagedBootstrapCandidate(
value: OpenCodeAppManagedBootstrapCandidate | undefined,
expected: {
teamName: string;
memberName: string;
runId: string;
laneId: string;
runtimeSessionId?: string;
}
): OpenCodeAppManagedBootstrapCandidate | undefined {
if (!value || value.schemaVersion !== 1 || value.source !== 'app_managed_bootstrap') {
return undefined;
}
if (
value.teamName !== expected.teamName ||
value.memberName !== expected.memberName ||
value.runId !== expected.runId ||
value.laneId !== expected.laneId ||
(expected.runtimeSessionId && value.runtimeSessionId !== expected.runtimeSessionId)
) {
return undefined;
}
if (
!isNonEmptyString(value.runtimeSessionId) ||
!isNonEmptyString(value.messageID) ||
!value.messageID.startsWith('msg') ||
!isNonEmptyString(value.contextHash) ||
!isNonEmptyString(value.briefingHash) ||
!isNonEmptyString(value.injectionVerifiedAt) ||
!isNonEmptyString(value.candidateAt)
) {
return undefined;
}
return {
schemaVersion: 1,
source: 'app_managed_bootstrap',
teamName: value.teamName,
memberName: value.memberName,
runId: value.runId,
laneId: value.laneId,
runtimeSessionId: value.runtimeSessionId,
messageID: value.messageID,
contextHash: value.contextHash,
briefingHash: value.briefingHash,
injectionVerifiedAt: value.injectionVerifiedAt,
candidateAt: value.candidateAt,
...(isNonEmptyString(value.model) ? { model: value.model } : {}),
...(isNonEmptyString(value.agent) ? { agent: value.agent } : {}),
};
}
function mapBridgeMemberToRuntimeEvidence(
memberName: string,
launchState: OpenCodeTeamMemberLaunchBridgeState,
@ -564,8 +645,30 @@ function mapBridgeMemberToRuntimeEvidence(
pendingPermissionRequestIds: string[] | undefined,
runtimeMaterialized: boolean,
diagnostics: string[],
runId: string,
laneId: string,
teamName: string,
bootstrapEvidenceSource: TeamRuntimeMemberLaunchEvidence['bootstrapEvidenceSource'] | undefined,
bootstrapMode: TeamRuntimeMemberLaunchEvidence['bootstrapMode'] | undefined,
appManagedBootstrapCandidate: OpenCodeAppManagedBootstrapCandidate | undefined,
selectedHardFailureReason: string
): TeamRuntimeMemberLaunchEvidence {
const normalizedAppManagedCandidate = normalizeAppManagedBootstrapCandidate(
appManagedBootstrapCandidate,
{
teamName,
memberName,
runId,
laneId,
runtimeSessionId: sessionId,
}
);
const appManagedCandidatePresent =
launchState === 'created' &&
isNonEmptyString(sessionId) &&
bootstrapEvidenceSource === 'app_managed_bootstrap' &&
bootstrapMode === 'app_managed_context' &&
normalizedAppManagedCandidate != null;
const confirmed = launchState === 'confirmed_alive';
const failed = launchState === 'failed';
const hasRuntimePid =
@ -580,20 +683,24 @@ function mapBridgeMemberToRuntimeEvidence(
: launchState === 'permission_blocked'
? 'permission_blocked'
: 'registered_only';
const runtimeDiagnostic = pendingRuntimeObserved
? hasRuntimePid
? 'OpenCode runtime pid reported by bridge without local process verification'
: 'OpenCode session exists without verified runtime pid'
: launchState === 'permission_blocked'
? 'OpenCode runtime is waiting for permission approval'
: runtimeMaterialized
? 'OpenCode bridge did not report a runtime session or pid for this member'
const runtimeDiagnostic = appManagedCandidatePresent
? 'OpenCode app-managed bootstrap context was injected and verified by the bridge; waiting for app-owned durable evidence commit.'
: pendingRuntimeObserved
? hasRuntimePid
? 'OpenCode runtime pid reported by bridge without local process verification'
: 'OpenCode session exists without verified runtime pid'
: launchState === 'permission_blocked'
? 'OpenCode runtime is waiting for permission approval'
: runtimeMaterialized
? 'OpenCode bridge did not report a runtime session or pid for this member'
: undefined;
const runtimeDiagnosticSeverity = appManagedCandidatePresent
? 'info'
: failed
? 'error'
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
? 'warning'
: undefined;
const runtimeDiagnosticSeverity = failed
? 'error'
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
? 'warning'
: undefined;
return {
memberName,
providerId: 'opencode',
@ -618,6 +725,13 @@ function mapBridgeMemberToRuntimeEvidence(
? [...new Set(pendingPermissionRequestIds)]
: undefined,
sessionId,
...(appManagedCandidatePresent
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }
: {}),
...(appManagedCandidatePresent ? { bootstrapMode: 'app_managed_context' as const } : {}),
...(normalizedAppManagedCandidate
? { appManagedBootstrapCandidate: normalizedAppManagedCandidate }
: {}),
...(hasRuntimePid ? { runtimePid } : {}),
livenessKind,
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
@ -725,24 +839,24 @@ function buildMemberBootstrapPrompt(
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
const workflow = member.workflow?.trim();
return [
'<agent_teams_app_managed_bootstrap_briefing>',
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
workflow ? `Workflow:\n${workflow}` : null,
'',
'This OpenCode session is already attached by the desktop app. Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
'This OpenCode session is created, attached, and launch-verified by the desktop app.',
'Do not call runtime_bootstrap_checkin or member_briefing just to prove launch readiness.',
'Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.',
'The desktop bridge may prepend runtime identity and bootstrap instructions. Follow those first.',
'After runtime identity check-in, if you have not already done so, call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:',
`{ "teamName": "${input.teamName}", "memberName": "${member.name}", "runtimeProvider": "opencode" }`,
'If that tool is not available, stay idle and wait for app-delivered instructions. Do not improvise a replacement workflow.',
'Launch bootstrap is a silent attach, not a user/team conversation turn.',
'After runtime_bootstrap_checkin and member_briefing both succeed, stop this turn immediately and wait for app-delivered messages or actionable task assignments.',
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
'If the briefing says there are no actionable tasks, stay idle silently.',
'',
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
'</agent_teams_app_managed_bootstrap_briefing>',
]
.filter((line): line is string => line !== null)
.join('\n');
@ -792,6 +906,10 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
input.taskRefs
?.map((ref) => ref.taskId?.trim())
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
// Work-sync nudges are health/reporting probes. Requiring a visible
// message_send reply here causes false delivery failures, so accept the
// dedicated member_work_sync_report proof path while keeping normal user
// messages on the visible reply contract.
const responseInstructions = isWorkSyncNudge
? [
'This delivered app message is a member-work-sync nudge.',

View file

@ -1,6 +1,9 @@
import type {
EffortLevel,
MemberLaunchState,
OpenCodeAppManagedBootstrapCandidate,
OpenCodeBootstrapEvidenceSource,
OpenCodeBootstrapMode,
PersistedTeamLaunchPhase,
PersistedTeamLaunchSnapshot,
TeamAgentRuntimeBackendType,
@ -79,6 +82,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
sessionId?: string;
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
backendType?: TeamAgentRuntimeBackendType;
runtimePid?: number;
livenessKind?: TeamAgentRuntimeLivenessKind;

View file

@ -72,10 +72,10 @@ export async function resolveDesktopTeammateModeDecision(
};
}
const tmuxAvailable = await isTmuxAvailable();
await isTmuxAvailable();
return {
injectedTeammateMode: tmuxAvailable ? 'tmux' : null,
injectedTeammateMode: null,
forceProcessTeammates: true,
};
}

View file

@ -107,6 +107,7 @@ export class TeamTaskStallNotifier {
taskRefs: [args.alert.taskRef],
actionMode: 'do',
source: 'system_notification',
messageKind: 'task_stall_remediation',
};
await this.inboxWriter.sendMessage(args.teamName, request);
return true;

View file

@ -10,6 +10,7 @@ import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'
import { TeamKanbanManager } from '../TeamKanbanManager';
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
import { TeamTaskReader } from '../TeamTaskReader';
import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked } from '../teamTaskActiveState';
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
@ -87,12 +88,47 @@ export class TeamTaskStallSnapshotSource {
this.kanbanManager.getState(teamName),
this.membersMetaStore.getMembers(teamName).catch(() => []),
]);
const allTasks = [...activeTasks, ...deletedTasks];
const withWorkflowOverlay = (task: TeamTask): TeamTask => {
const kanbanColumn = kanbanState.tasks[task.id]?.column;
const workflowColumn = getTeamTaskWorkflowColumn({
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
});
if (workflowColumn) {
return task.reviewState !== workflowColumn
? { ...task, reviewState: workflowColumn }
: task;
}
return task.reviewState === 'review' || task.reviewState === 'approved'
? { ...task, reviewState: 'none' }
: task;
};
const workflowActiveTasks = activeTasks.map(withWorkflowOverlay);
const allTasks = [...workflowActiveTasks, ...deletedTasks];
const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
const inProgressTasks = activeTasks.filter(
(task) => task.status === 'in_progress' && task.reviewState !== 'review'
);
const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review');
const inProgressTasks = workflowActiveTasks.filter((task) => {
const kanbanColumn = kanbanState.tasks[task.id]?.column;
const workflowColumn = getTeamTaskWorkflowColumn({
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
});
return (
workflowColumn !== 'review' &&
isTeamTaskActivelyWorked({
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
})
);
});
const reviewOpenTasks = workflowActiveTasks.filter((task) => {
const kanbanColumn = kanbanState.tasks[task.id]?.column;
return (
getTeamTaskWorkflowColumn({
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
}) === 'review'
);
});
const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
@ -157,7 +193,7 @@ export class TeamTaskStallSnapshotSource {
transcriptFiles: transcriptContext.transcriptFiles,
activityReadsEnabled,
exactReadsEnabled,
activeTasks,
activeTasks: workflowActiveTasks,
deletedTasks,
allTasksById,
inProgressTasks,

View file

@ -0,0 +1,70 @@
import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState';
import type { TeamTaskWithKanban } from '@shared/types';
export {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
isTeamTaskActivelyWorked,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
isTeamTaskTerminalForActionableWork,
} from '@shared/utils/teamTaskState';
function parseIsoTime(value: string | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getActiveWorkStartedAt(task: TeamTaskWithKanban): number {
const workIntervals = task.workIntervals ?? [];
for (let index = workIntervals.length - 1; index >= 0; index--) {
const interval = workIntervals[index];
if (interval && !interval.completedAt) {
const startedAt = parseIsoTime(interval.startedAt);
if (startedAt > 0) {
return startedAt;
}
}
}
const historyEvents = task.historyEvents ?? [];
for (let index = historyEvents.length - 1; index >= 0; index--) {
const event = historyEvents[index];
if (event?.type === 'status_changed' && event.to === 'in_progress') {
const startedAt = parseIsoTime(event.timestamp);
if (startedAt > 0) {
return startedAt;
}
}
}
return Math.max(parseIsoTime(task.updatedAt), parseIsoTime(task.createdAt));
}
function compareCurrentActiveTasks(left: TeamTaskWithKanban, right: TeamTaskWithKanban): number {
const byStartedAt = getActiveWorkStartedAt(right) - getActiveWorkStartedAt(left);
if (byStartedAt !== 0) return byStartedAt;
const byUpdatedAt = parseIsoTime(right.updatedAt) - parseIsoTime(left.updatedAt);
if (byUpdatedAt !== 0) return byUpdatedAt;
const byCreatedAt = parseIsoTime(right.createdAt) - parseIsoTime(left.createdAt);
if (byCreatedAt !== 0) return byCreatedAt;
const leftLabel = left.displayId ?? left.id;
const rightLabel = right.displayId ?? right.id;
return leftLabel.localeCompare(rightLabel, undefined, {
numeric: true,
sensitivity: 'base',
});
}
export function selectCurrentActiveTeamTask<T extends TeamTaskWithKanban>(
tasks: readonly T[]
): T | null {
const activeTasks = tasks.filter(isTeamTaskActivelyWorked);
if (activeTasks.length === 0) return null;
return [...activeTasks].sort(compareCurrentActiveTasks)[0] ?? null;
}

View file

@ -1239,7 +1239,9 @@ function normalizeFallbackReviewState(value: unknown, status: string): string {
if (status === 'in_progress' || status === 'deleted') return 'none';
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
if (status === 'completed') {
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
return reviewState === 'review' || reviewState === 'approved' || reviewState === 'needsFix'
? reviewState
: 'none';
}
return reviewState;
}
@ -1444,9 +1446,11 @@ async function readTasksDirForTeam(
parsed.status === 'deleted'
? (parsed.status as string)
: 'pending';
const derivedReviewState = deriveReviewStateFromEvents(historyEvents);
const reviewState =
deriveReviewStateFromEvents(historyEvents) ??
normalizeFallbackReviewState(parsed.reviewState, status);
derivedReviewState !== null
? normalizeFallbackReviewState(derivedReviewState, status)
: normalizeFallbackReviewState(parsed.reviewState, status);
const task = {
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',

View file

@ -16,10 +16,12 @@ export const WebPreviewBanner = (): React.JSX.Element | null => {
>
<FlaskConical className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div className="min-w-0">
<p className="text-sm font-medium text-amber-900">Web version is still in development</p>
<p className="text-sm font-medium text-amber-900">
Open the desktop app for full functionality
</p>
<p className="mt-1 text-xs leading-relaxed text-amber-800">
Some desktop features are not available in the browser yet. Project actions, integrations,
and live status data may be limited or not work as expected.
The browser version is still in development. Project actions, integrations, and live
status updates may be limited here. Use the desktop app to access all features reliably.
</p>
</div>
</div>

View file

@ -9,7 +9,10 @@ import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/membe
import { nameColorSet } from '@renderer/utils/projectColor';
import { projectColor } from '@renderer/utils/projectColor';
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -105,7 +108,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
}
}, [isRenaming, displaySubject]);
const reviewColumn = getTaskKanbanColumn(task);
const reviewColumn = getTeamTaskWorkflowColumn(task);
const cfg =
reviewColumn === 'approved'
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
@ -212,7 +215,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
</span>
))}
{displaySubject}
{task.reviewState === 'needsFix' && (
{isTeamTaskNeedsFixActionable(task) && (
<span
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>

View file

@ -1,7 +1,10 @@
import { useSyncExternalStore } from 'react';
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
export type TaskStatusFilterId =
| 'todo'
@ -50,10 +53,10 @@ export function taskMatchesStatus(
if (statusIds.size === 0) return false;
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
const kanbanColumn = getTaskKanbanColumn(task);
const inNeedsFix = task.reviewState === 'needsFix';
const kanbanColumn = getTeamTaskWorkflowColumn(task);
const inNeedsFix = isTeamTaskNeedsFixActionable(task);
const inTodo = task.status === 'pending' && !kanbanColumn && !inNeedsFix;
const inProgress = task.status === 'in_progress' && !kanbanColumn;
const inProgress = task.status === 'in_progress' && !kanbanColumn && !inNeedsFix;
const inDone = task.status === 'completed' && !kanbanColumn && !inNeedsFix;
const inReview = kanbanColumn === 'review';
const inApproved = kanbanColumn === 'approved';

View file

@ -7,7 +7,10 @@ import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow';
@ -29,7 +32,7 @@ const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
};
function getEffectiveColumn(task: TeamTaskWithKanban): string {
const reviewColumn = getTaskKanbanColumn(task);
const reviewColumn = getTeamTaskWorkflowColumn(task);
if (reviewColumn) return reviewColumn;
if (task.status === 'pending') return 'todo';
if (task.status === 'completed') return 'done';
@ -159,7 +162,7 @@ export const TaskTooltip = memo(function TaskTooltip({
>
{label}
</span>
{task.reviewState === 'needsFix' ? (
{isTeamTaskNeedsFixActionable(task) ? (
<span
className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>

View file

@ -1243,6 +1243,7 @@ export const TeamDetailView = memo(function TeamDetailView({
restoreTask,
fetchDeletedTasks,
deletedTasks,
activeTaskLogActivity,
launchParams,
messagesPanelMode,
messagesPanelWidth,
@ -1299,6 +1300,7 @@ export const TeamDetailView = memo(function TeamDetailView({
restoreTask: s.restoreTask,
fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks,
activeTaskLogActivity: teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
messagesPanelMode: s.messagesPanelMode,
messagesPanelWidth: s.messagesPanelWidth,
@ -2554,6 +2556,7 @@ export const TeamDetailView = memo(function TeamDetailView({
sessions={teamSessions}
leadSessionId={data.config.leadSessionId}
members={activeMembers}
activeTaskLogActivity={activeTaskLogActivity}
forceShowAllTasks={isKanbanSearchActive}
onFilterChange={setKanbanFilter}
onSortChange={setKanbanSort}

View file

@ -10,7 +10,9 @@ import {
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { ChevronRight } from 'lucide-react';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -27,7 +29,7 @@ interface ActiveTasksBlockProps {
interface ActivityEntry {
member: ResolvedTeamMember;
task: TeamTaskWithKanban | undefined;
task: TeamTaskWithKanban;
taskId: string;
kind: 'working' | 'reviewing';
}
@ -53,8 +55,8 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
for (const m of members) {
if (!m.currentTaskId) continue;
const task = taskMap.get(m.currentTaskId);
// Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale
if (task && (task.reviewState === 'approved' || task.status === 'completed')) continue;
// Defense-in-depth: hide stale currentTaskId until backend refresh clears it.
if (!isDisplayableCurrentTask(task)) continue;
workingMemberNames.add(m.name);
entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' });
}
@ -63,7 +65,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
for (const m of members) {
if (workingMemberNames.has(m.name)) continue;
const reviewTask = tasks.find(
(t) => t.reviewer === m.name && (t.reviewState === 'review' || t.kanbanColumn === 'review')
(t) => t.reviewer === m.name && getTeamTaskWorkflowColumn(t) === 'review'
);
if (reviewTask) {
entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' });

View file

@ -63,6 +63,7 @@ import {
getKnownSlashCommand,
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
AlertTriangle,
@ -379,6 +380,68 @@ const PassiveIdlePeerSummaryRow = ({
);
};
const TaskStallRemediationRow = ({
teamName,
recipientName,
recipientColor,
taskRef,
timestamp,
onMemberNameClick,
onTaskIdClick,
}: {
teamName: string;
recipientName: string;
recipientColor?: string;
taskRef?: NonNullable<InboxMessage['taskRefs']>[number];
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
const taskLabel = taskRef
? formatTaskDisplayLabel({ id: taskRef.taskId, displayId: taskRef.displayId })
: null;
return (
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
<span className="bg-amber-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
automation
</span>
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
stall nudge
</span>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
Asked teammate to continue stalled task
{taskRef && taskLabel ? (
<>
{' '}
<button
type="button"
className="font-medium text-blue-300 hover:text-blue-200"
onClick={(event) => {
event.stopPropagation();
onTaskIdClick?.(taskRef.taskId);
}}
>
{taskLabel}
</button>
</>
) : null}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
};
const BootstrapSystemRow = ({
teamName,
eventKind,
@ -926,6 +989,20 @@ export const ActivityItem = memo(
);
}
if (isTaskStallRemediationMessage(message)) {
return (
<TaskStallRemediationRow
teamName={teamName}
recipientName={message.to ?? 'teammate'}
recipientColor={recipientColor}
taskRef={message.taskRefs?.[0]}
timestamp={timestamp}
onMemberNameClick={onMemberNameClick}
onTaskIdClick={onTaskIdClick}
/>
);
}
if (bootstrapDisplay) {
return (
<BootstrapSystemRow

View file

@ -37,6 +37,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
@ -73,6 +74,7 @@ export function isLeadThought(msg: InboxMessage): boolean {
if (msg.messageKind === 'slash_command_result') return false;
// Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden
if (isThoughtProtocolNoise(msg.text)) return false;
if (isTeamInternalControlMessageText(msg.text)) return false;
if (msg.source === 'lead_session') return true;
if (msg.source === 'lead_process') return true;
return false;
@ -90,7 +92,7 @@ export function isLeadThought(msg: InboxMessage): boolean {
function isLeadSessionNoise(msg: InboxMessage): boolean {
if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false;
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
return isThoughtProtocolNoise(msg.text);
return isThoughtProtocolNoise(msg.text) || isTeamInternalControlMessageText(msg.text);
}
export type TimelineItem =

View file

@ -28,7 +28,7 @@ import {
extractTaskRefsFromText,
stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
@ -153,7 +153,7 @@ export const CreateTaskDialog = ({
// Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter(
(t) => t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved'
(t) => t.status !== 'deleted' && getTeamTaskWorkflowColumn(t) !== 'approved'
);
const toggleBlockedBy = (taskId: string): void => {

View file

@ -6,7 +6,7 @@ import {
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck } from 'lucide-react';
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react';
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
@ -107,6 +107,52 @@ const EventContent = ({
<StatusBadge status={event.to} />
</span>
);
case 'owner_changed':
return (
<span className="flex items-center gap-1">
<UserRound size={10} className="text-cyan-400" />
{event.from && event.to ? (
<>
Reassigned
<MemberBadge
name={event.from}
color={memberColorMap?.get(event.from)}
size="sm"
hideAvatar
/>
<ArrowRight size={10} className="text-[var(--color-text-muted)]" />
<MemberBadge
name={event.to}
color={memberColorMap?.get(event.to)}
size="sm"
hideAvatar
/>
</>
) : event.to ? (
<>
Assigned to
<MemberBadge
name={event.to}
color={memberColorMap?.get(event.to)}
size="sm"
hideAvatar
/>
</>
) : event.from ? (
<>
Unassigned from
<MemberBadge
name={event.from}
color={memberColorMap?.get(event.from)}
size="sm"
hideAvatar
/>
</>
) : (
'Owner changed'
)}
</span>
);
case 'review_requested':
return (
<span className="flex items-center gap-1">
@ -181,6 +227,8 @@ function dotColor(event: TaskHistoryEvent): string {
return dotColorForStatus(event.status);
case 'status_changed':
return dotColorForStatus(event.to);
case 'owner_changed':
return 'bg-cyan-400';
case 'review_requested':
return 'bg-purple-400';
case 'review_started':

View file

@ -54,7 +54,11 @@ import {
} from '@renderer/utils/taskChangeRequest';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import {
deriveTaskDisplayId,
formatTaskDisplayLabel,
@ -598,12 +602,10 @@ export const TaskDetailDialog = ({
);
}
const kanbanColumn =
kanbanTaskState?.column ??
getTaskKanbanColumn({
reviewState: currentTask.reviewState,
kanbanColumn: currentTask.kanbanColumn,
});
const kanbanColumn = getTeamTaskWorkflowColumn({
...currentTask,
...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
});
const status = currentTask.status;
const statusStyle =
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
@ -659,13 +661,13 @@ export const TaskDetailDialog = ({
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
{formatTaskDisplayLabel(currentTask)}
</Badge>
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
{(kanbanColumn === 'approved' || kanbanColumn === 'review') &&
currentTask.reviewer &&
currentTask.reviewer !== 'user' ? (
(() => {
const reviewerColor = colorMap.get(currentTask.reviewer);
const colors =
currentTask.reviewState === 'review'
kanbanColumn === 'review'
? getTeamColorSet('blue')
: getTeamColorSet(reviewerColor ?? '');
const reviewerBadgeStyle = {
@ -677,7 +679,7 @@ export const TaskDetailDialog = ({
};
const lastReviewEvent = currentTask.historyEvents
?.filter((e) =>
currentTask.reviewState === 'approved'
kanbanColumn === 'approved'
? e.type === 'review_approved'
: e.type === 'review_requested' || e.type === 'review_started'
)
@ -731,7 +733,7 @@ export const TaskDetailDialog = ({
{statusLabel}
</span>
)}
{currentTask.reviewState === 'needsFix' ? (
{isTeamTaskNeedsFixActionable(currentTask) ? (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
@ -941,7 +943,9 @@ export const TaskDetailDialog = ({
</span>
{blockedByIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
const isCompleted = depTask
? isTeamTaskFinishedForDependency(depTask)
: false;
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
@ -977,7 +981,9 @@ export const TaskDetailDialog = ({
</span>
{blocksIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
const isCompleted = depTask
? isTeamTaskFinishedForDependency(depTask)
: false;
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;

View file

@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
import { cn } from '@renderer/lib/utils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
import {
CheckCircle2,
ChevronDown,
@ -78,6 +79,7 @@ interface KanbanBoardProps {
sessions: Session[];
leadSessionId?: string;
members: ResolvedTeamMember[];
activeTaskLogActivity?: Record<string, true>;
/** Shows all cards when another UI flow, such as search, must not hide matches. */
forceShowAllTasks?: boolean;
onFilterChange: (filter: KanbanFilterState) => void;
@ -160,7 +162,7 @@ function estimateGridSkeletonCardHeight(
if (task.subject.length > 54) height += 10;
if (task.subject.length > 92) height += 8;
if (task.needsClarification) height += 16;
if (task.reviewState === 'needsFix') height += 14;
if (isTeamTaskNeedsFixActionable(task)) height += 14;
if ((task.blockedBy?.length ?? 0) > 0) height += 18;
if ((task.blocks?.length ?? 0) > 0) height += 18;
@ -244,6 +246,7 @@ interface SortableKanbanTaskCardProps {
compact?: boolean;
taskMap: Map<string, TeamTask>;
memberColorMap: Map<string, string>;
hasLiveTaskLogs?: boolean;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -265,6 +268,7 @@ const SortableKanbanTaskCard = ({
compact,
taskMap,
memberColorMap,
hasLiveTaskLogs,
onRequestReview,
onApprove,
onRequestChanges,
@ -300,6 +304,7 @@ const SortableKanbanTaskCard = ({
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
hasLiveTaskLogs={hasLiveTaskLogs}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -325,6 +330,7 @@ export const KanbanBoard = memo(function KanbanBoard({
sessions,
leadSessionId,
members,
activeTaskLogActivity,
forceShowAllTasks = false,
onFilterChange,
onSortChange,
@ -578,6 +584,7 @@ export const KanbanBoard = memo(function KanbanBoard({
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -610,6 +617,7 @@ export const KanbanBoard = memo(function KanbanBoard({
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -630,6 +638,7 @@ export const KanbanBoard = memo(function KanbanBoard({
},
[
enableTaskSorting,
activeTaskLogActivity,
handleScrollToTask,
hasReviewers,
kanbanState,

View file

@ -81,6 +81,41 @@ const baseTask: TeamTaskWithKanban = {
const noop = (): void => undefined;
async function renderTaskCard(
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: baseTask,
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
...props,
})
);
await Promise.resolve();
});
return { host, root };
}
describe('KanbanTaskCard change badge', () => {
afterEach(() => {
document.body.innerHTML = '';
@ -197,3 +232,88 @@ describe('KanbanTaskCard change badge', () => {
});
});
});
describe('KanbanTaskCard blocked border', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('highlights blocked tasks outside final columns', async () => {
const { host, root } = await renderTaskCard({
task: { ...baseTask, blockedBy: ['task-2'] },
columnId: 'in_progress',
});
const card = host.querySelector('[data-task-id="task-1"]');
expect(card?.className).toContain('kanban-task-card');
expect(card?.className).toContain('border-yellow-500/30');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it.each(['done', 'approved'] as const)(
'does not highlight blocked tasks in %s',
async (columnId) => {
const { host, root } = await renderTaskCard({
task: { ...baseTask, blockedBy: ['task-2'] },
columnId,
});
const card = host.querySelector('[data-task-id="task-1"]');
expect(card?.className).not.toContain('border-yellow-500/30');
expect(card?.className).toContain('border-[var(--color-border)]');
expect(host.textContent).toContain('Blocked by');
await act(async () => {
root.unmount();
await Promise.resolve();
});
}
);
});
describe('KanbanTaskCard live log indicator', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('shows the live log indicator only when task log activity is active', async () => {
const { host, root } = await renderTaskCard({ hasLiveTaskLogs: true });
expect(host.querySelector('[aria-label="Task logs active"]')).not.toBeNull();
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: baseTask,
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
hasLiveTaskLogs: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Task logs active"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -1,5 +1,6 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Button } from '@renderer/components/ui/button';
@ -12,6 +13,10 @@ import {
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import {
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
ArrowLeftFromLine,
@ -38,6 +43,7 @@ interface KanbanTaskCardProps {
compact?: boolean;
taskMap: Map<string, TeamTask>;
memberColorMap: Map<string, string>;
hasLiveTaskLogs?: boolean;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -63,7 +69,7 @@ const DependencyBadge = ({
onScrollToTask,
}: DependencyBadgeProps): React.JSX.Element => {
const depTask = taskMap.get(taskId);
const isCompleted = depTask?.status === 'completed';
const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false;
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(taskId)}`;
@ -227,6 +233,7 @@ export const KanbanTaskCard = memo(
compact,
taskMap,
memberColorMap,
hasLiveTaskLogs = false,
onRequestReview,
onApprove,
onRequestChanges,
@ -245,6 +252,7 @@ export const KanbanTaskCard = memo(
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const hasBlockedBy = blockedByIds.length > 0;
const hasBlocks = blocksIds.length > 0;
const shouldHighlightBlocked = hasBlockedBy && columnId !== 'done' && columnId !== 'approved';
const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]';
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
@ -288,8 +296,8 @@ export const KanbanTaskCard = memo(
return (
<div
data-task-id={task.id}
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
hasBlockedBy
className={`kanban-task-card relative cursor-pointer rounded-md border px-1.5 py-3 hover:border-[var(--color-border-emphasis)] ${
shouldHighlightBlocked
? `border-yellow-500/30 ${cardSurfaceClass}`
: `border-[var(--color-border)] ${cardSurfaceClass}`
}`}
@ -303,8 +311,13 @@ export const KanbanTaskCard = memo(
}
}}
>
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
{formatTaskDisplayLabel(task)}
<span className="absolute left-[3px] top-[2px] flex max-w-[calc(100%-72px)] items-center gap-1 text-[9px] leading-none text-[var(--color-text-muted)]">
<span className="truncate">{formatTaskDisplayLabel(task)}</span>
{hasLiveTaskLogs ? (
<span aria-label="Task logs active" className="inline-flex">
<OngoingIndicator size="sm" title="New task logs arriving" />
</span>
) : null}
</span>
{task.owner ? (
<span className="absolute right-[6px] top-[2px]">
@ -325,7 +338,7 @@ export const KanbanTaskCard = memo(
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span>
) : null}
{task.reviewState === 'needsFix' ? (
{isTeamTaskNeedsFixActionable(task) ? (
<span
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
@ -490,6 +503,7 @@ export const KanbanTaskCard = memo(
prev.compact === next.compact &&
prev.taskMap === next.taskMap &&
prev.memberColorMap === next.memberColorMap &&
prev.hasLiveTaskLogs === next.hasLiveTaskLogs &&
prev.onRequestReview === next.onRequestReview &&
prev.onApprove === next.onApprove &&
prev.onRequestChanges === next.onRequestChanges &&

View file

@ -163,8 +163,15 @@ export const MemberCard = memo(function MemberCard({
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const presentationMember =
member.currentTaskId && !currentTask
? {
...member,
currentTaskId: null,
}
: member;
const launchPresentation = buildMemberLaunchPresentation({
member,
member: presentationMember,
spawnStatus,
spawnLaunchState,
spawnLivenessSource,

View file

@ -18,7 +18,9 @@ import {
getRuntimeMemorySourceLabel,
resolveMemberRuntimeSummary,
} from '@renderer/utils/memberRuntimeSummary';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState';
import {
BarChart3,
FileText,
@ -155,12 +157,22 @@ export const MemberDetailDialog = ({
}, [member, memberMessages, members, tasks, teamName]);
const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length,
() => memberTasks.filter(isDisplayableCurrentTask).length,
[memberTasks]
);
const currentTaskCandidate = useMemo(
() =>
member?.currentTaskId
? (tasks.find((task) => task.id === member.currentTaskId) ?? null)
: null,
[member?.currentTaskId, tasks]
);
const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate)
? currentTaskCandidate
: null;
const completedTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'completed').length,
() => memberTasks.filter(isTeamTaskFinishedForDependency).length,
[memberTasks]
);
@ -255,7 +267,11 @@ export const MemberDetailDialog = ({
<div className="flex items-start gap-4">
<DialogHeader className="shrink-0">
<MemberDetailHeader
member={member}
member={
member.currentTaskId && !displayableCurrentTask
? { ...member, currentTaskId: null }
: member
}
runtimeSummary={runtimeSummary}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}

View file

@ -24,6 +24,7 @@ import {
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import {
buildMemberLaunchDiagnosticsPayload,
getMemberLaunchDiagnosticsErrorMessage,
@ -31,6 +32,7 @@ import {
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -126,8 +128,19 @@ export const MemberHoverCard = memo(function MemberHoverCard({
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
const colors = getTeamColorSet(color ?? member.color ?? '');
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null;
const presentationMember =
member.currentTaskId && !currentTask
? {
...member,
currentTaskId: null,
}
: member;
const launchPresentation = buildMemberLaunchPresentation({
member,
member: presentationMember,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource,
@ -171,15 +184,12 @@ export const MemberHoverCard = memo(function MemberHoverCard({
const showCopyDiagnostics =
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const reviewTask: TeamTaskWithKanban | null = tasks
? (tasks.find(
(task) =>
task.reviewer === member.name &&
task.id !== member.currentTaskId &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
task.id !== currentTask?.id &&
getTeamTaskWorkflowColumn(task) === 'review'
) ?? null)
: null;

View file

@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { MemberCard } from './MemberCard';
@ -425,7 +427,7 @@ export const MemberList = memo(function MemberList({
const result = new Map<string, TeamTaskWithKanban>();
if (!taskMap) return result;
for (const task of taskMap.values()) {
if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) {
if (task.reviewer && getTeamTaskWorkflowColumn(task) === 'review') {
result.set(task.reviewer, task);
}
}
@ -455,11 +457,14 @@ export const MemberList = memo(function MemberList({
<div ref={containerRef} className="flex flex-col gap-1">
<div className={gridClass}>
{activeMembers.map((member) => {
const currentTask =
const currentTaskCandidate =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
? currentTaskCandidate
: null;
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
const reviewTask =
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
return (

View file

@ -7,7 +7,10 @@ import {
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types';
@ -44,7 +47,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
<div className="max-h-[320px] overflow-y-auto">
<div className="flex flex-col gap-1">
{visibleTasks.map((task) => {
const col = getTaskKanbanColumn(task);
const col = getTeamTaskWorkflowColumn(task);
const style =
col && KANBAN_COLUMN_DISPLAY[col]
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
@ -71,7 +74,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
>
{label}
</span>
{task.reviewState === 'needsFix' ? (
{isTeamTaskNeedsFixActionable(task) ? (
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>

View file

@ -23,6 +23,7 @@ import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import { isLeadMember } from '@shared/utils/leadDetection';
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
import {
CheckCheck,
ChevronsDownUp,
@ -587,6 +588,7 @@ export const MessagesPanel = memo(function MessagesPanel({
const activityTimelineMessages = useMemo(() => {
return filterTeamMessages(effectiveMessages, {
includeAutomationEvents: true,
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
leadNames,
timeWindow,
@ -600,6 +602,7 @@ export const MessagesPanel = memo(function MessagesPanel({
effectiveMessages.filter(
(m) =>
m.messageKind !== 'task_comment_notification' &&
!isTaskStallRemediationMessage(m) &&
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
),
[effectiveMessages]

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { ChevronRight } from 'lucide-react';
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
@ -55,8 +56,7 @@ export const StatusBlock = ({
return members.some((m) => {
if (!m.currentTaskId) return false;
const task = tMap.get(m.currentTaskId);
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
return true;
return isDisplayableCurrentTask(task);
});
}, [members, tasks]);

View file

@ -14,6 +14,7 @@ import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
@ -375,7 +376,7 @@ export const TaskLogStreamSection = ({
}
const shouldReload =
event.type === 'log-source-change' ||
(event.type === 'task-log-change' && event.taskId === taskId);
(isTaskLogActivityChangeEvent(event) && event.taskId === taskId);
if (!shouldReload) {
return;
}

View file

@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
@ -64,9 +66,10 @@ export const TaskLogsPanel = ({
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countRequestSeqRef = useRef(0);
const taskLogTrackingEnabled =
hasOpenedContent && task.status === 'in_progress' && availableTabs.includes('stream');
const taskLogSummaryEnabled = hasOpenedContent && availableTabs.includes('stream');
const hasTaskLogStream = availableTabs.includes('stream');
const taskIsActivelyWorked = isDisplayableCurrentTask(task);
const taskLogActivityTrackingEnabled = taskIsActivelyWorked && hasTaskLogStream;
const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream;
useEffect(() => {
setActiveTab(defaultTab);
@ -133,7 +136,7 @@ export const TaskLogsPanel = ({
}, [task.id, taskLogSummaryEnabled, teamName]);
useEffect(() => {
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
if (!taskLogActivityTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
return;
}
@ -143,10 +146,10 @@ export const TaskLogsPanel = ({
() => undefined
);
};
}, [taskLogTrackingEnabled, teamName]);
}, [taskLogActivityTrackingEnabled, teamName]);
useEffect(() => {
if (!taskLogTrackingEnabled) {
if (!taskLogActivityTrackingEnabled) {
if (pulseTimerRef.current) {
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = null;
@ -160,7 +163,7 @@ export const TaskLogsPanel = ({
}
const scheduleCountReload = (): void => {
if (!api.teams.getTaskLogStreamSummary) {
if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) {
return;
}
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
@ -187,7 +190,7 @@ export const TaskLogsPanel = ({
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if (
event.teamName !== teamName ||
event.type !== 'task-log-change' ||
!isTaskLogActivityChangeEvent(event) ||
event.taskId !== task.id
) {
return;
@ -230,7 +233,7 @@ export const TaskLogsPanel = ({
unsubscribe();
}
};
}, [task.id, taskLogTrackingEnabled, teamName]);
}, [task.id, taskLogActivityTrackingEnabled, taskLogSummaryEnabled, teamName]);
return (
<Tabs
@ -260,7 +263,7 @@ export const TaskLogsPanel = ({
teamName={teamName}
taskId={task.id}
taskStatus={task.status}
liveEnabled={isOpen && task.status === 'in_progress'}
liveEnabled={isOpen && taskIsActivelyWorked}
/>
</TabsContent>
) : null}

View file

@ -5,7 +5,10 @@ import {
REVIEW_STATE_DISPLAY,
TASK_STATUS_LABELS,
} from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types';
@ -17,7 +20,7 @@ interface TaskRowProps {
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task);
const kanbanColumn = getTeamTaskWorkflowColumn(task);
return (
<tr className="border-t border-[var(--color-border)]">
@ -35,7 +38,7 @@ export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
</span>
{task.reviewState === 'needsFix' ? (
{isTeamTaskNeedsFixActionable(task) ? (
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>

View file

@ -20,6 +20,9 @@
/* Subtle borders */
--color-border-emphasis: rgba(148, 163, 184, 0.12);
/* Emphasis borders */
--kanban-task-card-hover-shadow:
0 0 0 1px rgba(129, 140, 248, 0.28), 0 10px 30px rgba(37, 99, 235, 0.24),
0 0 22px rgba(129, 140, 248, 0.16);
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
@ -269,6 +272,19 @@
overflow: visible;
}
.kanban-task-card {
box-shadow: none;
transition:
border-color 140ms ease,
box-shadow 140ms ease,
background-color 140ms ease;
}
.kanban-task-card:hover,
.kanban-task-card:focus-visible {
box-shadow: var(--kanban-task-card-hover-shadow);
}
.kanban-grid-item-wrapper {
height: 100%;
}
@ -466,6 +482,9 @@
/* Warm subtle border */
--color-border-emphasis: #a8a5a0;
/* Warm emphasis border */
--kanban-task-card-hover-shadow:
0 0 0 1px rgba(37, 99, 235, 0.28), 0 10px 26px rgba(37, 99, 235, 0.18),
0 0 18px rgba(79, 70, 229, 0.12);
--color-text: #1c1b19;
/* Warm near-black text */
--color-text-secondary: #4d4b46;

View file

@ -12,6 +12,8 @@ import {
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { createLogger } from '@shared/utils/logger';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import { create } from 'zustand';
@ -87,6 +89,7 @@ const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
@ -100,6 +103,7 @@ const RELEVANT_TEAM_CHANGE_EVENT_TYPES = new Set<TeamChangeEvent['type']>([
'lead-message',
'lead-context',
'lead-activity',
'member-advisory',
'process',
'member-spawn',
]);
@ -268,9 +272,11 @@ export function initializeNotificationListeners(): () => void {
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberAdvisorySafetyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let taskLogActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let processLiteStructuralReconcileTimers = new Map<
string,
{ firstScheduledAt: number; timer: ReturnType<typeof setTimeout> }
@ -545,6 +551,71 @@ export function initializeNotificationListeners(): () => void {
toolActivityTimers.delete(key);
}
};
const buildTaskLogActivityTimerKey = (teamName: string, taskId: string): string =>
`${teamName}\u0000${taskId}`;
const clearTaskLogActivityTimer = (teamName: string, taskId: string): void => {
const key = buildTaskLogActivityTimerKey(teamName, taskId);
const existing = taskLogActivityTimers.get(key);
if (existing) {
clearTimeout(existing);
taskLogActivityTimers.delete(key);
}
};
const clearTaskLogActivityTimersForTeam = (teamName: string): void => {
const prefix = `${teamName}\u0000`;
for (const [key, timer] of taskLogActivityTimers.entries()) {
if (!key.startsWith(prefix)) continue;
clearTimeout(timer);
taskLogActivityTimers.delete(key);
}
};
const clearTaskLogActivityStateForTeam = (teamName: string): void => {
clearTaskLogActivityTimersForTeam(teamName);
useStore.setState((prev) => {
if (!(teamName in prev.activeTaskLogActivityByTeam)) {
return {};
}
const next = { ...prev.activeTaskLogActivityByTeam };
delete next[teamName];
return { activeTaskLogActivityByTeam: next };
});
};
const markTaskLogActivity = (teamName: string, taskId: string): void => {
clearTaskLogActivityTimer(teamName, taskId);
const isAlreadyActive =
useStore.getState().activeTaskLogActivityByTeam[teamName]?.[taskId] === true;
if (!isAlreadyActive) {
useStore.setState((prev) => ({
activeTaskLogActivityByTeam: {
...prev.activeTaskLogActivityByTeam,
[teamName]: {
...(prev.activeTaskLogActivityByTeam[teamName] ?? {}),
[taskId]: true,
},
},
}));
}
const timerKey = buildTaskLogActivityTimerKey(teamName, taskId);
const timer = setTimeout(() => {
taskLogActivityTimers.delete(timerKey);
useStore.setState((prev) => {
const teamActivity = prev.activeTaskLogActivityByTeam[teamName];
if (!teamActivity?.[taskId]) {
return {};
}
const nextTeamActivity = { ...teamActivity };
delete nextTeamActivity[taskId];
const nextByTeam = { ...prev.activeTaskLogActivityByTeam };
if (Object.keys(nextTeamActivity).length === 0) {
delete nextByTeam[teamName];
} else {
nextByTeam[teamName] = nextTeamActivity;
}
return { activeTaskLogActivityByTeam: nextByTeam };
});
}, TASK_LOG_ACTIVITY_PULSE_MS);
taskLogActivityTimers.set(timerKey, timer);
};
const clearRuntimeToolStateForTeam = (
prev: AppState,
teamName: string
@ -666,7 +737,7 @@ export function initializeNotificationListeners(): () => void {
}
const candidateTasks = teamData.tasks.filter((task) => {
if (task.status !== 'in_progress') {
if (!isDisplayableCurrentTask(task)) {
return false;
}
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
@ -700,7 +771,7 @@ export function initializeNotificationListeners(): () => void {
}
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
if (currentTask?.status !== 'in_progress') {
if (!isDisplayableCurrentTask(currentTask)) {
continue;
}
@ -858,6 +929,10 @@ export function initializeNotificationListeners(): () => void {
return getVisibleTeamNamesInAnyPane();
};
const getTrackedTaskLogActivityTeams = (): Set<string> => {
return getVisibleTeamNamesInAnyPane();
};
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
teamLastRelevantActivityAt.set(teamName, timestamp);
};
@ -1218,6 +1293,46 @@ export function initializeNotificationListeners(): () => void {
});
}
if (api.teams?.setTaskLogStreamTracking) {
let trackedTeamNames = new Set<string>();
const syncVisibleTeamTracking = (): void => {
const nextTrackedTeamNames = getTrackedTaskLogActivityTeams();
for (const teamName of nextTrackedTeamNames) {
if (!trackedTeamNames.has(teamName)) {
void api.teams.setTaskLogStreamTracking(teamName, true).catch(() => undefined);
}
}
for (const teamName of trackedTeamNames) {
if (!nextTrackedTeamNames.has(teamName)) {
void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined);
clearTaskLogActivityStateForTeam(teamName);
}
}
trackedTeamNames = nextTrackedTeamNames;
};
syncVisibleTeamTracking();
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
if (state.paneLayout === prevState.paneLayout) {
return;
}
syncVisibleTeamTracking();
});
cleanupFns.push(() => {
unsubscribeVisibleTeamTracking();
for (const teamName of trackedTeamNames) {
void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined);
clearTaskLogActivityStateForTeam(teamName);
}
trackedTeamNames.clear();
});
}
// Listen for task-list file changes to refresh currently viewed session metadata
if (api.onTodoChange) {
const cleanup = api.onTodoChange((event) => {
@ -1420,6 +1535,8 @@ export function initializeNotificationListeners(): () => void {
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
delete nextState.leadContextByTeam[event.teamName];
Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName));
nextState.activeTaskLogActivityByTeam = { ...prev.activeTaskLogActivityByTeam };
delete nextState.activeTaskLogActivityByTeam[event.teamName];
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
nextState.ignoredRuntimeRunIds = event.runId
@ -1429,6 +1546,7 @@ export function initializeNotificationListeners(): () => void {
}
: prev.ignoredRuntimeRunIds;
clearToolActivityTimersForTeam(event.teamName);
clearTaskLogActivityTimersForTeam(event.teamName);
}
return nextState as typeof prev;
@ -1583,6 +1701,59 @@ export function initializeNotificationListeners(): () => void {
return;
}
if (event.type === 'task-log-change') {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
const visible = isTeamVisibleInAnyPane(event.teamName);
if (event.taskId && visible) {
const isLogActivitySignal = isTaskLogActivityChangeEvent(event);
if (isLogActivitySignal) {
markTaskLogActivity(event.teamName, event.taskId);
}
if (event.taskSignalKind === 'log') {
return;
}
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: existingDetailTimer ? 'coalesced' : 'scheduled',
reason: 'event:task-log-change:task-state-safety',
operation: 'refreshTeamData',
eventType: event.type,
selected: useStore.getState().selectedTeamName === event.teamName,
visible,
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
if (!existingDetailTimer) {
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
const visibleAtExecution = isTeamVisibleInAnyPane(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: visibleAtExecution ? 'executed' : 'skipped',
reason: 'event:task-log-change:task-state-safety',
operation: 'refreshTeamData',
eventType: event.type,
selected: current.selectedTeamName === event.teamName,
visible: visibleAtExecution,
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
if (!visibleAtExecution) {
return;
}
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
}
}
return;
}
// Member spawn status change: fetch updated spawn statuses for the team.
if (event.type === 'member-spawn') {
if (isStaleRuntimeEvent) {
@ -1610,6 +1781,75 @@ export function initializeNotificationListeners(): () => void {
return;
}
if (event.type === 'member-advisory') {
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
cancelProcessLiteStructuralReconcile(event.teamName);
const eventReason = buildTeamChangeFanoutReason(event.type);
const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName;
const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName;
const existingSafetyTimer = memberAdvisorySafetyRefreshTimers.get(event.teamName);
if (existingSafetyTimer) {
clearTimeout(existingSafetyTimer);
}
memberAdvisorySafetyRefreshTimers.set(
event.teamName,
setTimeout(() => {
memberAdvisorySafetyRefreshTimers.delete(event.teamName);
if (!isTeamVisibleInAnyPane(event.teamName)) {
return;
}
const current = useStore.getState();
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: `${eventReason}:safety`,
operation: 'refreshTeamData',
eventType: event.type,
selected: current.selectedTeamName === event.teamName,
visible: true,
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
void current.refreshTeamData(event.teamName);
}, TEAM_REFRESH_THROTTLE_MS + 250)
);
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: existingDetailTimer ? 'coalesced' : 'scheduled',
reason: eventReason,
operation: 'refreshTeamData',
eventType: event.type,
selected: selectedForRefresh,
visible: true,
activeTab: activeTabForRefresh,
});
if (existingDetailTimer) {
return;
}
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: eventReason,
operation: 'refreshTeamData',
eventType: event.type,
selected: current.selectedTeamName === event.teamName,
visible: isTeamVisibleInAnyPane(event.teamName),
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
return;
}
if (event.type === 'log-source-change') {
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
@ -1791,12 +2031,16 @@ export function initializeNotificationListeners(): () => void {
teamMessageRefreshTimers = new Map();
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
teamPresenceRefreshTimers = new Map();
for (const t of memberAdvisorySafetyRefreshTimers.values()) clearTimeout(t);
memberAdvisorySafetyRefreshTimers = new Map();
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
memberSpawnRefreshTimers = new Map();
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
teamAgentRuntimeRefreshTimers = new Map();
for (const t of toolActivityTimers.values()) clearTimeout(t);
toolActivityTimers = new Map();
for (const t of taskLogActivityTimers.values()) clearTimeout(t);
taskLogActivityTimers = new Map();
for (const state of processLiteStructuralReconcileTimers.values()) {
clearTimeout(state.timer);
}

View file

@ -13,12 +13,15 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
@ -328,6 +331,7 @@ function collectTeamScopedStateRemovals(
| 'provisioningStartedAtFloorByTeam'
| 'leadActivityByTeam'
| 'leadContextByTeam'
| 'activeTaskLogActivityByTeam'
| 'activeToolsByTeam'
| 'finishedVisibleByTeam'
| 'toolHistoryByTeam'
@ -353,6 +357,7 @@ function collectTeamScopedStateRemovals(
);
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName);
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
@ -378,6 +383,9 @@ function collectTeamScopedStateRemovals(
: {}),
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
...(nextActiveTaskLogActivity
? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity }
: {}),
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
@ -1382,11 +1390,12 @@ function detectStatusChangeNotifications(
if (!oldTask) continue;
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
const taskKanbanColumn = getTaskKanbanColumn(task);
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix';
const becameNeedsFix =
isTeamTaskNeedsFixActionable(task) && !isTeamTaskNeedsFixActionable(oldTask);
const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
@ -1681,7 +1690,7 @@ function detectAllTasksCompletedNotification(
for (const [teamName, tasks] of teamTasks) {
if (tasks.length === 0) continue;
const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted');
const allCompleted = tasks.every(isTeamTaskFinalForCompletionNotification);
if (!allCompleted) {
// Reset so we can notify again if tasks become all-completed later
notifiedAllCompletedTeams.delete(teamName);
@ -1692,8 +1701,7 @@ function detectAllTasksCompletedNotification(
// Check that at least one task was NOT completed before (real transition)
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
const wasAlreadyAllCompleted =
oldTeamTasks.length > 0 &&
oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted');
oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification);
if (wasAlreadyAllCompleted) {
notifiedAllCompletedTeams.add(teamName);
continue;
@ -2385,6 +2393,7 @@ export interface TeamSlice {
provisioningStartedAtFloorByTeam: Record<string, string>;
leadActivityByTeam: Record<string, LeadActivityState>;
leadContextByTeam: Record<string, LeadContextUsage>;
activeTaskLogActivityByTeam: Record<string, Record<string, true>>;
activeToolsByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
finishedVisibleByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
toolHistoryByTeam: Record<string, Record<string, ActiveToolCall[]>>;
@ -2727,6 +2736,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
provisioningStartedAtFloorByTeam: {},
leadActivityByTeam: {},
leadContextByTeam: {},
activeTaskLogActivityByTeam: {},
activeToolsByTeam: {},
finishedVisibleByTeam: {},
toolHistoryByTeam: {},
@ -3038,13 +3048,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
);
}
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (task.reviewState === 'needsFix') {
if (isTeamTaskNeedsFixActionable(task)) {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
}
if (getTaskKanbanColumn(task) === 'approved') {
if (getTeamTaskWorkflowColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
}
if (getTaskKanbanColumn(task) === 'review') {
if (getTeamTaskWorkflowColumn(task) === 'review') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
}
// Seed comment keys to prevent false notifications
@ -3062,7 +3072,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teamTasksMap.set(task.teamName, list);
}
for (const [teamName, teamTasks] of teamTasksMap) {
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
if (teamTasks.every(isTeamTaskFinalForCompletionNotification)) {
notifiedAllCompletedTeams.add(teamName);
}
}

View file

@ -4,6 +4,10 @@ import {
getTeamModelLabel,
getTeamProviderLabel,
} from '@renderer/utils/teamModelCatalog';
import {
isNativeAppManagedBootstrapCheckText,
isTeamInternalControlMessageEnvelope,
} from '@shared/utils/teamInternalControlMessages';
import type { InboxMessage, TeamProviderId } from '@shared/types';
@ -125,6 +129,29 @@ export interface BootstrapAcknowledgementDisplay {
body: string;
}
export interface InternalControlMessageDisplay {
summary: string;
body: string;
}
export function getInternalControlMessageDisplay(
message: Pick<InboxMessage, 'text'> & Partial<Pick<InboxMessage, 'from' | 'source'>>
): InternalControlMessageDisplay | null {
if (!isTeamInternalControlMessageEnvelope(message)) {
return null;
}
if (isNativeAppManagedBootstrapCheckText(message.text)) {
return {
summary: 'Internal bootstrap check',
body: 'Internal bootstrap check hidden in the UI.',
};
}
return {
summary: 'Internal control message',
body: 'Internal control message hidden in the UI.',
};
}
export function getBootstrapPromptDisplay(
message: Pick<InboxMessage, 'text' | 'to'>
): BootstrapPromptDisplay | null {
@ -209,8 +236,11 @@ export function getBootstrapAcknowledgementDisplay(
};
}
export function getSanitizedInboxMessageText(message: Pick<InboxMessage, 'text' | 'to'>): string {
export function getSanitizedInboxMessageText(
message: Pick<InboxMessage, 'text' | 'to'> & Partial<Pick<InboxMessage, 'from' | 'source'>>
): string {
return (
getInternalControlMessageDisplay(message)?.body ??
getBootstrapPromptDisplay(message)?.body ??
getBootstrapAcknowledgementDisplay(message as Pick<InboxMessage, 'text' | 'from'>)?.body ??
message.text ??
@ -219,9 +249,11 @@ export function getSanitizedInboxMessageText(message: Pick<InboxMessage, 'text'
}
export function getSanitizedInboxMessageSummary(
message: Pick<InboxMessage, 'text' | 'to' | 'from' | 'summary'>
message: Pick<InboxMessage, 'text' | 'to' | 'from' | 'summary'> &
Partial<Pick<InboxMessage, 'source'>>
): string {
return (
getInternalControlMessageDisplay(message)?.summary ??
getBootstrapPromptDisplay(message)?.summary ??
getBootstrapAcknowledgementDisplay(message)?.summary ??
message.summary ??

View file

@ -321,10 +321,59 @@ function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined)
}
function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string {
const trimmed = message?.trim();
const trimmed = formatRuntimeAdvisoryDisplayMessage(message);
return trimmed ? `${base}\n\n${trimmed}` : base;
}
function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined): boolean {
const displayMessage = formatRuntimeAdvisoryDisplayMessage(message);
return (
displayMessage.startsWith('OpenCode runtime delivery') ||
displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
displayMessage.startsWith('OpenCode accepted the prompt') ||
displayMessage.startsWith('OpenCode responded, but did not create') ||
displayMessage.startsWith('OpenCode created a reply without') ||
displayMessage.startsWith('OpenCode used tools, but did not create')
);
}
function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): string {
const trimmed = message?.trim();
if (!trimmed) {
return '';
}
if (trimmed === 'empty_assistant_turn') {
return 'OpenCode returned an empty assistant turn.';
}
if (trimmed === 'prompt_delivered_no_assistant_message') {
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
}
if (
trimmed === 'visible_reply_still_required' ||
trimmed === 'visible_reply_ack_only_still_requires_answer' ||
trimmed === 'plain_text_ack_only_still_requires_answer'
) {
return 'OpenCode responded, but did not create a visible message_send reply.';
}
if (
trimmed === 'visible_reply_destination_not_found_yet' ||
trimmed === 'visible_reply_missing_relayOfMessageId'
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (trimmed === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
if (
trimmed.startsWith(
'OpenCode bootstrap MCP did not complete required tools before assistant response:'
)
) {
return 'OpenCode runtime delivery did not complete.';
}
return trimmed;
}
function formatRuntimeAdvisoryBaseLabel(
advisory: MemberRuntimeAdvisory,
providerId: TeamProviderId | undefined
@ -344,8 +393,16 @@ function formatRuntimeAdvisoryBaseLabel(
return 'Network error';
case 'provider_overloaded':
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
case 'protocol_proof_missing':
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
case 'backend_error':
case 'unknown':
if (
providerId === 'opencode' &&
isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message)
) {
return 'OpenCode delivery error';
}
return providerLabel ? `${providerLabel} API error` : 'API error';
default:
return 'API error';
@ -365,6 +422,8 @@ function formatRuntimeAdvisoryBaseLabel(
return 'Network retry';
case 'provider_overloaded':
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
case 'protocol_proof_missing':
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
case 'backend_error':
case 'unknown':
return 'Provider retry';
@ -407,8 +466,24 @@ function formatRuntimeAdvisoryTitle(
'Provider is temporarily overloaded.',
advisory.message
);
case 'protocol_proof_missing':
return appendRuntimeAdvisoryRawMessage(
providerId === 'opencode'
? 'OpenCode delivery completed without required visible/progress proof.'
: 'Runtime delivery completed without required protocol proof.',
advisory.message
);
case 'backend_error':
case 'unknown':
if (
providerId === 'opencode' &&
isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message)
) {
return appendRuntimeAdvisoryRawMessage(
'OpenCode runtime delivery error.',
advisory.message
);
}
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} API error.`,
advisory.message
@ -449,6 +524,13 @@ function formatRuntimeAdvisoryTitle(
'Provider is temporarily overloaded. SDK is retrying automatically.',
advisory.message
);
case 'protocol_proof_missing':
return appendRuntimeAdvisoryRawMessage(
providerId === 'opencode'
? 'OpenCode delivery is waiting for required visible/progress proof.'
: 'Runtime delivery is waiting for required protocol proof.',
advisory.message
);
case 'backend_error':
case 'unknown':
return appendRuntimeAdvisoryRawMessage(
@ -505,6 +587,9 @@ export function getMemberRuntimeAdvisoryTone(
if (!advisory) {
return null;
}
if (advisory.reasonCode === 'protocol_proof_missing') {
return 'warning';
}
return advisory.kind === 'api_error' ? 'error' : 'warning';
}

View file

@ -33,6 +33,22 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
if (normalized === 'prompt_delivered_no_assistant_message') {
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
}
if (
normalized === 'visible_reply_still_required' ||
normalized === 'visible_reply_ack_only_still_requires_answer' ||
normalized === 'plain_text_ack_only_still_requires_answer'
) {
return 'OpenCode responded, but did not create a visible message_send reply.';
}
if (
normalized === 'visible_reply_destination_not_found_yet' ||
normalized === 'visible_reply_missing_relayOfMessageId'
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (normalized === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
return '';
}

View file

@ -1,3 +1,10 @@
import {
getTeamTaskWorkflowColumn,
isTeamTaskDeleted,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import type { GlobalTask } from '@shared/types';
export function normalizePath(p: string): string {
@ -15,10 +22,29 @@ export interface TaskStatusCounts {
completed: number;
}
function incrementStatus(counts: TaskStatusCounts, status: string): TaskStatusCounts {
if (status === 'pending') return { ...counts, pending: counts.pending + 1 };
if (status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 };
if (status === 'completed') return { ...counts, completed: counts.completed + 1 };
function incrementTaskStatus(
counts: TaskStatusCounts,
task: {
status: string;
reviewState?: string | null;
kanbanColumn?: string | null;
deletedAt?: string | null;
}
): TaskStatusCounts {
if (isTeamTaskDeleted(task)) return counts;
if (getTeamTaskWorkflowColumn(task) === 'approved') {
return { ...counts, completed: counts.completed + 1 };
}
if (isTeamTaskNeedsFixActionable(task)) {
return task.status === 'in_progress'
? { ...counts, inProgress: counts.inProgress + 1 }
: { ...counts, pending: counts.pending + 1 };
}
if (task.status === 'pending') return { ...counts, pending: counts.pending + 1 };
if (isTeamTaskFinishedForDependency(task)) {
return { ...counts, completed: counts.completed + 1 };
}
if (task.status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 };
return counts;
}
@ -29,7 +55,7 @@ export function buildTaskCountsByProject(tasks: GlobalTask[]): Map<string, TaskS
if (!task.projectPath) continue;
const key = normalizePath(task.projectPath);
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status));
map.set(key, incrementTaskStatus(counts, task));
}
return map;
}
@ -40,22 +66,29 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
for (const task of tasks) {
const key = task.teamName;
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status));
map.set(key, incrementTaskStatus(counts, task));
}
return map;
}
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
export function buildTaskCountsByOwner(
tasks: { owner?: string | null; status: string }[]
tasks: {
owner?: string | null;
status: string;
reviewState?: string | null;
kanbanColumn?: string | null;
deletedAt?: string | null;
}[]
): Map<string, TaskStatusCounts> {
const map = new Map<string, TaskStatusCounts>();
for (const task of tasks) {
const owner = task.owner?.trim();
if (!owner || task.status === 'deleted') continue;
if (!owner || isTeamTaskDeleted(task)) continue;
if (getTeamTaskWorkflowColumn(task) === 'review') continue;
const key = owner.toLowerCase();
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status));
map.set(key, incrementTaskStatus(counts, task));
}
return map;
}

View file

@ -0,0 +1,18 @@
import type { TeamChangeEvent } from '@shared/types';
const RUNTIME_TASK_EVENT_DETAIL_PREFIX = 'opencode-runtime-task-event:';
export function isTaskLogActivityChangeEvent(event: TeamChangeEvent): boolean {
if (event.type !== 'task-log-change') {
return false;
}
if (event.taskSignalKind === 'log') {
return true;
}
if (event.taskSignalKind === 'change') {
return false;
}
return (
typeof event.detail === 'string' && event.detail.startsWith(RUNTIME_TASK_EVENT_DETAIL_PREFIX)
);
}

View file

@ -4,6 +4,8 @@ import {
} from '@renderer/utils/bootstrapPromptSanitizer';
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
import type { InboxMessage } from '@shared/types';
@ -110,6 +112,7 @@ export function filterTeamMessages(
messages: InboxMessage[],
options: {
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
includeAutomationEvents?: boolean;
leadNames?: Iterable<string>;
timeWindow?: { start: number; end: number } | null;
filter: TeamMessagesFilter;
@ -118,6 +121,7 @@ export function filterTeamMessages(
): InboxMessage[] {
const {
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
includeAutomationEvents = false,
leadNames: rawLeadNames,
timeWindow,
filter,
@ -125,7 +129,12 @@ export function filterTeamMessages(
} = options;
const leadNames = normalizeLeadNames(rawLeadNames);
let list = messages.filter((m) => m.messageKind !== 'task_comment_notification');
let list = messages.filter(
(m) =>
m.messageKind !== 'task_comment_notification' &&
(includeAutomationEvents || !isTaskStallRemediationMessage(m)) &&
!isTeamInternalControlMessageEnvelope(m)
);
if (timeWindow) {
list = list.filter((m) => {
const ts = new Date(m.timestamp).getTime();

View file

@ -0,0 +1,9 @@
import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState';
import type { TeamTaskWithKanban } from '@shared/types';
export function isDisplayableCurrentTask(
task: TeamTaskWithKanban | null | undefined
): task is TeamTaskWithKanban {
return Boolean(task && isTeamTaskActivelyWorked(task));
}

View file

@ -126,6 +126,12 @@ export interface TaskStatusChangedEvent extends TaskHistoryEventBase {
to: TeamTaskStatus;
}
export interface TaskOwnerChangedEvent extends TaskHistoryEventBase {
type: 'owner_changed';
from?: string;
to?: string;
}
export interface TaskReviewRequestedEvent extends TaskHistoryEventBase {
type: 'review_requested';
from: TeamReviewState;
@ -157,6 +163,7 @@ export interface TaskReviewStartedEvent extends TaskHistoryEventBase {
export type TaskHistoryEvent =
| TaskCreatedEvent
| TaskStatusChangedEvent
| TaskOwnerChangedEvent
| TaskReviewRequestedEvent
| TaskReviewChangesRequestedEvent
| TaskReviewApprovedEvent
@ -427,6 +434,7 @@ export type InboxMessageKind =
| 'slash_command'
| 'slash_command_result'
| 'task_comment_notification'
| 'task_stall_remediation'
| 'member_work_sync_nudge'
| 'agent_error';
@ -821,6 +829,7 @@ export interface MemberRuntimeAdvisory {
| 'codex_native_timeout'
| 'network_error'
| 'provider_overloaded'
| 'protocol_proof_missing'
| 'backend_error'
| 'unknown';
message?: string;
@ -1005,6 +1014,27 @@ export interface PersistedTeamLaunchMemberSources {
duplicateRespawnBlocked?: boolean;
}
export interface OpenCodeAppManagedBootstrapCandidate {
schemaVersion: 1;
source: 'app_managed_bootstrap';
teamName: string;
memberName: string;
runId: string;
laneId: string;
runtimeSessionId: string;
messageID: string;
contextHash: string;
briefingHash: string;
injectionVerifiedAt: string;
candidateAt: string;
model?: string;
agent?: string;
}
export type OpenCodeBootstrapEvidenceSource = 'runtime_bootstrap_checkin' | 'app_managed_bootstrap';
export type OpenCodeBootstrapMode = 'model_tool_checkin' | 'app_managed_context';
export interface PersistedTeamLaunchMemberState {
name: string;
providerId?: TeamProviderId;
@ -1032,6 +1062,9 @@ export interface PersistedTeamLaunchMemberState {
/** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */
runtimeRunId?: string;
runtimeSessionId?: string;
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string;
@ -1170,12 +1203,15 @@ export interface TeamChangeEvent {
| 'lead-message'
| 'tool-activity'
| 'member-turn-settled'
| 'member-advisory'
| 'process'
| 'member-spawn';
teamName: string;
runId?: string;
detail?: string;
taskId?: string;
/** Distinguishes real task log freshness from task-change presence freshness. */
taskSignalKind?: 'log' | 'change';
}
export interface ProjectBranchChangeEvent {

View file

@ -14,18 +14,8 @@ export function normalizeReviewState(value: unknown): TeamReviewState {
}
export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
// Prefer derivation from historyEvents when available
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
const derived = getDerivedReviewStateFromHistory({
historyEvents: task.historyEvents as TaskHistoryEvent[],
});
if (derived) {
return derived;
}
}
const fallbackStatus = typeof task.status === 'string' ? task.status : null;
const normalizeFallback = (value: unknown): TeamReviewState | null => {
const normalizeForStatus = (value: unknown): TeamReviewState | null => {
const explicit = normalizeReviewState(value);
if (explicit === 'none') return null;
@ -36,16 +26,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
return explicit === 'needsFix' ? 'needsFix' : 'none';
}
if (fallbackStatus === 'completed') {
return explicit === 'review' || explicit === 'approved' ? explicit : 'none';
return explicit === 'review' || explicit === 'approved' || explicit === 'needsFix'
? explicit
: 'none';
}
return explicit;
};
const explicit = normalizeFallback(task.reviewState);
// Prefer derivation from historyEvents when available
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
const derived = getDerivedReviewStateFromHistory({
historyEvents: task.historyEvents as TaskHistoryEvent[],
});
if (derived !== null) {
return normalizeForStatus(derived) ?? 'none';
}
}
const explicit = normalizeForStatus(task.reviewState);
if (explicit) return explicit;
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return normalizeFallback(task.kanbanColumn) ?? 'none';
return normalizeForStatus(task.kanbanColumn) ?? 'none';
}
return 'none';

View file

@ -1,4 +1,5 @@
import { getReviewStateFromTask } from './reviewState';
import { getTeamTaskWorkflowColumn } from './teamTaskState';
import type { TeamReviewState } from '@shared/types';
@ -9,6 +10,7 @@ interface TaskChangeStateLike {
reviewState?: TeamReviewState | null;
historyEvents?: unknown[];
kanbanColumn?: 'review' | 'approved' | null;
deletedAt?: string | null;
}
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
@ -17,8 +19,15 @@ function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
const reviewState = getEffectiveReviewState(task);
if (reviewState === 'approved') return 'approved';
if (reviewState === 'review') return 'review';
const workflowColumn = getTeamTaskWorkflowColumn({
status: task.status ?? '',
reviewState,
kanbanColumn: task.kanbanColumn,
deletedAt: task.deletedAt,
});
if (workflowColumn === 'approved') return 'approved';
if (workflowColumn === 'review') return 'review';
if (reviewState === 'needsFix') return 'active';
return task.status === 'completed' ? 'completed' : 'active';
}

View file

@ -0,0 +1,16 @@
import type { InboxMessage } from '@shared/types';
type AutomationMessageLike = Pick<InboxMessage, 'from' | 'messageId' | 'messageKind' | 'source'>;
export function isTaskStallRemediationMessage(message: AutomationMessageLike): boolean {
if (message.messageKind === 'task_stall_remediation') {
return true;
}
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
return (
message.source === 'system_notification' &&
message.from === 'system' &&
messageId.startsWith('task-stall:')
);
}

View file

@ -0,0 +1,87 @@
const NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN = '<agent_teams_native_app_managed_bootstrap_check>';
const LEAD_INBOX_RELAY_PROMPT_OPEN = 'You have new inbox messages addressed to you (team lead ';
const TEAMMATE_MESSAGE_OPEN_RE = /^<teammate-message\s/i;
const INTERNAL_CONTROL_MESSAGE_SOURCES = new Set([
'lead_process',
'lead_session',
'runtime_delivery',
'system_notification',
]);
const INTERNAL_BOOTSTRAP_AUTHORS = new Set(['team-lead', 'lead', 'orchestrator']);
export function stripTranscriptSpeakerPrefix(value: string): string {
let normalized = value.trim();
for (let i = 0; i < 3; i += 1) {
const next = normalized.replace(/^(?:Human|User):\s*/i, '').trimStart();
if (next === normalized) break;
normalized = next;
}
return normalized;
}
export function isNativeAppManagedBootstrapCheckText(value: unknown): boolean {
return (
typeof value === 'string' &&
stripTranscriptSpeakerPrefix(value).startsWith(NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN)
);
}
export function isLeadInboxRelayControlPromptText(value: unknown): boolean {
if (typeof value !== 'string') {
return false;
}
const text = stripTranscriptSpeakerPrefix(value);
return (
text.startsWith(LEAD_INBOX_RELAY_PROMPT_OPEN) &&
text.includes('Process them in order (oldest first).') &&
text.includes('\nMessages:')
);
}
export function isTeammateProtocolControlText(value: unknown): boolean {
if (typeof value !== 'string') {
return false;
}
return TEAMMATE_MESSAGE_OPEN_RE.test(stripTranscriptSpeakerPrefix(value));
}
export function isTeamInternalControlMessageText(value: unknown): boolean {
return (
isNativeAppManagedBootstrapCheckText(value) ||
isLeadInboxRelayControlPromptText(value) ||
isTeammateProtocolControlText(value)
);
}
export function isTeamInternalControlMessageEnvelope(message: {
text?: unknown;
source?: unknown;
from?: unknown;
}): boolean {
if (isNativeAppManagedBootstrapCheckText(message.text)) {
if (typeof message.source === 'string') {
return INTERNAL_CONTROL_MESSAGE_SOURCES.has(message.source);
}
return (
typeof message.from === 'string' &&
INTERNAL_BOOTSTRAP_AUTHORS.has(message.from.trim().toLowerCase())
);
}
if (!isTeamInternalControlMessageText(message.text)) {
return false;
}
return typeof message.source === 'string' && INTERNAL_CONTROL_MESSAGE_SOURCES.has(message.source);
}
export function stripExactInternalControlEchoPrefix(
value: string,
expectedControlText: string
): string {
const text = stripTranscriptSpeakerPrefix(value);
const expected = stripTranscriptSpeakerPrefix(expectedControlText);
if (!expected || !text.startsWith(expected)) {
return value.trim();
}
return text.slice(expected.length).trim();
}

View file

@ -0,0 +1,102 @@
export interface TeamTaskStateLike {
status: string;
reviewState?: string | null;
kanbanColumn?: string | null;
deletedAt?: string | null;
}
export type TeamTaskWorkflowColumn = 'review' | 'approved';
export function isTeamTaskApproved(task: TeamTaskStateLike): boolean {
if (isTeamTaskDeleted(task) || task.status === 'pending') {
return false;
}
if (task.kanbanColumn === 'approved') {
return true;
}
if (task.kanbanColumn === 'review') {
return false;
}
return task.reviewState === 'approved';
}
export function isTeamTaskDeleted(task: TeamTaskStateLike): boolean {
return task.status === 'deleted' || Boolean(task.deletedAt);
}
export function isTeamTaskActivelyWorked(task: TeamTaskStateLike): boolean {
return (
task.status === 'in_progress' &&
getTeamTaskWorkflowColumn(task) !== 'review' &&
!isTeamTaskApproved(task) &&
!isTeamTaskDeleted(task)
);
}
export function isTeamTaskNeedsFixActionable(task: TeamTaskStateLike): boolean {
return (
task.reviewState === 'needsFix' &&
!isTeamTaskDeleted(task) &&
getTeamTaskWorkflowColumn(task) === undefined
);
}
export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolean {
const workflowColumn = getTeamTaskWorkflowColumn(task);
if (workflowColumn === 'approved') {
return true;
}
if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) {
return false;
}
return task.status === 'completed';
}
export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): boolean {
if (isTeamTaskDeleted(task)) {
return true;
}
const workflowColumn = getTeamTaskWorkflowColumn(task);
if (workflowColumn === 'approved') {
return true;
}
if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) {
return false;
}
return task.status === 'completed';
}
export function isTeamTaskFinalForCompletionNotification(task: TeamTaskStateLike): boolean {
return isTeamTaskTerminalForActionableWork(task);
}
export function getTeamTaskWorkflowColumn(
task: TeamTaskStateLike
): TeamTaskWorkflowColumn | undefined {
if (isTeamTaskDeleted(task) || task.status === 'pending') {
return undefined;
}
if (task.kanbanColumn === 'approved') {
return 'approved';
}
if (task.kanbanColumn === 'review') {
return 'review';
}
if (task.reviewState === 'approved') {
return 'approved';
}
if (task.reviewState === 'review') {
return 'review';
}
return undefined;
}

View file

@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
completeTask(taskId: string, actor?: string): unknown;
softDeleteTask(taskId: string, actor?: string): unknown;
restoreTask(taskId: string, actor?: string): unknown;
setTaskOwner(taskId: string, owner: string | null): unknown;
setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown;
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
memberBriefing(
memberName: string,
options?: { runtimeProvider?: 'native' | 'opencode' }
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
): Promise<string>;
leadBriefing(): Promise<string>;
taskBriefing(memberName: string): Promise<string>;

View file

@ -43,6 +43,177 @@ describe('buildActionableWorkAgenda', () => {
]);
});
it('does not keep stale terminal task state in the work agenda', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-completed',
displayId: '#6d4db591',
subject: 'Completed after stale work-sync status',
status: 'completed',
owner: 'jack',
},
{
id: 'task-deleted',
subject: 'Deleted after stale work-sync status',
status: 'in_progress',
owner: 'jack',
deletedAt: '2026-05-06T19:06:07.257Z',
},
{
id: 'task-review-approved',
subject: 'Approved review after stale work-sync status',
status: 'in_progress',
owner: 'jack',
reviewState: 'approved',
},
{
id: 'task-kanban-approved',
subject: 'Approved kanban after stale work-sync status',
status: 'in_progress',
owner: 'jack',
kanbanColumn: 'approved',
},
{
id: 'task-stale-needsfix-approved',
subject: 'Approved task after stale needsFix status',
status: 'in_progress',
owner: 'jack',
reviewState: 'needsFix',
kanbanColumn: 'approved',
},
],
hash,
});
expect(agenda.items).toEqual([]);
});
it('projects reopened in-progress work after a previous completion', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T18:56:19.173Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-reopened',
displayId: '#6d4db591',
subject: 'Reopened work',
status: 'in_progress',
owner: 'jack',
historyEvents: [
{
id: 'evt-completed',
type: 'status_changed',
timestamp: '2026-05-06T18:50:05.662Z',
from: 'in_progress',
to: 'completed',
},
{
id: 'evt-reopened',
type: 'status_changed',
timestamp: '2026-05-06T18:56:19.173Z',
from: 'completed',
to: 'in_progress',
},
],
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
['task-reopened', 'owned_in_progress_task'],
]);
});
it('does not treat approved dependencies as waiting blockers', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-approved',
subject: 'Approved dependency',
status: 'in_progress',
owner: 'alice',
kanbanColumn: 'approved',
},
{
id: 'task-dependent',
subject: 'Depends on approved task',
status: 'in_progress',
owner: 'jack',
blockedBy: ['task-approved'],
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
['task-dependent', 'owned_in_progress_task'],
]);
});
it('keeps dependencies blocked while completed work is still in review', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }, { name: 'alice' }],
tasks: [
{
id: 'task-review',
subject: 'Dependency waiting for review',
status: 'completed',
owner: 'alice',
reviewState: 'review',
kanbanColumn: 'review',
},
{
id: 'task-dependent',
subject: 'Depends on reviewed task',
status: 'in_progress',
owner: 'jack',
blockedBy: ['task-review'],
},
],
hash,
});
expect(agenda.items).toEqual([]);
});
it('does not let stale kanban approved hide a reopened pending task', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-reopened-pending',
subject: 'Reopened pending work',
status: 'pending',
owner: 'jack',
kanbanColumn: 'approved',
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
['task-reopened-pending', 'owned_pending_task'],
]);
});
it('assigns active review work to the current-cycle reviewer only', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
@ -78,6 +249,98 @@ describe('buildActionableWorkAgenda', () => {
});
});
it('keeps completed tasks actionable for the current reviewer while workflow is review', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review completed work',
status: 'completed',
owner: 'bob',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
expect(agenda.items).toHaveLength(1);
expect(agenda.items[0]).toMatchObject({
taskId: 'task-review',
kind: 'review',
assignee: 'alice',
});
});
it('does not assign owner work while stale in-progress task is in review workflow', () => {
const ownerAgenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review in progress status',
status: 'in_progress',
owner: 'bob',
reviewState: 'none',
kanbanColumn: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
const reviewerAgenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review in progress status',
status: 'in_progress',
owner: 'bob',
reviewState: 'none',
kanbanColumn: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
expect(ownerAgenda.items).toEqual([]);
expect(reviewerAgenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-review', 'review', 'current_cycle_review_assigned'],
]);
});
it('does not resurrect a stale reviewer after review was approved', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
@ -257,12 +520,20 @@ describe('buildActionableWorkAgenda', () => {
owner: 'bob',
reviewState: 'needsFix',
},
{
id: 'task-2',
subject: 'Fix completed review',
status: 'completed',
owner: 'bob',
reviewState: 'needsFix',
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-1', 'work', 'review_changes_requested'],
['task-2', 'work', 'review_changes_requested'],
]);
});

Some files were not shown because too many files have changed in this diff Show more