From d60abd54fe0c590ec941c986611f8e7839372073 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 11:53:34 +0300 Subject: [PATCH 01/22] chore: checkpoint team runtime work --- agent-teams-controller/src/internal/tasks.js | 19 +- mcp-server/src/agent-teams-controller.d.ts | 2 +- mcp-server/src/tools/taskTools.ts | 4 +- package.json | 4 + scripts/prove-agent-cli-launch.mjs | 43 + scripts/prove-opencode-semantic-gauntlet.mjs | 73 + scripts/prove-opencode-semantic-messaging.mjs | 65 + .../prove-opencode-semantic-model-matrix.mjs | 63 + .../buildMixedPersistedLaunchSnapshot.ts | 9 + .../services/team/TeamLaunchStateEvaluator.ts | 68 + .../services/team/TeamProvisioningService.ts | 394 ++++- .../bridge/OpenCodeBridgeCommandContract.ts | 36 +- .../bridge/OpenCodeBridgeHandshakeClient.ts | 6 + .../OpenCodeRuntimeManifestEvidenceReader.ts | 78 +- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 162 +- .../team/runtime/TeamRuntimeAdapter.ts | 6 + src/shared/types/team.ts | 24 + src/types/agent-teams-controller.d.ts | 2 +- .../main/createMemberWorkSyncFeature.test.ts | 1527 ++++++++++++++++- .../team/MemberWorkSyncOpenCode.live.test.ts | 286 +++ .../OpenCodeBridgeCommandContract.test.ts | 3 + .../team/OpenCodeMixedRecovery.live.test.ts | 33 +- .../team/OpenCodeTeamRuntimeAdapter.test.ts | 3 +- .../team/TeamProvisioningService.test.ts | 166 +- .../team/TeamProvisioningServiceRelay.test.ts | 173 ++ .../services/team/openCodeLiveTestHarness.ts | 21 +- 26 files changed, 3146 insertions(+), 124 deletions(-) create mode 100644 scripts/prove-agent-cli-launch.mjs create mode 100644 scripts/prove-opencode-semantic-gauntlet.mjs create mode 100644 scripts/prove-opencode-semantic-messaging.mjs create mode 100644 scripts/prove-opencode-semantic-model-matrix.mjs create mode 100644 test/main/services/team/MemberWorkSyncOpenCode.live.test.ts diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 0040643f..6222e4a7 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -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/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 9f7c9b31..a82d7702 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -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..1bf98490 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -622,8 +622,9 @@ 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 +632,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/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/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/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/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/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b78c3026..0530395f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -402,6 +402,8 @@ import type { MemberSpawnStatus, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + OpenCodeAppManagedBootstrapCandidate, + OpenCodeBootstrapEvidenceSource, OpenCodeRuntimeDeliveryStatus, PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, @@ -2160,6 +2162,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 +2517,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 +2530,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 +2656,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, @@ -6687,6 +6739,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; @@ -7115,6 +7174,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 +7236,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); @@ -8098,16 +8213,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 +8244,7 @@ export class TeamProvisioningService { }); ledgerRecord = proof.ledgerRecord; this.logOpenCodePromptDeliveryEvent( - result.ok + promptAccepted ? ledgerRecord.status === 'unanswered' ? 'opencode_prompt_delivery_unanswered' : ledgerRecord.status === 'responded' @@ -8135,7 +8252,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 +8280,7 @@ export class TeamProvisioningService { visibleReply, ledgerRecord, }); - if (ledgerRecord && result.ok && !readAllowed) { + if (ledgerRecord && promptAccepted && !readAllowed) { const retry = this.isOpenCodeDeliveryRetryablePendingResponse({ ledgerRecord, visibleReply, @@ -8196,7 +8316,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 +8359,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 +8375,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 +8399,7 @@ export class TeamProvisioningService { : {}), ...(pendingReason ? { reason: pendingReason } - : result.ok + : promptAccepted ? {} : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), diagnostics, @@ -9927,6 +10047,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 +10066,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 +10075,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 +10115,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 +10128,8 @@ export class TeamProvisioningService { laneId: string; memberName: string; runtimeSessionId: string; + source?: OpenCodeBootstrapEvidenceSource; + appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate; }): Promise { const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ teamsBasePath: getTeamsBasePath(), @@ -10009,12 +10142,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: { @@ -16656,7 +16805,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 +16813,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 +16946,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 +17019,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 +17085,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, @@ -18545,6 +18775,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 ?? []), @@ -21577,6 +21818,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 +21831,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 +21860,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 +21892,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 +22317,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 +22354,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 +22737,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 +22802,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 +22840,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, @@ -30055,6 +30362,7 @@ export class TeamProvisioningService { teamName: fixture.teamName, memberName: fixture.memberName, runtimeProvider: 'opencode', + includeActiveProcesses: false, }, }); throwIfCancelled(); diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index d772f05b..c8d2090f 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; @@ -373,6 +383,7 @@ export interface OpenCodeBridgePeerIdentity { currentVersion: number; supportedCommands: OpenCodeBridgeCommandName[]; opencodeTaskLedgerEvidenceContractVersion?: number; + opencodeAppManagedBootstrapContractVersion?: number; }; runtime: { providerId: 'opencode'; @@ -591,6 +602,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 +891,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/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/shared/types/team.ts b/src/shared/types/team.ts index 37a8e8f5..4a93ba75 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1005,6 +1005,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 +1053,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; diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index f5e5fccc..b858cae4 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -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/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 46fb6531..5181d4c4 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -75,7 +75,7 @@ async function seedShadowReadyMetrics(input: { } 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 +92,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 +337,1424 @@ 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('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/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/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/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/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 78635346..7cba9408 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: { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index a5aa95cc..a3686e61 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -1834,6 +1834,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 +1989,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'; 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(); From 1febc3448b3c77efd57da519083d89d473671d97 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 17:34:01 +0300 Subject: [PATCH 02/22] feat(team): add app-managed native bootstrap --- electron.vite.config.1778078040752.mjs | 149 ++++++++ .../services/team/TeamProvisioningService.ts | 341 +++++++++++++----- .../bootstrap/BootstrapProofValidation.ts | 225 ++++++++++++ ...NativeAppManagedBootstrapContextBuilder.ts | 186 ++++++++++ src/main/services/team/runtimeTeammateMode.ts | 4 +- .../team/AnthropicRuntimeMemory.live.test.ts | 22 +- .../team/BootstrapProofValidation.test.ts | 73 ++++ .../MemberWorkSyncClaudeStopHook.live.test.ts | 47 ++- .../team/MixedProviderTeamLaunch.live.test.ts | 3 +- ...eAppManagedBootstrapContextBuilder.test.ts | 131 +++++++ .../team/TeamProvisioningService.test.ts | 161 +++++++++ .../services/team/runtimeTeammateMode.test.ts | 6 +- 12 files changed, 1236 insertions(+), 112 deletions(-) create mode 100644 electron.vite.config.1778078040752.mjs create mode 100644 src/main/services/team/bootstrap/BootstrapProofValidation.ts create mode 100644 src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts create mode 100644 test/main/services/team/BootstrapProofValidation.test.ts create mode 100644 test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts diff --git a/electron.vite.config.1778078040752.mjs b/electron.vite.config.1778078040752.mjs new file mode 100644 index 00000000..95696b36 --- /dev/null +++ b/electron.vite.config.1778078040752.mjs @@ -0,0 +1,149 @@ +// electron.vite.config.ts +import { defineConfig } from "electron-vite"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; +import react from "@vitejs/plugin-react"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team"; +var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8")); +var prodDeps = Object.keys(pkg.dependencies || {}); +var runtimeExternalDeps = /* @__PURE__ */ new Set([ + "node-pty", + "agent-teams-controller", + "fastify", + "@fastify/cors", + "@fastify/static" +]); +var bundledDeps = prodDeps.filter((d) => !runtimeExternalDeps.has(d)); +function nativeModuleStub() { + const STUB_ID = "\0native-stub"; + const NODE_MODULE_RE = /\.node(?:\?.*)?$/; + return { + name: "native-module-stub", + enforce: "pre", + resolveId(source) { + if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`; + return null; + }, + load(id) { + if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return "export default {}"; + return null; + } + }; +} +var sentryPlugins = process.env.SENTRY_AUTH_TOKEN ? [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG ?? "quant-jump-pro", + project: process.env.SENTRY_PROJECT ?? "electron", + authToken: process.env.SENTRY_AUTH_TOKEN, + release: { name: `agent-teams-ai@${pkg.version}` }, + sourcemaps: { + filesToDeleteAfterUpload: ["./out/renderer/**/*.map", "./dist-electron/**/*.map"] + } + }) +] : []; +var electron_vite_config_default = defineConfig({ + main: { + plugins: [ + nativeModuleStub(), + ...sentryPlugins + ], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + // Inject DSN at compile time — process.env.SENTRY_DSN is NOT available + // at runtime in packaged Electron apps (only during CI build). + "process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "") + }, + resolve: { + alias: { + "@features": resolve(__electron_vite_injected_dirname, "src/features"), + "@main": resolve(__electron_vite_injected_dirname, "src/main"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@preload": resolve(__electron_vite_injected_dirname, "src/preload") + } + }, + build: { + externalizeDeps: { + exclude: bundledDeps + }, + commonjsOptions: { + strictRequires: [/node_modules\/.*ssh2\//] + }, + sourcemap: "hidden", + outDir: "dist-electron/main", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"), + "team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts"), + "task-change-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/task-change-worker.ts"), + "team-data-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-data-worker.ts") + }, + output: { + // CJS format so bundled deps can use __dirname/require. + // Use .cjs extension since package.json has "type": "module". + format: "cjs", + entryFileNames: "[name].cjs", + // Set UV_THREADPOOL_SIZE before any module code runs. + // Must be in the banner because ESM→CJS hoists imports above top-level code. + // On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher; + // with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock. + banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}` + } + } + } + }, + preload: { + resolve: { + alias: { + "@features": resolve(__electron_vite_injected_dirname, "src/features"), + "@preload": resolve(__electron_vite_injected_dirname, "src/preload"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main") + } + }, + build: { + outDir: "dist-electron/preload", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts") + }, + output: { + format: "cjs", + entryFileNames: "[name].js" + } + } + } + }, + renderer: { + optimizeDeps: { + include: ["@codemirror/language-data"], + exclude: ["@claude-teams/agent-graph"] + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + // Pass SENTRY_DSN to renderer as VITE_SENTRY_DSN (Vite replaces at compile time) + "import.meta.env.VITE_SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "") + }, + resolve: { + alias: { + "@features": resolve(__electron_vite_injected_dirname, "src/features"), + "@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main"), + "@claude-teams/agent-graph": resolve(__electron_vite_injected_dirname, "packages/agent-graph/src/index.ts") + } + }, + plugins: [react(), ...sentryPlugins], + build: { + sourcemap: "hidden", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html") + } + } + } + } +}); +export { + electron_vite_config_default as default +}; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0530395f..58233b05 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -255,6 +255,14 @@ import { import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; +import { + buildNativeAppManagedBootstrapSpecs, + type NativeAppManagedBootstrapSpec, +} from './bootstrap/NativeAppManagedBootstrapContextBuilder'; +import { + parseBootstrapRuntimeProofDetail, + validateBootstrapRuntimeProofEnvelope, +} from './bootstrap/BootstrapProofValidation'; import type { OpenCodeCommittedBootstrapSessionRecord, @@ -307,6 +315,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 +353,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 +361,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'; @@ -3927,6 +3913,7 @@ interface RuntimeBootstrapMemberSpec { description?: string; useSplitPane?: boolean; planModeRequired?: boolean; + nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec; } interface RuntimeBootstrapSpec { @@ -3961,7 +3948,8 @@ interface RuntimeBootstrapSpec { function buildDeterministicCreateBootstrapSpec( runId: string, request: TeamCreateRequest, - effectiveMembers: TeamCreateRequest['members'] + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -4001,6 +3989,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, @@ -4014,7 +4005,8 @@ function buildDeterministicCreateBootstrapSpec( function buildDeterministicLaunchBootstrapSpec( runId: string, request: TeamLaunchRequest, - effectiveMembers: TeamCreateRequest['members'] + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -4051,6 +4043,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, @@ -12041,6 +12036,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) { @@ -12077,6 +12080,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) { @@ -12137,6 +12157,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 ?? @@ -12172,7 +12193,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 { @@ -12188,7 +12240,7 @@ export class TeamProvisioningService { snapshotMembers[memberName] = { memberName, - alive: liveRuntimeMember?.alive === true, + alive: effectiveAlive, restartable, ...(backendType ? { backendType } : {}), ...(memberProviderId ? { providerId: memberProviderId } : {}), @@ -12201,9 +12253,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 } @@ -12221,11 +12271,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, @@ -16315,16 +16363,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; @@ -16335,6 +16373,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; @@ -16366,6 +16450,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( @@ -16434,35 +16523,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 || @@ -17617,7 +17677,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); @@ -20413,6 +20478,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> { @@ -20620,7 +20753,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)) { @@ -20739,7 +20877,6 @@ export class TeamProvisioningService { updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(), } : undefined; - const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus; const shouldUseWindowsHostRows = process.platform === 'win32' && (metadata.providerId === 'opencode' || @@ -20754,6 +20891,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, @@ -23194,24 +23340,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() : ''; @@ -23245,7 +23388,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, 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..ab3d43b5 --- /dev/null +++ b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts @@ -0,0 +1,186 @@ +import * as agentTeamsControllerModule from 'agent-teams-controller'; +import { createHash } from 'crypto'; + +import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import type { TeamCreateRequest, TeamProviderId } from '@shared/types'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; + +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-Za-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/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/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/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/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7cba9408..e9315099 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -12779,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/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(); }); }); From 7cc6cb100a1fae82003f8f3968911ae953495a8f Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 17:34:27 +0300 Subject: [PATCH 03/22] chore(team): remove generated vite config artifact --- electron.vite.config.1778078040752.mjs | 149 ------------------------- 1 file changed, 149 deletions(-) delete mode 100644 electron.vite.config.1778078040752.mjs diff --git a/electron.vite.config.1778078040752.mjs b/electron.vite.config.1778078040752.mjs deleted file mode 100644 index 95696b36..00000000 --- a/electron.vite.config.1778078040752.mjs +++ /dev/null @@ -1,149 +0,0 @@ -// electron.vite.config.ts -import { defineConfig } from "electron-vite"; -import { sentryVitePlugin } from "@sentry/vite-plugin"; -import react from "@vitejs/plugin-react"; -import { readFileSync } from "fs"; -import { resolve } from "path"; -var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team"; -var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8")); -var prodDeps = Object.keys(pkg.dependencies || {}); -var runtimeExternalDeps = /* @__PURE__ */ new Set([ - "node-pty", - "agent-teams-controller", - "fastify", - "@fastify/cors", - "@fastify/static" -]); -var bundledDeps = prodDeps.filter((d) => !runtimeExternalDeps.has(d)); -function nativeModuleStub() { - const STUB_ID = "\0native-stub"; - const NODE_MODULE_RE = /\.node(?:\?.*)?$/; - return { - name: "native-module-stub", - enforce: "pre", - resolveId(source) { - if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`; - return null; - }, - load(id) { - if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return "export default {}"; - return null; - } - }; -} -var sentryPlugins = process.env.SENTRY_AUTH_TOKEN ? [ - sentryVitePlugin({ - org: process.env.SENTRY_ORG ?? "quant-jump-pro", - project: process.env.SENTRY_PROJECT ?? "electron", - authToken: process.env.SENTRY_AUTH_TOKEN, - release: { name: `agent-teams-ai@${pkg.version}` }, - sourcemaps: { - filesToDeleteAfterUpload: ["./out/renderer/**/*.map", "./dist-electron/**/*.map"] - } - }) -] : []; -var electron_vite_config_default = defineConfig({ - main: { - plugins: [ - nativeModuleStub(), - ...sentryPlugins - ], - define: { - __APP_VERSION__: JSON.stringify(pkg.version), - // Inject DSN at compile time — process.env.SENTRY_DSN is NOT available - // at runtime in packaged Electron apps (only during CI build). - "process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "") - }, - resolve: { - alias: { - "@features": resolve(__electron_vite_injected_dirname, "src/features"), - "@main": resolve(__electron_vite_injected_dirname, "src/main"), - "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), - "@preload": resolve(__electron_vite_injected_dirname, "src/preload") - } - }, - build: { - externalizeDeps: { - exclude: bundledDeps - }, - commonjsOptions: { - strictRequires: [/node_modules\/.*ssh2\//] - }, - sourcemap: "hidden", - outDir: "dist-electron/main", - rollupOptions: { - input: { - index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"), - "team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts"), - "task-change-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/task-change-worker.ts"), - "team-data-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-data-worker.ts") - }, - output: { - // CJS format so bundled deps can use __dirname/require. - // Use .cjs extension since package.json has "type": "module". - format: "cjs", - entryFileNames: "[name].cjs", - // Set UV_THREADPOOL_SIZE before any module code runs. - // Must be in the banner because ESM→CJS hoists imports above top-level code. - // On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher; - // with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock. - banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}` - } - } - } - }, - preload: { - resolve: { - alias: { - "@features": resolve(__electron_vite_injected_dirname, "src/features"), - "@preload": resolve(__electron_vite_injected_dirname, "src/preload"), - "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), - "@main": resolve(__electron_vite_injected_dirname, "src/main") - } - }, - build: { - outDir: "dist-electron/preload", - rollupOptions: { - input: { - index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts") - }, - output: { - format: "cjs", - entryFileNames: "[name].js" - } - } - } - }, - renderer: { - optimizeDeps: { - include: ["@codemirror/language-data"], - exclude: ["@claude-teams/agent-graph"] - }, - define: { - __APP_VERSION__: JSON.stringify(pkg.version), - // Pass SENTRY_DSN to renderer as VITE_SENTRY_DSN (Vite replaces at compile time) - "import.meta.env.VITE_SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "") - }, - resolve: { - alias: { - "@features": resolve(__electron_vite_injected_dirname, "src/features"), - "@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"), - "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), - "@main": resolve(__electron_vite_injected_dirname, "src/main"), - "@claude-teams/agent-graph": resolve(__electron_vite_injected_dirname, "packages/agent-graph/src/index.ts") - } - }, - plugins: [react(), ...sentryPlugins], - build: { - sourcemap: "hidden", - rollupOptions: { - input: { - index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html") - } - } - } - } -}); -export { - electron_vite_config_default as default -}; From 4013c473320afd3f506d8b1a9646c324fa32c3ff Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 17:52:16 +0300 Subject: [PATCH 04/22] chore(runtime): pin orchestrator 0.0.19 --- runtime.lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index d7894dfa..1495a1da 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.18", - "sourceRef": "v0.0.18", + "version": "0.0.19", + "sourceRef": "v0.0.19", "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.19.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.19.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.19.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.19.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } From 2a41010610461b92ed4dc48593cf428b69e321b7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 18:17:56 +0300 Subject: [PATCH 05/22] fix(team): filter internal control messages --- src/main/index.ts | 4 + .../services/team/TeamMessageFeedService.ts | 9 ++- .../services/team/TeamProvisioningService.ts | 45 ++++++----- .../team/activity/LeadThoughtsGroup.tsx | 4 +- .../team/kanban/KanbanTaskCard.test.tsx | 77 +++++++++++++++++++ .../components/team/kanban/KanbanTaskCard.tsx | 5 +- src/renderer/index.css | 19 +++++ .../utils/bootstrapPromptSanitizer.ts | 29 +++++++ src/renderer/utils/teamMessageFiltering.ts | 6 +- .../utils/teamInternalControlMessages.ts | 47 +++++++++++ ...ProductionPromptArtifacts.safe-e2e.test.ts | 4 +- ...eStateChangingBridgeCommandService.test.ts | 3 + .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 25 ++++-- .../team/TeamMessageFeedService.test.ts | 23 ++++++ .../team/TeamProvisioningServiceRelay.test.ts | 67 ++++++++++++++++ .../team/activity/LeadThoughtsGroup.test.ts | 19 +++++ .../utils/bootstrapPromptSanitizer.test.ts | 25 ++++++ .../utils/teamMessageFiltering.test.ts | 75 ++++++++++++++++++ .../utils/teamInternalControlMessages.test.ts | 42 ++++++++++ 19 files changed, 496 insertions(+), 32 deletions(-) create mode 100644 src/shared/utils/teamInternalControlMessages.ts create mode 100644 test/shared/utils/teamInternalControlMessages.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 37d5534d..6ba75d59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -90,6 +90,7 @@ import { } from '@shared/constants'; import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics'; import { parseInboxJson } from '@shared/utils/inboxNoise'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { createLogger } from '@shared/utils/logger'; import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; @@ -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 (isTeamInternalControlMessageText(msg.text)) continue; // Skip internal coordination noise (idle_notification, shutdown_*, etc.) if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue; diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index 3865ef3d..28ede7ae 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 { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { createHash } from 'crypto'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; @@ -138,6 +139,10 @@ function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessa })); } +function isVisibleTeamMessage(message: InboxMessage): boolean { + return !isTeamInternalControlMessageText(message.text); +} + function annotateSlashCommandResponses(messages: InboxMessage[]): void { let pendingSlash = null as InboxMessage['slashCommand'] | null; @@ -499,7 +504,9 @@ export class TeamMessageFeedService { const normalizeStartedAt = Date.now(); const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); - let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages]; + 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 58233b05..76b93249 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -88,6 +88,7 @@ import { parseAllTeammateMessages, type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { @@ -19400,24 +19401,28 @@ export class TeamProvisioningService { // that is not meant for the human user. const cleanReply = replyText ? stripAgentBlocks(replyText) : 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; @@ -25426,7 +25431,7 @@ export class TeamProvisioningService { !hasCapturedVisibleSendMessage ) { const cleanText = stripAgentBlocks(text).trim(); - if (cleanText.length > 0) { + if (cleanText.length > 0 && !isTeamInternalControlMessageText(cleanText)) { this.pushLiveLeadTextMessage( run, cleanText, @@ -25440,7 +25445,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, diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index bcb1626f..46c79f76 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -37,6 +37,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; @@ -73,6 +74,7 @@ export function isLeadThought(msg: InboxMessage): boolean { if (msg.messageKind === 'slash_command_result') return false; // Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden if (isThoughtProtocolNoise(msg.text)) return false; + if (isTeamInternalControlMessageText(msg.text)) return false; if (msg.source === 'lead_session') return true; if (msg.source === 'lead_process') return true; return false; @@ -90,7 +92,7 @@ export function isLeadThought(msg: InboxMessage): boolean { function isLeadSessionNoise(msg: InboxMessage): boolean { if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false; if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; - return isThoughtProtocolNoise(msg.text); + return isThoughtProtocolNoise(msg.text) || isTeamInternalControlMessageText(msg.text); } export type TimelineItem = diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 6b7383f9..141124c9 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,45 @@ 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(); + }); + } + ); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 7c84488d..5d8686f0 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -245,6 +245,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 +289,8 @@ export const KanbanTaskCard = memo( return (
+): InternalControlMessageDisplay | null { + if (isNativeAppManagedBootstrapCheckText(message.text)) { + return { + summary: 'Internal bootstrap check', + body: 'Internal bootstrap check hidden in the UI.', + }; + } + if (!isTeamInternalControlMessageText(message.text)) { + return null; + } + return { + summary: 'Internal control message', + body: 'Internal control message hidden in the UI.', + }; +} + export function getBootstrapPromptDisplay( message: Pick ): BootstrapPromptDisplay | null { @@ -211,6 +238,7 @@ export function getBootstrapAcknowledgementDisplay( export function getSanitizedInboxMessageText(message: Pick): string { return ( + getInternalControlMessageDisplay(message)?.body ?? getBootstrapPromptDisplay(message)?.body ?? getBootstrapAcknowledgementDisplay(message as Pick)?.body ?? message.text ?? @@ -222,6 +250,7 @@ export function getSanitizedInboxMessageSummary( message: Pick ): string { return ( + getInternalControlMessageDisplay(message)?.summary ?? getBootstrapPromptDisplay(message)?.summary ?? getBootstrapAcknowledgementDisplay(message)?.summary ?? message.summary ?? diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 47e551a4..f462e8f9 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -4,6 +4,7 @@ import { } from '@renderer/utils/bootstrapPromptSanitizer'; import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import type { InboxMessage } from '@shared/types'; @@ -125,7 +126,10 @@ 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' && !isTeamInternalControlMessageText(m.text) + ); if (timeWindow) { list = list.filter((m) => { const ts = new Date(m.timestamp).getTime(); diff --git a/src/shared/utils/teamInternalControlMessages.ts b/src/shared/utils/teamInternalControlMessages.ts new file mode 100644 index 00000000..93e802dd --- /dev/null +++ b/src/shared/utils/teamInternalControlMessages.ts @@ -0,0 +1,47 @@ +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 = /^ { 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/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/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/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index b35ddae4..8483ff18 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -74,6 +74,29 @@ 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({ + messageId: 'native-bootstrap-private-check', + source: 'system_notification', + 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('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); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index a3686e61..a420ce1e 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -306,6 +306,42 @@ 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('treats member work sync nudges as actionable in lead relay prompt', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -436,6 +472,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'; 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/utils/bootstrapPromptSanitizer.test.ts b/test/renderer/utils/bootstrapPromptSanitizer.test.ts index e5780ee8..773b9208 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,28 @@ 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. +`); + + expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal bootstrap check'); + expect(getSanitizedInboxMessageText(message)).toBe('Internal bootstrap check hidden in the UI.'); + }); + + 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.`); + + expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal control message'); + expect(getSanitizedInboxMessageText(message)).toBe('Internal control message hidden in the UI.'); + }); }); diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index f5b02d94..b9ec6965 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -37,6 +37,81 @@ 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: 'system_notification', + 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('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('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({ diff --git a/test/shared/utils/teamInternalControlMessages.test.ts b/test/shared/utils/teamInternalControlMessages.test.ts new file mode 100644 index 00000000..29d32737 --- /dev/null +++ b/test/shared/utils/teamInternalControlMessages.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + isLeadInboxRelayControlPromptText, + isTeamInternalControlMessageText, + isTeammateProtocolControlText, +} 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.`; + +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); + }); +}); From 2080e86f44f9c84698b1c1f12145c251dac9f90e Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 18:39:17 +0300 Subject: [PATCH 06/22] fix(team): satisfy bootstrap redaction lint --- src/main/index.ts | 4 +- .../services/team/TeamMessageFeedService.ts | 4 +- .../services/team/TeamProvisioningService.ts | 28 ++++++++----- ...NativeAppManagedBootstrapContextBuilder.ts | 6 +-- .../utils/bootstrapPromptSanitizer.ts | 17 ++++---- src/renderer/utils/teamMessageFiltering.ts | 5 +-- .../utils/teamInternalControlMessages.ts | 31 +++++++++++++- .../team/TeamMessageFeedService.test.ts | 26 ++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 41 +++++++++++++++++++ .../utils/bootstrapPromptSanitizer.test.ts | 29 +++++++++++-- .../utils/teamMessageFiltering.test.ts | 25 +++++++++++ .../utils/teamInternalControlMessages.test.ts | 34 +++++++++++++++ 12 files changed, 218 insertions(+), 32 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 6ba75d59..a780e7b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -90,8 +90,8 @@ import { } from '@shared/constants'; import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics'; import { parseInboxJson } from '@shared/utils/inboxNoise'; -import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; 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'; @@ -473,7 +473,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise 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 (isTeamInternalControlMessageText(msg.text)) continue; + if (isTeamInternalControlMessageEnvelope(msg)) continue; // Skip internal coordination noise (idle_notification, shutdown_*, etc.) if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue; diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index 28ede7ae..668f529e 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -1,7 +1,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { createLogger } from '@shared/utils/logger'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; -import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; +import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import { createHash } from 'crypto'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; @@ -140,7 +140,7 @@ function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessa } function isVisibleTeamMessage(message: InboxMessage): boolean { - return !isTeamInternalControlMessageText(message.text); + return !isTeamInternalControlMessageEnvelope(message); } function annotateSlashCommandResponses(messages: InboxMessage[]): void { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 76b93249..e7d00a41 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -84,11 +84,14 @@ 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 { + isTeamInternalControlMessageText, + stripExactInternalControlEchoPrefix, +} from '@shared/utils/teamInternalControlMessages'; import { parseAllTeammateMessages, type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; -import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { @@ -143,6 +146,14 @@ import { type TeamRuntimeSettingsJson, } from '../runtime/teamRuntimeSettingsBundle'; +import { + parseBootstrapRuntimeProofDetail, + validateBootstrapRuntimeProofEnvelope, +} from './bootstrap/BootstrapProofValidation'; +import { + buildNativeAppManagedBootstrapSpecs, + type NativeAppManagedBootstrapSpec, +} from './bootstrap/NativeAppManagedBootstrapContextBuilder'; import { createOpenCodePromptDeliveryLedgerStore, hashOpenCodePromptDeliveryPayload, @@ -256,14 +267,6 @@ import { import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; -import { - buildNativeAppManagedBootstrapSpecs, - type NativeAppManagedBootstrapSpec, -} from './bootstrap/NativeAppManagedBootstrapContextBuilder'; -import { - parseBootstrapRuntimeProofDetail, - validateBootstrapRuntimeProofEnvelope, -} from './bootstrap/BootstrapProofValidation'; import type { OpenCodeCommittedBootstrapSessionRecord, @@ -19399,7 +19402,12 @@ 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) { if (isTeamInternalControlMessageText(cleanReply)) { logger.debug(`[${teamName}] Suppressed internal lead relay echo`); diff --git a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts index ab3d43b5..eb761ac3 100644 --- a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts +++ b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts @@ -1,9 +1,9 @@ +import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import { createHash } from 'crypto'; -import { getClaudeBasePath } from '@main/utils/pathDecoder'; import type { TeamCreateRequest, TeamProviderId } from '@shared/types'; -import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; const { createController } = agentTeamsControllerModule; @@ -41,7 +41,7 @@ function redactNativeBootstrapContextText(input: string): string { .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-Za-z0-9._-]+/gi, 'Bearer [REDACTED]'); + .replace(/Bearer\s+[A-Z0-9._-]+/gi, 'Bearer [REDACTED]'); } function boundText(input: string, maxChars: number): string { diff --git a/src/renderer/utils/bootstrapPromptSanitizer.ts b/src/renderer/utils/bootstrapPromptSanitizer.ts index 9c1cc23a..64538dc6 100644 --- a/src/renderer/utils/bootstrapPromptSanitizer.ts +++ b/src/renderer/utils/bootstrapPromptSanitizer.ts @@ -6,7 +6,7 @@ import { } from '@renderer/utils/teamModelCatalog'; import { isNativeAppManagedBootstrapCheckText, - isTeamInternalControlMessageText, + isTeamInternalControlMessageEnvelope, } from '@shared/utils/teamInternalControlMessages'; import type { InboxMessage, TeamProviderId } from '@shared/types'; @@ -135,17 +135,17 @@ export interface InternalControlMessageDisplay { } export function getInternalControlMessageDisplay( - message: Pick + 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.', }; } - if (!isTeamInternalControlMessageText(message.text)) { - return null; - } return { summary: 'Internal control message', body: 'Internal control message hidden in the UI.', @@ -236,7 +236,9 @@ export function getBootstrapAcknowledgementDisplay( }; } -export function getSanitizedInboxMessageText(message: Pick): string { +export function getSanitizedInboxMessageText( + message: Pick & Partial> +): string { return ( getInternalControlMessageDisplay(message)?.body ?? getBootstrapPromptDisplay(message)?.body ?? @@ -247,7 +249,8 @@ export function getSanitizedInboxMessageText(message: Pick + message: Pick & + Partial> ): string { return ( getInternalControlMessageDisplay(message)?.summary ?? diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index f462e8f9..91568dc5 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -4,7 +4,7 @@ import { } from '@renderer/utils/bootstrapPromptSanitizer'; import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; -import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; +import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import type { InboxMessage } from '@shared/types'; @@ -127,8 +127,7 @@ export function filterTeamMessages( const leadNames = normalizeLeadNames(rawLeadNames); let list = messages.filter( - (m) => - m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageText(m.text) + (m) => m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageEnvelope(m) ); if (timeWindow) { list = list.filter((m) => { diff --git a/src/shared/utils/teamInternalControlMessages.ts b/src/shared/utils/teamInternalControlMessages.ts index 93e802dd..2c2899aa 100644 --- a/src/shared/utils/teamInternalControlMessages.ts +++ b/src/shared/utils/teamInternalControlMessages.ts @@ -2,7 +2,14 @@ const NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN = ' { 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('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); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index a420ce1e..3919bd64 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -342,6 +342,47 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 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'; diff --git a/test/renderer/utils/bootstrapPromptSanitizer.test.ts b/test/renderer/utils/bootstrapPromptSanitizer.test.ts index 773b9208..5220eb80 100644 --- a/test/renderer/utils/bootstrapPromptSanitizer.test.ts +++ b/test/renderer/utils/bootstrapPromptSanitizer.test.ts @@ -67,16 +67,20 @@ Do NOT send acknowledgement-only messages such as "ready" or "online".`); }); it('sanitizes native app-managed bootstrap private control prompts defensively', () => { - const message = makeMessage(` + const message = makeMessage( + ` Your Agent Teams startup context was already loaded by the app. -`); +`, + { source: 'system_notification' } + ); expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal bootstrap check'); expect(getSanitizedInboxMessageText(message)).toBe('Internal bootstrap check hidden in the UI.'); }); it('sanitizes leaked lead inbox relay prompts defensively', () => { - const message = makeMessage(`Human: You have new inbox messages addressed to you (team lead "team-lead"). + 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. @@ -84,9 +88,26 @@ Messages: 1) From: tom Timestamp: 2026-05-06T15:02:54.853Z Text: - #f8d7235a done.`); + #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/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index b9ec6965..503132b8 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -90,6 +90,31 @@ Messages: 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({ diff --git a/test/shared/utils/teamInternalControlMessages.test.ts b/test/shared/utils/teamInternalControlMessages.test.ts index 29d32737..334ed5f7 100644 --- a/test/shared/utils/teamInternalControlMessages.test.ts +++ b/test/shared/utils/teamInternalControlMessages.test.ts @@ -1,9 +1,11 @@ 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"). @@ -39,4 +41,36 @@ describe('teamInternalControlMessages', () => { 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); + }); + + 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.'); + }); }); From ac2b6c93526ef0350734edc84d1ecf2f45ccbb87 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 18:59:54 +0300 Subject: [PATCH 07/22] fix(team): recognize native bootstrap control envelopes --- .../utils/bootstrapPromptSanitizer.ts | 4 +-- .../utils/teamInternalControlMessages.ts | 13 ++++++- .../team/TeamMessageFeedService.test.ts | 25 ++++++++++++- .../utils/bootstrapPromptSanitizer.test.ts | 21 ++++++++++- .../utils/teamMessageFiltering.test.ts | 21 ++++++++++- .../utils/teamInternalControlMessages.test.ts | 35 +++++++++++++++++++ 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/renderer/utils/bootstrapPromptSanitizer.ts b/src/renderer/utils/bootstrapPromptSanitizer.ts index 64538dc6..20576cd7 100644 --- a/src/renderer/utils/bootstrapPromptSanitizer.ts +++ b/src/renderer/utils/bootstrapPromptSanitizer.ts @@ -135,7 +135,7 @@ export interface InternalControlMessageDisplay { } export function getInternalControlMessageDisplay( - message: Pick & Partial> + message: Pick & Partial> ): InternalControlMessageDisplay | null { if (!isTeamInternalControlMessageEnvelope(message)) { return null; @@ -237,7 +237,7 @@ export function getBootstrapAcknowledgementDisplay( } export function getSanitizedInboxMessageText( - message: Pick & Partial> + message: Pick & Partial> ): string { return ( getInternalControlMessageDisplay(message)?.body ?? diff --git a/src/shared/utils/teamInternalControlMessages.ts b/src/shared/utils/teamInternalControlMessages.ts index 2c2899aa..ee3e5c88 100644 --- a/src/shared/utils/teamInternalControlMessages.ts +++ b/src/shared/utils/teamInternalControlMessages.ts @@ -8,6 +8,7 @@ const INTERNAL_CONTROL_MESSAGE_SOURCES = new Set([ 'runtime_delivery', 'system_notification', ]); +const INTERNAL_BOOTSTRAP_AUTHORS = new Set(['team-lead', 'lead', 'orchestrator']); export function stripTranscriptSpeakerPrefix(value: string): string { let normalized = value.trim(); @@ -22,7 +23,7 @@ export function stripTranscriptSpeakerPrefix(value: string): string { export function isNativeAppManagedBootstrapCheckText(value: unknown): boolean { return ( typeof value === 'string' && - stripTranscriptSpeakerPrefix(value).includes(NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN) + stripTranscriptSpeakerPrefix(value).startsWith(NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN) ); } @@ -56,7 +57,17 @@ export function isTeamInternalControlMessageText(value: unknown): boolean { export function isTeamInternalControlMessageEnvelope(message: { text?: unknown; source?: unknown; + from?: unknown; }): boolean { + if (isNativeAppManagedBootstrapCheckText(message.text)) { + if (typeof message.source === 'string') { + return INTERNAL_CONTROL_MESSAGE_SOURCES.has(message.source); + } + return ( + typeof message.from === 'string' && + INTERNAL_BOOTSTRAP_AUTHORS.has(message.from.trim().toLowerCase()) + ); + } if (!isTeamInternalControlMessageText(message.text)) { return false; } diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index f28d349c..6afdbd28 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -79,8 +79,10 @@ describe('TeamMessageFeedService', () => { getConfig: vi.fn(async () => config), getInboxMessages: vi.fn(async () => [ makeMessage({ + from: 'team-lead', + to: undefined, messageId: 'native-bootstrap-private-check', - source: 'system_notification', + source: undefined, text: '\nprivate\n', }), makeMessage({ @@ -123,6 +125,27 @@ Messages: 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); diff --git a/test/renderer/utils/bootstrapPromptSanitizer.test.ts b/test/renderer/utils/bootstrapPromptSanitizer.test.ts index 5220eb80..47d580c6 100644 --- a/test/renderer/utils/bootstrapPromptSanitizer.test.ts +++ b/test/renderer/utils/bootstrapPromptSanitizer.test.ts @@ -71,13 +71,32 @@ Do NOT send acknowledgement-only messages such as "ready" or "online".`); ` Your Agent Teams startup context was already loaded by the app. `, - { source: 'system_notification' } + { 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"). diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index 503132b8..0b079e9d 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -41,7 +41,7 @@ describe('filterTeamMessages', () => { const messages = [ makeMessage({ messageId: 'native-bootstrap-private-check', - source: 'system_notification', + source: undefined, text: '\nprivate\n', }), makeMessage({ @@ -59,6 +59,25 @@ describe('filterTeamMessages', () => { 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({ diff --git a/test/shared/utils/teamInternalControlMessages.test.ts b/test/shared/utils/teamInternalControlMessages.test.ts index 334ed5f7..d537a737 100644 --- a/test/shared/utils/teamInternalControlMessages.test.ts +++ b/test/shared/utils/teamInternalControlMessages.test.ts @@ -18,6 +18,9 @@ Messages: 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', () => { @@ -60,6 +63,38 @@ describe('teamInternalControlMessages', () => { 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', () => { From 2e6549620f2188c8083ff229295f50f279ebb06f Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 19:19:43 +0300 Subject: [PATCH 08/22] feat(member-work-sync): add nudge activation policy --- .../MemberWorkSyncNudgeActivationPolicy.ts | 58 ++++ .../MemberWorkSyncNudgeDispatcher.ts | 69 +++- .../MemberWorkSyncNudgeOutboxPlanner.ts | 4 +- .../core/application/index.ts | 1 + .../core/application/ports.ts | 13 + .../createMemberWorkSyncFeature.ts | 19 +- .../CompositeMemberWorkSyncBusySignal.ts | 35 ++ .../services/team/TeamProvisioningService.ts | 140 ++++++-- .../OpenCodePromptDeliveryRepairPolicy.ts | 314 ++++++++++++++++++ 9 files changed, 609 insertions(+), 44 deletions(-) create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts create mode 100644 src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts create mode 100644 src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts 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/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..c53549be --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts @@ -0,0 +1,35 @@ +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), + }); + return { + busy: true, + reason: 'busy_signal_error', + retryAfterIso: new Date(Date.parse(input.nowIso) + 60_000).toISOString(), + }; + } + } + + return { busy: false }; + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e7d00a41..b9012103 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -163,6 +163,10 @@ import { type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, } from './opencode/delivery/OpenCodePromptDeliveryLedger'; +import { + decideOpenCodePromptDeliveryRepair, + type OpenCodePromptDeliveryHardFailureKind, +} from './opencode/delivery/OpenCodePromptDeliveryRepairPolicy'; import { isOpenCodePromptDeliveryObserveLaterResponseState, isOpenCodePromptDeliveryRetryableResponseState, @@ -5192,6 +5196,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(); @@ -6713,6 +6729,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'; } @@ -6775,40 +6794,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 { @@ -8182,10 +8223,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 } : {}), @@ -18663,7 +18725,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) { 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..cda1d47b --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -0,0 +1,314 @@ +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(Boolean))]; + 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'); +} From 937999c3e373bbab9a2b8aec42deed58f6ed4e49 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 19:20:24 +0300 Subject: [PATCH 09/22] feat(opencode): wire work sync delivery busy signals --- src/main/index.ts | 18 +++ .../services/team/TeamProvisioningService.ts | 126 ++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index a780e7b9..399ca499 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1362,6 +1362,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/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b9012103..5c206fd9 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -10981,6 +10981,132 @@ 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 }; + } + + const activeRecord = await this.createOpenCodePromptDeliveryLedger( + input.teamName, + identity.laneId + ) + .getActiveForMember({ + teamName: input.teamName, + memberName: identity.canonicalMemberName, + laneId: identity.laneId, + }) + .catch(() => null); + 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 { From e96a74f4fa837863f441a5be835c6060d5766a05 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 19:22:14 +0300 Subject: [PATCH 10/22] test(team): cover opencode repair policies --- .../CompositeMemberWorkSyncBusySignal.ts | 5 +- .../OpenCodePromptDeliveryRepairPolicy.ts | 8 +- ...emberWorkSyncNudgeActivationPolicy.test.ts | 133 ++++++++++++++++++ ...OpenCodePromptDeliveryRepairPolicy.test.ts | 96 +++++++++++++ 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts create mode 100644 test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts diff --git a/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts index c53549be..242b7456 100644 --- a/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts +++ b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts @@ -22,10 +22,13 @@ export class CompositeMemberWorkSyncBusySignal implements MemberWorkSyncBusySign memberName: input.memberName, error: String(error), }); + const nowMs = Date.parse(input.nowIso); return { busy: true, reason: 'busy_signal_error', - retryAfterIso: new Date(Date.parse(input.nowIso) + 60_000).toISOString(), + retryAfterIso: new Date( + (Number.isFinite(nowMs) ? nowMs : Date.now()) + 60_000 + ).toISOString(), }; } } diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts index cda1d47b..8bea8b0f 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -116,7 +116,13 @@ function hasSideEffectTool(tools: Set): boolean { } function taskIdList(taskRefs: TaskRef[]): string | null { - const ids = [...new Set(taskRefs.map((taskRef) => taskRef.taskId?.trim()).filter(Boolean))]; + 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; } 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/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"'); + }); +}); From 9e1abb03326453a17e1190d87a0a1ae3aebd81ee Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 20:20:26 +0300 Subject: [PATCH 11/22] chore(runtime): pin orchestrator 0.0.20 --- runtime.lock.json | 12 +- .../services/team/TeamProvisioningService.ts | 21 +- .../main/createMemberWorkSyncFeature.test.ts | 591 ++++++++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 174 ++++++ 4 files changed, 785 insertions(+), 13 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index 1495a1da..c6b60f81 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.19", - "sourceRef": "v0.0.19", + "version": "0.0.20", + "sourceRef": "v0.0.20", "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.19.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.20.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.19.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.20.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.19.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.20.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.19.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.20.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5c206fd9..26e3311c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -11064,16 +11064,23 @@ export class TeamProvisioningService { return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso }; } - const activeRecord = await this.createOpenCodePromptDeliveryLedger( - input.teamName, - identity.laneId - ) - .getActiveForMember({ + let activeRecord: OpenCodePromptDeliveryLedgerRecord | null; + try { + activeRecord = await this.createOpenCodePromptDeliveryLedger( + input.teamName, + identity.laneId + ).getActiveForMember({ teamName: input.teamName, memberName: identity.canonicalMemberName, laneId: identity.laneId, - }) - .catch(() => null); + }); + } catch { + return { + busy: true, + reason: 'opencode_prompt_ledger_unavailable', + retryAfterIso, + }; + } if (activeRecord) { return { busy: true, diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 5181d4c4..80b3813d 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -74,6 +74,111 @@ 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() + 2_000; let lastError: unknown; @@ -443,6 +548,492 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + 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); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 3919bd64..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( @@ -2738,4 +2740,176 @@ Messages: 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 }); + }); }); From 3992ab0dab64173d7cfb72603709ad906109cf2d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 20:41:24 +0300 Subject: [PATCH 12/22] feat(tasks): update team task status timeline --- .../src/internal/taskStore.js | 29 +++++++++-- agent-teams-controller/src/internal/tasks.js | 6 +-- .../test/controller.test.js | 21 ++++++++ mcp-server/src/agent-teams-controller.d.ts | 2 +- mcp-server/src/tools/taskTools.ts | 15 ++++-- mcp-server/test/tools.test.ts | 21 ++++++++ src/main/services/team/TeamDataService.ts | 2 +- src/main/services/team/TeamTaskWriter.ts | 18 ++++++- .../team/dialogs/StatusHistoryTimeline.tsx | 50 ++++++++++++++++++- src/shared/types/team.ts | 7 +++ src/types/agent-teams-controller.d.ts | 2 +- .../main/services/team/TeamTaskWriter.test.ts | 37 ++++++++++++++ 12 files changed, 193 insertions(+), 17 deletions(-) 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 6222e4a7..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: 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 a82d7702..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; diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 1bf98490..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 > @@ -624,7 +625,13 @@ export function registerTaskTools(server: Pick) { runtimeProvider: z.enum(['native', 'opencode']).optional(), includeActiveProcesses: z.boolean().optional(), }), - execute: async ({ teamName, claudeDir, memberName, runtimeProvider, includeActiveProcesses }) => { + execute: async ({ + teamName, + claudeDir, + memberName, + runtimeProvider, + includeActiveProcesses, + }) => { assertConfiguredTeam(teamName, claudeDir); return { content: [ 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/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a4add099..de1bad7a 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2137,7 +2137,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/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/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx index 589e67cf..09f0594d 100644 --- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx +++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx @@ -6,7 +6,7 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; -import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck } from 'lucide-react'; +import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react'; import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types'; @@ -107,6 +107,52 @@ const EventContent = ({ ); + 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/shared/types/team.ts b/src/shared/types/team.ts index 4a93ba75..b11ac9fc 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 diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index b858cae4..271cd5df 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/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; 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(); + }); }); }); From f57b1bf18bbc1b2d2f3534f409238177b1b14f80 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 21:56:47 +0300 Subject: [PATCH 13/22] feat(opencode): surface runtime delivery diagnostics --- src/main/index.ts | 3 + .../team/TeamMemberRuntimeAdvisoryService.ts | 360 +++++++++++++++++- .../services/team/TeamMessageFeedService.ts | 26 +- .../services/team/TeamProvisioningService.ts | 167 ++++++++ .../OpenCodeRuntimeDeliveryDiagnostics.ts | 96 +++++ .../components/dashboard/WebPreviewBanner.tsx | 8 +- .../team/taskLogs/TaskLogsPanel.tsx | 16 +- src/renderer/store/index.ts | 73 ++++ src/renderer/utils/memberHelpers.ts | 62 ++- .../openCodeRuntimeDeliveryDiagnostics.ts | 13 + src/shared/types/team.ts | 1 + ...OpenCodeRuntimeDeliveryDiagnostics.test.ts | 117 ++++++ .../TeamMemberRuntimeAdvisoryService.test.ts | 208 ++++++++++ .../team/taskLogs/TaskLogsPanel.test.ts | 35 +- test/renderer/utils/memberHelpers.test.ts | 33 ++ ...openCodeRuntimeDeliveryDiagnostics.test.ts | 25 ++ 16 files changed, 1194 insertions(+), 49 deletions(-) create mode 100644 src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts create mode 100644 test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 399ca499..d6722651 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1144,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)}`) diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 8eddcde1..47d912a8 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,13 @@ 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', ]; const RATE_LIMITED_TOKENS = [ 'rate limit', @@ -70,7 +83,6 @@ const PROVIDER_OVERLOADED_TOKENS = [ 'service unavailable', '503', ]; - const logger = createLogger('Service:TeamMemberRuntimeAdvisory'); interface CachedRuntimeAdvisory { @@ -114,6 +126,43 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory 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 +186,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 +199,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 +255,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 +281,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 +306,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 +384,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 +403,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 +438,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 668f529e..b4de64c9 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -85,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(); @@ -99,43 +99,49 @@ 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.name.trim().toLowerCase() !== normalizedLeadName && member.removedAt == null && (member as { isActive?: unknown }).isActive !== false ) .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}`, })); } @@ -503,7 +509,7 @@ export class TeamMessageFeedService { const sourceMs = Date.now() - sourceStartedAt; const normalizeStartedAt = Date.now(); - const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); + const syntheticMessages = buildSyntheticBootstrapMessages(config); let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter( isVisibleTeamMessage ); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 26e3311c..fac7371a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -163,6 +163,7 @@ import { type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, } from './opencode/delivery/OpenCodePromptDeliveryLedger'; +import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import { decideOpenCodePromptDeliveryRepair, type OpenCodePromptDeliveryHardFailureKind, @@ -5225,6 +5226,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(); @@ -5271,6 +5274,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; @@ -5340,6 +5345,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; @@ -5584,6 +5592,12 @@ export class TeamProvisioningService { this.runtimeAdapterRegistry = registry; } + setMemberRuntimeAdvisoryInvalidator( + invalidator: ((teamName: string, memberName: string) => void) | null + ): void { + this.memberRuntimeAdvisoryInvalidator = invalidator; + } + setCrossTeamSender( sender: | ((request: { @@ -7509,6 +7523,159 @@ export class TeamProvisioningService { ...extra, }) ); + if ( + event === 'opencode_prompt_delivery_terminal_failure' && + record.status === 'failed_terminal' + ) { + void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => { + logger.warn( + `[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}` + ); + }); + } + } + + 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 eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}`; + 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 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 { 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..d66cf4a6 --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -0,0 +1,96 @@ +import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger'; + +const SECRET_VALUE_PATTERN = + /\b(?:sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]*api[_-]?key[A-Za-z0-9_-]*[=:]\s*['"]?[^'"\s]+|authorization:\s*bearer\s+[^'"\s]+)\b/gi; + +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', +] as const; + +export function normalizeOpenCodeRuntimeDeliveryDiagnostic( + message: string | null | undefined +): string | null { + const normalized = message + ?.replace(/\s+/g, ' ') + .trim() + .replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '') + .replace(/^APIError\s*[-:]\s*/i, '') + .replace(SECRET_VALUE_PATTERN, '[redacted]'); + 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; +} + +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.'; + } + return null; +} + +function boundOpenCodeRuntimeDeliveryReason(reason: string): string { + return reason.length > 500 ? `${reason.slice(0, 497).trimEnd()}...` : reason; +} 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/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index ae98920c..2f99e8b0 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -64,9 +64,9 @@ 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 taskLogActivityTrackingEnabled = task.status === 'in_progress' && hasTaskLogStream; + const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream; useEffect(() => { setActiveTab(defaultTab); @@ -133,7 +133,7 @@ export const TaskLogsPanel = ({ }, [task.id, taskLogSummaryEnabled, teamName]); useEffect(() => { - if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) { + if (!taskLogActivityTrackingEnabled || !api.teams.setTaskLogStreamTracking) { return; } @@ -143,10 +143,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 +160,7 @@ export const TaskLogsPanel = ({ } const scheduleCountReload = (): void => { - if (!api.teams.getTaskLogStreamSummary) { + if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) { return; } if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { @@ -230,7 +230,7 @@ export const TaskLogsPanel = ({ unsubscribe(); } }; - }, [task.id, taskLogTrackingEnabled, teamName]); + }, [task.id, taskLogActivityTrackingEnabled, taskLogSummaryEnabled, teamName]); return ( ([ 'lead-message', 'lead-context', 'lead-activity', + 'member-advisory', 'process', 'member-spawn', ]); @@ -268,6 +269,7 @@ 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>(); @@ -1610,6 +1612,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,6 +1862,8 @@ 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); diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 690b4725..70af94e2 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -321,10 +321,55 @@ 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') + ); +} + +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.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 @@ -346,6 +391,12 @@ function formatRuntimeAdvisoryBaseLabel( return providerLabel ? `${providerLabel} overload` : 'Provider overload'; 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'; @@ -409,6 +460,15 @@ function formatRuntimeAdvisoryTitle( ); 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 diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 3916c01c..37dd17b3 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -33,6 +33,19 @@ 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.'; + } return ''; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b11ac9fc..e42bcf6e 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1201,6 +1201,7 @@ export interface TeamChangeEvent { | 'lead-message' | 'tool-activity' | 'member-turn-settled' + | 'member-advisory' | 'process' | 'member-spawn'; teamName: string; diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts new file mode 100644 index 00000000..4ba27b7f --- /dev/null +++ b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { selectOpenCodeRuntimeDeliveryReason } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; + +import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; + +function record( + input: Partial +): OpenCodePromptDeliveryLedgerRecord { + return { + id: 'opencode-prompt:test', + teamName: 'forge-labs', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'msg-1', + inboxTimestamp: '2026-05-06T18:31:36.478Z', + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'not_observed', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: null, + lastObservedAt: null, + acceptedAt: null, + respondedAt: null, + failedAt: '2026-05-06T18:33:42.896Z', + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: null, + observedAssistantMessageId: null, + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: null, + diagnostics: [], + createdAt: '2026-05-06T18:31:36.636Z', + updatedAt: '2026-05-06T18:33:42.896Z', + ...input, + }; +} + +describe('OpenCodeRuntimeDeliveryDiagnostics', () => { + it('skips internal bootstrap MCP diagnostics when a provider error is available', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + responseState: 'empty_assistant_turn', + lastReason: 'empty_assistant_turn', + diagnostics: [ + 'OpenCode app MCP was reattached before message delivery.', + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + 'Latest assistant message msg_1 failed with APIError - Insufficient credits. Add more credits.', + 'empty_assistant_turn', + ], + }) + ); + + expect(reason).toBe('Insufficient credits. Add more credits.'); + }); + + it('falls back to empty assistant turn when diagnostics are only internal noise', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + responseState: 'empty_assistant_turn', + lastReason: 'empty_assistant_turn', + diagnostics: [ + 'OpenCode bridge command timed out', + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + 'empty_assistant_turn', + ], + }) + ); + + expect(reason).toBe('OpenCode returned an empty assistant turn.'); + }); + + it('maps missing visible reply proof to a readable protocol error', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + responseState: 'responded_non_visible_tool', + 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', + ], + }) + ); + + expect(reason).toBe('OpenCode responded, but did not create a visible message_send reply.'); + }); + + it('never exposes only internal generic bootstrap diagnostics as the user-facing reason', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + diagnostics: [ + 'OpenCode app MCP was reattached before message delivery.', + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + ], + }) + ); + + expect(reason).toBe('OpenCode runtime delivery did not complete.'); + }); +}); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index a6a2d4a8..847b0dfc 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -257,6 +257,214 @@ 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('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); diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts index b3a37cb8..8692e530 100644 --- a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -370,11 +370,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 +396,7 @@ describe('TaskLogsPanel', () => { teamName: 'demo', task: makeTask(), isOpen: false, - onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + onTaskLogActivityChange, }) ); await flushMicrotasks(); @@ -402,18 +405,33 @@ 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' }); + 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,7 +440,6 @@ 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 () => { @@ -430,14 +447,14 @@ describe('TaskLogsPanel', () => { 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(); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 8cd3f956..651fad05 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -699,6 +699,39 @@ 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: 'backend_error' as const, + message: 'visible_reply_still_required', + }; + + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode delivery error'); + + const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); + + expect(title).toContain('OpenCode runtime delivery error.'); + 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('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..763980e0 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -52,4 +52,29 @@ 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', + }); + }); }); From b7fa5443fda3c3c63a5ec4d17fc2873b7ba00c20 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 23:15:27 +0300 Subject: [PATCH 14/22] feat(team): show live task log activity --- .../services/team/TeamLogSourceTracker.ts | 156 +++++++++++--- .../services/team/TeamMemberLogsFinder.ts | 35 ++- .../services/team/TeamProvisioningService.ts | 1 + .../components/team/TeamDetailView.tsx | 3 + .../components/team/kanban/KanbanBoard.tsx | 8 + .../team/kanban/KanbanTaskCard.test.tsx | 43 ++++ .../components/team/kanban/KanbanTaskCard.tsx | 13 +- .../team/taskLogs/TaskLogStreamSection.tsx | 3 +- .../team/taskLogs/TaskLogsPanel.tsx | 3 +- src/renderer/store/index.ts | 162 ++++++++++++++ src/renderer/store/slices/teamSlice.ts | 7 + src/renderer/utils/teamChangeEvents.ts | 18 ++ src/shared/types/team.ts | 2 + .../team/TeamLogSourceTracker.test.ts | 167 +++++++++++++- .../team/TeamMemberLogsFinder.test.ts | 15 +- .../taskLogs/TaskLogStreamSection.test.ts | 41 +++- .../team/taskLogs/TaskLogsPanel.test.ts | 48 ++++- .../renderer/store/teamChangeThrottle.test.ts | 204 +++++++++++++++++- 18 files changed, 880 insertions(+), 49 deletions(-) create mode 100644 src/renderer/utils/teamChangeEvents.ts 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/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index fac7371a..0fdb92c4 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -10645,6 +10645,7 @@ export class TeamProvisioningService { runId, taskId, detail: `opencode-runtime-task-event:${event}`, + taskSignalKind: 'log', }); return { 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/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 81b24523..21e1487c 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -78,6 +78,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; @@ -244,6 +245,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 +267,7 @@ const SortableKanbanTaskCard = ({ compact, taskMap, memberColorMap, + hasLiveTaskLogs, onRequestReview, onApprove, onRequestChanges, @@ -300,6 +303,7 @@ const SortableKanbanTaskCard = ({ compact={compact} taskMap={taskMap} memberColorMap={memberColorMap} + hasLiveTaskLogs={hasLiveTaskLogs} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} @@ -325,6 +329,7 @@ export const KanbanBoard = memo(function KanbanBoard({ sessions, leadSessionId, members, + activeTaskLogActivity, forceShowAllTasks = false, onFilterChange, onSortChange, @@ -578,6 +583,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 +616,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 +637,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 141124c9..5a90d2ae 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -274,3 +274,46 @@ describe('KanbanTaskCard blocked border', () => { } ); }); + +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 5d8686f0..608290e3 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'; @@ -38,6 +39,7 @@ interface KanbanTaskCardProps { compact?: boolean; taskMap: Map; memberColorMap: Map; + hasLiveTaskLogs?: boolean; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; @@ -227,6 +229,7 @@ export const KanbanTaskCard = memo( compact, taskMap, memberColorMap, + hasLiveTaskLogs = false, onRequestReview, onApprove, onRequestChanges, @@ -304,8 +307,13 @@ export const KanbanTaskCard = memo( } }} > - - {formatTaskDisplayLabel(task)} + + {formatTaskDisplayLabel(task)} + {hasLiveTaskLogs ? ( + + + + ) : null} {task.owner ? ( @@ -491,6 +499,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/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 2f99e8b0..96dcda35 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -2,6 +2,7 @@ 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 { ExecutionSessionsSection } from './ExecutionSessionsSection'; import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates'; @@ -187,7 +188,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; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 749951b1..426c1523 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -12,6 +12,7 @@ import { buildTaskChangeRequestOptions, canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; +import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { createLogger } from '@shared/utils/logger'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { create } from 'zustand'; @@ -87,6 +88,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 = 2_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'; @@ -273,6 +275,7 @@ export function initializeNotificationListeners(): () => void { 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 } @@ -547,6 +550,67 @@ 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); + 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 @@ -860,6 +924,10 @@ export function initializeNotificationListeners(): () => void { return getVisibleTeamNamesInAnyPane(); }; + const getTrackedTaskLogActivityTeams = (): Set => { + return getVisibleTeamNamesInAnyPane(); + }; + const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => { teamLastRelevantActivityAt.set(teamName, timestamp); }; @@ -1220,6 +1288,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) => { @@ -1422,6 +1530,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 @@ -1431,6 +1541,7 @@ export function initializeNotificationListeners(): () => void { } : prev.ignoredRuntimeRunIds; clearToolActivityTimersForTeam(event.teamName); + clearTaskLogActivityTimersForTeam(event.teamName); } return nextState as typeof prev; @@ -1585,6 +1696,55 @@ export function initializeNotificationListeners(): () => void { return; } + if (event.type === 'task-log-change') { + if (isStaleRuntimeEvent) { + return; + } + seedCurrentRunIdIfMissing(); + const visible = isTeamVisibleInAnyPane(event.teamName); + if (event.taskId && visible) { + if (isTaskLogActivityChangeEvent(event)) { + markTaskLogActivity(event.teamName, event.taskId); + } + 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) { @@ -1870,6 +2030,8 @@ export function initializeNotificationListeners(): () => void { 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..79e405f8 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -328,6 +328,7 @@ function collectTeamScopedStateRemovals( | 'provisioningStartedAtFloorByTeam' | 'leadActivityByTeam' | 'leadContextByTeam' + | 'activeTaskLogActivityByTeam' | 'activeToolsByTeam' | 'finishedVisibleByTeam' | 'toolHistoryByTeam' @@ -353,6 +354,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 +380,9 @@ function collectTeamScopedStateRemovals( : {}), ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), + ...(nextActiveTaskLogActivity + ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } + : {}), ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), @@ -2385,6 +2390,7 @@ export interface TeamSlice { provisioningStartedAtFloorByTeam: Record; leadActivityByTeam: Record; leadContextByTeam: Record; + activeTaskLogActivityByTeam: Record>; activeToolsByTeam: Record>>; finishedVisibleByTeam: Record>>; toolHistoryByTeam: Record>; @@ -2727,6 +2733,7 @@ export const createTeamSlice: StateCreator = (set, provisioningStartedAtFloorByTeam: {}, leadActivityByTeam: {}, leadContextByTeam: {}, + activeTaskLogActivityByTeam: {}, activeToolsByTeam: {}, finishedVisibleByTeam: {}, toolHistoryByTeam: {}, 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/shared/types/team.ts b/src/shared/types/team.ts index e42bcf6e..4d87e1a4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1208,6 +1208,8 @@ export interface TeamChangeEvent { 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/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..63933f06 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([ + projectPath, + memberProjectPath, + runtimeProjectPath, + ]); }); it('returns subagent logs for a member and lead session for team-lead', async () => { 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 8692e530..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(); }); @@ -411,7 +432,12 @@ describe('TaskLogsPanel', () => { 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(); }); @@ -443,7 +469,12 @@ describe('TaskLogsPanel', () => { 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(); }); @@ -564,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/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 23331506..27cea90a 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,190 @@ 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(2499); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(1); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + }); + + it('schedules a bounded team data refresh for visible task 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(); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + }); + + 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('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'); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + await vi.advanceTimersByTimeAsync(2000); + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + await vi.advanceTimersByTimeAsync(2499); + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(1); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + 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).toHaveBeenCalledTimes(2); + }); + it('applies targeted tool resets without clearing sibling tools', async () => { useStore.setState({ activeToolsByTeam: { From abe26ddcc4d21694e8223b6307882d65126cdfc9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 23:30:51 +0300 Subject: [PATCH 15/22] perf(team): skip data refresh for log activity pulses --- src/renderer/store/index.ts | 26 ++++++---- .../renderer/store/teamChangeThrottle.test.ts | 50 ++++++++++++++++--- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 426c1523..225c165b 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -581,15 +581,19 @@ export function initializeNotificationListeners(): () => void { }; const markTaskLogActivity = (teamName: string, taskId: string): void => { clearTaskLogActivityTimer(teamName, taskId); - useStore.setState((prev) => ({ - activeTaskLogActivityByTeam: { - ...prev.activeTaskLogActivityByTeam, - [teamName]: { - ...(prev.activeTaskLogActivityByTeam[teamName] ?? {}), - [taskId]: true, + 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); @@ -1703,9 +1707,13 @@ export function initializeNotificationListeners(): () => void { seedCurrentRunIdIfMissing(); const visible = isTeamVisibleInAnyPane(event.teamName); if (event.taskId && visible) { - if (isTaskLogActivityChangeEvent(event)) { + 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, diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 27cea90a..a93d68c5 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -1623,7 +1623,7 @@ describe('team change throttling', () => { expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); }); - it('schedules a bounded team data refresh for visible task log signals', async () => { + 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'); @@ -1638,11 +1638,13 @@ describe('team change throttling', () => { ); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + 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 }); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); }); it('refreshes visible team data for task change freshness without pulsing live log activity', async () => { @@ -1667,6 +1669,30 @@ describe('team change throttling', () => { 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'); @@ -1696,6 +1722,12 @@ describe('team change throttling', () => { 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', @@ -1704,8 +1736,10 @@ describe('team change throttling', () => { taskSignalKind: 'log', }); + expect(activitySnapshots).toEqual([{ 'task-live': true }]); + await vi.advanceTimersByTimeAsync(2000); - expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); hoisted.onTeamChangeCb?.({}, { type: 'task-log-change', @@ -1714,14 +1748,17 @@ describe('team change throttling', () => { taskSignalKind: 'log', }); + expect(activitySnapshots).toEqual([{ 'task-live': true }]); + await vi.advanceTimersByTimeAsync(2499); - expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); + 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: { @@ -1740,7 +1777,8 @@ describe('team change throttling', () => { expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); await vi.advanceTimersByTimeAsync(800); - expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + unsubscribeActivitySnapshots(); }); it('applies targeted tool resets without clearing sibling tools', async () => { From 9b5b4023d2329a1dc91a2e7aaab09e05880ab66d Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 00:44:25 +0300 Subject: [PATCH 16/22] perf(team): extend task log activity pulse --- src/renderer/store/index.ts | 2 +- test/renderer/store/teamChangeThrottle.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 225c165b..01b87cbc 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -88,7 +88,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 = 2_500; +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'; diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index a93d68c5..1f5e05f9 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -1614,7 +1614,7 @@ describe('team change throttling', () => { 'task-live': true, }); - await vi.advanceTimersByTimeAsync(2499); + await vi.advanceTimersByTimeAsync(3499); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ 'task-live': true, }); @@ -1750,7 +1750,7 @@ describe('team change throttling', () => { expect(activitySnapshots).toEqual([{ 'task-live': true }]); - await vi.advanceTimersByTimeAsync(2499); + await vi.advanceTimersByTimeAsync(3499); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ 'task-live': true, From 2c30fd223582776c69e51a2088a5326cde0a130a Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 01:07:06 +0300 Subject: [PATCH 17/22] feat(agent-graph): show live task log indicator --- packages/agent-graph/src/canvas/draw-tasks.ts | 46 ++++++++- packages/agent-graph/src/ports/types.ts | 2 + .../core/domain/collapseOverflowStacks.ts | 1 + .../renderer/adapters/TeamGraphAdapter.ts | 10 +- .../renderer/hooks/useTeamGraphAdapter.ts | 7 +- .../agent-graph/TeamGraphAdapter.test.ts | 66 +++++++++++++ .../features/agent-graph/drawTasks.test.ts | 93 +++++++++++++++++++ 7 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 test/renderer/features/agent-graph/drawTasks.test.ts 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/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/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 8dbf3086..56da2f01 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -118,7 +118,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 +204,8 @@ export class TeamGraphAdapter { commentReadState, memberNodeIdByAlias, leadId, - leadName + leadName, + activeTaskLogActivity ); this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); @@ -627,7 +629,8 @@ export class TeamGraphAdapter { commentReadState?: Record, memberNodeIdByAlias?: ReadonlyMap, leadId?: string, - leadName?: string + leadName?: string, + activeTaskLogActivity?: Record ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -698,6 +701,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/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); + }); +}); From 38b3a1e7899602ae50b857357752941a6b74fd21 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 01:22:16 +0300 Subject: [PATCH 18/22] feat(team): update task automation state --- resources/pricing.json | 33 +- runtime.lock.json | 12 +- .../core/domain/taskGraphSemantics.ts | 33 +- .../renderer/adapters/TeamGraphAdapter.ts | 53 ++- .../renderer/ui/GraphNodePopover.tsx | 27 +- .../agent-graph/renderer/ui/GraphTaskCard.tsx | 3 +- .../core/domain/ActionableWorkAgenda.ts | 47 ++- .../core/domain/currentReviewCycle.ts | 2 + .../input/MemberWorkSyncTaskImpactResolver.ts | 42 ++- .../adapters/output/TeamTaskAgendaSource.ts | 8 +- src/main/services/team/TeamDataService.ts | 44 ++- src/main/services/team/TeamMemberResolver.ts | 10 +- .../team/TeamMemberRuntimeAdvisoryService.ts | 17 + .../services/team/TeamMessageFeedService.ts | 3 +- .../services/team/TeamProvisioningService.ts | 112 +++++- .../OpenCodeRuntimeDeliveryDiagnostics.ts | 32 ++ .../stallMonitor/TeamTaskStallNotifier.ts | 1 + .../TeamTaskStallSnapshotSource.ts | 48 ++- src/main/services/team/teamTaskActiveState.ts | 70 ++++ src/main/workers/team-fs-worker.ts | 10 +- .../components/sidebar/SidebarTaskItem.tsx | 9 +- .../components/sidebar/taskFiltersState.ts | 11 +- src/renderer/components/team/TaskTooltip.tsx | 9 +- .../team/activity/ActiveTasksBlock.tsx | 10 +- .../components/team/activity/ActivityItem.tsx | 77 ++++ .../team/dialogs/CreateTaskDialog.tsx | 4 +- .../team/dialogs/TaskDetailDialog.tsx | 32 +- .../components/team/kanban/KanbanBoard.tsx | 3 +- .../components/team/kanban/KanbanTaskCard.tsx | 8 +- .../components/team/members/MemberCard.tsx | 9 +- .../team/members/MemberDetailDialog.tsx | 22 +- .../team/members/MemberHoverCard.tsx | 22 +- .../components/team/members/MemberList.tsx | 11 +- .../team/members/MemberTasksTab.tsx | 9 +- .../team/messages/MessagesPanel.tsx | 3 + .../components/team/messages/StatusBlock.tsx | 4 +- .../team/taskLogs/TaskLogsPanel.tsx | 6 +- .../components/team/tasks/TaskRow.tsx | 9 +- src/renderer/store/index.ts | 5 +- src/renderer/store/slices/teamSlice.ts | 27 +- src/renderer/utils/memberHelpers.ts | 27 +- .../openCodeRuntimeDeliveryDiagnostics.ts | 3 + src/renderer/utils/pathNormalize.ts | 50 ++- src/renderer/utils/teamMessageFiltering.ts | 8 +- src/renderer/utils/teamTaskDisplayState.ts | 9 + src/shared/types/team.ts | 2 + src/shared/utils/reviewState.ts | 30 +- src/shared/utils/taskChangeState.ts | 13 +- src/shared/utils/teamAutomationMessages.ts | 16 + src/shared/utils/teamTaskState.ts | 115 ++++++ .../core/ActionableWorkAgenda.test.ts | 271 +++++++++++++++ .../core/SyncDecisionPolicy.test.ts | 36 ++ .../MemberWorkSyncTaskImpactResolver.test.ts | 86 +++++ .../output/TeamTaskAgendaSource.test.ts | 55 +++ ...OpenCodeRuntimeDeliveryDiagnostics.test.ts | 132 ++----- .../services/team/TeamDataService.test.ts | 239 +++++++++++++ .../services/team/TeamMemberResolver.test.ts | 80 +++++ .../TeamMemberRuntimeAdvisoryService.test.ts | 97 ++++++ .../team/TeamMessageFeedService.test.ts | 39 ++- .../TeamTaskStallNotifier.test.ts | 1 + .../TeamTaskStallSnapshotSource.test.ts | 17 +- .../services/team/teamTaskActiveState.test.ts | 329 ++++++++++++++++++ .../sidebar/taskFiltersState.test.ts | 32 ++ .../team/activity/ActivityItem.test.ts | 37 ++ .../team/members/MemberList.test.ts | 47 ++- .../agent-graph/taskGraphSemantics.test.ts | 75 ++++ test/renderer/utils/memberHelpers.test.ts | 46 ++- ...openCodeRuntimeDeliveryDiagnostics.test.ts | 21 ++ test/renderer/utils/pathNormalize.test.ts | 105 ++++++ .../utils/teamMessageFiltering.test.ts | 50 +++ test/shared/utils/reviewState.test.ts | 54 +++ test/shared/utils/taskChangeState.test.ts | 59 ++++ 72 files changed, 2756 insertions(+), 322 deletions(-) create mode 100644 src/main/services/team/teamTaskActiveState.ts create mode 100644 src/renderer/utils/teamTaskDisplayState.ts create mode 100644 src/shared/utils/teamAutomationMessages.ts create mode 100644 src/shared/utils/teamTaskState.ts create mode 100644 test/features/member-work-sync/core/SyncDecisionPolicy.test.ts create mode 100644 test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts create mode 100644 test/main/services/team/teamTaskActiveState.test.ts create mode 100644 test/renderer/features/agent-graph/taskGraphSemantics.test.ts create mode 100644 test/renderer/utils/pathNormalize.test.ts 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 c6b60f81..7c6fc290 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.20", - "sourceRef": "v0.0.20", + "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.20.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.20.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.20.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.20.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.21.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } 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 56da2f01..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'; @@ -541,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, @@ -579,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, @@ -632,12 +644,20 @@ export class TeamGraphAdapter { 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) { @@ -660,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)}`) @@ -686,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, 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/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 7c8bb66a..657b53fc 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: task.reviewState, + 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/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index de1bad7a..b27ba729 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,24 @@ export class TeamDataService { task: Pick, kanbanTaskState?: KanbanState['tasks'][string] ): 'none' | 'review' | 'needsFix' | 'approved' { - return getReviewStateFromTask({ + const kanbanColumn = kanbanTaskState?.column; + 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 +621,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 +1043,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. 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 47d912a8..4f83608d 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -47,6 +47,8 @@ const QUOTA_EXHAUSTED_TOKENS = [ 'quota exceeded', 'quota exhausted', 'insufficient credits', + 'key limit exceeded', + 'total limit', ]; const RATE_LIMITED_TOKENS = [ 'rate limit', @@ -83,6 +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 { @@ -123,6 +137,9 @@ 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'; } diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index b4de64c9..74089ba7 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -131,8 +131,7 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] { member && member.name?.trim() && member.name.trim().toLowerCase() !== normalizedLeadName && - member.removedAt == null && - (member as { isActive?: unknown }).isActive !== false + member.removedAt == null ) .map((member) => ({ from: leadName, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0fdb92c4..1f2f75fb 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -84,6 +84,12 @@ 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, @@ -163,7 +169,10 @@ import { type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, } from './opencode/delivery/OpenCodePromptDeliveryLedger'; -import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; +import { + isActionRequiredOpenCodeRuntimeDeliveryReason, + selectOpenCodeRuntimeDeliveryReason, +} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import { decideOpenCodePromptDeliveryRepair, type OpenCodePromptDeliveryHardFailureKind, @@ -4392,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)) @@ -4409,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( @@ -7523,16 +7550,56 @@ export class TeamProvisioningService { ...extra, }) ); - if ( - event === 'opencode_prompt_delivery_terminal_failure' && - record.status === 'failed_terminal' - ) { + 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( @@ -7596,7 +7663,8 @@ export class TeamProvisioningService { ); } - const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}`; + 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)) { @@ -7626,6 +7694,20 @@ export class TeamProvisioningService { } } + 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; @@ -27536,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).`, diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts index d66cf4a6..648ec547 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -19,6 +19,25 @@ const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [ '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( @@ -61,6 +80,16 @@ export function selectOpenCodeRuntimeDeliveryReason( 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 { @@ -88,6 +117,9 @@ function getOpenCodeRuntimeDeliveryStateFallback( ) { 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; } 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/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/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 ( t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved' + (t) => t.status !== 'deleted' && getTeamTaskWorkflowColumn(t) !== 'approved' ); const toggleBlockedBy = (taskId: string): void => { 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 21e1487c..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, @@ -161,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; diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 608290e3..0377c1d0 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -13,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, @@ -65,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)}`; @@ -334,7 +338,7 @@ export const KanbanTaskCard = memo( {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} ) : null} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( 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/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 96dcda35..d12948e6 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -3,6 +3,7 @@ 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'; @@ -66,7 +67,8 @@ export const TaskLogsPanel = ({ const countReloadTimerRef = useRef | null>(null); const countRequestSeqRef = useRef(0); const hasTaskLogStream = availableTabs.includes('stream'); - const taskLogActivityTrackingEnabled = task.status === 'in_progress' && hasTaskLogStream; + const taskIsActivelyWorked = isDisplayableCurrentTask(task); + const taskLogActivityTrackingEnabled = taskIsActivelyWorked && hasTaskLogStream; const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream; useEffect(() => { @@ -261,7 +263,7 @@ export const TaskLogsPanel = ({ teamName={teamName} taskId={task.id} taskStatus={task.status} - liveEnabled={isOpen && task.status === 'in_progress'} + liveEnabled={isOpen && taskIsActivelyWorked} /> ) : 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/store/index.ts b/src/renderer/store/index.ts index 01b87cbc..8d7fa9d9 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -13,6 +13,7 @@ import { 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'; @@ -736,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)); @@ -770,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; } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 79e405f8..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'; @@ -1387,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; @@ -1686,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); @@ -1697,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; @@ -3045,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 @@ -3069,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/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 70af94e2..4c43e506 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -332,7 +332,8 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined): 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 created a reply without') || + displayMessage.startsWith('OpenCode used tools, but did not create') ); } @@ -360,6 +361,9 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin ) { 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:' @@ -389,6 +393,8 @@ 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 ( @@ -416,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'; @@ -458,6 +466,13 @@ 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 ( @@ -509,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( @@ -565,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 37dd17b3..1482a527 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -46,6 +46,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde ) { 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..5144f6b1 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; 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/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 91568dc5..589accc7 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -4,6 +4,7 @@ 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'; @@ -111,6 +112,7 @@ export function filterTeamMessages( messages: InboxMessage[], options: { includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean; + includeAutomationEvents?: boolean; leadNames?: Iterable; timeWindow?: { start: number; end: number } | null; filter: TeamMessagesFilter; @@ -119,6 +121,7 @@ export function filterTeamMessages( ): InboxMessage[] { const { includePassiveIdlePeerSummariesWhenNoiseHidden = false, + includeAutomationEvents = false, leadNames: rawLeadNames, timeWindow, filter, @@ -127,7 +130,10 @@ export function filterTeamMessages( const leadNames = normalizeLeadNames(rawLeadNames); let list = messages.filter( - (m) => m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageEnvelope(m) + (m) => + m.messageKind !== 'task_comment_notification' && + (includeAutomationEvents || !isTaskStallRemediationMessage(m)) && + !isTeamInternalControlMessageEnvelope(m) ); if (timeWindow) { list = list.filter((m) => { 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 4d87e1a4..bec4cb53 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -434,6 +434,7 @@ export type InboxMessageKind = | 'slash_command' | 'slash_command_result' | 'task_comment_notification' + | 'task_stall_remediation' | 'member_work_sync_nudge' | 'agent_error'; @@ -828,6 +829,7 @@ export interface MemberRuntimeAdvisory { | 'codex_native_timeout' | 'network_error' | 'provider_overloaded' + | 'protocol_proof_missing' | 'backend_error' | 'unknown'; message?: string; 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/teamTaskState.ts b/src/shared/utils/teamTaskState.ts new file mode 100644 index 00000000..961b0f1a --- /dev/null +++ b/src/shared/utils/teamTaskState.ts @@ -0,0 +1,115 @@ +export interface TeamTaskStateLike { + status: string; + reviewState?: string | null; + kanbanColumn?: string | null; + deletedAt?: string | null; +} + +export type TeamTaskWorkflowColumn = 'review' | 'approved'; + +export function isTeamTaskApproved(task: TeamTaskStateLike): boolean { + if (isTeamTaskDeleted(task) || task.status === 'pending') { + return false; + } + + if (task.kanbanColumn === 'approved') { + return true; + } + + if (task.kanbanColumn === 'review') { + return false; + } + + return task.reviewState === 'approved'; +} + +export function isTeamTaskDeleted(task: TeamTaskStateLike): boolean { + return task.status === 'deleted' || Boolean(task.deletedAt); +} + +export function isTeamTaskActivelyWorked(task: TeamTaskStateLike): boolean { + return ( + task.status === 'in_progress' && + getTeamTaskWorkflowColumn(task) !== 'review' && + !isTeamTaskApproved(task) && + !isTeamTaskDeleted(task) + ); +} + +export function isTeamTaskNeedsFixActionable(task: TeamTaskStateLike): boolean { + return ( + task.reviewState === 'needsFix' && + !isTeamTaskDeleted(task) && + getTeamTaskWorkflowColumn(task) === undefined + ); +} + +export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolean { + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'approved') { + return true; + } + if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) { + return false; + } + return task.status === 'completed'; +} + +export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): boolean { + if (isTeamTaskDeleted(task)) { + return true; + } + + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'approved') { + return true; + } + + if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) { + return false; + } + return task.status === 'completed'; +} + +export function isTeamTaskFinalForCompletionNotification(task: TeamTaskStateLike): boolean { + if (isTeamTaskDeleted(task)) { + return true; + } + + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'approved') { + return true; + } + + if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) { + return false; + } + + return task.status === 'completed'; +} + +export function getTeamTaskWorkflowColumn( + task: TeamTaskStateLike +): TeamTaskWorkflowColumn | undefined { + if (isTeamTaskDeleted(task) || task.status === 'pending') { + return undefined; + } + + if (task.kanbanColumn === 'approved') { + return 'approved'; + } + + if (task.kanbanColumn === 'review') { + return 'review'; + } + + if (task.reviewState === 'approved') { + return 'approved'; + } + + if (task.reviewState === 'review') { + return 'review'; + } + + return undefined; +} 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/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/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts index 4ba27b7f..80d209a0 100644 --- a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts @@ -1,117 +1,47 @@ import { describe, expect, it } from 'vitest'; -import { selectOpenCodeRuntimeDeliveryReason } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; - -import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; - -function record( - input: Partial -): OpenCodePromptDeliveryLedgerRecord { - return { - id: 'opencode-prompt:test', - teamName: 'forge-labs', - memberName: 'bob', - laneId: 'secondary:opencode:bob', - runId: 'run-1', - runtimeSessionId: 'ses-1', - inboxMessageId: 'msg-1', - inboxTimestamp: '2026-05-06T18:31:36.478Z', - source: 'watcher', - messageKind: null, - replyRecipient: 'team-lead', - actionMode: null, - taskRefs: [], - payloadHash: 'sha256:test', - status: 'failed_terminal', - responseState: 'not_observed', - attempts: 3, - maxAttempts: 3, - acceptanceUnknown: false, - nextAttemptAt: null, - lastAttemptAt: null, - lastObservedAt: null, - acceptedAt: null, - respondedAt: null, - failedAt: '2026-05-06T18:33:42.896Z', - inboxReadCommittedAt: null, - inboxReadCommitError: null, - prePromptCursor: null, - postPromptCursor: null, - deliveredUserMessageId: null, - observedAssistantMessageId: null, - observedAssistantPreview: null, - observedToolCallNames: [], - observedVisibleMessageId: null, - visibleReplyMessageId: null, - visibleReplyInbox: null, - visibleReplyCorrelation: null, - lastReason: null, - diagnostics: [], - createdAt: '2026-05-06T18:31:36.636Z', - updatedAt: '2026-05-06T18:33:42.896Z', - ...input, - }; -} +import { + isActionRequiredOpenCodeRuntimeDeliveryReason, + selectOpenCodeRuntimeDeliveryReason, +} from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; describe('OpenCodeRuntimeDeliveryDiagnostics', () => { - it('skips internal bootstrap MCP diagnostics when a provider error is available', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - responseState: 'empty_assistant_turn', - lastReason: 'empty_assistant_turn', - diagnostics: [ - 'OpenCode app MCP was reattached before message delivery.', - 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', - 'Latest assistant message msg_1 failed with APIError - Insufficient credits. Add more credits.', - 'empty_assistant_turn', - ], - }) - ); + 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(reason).toBe('Insufficient credits. Add more credits.'); + expect(isActionRequiredOpenCodeRuntimeDeliveryReason(reason)).toBe(true); }); - it('falls back to empty assistant turn when diagnostics are only internal noise', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - responseState: 'empty_assistant_turn', - lastReason: 'empty_assistant_turn', - diagnostics: [ - 'OpenCode bridge command timed out', - 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', - 'empty_assistant_turn', - ], - }) + it('does not treat protocol proof repair reasons as action-required provider failures', () => { + expect(isActionRequiredOpenCodeRuntimeDeliveryReason('visible_reply_still_required')).toBe( + false ); - - expect(reason).toBe('OpenCode returned an empty assistant turn.'); }); - it('maps missing visible reply proof to a readable protocol error', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - responseState: 'responded_non_visible_tool', - 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', - ], - }) - ); + 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(reason).toBe('OpenCode responded, but did not create a visible message_send reply.'); + expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded'); }); - it('never exposes only internal generic bootstrap diagnostics as the user-facing reason', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - diagnostics: [ - 'OpenCode app MCP was reattached before message delivery.', - 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', - ], - }) - ); + 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(reason).toBe('OpenCode runtime delivery did not complete.'); + 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/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index d36d1379..02b672ee 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1908,6 +1908,187 @@ 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 () => [ + { + 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', + 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 +2148,64 @@ 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', + to: 'review', + timestamp: '2026-03-01T09:00:00.000Z', + }, + ], + }, + ]), + } 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/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 847b0dfc..d474d23e 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).'], @@ -357,6 +361,99 @@ describe('TeamMemberRuntimeAdvisoryService', () => { 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); diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index 6afdbd28..dc15b120 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -252,7 +252,7 @@ Messages: 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', @@ -281,7 +281,7 @@ Messages: 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.'); @@ -292,4 +292,39 @@ Messages: '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/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/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/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/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 651fad05..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'; @@ -703,15 +704,18 @@ describe('memberHelpers spawn-aware presence', () => { const advisory = { kind: 'api_error' as const, observedAt: '2026-04-07T09:00:00.000Z', - reasonCode: 'backend_error' as const, + reasonCode: 'protocol_proof_missing' as const, message: 'visible_reply_still_required', }; - expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode delivery error'); + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode proof missing'); + expect(getMemberRuntimeAdvisoryTone(advisory)).toBe('warning'); const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); - expect(title).toContain('OpenCode runtime delivery error.'); + 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'); }); @@ -732,6 +736,42 @@ describe('memberHelpers spawn-aware presence', () => { 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 763980e0..f538a623 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -77,4 +77,25 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { 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 0b079e9d..02c46d7b 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -534,4 +534,54 @@ Messages: 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); + }); }); From cbf2d364bed547b27914771776702f7060d2cebe Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 01:38:28 +0300 Subject: [PATCH 19/22] fix(team): restore task automation ci --- src/main/services/team/TeamDataService.ts | 11 +++++++++++ .../bridge/OpenCodeBridgeCommandContract.ts | 1 + .../OpenCodeRuntimeDeliveryDiagnostics.ts | 16 +++++++++++----- src/shared/utils/teamTaskState.ts | 15 +-------------- test/main/services/team/TeamDataService.test.ts | 5 ++++- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index b27ba729..2d1be942 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -596,6 +596,17 @@ export class TeamDataService { kanbanTaskState?: KanbanState['tasks'][string] ): 'none' | 'review' | 'needsFix' | 'approved' { 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, diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index c8d2090f..788c8b9a 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -176,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 }[]; diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts index 648ec547..8fc937bc 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -1,7 +1,10 @@ import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger'; -const SECRET_VALUE_PATTERN = - /\b(?:sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]*api[_-]?key[A-Za-z0-9_-]*[=:]\s*['"]?[^'"\s]+|authorization:\s*bearer\s+[^'"\s]+)\b/gi; +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', @@ -43,12 +46,15 @@ const ACTION_REQUIRED_DELIVERY_ERROR_TOKENS = [ export function normalizeOpenCodeRuntimeDeliveryDiagnostic( message: string | null | undefined ): string | null { - const normalized = message + 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, '') - .replace(SECRET_VALUE_PATTERN, '[redacted]'); + .replace(/^APIError\s*[-:]\s*/i, ''); return normalized && normalized.length > 0 ? normalized : null; } diff --git a/src/shared/utils/teamTaskState.ts b/src/shared/utils/teamTaskState.ts index 961b0f1a..b3d059a8 100644 --- a/src/shared/utils/teamTaskState.ts +++ b/src/shared/utils/teamTaskState.ts @@ -72,20 +72,7 @@ export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): bo } export function isTeamTaskFinalForCompletionNotification(task: TeamTaskStateLike): boolean { - if (isTeamTaskDeleted(task)) { - return true; - } - - const workflowColumn = getTeamTaskWorkflowColumn(task); - if (workflowColumn === 'approved') { - return true; - } - - if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) { - return false; - } - - return task.status === 'completed'; + return isTeamTaskTerminalForActionableWork(task); } export function getTeamTaskWorkflowColumn( diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 02b672ee..e89ee34f 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1914,7 +1914,7 @@ describe('TeamDataService', () => { name: 'My team', members: [{ name: 'jack', role: 'developer' }], }, - getTasks: async () => [ + getTasks: async (): Promise => [ { id: 'task-approved', subject: 'Approved but stale status', @@ -1975,6 +1975,7 @@ describe('TeamDataService', () => { id: 'review-started', type: 'review_started', timestamp: '2026-05-06T19:00:00.000Z', + from: 'none', to: 'review', }, ], @@ -2172,6 +2173,7 @@ describe('TeamDataService', () => { { id: 'evt-review', type: 'review_started', + from: 'none', to: 'review', timestamp: '2026-03-01T09:00:00.000Z', }, @@ -2182,6 +2184,7 @@ describe('TeamDataService', () => { {} as never, {} as never, {} as never, + {} as never, { getState: vi.fn(async () => ({ teamName: 'my-team', From 7137c24d482f72d89cbe38a8ca6f9fb2f3692c1d Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 01:47:09 +0300 Subject: [PATCH 20/22] fix(team): honor kanban review agenda --- .../member-work-sync/core/domain/ActionableWorkAgenda.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 657b53fc..94022fff 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -182,7 +182,7 @@ export function buildActionableWorkAgenda( const reviewOwner = isReviewWorkflow ? resolveCurrentReviewOwner({ - reviewState: task.reviewState, + reviewState: workflowColumn, kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, historyEvents: task.historyEvents, }) From 43aba1ef113c549786742d4a918abfde48138cb2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 01:57:33 +0300 Subject: [PATCH 21/22] fix(team): stabilize frontend ci checks --- src/main/services/infrastructure/NotificationManager.ts | 5 ++++- src/renderer/utils/pathNormalize.ts | 1 + .../services/team/TeamMemberRuntimeAdvisoryService.test.ts | 3 +-- 3 files changed, 6 insertions(+), 3 deletions(-) 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/renderer/utils/pathNormalize.ts b/src/renderer/utils/pathNormalize.ts index 5144f6b1..168fc2fd 100644 --- a/src/renderer/utils/pathNormalize.ts +++ b/src/renderer/utils/pathNormalize.ts @@ -85,6 +85,7 @@ export function buildTaskCountsByOwner( for (const task of tasks) { const owner = task.owner?.trim(); 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, incrementTaskStatus(counts, task)); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index d474d23e..09c8a2f9 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -655,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]); From a6f078c3d78509d8e002e15333ceaf37c873866a Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 02:19:12 +0300 Subject: [PATCH 22/22] test(team): normalize log root expectation --- test/main/services/team/TeamMemberLogsFinder.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 63933f06..be14e2c1 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -99,9 +99,9 @@ describe('TeamMemberLogsFinder', () => { ]); expect(context?.sessionIds).toEqual(context?.watchSessionIds); expect(context?.taskFreshnessRootDirs).toEqual([ - projectPath, - memberProjectPath, - runtimeProjectPath, + path.normalize(projectPath), + path.normalize(memberProjectPath), + path.normalize(runtimeProjectPath), ]); });