diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index 62f489d3..a25b075a 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -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; }); } diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 0040643f..82d49ab9 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -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: "", owner: "" } + { teamName: "${teamName}", taskId: "", owner: "", actor: "" } - 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({ diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index c4136a2f..fbada8cd 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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 }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 9f7c9b31..48c9b8b8 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -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): unknown; attachTaskFile(taskId: string, flags: Record): 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; leadBriefing(): Promise; taskBriefing(memberName: string): Promise; diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 675117f1..fe4fb479 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -413,18 +413,19 @@ export function registerTaskTools(server: Pick) { 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) { ...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) { type: 'text' as const, text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, { ...(runtimeProvider ? { runtimeProvider } : {}), + ...(includeActiveProcesses !== undefined ? { includeActiveProcesses } : {}), }), }, ], diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 034df9f2..9860f8e1 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -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({ diff --git a/package.json b/package.json index 0c0df825..531038d2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index ccc691a8..fda78d88 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -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(); } diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 8a990dbb..b98db6fa 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -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 */ diff --git a/resources/pricing.json b/resources/pricing.json index b8e4c05a..c45fff83 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -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, diff --git a/runtime.lock.json b/runtime.lock.json index d7894dfa..7c6fc290 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -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" } diff --git a/scripts/prove-agent-cli-launch.mjs b/scripts/prove-agent-cli-launch.mjs new file mode 100644 index 00000000..df0f2c35 --- /dev/null +++ b/scripts/prove-agent-cli-launch.mjs @@ -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); diff --git a/scripts/prove-opencode-semantic-gauntlet.mjs b/scripts/prove-opencode-semantic-gauntlet.mjs new file mode 100644 index 00000000..abb9cc1a --- /dev/null +++ b/scripts/prove-opencode-semantic-gauntlet.mjs @@ -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); diff --git a/scripts/prove-opencode-semantic-messaging.mjs b/scripts/prove-opencode-semantic-messaging.mjs new file mode 100644 index 00000000..19d4e0bd --- /dev/null +++ b/scripts/prove-opencode-semantic-messaging.mjs @@ -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); diff --git a/scripts/prove-opencode-semantic-model-matrix.mjs b/scripts/prove-opencode-semantic-model-matrix.mjs new file mode 100644 index 00000000..717dc804 --- /dev/null +++ b/scripts/prove-opencode-semantic-model-matrix.mjs @@ -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); diff --git a/src/features/agent-graph/core/domain/collapseOverflowStacks.ts b/src/features/agent-graph/core/domain/collapseOverflowStacks.ts index 1ef45c8f..8d99dbc1 100644 --- a/src/features/agent-graph/core/domain/collapseOverflowStacks.ts +++ b/src/features/agent-graph/core/domain/collapseOverflowStacks.ts @@ -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, diff --git a/src/features/agent-graph/core/domain/taskGraphSemantics.ts b/src/features/agent-graph/core/domain/taskGraphSemantics.ts index b629857e..53ed30ed 100644 --- a/src/features/agent-graph/core/domain/taskGraphSemantics.ts +++ b/src/features/agent-graph/core/domain/taskGraphSemantics.ts @@ -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; +type TaskColumnInput = Pick< + TeamTaskWithKanban, + 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt' +>; type TaskReviewerInput = Pick; type TaskBlockInput = Pick; -type TaskBlockState = Pick; +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)); }); } diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 8dbf3086..06bb7df7 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -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, layoutMode: GraphLayoutMode = 'radial', - gridOwnerOrder?: readonly string[] + gridOwnerOrder?: readonly string[], + activeTaskLogActivity?: Record ): 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, memberNodeIdByAlias?: ReadonlyMap, leadId?: string, - leadName?: string + leadName?: string, + activeTaskLogActivity?: Record ): void { - const taskStateById = new Map>(); + const taskStateById = new Map< + string, + Pick + >(); const taskDisplayIds = new Map(); const memberColorByName = new Map(); 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 }, }); } diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index f3ddcb70..9b9feca3 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -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(() => { diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index 6c762279..184d9590 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -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 && (
{ 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}
)} diff --git a/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx index a051ba17..bf41c87b 100644 --- a/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx +++ b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx @@ -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): 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': diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts new file mode 100644 index 00000000..7f14c441 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -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' }; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index c41fdd28..0b74ce17 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -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 { + 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), + }); + } } } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts index ab01079e..23129a6e 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -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' }; } diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 94bd511d..b1f2c6d6 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -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'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 886cb178..b7d06c74 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -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; +} + 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; diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 7c8bb66a..94022fff 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -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 { 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, diff --git a/src/features/member-work-sync/core/domain/currentReviewCycle.ts b/src/features/member-work-sync/core/domain/currentReviewCycle.ts index 47261959..f5b4cdd9 100644 --- a/src/features/member-work-sync/core/domain/currentReviewCycle.ts +++ b/src/features/member-work-sync/core/domain/currentReviewCycle.ts @@ -6,6 +6,8 @@ export interface ReviewHistoryEventLike { timestamp?: string; actor?: string; reviewer?: string; + from?: string; + to?: string; } export interface CurrentReviewOwner { diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts index 8b0835bb..3163755e 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts @@ -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): boolean { - return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt); -} - function isDeletedTask(task: Pick): 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 ( diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts index b1b01a5a..5550c5d5 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -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]) diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 77f0e4d9..140d2b6b 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -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; 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), diff --git a/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts new file mode 100644 index 00000000..242b7456 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts @@ -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[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 }; + } +} diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 96e92971..5eae2e40 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -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, diff --git a/src/main/index.ts b/src/main/index.ts index 37d5534d..d6722651 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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 { ) .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) => diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 1d6633e4..7b3bad30 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -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; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a4add099..2d1be942 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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, 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, + 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 { - this.getController(teamName).tasks.setTaskOwner(taskId, owner); + this.getController(teamName).tasks.setTaskOwner(taskId, owner, 'user'); this.invalidateGlobalTaskProjectionCache(); } diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index df74b5b8..b15df035 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -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; + 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), diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 6435907f..01d531bc 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -69,10 +69,22 @@ type DecodedFreshnessTaskId = | { kind: 'opaque-safe-segment' } | { kind: 'invalid' }; +type TaskFreshnessSignalKind = NonNullable; + 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 { + 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 { + 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 }> { const targets = new Set(); const scopedSessionIds = new Set(); 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 { + private async emitTaskFreshnessSignalFromFile( + teamName: string, + filePath: string, + taskSignalKind: TaskFreshnessSignalKind + ): Promise { try { const raw = await fs.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw) as Record; @@ -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 { const state = this.getOrCreateState(teamName); if (this.getActiveConsumerCount(state) === 0) { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 3e1828b9..a96380be 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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( return results; } +function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[] { + const roots: string[] = []; + const seen = new Set(); + 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(); 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, diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index a2f9d6c3..fc209f53 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -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); diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 8eddcde1..4f83608d 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -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( items: readonly T[], limit: number, @@ -137,8 +203,10 @@ async function mapLimit( } export class TeamMemberRuntimeAdvisoryService { + private readonly inboxReader = new TeamInboxReader(); private readonly memberCache = new Map(); private readonly teamBatchCacheByTeam = new Map(); + private readonly cacheGenerationByTeam = new Map(); private readonly inFlightBatchRequests = new Map< string, Promise> @@ -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[] @@ -187,17 +272,21 @@ export class TeamMemberRuntimeAdvisoryService { teamName: string, memberName: string ): Promise { + 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> { const startedAt = performance.now(); const now = Date.now(); + const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0; const result = new Map(); 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 { + 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 { + 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 { + const advisories = await this.findRecentOpenCodeDeliveryAdvisories(teamName, [memberName]); + return advisories.get(memberName) ?? null; + } + + private async findRecentOpenCodeDeliveryAdvisories( + teamName: string, + memberNames: readonly string[] + ): Promise> { + const activeMembersByKey = new Map(); + 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(); + 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(); + 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(); + 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 + ): 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 + ): Promise> { + const result = new Map(); + 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 + ): 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( diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index 3865ef3d..74089ba7 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -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); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b78c3026..1f2f75fb 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -84,6 +84,16 @@ import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskActivelyWorked, + isTeamTaskDeleted, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; +import { + isTeamInternalControlMessageText, + stripExactInternalControlEchoPrefix, +} from '@shared/utils/teamInternalControlMessages'; import { parseAllTeammateMessages, type ParsedTeammateContent, @@ -142,6 +152,14 @@ import { type TeamRuntimeSettingsJson, } from '../runtime/teamRuntimeSettingsBundle'; +import { + parseBootstrapRuntimeProofDetail, + validateBootstrapRuntimeProofEnvelope, +} from './bootstrap/BootstrapProofValidation'; +import { + buildNativeAppManagedBootstrapSpecs, + type NativeAppManagedBootstrapSpec, +} from './bootstrap/NativeAppManagedBootstrapContextBuilder'; import { createOpenCodePromptDeliveryLedgerStore, hashOpenCodePromptDeliveryPayload, @@ -151,6 +169,14 @@ import { type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, } from './opencode/delivery/OpenCodePromptDeliveryLedger'; +import { + isActionRequiredOpenCodeRuntimeDeliveryReason, + selectOpenCodeRuntimeDeliveryReason, +} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; +import { + decideOpenCodePromptDeliveryRepair, + type OpenCodePromptDeliveryHardFailureKind, +} from './opencode/delivery/OpenCodePromptDeliveryRepairPolicy'; import { isOpenCodePromptDeliveryObserveLaterResponseState, isOpenCodePromptDeliveryRetryableResponseState, @@ -307,6 +333,10 @@ interface PersistedRuntimeMemberLike { cwd?: string; bootstrapExpectedAfter?: string; bootstrapProofToken?: string; + bootstrapRunId?: string; + bootstrapProofMode?: string; + bootstrapContextHash?: string; + bootstrapBriefingHash?: string; bootstrapRuntimeEventsPath?: string; runtimePid?: number; runtimeSessionId?: string; @@ -341,7 +371,6 @@ interface LaunchStateWriteResult { type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; -const BOOTSTRAP_RUNTIME_PROOF_SOURCE = 'member_briefing_tool_success'; const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024; function sanitizeRuntimeEventFilePrefix(value: string): string { @@ -350,31 +379,6 @@ function sanitizeRuntimeEventFilePrefix(value: string): string { .toLowerCase(); } -function parseRuntimeBootstrapProofDetail(detail: unknown): Record { - if (typeof detail !== 'string' || detail.trim().length === 0) { - return {}; - } - try { - const parsed = JSON.parse(detail) as unknown; - return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; - } catch { - return {}; - } -} - -function getRuntimeBootstrapProofString( - event: Record, - detail: Record, - field: 'source' | 'bootstrapProofToken' -): 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; -} - type BootstrapTranscriptOutcome = | { kind: 'success'; @@ -402,6 +406,8 @@ import type { MemberSpawnStatus, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + OpenCodeAppManagedBootstrapCandidate, + OpenCodeBootstrapEvidenceSource, OpenCodeRuntimeDeliveryStatus, PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, @@ -2160,6 +2166,28 @@ function downgradeUncommittedOpenCodeBootstrapEvidence( }; } +function promoteCommittedOpenCodeAppManagedBootstrapEvidence( + evidence: TeamRuntimeMemberLaunchEvidence +): TeamRuntimeMemberLaunchEvidence { + return { + ...evidence, + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + hardFailureReason: undefined, + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: + 'OpenCode app-managed bootstrap evidence was committed and read back by the desktop app.', + runtimeDiagnosticSeverity: 'info', + diagnostics: appendDiagnosticOnce( + evidence.diagnostics, + 'OpenCode app-managed bootstrap evidence committed and read back.' + ), + }; +} + function summarizeRuntimeLaunchResultMembers( members: Record ): TeamLaunchAggregateState { @@ -2493,6 +2521,7 @@ const OPEN_CODE_SECRET_FLAG_PATTERN = /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Z0-9._~+/=-]+/gi; const OPEN_CODE_SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g; +const OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS = 12_000; function normalizeOpenCodePersistedFailureReason(value: string | undefined): string | undefined { const trimmed = value?.replace(/\s+/g, ' ').trim(); @@ -2505,6 +2534,21 @@ function normalizeOpenCodePersistedFailureReason(value: string | undefined): str .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]'); } +function redactOpenCodeAppManagedContextText(value: string): string { + return value + .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]') + .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]') + .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]'); +} + +function boundOpenCodeAppManagedBriefingText(value: string): string { + const normalized = redactOpenCodeAppManagedContextText(value.replace(/\r\n/g, '\n')).trim(); + if (normalized.length <= OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS) { + return normalized; + } + return `${normalized.slice(0, OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS)}\n[truncated app-managed briefing]`; +} + function isGenericOpenCodePersistedFailureReason(value: string | undefined): boolean { const normalized = normalizeOpenCodePersistedFailureReason(value); return ( @@ -2616,8 +2660,20 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: { hardFailureReason: undefined, runtimeRunId: input.session.runId ?? input.current.runtimeRunId, runtimeSessionId: input.session.id, + bootstrapEvidenceSource: input.session.source, + bootstrapMode: + input.session.source === 'app_managed_bootstrap' + ? 'app_managed_context' + : 'model_tool_checkin', + appManagedBootstrapCandidate: + input.session.source === 'app_managed_bootstrap' + ? input.session.appManagedBootstrapCandidate + : undefined, livenessKind, - runtimeDiagnostic: 'OpenCode bootstrap evidence committed.', + runtimeDiagnostic: + input.session.source === 'app_managed_bootstrap' + ? 'OpenCode app-managed bootstrap evidence committed.' + : 'OpenCode bootstrap evidence committed.', runtimeDiagnosticSeverity: 'info', firstSpawnAcceptedAt: input.current.firstSpawnAcceptedAt ?? input.previous?.firstSpawnAcceptedAt ?? observedAt, @@ -3875,6 +3931,7 @@ interface RuntimeBootstrapMemberSpec { description?: string; useSplitPane?: boolean; planModeRequired?: boolean; + nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec; } interface RuntimeBootstrapSpec { @@ -3909,7 +3966,8 @@ interface RuntimeBootstrapSpec { function buildDeterministicCreateBootstrapSpec( runId: string, request: TeamCreateRequest, - effectiveMembers: TeamCreateRequest['members'] + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -3949,6 +4007,9 @@ function buildDeterministicCreateBootstrapSpec( ...(member.effort ? { effort: member.effort } : {}), ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), })), launch: { continueOnPartialFailure: true, @@ -3962,7 +4023,8 @@ function buildDeterministicCreateBootstrapSpec( function buildDeterministicLaunchBootstrapSpec( runId: string, request: TeamLaunchRequest, - effectiveMembers: TeamCreateRequest['members'] + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -3999,6 +4061,9 @@ function buildDeterministicLaunchBootstrapSpec( ...(member.role?.trim() ? { role: member.role.trim() } : {}), ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), })), launch: { continueOnPartialFailure: true, @@ -4336,16 +4401,34 @@ function getAgentLanguageInstruction(): string { return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`; } +function isTaskBoardSnapshotWorkCandidate(task: TeamTask): boolean { + if (!task.id || task.id.startsWith('_internal') || isTeamTaskDeleted(task)) { + return false; + } + + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'review' || workflowColumn === 'approved') { + return false; + } + + return ( + task.status === 'pending' || + isTeamTaskNeedsFixActionable(task) || + isTeamTaskActivelyWorked(task) + ); +} + /** Build a full task board snapshot for the lead. */ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { - const active = tasks.filter( - (t) => (t.status === 'pending' || t.status === 'in_progress') && !t.id.startsWith('_internal') - ); + const active = tasks.filter(isTaskBoardSnapshotWorkCandidate); if (active.length === 0) return '\nNo pending tasks on the board.\n'; const lines = active.map((t) => { const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)'; const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; + const stateLabel = [t.status, isTeamTaskNeedsFixActionable(t) ? 'needsFix' : null] + .filter(Boolean) + .join(', '); const deps = t.blockedBy?.length ? ` [blocked by: ${t.blockedBy .map((id) => tasks.find((candidate) => candidate.id === id)) @@ -4353,9 +4436,9 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { .map((task) => formatTaskDisplayLabel(task)) .join(', ')}]` : ''; - return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}]${owner} ${t.subject}${deps}${desc}`; + return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${stateLabel}]${owner} ${t.subject}${deps}${desc}`; }); - return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`; + return `\nCurrent actionable task board (pending/in_progress/needsFix):\n${lines.join('\n')}\n`; } function buildDeterministicLaunchHydrationPrompt( @@ -5141,6 +5224,18 @@ function normalizeSameTeamText(text: string): string { return text.trim().replace(/\r\n/g, '\n'); } +function getOpenCodeInboxRelayPriority( + message: Pick +): number { + if (message.messageKind === 'member_work_sync_nudge') { + return 30; + } + if (message.source === 'system_notification') { + return 20; + } + return 0; +} + export class TeamProvisioningService { private readonly runtimeLaneCoordinator = createTeamRuntimeLaneCoordinator(); private readonly providerConnectionService = ProviderConnectionService.getInstance(); @@ -5158,6 +5253,8 @@ export class TeamProvisioningService { private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500; private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000; private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000; + private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000; + private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -5204,6 +5301,8 @@ export class TeamProvisioningService { Promise >(); private readonly openCodePromptDeliveryWatchdogTimers = new Map(); + private readonly openCodeRuntimeDeliveryAdvisoryEventSentAt = new Map(); + private readonly openCodeRuntimeDeliveryLeadNoticeSentAt = new Map(); private readonly openCodePromptDeliveryWatchdogQueue: { teamName: string; run: () => Promise; @@ -5273,6 +5372,9 @@ export class TeamProvisioningService { string, Promise >(); + private memberRuntimeAdvisoryInvalidator: + | ((teamName: string, memberName: string) => void) + | null = null; private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -5517,6 +5619,12 @@ export class TeamProvisioningService { this.runtimeAdapterRegistry = registry; } + setMemberRuntimeAdvisoryInvalidator( + invalidator: ((teamName: string, memberName: string) => void) | null + ): void { + this.memberRuntimeAdvisoryInvalidator = invalidator; + } + setCrossTeamSender( sender: | ((request: { @@ -6662,6 +6770,9 @@ export class TeamProvisioningService { if (state === 'prompt_delivered_no_assistant_message') { return 'prompt_delivered_no_assistant_message'; } + if (state === 'tool_error') { + return 'tool_error_without_required_delivery_proof'; + } return record?.lastReason ?? 'opencode_delivery_response_pending'; } @@ -6687,6 +6798,13 @@ export class TeamProvisioningService { }; } + private isOpenCodePromptAcceptedByObservation( + observation?: NonNullable + ): boolean { + const deliveredUserMessageId = observation?.deliveredUserMessageId; + return typeof deliveredUserMessageId === 'string' && deliveredUserMessageId.trim().length > 0; + } + private isOpenCodeDeliveryRetryablePendingResponse(input: { ledgerRecord: OpenCodePromptDeliveryLedgerRecord; visibleReply?: OpenCodeVisibleReplyProof | null; @@ -6717,40 +6835,62 @@ export class TeamProvisioningService { return false; } - private buildOpenCodePromptDeliveryAttemptText(input: { - ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; - text: string; - replyRecipient: string; - }): string { - const record = input.ledgerRecord; - if (!record || record.status === 'pending' || record.attempts <= 0) { - return input.text; + private getOpenCodeDeliveryHardFailureKind( + record?: OpenCodePromptDeliveryLedgerRecord | null + ): OpenCodePromptDeliveryHardFailureKind { + if (!record) { + return 'none'; } - const visibleAnswerRequired = - record.lastReason === 'visible_reply_still_required' || - record.lastReason === 'plain_text_ack_only_still_requires_answer' || - (record.responseState === 'responded_non_visible_tool' && - record.actionMode === 'ask' && - record.taskRefs.length === 0); - const attemptNumber = Math.min(record.attempts + 1, record.maxAttempts); - const header = visibleAnswerRequired - ? [ - '', - `This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`, - `You accepted the earlier prompt but did not provide a visible/concrete answer for the recipient "${input.replyRecipient}".`, - `Please reply with agent-teams_message_send to "${input.replyRecipient}" and include relayOfMessageId="${record.inboxMessageId}". If that tool is unavailable, provide a concise plain-text answer.`, - 'Do not repeat tool work unless needed and do not reply only with acknowledgement.', - '', - ] - : [ - '', - `This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`, - 'The previous OpenCode turn was accepted, but the app still has no sufficient response proof for this message.', - `If you already acted on this message, do not duplicate work; send a concrete status via agent-teams_message_send with relayOfMessageId="${record.inboxMessageId}" or update the related task.`, - 'Do not reply only with acknowledgement.', - '', - ]; - return `${header.join('\n')}\n\n${input.text}`; + if (record.status === 'failed_terminal') { + return 'unknown'; + } + if (record.responseState === 'permission_blocked') { + return 'permission'; + } + if (record.responseState === 'session_error') { + return 'session'; + } + return 'none'; + } + + private buildOpenCodePromptDeliveryRepairControlText(input: { + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; + readAllowed: boolean; + pendingReason: string; + }): string | null { + const record = input.ledgerRecord; + if (!record) { + return null; + } + return decideOpenCodePromptDeliveryRepair({ + teamName: record.teamName, + memberName: record.memberName, + inboxMessageId: record.inboxMessageId, + replyRecipient: record.replyRecipient, + messageKind: record.messageKind, + actionMode: record.actionMode, + taskRefs: record.taskRefs, + status: record.status, + responseState: record.responseState, + attempts: record.attempts, + maxAttempts: record.maxAttempts, + pendingReason: input.pendingReason, + readAllowed: input.readAllowed, + inboxReadCommitted: Boolean(record.inboxReadCommittedAt), + visibleReplyFound: Boolean(record.visibleReplyMessageId), + hasKnownProgressProof: this.hasOpenCodeNonVisibleProgressProof(record), + toolCallNames: record.observedToolCallNames, + acceptanceUnknown: record.acceptanceUnknown, + hardFailureKind: this.getOpenCodeDeliveryHardFailureKind(record), + }).controlText; + } + + private buildOpenCodePromptDeliveryAttemptText(input: { + text: string; + controlText?: string | null; + }): string { + const controlText = input.controlText?.trim(); + return controlText ? `${controlText}\n\n${input.text}` : input.text; } private isOpenCodePromptAcceptanceUnknownFailure(diagnostics: readonly string[]): boolean { @@ -7115,6 +7255,42 @@ export class TeamProvisioningService { } } + private async isStaleOpenCodePromptDeliveryWatchdogError(input: { + teamName: string; + memberName: string; + messageId: string; + error: unknown; + }): Promise { + if (!getErrorMessage(input.error).startsWith('OpenCode prompt delivery record not found:')) { + return false; + } + if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) { + return true; + } + + const inboxMessages = await this.inboxReader + .getMessagesFor(input.teamName, input.memberName) + .catch(() => []); + const targetMessage = inboxMessages.find((message) => message.messageId === input.messageId); + if (!targetMessage || targetMessage.read) { + return true; + } + + const identity = await this.resolveOpenCodeMemberDeliveryIdentity( + input.teamName, + input.memberName + ).catch(() => null); + if (!identity?.ok) { + return true; + } + + const laneActive = await this.isOpenCodeRuntimeLaneIndexActive( + input.teamName, + identity.laneId + ).catch(() => false); + return !laneActive; + } + private scheduleOpenCodePromptDeliveryWatchdog(input: { teamName: string; memberName: string; @@ -7141,10 +7317,30 @@ export class TeamProvisioningService { this.enqueueOpenCodePromptDeliveryWatchdogJob({ teamName: input.teamName, run: async () => { - await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, { - onlyMessageId: messageId, - source: 'watchdog', - }); + if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) { + return; + } + try { + await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, { + onlyMessageId: messageId, + source: 'watchdog', + }); + } catch (error) { + if ( + await this.isStaleOpenCodePromptDeliveryWatchdogError({ + teamName: input.teamName, + memberName: input.memberName, + messageId, + error, + }) + ) { + logger.debug( + `[${input.teamName}] Ignoring stale OpenCode prompt delivery watchdog job for ${input.memberName}/${messageId}: ${getErrorMessage(error)}` + ); + return; + } + throw error; + } }, }); }, delayMs); @@ -7354,6 +7550,214 @@ export class TeamProvisioningService { ...extra, }) ); + const shouldNotifyTerminalFailure = + event === 'opencode_prompt_delivery_terminal_failure' && record.status === 'failed_terminal'; + const shouldNotifyActionRequiredRetry = + !shouldNotifyTerminalFailure && + this.shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal(record); + if (shouldNotifyTerminalFailure || shouldNotifyActionRequiredRetry) { + void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => { + logger.warn( + `[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}` + ); + }); + return; + } + if (this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) { + this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(record); + } + } + + private shouldSurfaceOpenCodeRuntimeDeliveryAdvisory( + record: OpenCodePromptDeliveryLedgerRecord + ): boolean { + if (!selectOpenCodeRuntimeDeliveryReason(record)) { + return false; + } + if (record.status === 'failed_terminal') { + return true; + } + if (record.status === 'responded') { + return false; + } + return ( + record.responseState === 'session_error' || + record.responseState === 'tool_error' || + record.responseState === 'permission_blocked' || + record.responseState === 'reconcile_failed' + ); + } + + private shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal( + record: OpenCodePromptDeliveryLedgerRecord + ): boolean { + if (!this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) { + return false; + } + if (record.status === 'failed_terminal') { + return false; + } + return isActionRequiredOpenCodeRuntimeDeliveryReason( + selectOpenCodeRuntimeDeliveryReason(record) + ); + } + + private async fireOpenCodeRuntimeDeliveryErrorNotification( + record: OpenCodePromptDeliveryLedgerRecord + ): Promise { + const reason = this.selectOpenCodeRuntimeDeliveryNotificationReason(record); + if (!reason) { + return; + } + + const config = await this.readConfigSnapshot(record.teamName).catch(() => null); + const teamDisplayName = config?.name?.trim() || record.teamName; + const taskLabel = record.taskRefs[0]?.displayId?.trim() + ? `#${record.taskRefs[0].displayId.trim()}` + : null; + const context = taskLabel ? ` while handling ${taskLabel}` : ''; + const body = `Team ${teamDisplayName}: @${record.memberName} hit an OpenCode runtime delivery error${context}. ${reason}`; + + try { + await NotificationManager.getInstance().addTeamNotification({ + teamEventType: 'api_error', + teamName: record.teamName, + teamDisplayName, + from: record.memberName, + summary: taskLabel + ? `OpenCode runtime error ${taskLabel}` + : 'OpenCode runtime delivery error', + body, + dedupeKey: `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}`, + target: { + kind: 'member', + teamName: record.teamName, + memberName: record.memberName, + focus: 'messages', + }, + projectPath: config?.projectPath, + }); + } catch (error) { + logger.warn( + `[${record.teamName}] Failed to store OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}` + ); + } + + this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(record); + + await this.notifyLeadAboutOpenCodeRuntimeDeliveryError({ + record, + reason, + taskLabel, + }); + } + + private emitOpenCodeRuntimeDeliveryAdvisoryEvent( + record: OpenCodePromptDeliveryLedgerRecord + ): void { + try { + this.memberRuntimeAdvisoryInvalidator?.(record.teamName, record.memberName); + } catch (error) { + logger.warn( + `[${record.teamName}] Failed to invalidate OpenCode runtime advisory cache for ${record.memberName}: ${getErrorMessage(error)}` + ); + } + + const reasonKey = this.getOpenCodeRuntimeDeliveryAdvisoryReasonKey(record); + const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}:${reasonKey}`; + const now = Date.now(); + this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now); + if (this.openCodeRuntimeDeliveryAdvisoryEventSentAt.has(eventKey)) { + return; + } + + try { + this.teamChangeEmitter?.({ + type: 'member-advisory', + teamName: record.teamName, + detail: `opencode-runtime-delivery-error:${record.memberName}:${record.id}`, + }); + this.openCodeRuntimeDeliveryAdvisoryEventSentAt.set(eventKey, now); + } catch (error) { + logger.warn( + `[${record.teamName}] Failed to emit member advisory refresh for ${record.memberName}: ${getErrorMessage(error)}` + ); + } + } + + private pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now: number): void { + const ttlMs = TeamProvisioningService.OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS; + for (const [key, sentAt] of this.openCodeRuntimeDeliveryAdvisoryEventSentAt) { + if (now - sentAt > ttlMs) { + this.openCodeRuntimeDeliveryAdvisoryEventSentAt.delete(key); + } + } + } + + private getOpenCodeRuntimeDeliveryAdvisoryReasonKey( + record: OpenCodePromptDeliveryLedgerRecord + ): string { + const reason = + selectOpenCodeRuntimeDeliveryReason(record) ?? record.responseState ?? record.status; + const normalized = reason + .toLowerCase() + .replace(/https?:\/\/\S+/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 96); + return normalized || 'unknown'; + } + + private async notifyLeadAboutOpenCodeRuntimeDeliveryError(input: { + record: OpenCodePromptDeliveryLedgerRecord; + reason: string; + taskLabel: string | null; + }): Promise { + const runId = this.getAliveRunId(input.record.teamName); + const run = runId ? this.runs.get(runId) : null; + if (!run || run.processKilled || run.cancelRequested) { + return; + } + + const noticeKey = `opencode_runtime_delivery_error:${input.record.teamName}:${input.record.memberName}:${input.record.id}`; + const now = Date.now(); + this.pruneOpenCodeRuntimeDeliveryLeadNoticeDedupe(now); + if (this.openCodeRuntimeDeliveryLeadNoticeSentAt.has(noticeKey)) { + return; + } + + this.openCodeRuntimeDeliveryLeadNoticeSentAt.set(noticeKey, now); + const taskContext = input.taskLabel ? ` while handling ${input.taskLabel}` : ''; + const message = [ + `System notice: OpenCode teammate @${input.record.memberName} hit a runtime delivery error${taskContext}.`, + `Reason: ${input.reason}`, + `Treat @${input.record.memberName} as unavailable for that work until retry or restart succeeds.`, + `Do not message the human user solely because of this notice unless user action is required.`, + ].join(' '); + + try { + await this.sendMessageToRun(run, message); + } catch (error) { + this.openCodeRuntimeDeliveryLeadNoticeSentAt.delete(noticeKey); + logger.warn( + `[${input.record.teamName}] Failed to notify lead about OpenCode runtime delivery error for ${input.record.memberName}: ${getErrorMessage(error)}` + ); + } + } + + private pruneOpenCodeRuntimeDeliveryLeadNoticeDedupe(now: number): void { + const ttlMs = TeamProvisioningService.OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS; + for (const [key, sentAt] of this.openCodeRuntimeDeliveryLeadNoticeSentAt) { + if (now - sentAt > ttlMs) { + this.openCodeRuntimeDeliveryLeadNoticeSentAt.delete(key); + } + } + } + + private selectOpenCodeRuntimeDeliveryNotificationReason( + record: OpenCodePromptDeliveryLedgerRecord + ): string | null { + return selectOpenCodeRuntimeDeliveryReason(record); } async scanOpenCodePromptDeliveryWatchdog(teamName: string): Promise { @@ -8068,10 +8472,31 @@ export class TeamProvisioningService { } } + const retryReadAllowed = ledgerRecord + ? this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode ?? undefined, + taskRefs: ledgerRecord.taskRefs, + visibleReply: null, + ledgerRecord, + }) + : false; + const retryPendingReason = ledgerRecord + ? this.getOpenCodeDeliveryPendingReason({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode, + taskRefs: ledgerRecord.taskRefs, + visibleReply: null, + ledgerRecord, + }) + : 'opencode_delivery_response_pending'; const deliveryText = this.buildOpenCodePromptDeliveryAttemptText({ - ledgerRecord, text: input.text, - replyRecipient: input.replyRecipient ?? ledgerRecord?.replyRecipient ?? 'user', + controlText: this.buildOpenCodePromptDeliveryRepairControlText({ + ledgerRecord, + readAllowed: retryReadAllowed, + pendingReason: retryPendingReason, + }), }); const result = await adapter.sendMessageToMember({ ...(runtimeRunId ? { runId: runtimeRunId } : {}), @@ -8098,16 +8523,18 @@ export class TeamProvisioningService { const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( result.responseObservation ); + const promptAccepted = + result.ok || this.isOpenCodePromptAcceptedByObservation(responseObservation); if (ledgerRecord && ledger) { ledgerRecord = await ledger.applyDeliveryResult({ id: ledgerRecord.id, - accepted: result.ok, + accepted: promptAccepted, attempted: true, responseObservation, sessionId: result.sessionId, prePromptCursor: result.prePromptCursor, diagnostics: result.diagnostics, - reason: result.ok ? responseObservation?.reason : result.diagnostics[0], + reason: promptAccepted ? responseObservation?.reason : result.diagnostics[0], now: nowIso(), }); let proof = await this.applyOpenCodeVisibleDestinationProof({ @@ -8127,7 +8554,7 @@ export class TeamProvisioningService { }); ledgerRecord = proof.ledgerRecord; this.logOpenCodePromptDeliveryEvent( - result.ok + promptAccepted ? ledgerRecord.status === 'unanswered' ? 'opencode_prompt_delivery_unanswered' : ledgerRecord.status === 'responded' @@ -8135,7 +8562,10 @@ export class TeamProvisioningService { : 'opencode_prompt_delivery_prompt_accepted' : 'opencode_prompt_delivery_retry_scheduled', ledgerRecord, - { accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null } + { + accepted: promptAccepted, + reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null, + } ); } const responseState = ledgerRecord?.responseState ?? responseObservation?.state; @@ -8160,7 +8590,7 @@ export class TeamProvisioningService { visibleReply, ledgerRecord, }); - if (ledgerRecord && result.ok && !readAllowed) { + if (ledgerRecord && promptAccepted && !readAllowed) { const retry = this.isOpenCodeDeliveryRetryablePendingResponse({ ledgerRecord, visibleReply, @@ -8196,7 +8626,7 @@ export class TeamProvisioningService { }; } } - if (ledgerRecord && !result.ok) { + if (ledgerRecord && !promptAccepted) { const reason = this.isOpenCodePromptAcceptanceUnknownFailure(result.diagnostics) ? 'opencode_prompt_acceptance_unknown_after_bridge_timeout' : (result.diagnostics[0] ?? 'opencode_message_delivery_failed'); @@ -8239,9 +8669,9 @@ export class TeamProvisioningService { ledgerRecord?.visibleReplyCorrelation ?? responseObservation?.visibleReplyCorrelation ?? undefined; - const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok); + const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !promptAccepted); const responsePending = - acceptanceUnknown || (result.ok && Boolean(ledgerRecord || responseObservation)) + acceptanceUnknown || (promptAccepted && Boolean(ledgerRecord || responseObservation)) ? !readAllowed : false; const pendingReason = @@ -8255,8 +8685,8 @@ export class TeamProvisioningService { ? ledgerRecord.diagnostics : result.diagnostics; return { - delivered: result.ok || acceptanceUnknown, - ...(ledgerRecord || responseObservation ? { accepted: result.ok } : {}), + delivered: promptAccepted || acceptanceUnknown, + ...(ledgerRecord || responseObservation ? { accepted: promptAccepted } : {}), ...(ledgerRecord || responseObservation ? { responsePending } : {}), ...(acceptanceUnknown ? { acceptanceUnknown: true } : {}), ...(ledgerRecord @@ -8279,7 +8709,7 @@ export class TeamProvisioningService { : {}), ...(pendingReason ? { reason: pendingReason } - : result.ok + : promptAccepted ? {} : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), diagnostics, @@ -9927,6 +10357,8 @@ export class TeamProvisioningService { memberName: string; runtimeSessionId: string; observedAt: string; + source?: OpenCodeBootstrapEvidenceSource; + appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate; }): Promise { const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find( (candidate) => candidate.schemaName === 'opencode.sessionStore' @@ -9944,6 +10376,7 @@ export class TeamProvisioningService { await fs.promises.mkdir(runtimeDirectory, { recursive: true }); const sessionStorePath = path.join(runtimeDirectory, descriptor.relativePath); const existingSessions = await this.readOpenCodeRuntimeSessionStore(sessionStorePath); + const source = input.source ?? 'runtime_bootstrap_checkin'; const session = { id: input.runtimeSessionId, teamName: input.teamName, @@ -9952,7 +10385,10 @@ export class TeamProvisioningService { laneId: input.laneId, providerId: 'opencode', observedAt: input.observedAt, - source: 'runtime_bootstrap_checkin', + source, + ...(source === 'app_managed_bootstrap' && input.appManagedBootstrapCandidate + ? { appManagedBootstrapCandidate: input.appManagedBootstrapCandidate } + : {}), }; const sessions = this.mergeOpenCodeRuntimeSessionRecords(existingSessions, session); const manifestStore = createRuntimeStoreManifestStore({ @@ -9989,6 +10425,11 @@ export class TeamProvisioningService { } throw error; } + if (!(await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input))) { + throw new Error( + `OpenCode bootstrap session evidence write did not verify for ${input.memberName}` + ); + } } private async hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input: { @@ -9997,6 +10438,8 @@ export class TeamProvisioningService { laneId: string; memberName: string; runtimeSessionId: string; + source?: OpenCodeBootstrapEvidenceSource; + appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate; }): Promise { const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ teamsBasePath: getTeamsBasePath(), @@ -10009,12 +10452,28 @@ export class TeamProvisioningService { if (evidence.activeRunId && evidence.activeRunId.trim() !== input.runId) { return false; } - return evidence.sessions.some( - (session) => - session.id === input.runtimeSessionId && - session.runId === input.runId && - namesMatchCaseInsensitive(session.memberName, input.memberName) - ); + return evidence.sessions.some((session) => { + if ( + session.id !== input.runtimeSessionId || + session.runId !== input.runId || + !namesMatchCaseInsensitive(session.memberName, input.memberName) + ) { + return false; + } + if (input.source && session.source !== input.source) { + return false; + } + if (input.source === 'app_managed_bootstrap' && input.appManagedBootstrapCandidate) { + const candidate = session.appManagedBootstrapCandidate; + return ( + candidate?.runtimeSessionId === input.appManagedBootstrapCandidate.runtimeSessionId && + candidate.messageID === input.appManagedBootstrapCandidate.messageID && + candidate.contextHash === input.appManagedBootstrapCandidate.contextHash && + candidate.briefingHash === input.appManagedBootstrapCandidate.briefingHash + ); + } + return true; + }); } private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: { @@ -10268,6 +10727,7 @@ export class TeamProvisioningService { runId, taskId, detail: `opencode-runtime-task-event:${event}`, + taskSignalKind: 'log', }); return { @@ -10771,6 +11231,139 @@ export class TeamProvisioningService { return null; } + async getOpenCodeMemberDeliveryBusyStatus(input: { + teamName: string; + memberName: string; + nowIso: string; + }): Promise<{ + busy: boolean; + reason?: string; + retryAfterIso?: string; + activeMessageId?: string; + activeMessageKind?: string | null; + }> { + if (!(await this.isOpenCodeRuntimeRecipient(input.teamName, input.memberName))) { + return { busy: false }; + } + + const nowMs = Date.parse(input.nowIso); + const retryAfterIso = new Date( + (Number.isFinite(nowMs) ? nowMs : Date.now()) + 60_000 + ).toISOString(); + + let inboxMessages: Awaited>; + try { + inboxMessages = await this.inboxReader.getMessagesFor(input.teamName, input.memberName); + } catch { + return { + busy: true, + reason: 'opencode_inbox_read_failed', + retryAfterIso, + }; + } + + const foregroundMessages = inboxMessages.filter( + (message) => message.messageKind !== 'member_work_sync_nudge' + ); + const unreadForeground = foregroundMessages.find( + (message) => + !message.read && + typeof message.text === 'string' && + message.text.trim().length > 0 && + this.hasStableMessageId(message) + ); + if (unreadForeground?.messageId) { + return { + busy: true, + reason: 'opencode_foreground_inbox_unread', + retryAfterIso, + activeMessageId: unreadForeground.messageId, + activeMessageKind: unreadForeground.messageKind ?? null, + }; + } + + const recentForeground = foregroundMessages.find((message) => { + const timestampMs = Date.parse(message.timestamp); + return Number.isFinite(timestampMs) && Number.isFinite(nowMs) && nowMs - timestampMs < 60_000; + }); + if (recentForeground?.messageId) { + return { + busy: true, + reason: 'opencode_foreground_inbox_recent', + retryAfterIso, + activeMessageId: recentForeground.messageId, + activeMessageKind: recentForeground.messageKind ?? null, + }; + } + + const identity = await this.resolveOpenCodeMemberDeliveryIdentity( + input.teamName, + input.memberName + ); + if (!identity.ok) { + return { busy: true, reason: identity.reason, retryAfterIso }; + } + + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), input.teamName).catch( + () => null + ); + if (!laneIndex) { + return { busy: true, reason: 'opencode_lane_index_unavailable', retryAfterIso }; + } + if (laneIndex.lanes[identity.laneId]?.state !== 'active') { + return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso }; + } + + let activeRecord: OpenCodePromptDeliveryLedgerRecord | null; + try { + activeRecord = await this.createOpenCodePromptDeliveryLedger( + input.teamName, + identity.laneId + ).getActiveForMember({ + teamName: input.teamName, + memberName: identity.canonicalMemberName, + laneId: identity.laneId, + }); + } catch { + return { + busy: true, + reason: 'opencode_prompt_ledger_unavailable', + retryAfterIso, + }; + } + if (activeRecord) { + return { + busy: true, + reason: `opencode_prompt_delivery_active:${activeRecord.messageKind ?? 'default'}`, + retryAfterIso: activeRecord.nextAttemptAt ?? retryAfterIso, + activeMessageId: activeRecord.inboxMessageId, + activeMessageKind: activeRecord.messageKind, + }; + } + + return { busy: false }; + } + + scheduleOpenCodeMemberInboxDeliveryWake(input: { + teamName: string; + memberName: string; + messageId: string; + delayMs?: number; + }): void { + const teamName = input.teamName.trim(); + const memberName = input.memberName.trim(); + const messageId = input.messageId.trim(); + if (!teamName || !memberName || !messageId || !this.isOpenCodePromptDeliveryWatchdogEnabled()) { + return; + } + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName, + memberName, + messageId, + delayMs: Math.max(0, input.delayMs ?? 500), + }); + } + private toOpenCodeRuntimeDeliveryStatus( record: OpenCodePromptDeliveryLedgerRecord ): OpenCodeRuntimeDeliveryStatus { @@ -11892,6 +12485,14 @@ export class TeamProvisioningService { ); const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + const spawnStatusSnapshot = await this.getMemberSpawnStatuses(teamName).catch(() => null); + const activeRuntimeRunId = + run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || ''; + const spawnStatusRunId = spawnStatusSnapshot?.runId?.trim() ?? ''; + const canUseLiveSpawnStatusRuntimeTruth = + spawnStatusSnapshot?.source === 'live' && + activeRuntimeRunId.length > 0 && + spawnStatusRunId === activeRuntimeRunId; const runtimePids = new Set(); const leadPid = run?.child?.pid; if (typeof leadPid === 'number' && Number.isFinite(leadPid) && leadPid > 0) { @@ -11928,6 +12529,23 @@ export class TeamProvisioningService { } return fallback; }; + const getSpawnStatusMember = (memberName: string): MemberSpawnStatusEntry | undefined => { + const statuses = spawnStatusSnapshot?.statuses; + if (!statuses) { + return undefined; + } + const direct = statuses[memberName]; + if (direct) { + return direct; + } + let fallback: MemberSpawnStatusEntry | undefined; + for (const [candidateName, status] of Object.entries(statuses)) { + if (matchesMemberNameOrBase(candidateName, memberName)) { + fallback = status; + } + } + return fallback; + }; const candidateMembers = new Map(); for (const member of configuredMembers) { @@ -11988,6 +12606,7 @@ export class TeamProvisioningService { const persistedRuntimeMember = getPersistedRuntimeMember(memberName); const liveRuntimeMember = getLiveRuntimeMember(memberName); + const spawnStatusMember = getSpawnStatusMember(memberName); const launchMember = launchSnapshot?.members[memberName]; const backendType = liveRuntimeMember?.backendType ?? @@ -12023,7 +12642,38 @@ export class TeamProvisioningService { : backendType !== 'in-process'; const historicalBootstrapConfirmed = launchMember?.bootstrapConfirmed === true || - launchMember?.launchState === 'confirmed_alive'; + launchMember?.launchState === 'confirmed_alive' || + spawnStatusMember?.bootstrapConfirmed === true || + spawnStatusMember?.launchState === 'confirmed_alive'; + const hasOpenCodeRuntimeHandle = + isOpenCodeMember && + (typeof liveRuntimeMember?.pid === 'number' || + typeof liveRuntimeMember?.metricsPid === 'number' || + typeof liveRuntimeMember?.runtimeSessionId === 'string'); + const confirmedOpenCodeRuntimeAlive = + isOpenCodeMember && + canUseLiveSpawnStatusRuntimeTruth && + historicalBootstrapConfirmed && + hasOpenCodeRuntimeHandle && + spawnStatusMember?.hardFailure !== true && + spawnStatusMember?.launchState !== 'failed_to_start' && + spawnStatusMember?.launchState !== 'runtime_pending_permission'; + const effectiveAlive = liveRuntimeMember?.alive === true || confirmedOpenCodeRuntimeAlive; + const effectiveLivenessKind = + confirmedOpenCodeRuntimeAlive && + liveRuntimeMember?.livenessKind === 'runtime_process_candidate' + ? 'confirmed_bootstrap' + : liveRuntimeMember?.livenessKind; + const effectiveRuntimeDiagnostic = + confirmedOpenCodeRuntimeAlive && + liveRuntimeMember?.livenessKind === 'runtime_process_candidate' + ? 'OpenCode bootstrap confirmed; runtime host/session evidence present.' + : liveRuntimeMember?.runtimeDiagnostic; + const effectiveRuntimeDiagnosticSeverity = + confirmedOpenCodeRuntimeAlive && + liveRuntimeMember?.livenessKind === 'runtime_process_candidate' + ? 'info' + : liveRuntimeMember?.runtimeDiagnosticSeverity; let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined; if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) { try { @@ -12039,7 +12689,7 @@ export class TeamProvisioningService { snapshotMembers[memberName] = { memberName, - alive: liveRuntimeMember?.alive === true, + alive: effectiveAlive, restartable, ...(backendType ? { backendType } : {}), ...(memberProviderId ? { providerId: memberProviderId } : {}), @@ -12052,9 +12702,7 @@ export class TeamProvisioningService { ...(runtimeModel ? { runtimeModel } : {}), ...(runtimeCwd ? { cwd: runtimeCwd } : {}), ...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}), - ...(liveRuntimeMember?.livenessKind - ? { livenessKind: liveRuntimeMember.livenessKind } - : {}), + ...(effectiveLivenessKind ? { livenessKind: effectiveLivenessKind } : {}), ...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}), ...(liveRuntimeMember?.processCommand ? { processCommand: liveRuntimeMember.processCommand } @@ -12072,11 +12720,9 @@ export class TeamProvisioningService { ? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt } : {}), ...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}), - ...(liveRuntimeMember?.runtimeDiagnostic - ? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic } - : {}), - ...(liveRuntimeMember?.runtimeDiagnosticSeverity - ? { runtimeDiagnosticSeverity: liveRuntimeMember.runtimeDiagnosticSeverity } + ...(effectiveRuntimeDiagnostic ? { runtimeDiagnostic: effectiveRuntimeDiagnostic } : {}), + ...(effectiveRuntimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: effectiveRuntimeDiagnosticSeverity } : {}), ...(liveRuntimeMember?.diagnostics ? { diagnostics: liveRuntimeMember.diagnostics } : {}), updatedAt, @@ -16166,16 +16812,6 @@ export class TeamProvisioningService { emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); await this.clearPersistedLaunchState(request.teamName); - emitProvisioningCheckpoint( - run, - 'Building deterministic create bootstrap spec', - `expectedMembers=${effectiveMemberSpecs.length}` - ); - const bootstrapSpec = buildDeterministicCreateBootstrapSpec( - runId, - request, - effectiveMemberSpecs - ); const initialUserPrompt = request.prompt?.trim() ?? ''; const promptSize = getPromptSizeSummary(initialUserPrompt); let child: ReturnType; @@ -16186,6 +16822,52 @@ export class TeamProvisioningService { let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; try { + // Pre-save our meta files before native app-managed briefing generation. + // member_briefing intentionally reads canonical team metadata/inboxes, so + // createTeam must materialize those files before building the bootstrap spec. + emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); + const teamDir = path.join(getTeamsBasePath(), request.teamName); + const tasksDir = path.join(getTasksBasePath(), request.teamName); + await fs.promises.mkdir(teamDir, { recursive: true }); + await fs.promises.mkdir(tasksDir, { recursive: true }); + await this.teamMetaStore.writeMeta(request.teamName, { + displayName: request.displayName, + description: request.description, + color: request.color, + cwd: request.cwd, + prompt: request.prompt, + providerId: request.providerId, + providerBackendId: request.providerBackendId, + model: request.model, + effort: request.effort, + fastMode: request.fastMode, + skipPermissions: request.skipPermissions, + worktree: request.worktree, + extraCliArgs: request.extraCliArgs, + limitContext: request.limitContext, + launchIdentity, + createdAt: Date.now(), + }); + const membersToWrite = this.buildMembersMetaWritePayload(allEffectiveMemberSpecs); + await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { + providerBackendId: request.providerBackendId, + }); + emitProvisioningCheckpoint( + run, + 'Building deterministic create bootstrap spec', + `expectedMembers=${effectiveMemberSpecs.length}` + ); + const nativeAppManagedBootstrapByMember = await buildNativeAppManagedBootstrapSpecs({ + teamName: request.teamName, + cwd: request.cwd, + members: effectiveMemberSpecs, + }); + const bootstrapSpec = buildDeterministicCreateBootstrapSpec( + runId, + request, + effectiveMemberSpecs, + nativeAppManagedBootstrapByMember + ); emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); run.bootstrapSpecPath = bootstrapSpecPath; @@ -16217,6 +16899,11 @@ export class TeamProvisioningService { directory: provisioningEnv.anthropicApiKeyHelper.directory, }).catch(() => undefined); } + await this.teamMetaStore.deleteMeta(request.teamName).catch(() => {}); + const teamDir = path.join(getTeamsBasePath(), request.teamName); + const tasksDir = path.join(getTasksBasePath(), request.teamName); + await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {}); + await fs.promises.rm(tasksDir, { recursive: true, force: true }).catch(() => {}); await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {}); run.bootstrapSpecPath = null; await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch( @@ -16285,35 +16972,6 @@ export class TeamProvisioningService { launchIdentity, }); try { - // Pre-save our meta files before spawn — CLI doesn't touch these. - // If provisioning fails before TeamCreate, user can retry without re-entering config. - emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); - const teamDir = path.join(getTeamsBasePath(), request.teamName); - const tasksDir = path.join(getTasksBasePath(), request.teamName); - await fs.promises.mkdir(teamDir, { recursive: true }); - await fs.promises.mkdir(tasksDir, { recursive: true }); - await this.teamMetaStore.writeMeta(request.teamName, { - displayName: request.displayName, - description: request.description, - color: request.color, - cwd: request.cwd, - prompt: request.prompt, - providerId: request.providerId, - providerBackendId: request.providerBackendId, - model: request.model, - effort: request.effort, - fastMode: request.fastMode, - skipPermissions: request.skipPermissions, - worktree: request.worktree, - extraCliArgs: request.extraCliArgs, - limitContext: request.limitContext, - launchIdentity, - createdAt: Date.now(), - }); - const membersToWrite = this.buildMembersMetaWritePayload(allEffectiveMemberSpecs); - await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { - providerBackendId: request.providerBackendId, - }); if ( run.cancelRequested || run.processKilled || @@ -16656,7 +17314,7 @@ export class TeamProvisioningService { laneId: 'primary', runId, }); - const result = await adapter.launch(launchInput); + const launchResult = await adapter.launch(launchInput); if ( this.cancelledRuntimeAdapterRunIds.delete(runId) || this.provisioningRunByTeam.get(input.request.teamName) !== runId @@ -16664,7 +17322,10 @@ export class TeamProvisioningService { await this.clearOpenCodeRuntimeAdapterPrimaryLaneIfOwned(input.request.teamName, runId); return { runId }; } - await this.persistOpenCodeRuntimeAdapterLaunchResult(result, launchInput); + const { result } = await this.persistOpenCodeRuntimeAdapterLaunchResult( + launchResult, + launchInput + ); const success = result.teamLaunchState === 'clean_success'; const pending = result.teamLaunchState === 'partial_pending'; const failed = result.teamLaunchState === 'partial_failure'; @@ -16794,42 +17455,72 @@ export class TeamProvisioningService { private async persistOpenCodeRuntimeAdapterLaunchResult( result: TeamRuntimeLaunchResult, input: TeamRuntimeLaunchInput - ): Promise { - await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({ + ): Promise<{ + snapshot: PersistedTeamLaunchSnapshot; + result: TeamRuntimeLaunchResult; + }> { + const committedResult = await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({ teamName: input.teamName, laneId: input.laneId?.trim() || 'primary', result, }); const members: Record = {}; for (const member of input.expectedMembers) { - const evidence = result.members[member.name]; - members[member.name] = this.toOpenCodePersistedLaunchMember(member, evidence); + const evidence = committedResult.members[member.name]; + members[member.name] = this.toOpenCodePersistedLaunchMember( + member, + evidence, + committedResult.runId + ); } const snapshot = createPersistedLaunchSnapshot({ teamName: input.teamName, expectedMembers: input.expectedMembers.map((member) => member.name), bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name), leadSessionId: result.leadSessionId, - launchPhase: result.launchPhase, + launchPhase: committedResult.launchPhase, members, }); - return this.writeLaunchStateSnapshot(input.teamName, snapshot); + return { + snapshot: await this.writeLaunchStateSnapshot(input.teamName, snapshot), + result: committedResult, + }; } private async commitOpenCodeRuntimeAdapterLaunchSessionEvidence(params: { teamName: string; laneId: string; result: TeamRuntimeLaunchResult; - }): Promise { + }): Promise { + let changed = false; + const members: Record = { ...params.result.members }; for (const [memberName, evidence] of Object.entries(params.result.members)) { const runtimeSessionId = evidence.sessionId?.trim(); const confirmed = evidence.launchState === 'confirmed_alive' || evidence.bootstrapConfirmed === true || evidence.livenessKind === 'confirmed_bootstrap'; - if (!confirmed || !runtimeSessionId) { + const appManagedCandidate = + evidence.bootstrapEvidenceSource === 'app_managed_bootstrap' && + evidence.bootstrapMode === 'app_managed_context' + ? evidence.appManagedBootstrapCandidate + : undefined; + const appManagedCandidateMatches = + appManagedCandidate?.source === 'app_managed_bootstrap' && + appManagedCandidate.teamName === params.teamName && + appManagedCandidate.memberName === memberName && + appManagedCandidate.runId === params.result.runId && + appManagedCandidate.laneId === params.laneId && + appManagedCandidate.runtimeSessionId === runtimeSessionId; + if ((!confirmed && !appManagedCandidateMatches) || !runtimeSessionId) { continue; } + // For app-managed bootstrap, promotion is intentionally two-phase: + // write the candidate as runtime evidence, then verify it using the same + // reader path used by later reconciliation/restart flows. + const source: OpenCodeBootstrapEvidenceSource = appManagedCandidateMatches + ? 'app_managed_bootstrap' + : (evidence.bootstrapEvidenceSource ?? 'runtime_bootstrap_checkin'); await this.commitOpenCodeRuntimeBootstrapSessionEvidence({ teamName: params.teamName, runId: params.result.runId, @@ -16837,13 +17528,47 @@ export class TeamProvisioningService { memberName, runtimeSessionId, observedAt: nowIso(), + source, + appManagedBootstrapCandidate: appManagedCandidateMatches + ? appManagedCandidate + : evidence.appManagedBootstrapCandidate, }); + const verified = await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence({ + teamName: params.teamName, + runId: params.result.runId, + laneId: params.laneId, + memberName, + runtimeSessionId, + source, + appManagedBootstrapCandidate: appManagedCandidateMatches + ? appManagedCandidate + : evidence.appManagedBootstrapCandidate, + }); + if (appManagedCandidateMatches && verified && !confirmed) { + members[memberName] = promoteCommittedOpenCodeAppManagedBootstrapEvidence(evidence); + changed = true; + } } + if (!changed) { + return params.result; + } + const teamLaunchState = summarizeRuntimeLaunchResultMembers(members); + return { + ...params.result, + launchPhase: teamLaunchState === 'clean_success' ? 'finished' : params.result.launchPhase, + teamLaunchState, + members, + diagnostics: appendDiagnosticOnce( + params.result.diagnostics, + 'OpenCode app-managed bootstrap evidence was committed and read back before readiness promotion.' + ), + }; } private toOpenCodePersistedLaunchMember( member: TeamRuntimeLaunchInput['expectedMembers'][number], - evidence: TeamRuntimeMemberLaunchEvidence | undefined + evidence: TeamRuntimeMemberLaunchEvidence | undefined, + runId?: string ): PersistedTeamLaunchMemberState { const now = nowIso(); const launchState = evidence?.launchState ?? 'failed_to_start'; @@ -16869,10 +17594,24 @@ export class TeamProvisioningService { : undefined, ...(evidence?.runtimePid ? { runtimePid: evidence.runtimePid } : {}), ...(evidence?.sessionId ? { runtimeSessionId: evidence.sessionId } : {}), + ...(evidence?.sessionId + ? { runtimeRunId: evidence.appManagedBootstrapCandidate?.runId ?? runId } + : {}), + ...(evidence?.bootstrapEvidenceSource + ? { bootstrapEvidenceSource: evidence.bootstrapEvidenceSource } + : {}), + ...(evidence?.bootstrapMode ? { bootstrapMode: evidence.bootstrapMode } : {}), + ...(evidence?.appManagedBootstrapCandidate + ? { appManagedBootstrapCandidate: evidence.appManagedBootstrapCandidate } + : {}), ...(evidence?.livenessKind ? { livenessKind: evidence.livenessKind } : {}), ...(evidence?.pidSource ? { pidSource: evidence.pidSource } : {}), ...(evidence?.runtimeDiagnostic ? { runtimeDiagnostic: evidence.runtimeDiagnostic } : {}), - ...(evidence?.runtimeDiagnostic ? { runtimeDiagnosticSeverity: 'info' as const } : {}), + ...(evidence?.runtimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: evidence.runtimeDiagnosticSeverity } + : evidence?.runtimeDiagnostic + ? { runtimeDiagnosticSeverity: 'info' as const } + : {}), ...(evidence?.runtimeAlive ? { runtimeLastSeenAt: now } : {}), firstSpawnAcceptedAt: evidence?.agentToolAccepted ? now : undefined, lastHeartbeatAt: evidence?.bootstrapConfirmed ? now : undefined, @@ -17387,7 +18126,12 @@ export class TeamProvisioningService { const bootstrapSpec = buildDeterministicLaunchBootstrapSpec( runId, request, - effectiveMemberSpecs + effectiveMemberSpecs, + await buildNativeAppManagedBootstrapSpecs({ + teamName: request.teamName, + cwd: request.cwd, + members: effectiveMemberSpecs, + }) ); emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); @@ -18364,7 +19108,13 @@ export class TeamProvisioningService { if (typeof message.text !== 'string' || message.text.trim().length === 0) return false; return this.hasStableMessageId(message); }) - .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) + .sort((a, b) => { + const priorityDelta = getOpenCodeInboxRelayPriority(a) - getOpenCodeInboxRelayPriority(b); + if (priorityDelta !== 0) return priorityDelta; + const timeDelta = Date.parse(a.timestamp) - Date.parse(b.timestamp); + if (timeDelta !== 0) return timeDelta; + return a.messageId.localeCompare(b.messageId); + }) .slice(0, 10); for (const message of unread) { @@ -18545,6 +19295,17 @@ export class TeamProvisioningService { }); result.lastDelivery = delivery; if (!delivery.delivered) { + if (delivery.accepted === true) { + const diagnostics = delivery.diagnostics ?? [ + delivery.reason ?? 'opencode_delivery_response_pending', + ]; + result.diagnostics = [...(result.diagnostics ?? []), ...diagnostics]; + result.lastDelivery = { + ...delivery, + diagnostics, + }; + break; + } result.failed += 1; result.diagnostics = [ ...(result.diagnostics ?? []), @@ -19092,26 +19853,35 @@ export class TeamProvisioningService { // Strip agent-only blocks — lead may respond with pure coordination content // that is not meant for the human user. - const cleanReply = replyText ? stripAgentBlocks(replyText) : null; + const cleanReply = replyText + ? stripExactInternalControlEchoPrefix( + stripAgentBlocks(replyText), + stripAgentBlocks(message) + ) + : null; if (cleanReply) { - const relayMsg: InboxMessage = { - from: leadName, - to: 'user', - text: cleanReply, - timestamp: nowIso(), - read: true, - summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply, - messageId: `lead-process-${runId}-${Date.now()}`, - source: 'lead_process', - }; - this.pushLiveLeadProcessMessage(teamName, relayMsg); - // Persist to disk so relayed replies survive app restart and trigger FileWatcher - this.persistSentMessage(teamName, relayMsg); - this.teamChangeEmitter?.({ - type: 'inbox', - teamName, - detail: 'lead-process-reply', - }); + if (isTeamInternalControlMessageText(cleanReply)) { + logger.debug(`[${teamName}] Suppressed internal lead relay echo`); + } else { + const relayMsg: InboxMessage = { + from: leadName, + to: 'user', + text: cleanReply, + timestamp: nowIso(), + read: true, + summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply, + messageId: `lead-process-${runId}-${Date.now()}`, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(teamName, relayMsg); + // Persist to disk so relayed replies survive app restart and trigger FileWatcher + this.persistSentMessage(teamName, relayMsg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName, + detail: 'lead-process-reply', + }); + } } return batch.length; @@ -20172,6 +20942,74 @@ export class TeamProvisioningService { return undefined; } + private buildLaunchMemberSpawnStatus( + member: PersistedTeamLaunchMemberState | undefined, + runtimeModel?: string + ): MemberSpawnStatusEntry | undefined { + if (!member) { + return undefined; + } + return { + status: member.hardFailure + ? 'error' + : member.bootstrapConfirmed || member.launchState === 'confirmed_alive' + ? 'online' + : member.agentToolAccepted + ? 'waiting' + : 'spawning', + launchState: member.launchState, + ...(member.hardFailureReason ? { hardFailureReason: member.hardFailureReason } : {}), + ...(member.pendingPermissionRequestIds?.length + ? { pendingPermissionRequestIds: member.pendingPermissionRequestIds } + : {}), + agentToolAccepted: member.agentToolAccepted, + runtimeAlive: member.runtimeAlive, + bootstrapConfirmed: member.bootstrapConfirmed, + hardFailure: member.hardFailure, + ...(runtimeModel ? { runtimeModel } : {}), + ...(member.livenessKind ? { livenessKind: member.livenessKind } : {}), + ...(member.runtimeDiagnostic ? { runtimeDiagnostic: member.runtimeDiagnostic } : {}), + ...(member.runtimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity } + : {}), + ...(member.bootstrapStalled ? { bootstrapStalled: true } : {}), + ...(member.firstSpawnAcceptedAt ? { firstSpawnAcceptedAt: member.firstSpawnAcceptedAt } : {}), + ...(member.lastHeartbeatAt ? { lastHeartbeatAt: member.lastHeartbeatAt } : {}), + updatedAt: member.lastEvaluatedAt, + }; + } + + private shouldPreferCurrentLaunchMemberStatus( + trackedStatus: MemberSpawnStatusEntry | undefined, + launchStatus: MemberSpawnStatusEntry | undefined + ): boolean { + if (!launchStatus?.bootstrapConfirmed && launchStatus?.launchState !== 'confirmed_alive') { + return false; + } + if (!trackedStatus) { + return true; + } + return ( + trackedStatus.hardFailure !== true && + trackedStatus.launchState !== 'failed_to_start' && + trackedStatus.launchState !== 'runtime_pending_permission' + ); + } + + private isLaunchMemberStatusRelevantToRuntimeRun( + member: PersistedTeamLaunchMemberState | undefined, + activeRuntimeRunId: string + ): boolean { + if (!member || activeRuntimeRunId.length === 0) { + return false; + } + const memberRuntimeRunId = member.runtimeRunId?.trim() ?? ''; + if (member.providerId === 'opencode') { + return memberRuntimeRunId.length > 0 && memberRuntimeRunId === activeRuntimeRunId; + } + return memberRuntimeRunId.length === 0 || memberRuntimeRunId === activeRuntimeRunId; + } + private async getLiveTeamAgentRuntimeMetadata( teamName: string ): Promise> { @@ -20379,7 +21217,12 @@ export class TeamProvisioningService { } const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName); - const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null); + const persistedLaunchSnapshot = choosePreferredLaunchSnapshot( + await readBootstrapLaunchSnapshot(teamName).catch(() => null), + await this.launchStateStore.read(teamName).catch(() => null) + ); + const activeRuntimeRunId = + run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || ''; for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) { const memberName = persistedMember.name?.trim() ?? ''; if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) { @@ -20498,7 +21341,6 @@ export class TeamProvisioningService { updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(), } : undefined; - const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus; const shouldUseWindowsHostRows = process.platform === 'win32' && (metadata.providerId === 'opencode' || @@ -20513,6 +21355,15 @@ export class TeamProvisioningService { const memberProcessTableAvailable = shouldUseWindowsHostRows ? windowsHostProcessTableAvailable || processTableAvailable : processTableAvailable; + const trackedStatus = this.findTrackedMemberSpawnStatus(run, memberName); + const launchStatus = + this.isLaunchMemberStatusRelevantToRuntimeRun(launchMember, activeRuntimeRunId) && + launchMember + ? this.buildLaunchMemberSpawnStatus(launchMember, metadata.model) + : undefined; + const status = this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, launchStatus) + ? launchStatus + : (trackedStatus ?? adapterStatus ?? launchStatus); const resolved = resolveTeamMemberRuntimeLiveness({ teamName, memberName, @@ -21577,6 +22428,10 @@ export class TeamProvisioningService { result: TeamRuntimeLaunchResult; memberName: string; }): Promise { + // OpenCode launch can now return an app-managed bootstrap candidate without + // a model tool call. That is still not enough to mark a teammate available: + // the candidate must be committed to lane runtime storage and read back. + // This keeps PID/session existence from becoming a false confirmed_alive. const memberEvidence = params.result.members[params.memberName]; if (!memberEvidence) { return params.result; @@ -21586,14 +22441,28 @@ export class TeamProvisioningService { memberEvidence.launchState === 'confirmed_alive' || memberEvidence.bootstrapConfirmed === true || memberEvidence.livenessKind === 'confirmed_bootstrap'; - if (!claimsBootstrapConfirmed) { + const runtimeSessionId = memberEvidence.sessionId?.trim(); + const appManagedCandidate = + memberEvidence.bootstrapEvidenceSource === 'app_managed_bootstrap' && + memberEvidence.bootstrapMode === 'app_managed_context' + ? memberEvidence.appManagedBootstrapCandidate + : undefined; + const appManagedCandidateMatches = + appManagedCandidate?.source === 'app_managed_bootstrap' && + appManagedCandidate.teamName === params.teamName && + appManagedCandidate.memberName === params.memberName && + appManagedCandidate.runId === params.result.runId && + appManagedCandidate.laneId === params.laneId && + appManagedCandidate.runtimeSessionId === runtimeSessionId; + if (!claimsBootstrapConfirmed && !appManagedCandidateMatches) { return params.result; } - await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({ + const committedResult = await this.commitOpenCodeRuntimeAdapterLaunchSessionEvidence({ teamName: params.teamName, laneId: params.laneId, result: params.result, }); + const committedMemberEvidence = committedResult.members[params.memberName] ?? memberEvidence; const storage = await inspectOpenCodeRuntimeLaneStorage({ teamsBasePath: getTeamsBasePath(), @@ -21601,14 +22470,17 @@ export class TeamProvisioningService { laneId: params.laneId, }); if (storage.hasRuntimeEvidenceOnDisk) { - return params.result; + return committedResult; + } + if (!claimsBootstrapConfirmed) { + return committedResult; } const diagnostics = buildOpenCodeUncommittedBootstrapDiagnostic(storage); const members = { - ...params.result.members, + ...committedResult.members, [params.memberName]: downgradeUncommittedOpenCodeBootstrapEvidence( - memberEvidence, + committedMemberEvidence, diagnostics ), }; @@ -21630,10 +22502,36 @@ export class TeamProvisioningService { launchPhase: teamLaunchState === 'clean_success' ? params.result.launchPhase : 'active', teamLaunchState, members, - diagnostics: Array.from(new Set([...params.result.diagnostics, ...diagnostics])), + diagnostics: Array.from(new Set([...committedResult.diagnostics, ...diagnostics])), }; } + private async buildOpenCodeSecondaryAppManagedLaunchPrompt( + run: ProvisioningRun, + lane: MixedSecondaryRuntimeLaneState + ): Promise { + const controller = createController({ + teamName: run.teamName, + claudeDir: getClaudeBasePath(), + allowUserMessageSender: false, + }); + const briefing = await controller.tasks.memberBriefing(lane.member.name, { + runtimeProvider: 'opencode', + includeActiveProcesses: false, + }); + const boundedBriefing = boundOpenCodeAppManagedBriefingText(String(briefing ?? '')); + if (!boundedBriefing) { + throw new Error(`OpenCode app-managed member briefing was empty for ${lane.member.name}`); + } + return [ + '', + 'This briefing was loaded by the desktop app via member_briefing with includeActiveProcesses=false.', + 'Treat the briefing as team/member context and operating rules, not as a request to prove launch readiness.', + boundedBriefing, + '', + ].join('\n'); + } + private buildMixedPersistedLaunchSnapshotForRun( run: ProvisioningRun, launchPhase: PersistedTeamLaunchPhase @@ -22029,17 +22927,24 @@ export class TeamProvisioningService { await finishCancelledLane(); return; } + const appManagedLaunchPrompt = await this.buildOpenCodeSecondaryAppManagedLaunchPrompt( + run, + lane + ); + if (shouldAbortLaunch()) { + await finishCancelledLane(); + return; + } const rawResult = await adapter.launch({ runId: lane.runId, laneId: lane.laneId, teamName: run.teamName, cwd: laneCwd, - prompt: run.request.prompt?.trim() ?? undefined, + prompt: appManagedLaunchPrompt, providerId: 'opencode', model: lane.member.model, effort: lane.member.effort, runtimeOnly: true, - skipReadinessPreflight: true, skipPermissions: run.request.skipPermissions !== false, expectedMembers: [ { @@ -22059,6 +22964,9 @@ export class TeamProvisioningService { await finishCancelledLane(); return; } + // Treat the bridge result as provisional. The guard below is the single + // promotion gate that turns app-managed OpenCode bootstrap into + // confirmed_alive only after durable lane evidence exists on disk. const result = await this.guardCommittedOpenCodeSecondaryLaneEvidence({ teamName: run.teamName, laneId: lane.laneId, @@ -22439,6 +23347,9 @@ export class TeamProvisioningService { runtimePid?: number; sessionId?: string; runtimeSessionId?: string; + bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource; + bootstrapMode?: 'model_tool_checkin' | 'app_managed_context'; + appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate; livenessKind?: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; runtimeDiagnostic?: string; @@ -22501,6 +23412,9 @@ export class TeamProvisioningService { runtimePid: runtimeEvidence.runtimePid, sessionId: runtimeEvidence.sessionId, runtimeSessionId: runtimeEvidence.sessionId, + bootstrapEvidenceSource: runtimeEvidence.bootstrapEvidenceSource, + bootstrapMode: runtimeEvidence.bootstrapMode, + appManagedBootstrapCandidate: runtimeEvidence.appManagedBootstrapCandidate, livenessKind: runtimeEvidence.livenessKind, pidSource: runtimeEvidence.pidSource, runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic, @@ -22536,6 +23450,9 @@ export class TeamProvisioningService { pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds, runtimePid: runtimeEvidence.runtimePid, sessionId: runtimeEvidence.sessionId, + bootstrapEvidenceSource: runtimeEvidence.bootstrapEvidenceSource, + bootstrapMode: runtimeEvidence.bootstrapMode, + appManagedBootstrapCandidate: runtimeEvidence.appManagedBootstrapCandidate, livenessKind: runtimeEvidence.livenessKind, pidSource: runtimeEvidence.pidSource, runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic, @@ -22887,24 +23804,21 @@ export class TeamProvisioningService { boundaryMs: number; }): boolean { const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input; - if (event.type !== 'bootstrap_confirmed') { - return false; - } - if (typeof event.teamName === 'string' && event.teamName.trim() !== teamName) { - return false; - } - const source = getRuntimeBootstrapProofString(event, detail, 'source'); - if (source !== BOOTSTRAP_RUNTIME_PROOF_SOURCE) { - return false; - } - const timestamp = typeof event.timestamp === 'string' ? event.timestamp : ''; - const eventMs = Date.parse(timestamp); - if (Number.isFinite(boundaryMs) && (!Number.isFinite(eventMs) || eventMs < boundaryMs)) { - return false; - } - const expectedToken = runtimeMember?.bootstrapProofToken?.trim(); - const eventToken = getRuntimeBootstrapProofString(event, detail, 'bootstrapProofToken'); - if (expectedToken && eventToken !== expectedToken) { + if ( + !validateBootstrapRuntimeProofEnvelope({ + event, + detail, + expected: { + teamName, + boundaryMs, + proofToken: runtimeMember?.bootstrapProofToken?.trim(), + proofMode: runtimeMember?.bootstrapProofMode?.trim(), + contextHash: runtimeMember?.bootstrapContextHash?.trim(), + briefingHash: runtimeMember?.bootstrapBriefingHash?.trim(), + runId: runtimeMember?.bootstrapRunId?.trim(), + }, + }) + ) { return false; } const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : ''; @@ -22938,7 +23852,7 @@ export class TeamProvisioningService { let latest: string | null = null; let latestMs = Number.NEGATIVE_INFINITY; for (const event of events) { - const detail = parseRuntimeBootstrapProofDetail(event.detail); + const detail = parseBootstrapRuntimeProofDetail(event.detail); if ( !this.isRuntimeBootstrapProofEventValid({ event, @@ -24976,7 +25890,7 @@ export class TeamProvisioningService { !hasCapturedVisibleSendMessage ) { const cleanText = stripAgentBlocks(text).trim(); - if (cleanText.length > 0) { + if (cleanText.length > 0 && !isTeamInternalControlMessageText(cleanText)) { this.pushLiveLeadTextMessage( run, cleanText, @@ -24990,7 +25904,7 @@ export class TeamProvisioningService { // into the live cache so Messages/Activity can show the earliest assistant output. if (!run.silentUserDmForward && !hasCapturedVisibleSendMessage) { const cleanText = stripAgentBlocks(text).trim(); - if (cleanText.length > 0) { + if (cleanText.length > 0 && !isTeamInternalControlMessageText(cleanText)) { this.pushLiveLeadTextMessage( run, cleanText, @@ -26704,18 +27618,14 @@ export class TeamProvisioningService { try { const taskReader = new TeamTaskReader(); const tasks = await taskReader.getTasks(run.teamName); - const active = tasks.filter( - (t) => - (t.status === 'pending' || t.status === 'in_progress') && - !t.id.startsWith('_internal') - ); + const active = tasks.filter(isTaskBoardSnapshotWorkCandidate); if (active.length === 0) return; const board = buildTaskBoardSnapshot(tasks); const message = [ `Reconnected and ready. Begin executing tasks now.`, `Execute tasks sequentially and keep the board + user updated:`, - `- Identify the next READY task (pending, not blocked by incomplete dependencies).`, + `- Identify the next READY task (pending or needsFix, not blocked by incomplete dependencies).`, `- If the task is unassigned, set yourself as owner.`, `- BEFORE doing any work on a task: mark it started (in_progress).`, `- Immediately SendMessage "user" that you started task # (what you're doing + next step).`, @@ -30055,6 +30965,7 @@ export class TeamProvisioningService { teamName: fixture.teamName, memberName: fixture.memberName, runtimeProvider: 'opencode', + includeActiveProcesses: false, }, }); throwIfCancelled(); diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 233f6908..e537448d 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -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 + ); + } await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); }); } diff --git a/src/main/services/team/bootstrap/BootstrapProofValidation.ts b/src/main/services/team/bootstrap/BootstrapProofValidation.ts new file mode 100644 index 00000000..ff5804df --- /dev/null +++ b/src/main/services/team/bootstrap/BootstrapProofValidation.ts @@ -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 { + 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) + : {}; + } catch { + return {}; + } +} + +function readProofField( + event: BootstrapRuntimeProofEventLike, + detail: Record, + 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 +): 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; + 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; + 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; + 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; + 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; + 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; + expected: BootstrapRuntimeProofExpected; +}): boolean { + return validateBootstrapRuntimeProofEnvelopeDetailed(input).ok; +} diff --git a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts new file mode 100644 index 00000000..eb761ac3 --- /dev/null +++ b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts @@ -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( + [ + '', + `Team: ${params.teamName}`, + `Member: ${params.memberName}`, + `Provider: ${params.providerId ?? 'anthropic'}`, + `Project: ${params.cwd}`, + '', + '', + briefing, + '', + '', + ].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> { + const controller = createController({ + teamName: params.teamName, + claudeDir: getClaudeBasePath(), + allowUserMessageSender: false, + }); + const result = new Map(); + 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; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index d772f05b..788c8b9a 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -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; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts index b2b0d377..3c323ead 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts @@ -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', diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts new file mode 100644 index 00000000..8bea8b0f --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -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, + reason: string, + lines: string[] +): OpenCodePromptDeliveryRepairDecision { + const attemptNumber = Math.min(Math.max(input.attempts + 1, 1), input.maxAttempts); + return { + kind, + retryable: true, + reason, + controlText: [ + '', + `Retry attempt ${attemptNumber}/${input.maxAttempts} for inbound app messageId "${input.inboxMessageId}".`, + ...lines, + '', + ].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 { + return new Set(input.toolCallNames.map(normalizeToolName).filter(Boolean)); +} + +function hasTool(tools: Set, toolName: string): boolean { + return tools.has(toolName); +} + +function hasTaskTool(tools: Set): boolean { + for (const tool of tools) { + if (tool.startsWith('task_') || tool === 'runtime_task_event') { + return true; + } + } + return false; +} + +function hasSideEffectTool(tools: Set): 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'); +} diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts new file mode 100644 index 00000000..8fc937bc --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -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; +} diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index cbe3ebe7..73635a19 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -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; + 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', diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 1912dd3a..4fa8d0ef 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -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_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.', + '', ] .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.', diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index b0b5b22e..d98a8cf6 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -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; diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index da393e4c..a61af14e 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -72,10 +72,10 @@ export async function resolveDesktopTeammateModeDecision( }; } - const tmuxAvailable = await isTmuxAvailable(); + await isTmuxAvailable(); return { - injectedTeammateMode: tmuxAvailable ? 'tmux' : null, + injectedTeammateMode: null, forceProcessTeammates: true, }; } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts index e5b3adb5..e218cea6 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts @@ -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; diff --git a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts index 336c6df3..8e1a31e6 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts @@ -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, diff --git a/src/main/services/team/teamTaskActiveState.ts b/src/main/services/team/teamTaskActiveState.ts new file mode 100644 index 00000000..059575d6 --- /dev/null +++ b/src/main/services/team/teamTaskActiveState.ts @@ -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( + tasks: readonly T[] +): T | null { + const activeTasks = tasks.filter(isTeamTaskActivelyWorked); + if (activeTasks.length === 0) return null; + return [...activeTasks].sort(compareCurrentActiveTasks)[0] ?? null; +} diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index d3afb416..f471ecdb 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -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) : '', diff --git a/src/renderer/components/dashboard/WebPreviewBanner.tsx b/src/renderer/components/dashboard/WebPreviewBanner.tsx index 2b4467c3..1c7a7cdd 100644 --- a/src/renderer/components/dashboard/WebPreviewBanner.tsx +++ b/src/renderer/components/dashboard/WebPreviewBanner.tsx @@ -16,10 +16,12 @@ export const WebPreviewBanner = (): React.JSX.Element | null => { >
-

Web version is still in development

+

+ Open the desktop app for full functionality +

- 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.

diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index ef6d959b..13557262 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -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({ ))} {displaySubject} - {task.reviewState === 'needsFix' && ( + {isTeamTaskNeedsFixActionable(task) && ( diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index 1cc7a86e..a02dee58 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -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'; diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index b777c22f..5382ef16 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -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 = { }; 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} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 1e13a85e..bcf58b78 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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} diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index ac55ed8c..034c4974 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -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' }); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 366e5521..592b399a 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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[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 ( +
+ + automation + + + stall nudge + + + + + Asked teammate to continue stalled task + {taskRef && taskLabel ? ( + <> + {' '} + + + ) : null} + + + {timestamp} + +
+ ); +}; + const BootstrapSystemRow = ({ teamName, eventKind, @@ -926,6 +989,20 @@ export const ActivityItem = memo( ); } + if (isTaskStallRemediationMessage(message)) { + return ( + + ); + } + if (bootstrapDisplay) { return (
); + case 'owner_changed': + return ( + + + {event.from && event.to ? ( + <> + Reassigned + + + + + ) : event.to ? ( + <> + Assigned to + + + ) : event.from ? ( + <> + Unassigned from + + + ) : ( + 'Owner changed' + )} + + ); case 'review_requested': return ( @@ -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': diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index bbc41c65..2e903d37 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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 = ({ {formatTaskDisplayLabel(currentTask)} - {(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} )} - {currentTask.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(currentTask) ? ( @@ -941,7 +943,9 @@ export const TaskDetailDialog = ({ {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 = ({ {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)}`; diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 81b24523..eb55d9b6 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -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; /** 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; memberColorMap: Map; + 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, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 6b7383f9..5a90d2ae 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -81,6 +81,41 @@ const baseTask: TeamTaskWithKanban = { const noop = (): void => undefined; +async function renderTaskCard( + props: Partial> = {} +): Promise<{ host: HTMLDivElement; root: ReturnType }> { + 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(); + }); + }); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 7c84488d..0377c1d0 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -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; memberColorMap: Map; + 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 (
- - {formatTaskDisplayLabel(task)} + + {formatTaskDisplayLabel(task)} + {hasLiveTaskLogs ? ( + + + + ) : null} {task.owner ? ( @@ -325,7 +338,7 @@ export const KanbanTaskCard = memo( {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} ) : null} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( @@ -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 && diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 60ed3d0b..7008575b 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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, diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 92458c37..0df100a5 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -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 = ({
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; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 95fc1934..334d8c2c 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -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(); 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({
{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 ( diff --git a/src/renderer/components/team/members/MemberTasksTab.tsx b/src/renderer/components/team/members/MemberTasksTab.tsx index f3150d81..1ef96423 100644 --- a/src/renderer/components/team/members/MemberTasksTab.tsx +++ b/src/renderer/components/team/members/MemberTasksTab.tsx @@ -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
{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} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 8a3e3841..4d0ad8ce 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -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] diff --git a/src/renderer/components/team/messages/StatusBlock.tsx b/src/renderer/components/team/messages/StatusBlock.tsx index dc272180..116a0dee 100644 --- a/src/renderer/components/team/messages/StatusBlock.tsx +++ b/src/renderer/components/team/messages/StatusBlock.tsx @@ -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]); diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index aa4d1c02..d9eea725 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -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; } diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index ae98920c..d12948e6 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -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 | null>(null); const countReloadTimerRef = useRef | 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 ( ) : null} diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 5e188cda..f2c067e8 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -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 ( @@ -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)} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( diff --git a/src/renderer/index.css b/src/renderer/index.css index a1f020e4..cf2d9fd1 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index b19f8f9f..8d7fa9d9 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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 = 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([ 'lead-message', 'lead-context', 'lead-activity', + 'member-advisory', 'process', 'member-spawn', ]); @@ -268,9 +272,11 @@ export function initializeNotificationListeners(): () => void { let teamRefreshTimers = new Map>(); let teamMessageRefreshTimers = new Map>(); let teamPresenceRefreshTimers = new Map>(); + let memberAdvisorySafetyRefreshTimers = new Map>(); let memberSpawnRefreshTimers = new Map>(); let teamAgentRuntimeRefreshTimers = new Map>(); let toolActivityTimers = new Map>(); + let taskLogActivityTimers = new Map>(); let processLiteStructuralReconcileTimers = new Map< string, { firstScheduledAt: number; timer: ReturnType } @@ -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 => { + 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(); + 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); } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 7e8ddc6a..d5a19810 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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; leadActivityByTeam: Record; leadContextByTeam: Record; + activeTaskLogActivityByTeam: Record>; activeToolsByTeam: Record>>; finishedVisibleByTeam: Record>>; toolHistoryByTeam: Record>; @@ -2727,6 +2736,7 @@ export const createTeamSlice: StateCreator = (set, provisioningStartedAtFloorByTeam: {}, leadActivityByTeam: {}, leadContextByTeam: {}, + activeTaskLogActivityByTeam: {}, activeToolsByTeam: {}, finishedVisibleByTeam: {}, toolHistoryByTeam: {}, @@ -3038,13 +3048,13 @@ export const createTeamSlice: StateCreator = (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 = (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); } } diff --git a/src/renderer/utils/bootstrapPromptSanitizer.ts b/src/renderer/utils/bootstrapPromptSanitizer.ts index c777c010..20576cd7 100644 --- a/src/renderer/utils/bootstrapPromptSanitizer.ts +++ b/src/renderer/utils/bootstrapPromptSanitizer.ts @@ -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 & Partial> +): 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 ): BootstrapPromptDisplay | null { @@ -209,8 +236,11 @@ export function getBootstrapAcknowledgementDisplay( }; } -export function getSanitizedInboxMessageText(message: Pick): string { +export function getSanitizedInboxMessageText( + message: Pick & Partial> +): string { return ( + getInternalControlMessageDisplay(message)?.body ?? getBootstrapPromptDisplay(message)?.body ?? getBootstrapAcknowledgementDisplay(message as Pick)?.body ?? message.text ?? @@ -219,9 +249,11 @@ export function getSanitizedInboxMessageText(message: Pick + message: Pick & + Partial> ): string { return ( + getInternalControlMessageDisplay(message)?.summary ?? getBootstrapPromptDisplay(message)?.summary ?? getBootstrapAcknowledgementDisplay(message)?.summary ?? message.summary ?? diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 690b4725..4c43e506 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -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'; } diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 3916c01c..1482a527 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -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 ''; } diff --git a/src/renderer/utils/pathNormalize.ts b/src/renderer/utils/pathNormalize.ts index ba49d75c..168fc2fd 100644 --- a/src/renderer/utils/pathNormalize.ts +++ b/src/renderer/utils/pathNormalize.ts @@ -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 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 { const map = new Map(); 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; } diff --git a/src/renderer/utils/teamChangeEvents.ts b/src/renderer/utils/teamChangeEvents.ts new file mode 100644 index 00000000..f8ced134 --- /dev/null +++ b/src/renderer/utils/teamChangeEvents.ts @@ -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) + ); +} diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 47e551a4..589accc7 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -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; 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(); diff --git a/src/renderer/utils/teamTaskDisplayState.ts b/src/renderer/utils/teamTaskDisplayState.ts new file mode 100644 index 00000000..f5c6ddcb --- /dev/null +++ b/src/renderer/utils/teamTaskDisplayState.ts @@ -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)); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 37a8e8f5..bec4cb53 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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 { diff --git a/src/shared/utils/reviewState.ts b/src/shared/utils/reviewState.ts index 231cdd9b..cd366553 100644 --- a/src/shared/utils/reviewState.ts +++ b/src/shared/utils/reviewState.ts @@ -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'; diff --git a/src/shared/utils/taskChangeState.ts b/src/shared/utils/taskChangeState.ts index e0c7e4e0..30c0139c 100644 --- a/src/shared/utils/taskChangeState.ts +++ b/src/shared/utils/taskChangeState.ts @@ -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'; } diff --git a/src/shared/utils/teamAutomationMessages.ts b/src/shared/utils/teamAutomationMessages.ts new file mode 100644 index 00000000..9c86a94e --- /dev/null +++ b/src/shared/utils/teamAutomationMessages.ts @@ -0,0 +1,16 @@ +import type { InboxMessage } from '@shared/types'; + +type AutomationMessageLike = Pick; + +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:') + ); +} diff --git a/src/shared/utils/teamInternalControlMessages.ts b/src/shared/utils/teamInternalControlMessages.ts new file mode 100644 index 00000000..ee3e5c88 --- /dev/null +++ b/src/shared/utils/teamInternalControlMessages.ts @@ -0,0 +1,87 @@ +const NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN = ''; +const LEAD_INBOX_RELAY_PROMPT_OPEN = 'You have new inbox messages addressed to you (team lead '; +const TEAMMATE_MESSAGE_OPEN_RE = /^): unknown; attachTaskFile(taskId: string, flags: Record): 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; leadBriefing(): Promise; taskBriefing(memberName: string): Promise; diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts index 5c269201..c81270c3 100644 --- a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -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'], ]); }); diff --git a/test/features/member-work-sync/core/SyncDecisionPolicy.test.ts b/test/features/member-work-sync/core/SyncDecisionPolicy.test.ts new file mode 100644 index 00000000..81293c5a --- /dev/null +++ b/test/features/member-work-sync/core/SyncDecisionPolicy.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { decideMemberWorkSyncStatus } from '@features/member-work-sync/core/domain'; + +import type { MemberWorkSyncAgenda, MemberWorkSyncReport } from '@features/member-work-sync/contracts'; + +describe('decideMemberWorkSyncStatus', () => { + it('returns caught_up when canonical filtering leaves no actionable work', () => { + const agenda: MemberWorkSyncAgenda = { + teamName: 'forge-labs', + memberName: 'jack', + generatedAt: '2026-05-06T19:06:07.257Z', + fingerprint: 'agenda-empty', + items: [], + diagnostics: [], + }; + const staleReport: MemberWorkSyncReport = { + teamName: 'forge-labs', + memberName: 'jack', + state: 'still_working', + agendaFingerprint: 'stale-owned-in-progress-task', + reportedAt: '2026-05-06T19:00:26.089Z', + accepted: true, + }; + + const decision = decideMemberWorkSyncStatus({ + agenda, + latestAcceptedReport: staleReport, + nowIso: '2026-05-06T19:06:07.257Z', + }); + + expect(decision.state).toBe('caught_up'); + expect(decision.acceptedReport).toBeUndefined(); + expect(decision.diagnostics).toContain('agenda_empty'); + }); +}); diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts new file mode 100644 index 00000000..86a6d1a5 --- /dev/null +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { decideMemberWorkSyncNudgeActivation } from '@features/member-work-sync/core/application'; + +import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '@features/member-work-sync/contracts'; + +function status(overrides: Partial = {}): MemberWorkSyncStatus { + return { + teamName: 'team-a', + memberName: 'alice', + state: 'needs_sync', + agenda: { + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-05-06T00:00:00.000Z', + fingerprint: 'agenda:v1:test', + items: [ + { + taskId: 'task-1', + displayId: '#1', + subject: 'Do work', + kind: 'work', + assignee: 'alice', + priority: 'normal', + reason: 'assigned', + evidence: { status: 'in_progress' }, + }, + ], + diagnostics: [], + }, + shadow: { + reconciledBy: 'queue', + wouldNudge: true, + fingerprintChanged: false, + }, + evaluatedAt: '2026-05-06T00:00:00.000Z', + diagnostics: [], + providerId: 'opencode', + ...overrides, + }; +} + +function metrics(overrides: Partial = {}): MemberWorkSyncTeamMetrics { + return { + teamName: 'team-a', + generatedAt: '2026-05-06T00:00:00.000Z', + memberCount: 1, + stateCounts: { + caught_up: 0, + needs_sync: 1, + still_working: 0, + blocked: 0, + inactive: 0, + unknown: 0, + }, + actionableItemCount: 1, + wouldNudgeCount: 1, + fingerprintChangeCount: 0, + reportAcceptedCount: 0, + reportRejectedCount: 0, + recentEvents: [], + phase2Readiness: { + state: 'collecting_shadow_data', + reasons: ['insufficient_status_events'], + thresholds: { + minObservedMembers: 1, + minStatusEvents: 20, + minObservationHours: 1, + maxWouldNudgesPerMemberHour: 2, + maxFingerprintChangesPerMemberHour: 1, + maxReportRejectionRate: 0.2, + }, + rates: { + observationHours: 0, + statusEventCount: 1, + wouldNudgesPerMemberHour: 1, + fingerprintChangesPerMemberHour: 0, + reportRejectionRate: 0, + }, + diagnostics: ['phase2_readiness:insufficient_status_events'], + }, + ...overrides, + }; +} + +describe('MemberWorkSyncNudgeActivationPolicy', () => { + it('activates OpenCode targeted nudges while shadow data is still collecting', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status(), + metrics: metrics(), + }) + ).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' }); + }); + + it('keeps non-OpenCode providers behind phase2 readiness while collecting', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ providerId: 'anthropic' }), + metrics: metrics(), + }) + ).toEqual({ active: false, reason: 'phase2_not_ready' }); + }); + + it('does not activate when blocking safety metrics are present', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status(), + metrics: metrics({ + phase2Readiness: { + ...metrics().phase2Readiness, + reasons: ['insufficient_status_events', 'would_nudge_rate_high'], + }, + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('keeps existing shadow_ready behavior for all providers', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ providerId: 'codex' }), + metrics: metrics({ + phase2Readiness: { + ...metrics().phase2Readiness, + state: 'shadow_ready', + reasons: [], + }, + }), + }) + ).toEqual({ active: true, reason: 'shadow_ready' }); + }); +}); diff --git a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts index 7fe02e87..7ecc1179 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts @@ -129,4 +129,90 @@ describe('MemberWorkSyncTaskImpactResolver', () => { diagnostics: [], }); }); + + it('does not target owners of already approved dependent tasks', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-a', + subject: 'Changed dependency', + status: 'completed', + owner: 'alice', + }, + { + id: 'task-approved-dependent', + subject: 'Already approved dependent', + status: 'in_progress', + owner: 'tom', + blockedBy: ['task-a'], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { + getState: vi.fn(async () => ({ + tasks: { + 'task-approved-dependent': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + })), + }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({ + memberNames: ['alice'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); + + it('does not treat stale review state as reviewer-missing when kanban says approved', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-approved', + subject: 'Approved after review', + status: 'in_progress', + owner: 'alice', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + timestamp: '2026-05-06T19:00:00.000Z', + from: 'none', + to: 'review', + reviewer: 'bob', + }, + ], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { + getState: vi.fn(async () => ({ + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + })), + }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'bob', 'team-lead']), + }, + } as never); + + await expect( + resolver.resolve({ teamName: 'team-a', taskId: 'task-approved' }) + ).resolves.toEqual({ + memberNames: ['alice'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); }); diff --git a/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts b/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts new file mode 100644 index 00000000..6ce94a7d --- /dev/null +++ b/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TeamTaskAgendaSource } from '@features/member-work-sync/main/adapters/output/TeamTaskAgendaSource'; + +describe('TeamTaskAgendaSource', () => { + it('applies kanban approved overlay before building member work agenda', async () => { + const source = new TeamTaskAgendaSource({ + configReader: { + getConfig: vi.fn(async () => ({ + members: [{ name: 'jack', agentType: 'developer' }], + })), + }, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-approved', + displayId: '#6d4db591', + subject: 'Approved through kanban', + status: 'in_progress', + owner: 'jack', + reviewState: 'none', + }, + ]), + }, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName: 'forge-labs', + reviewers: [], + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + })), + }, + membersMetaStore: { + getMembers: vi.fn(async () => []), + }, + hash: { + sha256Hex: vi.fn((value: string) => `h${value.length}`), + }, + clock: { + now: () => new Date('2026-05-06T19:06:07.257Z'), + }, + } as never); + + const result = await source.loadAgenda({ + teamName: 'forge-labs', + memberName: 'jack', + }); + + expect(result.agenda.items).toEqual([]); + }); +}); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 46fb6531..80b3813d 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -74,8 +74,113 @@ async function seedShadowReadyMetrics(input: { ); } +async function seedNonBlockingShadowCollectingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + const metricsPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.promises.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'caught_up', + agendaFingerprint: 'agenda:v1:seed', + actionableCount: 0, + evaluatedAt: '2026-01-01T00:00:00.000Z', + }, + }, + recentEvents: Array.from({ length: 18 }, (_, index) => ({ + id: `seed-status-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'caught_up', + agendaFingerprint: `agenda:v1:seed-${index}`, + recordedAt: new Date(Date.UTC(2026, 0, 1, index * 6)).toISOString(), + actionableCount: 0, + })), + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function seedBlockingShadowCollectingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + const nowMs = Date.now(); + const firstObservedAt = new Date(nowMs - 1_000).toISOString(); + const secondObservedAt = new Date(nowMs).toISOString(); + const metricsPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.promises.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:seed', + actionableCount: 1, + evaluatedAt: firstObservedAt, + }, + }, + recentEvents: [ + { + id: 'seed-status-0', + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:seed', + recordedAt: firstObservedAt, + actionableCount: 1, + }, + { + id: 'seed-would-nudge-0', + teamName: input.teamName, + memberName: input.memberName, + kind: 'would_nudge', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:seed', + recordedAt: secondObservedAt, + actionableCount: 1, + }, + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + async function waitForAssertion(assertion: () => Promise | void): Promise { - const deadline = Date.now() + 1_000; + const deadline = Date.now() + 2_000; let lastError: unknown; while (Date.now() < deadline) { try { @@ -92,6 +197,113 @@ async function waitForAssertion(assertion: () => Promise | void): Promise< await assertion(); } +async function waitForQueueIdle( + feature: ReturnType +): Promise { + await waitForAssertion(() => { + expect(feature.getQueueDiagnostics()).toMatchObject({ + queued: 0, + running: 0, + }); + }); +} + +async function readInboxMessages(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise> { + const inboxPath = path.join( + input.teamsBasePath, + input.teamName, + 'inboxes', + `${input.memberName}.json` + ); + let raw: string; + try { + raw = await fs.promises.readFile(inboxPath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'EISDIR') { + return []; + } + throw error; + } + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) + ? parsed.filter( + (item): item is { messageId?: string; messageKind?: string; text?: string } => + Boolean(item) && typeof item === 'object' + ) + : []; +} + +async function readMemberOutboxItems(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise< + Record< + string, + { status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string } + > +> { + const outboxPath = path.join( + input.teamsBasePath, + input.teamName, + 'members', + input.memberName, + '.member-work-sync', + 'outbox.json' + ); + let raw: string; + try { + raw = await fs.promises.readFile(outboxPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } + const parsed = JSON.parse(raw) as { + items?: Record; + }; + return parsed.items ?? {}; +} + +async function forceRetryableOutboxDue(input: { + teamsBasePath: string; + teamName: string; + memberName: string; + nextAttemptAt: string; +}): Promise { + const outboxPath = path.join( + input.teamsBasePath, + input.teamName, + 'members', + input.memberName, + '.member-work-sync', + 'outbox.json' + ); + const parsed = JSON.parse(await fs.promises.readFile(outboxPath, 'utf8')) as { + items?: Record; + }; + let touched = 0; + for (const item of Object.values(parsed.items ?? {})) { + if (item.status === 'failed_retryable') { + item.nextAttemptAt = input.nextAttemptAt; + item.updatedAt = input.nextAttemptAt; + touched += 1; + } + } + expect(touched).toBeGreaterThan(0); + await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8'); + await fs.promises.rm( + path.join(input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'outbox-index.json'), + { force: true } + ); +} + describe('createMemberWorkSyncFeature composition', () => { it('dispatches a due nudge through the real outbox and inbox by default', async () => { const claudeRoot = makeTempRoot(); @@ -230,6 +442,1910 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('drains runtime turn-settled files into queued reconcile and nudge delivery', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync after settled turn', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); + const spoolRoot = env?.[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]; + expect(spoolRoot).toBeTruthy(); + const eventFileName = '20260505T120000000Z-test.opencode.json'; + await fs.promises.writeFile( + path.join(spoolRoot!, 'incoming', eventFileName), + `${JSON.stringify({ + schemaVersion: 1, + provider: 'opencode', + source: 'agent-teams-orchestrator-opencode', + eventName: 'runtime_turn_settled', + hookEventName: 'Stop', + sessionId: 'ses-opencode-1', + runtimePromptMessageId: 'msg_123', + laneId: 'secondary:opencode:bob', + memberName, + teamName, + cwd: claudeRoot, + outcome: 'success', + recordedAt: '2026-05-05T12:00:00.000Z', + })}\n`, + 'utf8' + ); + + await expect(feature.drainRuntimeTurnSettledEvents()).resolves.toMatchObject({ + claimed: 1, + enqueued: 1, + invalid: 0, + unresolved: 0, + }); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + const status = await feature.getStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + providerId: 'opencode', + shadow: { + wouldNudge: true, + triggerReasons: ['turn_settled'], + }, + }); + }); + + const processedMeta = JSON.parse( + await fs.promises.readFile( + path.join(spoolRoot!, 'processed', `${eventFileName}.meta.json`), + 'utf8' + ) + ) as { outcome?: string; teamName?: string; memberName?: string }; + expect(processedMeta).toMatchObject({ + outcome: 'enqueued', + teamName, + memberName, + }); + } finally { + await feature.dispose(); + } + }); + + it('delivers targeted OpenCode nudges during shadow collection and schedules a delivery wake', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-targeted'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship OpenCode targeted nudge', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'opencode', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { state: 'collecting_shadow_data' }, + }); + await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({ + state: 'needs_sync', + providerId: 'opencode', + shadow: { wouldNudge: true }, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_delivered"'); + expect(journal).not.toContain('"reason":"phase2_not_ready"'); + } finally { + await feature.dispose(); + } + }); + + it('does not apply the OpenCode shadow-collection exception to Codex members', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-codex-shadow-gated'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Keep Codex gated during shadow collection', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { state: 'collecting_shadow_data' }, + }); + await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({ + state: 'needs_sync', + providerId: 'codex', + shadow: { wouldNudge: true }, + }); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_skipped"'); + expect(journal).toContain('"reason":"phase2_not_ready"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('blocks targeted OpenCode nudges when phase2 metrics are unsafe', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-blocking-metrics'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Do not nudge when metrics are unsafe', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { + reasons: expect.arrayContaining(['would_nudge_rate_high']), + }, + }); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_skipped"'); + expect(journal).toContain('"reason":"phase2_not_ready"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('recovers targeted OpenCode nudge delivery after unsafe metrics become ready', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-metrics-recovery'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Recover OpenCode nudge after metrics ready', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + }); + + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'opencode', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + } finally { + await feature.dispose(); + } + }); + + it('keeps targeted OpenCode nudges retryable when prompt delivery is busy', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-busy'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + let promptDeliveryBusy = true; + const promptDeliveryBusySignal = { + isBusy: vi.fn(async () => + promptDeliveryBusy + ? { + busy: true, + reason: 'opencode_prompt_delivery_active', + retryAfterIso: '2026-05-05T12:05:00.000Z', + } + : { busy: false } + ), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship OpenCode busy nudge', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + extraBusySignals: [promptDeliveryBusySignal], + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'member_busy:opencode_prompt_delivery_active', + nextAttemptAt: '2026-05-05T12:05:00.000Z', + }), + ]); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"member_busy"'); + expect(journal).toContain('"reason":"member_busy:opencode_prompt_delivery_active"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + + promptDeliveryBusy = false; + await forceRetryableOutboxDue({ + teamsBasePath, + teamName, + memberName, + nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), + }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 1, + superseded: 0, + retryable: 0, + terminal: 0, + }); + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'opencode', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + + const recoveredJournal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(recoveredJournal).toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('keeps nudges gated until shadow readiness is reached, then delivers on the next reconcile', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync after readiness', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { state: 'collecting_shadow_data' }, + }); + }); + + await waitForAssertion(async () => { + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_skipped"'); + expect(journal).toContain('"reason":"phase2_not_ready"'); + }); + + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + const outboxItems = Object.values( + await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ); + expect(outboxItems).toEqual([ + expect.objectContaining({ + status: 'delivered', + }), + ]); + }); + } finally { + await feature.dispose(); + } + }); + + it('runs the active bounded loop without duplicate nudges across report and fingerprint changes', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + let tasks = [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync', + status: 'pending', + owner: memberName, + }, + ]; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => tasks), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + let firstStatus = await feature.getStatus({ teamName, memberName }); + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + firstStatus = await feature.getStatus({ teamName, memberName }); + expect(firstStatus).toMatchObject({ + state: 'needs_sync', + providerId: 'codex', + shadow: { wouldNudge: true }, + }); + expect(firstStatus.reportToken).toBeTruthy(); + }); + + const firstFingerprint = firstStatus.agenda.fingerprint; + await expect( + feature.report({ + teamName, + memberName, + state: 'still_working', + agendaFingerprint: firstFingerprint, + reportToken: firstStatus.reportToken, + taskIds: ['task-1'], + source: 'test', + }) + ).resolves.toMatchObject({ + accepted: true, + status: { + state: 'still_working', + report: { accepted: true, state: 'still_working' }, + }, + }); + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 0, + delivered: 0, + superseded: 0, + retryable: 0, + terminal: 0, + }); + expect( + (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ) + ).toHaveLength(1); + + tasks = [ + ...tasks, + { + id: 'task-2', + displayId: '22222222', + subject: 'Ship follow-up sync', + status: 'pending', + owner: memberName, + }, + ]; + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never); + + let secondStatus = firstStatus; + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(2); + expect(new Set(nudges.map((message) => message.messageId)).size).toBe(2); + expect(nudges.at(-1)?.text).toContain('22222222'); + secondStatus = await feature.getStatus({ teamName, memberName }); + expect(secondStatus.state).toBe('needs_sync'); + expect(secondStatus.agenda.fingerprint).not.toBe(firstFingerprint); + expect(secondStatus.shadow).toMatchObject({ + wouldNudge: true, + fingerprintChanged: true, + previousFingerprint: firstFingerprint, + }); + }); + + const secondTaskIds = secondStatus.agenda.items.map((item) => item.taskId); + await expect( + feature.report({ + teamName, + memberName, + state: 'still_working', + agendaFingerprint: secondStatus.agenda.fingerprint, + reportToken: secondStatus.reportToken, + taskIds: secondTaskIds, + source: 'test', + }) + ).resolves.toMatchObject({ + accepted: true, + status: { + state: 'still_working', + report: { accepted: true, taskIds: secondTaskIds }, + }, + }); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + + tasks = tasks.map((task) => ({ ...task, status: 'completed' })); + const clearedStatus = await feature.refreshStatus({ teamName, memberName }); + expect(clearedStatus).toMatchObject({ + state: 'caught_up', + agenda: { items: [] }, + shadow: { wouldNudge: false }, + }); + await expect( + feature.report({ + teamName, + memberName, + state: 'caught_up', + agendaFingerprint: clearedStatus.agenda.fingerprint, + reportToken: clearedStatus.reportToken, + source: 'test', + }) + ).resolves.toMatchObject({ + accepted: true, + status: { + state: 'caught_up', + report: { accepted: true, state: 'caught_up' }, + }, + }); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + expect( + (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ) + ).toHaveLength(2); + + const journal = await fs.promises.readFile( + path.join(teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl'), + 'utf8' + ); + const events = journal + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as { event: string }).event); + expect(events.filter((event) => event === 'nudge_delivered')).toHaveLength(2); + expect(events.filter((event) => event === 'report_accepted')).toHaveLength(3); + } finally { + await feature.dispose(); + } + }); + + it('supersedes stale file-backed nudges and rejects stale reports before accepting the current fingerprint', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + let tasks = [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync', + status: 'pending', + owner: memberName, + }, + ]; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => tasks), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const staleStatus = await feature.refreshStatus({ teamName, memberName }); + expect(staleStatus).toMatchObject({ + state: 'needs_sync', + shadow: { wouldNudge: true }, + }); + const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ + status: staleStatus, + hash: new NodeHashAdapter(), + nowIso: staleStatus.evaluatedAt, + }); + expect(outboxInput).not.toBeNull(); + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); + await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ + ok: true, + outcome: 'created', + }); + const staleOutboxId = `member-work-sync:${teamName}:${memberName}:${staleStatus.agenda.fingerprint}`; + await expect( + readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ).resolves.toMatchObject({ + [staleOutboxId]: { status: 'pending' }, + }); + + tasks = tasks.map((task) => ({ ...task, status: 'completed' })); + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 0, + superseded: 1, + retryable: 0, + terminal: 0, + }); + await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]); + await expect( + readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ).resolves.toMatchObject({ + [staleOutboxId]: { + status: 'superseded', + lastError: 'status_no_longer_matches_outbox', + }, + }); + + await expect( + feature.report({ + teamName, + memberName, + state: 'still_working', + agendaFingerprint: staleStatus.agenda.fingerprint, + reportToken: staleStatus.reportToken, + taskIds: ['task-1'], + source: 'test', + }) + ).resolves.toMatchObject({ + accepted: false, + code: 'stale_fingerprint', + status: { + state: 'caught_up', + report: { + accepted: false, + rejectionCode: 'stale_fingerprint', + }, + }, + }); + + const currentStatus = await feature.getStatus({ teamName, memberName }); + await expect( + feature.report({ + teamName, + memberName, + state: 'caught_up', + agendaFingerprint: currentStatus.agenda.fingerprint, + reportToken: currentStatus.reportToken, + source: 'test', + }) + ).resolves.toMatchObject({ + accepted: true, + status: { + state: 'caught_up', + report: { accepted: true, state: 'caught_up' }, + }, + }); + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 0, + delivered: 0, + superseded: 0, + retryable: 0, + terminal: 0, + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + const events = journal + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as { event: string }).event); + expect(events).toContain('nudge_superseded'); + expect(events).toContain('report_rejected'); + expect(events).toContain('report_accepted'); + } finally { + await feature.dispose(); + } + }); + + it('supersedes pending nudges without delivery when the team becomes inactive', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + let teamActive = true; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync before shutdown', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => teamActive), + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const status = await feature.refreshStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + shadow: { wouldNudge: true }, + }); + const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ + status, + hash: new NodeHashAdapter(), + nowIso: status.evaluatedAt, + }); + expect(outboxInput).not.toBeNull(); + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); + await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ + ok: true, + outcome: 'created', + }); + + teamActive = false; + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 0, + superseded: 1, + retryable: 0, + terminal: 0, + }); + await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]); + await expect( + readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ).resolves.toMatchObject({ + [outboxInput!.id]: { + status: 'superseded', + lastError: 'team_inactive', + }, + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_superseded"'); + expect(journal).toContain('"reason":"team_inactive"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('replays legacy controller pending report intents through the real app validator', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync after offline report', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + }); + + try { + const status = await feature.refreshStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + agenda: { items: [expect.objectContaining({ taskId: 'task-1' })] }, + }); + expect(status.reportToken).toBeTruthy(); + + const legacyIntentPath = path.join( + teamsBasePath, + teamName, + '.member-work-sync', + 'pending-reports.json' + ); + const intentId = 'legacy-intent-1'; + await fs.promises.mkdir(path.dirname(legacyIntentPath), { recursive: true }); + await fs.promises.writeFile( + legacyIntentPath, + `${JSON.stringify( + { + schemaVersion: 1, + intents: { + [intentId]: { + id: intentId, + teamName, + memberName, + status: 'pending', + reason: 'control_api_unavailable', + recordedAt: '2026-05-05T12:00:00.000Z', + request: { + teamName, + memberName, + state: 'still_working', + agendaFingerprint: status.agenda.fingerprint, + reportToken: status.reportToken, + taskIds: ['task-1'], + source: 'mcp', + }, + }, + }, + }, + null, + 2 + )}\n`, + 'utf8' + ); + + await expect(feature.replayPendingReports([teamName])).resolves.toEqual({ + processed: 1, + accepted: 1, + rejected: 0, + superseded: 0, + }); + + const finalStatus = await feature.getStatus({ teamName, memberName }); + expect(finalStatus).toMatchObject({ + state: 'still_working', + report: { + accepted: true, + state: 'still_working', + taskIds: ['task-1'], + source: 'mcp', + }, + }); + const memberReports = JSON.parse( + await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'reports.json' + ), + 'utf8' + ) + ) as { intents?: Record }; + expect(memberReports.intents?.[intentId]).toMatchObject({ + status: 'accepted', + resultCode: 'accepted', + }); + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"legacy_fallback_used"'); + expect(journal).toContain('"event":"report_accepted"'); + } finally { + await feature.dispose(); + } + }); + + it('defers nudges while a member is busy and recovers on the next agenda change', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + let tasks = [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync while busy', + status: 'pending', + owner: memberName, + }, + ]; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => tasks), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify({ + action: 'start', + activity: { + memberName, + toolUseId: 'tool-1', + toolName: 'bash', + startedAt: '2026-05-05T12:00:00.000Z', + source: 'runtime', + }, + }), + } as never); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + const outboxItems = Object.values( + await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ); + expect(outboxItems).toEqual([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'member_busy:active_tool_activity', + }), + ]); + }); + + feature.noteTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify({ + action: 'reset', + memberName, + toolUseIds: ['tool-1'], + }), + } as never); + tasks = [ + ...tasks, + { + id: 'task-2', + displayId: '22222222', + subject: 'Ship sync after busy clears', + status: 'pending', + owner: memberName, + }, + ]; + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('22222222'); + const outboxItems = Object.values( + await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ); + expect(outboxItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'member_busy:active_tool_activity', + }), + expect.objectContaining({ + status: 'delivered', + }), + ]) + ); + }); + + await waitForAssertion(async () => { + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"member_busy"'); + expect(journal).toContain('"event":"nudge_delivered"'); + }); + } finally { + await feature.dispose(); + } + }); + + it('rate-limits the active loop after two delivered nudges per member per hour', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + let tasks = [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync first', + status: 'pending', + owner: memberName, + }, + ]; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => tasks), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + }); + + tasks = [ + ...tasks, + { + id: 'task-2', + displayId: '22222222', + subject: 'Ship sync second', + status: 'pending', + owner: memberName, + }, + ]; + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(2); + expect(nudges.at(-1)?.text).toContain('22222222'); + const outboxItems = Object.values( + await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ); + expect(outboxItems.filter((item) => item.status === 'delivered')).toHaveLength(2); + }); + + tasks = [ + ...tasks, + { + id: 'task-3', + displayId: '33333333', + subject: 'Ship sync third', + status: 'pending', + owner: memberName, + }, + ]; + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-3' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(2); + expect(nudges.some((message) => message.text?.includes('33333333'))).toBe(false); + const outboxItems = Object.values( + await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ); + expect(outboxItems.filter((item) => item.status === 'delivered')).toHaveLength(2); + expect(outboxItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'member_nudge_rate_limited', + }), + ]) + ); + }); + + await waitForAssertion(async () => { + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + const events = journal + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { event: string; reason?: string }); + expect(events.filter((event) => event.event === 'nudge_delivered')).toHaveLength(2); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'nudge_skipped', + reason: 'member_nudge_rate_limited', + }), + ]) + ); + }); + } finally { + await feature.dispose(); + } + }); + + it('recovers retryable inbox delivery failures without duplicate nudges', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync after inbox retry', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const inboxPath = path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`); + await fs.promises.mkdir(inboxPath, { recursive: true }); + + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0); + const outboxItems = Object.values( + await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ); + expect(outboxItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: expect.stringMatching(/EISDIR|ENOTDIR|EEXIST/), + }), + ]) + ); + }); + await waitForQueueIdle(feature); + + await fs.promises.rm(inboxPath, { recursive: true, force: true }); + await forceRetryableOutboxDue({ + teamsBasePath, + teamName, + memberName, + nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), + }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 1, + superseded: 0, + retryable: 0, + terminal: 0, + }); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: expect.any(String), + }), + ]) + ); + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_retryable"'); + expect(journal).toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('respects watchdog cooldown and delivers after the retry window is due', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync after watchdog cooldown', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const stallJournalPath = path.join(teamsBasePath, teamName, 'stall-monitor-journal.json'); + await fs.promises.mkdir(path.dirname(stallJournalPath), { recursive: true }); + await fs.promises.writeFile( + stallJournalPath, + `${JSON.stringify([ + { + taskId: 'task-1', + state: 'alerted', + alertedAt: new Date().toISOString(), + }, + ])}\n`, + 'utf8' + ); + + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(0); + const outboxItems = Object.values( + await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) + ); + expect(outboxItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'watchdog_cooldown_active', + }), + ]) + ); + }); + await waitForQueueIdle(feature); + + await fs.promises.writeFile( + stallJournalPath, + `${JSON.stringify([ + { + taskId: 'task-1', + state: 'alerted', + alertedAt: new Date(Date.now() - 11 * 60_000).toISOString(), + }, + ])}\n`, + 'utf8' + ); + await forceRetryableOutboxDue({ + teamsBasePath, + teamName, + memberName, + nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), + }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 1, + superseded: 0, + retryable: 0, + terminal: 0, + }); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"watchdog_cooldown_active"'); + expect(journal).toContain('"reason":"watchdog_cooldown_active"'); + expect(journal).toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('supersedes retryable nudges when the member reports before retry delivery', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync without stale retry', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const stallJournalPath = path.join(teamsBasePath, teamName, 'stall-monitor-journal.json'); + await fs.promises.mkdir(path.dirname(stallJournalPath), { recursive: true }); + await fs.promises.writeFile( + stallJournalPath, + `${JSON.stringify([ + { + taskId: 'task-1', + state: 'alerted', + alertedAt: new Date().toISOString(), + }, + ])}\n`, + 'utf8' + ); + + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + let status = await feature.getStatus({ teamName, memberName }); + await waitForAssertion(async () => { + status = await feature.getStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + shadow: { wouldNudge: true }, + }); + expect(status.reportToken).toBeTruthy(); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'watchdog_cooldown_active', + }), + ]) + ); + }); + await waitForQueueIdle(feature); + + await expect( + feature.report({ + teamName, + memberName, + state: 'still_working', + agendaFingerprint: status.agenda.fingerprint, + reportToken: status.reportToken, + taskIds: ['task-1'], + source: 'test', + }) + ).resolves.toMatchObject({ + accepted: true, + status: { state: 'still_working', report: { accepted: true } }, + }); + await forceRetryableOutboxDue({ + teamsBasePath, + teamName, + memberName, + nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), + }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 0, + superseded: 1, + retryable: 0, + terminal: 0, + }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'superseded', + lastError: 'status_no_longer_matches_outbox', + }), + ]) + ); + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"watchdog_cooldown_active"'); + expect(journal).toContain('"event":"report_accepted"'); + expect(journal).toContain('"event":"nudge_superseded"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + it('uses snapshot config reads for startup roster materialization', async () => { const getConfig = vi.fn(async () => ({ members: [] })); const getConfigSnapshot = vi.fn(async () => ({ diff --git a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts index 19a0f5cd..2cd6a97a 100644 --- a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts +++ b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts @@ -177,7 +177,8 @@ async function assertExecutable(filePath: string): Promise { } async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { - const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/'); + const realProjectPath = await fs.realpath(projectPath).catch(() => projectPath); + const normalizedProjectPath = path.normalize(realProjectPath).replace(/\\/g, '/'); const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20); const config: { projects: Record; @@ -203,17 +204,28 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string): } async function removeTempDirWithRetries(dirPath: string): Promise { - const attempts = process.platform === 'win32' ? 20 : 1; + const attempts = process.platform === 'win32' ? 20 : 5; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { - await fs.rm(dirPath, { recursive: true, force: true }); + await fs.rm(dirPath, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 200, + }); return; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === attempts) { + if (code === 'ENOENT') { + return; + } + if ( + (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') || + attempt === attempts + ) { throw error; } - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 200)); } } } diff --git a/test/main/services/team/BootstrapProofValidation.test.ts b/test/main/services/team/BootstrapProofValidation.test.ts new file mode 100644 index 00000000..00af099d --- /dev/null +++ b/test/main/services/team/BootstrapProofValidation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseBootstrapRuntimeProofDetail, + validateBootstrapRuntimeProofEnvelope, + validateBootstrapRuntimeProofEnvelopeDetailed, +} from '../../../../src/main/services/team/bootstrap/BootstrapProofValidation'; + +describe('BootstrapProofValidation', () => { + const expected = { + teamName: 'native-proof-team', + boundaryMs: Date.parse('2026-05-01T10:00:00.000Z'), + proofToken: 'proof-token', + proofMode: 'native_app_managed_context', + runId: 'run-native-proof', + contextHash: 'a'.repeat(64), + briefingHash: 'b'.repeat(64), + }; + + it('accepts native app-managed proof only when team, token, run and hashes match', () => { + expect( + validateBootstrapRuntimeProofEnvelope({ + event: { + type: 'bootstrap_confirmed', + timestamp: '2026-05-01T10:00:01.000Z', + teamName: expected.teamName, + source: 'native_app_managed_bootstrap_private_turn', + bootstrapProofToken: expected.proofToken, + runId: expected.runId, + contextHash: expected.contextHash, + briefingHash: expected.briefingHash, + }, + expected, + }) + ).toBe(true); + }); + + it('rejects native app-managed proof without explicit team binding', () => { + const result = validateBootstrapRuntimeProofEnvelopeDetailed({ + event: { + type: 'bootstrap_confirmed', + timestamp: '2026-05-01T10:00:01.000Z', + source: 'native_app_managed_bootstrap_private_turn', + bootstrapProofToken: expected.proofToken, + runId: expected.runId, + contextHash: expected.contextHash, + briefingHash: expected.briefingHash, + }, + expected, + }); + + expect(result).toMatchObject({ ok: false, reason: 'missing_team' }); + }); + + it('keeps legacy member_briefing proof compatible with missing teamName', () => { + expect( + validateBootstrapRuntimeProofEnvelope({ + event: { + type: 'bootstrap_confirmed', + timestamp: '2026-05-01T10:00:01.000Z', + source: 'member_briefing_tool_success', + bootstrapProofToken: expected.proofToken, + }, + detail: parseBootstrapRuntimeProofDetail(''), + expected: { + teamName: expected.teamName, + boundaryMs: expected.boundaryMs, + proofToken: expected.proofToken, + }, + }) + ).toBe(true); + }); +}); diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index a51381cd..f9c3a956 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -185,6 +185,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`; const projectPath = path.join(tempDir, 'project'); await fs.mkdir(projectPath, { recursive: true }); + await writeTrustedClaudeConfig(tempClaudeRoot, projectPath); await fs.writeFile( path.join(projectPath, 'README.md'), '# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n', @@ -514,13 +515,28 @@ async function removeTempDirAfterLateShellWrites(tempDir: string): Promise // Claude Code can leave child shells that write ~/.zsh_history just after stopTeam cleanup. // Bounded repeated passes keep live tests from leaving tiny recreated HOME directories behind. for (let attempt = 0; attempt < 6; attempt += 1) { - await fs.rm(tempDir, { recursive: true, force: true }); + await removeTempDirBestEffort(tempDir); if (attempt < 5) { await new Promise((resolve) => setTimeout(resolve, 1_000)); } } } +async function removeTempDirBestEffort(tempDir: string): Promise { + try { + await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); + } catch (error) { + const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null; + if (code === 'ENOENT') { + return; + } + // Live Claude processes can briefly recreate files under the temp HOME while + // the test harness is tearing down. The repeated outer cleanup loop handles + // those late writes, so cleanup must not turn an already-finished live e2e + // assertion into a false failure. + } +} + async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise { const tmpRoot = os.tmpdir(); for (let attempt = 0; attempt < 6; attempt += 1) { @@ -533,7 +549,7 @@ async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise { await Promise.all( entries .filter((entry) => entry.isDirectory() && entry.name.startsWith('member-work-sync-claude-stop-live-')) - .map((entry) => fs.rm(path.join(tmpRoot, entry.name), { recursive: true, force: true })) + .map((entry) => removeTempDirBestEffort(path.join(tmpRoot, entry.name))) ); if (attempt < 5) { await new Promise((resolve) => setTimeout(resolve, 1_000)); @@ -545,6 +561,33 @@ function hasLiveAnthropicApiKey(): boolean { return Boolean(process.env.ANTHROPIC_API_KEY?.trim()); } +async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { + const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath); + const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/'); + const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20); + const config: { + projects: Record; + customApiKeyResponses?: { approved: string[]; rejected: string[] }; + } = { + projects: { + [normalizedProjectPath]: { + hasTrustDialogAccepted: true, + }, + }, + }; + if (approvedApiKeySuffix) { + config.customApiKeyResponses = { + approved: [approvedApiKeySuffix], + rejected: [], + }; + } + await fs.writeFile( + path.join(configDir, '.claude.json'), + `${JSON.stringify(config, null, 2)}\n`, + 'utf8' + ); +} + function resolveConnectedClaudeHome(previousHome: string | undefined): string { const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim(); if (explicit) { diff --git a/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts b/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts new file mode 100644 index 00000000..56d2ed58 --- /dev/null +++ b/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts @@ -0,0 +1,286 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createMemberWorkSyncFeature, + type MemberWorkSyncFeatureFacade, +} from '../../../../src/features/member-work-sync/main'; +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; +import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; +import { TeamKanbanManager } from '../../../../src/main/services/team/TeamKanbanManager'; +import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore'; +import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; +import { + formatMemberWorkSyncDiagnostics, + formatProgressDump, + readRuntimeTurnSettledProcessedMetas, + waitUntil, +} from './memberWorkSyncLiveHarness'; +import { + createOpenCodeLiveHarness, + readInboxMessages, + waitForOpenCodeLanesStopped, + type OpenCodeLiveHarness, +} from './openCodeLiveTestHarness'; + +import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types'; + +const liveDescribe = + process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MEMBER_WORK_SYNC === '1' + ? describe + : describe.skip; + +const DEFAULT_MODEL = 'opencode/gpt-5-nano'; + +liveDescribe('Member work sync OpenCode live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let feature: MemberWorkSyncFeatureFacade | null; + let harness: OpenCodeLiveHarness | null; + let teamName: string | null; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-opencode-live-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + setClaudeBasePathOverride(tempClaudeRoot); + feature = null; + harness = null; + teamName = null; + }); + + afterEach(async () => { + if (harness && teamName) { + await harness.svc.stopTeam(teamName).catch(() => undefined); + await waitForOpenCodeLanesStopped(teamName); + } + await feature?.dispose().catch(() => undefined); + await harness?.dispose().catch(() => undefined); + setClaudeBasePathOverride(null); + if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') { + console.info(`[MemberWorkSyncOpenCode.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it( + 'delivers a work-sync nudge to a real OpenCode member and accepts its still-working report', + async () => { + const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; + const projectPath = path.join(tempDir, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'README.md'), + '# Member work sync OpenCode live e2e\n\nKeep this project intentionally tiny.\n', + 'utf8' + ); + + let activeService: OpenCodeLiveHarness['svc'] | null = null; + harness = await createOpenCodeLiveHarness({ + tempDir, + selectedModel, + projectPath, + configureServices: (svc) => { + activeService = svc; + const configReader = new TeamConfigReader(); + feature = createMemberWorkSyncFeature({ + teamsBasePath: getTeamsBasePath(), + configReader, + taskReader: new TeamTaskReader(), + kanbanManager: new TeamKanbanManager(), + membersMetaStore: new TeamMembersMetaStore(), + isTeamActive: (name) => svc.isTeamAlive(name) || svc.hasProvisioningRun(name), + listLifecycleActiveTeamNames: async () => (teamName ? [teamName] : []), + queueQuietWindowMs: 1, + }); + svc.setTeamChangeEmitter((event: TeamChangeEvent) => feature!.noteTeamChange(event)); + svc.setRuntimeTurnSettledEnvironmentProvider((input) => + feature!.buildRuntimeTurnSettledEnvironment(input) + ); + return { memberWorkSyncFeature: feature! }; + }, + }); + expect(activeService).toBe(harness.svc); + + const memberName = 'bob'; + const marker = `member-work-sync-opencode-live-${Date.now()}`; + teamName = `member-work-sync-opencode-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + + await harness.svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: selectedModel, + skipPermissions: true, + prompt: [ + 'Keep launch work minimal.', + 'If you receive a member_work_sync_nudge, call member_work_sync_status first.', + 'Then call member_work_sync_report with state "still_working", the returned agendaFingerprint/reportToken, and taskIds from the nudge.', + 'Do not complete the task and do not reply only with acknowledgement.', + ].join(' '), + members: [ + { + name: memberName, + role: 'Developer', + providerId: 'opencode', + model: selectedModel, + }, + ], + }, + (progress) => { + progressEvents.push(progress); + } + ); + + await waitUntil(async () => { + const last = progressEvents.at(-1); + if (last?.state === 'failed') { + throw new Error(formatProgressDump(progressEvents)); + } + return progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ); + }, 240_000); + + await seedShadowReadyMetrics({ teamName, memberName }); + const task = await new TeamDataService().createTask(teamName, { + subject: `Member work sync OpenCode live nudge ${marker}`, + owner: memberName, + startImmediately: false, + prompt: [ + `This is a live member-work-sync OpenCode validation task. Marker: ${marker}.`, + 'Do not edit files and do not complete this task.', + 'Only report still_working if member-work-sync asks you to synchronize.', + ].join('\n'), + }); + feature!.noteTeamChange({ type: 'task', teamName, taskId: task.id }); + + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${memberName}.json`); + await waitUntil(async () => { + const status = await feature!.getStatus({ teamName: teamName!, memberName }); + const inbox = await readInboxMessages(inboxPath); + return ( + status.agenda.items.some((item) => item.taskId === task.id) && + inbox.some( + (message) => + message.messageKind === 'member_work_sync_nudge' && + typeof message.messageId === 'string' + ) + ); + }, 60_000, 500, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const nudge = (await readInboxMessages(inboxPath)).find( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudge?.messageId).toBeTruthy(); + + let lastRelay: Awaited< + ReturnType + > | null = null; + await waitUntil(async () => { + lastRelay = await harness!.svc.relayOpenCodeMemberInboxMessages(teamName!, memberName, { + onlyMessageId: nudge!.messageId, + source: 'manual', + deliveryMetadata: { + replyRecipient: 'user', + }, + }); + return Boolean(lastRelay.lastDelivery?.delivered); + }, 120_000); + + await waitUntil(async () => { + const status = await feature!.getStatus({ teamName: teamName!, memberName }); + return status.report?.accepted === true && status.report.state === 'still_working'; + }, 180_000, 2_000, async () => + [ + `Last OpenCode relay: ${JSON.stringify(lastRelay, null, 2)}`, + await formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }), + ].join('\n') + ); + + await waitUntil(async () => { + await feature!.drainRuntimeTurnSettledEvents(); + const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); + return metas.some(({ meta }) => { + const event = meta.event as Record | undefined; + return event?.provider === 'opencode' && event.teamName === teamName; + }); + }, 60_000); + + await expect(feature!.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + }, + 420_000 + ); +}); + +async function seedShadowReadyMetrics(input: { + teamName: string; + memberName: string; +}): Promise { + const metricsPath = path.join( + getTeamsBasePath(), + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + const startMs = Date.now() - 2 * 60 * 60_000; + await fs.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'caught_up', + agendaFingerprint: 'agenda:v1:seed', + actionableCount: 0, + evaluatedAt: new Date(startMs).toISOString(), + providerId: 'opencode', + }, + }, + recentEvents: Array.from({ length: 24 }, (_, index) => ({ + id: `seed-status-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'caught_up', + agendaFingerprint: `agenda:v1:seed-${index}`, + recordedAt: new Date(startMs + index * 6 * 60_000).toISOString(), + actionableCount: 0, + providerId: 'opencode', + })), + }, + null, + 2 + )}\n`, + 'utf8' + ); +} diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts index 65516bf9..aacd1001 100644 --- a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts +++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts @@ -283,7 +283,8 @@ async function assertExecutable(filePath: string): Promise { } async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { - const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/'); + const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath); + const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/'); const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20); const config: { projects: Record; diff --git a/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts new file mode 100644 index 00000000..7bb2abe4 --- /dev/null +++ b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + buildNativeAppManagedBootstrapSpecs, + hashNativeBootstrapText, +} from '../../../../src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder'; +import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore'; +import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +describe('NativeAppManagedBootstrapContextBuilder', () => { + let tempClaudeRoot = ''; + + beforeEach(async () => { + tempClaudeRoot = await mkdtemp(join(tmpdir(), 'native-bootstrap-builder-')); + setClaudeBasePathOverride(tempClaudeRoot); + }); + + afterEach(async () => { + setClaudeBasePathOverride(null); + await rm(tempClaudeRoot, { recursive: true, force: true }); + }); + + it('canonical hash normalizes line endings and trailing whitespace', () => { + expect(hashNativeBootstrapText('line 1\r\nline 2 \n')).toBe( + hashNativeBootstrapText('line 1\nline 2') + ); + }); + + it('builds bounded redacted context for native providers and skips non-native providers', async () => { + await new TeamMetaStore().writeMeta('native-ready-team', { + cwd: '/tmp/workspace', + providerId: 'anthropic', + model: 'claude-opus-4-6', + createdAt: Date.now(), + }); + await new TeamMembersMetaStore().writeMembers('native-ready-team', [ + { + name: 'alice', + providerId: 'anthropic', + role: 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret', + }, + { + name: 'bob', + providerId: 'codex', + role: 'Developer Bearer secret-token', + }, + { + name: 'zoe', + providerId: 'gemini', + role: 'Gemini member', + }, + { + name: 'tom', + providerId: 'opencode', + role: 'OpenCode member', + }, + ]); + + const specs = await buildNativeAppManagedBootstrapSpecs({ + teamName: 'native-ready-team', + cwd: '/tmp/workspace', + members: [ + { + name: 'alice', + providerId: 'anthropic', + role: 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret', + }, + { + name: 'bob', + providerId: 'codex', + role: 'Developer Bearer secret-token', + }, + { + name: 'zoe', + providerId: 'gemini', + role: 'Gemini member', + }, + { + name: 'tom', + providerId: 'opencode', + role: 'OpenCode member', + }, + ], + }); + + expect([...specs.keys()].sort()).toEqual(['alice', 'bob']); + const alice = specs.get('alice'); + const bob = specs.get('bob'); + expect(alice?.contextText).toContain(''); + expect(alice?.contextText).not.toContain('sk-ant-secret'); + expect(alice?.contextText).toContain('ANTHROPIC_API_KEY=[REDACTED]'); + expect(bob?.contextText).not.toContain('Bearer secret-token'); + expect(bob?.contextText).toContain('Bearer [REDACTED]'); + expect(alice?.contextHash).toBe(hashNativeBootstrapText(alice?.contextText ?? '')); + }); + + it('fails closed when aggregate native context budget is exceeded', async () => { + const hugeRole = 'x'.repeat(40_000); + await new TeamMetaStore().writeMeta('large-native-team', { + cwd: '/tmp/workspace', + providerId: 'anthropic', + model: 'claude-opus-4-6', + createdAt: Date.now(), + }); + await new TeamMembersMetaStore().writeMembers( + 'large-native-team', + Array.from({ length: 8 }, (_, index) => ({ + name: `member-${index}`, + providerId: 'anthropic' as const, + role: hugeRole, + })) + ); + + await expect( + buildNativeAppManagedBootstrapSpecs({ + teamName: 'large-native-team', + cwd: '/tmp/workspace', + members: Array.from({ length: 8 }, (_, index) => ({ + name: `member-${index}`, + providerId: 'anthropic' as const, + role: hugeRole, + })), + }) + ).rejects.toThrow(/aggregate size budget/); + }); +}); diff --git a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts index 7be4e603..f4a331a3 100644 --- a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts @@ -6,6 +6,7 @@ import { createOpenCodeBridgeHandshakeIdentityHash, createOpenCodeBridgeIdempotencyKey, isOpenCodeBridgeCommandName, + OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION, parseSingleBridgeJsonResult, stableHash, @@ -312,6 +313,8 @@ function peerIdentity( 'opencode.launchTeam', 'opencode.stopTeam', ], + opencodeAppManagedBootstrapContractVersion: + OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, }, runtime: { providerId: 'opencode', diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index 841334c7..e220c012 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -119,7 +119,14 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { selectedModel, }); launchedLanes.push(launchInput); - const launchResult = await adapter.launch(launchInput); + const rawLaunchResult = await adapter.launch(launchInput); + const launchResult = await commitMixedOpenCodeLaunchResult({ + service: svc, + teamName, + laneId: launchInput.laneId ?? 'secondary:opencode:bob', + memberName: 'bob', + result: rawLaunchResult, + }); expectCleanOpenCodeLaunch(launchResult); expect(launchResult.members.bob).toMatchObject({ launchState: 'confirmed_alive', @@ -226,7 +233,14 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { selectedModel, }); launchedLanes.push(launchInput); - const launchResult = await adapter.launch(launchInput); + const rawLaunchResult = await adapter.launch(launchInput); + const launchResult = await commitMixedOpenCodeLaunchResult({ + service: svc, + teamName, + laneId: launchInput.laneId ?? `secondary:opencode:${memberName}`, + memberName, + result: rawLaunchResult, + }); expectCleanOpenCodeLaunch(launchResult); expect(launchResult.members[memberName]).toMatchObject({ launchState: 'confirmed_alive', @@ -327,6 +341,21 @@ function expectCleanOpenCodeLaunch( expect(launchResult.teamLaunchState).toBe('clean_success'); } +async function commitMixedOpenCodeLaunchResult(input: { + service: TeamProvisioningService; + teamName: string; + laneId: string; + memberName: string; + result: Awaited>; +}): Promise>> { + return (input.service as any).guardCommittedOpenCodeSecondaryLaneEvidence({ + teamName: input.teamName, + laneId: input.laneId, + memberName: input.memberName, + result: input.result, + }); +} + async function writeMixedRecoveryFixtures(input: { teamName: string; projectPath: string; diff --git a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts index 60d4b1a8..5ca3adf1 100644 --- a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts +++ b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts @@ -83,8 +83,8 @@ describe('OpenCode production prompt artifacts safe e2e', () => { for (const member of launchCommand?.members ?? []) { expect(member.prompt).toContain(`You are ${member.name}`); expect(member.prompt).toContain('Team launch context:'); - expect(member.prompt).toContain('agent-teams_member_briefing'); - expect(member.prompt).toContain('"runtimeProvider": "opencode"'); + expect(member.prompt).toContain('agent_teams_app_managed_bootstrap_briefing'); + expect(member.prompt).toContain('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'); expect(member.prompt).toContain('agent-teams_message_send'); expect(member.prompt).toContain('Launch bootstrap is a silent attach'); expect(member.prompt).toContain('stay idle silently'); diff --git a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts new file mode 100644 index 00000000..73d4d653 --- /dev/null +++ b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { + decideOpenCodePromptDeliveryRepair, + type OpenCodePromptDeliveryRepairInput, +} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy'; + +function base(overrides: Partial = {}) { + return { + teamName: 'team-a', + memberName: 'alice', + inboxMessageId: 'msg-1', + replyRecipient: 'user', + messageKind: 'default', + actionMode: 'ask', + taskRefs: [], + status: 'responded', + responseState: 'empty_assistant_turn', + attempts: 1, + maxAttempts: 3, + pendingReason: 'empty_assistant_turn', + readAllowed: false, + inboxReadCommitted: false, + visibleReplyFound: false, + hasKnownProgressProof: false, + toolCallNames: [], + acceptanceUnknown: false, + hardFailureKind: 'none', + ...overrides, + } satisfies OpenCodePromptDeliveryRepairInput; +} + +describe('OpenCodePromptDeliveryRepairPolicy', () => { + it('adds no-assistant response repair without treating it as success', () => { + const decision = decideOpenCodePromptDeliveryRepair(base()); + + expect(decision.kind).toBe('no_assistant_response'); + expect(decision.retryable).toBe(true); + expect(decision.controlText).toContain('You must not end this turn empty.'); + expect(decision.controlText).toContain('relayOfMessageId="msg-1"'); + }); + + it('requires member work sync status and report for work-sync nudges', () => { + const decision = decideOpenCodePromptDeliveryRepair( + base({ + messageKind: 'member_work_sync_nudge', + actionMode: 'do', + taskRefs: [{ taskId: 'task-1', displayId: '#1', teamName: 'team-a' }], + responseState: 'responded_plain_text', + pendingReason: 'plain_text_ack_only_still_requires_answer', + }) + ); + + expect(decision.kind).toBe('work_sync_report_required'); + expect(decision.controlText).toContain('member_work_sync_status'); + expect(decision.controlText).toContain('member_work_sync_report'); + expect(decision.controlText).toContain('"task-1"'); + expect(decision.controlText).not.toContain('reportToken='); + }); + + it('does not repair terminal, permission, or session failures', () => { + expect( + decideOpenCodePromptDeliveryRepair( + base({ status: 'failed_terminal', responseState: 'empty_assistant_turn' }) + ) + ).toMatchObject({ kind: 'none', retryable: false }); + + expect( + decideOpenCodePromptDeliveryRepair( + base({ responseState: 'permission_blocked', hardFailureKind: 'permission' }) + ) + ).toMatchObject({ kind: 'none', retryable: false }); + + expect( + decideOpenCodePromptDeliveryRepair( + base({ responseState: 'session_error', hardFailureKind: 'session' }) + ) + ).toMatchObject({ kind: 'none', retryable: false }); + }); + + it('does not ask to repeat side-effect tools after tool_error', () => { + const decision = decideOpenCodePromptDeliveryRepair( + base({ + responseState: 'tool_error', + pendingReason: 'tool_error_without_required_delivery_proof', + toolCallNames: ['bash'], + actionMode: 'do', + taskRefs: [{ taskId: 'task-2', displayId: '#2', teamName: 'team-a' }], + }) + ); + + expect(decision.kind).toBe('progress_proof_required'); + expect(decision.controlText).toContain('Do not repeat side-effectful commands'); + expect(decision.controlText).toContain('"task-2"'); + }); +}); diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts new file mode 100644 index 00000000..80d209a0 --- /dev/null +++ b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { + isActionRequiredOpenCodeRuntimeDeliveryReason, + selectOpenCodeRuntimeDeliveryReason, +} from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; + +describe('OpenCodeRuntimeDeliveryDiagnostics', () => { + it('treats OpenRouter key limit errors as action-required delivery failures', () => { + const reason = + 'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys'; + + expect(isActionRequiredOpenCodeRuntimeDeliveryReason(reason)).toBe(true); + }); + + it('does not treat protocol proof repair reasons as action-required provider failures', () => { + expect(isActionRequiredOpenCodeRuntimeDeliveryReason('visible_reply_still_required')).toBe( + false + ); + }); + + it('selects a concrete OpenCode runtime delivery diagnostic before generic fallback text', () => { + const record = { + diagnostics: [ + 'Latest assistant message for opencode session abc failed with APIError - Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys', + ], + lastReason: 'OpenCode runtime delivery failed', + responseState: 'session_error', + status: 'accepted', + } as Parameters[0]; + + expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded'); + }); + + it('formats non-visible tool progress failures without exposing the internal reason code', () => { + const record = { + diagnostics: ['non_visible_tool_without_task_progress'], + lastReason: 'non_visible_tool_without_task_progress', + responseState: 'responded_non_visible_tool', + status: 'failed_terminal', + } as Parameters[0]; + + expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe( + 'OpenCode used tools, but did not create a visible reply or task progress proof.' + ); + }); +}); diff --git a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts index 5ab27e5a..aa7adb78 100644 --- a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts +++ b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, createOpenCodeBridgeHandshakeIdentityHash, type OpenCodeBridgeCommandName, type OpenCodeBridgeHandshake, @@ -272,6 +273,8 @@ function peerIdentity( 'opencode.launchTeam', 'opencode.stopTeam', ], + opencodeAppManagedBootstrapContractVersion: + OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, }, runtime: { providerId: 'opencode', diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index c07efb38..a6e9cb77 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -368,7 +368,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { members: [ expect.objectContaining({ name: 'alice', - prompt: expect.stringContaining('agent-teams_member_briefing'), + prompt: expect.stringContaining('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'), }), ], }) @@ -377,6 +377,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files'); expect(launchArg?.members[0]?.prompt).toContain('Launch bootstrap is a silent attach'); expect(launchArg?.members[0]?.prompt).toContain('stay idle silently'); + expect(launchArg?.members[0]?.prompt).not.toContain('agent-teams_member_briefing'); expect(launchArg?.members[0]?.prompt).not.toContain('Join team "team-a"'); }); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index da5fa3be..55189d09 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -5052,7 +5052,6 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -5094,7 +5093,6 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -5146,13 +5144,17 @@ describe('Team agent launch matrix safe e2e', () => { const svc = new TeamProvisioningService(); svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + removeMixedOpenCodeLaneForTest(run, 'bob'); trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); + expect(adapter.launchInputs.map((input) => input.expectedMembers.map((member) => member.name))).toEqual([ + ['tom'], + ]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -5215,15 +5217,19 @@ describe('Team agent launch matrix safe e2e', () => { model: 'opencode/nemotron-3-super-free', }, ], - ]); + ]); const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + removeMixedOpenCodeLaneForTest(run, 'bob'); trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); + expect(adapter.launchInputs.map((input) => input.expectedMembers.map((member) => member.name))).toEqual([ + ['tom'], + ]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -17443,6 +17449,15 @@ function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): vo }; } +function removeMixedOpenCodeLaneForTest(run: any, memberName: string): void { + run.allEffectiveMembers = (run.allEffectiveMembers ?? []).filter( + (member: { name?: string }) => member.name !== memberName + ); + run.mixedSecondaryLanes = (run.mixedSecondaryLanes ?? []).filter( + (lane: { member?: { name?: string } }) => lane.member?.name !== memberName + ); +} + function addGeminiPrimaryToMixedRun(run: any): void { const now = '2026-04-23T10:00:00.000Z'; const reviewer = { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index d36d1379..e89ee34f 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1908,6 +1908,188 @@ describe('TeamDataService', () => { }); }); + it('preserves kanban approved overlay even when task status is still in_progress', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async (): Promise => [ + { + id: 'task-approved', + subject: 'Approved but stale status', + status: 'in_progress', + owner: 'jack', + reviewState: 'none', + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-approved', + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'approved', + }); + expect(harness.resolveMembersSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Array), + expect.any(Array), + expect.arrayContaining([ + expect.objectContaining({ + id: 'task-approved', + kanbanColumn: 'approved', + }), + ]), + expect.any(Object) + ); + }); + + it('lets current kanban approved overlay win over stale review history', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async () => [ + { + id: 'task-approved', + subject: 'Approved after review', + status: 'in_progress', + owner: 'jack', + reviewState: 'none', + historyEvents: [ + { + id: 'review-started', + type: 'review_started', + timestamp: '2026-05-06T19:00:00.000Z', + from: 'none', + to: 'review', + }, + ], + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-approved', + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'approved', + reviewer: null, + }); + }); + + it('lets current kanban review overlay win over stale approved review state', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async () => [ + { + id: 'task-review', + subject: 'Moved back to review', + status: 'completed', + owner: 'jack', + reviewState: 'approved', + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-review': { + column: 'review', + reviewer: 'carol', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-review', + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'carol', + }); + }); + + it('does not preserve stale kanban approved overlay for reopened pending tasks', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async () => [ + { + id: 'task-reopened', + subject: 'Reopened pending task', + status: 'pending', + owner: 'jack', + reviewState: 'none', + historyEvents: [ + { + id: 'review-approved', + type: 'review_approved', + timestamp: '2026-05-06T19:00:00.000Z', + from: 'review', + to: 'approved', + actor: 'carol', + }, + ], + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-reopened': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-reopened', + status: 'pending', + reviewState: 'none', + }); + expect(data.tasks[0]?.kanbanColumn).toBeUndefined(); + }); + it('applies kanban overlay review state in global task projections', async () => { const service = new TeamDataService( { @@ -1967,6 +2149,66 @@ describe('TeamDataService', () => { }); }); + it('lets kanban approved overlay win over stale review history in global task projections', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(async () => [ + { + teamName: 'my-team', + displayName: 'My team', + projectPath: '/repo', + }, + ]), + } as never, + { + getAllTasks: vi.fn(async () => [ + { + id: 'task-global-approved', + teamName: 'my-team', + subject: 'Global approved task', + status: 'completed', + owner: 'bob', + reviewState: 'none', + historyEvents: [ + { + id: 'evt-review', + type: 'review_started', + from: 'none', + to: 'review', + timestamp: '2026-03-01T09:00:00.000Z', + }, + ], + }, + ]), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { + getState: vi.fn(async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-global-approved': { + column: 'approved', + reviewer: 'carol', + movedAt: '2026-03-01T10:00:00.000Z', + }, + }, + })), + } as never + ); + + const tasks = await service.getAllTasks(); + + expect(tasks[0]).toMatchObject({ + id: 'task-global-approved', + reviewState: 'approved', + kanbanColumn: 'approved', + }); + }); + it('propagates leadSessionId for kanban-driven review transitions', async () => { const requestReviewMock = vi.fn(); const approveReviewMock = vi.fn(); diff --git a/test/main/services/team/TeamLogSourceTracker.test.ts b/test/main/services/team/TeamLogSourceTracker.test.ts index 74beea88..7f5e0f0a 100644 --- a/test/main/services/team/TeamLogSourceTracker.test.ts +++ b/test/main/services/team/TeamLogSourceTracker.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'crypto'; -import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import { mkdtemp, mkdir, rm, stat, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -55,6 +55,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'log', }); }); @@ -95,6 +96,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'log', }); }); @@ -106,6 +108,167 @@ describe('TeamLogSourceTracker', () => { expect(emitter).not.toHaveBeenCalled(); }); + it('creates transcript freshness dirs without creating missing live cwd roots', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-missing-root-')); + const transcriptProjectDir = path.join(tempDir, 'transcript-project'); + const missingWorkspaceDir = path.join(tempDir, 'missing-workspace'); + + const logsFinder = { + getLiveLogSourceWatchContext: vi.fn(async () => ({ + projectDir: transcriptProjectDir, + projectPath: missingWorkspaceDir, + taskFreshnessRootDirs: [missingWorkspaceDir], + sessionIds: [], + watchSessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'task_log_stream'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect((await stat(path.join(transcriptProjectDir, '.board-task-log-freshness'))).isDirectory()) + .toBe(true); + await expect(stat(missingWorkspaceDir)).rejects.toThrow(); + + const taskId = 'transcript-root-task'; + await writeFile( + path.join( + transcriptProjectDir, + '.board-task-log-freshness', + `${encodeURIComponent(taskId)}.json` + ), + JSON.stringify({ taskId }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + taskSignalKind: 'log', + }); + }); + + await tracker.disableTracking('demo', 'task_log_stream'); + }); + + it('emits log freshness kind from Windows-safe hashed task-log freshness files', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-log-')); + + const logsFinder = { + getLiveLogSourceWatchContext: vi.fn(async () => ({ + projectDir: tempDir!, + sessionIds: [], + watchSessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'task_log_stream'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const taskId = 'AUX'; + const signalDir = path.join(tempDir, '.board-task-log-freshness'); + await mkdir(signalDir, { recursive: true }); + await writeFile( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + taskSignalKind: 'log', + }); + }); + + await tracker.disableTracking('demo', 'task_log_stream'); + }); + + it('watches live cwd freshness roots used by Codex Native traces', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-codex-root-')); + const transcriptProjectDir = path.join(tempDir, 'transcripts'); + const workspaceProjectDir = path.join(tempDir, 'workspace'); + const memberProjectDir = path.join(tempDir, 'member-workspace'); + await mkdir(transcriptProjectDir, { recursive: true }); + await mkdir(workspaceProjectDir, { recursive: true }); + await mkdir(memberProjectDir, { recursive: true }); + + const logsFinder = { + getLiveLogSourceWatchContext: vi.fn(async () => ({ + projectDir: transcriptProjectDir, + projectPath: workspaceProjectDir, + taskFreshnessRootDirs: [workspaceProjectDir, memberProjectDir], + sessionIds: [], + watchSessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'task_log_stream'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logTaskId = 'codex-task-1'; + await writeFile( + path.join( + memberProjectDir, + '.board-task-log-freshness', + `${encodeURIComponent(logTaskId)}.json` + ), + JSON.stringify({ taskId: logTaskId, source: 'codex-native-trace' }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId: logTaskId, + taskSignalKind: 'log', + }); + }); + + emitter.mockClear(); + const changeTaskId = 'codex-task-2'; + await writeFile( + path.join( + workspaceProjectDir, + '.board-task-change-freshness', + `${encodeURIComponent(changeTaskId)}.json` + ), + JSON.stringify({ taskId: changeTaskId }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId: changeTaskId, + taskSignalKind: 'change', + }); + }); + + await tracker.disableTracking('demo', 'task_log_stream'); + }); + it('emits log-source-change for scoped root transcripts', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-scoped-root-')); await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":1}\n'); @@ -275,6 +438,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'log', }); }); @@ -314,6 +478,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'change', }); }); expect(emitter.mock.calls).not.toContainEqual([ diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 19ea0cff..be14e2c1 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -24,13 +24,15 @@ describe('TeamMemberLogsFinder', () => { const teamName = 'live-context-team'; const projectPath = '/Users/test/live-context'; + const memberProjectPath = '/Users/test/member-cwd'; + const runtimeProjectPath = '/Users/test/runtime-bob-cwd'; const projectRoot = path.join(tmpDir, 'projects', '-Users-test-live-context'); const config = { name: teamName, projectPath, leadSessionId: 'lead-session', sessionHistory: ['old-session', 'recent-session'], - members: [], + members: [{ name: 'bob', cwd: memberProjectPath }], }; await fs.mkdir(projectRoot, { recursive: true }); @@ -61,6 +63,7 @@ describe('TeamMemberLogsFinder', () => { bootstrapConfirmed: false, hardFailure: false, runtimeSessionId: 'runtime-bob', + cwd: runtimeProjectPath, updatedAt: '2026-05-03T12:00:00.000Z', }, }, @@ -81,7 +84,10 @@ describe('TeamMemberLogsFinder', () => { expect(projectResolver.getLiveBaseContext).toHaveBeenCalledWith( teamName, - expect.objectContaining({ forceRefresh: true }) + expect.objectContaining({ + forceRefresh: true, + extraProjectPathCandidates: [runtimeProjectPath], + }) ); expect(projectResolver.getContext).not.toHaveBeenCalled(); expect(context?.projectDir).toBe(projectRoot); @@ -92,6 +98,11 @@ describe('TeamMemberLogsFinder', () => { 'old-session', ]); expect(context?.sessionIds).toEqual(context?.watchSessionIds); + expect(context?.taskFreshnessRootDirs).toEqual([ + path.normalize(projectPath), + path.normalize(memberProjectPath), + path.normalize(runtimeProjectPath), + ]); }); it('returns subagent logs for a member and lead session for team-lead', async () => { diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index 66066540..a8a0c09a 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -41,6 +41,86 @@ describe('TeamMemberResolver', () => { expect(lead?.agentType).toBe('team-lead'); }); + it('does not expose completed, deleted, or approved tasks as current work', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: 'alice', agentType: 'general-purpose' }, + { name: 'bob', agentType: 'general-purpose' }, + { name: 'carol', agentType: 'general-purpose' }, + { name: 'dave', agentType: 'general-purpose' }, + ], + }; + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-completed', + subject: 'Done', + status: 'completed', + owner: 'alice', + }, + { + id: 'task-deleted', + subject: 'Deleted', + status: 'deleted', + owner: 'bob', + deletedAt: '2026-05-06T00:00:00.000Z', + }, + { + id: 'task-approved-review', + subject: 'Approved review', + status: 'in_progress', + owner: 'carol', + reviewState: 'approved', + }, + { + id: 'task-approved-kanban', + subject: 'Approved kanban', + status: 'in_progress', + owner: 'dave', + kanbanColumn: 'approved', + }, + { + id: 'task-review-kanban', + subject: 'Review kanban', + status: 'in_progress', + owner: 'dave', + kanbanColumn: 'review', + }, + ]; + + const members = resolver.resolveMembers(config, [], [], tasks); + + expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBeNull(); + expect(members.find((member) => member.name === 'bob')?.currentTaskId).toBeNull(); + expect(members.find((member) => member.name === 'carol')?.currentTaskId).toBeNull(); + expect(members.find((member) => member.name === 'dave')?.currentTaskId).toBeNull(); + }); + + it('keeps real in-progress task as current work', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: 'alice', agentType: 'general-purpose' }, + ], + }; + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-active', + subject: 'Active', + status: 'in_progress', + owner: 'alice', + }, + ]; + + const members = resolver.resolveMembers(config, [], [], tasks); + + expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBe('task-active'); + }); + it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index a6a2d4a8..09c8a2f9 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -163,6 +163,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => { 'All credentials for model claude-opus-4-6 are cooling down via provider claude.', ], ['auth_error', 'Authentication failed due to invalid API key.'], + [ + 'quota_exhausted', + 'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys', + ], ['codex_native_timeout', 'Codex native exec timed out after 120000ms.'], ['network_error', 'Fetch failed because the network connection timed out.'], ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], @@ -257,6 +261,307 @@ describe('TeamMemberRuntimeAdvisoryService', () => { expect(advisory?.reasonCode).toBe('auth_error'); }); + it('surfaces recent OpenCode prompt delivery provider failures as member advisories', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'signal-ops'; + const laneId = 'secondary:opencode:bob'; + const nowIso = new Date().toISOString(); + const laneDir = path.join( + tmpDir, + 'teams', + teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(laneId) + ); + await fs.mkdir(laneDir, { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), + JSON.stringify({ + version: 1, + updatedAt: nowIso, + lanes: { + [laneId]: { laneId, state: 'active', updatedAt: nowIso }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), + JSON.stringify({ + schemaVersion: 1, + updatedAt: nowIso, + data: [ + { + id: 'opencode-prompt:test', + teamName, + memberName: 'bob', + laneId, + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'msg-1', + inboxTimestamp: nowIso, + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'empty_assistant_turn', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: nowIso, + lastObservedAt: nowIso, + acceptedAt: nowIso, + respondedAt: null, + failedAt: nowIso, + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'delivered-1', + observedAssistantMessageId: 'assistant-1', + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'empty_assistant_turn', + diagnostics: [ + 'OpenCode bridge command timed out', + 'Latest assistant message msg_1 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits', + 'empty_assistant_turn', + ], + createdAt: nowIso, + updatedAt: nowIso, + }, + ], + }), + 'utf8' + ); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => { + throw new Error('log scan should not be needed when OpenCode ledger has an error'); + }), + }); + const advisory = await service.getMemberAdvisory(teamName, 'bob'); + + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'quota_exhausted', + }); + expect(advisory?.message).toContain('Insufficient credits'); + expect(advisory?.message).not.toContain('Latest assistant message'); + }); + + it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-works'; + const laneId = 'secondary:opencode:jack'; + const nowIso = new Date().toISOString(); + const laneDir = path.join( + tmpDir, + 'teams', + teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(laneId) + ); + await fs.mkdir(laneDir, { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), + JSON.stringify({ + version: 1, + updatedAt: nowIso, + lanes: { + [laneId]: { laneId, state: 'active', updatedAt: nowIso }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), + JSON.stringify({ + schemaVersion: 1, + updatedAt: nowIso, + data: [ + { + id: 'opencode-prompt:proof-missing', + teamName, + memberName: 'jack', + laneId, + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'msg-1', + inboxTimestamp: nowIso, + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName }], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'responded_non_visible_tool', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: nowIso, + lastObservedAt: nowIso, + acceptedAt: nowIso, + respondedAt: nowIso, + failedAt: nowIso, + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'delivered-1', + observedAssistantMessageId: 'assistant-1', + observedAssistantPreview: null, + observedToolCallNames: ['task_get'], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'non_visible_tool_without_task_progress', + diagnostics: ['non_visible_tool_without_task_progress'], + createdAt: nowIso, + updatedAt: nowIso, + }, + ], + }), + 'utf8' + ); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'jack'); + + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'protocol_proof_missing', + message: 'OpenCode used tools, but did not create a visible reply or task progress proof.', + }); + }); + + it('suppresses stale OpenCode prompt delivery advisories after a visible runtime reply exists', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'forge-labs'; + const laneId = 'secondary:opencode:jack'; + const laneDir = path.join( + tmpDir, + 'teams', + teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(laneId) + ); + await fs.mkdir(laneDir, { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'teams', teamName, 'inboxes'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), + JSON.stringify({ + version: 1, + updatedAt: '2026-05-06T18:37:22.058Z', + lanes: { + [laneId]: { laneId, state: 'active', updatedAt: '2026-05-06T18:37:22.058Z' }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), + JSON.stringify({ + schemaVersion: 1, + updatedAt: '2026-05-06T18:37:22.058Z', + data: [ + { + id: 'opencode-prompt:visible-required', + teamName, + memberName: 'jack', + laneId, + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'comment-forward-1', + inboxTimestamp: '2026-05-06T18:35:46.580Z', + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'responded_non_visible_tool', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-05-06T18:37:22.019Z', + lastObservedAt: '2026-05-06T18:37:22.019Z', + acceptedAt: '2026-05-06T18:35:58.744Z', + respondedAt: '2026-05-06T18:36:38.565Z', + failedAt: '2026-05-06T18:37:22.056Z', + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'delivered-1', + observedAssistantMessageId: 'assistant-1', + observedAssistantPreview: null, + observedToolCallNames: ['task_get'], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'visible_reply_still_required', + diagnostics: [ + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + 'visible_reply_still_required', + ], + createdAt: '2026-05-06T18:35:46.752Z', + updatedAt: '2026-05-06T18:37:22.056Z', + }, + ], + }), + 'utf8' + ); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'inboxes', 'team-lead.json'), + JSON.stringify([ + { + from: 'jack', + to: 'team-lead', + text: 'Готово, детали ниже.', + timestamp: '2026-05-06T18:43:01.248Z', + read: true, + relayOfMessageId: 'comment-forward-1', + source: 'runtime_delivery', + messageId: 'visible-reply-1', + }, + ]), + 'utf8' + ); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'jack'); + + expect(advisory).toBeNull(); + }); + it('ignores expired retry advisories', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); @@ -350,9 +655,8 @@ describe('TeamMemberRuntimeAdvisoryService', () => { const firstRequest = service.getMemberAdvisories('signal-ops', [buildMember('Alice')]); const secondRequest = service.getMemberAdvisories('signal-ops', [buildMember('Alice')]); - await Promise.resolve(); - expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1)); gate.resolve(); const [first, second] = await Promise.all([firstRequest, secondRequest]); diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index b35ddae4..dc15b120 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -74,6 +74,78 @@ describe('TeamMessageFeedService', () => { expect(second.messages).toHaveLength(1); }); + it('hides native app-managed bootstrap private control messages from the feed', async () => { + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages: vi.fn(async () => [ + makeMessage({ + from: 'team-lead', + to: undefined, + messageId: 'native-bootstrap-private-check', + source: undefined, + text: '\nprivate\n', + }), + makeMessage({ + messageId: 'visible-user-message', + text: 'Visible message', + }), + ]), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const feed = await service.getFeed('signal-ops-4'); + + expect(feed.messages.map((message) => message.messageId)).toEqual(['visible-user-message']); + }); + + it('does not hide user-authored text just because it resembles an internal prompt', async () => { + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages: vi.fn(async () => [ + makeMessage({ + messageId: 'quoted-control-prompt', + source: 'user_sent', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }), + ]), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const feed = await service.getFeed('signal-ops-4'); + + expect(feed.messages.map((message) => message.messageId)).toEqual(['quoted-control-prompt']); + }); + + it('does not hide user-authored native bootstrap marker quotes from the feed', async () => { + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages: vi.fn(async () => [ + makeMessage({ + messageId: 'quoted-native-bootstrap-control', + source: 'user_sent', + text: '\nquoted\n', + }), + ]), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const feed = await service.getFeed('signal-ops-4'); + + expect(feed.messages.map((message) => message.messageId)).toEqual([ + 'quoted-native-bootstrap-control', + ]); + }); + it('refreshes the durable feed after cache expiry even when the dirty signal was missed', async () => { let inboxMessages: InboxMessage[] = [makeMessage()]; const getInboxMessages = vi.fn(async () => inboxMessages); @@ -180,7 +252,7 @@ describe('TeamMessageFeedService', () => { expect(getInboxMessages).toHaveBeenCalledTimes(2); }); - it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => { + it('adds UI-only bootstrap start rows for side-lane teammates', async () => { const opencodeConfig: TeamConfig = { name: 'relay-works-14', description: 'relay-works-14 team for provisioning flow', @@ -209,7 +281,7 @@ describe('TeamMessageFeedService', () => { from: 'team-lead', to: 'bob', source: 'system_notification', - messageId: 'opencode-bootstrap-start:relay-works-14:bob', + messageId: 'bootstrap-start:relay-works-14:bob', timestamp: '2026-04-30T17:42:26.947Z', }); expect(feed.messages[0]?.text).toContain('Provider override for this teammate: opencode.'); @@ -220,4 +292,39 @@ describe('TeamMessageFeedService', () => { 'The team has already been created and you are being attached as a persistent teammate.' ); }); + + it('keeps UI-only bootstrap start rows for members with stale inactive config flags', async () => { + const configWithStaleInactiveMember: TeamConfig = { + name: 'atlas-hq', + description: 'atlas-hq team for provisioning flow', + members: [ + { name: 'team-lead', role: 'Lead', providerId: 'codex' }, + { + name: 'alice', + role: 'reviewer', + providerId: 'anthropic', + model: 'claude-opus-4-6', + joinedAt: 1778102486293, + isActive: false, + } as NonNullable[number], + ], + }; + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => configWithStaleInactiveMember), + getInboxMessages: vi.fn(async () => []), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const feed = await service.getFeed('atlas-hq'); + + expect(feed.messages).toHaveLength(1); + expect(feed.messages[0]).toMatchObject({ + from: 'team-lead', + to: 'alice', + source: 'system_notification', + messageId: 'bootstrap-start:atlas-hq:alice', + timestamp: '2026-05-06T21:21:26.293Z', + }); + }); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 78635346..e9315099 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -547,6 +547,31 @@ function createMemberSpawnRun(params?: { } as any; } +const TEST_OPENCODE_APP_MANAGED_BOOTSTRAP_PROMPT = [ + 'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1', + '', + 'Test app-managed member briefing.', + '', +].join('\n'); + +function stubOpenCodeAppManagedLaunchPrompt(svc: TeamProvisioningService) { + return vi + .spyOn(svc as any, 'buildOpenCodeSecondaryAppManagedLaunchPrompt') + .mockImplementation(async (_run: unknown, lane: unknown) => { + const memberName = + lane && + typeof lane === 'object' && + 'member' in lane && + lane.member && + typeof lane.member === 'object' && + 'name' in lane.member && + typeof lane.member.name === 'string' + ? lane.member.name + : 'unknown'; + return `${TEST_OPENCODE_APP_MANAGED_BOOTSTRAP_PROMPT}\nmember=${memberName}`; + }); +} + function createClaudeLogsRun(overrides: Record = {}) { return { runId: 'run-logs-1', @@ -4357,25 +4382,21 @@ describe('TeamProvisioningService', () => { const teamName = String(input.teamName); const laneId = String(input.laneId); const runId = String(input.runId); - const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId); - await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true }); - await fsPromises.writeFile( - manifestPath, - `${JSON.stringify( + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId, + runId, + sessions: [ { - ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'), - activeRunId: runId, + id: 'oc-session-bob', + teamName, + memberName: 'bob', + laneId, + runId, + source: 'runtime_bootstrap_checkin', }, - null, - 2 - )}\n`, - 'utf8' - ); - await fsPromises.writeFile( - path.join(path.dirname(manifestPath), 'opencode-sessions.json'), - `${JSON.stringify({ sessions: [{ id: 'oc-session-bob' }] })}\n`, - 'utf8' - ); + ], + }); return { runId, teamName, @@ -4408,6 +4429,7 @@ describe('TeamProvisioningService', () => { } as any, ]); svc.setRuntimeAdapterRegistry(registry); + stubOpenCodeAppManagedLaunchPrompt(svc); (svc as any).launchStateStore = { read: vi.fn(async () => null), @@ -4480,7 +4502,7 @@ describe('TeamProvisioningService', () => { model: 'minimax-m2.5-free', effort: 'medium', runtimeOnly: true, - skipReadinessPreflight: true, + prompt: expect.stringContaining('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'), cwd: '/tmp/mixed-team', expectedMembers: [ expect.objectContaining({ @@ -4493,6 +4515,7 @@ describe('TeamProvisioningService', () => { ], }) ); + expect(adapterLaunch.mock.calls[0]?.[0]).not.toHaveProperty('skipReadinessPreflight'); }); it('does not trust OpenCode secondary bootstrap success without committed lane evidence', async () => { @@ -6622,6 +6645,57 @@ describe('TeamProvisioningService', () => { }); }); + it('treats OpenCode empty assistant turns with prompt proof as pending delivery', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: false, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'empty_assistant_turn' as const, + deliveredUserMessageId: 'oc-user-empty', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'empty_assistant_turn', + }, + diagnostics: ['empty_assistant_turn'], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', + messageId: 'msg-empty-assistant-pending', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + taskRefs: [ + { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }, + ], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'prompt_delivered_no_assistant_message', + ledgerStatus: 'retry_scheduled', + reason: 'prompt_delivered_no_assistant_message', + }); + }); + it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => { const svc = new TeamProvisioningService(); const emptyResponseObservation = { @@ -7624,6 +7698,7 @@ describe('TeamProvisioningService', () => { } as any, ]); svc.setRuntimeAdapterRegistry(registry); + stubOpenCodeAppManagedLaunchPrompt(svc); (svc as any).launchStateStore = { read: vi.fn(async () => null), @@ -7761,6 +7836,7 @@ describe('TeamProvisioningService', () => { } as any, ]) ); + stubOpenCodeAppManagedLaunchPrompt(svc); (svc as any).launchStateStore = { read: vi.fn(async () => null), write: vi.fn(async () => {}), @@ -7873,6 +7949,7 @@ describe('TeamProvisioningService', () => { } as any, ]) ); + stubOpenCodeAppManagedLaunchPrompt(svc); (svc as any).launchStateStore = { read: vi.fn(async () => null), write: vi.fn(async () => {}), @@ -10828,6 +10905,7 @@ describe('TeamProvisioningService', () => { resolvedFastMode: null, fastResolutionReason: null, })); + stubOpenCodeAppManagedLaunchPrompt(svc); return { svc, mcpConfigBuilder, membersMetaStore, teamMetaStore }; } @@ -11121,25 +11199,21 @@ describe('TeamProvisioningService', () => { const teamName = String(input.teamName); const laneId = String(input.laneId); const runId = String(input.runId); - const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId); - await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true }); - await fsPromises.writeFile( - manifestPath, - `${JSON.stringify( + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId, + runId, + sessions: [ { - ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'), - activeRunId: runId, + id: `oc-session-${memberName}`, + teamName, + memberName, + laneId, + runId, + source: 'runtime_bootstrap_checkin', }, - null, - 2 - )}\n`, - 'utf8' - ); - await fsPromises.writeFile( - path.join(path.dirname(manifestPath), 'opencode-sessions.json'), - `${JSON.stringify({ sessions: [{ id: `oc-session-${memberName}` }] })}\n`, - 'utf8' - ); + ], + }); return { runId, teamName, @@ -11447,9 +11521,27 @@ describe('TeamProvisioningService', () => { const adapterLaunch = vi.fn(async (input: Record) => { const expectedMembers = input.expectedMembers as Array<{ name: string }>; const memberName = expectedMembers[0]?.name ?? 'unknown'; + const teamName = String(input.teamName); + const laneId = String(input.laneId); + const runId = String(input.runId); + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId, + runId, + sessions: [ + { + id: `oc-session-${memberName}`, + teamName, + memberName, + laneId, + runId, + source: 'runtime_bootstrap_checkin', + }, + ], + }); return { - runId: String(input.runId), - teamName: String(input.teamName), + runId, + teamName, launchPhase: 'finished', teamLaunchState: 'clean_success', members: { @@ -12687,6 +12779,167 @@ describe('TeamProvisioningService', () => { }); }); + it('heals terminal bootstrap-state failures when native app-managed proof matches token and hashes', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-native-runtime-proof-heals'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const proofAt = new Date(Date.now() - 60_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + const proofToken = 'proof-token-jack-native'; + const bootstrapRunId = 'run-native-proof'; + const contextHash = 'a'.repeat(64); + const briefingHash = 'b'.repeat(64); + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + bootstrapExpectedAfter: acceptedAt, + bootstrapProofToken: proofToken, + bootstrapRunId, + bootstrapProofMode: 'native_app_managed_context', + bootstrapContextHash: contextHash, + bootstrapBriefingHash: briefingHash, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: 1234, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + runId: bootstrapRunId, + source: 'native_app_managed_bootstrap_private_turn', + bootstrapProofToken: proofToken, + contextHash, + briefingHash, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.jack).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + hardFailure: false, + error: undefined, + }); + }); + + it('does not heal terminal bootstrap-state failures from native app-managed proof with mismatched hashes', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-native-runtime-proof-hash-mismatch'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const proofAt = new Date(Date.now() - 60_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + const proofToken = 'proof-token-jack-native'; + const bootstrapRunId = 'run-native-proof'; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + bootstrapExpectedAfter: acceptedAt, + bootstrapProofToken: proofToken, + bootstrapRunId, + bootstrapProofMode: 'native_app_managed_context', + bootstrapContextHash: 'a'.repeat(64), + bootstrapBriefingHash: 'b'.repeat(64), + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: 1234, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + runId: bootstrapRunId, + source: 'native_app_managed_bootstrap_private_turn', + bootstrapProofToken: proofToken, + contextHash: 'c'.repeat(64), + briefingHash: 'b'.repeat(64), + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.statuses.jack).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + bootstrapConfirmed: false, + runtimeAlive: false, + hardFailure: true, + }); + }); + it('does not heal bootstrap-state failures from stale runtime proof before spawn acceptance', async () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored'; diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index a5aa95cc..d79e30c5 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -156,8 +156,10 @@ vi.mock('agent-teams-controller', () => ({ })); import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity'; +import * as OpenCodeRuntimeStore from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; +import { getTeamsBasePath } from '../../../../src/main/utils/pathDecoder'; function seedConfig(teamName: string): void { hoisted.files.set( @@ -306,6 +308,83 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); }); + it('does not persist echoed lead relay prompts as user-visible replies', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'tom', + text: '#f8d7235a done.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: '#f8d7235a done', + messageId: 'm-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0] ?? '{}')) as { + message?: { content?: Array<{ text?: string }> }; + }; + const relayedPrompt = payload.message?.content?.[0]?.text ?? ''; + + expect(relayedPrompt).toContain('You have new inbox messages addressed to you'); + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: `Human: ${relayedPrompt}` }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(0); + expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined(); + }); + + it('preserves visible summary text after stripping an echoed lead relay prompt', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'tom', + text: '#f8d7235a done.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: '#f8d7235a done', + messageId: 'm-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0] ?? '{}')) as { + message?: { content?: Array<{ text?: string }> }; + }; + const relayedPrompt = payload.message?.content?.[0]?.text ?? ''; + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: `Human: ${relayedPrompt}\n\nDelegated to bob.` }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + expect(service.getLiveLeadProcessMessages(teamName).map((message) => message.text)).toEqual([ + 'Delegated to bob.', + ]); + const sentRows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`) ?? '[]' + ) as Array<{ + text?: string; + }>; + expect(sentRows.map((message) => message.text)).toEqual(['Delegated to bob.']); + }); + it('treats member work sync nudges as actionable in lead relay prompt', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -436,6 +515,37 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { } }); + it('does not show internal control echoes as late lead thoughts', () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + attachAliveRun(service, teamName); + + const run = (service as unknown as { runs: Map }).runs.get('run-1') as { + leadRelayCapture: null; + }; + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [ + { + type: 'text', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }, + ], + }); + + expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(0); + }); + it('adds substantive-only task comment guidance for lead relay prompts', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -1834,6 +1944,94 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(rows[0].read).toBe(false); }); + it('keeps accepted OpenCode prompt rows pending without warning when response proof is terminally absent', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Please sync your current task.', + timestamp: '2026-02-23T17:04:00.000Z', + read: false, + messageId: 'opencode-accepted-terminal-empty-1', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + }, + ]); + vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({ + delivered: false, + accepted: true, + responsePending: false, + responseState: 'empty_assistant_turn', + ledgerStatus: 'failed_terminal', + ledgerRecordId: 'ledger-1', + laneId: 'secondary:opencode:jack', + reason: 'empty_assistant_turn', + diagnostics: ['empty_assistant_turn'], + }); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); + + expect(relay).toMatchObject({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 0, + lastDelivery: { + delivered: false, + accepted: true, + responsePending: false, + ledgerStatus: 'failed_terminal', + reason: 'empty_assistant_turn', + }, + }); + expect(vi.mocked(console.warn)).not.toHaveBeenCalledWith( + expect.stringContaining('OpenCode inbox relay failed') + ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); + expect(rows[0].read).toBe(false); + }); + + it('does not treat empty OpenCode observations as accepted without delivered prompt proof', () => { + const service = new TeamProvisioningService(); + const isAccepted = ( + service as unknown as { + isOpenCodePromptAcceptedByObservation: (observation?: unknown) => boolean; + } + ).isOpenCodePromptAcceptedByObservation.bind(service); + + expect( + isAccepted({ + state: 'empty_assistant_turn', + deliveredUserMessageId: null, + }) + ).toBe(false); + expect( + isAccepted({ + state: 'prompt_delivered_no_assistant_message', + deliveredUserMessageId: '', + }) + ).toBe(false); + expect( + isAccepted({ + state: 'empty_assistant_turn', + deliveredUserMessageId: 'opencode-user-message-1', + }) + ).toBe(true); + }); + it('reuses existing OpenCode prompt ledger metadata during watchdog relay retries', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -1901,6 +2099,91 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ); }); + it('ignores stale OpenCode watchdog jobs after the runtime lane is no longer active', async () => { + vi.useFakeTimers(); + try { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Please sync.', + timestamp: '2026-02-23T17:00:00.000Z', + read: false, + messageId: 'opencode-stale-watchdog-1', + }, + ]); + const deliverSpy = vi + .spyOn(service, 'deliverOpenCodeMemberMessage') + .mockRejectedValue( + new Error('OpenCode prompt delivery record not found: opencode-prompt:stale') + ); + + (service as any).scheduleOpenCodePromptDeliveryWatchdog({ + teamName, + memberName: 'jack', + messageId: 'opencode-stale-watchdog-1', + delayMs: 500, + }); + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + expect(deliverSpy).not.toHaveBeenCalled(); + expect(vi.mocked(console.warn)).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not classify missing OpenCode watchdog ledger rows as stale while the lane is active', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + attachAliveRun(service, teamName); + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Please sync.', + timestamp: '2026-02-23T17:00:00.000Z', + read: false, + messageId: 'opencode-active-watchdog-1', + }, + ]); + vi.spyOn(service as any, 'isOpenCodeRuntimeLaneIndexActive').mockResolvedValue(true); + + await expect( + (service as any).isStaleOpenCodePromptDeliveryWatchdogError({ + teamName, + memberName: 'jack', + messageId: 'opencode-active-watchdog-1', + error: new Error('OpenCode prompt delivery record not found: opencode-prompt:active'), + }) + ).resolves.toBe(false); + }); + it('skips failed-terminal OpenCode rows without blocking newer unread rows', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -2457,4 +2740,176 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); + + it('fails closed when OpenCode prompt ledger cannot be inspected for work-sync busy checks', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + OpenCodeRuntimeStore.getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName), + JSON.stringify({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + primary: { + laneId: 'primary', + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }) + ); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/jack.json`, JSON.stringify([])); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + getActiveForMember: vi.fn(async () => { + throw new Error('ledger read failed'); + }), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:00.000Z', + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_prompt_ledger_unavailable', + }); + }); + + it('treats unread OpenCode foreground inbox messages as busy for work-sync checks', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'user', + to: 'jack', + text: 'Please check the current issue.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'foreground-message-1', + messageKind: 'direct', + }, + ]) + ); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:10.000Z', + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'foreground-message-1', + }); + }); + + it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'system', + to: 'jack', + text: 'Work sync check.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'work-sync-nudge-1', + messageKind: 'member_work_sync_nudge', + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + getActiveForMember: vi.fn(async () => null), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:10.000Z', + }); + + expect(busy).toEqual({ busy: false }); + }); }); diff --git a/test/main/services/team/TeamTaskWriter.test.ts b/test/main/services/team/TeamTaskWriter.test.ts index 0b237706..2c15e0dc 100644 --- a/test/main/services/team/TeamTaskWriter.test.ts +++ b/test/main/services/team/TeamTaskWriter.test.ts @@ -332,5 +332,42 @@ describe('TeamTaskWriter', () => { actor: 'user', }); }); + + it('updateOwner appends owner_changed event', async () => { + hoisted.files.set( + taskPath, + JSON.stringify({ + id: '12', + subject: 'task', + owner: 'alice', + status: 'pending', + historyEvents: [ + { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, + ], + }) + ); + + await writer.updateOwner('my-team', '12', 'bob'); + await writer.updateOwner('my-team', '12', 'bob'); + await writer.updateOwner('my-team', '12', null); + + const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); + const ownerEvents = persisted.historyEvents.filter( + (event: Record) => event.type === 'owner_changed' + ); + expect(ownerEvents).toHaveLength(2); + expect(ownerEvents[0]).toMatchObject({ + type: 'owner_changed', + from: 'alice', + to: 'bob', + actor: 'user', + }); + expect(ownerEvents[1]).toMatchObject({ + type: 'owner_changed', + from: 'bob', + actor: 'user', + }); + expect(ownerEvents[1].to).toBeUndefined(); + }); }); }); diff --git a/test/main/services/team/openCodeLiveTestHarness.ts b/test/main/services/team/openCodeLiveTestHarness.ts index 174b49e3..8d3d3afd 100644 --- a/test/main/services/team/openCodeLiveTestHarness.ts +++ b/test/main/services/team/openCodeLiveTestHarness.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import Fastify from 'fastify'; +import { buildMemberWorkSyncRuntimeTurnSettledEnvironment } from '../../../../src/features/member-work-sync/main'; import { registerTeamRoutes } from '../../../../src/main/http/teams'; import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient'; @@ -39,6 +40,7 @@ export interface InboxMessage { to?: string; text?: string; messageId?: string; + messageKind?: string; read?: boolean; taskRefs?: TaskRef[]; source?: string; @@ -55,13 +57,17 @@ export async function createOpenCodeLiveHarness(input: { tempDir: string; selectedModel: string; projectPath?: string; + configureServices?: ( + svc: TeamProvisioningService + ) => Partial | Promise | void> | void; }): Promise { const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; await assertExecutable(orchestratorCli); const svc = new TeamProvisioningService(); - const controlApi = await startLiveTeamControlApi(svc); + const extraServices = (await input.configureServices?.(svc)) ?? {}; + const controlApi = await startLiveTeamControlApi(svc, extraServices); svc.setControlApiBaseUrlResolver(async () => controlApi.baseUrl); const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); @@ -75,6 +81,13 @@ export async function createOpenCodeLiveHarness(input: { CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), }; + const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ + teamsBasePath: getTeamsBasePath(), + provider: 'opencode', + }); + if (turnSettledEnv) { + Object.assign(bridgeEnv, turnSettledEnv); + } if (process.env.OPENCODE_E2E_USE_REAL_APP_CREDENTIALS !== '1') { bridgeEnv.XDG_DATA_HOME = path.join(input.tempDir, 'xdg-data'); } else if (stableBridgeEnv.XDG_DATA_HOME) { @@ -326,13 +339,17 @@ function getTranscriptDurableState(transcript: unknown): string | null { return typeof durableState === 'string' ? durableState : null; } -async function startLiveTeamControlApi(svc: TeamProvisioningService): Promise<{ +async function startLiveTeamControlApi( + svc: TeamProvisioningService, + extraServices: Partial = {} +): Promise<{ baseUrl: string; close: () => Promise; }> { const app = Fastify({ logger: false }); registerTeamRoutes(app, { teamProvisioningService: svc, + ...extraServices, } as HttpServices); await app.listen({ host: '127.0.0.1', port: 0 }); const address = app.server.address(); diff --git a/test/main/services/team/runtimeTeammateMode.test.ts b/test/main/services/team/runtimeTeammateMode.test.ts index 74313147..44faceea 100644 --- a/test/main/services/team/runtimeTeammateMode.test.ts +++ b/test/main/services/team/runtimeTeammateMode.test.ts @@ -12,7 +12,7 @@ describe('runtimeTeammateMode', () => { vi.clearAllMocks(); }); - it('enables process teammates in auto mode when tmux runtime is ready', async () => { + it('does not inject tmux mode in default desktop launch when tmux runtime is ready', async () => { mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true); const { resolveDesktopTeammateModeDecision } = await import('@main/services/team/runtimeTeammateMode'); @@ -20,7 +20,7 @@ describe('runtimeTeammateMode', () => { const decision = await resolveDesktopTeammateModeDecision(undefined); expect(decision.forceProcessTeammates).toBe(true); - expect(decision.injectedTeammateMode).toBe('tmux'); + expect(decision.injectedTeammateMode).toBeNull(); }); it('uses native process teammates when tmux runtime is not ready', async () => { @@ -97,6 +97,6 @@ describe('runtimeTeammateMode', () => { expect(firstDecision.forceProcessTeammates).toBe(true); expect(firstDecision.injectedTeammateMode).toBeNull(); expect(secondDecision.forceProcessTeammates).toBe(true); - expect(secondDecision.injectedTeammateMode).toBe('tmux'); + expect(secondDecision.injectedTeammateMode).toBeNull(); }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts index 3c792f02..111713ad 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts @@ -68,6 +68,7 @@ describe('TeamTaskStallNotifier', () => { taskRefs: [alert.taskRef], actionMode: 'do', source: 'system_notification', + messageKind: 'task_stall_remediation', }) ); expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith( diff --git a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts index 2b2b4dd0..ff4b760e 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts @@ -37,6 +37,8 @@ describe('TeamTaskStallSnapshotSource', () => { }, ], }, + { id: 'task-approved', subject: 'Approved', status: 'in_progress' }, + { id: 'task-reopened', subject: 'Reopened', status: 'pending', reviewState: 'approved' }, ]; const deletedTasks = [{ id: 'task-deleted', subject: 'D', status: 'deleted' }]; const transcriptContext = { @@ -98,6 +100,10 @@ describe('TeamTaskStallSnapshotSource', () => { movedAt: '2026-04-19T12:00:00.000Z', reviewer: 'alice', }, + 'task-approved': { + column: 'approved', + movedAt: '2026-04-19T12:05:00.000Z', + }, }, })), }; @@ -136,23 +142,30 @@ describe('TeamTaskStallSnapshotSource', () => { ); const snapshot = await source.getSnapshot('demo'); + const expectedWorkflowActiveTasks = [ + activeTasks[0], + activeTasks[1], + { ...activeTasks[2], reviewState: 'approved' }, + { ...activeTasks[3], reviewState: 'none' }, + ]; expect(snapshot).not.toBeNull(); expect(batchIndexer.buildIndex).toHaveBeenCalledWith({ teamName: 'demo', - tasks: [...activeTasks, ...deletedTasks], + tasks: [...expectedWorkflowActiveTasks, ...deletedTasks], messages: rawMessages, }); expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']); expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']); expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({ teamName: 'demo', - tasks: [activeTasks[0], activeTasks[1]], + tasks: [expectedWorkflowActiveTasks[0], expectedWorkflowActiveTasks[1]], providerByMemberName: new Map([ ['team-lead', 'codex'], ['alice', 'opencode'], ]), }); + expect(snapshot?.activeTasks).toEqual(expectedWorkflowActiveTasks); expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']); expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']); expect(snapshot?.leadName).toBe('team-lead'); diff --git a/test/main/services/team/teamTaskActiveState.test.ts b/test/main/services/team/teamTaskActiveState.test.ts new file mode 100644 index 00000000..2cb34fe4 --- /dev/null +++ b/test/main/services/team/teamTaskActiveState.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest'; + +import { + getTeamTaskWorkflowColumn, + isTeamTaskActivelyWorked, + isTeamTaskFinalForCompletionNotification, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, + isTeamTaskTerminalForActionableWork, + selectCurrentActiveTeamTask, +} from '../../../../src/main/services/team/teamTaskActiveState'; + +import type { TeamTaskWithKanban } from '../../../../src/shared/types'; + +describe('isTeamTaskActivelyWorked', () => { + it('accepts only canonical active work', () => { + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'none', + }) + ).toBe(true); + }); + + it('rejects terminal and approved task states', () => { + expect( + isTeamTaskActivelyWorked({ + status: 'completed', + reviewState: 'none', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'deleted', + reviewState: 'none', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'deleted', + reviewState: 'approved', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'approved', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'approved', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'review', + }) + ).toBe(false); + }); + + it('does not treat current kanban review as terminal even with stale approved review state', () => { + const task = { + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'review', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(false); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(false); + }); + + it('does not treat completed review workflow as dependency-finished', () => { + const task = { + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(false); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(false); + }); + + it('does not treat needsFix tasks as dependency-finished or actionable-terminal', () => { + const task = { + status: 'completed', + reviewState: 'needsFix', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(false); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(false); + }); + + it('lets current approved overlay win over stale needsFix for dependency and actionable terminal checks', () => { + const task = { + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(true); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(true); + }); +}); + +describe('getTeamTaskWorkflowColumn', () => { + it('keeps stale in-progress approved overlay visible as approved', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + }); + + it('does not treat reopened pending tasks as approved from stale kanban overlay', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'pending', + reviewState: 'none', + kanbanColumn: 'approved', + }) + ).toBeUndefined(); + }); + + it('does not treat reopened pending tasks as review or approved from stale review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'pending', + reviewState: 'review', + }) + ).toBeUndefined(); + expect( + getTeamTaskWorkflowColumn({ + status: 'pending', + reviewState: 'approved', + }) + ).toBeUndefined(); + }); + + it('prefers current kanban approved over stale review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'in_progress', + reviewState: 'review', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + }); + + it('prefers current kanban review over stale approved review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'review', + }) + ).toBe('review'); + }); + + it('does not treat deleted tasks as approved from stale review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'deleted', + reviewState: 'approved', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBeUndefined(); + }); +}); + +describe('isTeamTaskNeedsFixActionable', () => { + it('treats needsFix as actionable only when no current workflow overlay wins', () => { + expect( + isTeamTaskNeedsFixActionable({ + status: 'completed', + reviewState: 'needsFix', + }) + ).toBe(true); + expect( + isTeamTaskNeedsFixActionable({ + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }) + ).toBe(false); + expect( + isTeamTaskNeedsFixActionable({ + status: 'completed', + reviewState: 'needsFix', + kanbanColumn: 'review', + }) + ).toBe(false); + }); +}); + +describe('isTeamTaskFinalForCompletionNotification', () => { + it('does not notify all-completed while a completed task is still in review', () => { + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }) + ).toBe(false); + }); + + it('does not notify all-completed while a task needs fixes', () => { + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'completed', + reviewState: 'needsFix', + }) + ).toBe(false); + }); + + it('treats approved overlay and plain completed tasks as final for completion notifications', () => { + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }) + ).toBe(true); + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'completed', + reviewState: 'none', + }) + ).toBe(true); + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'deleted', + reviewState: 'needsFix', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBe(true); + }); +}); + +describe('selectCurrentActiveTeamTask', () => { + it('selects the latest active work interval instead of the first display id', () => { + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-a', + displayId: '1', + subject: 'Older active task', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T10:00:00.000Z' }], + }, + { + id: 'task-b', + displayId: '2', + subject: 'Newer active task', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T11:00:00.000Z' }], + }, + ]; + + const selected = selectCurrentActiveTeamTask(tasks); + + expect(selected?.id).toBe('task-b'); + }); + + it('ignores approved active-looking tasks when selecting current work', () => { + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-approved', + displayId: '1', + subject: 'Approved stale task', + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'approved', + workIntervals: [{ startedAt: '2026-05-06T12:00:00.000Z' }], + }, + { + id: 'task-active', + displayId: '2', + subject: 'Active task', + status: 'in_progress', + reviewState: 'none', + workIntervals: [{ startedAt: '2026-05-06T10:00:00.000Z' }], + }, + ]; + + const selected = selectCurrentActiveTeamTask(tasks); + + expect(selected?.id).toBe('task-active'); + }); + + it('falls back to history when the open work interval timestamp is invalid', () => { + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-a', + displayId: '1', + subject: 'Corrupt interval but newer history', + status: 'in_progress', + workIntervals: [{ startedAt: 'not-a-date' }], + historyEvents: [ + { + id: 'event-a', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-06T12:00:00.000Z', + }, + ], + }, + { + id: 'task-b', + displayId: '2', + subject: 'Older active task', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T11:00:00.000Z' }], + }, + ]; + + const selected = selectCurrentActiveTeamTask(tasks); + + expect(selected?.id).toBe('task-a'); + }); +}); diff --git a/test/renderer/components/sidebar/taskFiltersState.test.ts b/test/renderer/components/sidebar/taskFiltersState.test.ts index dcce0001..e996d179 100644 --- a/test/renderer/components/sidebar/taskFiltersState.test.ts +++ b/test/renderer/components/sidebar/taskFiltersState.test.ts @@ -6,12 +6,44 @@ describe('taskFiltersState', () => { it('treats needsFix as distinct from normal todo/done buckets', () => { const pendingNeedsFixTask = { status: 'pending', reviewState: 'needsFix' as const }; const completedNeedsFixTask = { status: 'completed', reviewState: 'needsFix' as const }; + const activeNeedsFixTask = { status: 'in_progress', reviewState: 'needsFix' as const }; const normalPendingTask = { status: 'pending', reviewState: 'none' as const }; expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['needs_fix']))).toBe(true); expect(taskMatchesStatus(completedNeedsFixTask, new Set(['needs_fix']))).toBe(true); + expect(taskMatchesStatus(activeNeedsFixTask, new Set(['needs_fix']))).toBe(true); expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['todo']))).toBe(false); expect(taskMatchesStatus(completedNeedsFixTask, new Set(['done']))).toBe(false); + expect(taskMatchesStatus(activeNeedsFixTask, new Set(['in_progress']))).toBe(false); expect(taskMatchesStatus(normalPendingTask, new Set(['todo']))).toBe(true); }); + + it('treats completed review workflow as review, not done', () => { + const completedReviewTask = { + status: 'completed', + reviewState: 'review' as const, + kanbanColumn: 'review' as const, + }; + + expect(taskMatchesStatus(completedReviewTask, new Set(['review']))).toBe(true); + expect(taskMatchesStatus(completedReviewTask, new Set(['done']))).toBe(false); + }); + + it('lets current workflow overlay win over stale needsFix in filters', () => { + const approvedTask = { + status: 'in_progress', + reviewState: 'needsFix' as const, + kanbanColumn: 'approved' as const, + }; + const reviewTask = { + status: 'completed', + reviewState: 'needsFix' as const, + kanbanColumn: 'review' as const, + }; + + expect(taskMatchesStatus(approvedTask, new Set(['approved']))).toBe(true); + expect(taskMatchesStatus(approvedTask, new Set(['needs_fix']))).toBe(false); + expect(taskMatchesStatus(reviewTask, new Set(['review']))).toBe(true); + expect(taskMatchesStatus(reviewTask, new Set(['needs_fix']))).toBe(false); + }); }); diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 58580b34..4c11635e 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -541,4 +541,41 @@ describe('ActivityItem legacy system message fallback', () => { await Promise.resolve(); }); }); + + it('renders task stall remediation as a compact automation row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const message: InboxMessage = { + from: 'system', + to: 'jack', + text: 'Task #1c24a4c4 may be stalled after a low-signal progress update.', + summary: 'Potential stalled task', + timestamp: new Date('2026-04-13T13:36:00.000Z').toISOString(), + read: true, + source: 'system_notification', + messageKind: 'task_stall_remediation', + messageId: 'task-stall:demo:task-a:epoch-a', + taskRefs: [{ taskId: 'task-a', displayId: '#1c24a4c4', teamName: 'my-team' }], + }; + + await act(async () => { + root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('automation'); + expect(host.textContent).toContain('stall nudge'); + expect(host.textContent).toContain('jack'); + expect(host.textContent).toContain('#1c24a4c4'); + expect(host.textContent).not.toContain('may be stalled after a low-signal progress update'); + expect(host.textContent).not.toContain('Do not send acknowledgement-only replies'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index 0960a38b..e4d909c6 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -130,6 +130,25 @@ describe('LeadThoughtsGroup', () => { expect(groupTimelineItems([noise])).toEqual([]); }); + it('excludes Human-prefixed internal control echoes from timeline', () => { + const leadRelayEcho = makeLeadSessionMsg(`Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`); + const teammateEcho = makeLeadSessionMsg( + 'Human: {"type":"idle_notification"}' + ); + + expect(isLeadThought(leadRelayEcho)).toBe(false); + expect(isLeadThought(teammateEcho)).toBe(false); + expect(groupTimelineItems([leadRelayEcho, teammateEcho])).toEqual([]); + }); + it('does not exclude noise messages with a recipient (captured SendMessage)', () => { const sendMsg = makeLeadSessionMsg( '{"type":"idle_notification","from":"tom","idleReason":"available"}', diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index 02516601..b5880a44 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; vi.mock('@renderer/components/team/members/MemberCard', () => ({ MemberCard: ({ @@ -10,6 +10,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ spawnError, spawnStatus, spawnLaunchState, + currentTask, + reviewTask, onRestartMember, onSkipMemberForLaunch, }: { @@ -17,6 +19,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ spawnError?: string; spawnStatus?: string; spawnLaunchState?: string; + currentTask?: TeamTaskWithKanban | null; + reviewTask?: TeamTaskWithKanban | null; onRestartMember?: (memberName: string) => void; onSkipMemberForLaunch?: (memberName: string) => void; }) => @@ -24,6 +28,12 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ 'div', { 'data-testid': `member-${member.name}` }, spawnError ?? '', + currentTask + ? React.createElement('span', { 'data-testid': `current-${member.name}` }, currentTask.id) + : null, + reviewTask + ? React.createElement('span', { 'data-testid': `review-${member.name}` }, reviewTask.id) + : null, onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start') ? React.createElement( 'button', @@ -195,6 +205,41 @@ describe('MemberList spawn-status memoization', () => { }); }); + it('shows a review task when a stale currentTaskId points at the same non-active task', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: 'task-review' }]; + const reviewTask: TeamTaskWithKanban = { + id: 'task-review', + subject: 'Review this', + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'bob', + }; + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + taskMap: new Map([[reviewTask.id, reviewTask]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="current-bob"]')).toBeNull(); + expect(host.querySelector('[data-testid="review-bob"]')?.textContent).toBe('task-review'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts index 610d2356..17a393c0 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts @@ -478,7 +478,12 @@ describe('TaskLogStreamSection', () => { expect(handler).toBeTypeOf('function'); await act(async () => { - handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-a' }); + handler?.(null, { + teamName: 'other-team', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); @@ -486,7 +491,12 @@ describe('TaskLogStreamSection', () => { expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-b' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-b', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); @@ -494,7 +504,25 @@ describe('TaskLogStreamSection', () => { expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'change', + }); + vi.advanceTimersByTime(400); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); @@ -586,7 +614,12 @@ describe('TaskLogStreamSection', () => { ).toBe('false'); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts index b3a37cb8..1283dfd1 100644 --- a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -341,15 +341,36 @@ describe('TaskLogsPanel', () => { expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledTimes(1); await act(async () => { - handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-1' }); - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-2' }); + handler?.(null, { + teamName: 'other-team', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-2', + taskSignalKind: 'log', + }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'change', + }); await flushMicrotasks(); }); expect(activityStates).toEqual([false]); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); await flushMicrotasks(); }); @@ -370,11 +391,14 @@ describe('TaskLogsPanel', () => { expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false); }); - it('defers Task Log Stream work while collapsed, then starts tracking after first open', async () => { + it('tracks header activity while collapsed but defers Task Log Stream content until first open', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.useFakeTimers(); const activityStates: boolean[] = []; + const onTaskLogActivityChange = (isActive: boolean): void => { + activityStates.push(isActive); + }; let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null; apiState.onTeamChange.mockImplementation((callback) => { handler = callback; @@ -393,7 +417,7 @@ describe('TaskLogsPanel', () => { teamName: 'demo', task: makeTask(), isOpen: false, - onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + onTaskLogActivityChange, }) ); await flushMicrotasks(); @@ -402,18 +426,38 @@ describe('TaskLogsPanel', () => { expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull(); expect(taskLogStreamProps.calls).toHaveLength(0); expect(apiState.getTaskLogStreamSummary).not.toHaveBeenCalled(); - expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled(); - expect(apiState.onTeamChange).not.toHaveBeenCalled(); - expect(handler).toBeNull(); + expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true); + expect(apiState.onTeamChange).toHaveBeenCalledTimes(1); + expect(handler).toBeTypeOf('function'); expect(activityStates).toEqual([false]); + await act(async () => { + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true]); + expect(apiState.getTaskLogStreamSummary).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1800); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true, false]); + await act(async () => { root.render( React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask(), isOpen: true, - onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + onTaskLogActivityChange, }) ); await flushMicrotasks(); @@ -422,22 +466,26 @@ describe('TaskLogsPanel', () => { expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull(); expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledWith('demo', 'task-1'); - expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true); expect(handler).toBeTypeOf('function'); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); await flushMicrotasks(); }); - expect(activityStates).toEqual([false, false, true]); + expect(activityStates).toEqual([false, true, false, true]); await act(async () => { vi.advanceTimersByTime(1800); await flushMicrotasks(); }); - expect(activityStates).toEqual([false, false, true, false]); + expect(activityStates).toEqual([false, true, false, true, false]); await act(async () => { root.unmount(); @@ -547,7 +595,12 @@ describe('TaskLogsPanel', () => { expect(counts).toEqual([undefined, 4]); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(350); await flushMicrotasks(); }); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 5540c24f..c2cdc1ed 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -62,6 +62,31 @@ function findNode(graph: GraphDataPort, nodeId: string) { return graph.nodes.find((node) => node.id === nodeId); } +function adaptWithActiveTaskLogActivity( + adapter: TeamGraphAdapter, + teamData: TeamGraphData, + activeTaskLogActivity: Record +): GraphDataPort { + return adapter.adapt( + teamData, + 'my-team', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + activeTaskLogActivity + ); +} + describe('TeamGraphAdapter particles', () => { beforeEach(() => { vi.useFakeTimers(); @@ -1631,6 +1656,47 @@ describe('TeamGraphAdapter particles', () => { expect(findNode(readGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBeUndefined(); }); + it('projects live task log activity onto visible task nodes and overflow stacks', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adaptWithActiveTaskLogActivity( + adapter, + createBaseTeamData({ + tasks: [ + { + id: 'task-live-visible', + displayId: '#1', + subject: 'Visible live logs', + owner: 'alice', + status: 'in_progress', + reviewState: 'none', + }, + ...Array.from({ length: 5 }, (_, index) => ({ + id: `task-overflow-${index + 1}`, + displayId: `#${index + 2}`, + subject: `Overflow task ${index + 1}`, + owner: 'alice', + status: 'in_progress', + reviewState: 'none', + })), + ] as TeamTaskWithKanban[], + }), + { + 'task-live-visible': true, + 'task-overflow-5': true, + } + ); + + const visibleLiveTask = findNode(graph, 'task:my-team:task-live-visible'); + const overflowNode = graph.nodes.find((node) => node.kind === 'task' && node.isOverflowStack); + + expect(visibleLiveTask).toMatchObject({ hasLiveTaskLogs: true }); + expect(overflowNode).toMatchObject({ + hasLiveTaskLogs: true, + overflowTaskIds: expect.arrayContaining(['task-overflow-5']), + }); + expect(findNode(graph, 'task:my-team:task-overflow-1')?.hasLiveTaskLogs).toBeUndefined(); + }); + it('dedupes symmetric blocking links and ignores completed blockers for blocked state', () => { const adapter = TeamGraphAdapter.create(); const inProgressGraph = adapter.adapt( diff --git a/test/renderer/features/agent-graph/drawTasks.test.ts b/test/renderer/features/agent-graph/drawTasks.test.ts new file mode 100644 index 00000000..de481ffe --- /dev/null +++ b/test/renderer/features/agent-graph/drawTasks.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { drawTasks } from '../../../../packages/agent-graph/src/canvas/draw-tasks'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +function createMockContext() { + const arcCalls: Array<{ x: number; y: number; radius: number }> = []; + const gradient = { addColorStop: vi.fn() }; + let fillStyle: string | CanvasGradient | CanvasPattern = ''; + let globalAlpha = 1; + + const ctx = { + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + closePath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn((x: number, y: number, radius: number) => { + arcCalls.push({ x, y, radius }); + }), + fill: vi.fn(), + stroke: vi.fn(), + clip: vi.fn(), + drawImage: vi.fn(), + setLineDash: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + roundRect: vi.fn(), + createRadialGradient: vi.fn(() => gradient), + createLinearGradient: vi.fn(() => gradient), + measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })), + fillText: vi.fn(), + strokeText: vi.fn(), + shadowColor: '', + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + strokeStyle: '', + lineWidth: 1, + font: '', + textAlign: 'left' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline, + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: string | CanvasGradient | CanvasPattern) { + fillStyle = value; + }, + get globalAlpha() { + return globalAlpha; + }, + set globalAlpha(value: number) { + globalAlpha = value; + }, + } as unknown as CanvasRenderingContext2D; + + return { ctx, arcCalls }; +} + +function createTaskNode(hasLiveTaskLogs: boolean): GraphNode { + return { + id: 'task:demo:task-live', + kind: 'task', + label: '#1', + state: 'active', + displayId: '#1', + sublabel: 'Live log task', + taskStatus: 'in_progress', + reviewState: 'none', + hasLiveTaskLogs: hasLiveTaskLogs ? true : undefined, + domainRef: { kind: 'task', teamName: 'demo', taskId: 'task-live' }, + x: 120, + y: 80, + }; +} + +describe('drawTasks', () => { + it('draws the live log indicator only for task nodes with live log activity', () => { + const active = createMockContext(); + drawTasks(active.ctx, [createTaskNode(true)], 1, null, null, null, 1); + + const inactive = createMockContext(); + drawTasks(inactive.ctx, [createTaskNode(false)], 1, null, null, null, 1); + + expect(active.arcCalls.length).toBeGreaterThanOrEqual(3); + expect(inactive.arcCalls).toHaveLength(0); + }); +}); diff --git a/test/renderer/features/agent-graph/taskGraphSemantics.test.ts b/test/renderer/features/agent-graph/taskGraphSemantics.test.ts new file mode 100644 index 00000000..0e266a44 --- /dev/null +++ b/test/renderer/features/agent-graph/taskGraphSemantics.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { + isTaskBlocked, + resolveTaskGraphColumn, +} from '@features/agent-graph/core/domain/taskGraphSemantics'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +describe('taskGraphSemantics', () => { + it('uses workflow column semantics for graph columns', () => { + expect(resolveTaskGraphColumn({ status: 'in_progress', kanbanColumn: 'approved' })).toBe( + 'approved' + ); + expect(resolveTaskGraphColumn({ status: 'pending', kanbanColumn: 'approved' })).toBe('todo'); + expect(resolveTaskGraphColumn({ status: 'pending', kanbanColumn: 'review' })).toBe('todo'); + expect( + resolveTaskGraphColumn({ + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + expect( + resolveTaskGraphColumn({ + status: 'in_progress', + reviewState: 'review', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + expect( + resolveTaskGraphColumn({ + status: 'deleted', + reviewState: 'approved', + deletedAt: '2026-05-06T19:06:07.257Z', + }) + ).toBe('todo'); + expect(resolveTaskGraphColumn({ status: 'pending', reviewState: 'needsFix' })).toBe('review'); + }); + + it('treats approved blockers as finished dependencies', () => { + const taskStateById = new Map< + string, + Pick + >([ + ['completed', { status: 'completed' }], + ['soft-deleted', { status: 'in_progress', deletedAt: '2026-05-06T19:06:07.257Z' }], + ['review-approved', { status: 'in_progress', reviewState: 'approved' }], + ['kanban-approved', { status: 'in_progress', kanbanColumn: 'approved' }], + ]); + + expect(isTaskBlocked({ blockedBy: ['completed'] }, taskStateById)).toBe(false); + expect(isTaskBlocked({ blockedBy: ['soft-deleted'] }, taskStateById)).toBe(false); + expect(isTaskBlocked({ blockedBy: ['review-approved'] }, taskStateById)).toBe(false); + expect(isTaskBlocked({ blockedBy: ['kanban-approved'] }, taskStateById)).toBe(false); + }); + + it('keeps blockers active while completed work is still in review', () => { + const taskStateById = new Map< + string, + Pick + >([ + [ + 'completed-review', + { + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }, + ], + ]); + + expect(isTaskBlocked({ blockedBy: ['completed-review'] }, taskStateById)).toBe(true); + }); +}); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 23331506..1f5e05f9 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -4,7 +4,14 @@ const hoisted = vi.hoisted(() => ({ onTeamChangeCb: null as | (( event: unknown, - data: { type?: string; teamName: string; detail?: string; runId?: string } + data: { + type?: string; + teamName: string; + detail?: string; + runId?: string; + taskId?: string; + taskSignalKind?: 'log' | 'change'; + } ) => void) | null, onProvisioningProgressCb: null as @@ -36,11 +43,19 @@ vi.mock('@renderer/api', () => ({ teams: { setChangePresenceTracking: vi.fn(async () => undefined), setToolActivityTracking: vi.fn(async () => undefined), + setTaskLogStreamTracking: vi.fn(async () => undefined), onTeamChange: vi.fn( ( cb: ( event: unknown, - data: { teamName: string; type?: string; detail?: string; runId?: string } + data: { + teamName: string; + type?: string; + detail?: string; + runId?: string; + taskId?: string; + taskSignalKind?: 'log' | 'change'; + } ) => void ): (() => void) => { hoisted.onTeamChangeCb = cb; @@ -112,6 +127,7 @@ describe('team change throttling', () => { currentRuntimeRunIdByTeam: {}, ignoredProvisioningRunIds: {}, ignoredRuntimeRunIds: {}, + activeTaskLogActivityByTeam: {}, memberSpawnStatusesByTeam: {}, memberSpawnSnapshotsByTeam: {}, teamAgentRuntimeByTeam: {}, @@ -1543,6 +1559,228 @@ describe('team change throttling', () => { expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); }); + it('tracks visible team tabs for task log activity and disables tracking when tab disappears', async () => { + const setTaskLogStreamTrackingSpy = vi.mocked(api.teams.setTaskLogStreamTracking); + setTaskLogStreamTrackingSpy.mockClear(); + + cleanup?.(); + cleanup = initializeNotificationListeners(); + await vi.advanceTimersByTimeAsync(0); + + expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', true); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + await vi.advanceTimersByTimeAsync(0); + + expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', false); + }); + + it('pulses task log activity only for real log signals and clears it after inactivity', async () => { + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-change-only', + taskSignalKind: 'change', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + useStore.setState({ currentRuntimeRunIdByTeam: { 'my-team': 'run-current' } } as never); + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + runId: 'run-old', + taskId: 'task-stale', + taskSignalKind: 'log', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + runId: 'run-current', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(3499); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(1); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + }); + + it('pulses visible task log activity without refreshing team data for explicit log signals', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.( + {}, + { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + } + ); + + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + }); + + it('refreshes visible team data for task change freshness without pulsing live log activity', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.( + {}, + { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-completed', + taskSignalKind: 'change', + } + ); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + }); + + it('keeps the bounded team data refresh for legacy task log change events', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.( + {}, + { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + detail: 'opencode-runtime-task-event:start', + } + ); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + }); + + it('skips the bounded task log refresh if the team is hidden before execution', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.( + {}, + { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + } + ); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + }); + + it('extends task log activity pulse on repeated log signals and ignores hidden teams', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + const activitySnapshots: Array | undefined> = []; + const unsubscribeActivitySnapshots = useStore.subscribe((nextState, prevState) => { + if (nextState.activeTaskLogActivityByTeam !== prevState.activeTaskLogActivityByTeam) { + activitySnapshots.push(nextState.activeTaskLogActivityByTeam['my-team']); + } + }); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + expect(activitySnapshots).toEqual([{ 'task-live': true }]); + + await vi.advanceTimersByTimeAsync(2000); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + expect(activitySnapshots).toEqual([{ 'task-live': true }]); + + await vi.advanceTimersByTimeAsync(3499); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(1); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + expect(activitySnapshots).toEqual([{ 'task-live': true }, undefined]); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-hidden', + taskSignalKind: 'log', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(800); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + unsubscribeActivitySnapshots(); + }); + it('applies targeted tool resets without clearing sibling tools', async () => { useStore.setState({ activeToolsByTeam: { diff --git a/test/renderer/utils/bootstrapPromptSanitizer.test.ts b/test/renderer/utils/bootstrapPromptSanitizer.test.ts index e5780ee8..47d580c6 100644 --- a/test/renderer/utils/bootstrapPromptSanitizer.test.ts +++ b/test/renderer/utils/bootstrapPromptSanitizer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getInternalControlMessageDisplay, getBootstrapPromptDisplay, getSanitizedInboxMessageText, } from '@renderer/utils/bootstrapPromptSanitizer'; @@ -64,4 +65,68 @@ Do NOT send acknowledgement-only messages such as "ready" or "online".`); expect(display?.runtime).toBe('GPT-5.4 Mini'); }); + + it('sanitizes native app-managed bootstrap private control prompts defensively', () => { + const message = makeMessage( + ` +Your Agent Teams startup context was already loaded by the app. +`, + { source: undefined } + ); + + expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal bootstrap check'); + expect(getSanitizedInboxMessageText(message)).toBe('Internal bootstrap check hidden in the UI.'); + }); + + it('does not sanitize user-authored native bootstrap marker quotes', () => { + const text = ` +Your Agent Teams startup context was already loaded by the app. +`; + const message = makeMessage(text, { from: 'user', source: 'user_sent' }); + + expect(getInternalControlMessageDisplay(message)).toBeNull(); + expect(getSanitizedInboxMessageText(message)).toBe(text); + }); + + it('does not sanitize visible lead text that only mentions the native bootstrap marker', () => { + const text = + 'Visible note quoting for diagnostics.'; + const message = makeMessage(text, { from: 'team-lead', source: 'lead_process' }); + + expect(getInternalControlMessageDisplay(message)).toBeNull(); + expect(getSanitizedInboxMessageText(message)).toBe(text); + }); + + it('sanitizes leaked lead inbox relay prompts defensively', () => { + const message = makeMessage( + `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + { source: 'lead_process' } + ); + + expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal control message'); + expect(getSanitizedInboxMessageText(message)).toBe('Internal control message hidden in the UI.'); + }); + + it('does not sanitize user-authored text that quotes an internal prompt', () => { + const text = `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`; + const message = makeMessage(text, { source: 'user_sent' }); + + expect(getInternalControlMessageDisplay(message)).toBeNull(); + expect(getSanitizedInboxMessageText(message)).toBe(text); + }); }); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 8cd3f956..7c0e1ea5 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -6,6 +6,7 @@ import { getSpawnCardClass, getMemberRuntimeAdvisoryLabel, getMemberRuntimeAdvisoryTitle, + getMemberRuntimeAdvisoryTone, isOpenCodeRelaunchActionable, } from '@renderer/utils/memberHelpers'; @@ -699,6 +700,78 @@ describe('memberHelpers spawn-aware presence', () => { ).toContain('Anthropic authentication error'); }); + it('formats raw OpenCode protocol advisory reasons before showing them in titles', () => { + const advisory = { + kind: 'api_error' as const, + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing' as const, + message: 'visible_reply_still_required', + }; + + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode proof missing'); + expect(getMemberRuntimeAdvisoryTone(advisory)).toBe('warning'); + + const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); + + expect(title).toContain( + 'OpenCode delivery completed without required visible/progress proof.' + ); + expect(title).toContain('OpenCode responded, but did not create a visible message_send reply.'); + expect(title).not.toContain('visible_reply_still_required'); + }); + + it('hides internal OpenCode bootstrap MCP diagnostics from advisory titles', () => { + const title = getMemberRuntimeAdvisoryTitle( + { + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'backend_error', + message: + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + }, + 'opencode' + ); + + expect(title).toContain('OpenCode runtime delivery did not complete.'); + expect(title).not.toContain('runtime_bootstrap_checkin'); + }); + + it('formats non-visible tool progress advisory reasons before showing them in titles', () => { + const title = getMemberRuntimeAdvisoryTitle( + { + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing', + message: 'non_visible_tool_without_task_progress', + }, + 'opencode' + ); + + expect( + getMemberRuntimeAdvisoryLabel( + { + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing', + message: 'non_visible_tool_without_task_progress', + }, + 'opencode' + ) + ).toBe('OpenCode proof missing'); + expect( + getMemberRuntimeAdvisoryTone({ + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing', + message: 'non_visible_tool_without_task_progress', + }) + ).toBe('warning'); + expect(title).toContain( + 'OpenCode used tools, but did not create a visible reply or task progress proof.' + ); + expect(title).not.toContain('non_visible_tool_without_task_progress'); + }); + it('renders Codex native timeout separately from network errors', () => { const advisory = { kind: 'api_error' as const, diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index f4f3be51..f538a623 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -52,4 +52,50 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { reason: 'prompt_delivered_no_assistant_message', }); }); + + it('surfaces missing visible reply proof as a readable failure', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-visible-required', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'failed_terminal', + reason: 'visible_reply_still_required', + diagnostics: ['visible_reply_still_required'], + }, + }); + + expect(diagnostics.warning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode responded, but did not create a visible message_send reply.' + ); + expect(diagnostics.debugDetails).toMatchObject({ + responseState: 'responded_non_visible_tool', + reason: 'visible_reply_still_required', + }); + }); + + it('surfaces missing task progress proof as a readable failure', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-progress-required', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'failed_terminal', + reason: 'non_visible_tool_without_task_progress', + diagnostics: ['non_visible_tool_without_task_progress'], + }, + }); + + expect(diagnostics.warning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode used tools, but did not create a visible reply or task progress proof.' + ); + }); }); diff --git a/test/renderer/utils/pathNormalize.test.ts b/test/renderer/utils/pathNormalize.test.ts new file mode 100644 index 00000000..c0818662 --- /dev/null +++ b/test/renderer/utils/pathNormalize.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; + +describe('pathNormalize task counts', () => { + it('counts approved tasks as completed instead of in-progress', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'in_progress', + kanbanColumn: 'approved', + }, + { + owner: 'jack', + status: 'in_progress', + reviewState: 'approved', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 0, + inProgress: 0, + completed: 2, + }); + }); + + it('ignores soft-deleted tasks even when status is stale', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'in_progress', + deletedAt: '2026-05-06T19:06:07.257Z', + }, + ]); + + expect(counts.get('jack')).toBeUndefined(); + }); + + it('keeps reopened pending tasks pending when kanban approved is stale', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'pending', + kanbanColumn: 'approved', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 1, + inProgress: 0, + completed: 0, + }); + }); + + it('counts needsFix tasks as actionable instead of completed', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'completed', + reviewState: 'needsFix', + }, + { + owner: 'jack', + status: 'in_progress', + reviewState: 'needsFix', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 1, + inProgress: 1, + completed: 0, + }); + }); + + it('does not count review workflow tasks as completed owner progress', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }, + ]); + + expect(counts.get('jack')).toBeUndefined(); + }); + + it('lets current approved overlay win over stale needsFix in task counts', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 0, + inProgress: 0, + completed: 1, + }); + }); +}); diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index f5b02d94..02c46d7b 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -37,6 +37,125 @@ describe('filterTeamMessages', () => { expect(result[0].source).toBe('lead_process'); }); + it('hides native app-managed bootstrap private control messages', () => { + const messages = [ + makeMessage({ + messageId: 'native-bootstrap-private-check', + source: undefined, + text: '\nprivate\n', + }), + makeMessage({ + messageId: 'visible-message', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['visible-message']); + }); + + it('keeps user-authored native bootstrap marker quotes visible', () => { + const messages = [ + makeMessage({ + from: 'user', + messageId: 'user-native-bootstrap-quote', + source: 'user_sent', + text: '\nquoted\n', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['user-native-bootstrap-quote']); + }); + + it('hides leaked lead inbox relay prompt echoes', () => { + const messages = [ + makeMessage({ + messageId: 'lead-relay-echo', + source: 'lead_process', + to: 'user', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }), + makeMessage({ + messageId: 'visible-message', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['visible-message']); + }); + + it('does not hide user-authored text that quotes an internal prompt', () => { + const messages = [ + makeMessage({ + messageId: 'quoted-control-prompt', + source: 'user_sent', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['quoted-control-prompt']); + }); + + it('hides Human-prefixed teammate protocol echoes', () => { + const messages = [ + makeMessage({ + messageId: 'teammate-protocol-echo', + source: 'lead_process', + text: 'Human: {"type":"idle_notification"}', + }), + makeMessage({ + messageId: 'visible-message', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['visible-message']); + }); + it('hides relay bridge copies when the original message is visible', () => { const messages = [ makeMessage({ @@ -415,4 +534,54 @@ describe('filterTeamMessages', () => { expect(result).toHaveLength(1); expect(result[0].messageId).toBe('msg-2'); }); + + it('hides task stall remediation automation rows from conversational message counts by default', () => { + const messages = [ + makeMessage({ + messageId: 'task-stall:demo:task-a:epoch-a', + from: 'system', + to: 'jack', + source: 'system_notification', + messageKind: 'task_stall_remediation', + summary: 'Potential stalled task', + text: 'Task #abcd1234 may be stalled.', + }), + makeMessage({ + messageId: 'msg-2', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['msg-2']); + }); + + it('can include task stall remediation automation rows for the activity timeline', () => { + const messages = [ + makeMessage({ + messageId: 'task-stall:demo:task-a:legacy-epoch', + from: 'system', + to: 'jack', + source: 'system_notification', + summary: 'Potential stalled task', + text: 'Task #abcd1234 may be stalled.', + }), + ]; + + const result = filterTeamMessages(messages, { + includeAutomationEvents: true, + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual([ + 'task-stall:demo:task-a:legacy-epoch', + ]); + }); }); diff --git a/test/shared/utils/reviewState.test.ts b/test/shared/utils/reviewState.test.ts index d9b5d453..b3cea794 100644 --- a/test/shared/utils/reviewState.test.ts +++ b/test/shared/utils/reviewState.test.ts @@ -111,6 +111,39 @@ describe('reviewState utils', () => { ).toBe('none'); }); + it('lets canonical pending status clear stale review history when no reopen event exists', () => { + expect( + getReviewStateFromTask({ + status: 'pending', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'review_approved', + from: 'review', + to: 'approved', + actor: 'alice', + }, + ], + }) + ).toBe('none'); + expect( + getReviewStateFromTask({ + status: 'pending', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'review_requested', + from: 'none', + to: 'review', + reviewer: 'bob', + }, + ], + }) + ).toBe('none'); + }); + it('falls back to persisted legacy reviewState when history has no review signal', () => { expect( getReviewStateFromTask({ @@ -139,4 +172,25 @@ describe('reviewState utils', () => { 'needsFix' ); }); + + it('keeps completed needsFix as a non-final review correction state', () => { + expect(getReviewStateFromTask({ reviewState: 'needsFix', status: 'completed' })).toBe( + 'needsFix' + ); + expect( + getReviewStateFromTask({ + status: 'completed', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'review_changes_requested', + from: 'review', + to: 'needsFix', + actor: 'reviewer', + }, + ], + }) + ).toBe('needsFix'); + }); }); diff --git a/test/shared/utils/taskChangeState.test.ts b/test/shared/utils/taskChangeState.test.ts index 18705758..85837d8d 100644 --- a/test/shared/utils/taskChangeState.test.ts +++ b/test/shared/utils/taskChangeState.test.ts @@ -67,4 +67,63 @@ describe('taskChangeState utils', () => { }) ).toBe('active'); }); + + it('treats in-progress tasks approved through kanban overlay as approved', () => { + const bucket = getTaskChangeStateBucket({ + status: 'in_progress', + kanbanColumn: 'approved', + }); + + expect(bucket).toBe('approved'); + expect(isTaskChangeSummaryCacheable(bucket)).toBe(true); + }); + + it('does not treat pending tasks with stale approved kanban overlay as approved', () => { + expect( + getTaskChangeStateBucket({ + status: 'pending', + kanbanColumn: 'approved', + }) + ).toBe('active'); + }); + + it('does not treat pending tasks with stale review kanban overlay as review', () => { + expect( + getTaskChangeStateBucket({ + status: 'pending', + kanbanColumn: 'review', + }) + ).toBe('active'); + }); + + it('lets current kanban review overlay win over stale approved review state', () => { + expect( + getTaskChangeStateBucket({ + status: 'completed', + reviewState: 'approved', + kanbanColumn: 'review', + }) + ).toBe('review'); + }); + + it('does not cache completed tasks that still need fixes', () => { + const bucket = getTaskChangeStateBucket({ + status: 'completed', + reviewState: 'needsFix', + }); + + expect(bucket).toBe('active'); + expect(isTaskChangeSummaryCacheable(bucket)).toBe(false); + }); + + it('lets current approved overlay win over stale needsFix for change summary caching', () => { + const bucket = getTaskChangeStateBucket({ + status: 'completed', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }); + + expect(bucket).toBe('approved'); + expect(isTaskChangeSummaryCacheable(bucket)).toBe(true); + }); }); diff --git a/test/shared/utils/teamInternalControlMessages.test.ts b/test/shared/utils/teamInternalControlMessages.test.ts new file mode 100644 index 00000000..d537a737 --- /dev/null +++ b/test/shared/utils/teamInternalControlMessages.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { + isTeamInternalControlMessageEnvelope, + isLeadInboxRelayControlPromptText, + isTeamInternalControlMessageText, + isTeammateProtocolControlText, + stripExactInternalControlEchoPrefix, +} from '@shared/utils/teamInternalControlMessages'; + +const leadRelayPrompt = `You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. +IMPORTANT: Your text response here is shown to the user. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`; +const nativeBootstrapPrompt = ` +Your Agent Teams startup context was already loaded by the app. +`; + +describe('teamInternalControlMessages', () => { + it('detects lead inbox relay prompts and Human-prefixed echoes', () => { + expect(isLeadInboxRelayControlPromptText(leadRelayPrompt)).toBe(true); + expect(isLeadInboxRelayControlPromptText(`Human: ${leadRelayPrompt}`)).toBe(true); + expect(isTeamInternalControlMessageText(`Human: ${leadRelayPrompt}`)).toBe(true); + }); + + it('does not hide ordinary visible lead replies', () => { + expect( + isLeadInboxRelayControlPromptText( + 'I delegated #f8d7235a to tom and asked alice to review when blockers clear.' + ) + ).toBe(false); + }); + + it('detects Human-prefixed teammate protocol blocks', () => { + const text = + 'Human: \n{"type":"idle_notification"}\n'; + + expect(isTeammateProtocolControlText(text)).toBe(true); + expect(isTeamInternalControlMessageText(text)).toBe(true); + }); + + it('only treats internal-looking text as hidden for internal message sources', () => { + expect( + isTeamInternalControlMessageEnvelope({ + source: 'lead_process', + text: `Human: ${leadRelayPrompt}`, + }) + ).toBe(true); + expect( + isTeamInternalControlMessageEnvelope({ + source: 'user_sent', + text: `Human: ${leadRelayPrompt}`, + }) + ).toBe(false); + expect( + isTeamInternalControlMessageEnvelope({ + text: `Human: ${leadRelayPrompt}`, + }) + ).toBe(false); + expect( + isTeamInternalControlMessageEnvelope({ + text: nativeBootstrapPrompt, + from: 'team-lead', + }) + ).toBe(true); + expect( + isTeamInternalControlMessageEnvelope({ + text: nativeBootstrapPrompt, + from: 'orchestrator', + }) + ).toBe(true); + expect(isTeamInternalControlMessageText(`Human: ${nativeBootstrapPrompt}`)).toBe(true); + expect( + isTeamInternalControlMessageEnvelope({ + source: 'lead_process', + text: `Visible note quoting ${nativeBootstrapPrompt}`, + }) + ).toBe(false); + expect( + isTeamInternalControlMessageEnvelope({ + source: 'user_sent', + text: nativeBootstrapPrompt, + from: 'user', + }) + ).toBe(false); + expect( + isTeamInternalControlMessageEnvelope({ + text: nativeBootstrapPrompt, + from: 'user', + }) + ).toBe(false); + }); + + it('strips an exact echoed control prefix while preserving visible trailing text', () => { + expect(stripExactInternalControlEchoPrefix(`Human: ${leadRelayPrompt}`, leadRelayPrompt)).toBe( + '' + ); + expect( + stripExactInternalControlEchoPrefix( + `Human: ${leadRelayPrompt}\n\nDelegated to bob.`, + leadRelayPrompt + ) + ).toBe('Delegated to bob.'); + }); +});