From d60abd54fe0c590ec941c986611f8e7839372073 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 11:53:34 +0300 Subject: [PATCH] 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();