diff --git a/src/main/index.ts b/src/main/index.ts index aa40badd..c9c71c37 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -459,6 +459,7 @@ async function createOpenCodeRuntimeAdapterRegistry( reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...'); const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash; reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...'); } catch (error) { logger.warn( @@ -487,11 +488,15 @@ async function createOpenCodeRuntimeAdapterRegistry( try { const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash; nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; + nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash; await ensureOpenCodeLocalMcpLaunchEnv(nextEnv); } catch (error) { delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; + delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH; delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; + delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH; await ensureOpenCodeLocalMcpLaunchEnv(nextEnv); logger.warn( `[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${ diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index 4b81baf9..48299425 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -3,6 +3,7 @@ import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { type ChildProcess } from 'child_process'; +import { createHash } from 'crypto'; import http from 'http'; import net from 'net'; @@ -14,11 +15,32 @@ const MCP_HTTP_ENDPOINT = '/mcp'; const MCP_HTTP_READY_TIMEOUT_MS = 5_000; const MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS = 3_000; const MCP_HTTP_READY_POLL_MS = 100; +const MCP_HTTP_PORT_RELEASE_TIMEOUT_MS = 3_000; +const MCP_HTTP_STABLE_PORT_BASE = 43_100; +const MCP_HTTP_STABLE_PORT_SPAN = 700; +const MCP_HTTP_STABLE_PORT_SCAN_LIMIT = 20; +const MCP_HTTP_PORT_ENV = 'CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT'; + +export interface AgentTeamsMcpHttpTransportEvidence { + schemaVersion: 1; + transport: 'httpStream'; + host: string; + port: number; + endpoint: string; + url: string; + urlHash: string; + generation: number; + observedAt: string; +} export interface AgentTeamsMcpHttpServerHandle { url: string; port: number; pid: number | null; + generation: number; + urlHash: string; + transportEvidence: AgentTeamsMcpHttpTransportEvidence; + diagnostics: string[]; } export interface AgentTeamsMcpHttpServerDeps { @@ -50,6 +72,18 @@ async function allocateLoopbackPort(): Promise { }); } +async function canListenOnLoopbackPort(host: string, port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => { + resolve(false); + }); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); +} + async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -91,6 +125,21 @@ async function waitForLoopbackPort(host: string, port: number, timeoutMs: number ); } +async function waitForLoopbackPortAvailable( + host: string, + port: number, + timeoutMs: number +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await canListenOnLoopbackPort(host, port)) { + return true; + } + await sleep(MCP_HTTP_READY_POLL_MS); + } + return await canListenOnLoopbackPort(host, port); +} + function defaultSpawnProcess( command: string, args: string[], @@ -117,10 +166,56 @@ function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] ]; } +function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function parseConfiguredStablePort(value: string | undefined): number | null { + if (!value?.trim()) { + return null; + } + const parsed = Number(value.trim()); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) { + logger.warn(`Ignoring invalid ${MCP_HTTP_PORT_ENV} value: ${value}`); + return null; + } + return parsed; +} + +function resolveDefaultStablePort(): number { + const configured = parseConfiguredStablePort(process.env[MCP_HTTP_PORT_ENV]); + if (configured) { + return configured; + } + const basis = `${getClaudeBasePath()}|agent-teams-opencode-mcp-http`; + const hashPrefix = sha256Hex(basis).slice(0, 8); + const offset = Number.parseInt(hashPrefix, 16) % MCP_HTTP_STABLE_PORT_SPAN; + return MCP_HTTP_STABLE_PORT_BASE + offset; +} + +function buildTransportEvidence( + port: number, + generation: number +): AgentTeamsMcpHttpTransportEvidence { + const url = `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`; + return { + schemaVersion: 1, + transport: 'httpStream', + host: MCP_HTTP_HOST, + port, + endpoint: MCP_HTTP_ENDPOINT, + url, + urlHash: sha256Hex(url), + generation, + observedAt: new Date().toISOString(), + }; +} + export class AgentTeamsMcpHttpServer { private startPromise: Promise | null = null; private child: ChildProcess | null = null; private handle: AgentTeamsMcpHttpServerHandle | null = null; + private generation = 0; private readonly expectedStopChildren = new WeakSet(); constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {} @@ -140,12 +235,24 @@ export class AgentTeamsMcpHttpServer { async stop(): Promise { const child = this.child; + const releasePort = this.handle?.port ?? null; this.child = null; this.handle = null; if (child) { this.expectedStopChildren.add(child); killProcessTree(child, 'SIGKILL'); } + if (releasePort) { + await waitForLoopbackPortAvailable( + MCP_HTTP_HOST, + releasePort, + MCP_HTTP_PORT_RELEASE_TIMEOUT_MS + ); + } + } + + getCurrentHandle(): AgentTeamsMcpHttpServerHandle | null { + return this.handle; } private async reuseOrRestartExistingHandle( @@ -164,20 +271,72 @@ export class AgentTeamsMcpHttpServer { error instanceof Error ? error.message : String(error) }` ); + const restartPort = handle.port; + const previousUrlHash = handle.urlHash; await this.stop(); + return this.startOnce({ + preferredPort: restartPort, + previousUrlHash, + reason: 'health_reuse_failed', + }); } } return this.startOnce(); } - private async startOnce(): Promise { - const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec; + private async resolveStartPort(preferredPort?: number | null): Promise<{ + port: number; + diagnostics: string[]; + }> { + const diagnostics: string[] = []; + if (preferredPort && (await canListenOnLoopbackPort(MCP_HTTP_HOST, preferredPort))) { + return { port: preferredPort, diagnostics }; + } + if (preferredPort) { + diagnostics.push(`opencode_app_mcp_preferred_port_unavailable:${preferredPort}`); + } + + if (this.deps.allocatePort && (!preferredPort || diagnostics.length > 0)) { + return { port: await this.deps.allocatePort(), diagnostics }; + } + + const stablePort = resolveDefaultStablePort(); + for (let offset = 0; offset < MCP_HTTP_STABLE_PORT_SCAN_LIMIT; offset += 1) { + const candidate = stablePort + offset; + if (candidate > 65_535) { + break; + } + if (preferredPort === candidate) { + continue; + } + if (await canListenOnLoopbackPort(MCP_HTTP_HOST, candidate)) { + if (candidate !== stablePort) { + diagnostics.push(`opencode_app_mcp_preferred_port_unavailable:${stablePort}`); + } + return { port: candidate, diagnostics }; + } + } + const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort; + const port = await allocatePort(); + diagnostics.push('opencode_app_mcp_stable_port_range_unavailable'); + return { port, diagnostics }; + } + + private async startOnce( + input: { + preferredPort?: number | null; + previousUrlHash?: string | null; + reason?: string; + } = {} + ): Promise { + const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec; const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess; const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort; const launchSpec = await resolveLaunchSpec(); - const port = await allocatePort(); + const selectedPort = await this.resolveStartPort(input.preferredPort ?? null); + const port = selectedPort.port; const args = buildHttpServerArgs(launchSpec, port); const childEnv = applyAgentTeamsIdentityEnv({ ...process.env, @@ -254,14 +413,35 @@ export class AgentTeamsMcpHttpServer { } startupSettled = true; + const generation = this.generation + 1; + const transportEvidence = buildTransportEvidence(port, generation); + this.generation = generation; + const diagnostics = [...selectedPort.diagnostics]; + if (input.previousUrlHash && input.previousUrlHash !== transportEvidence.urlHash) { + diagnostics.push('opencode_app_mcp_public_url_changed'); + } + if (input.reason) { + diagnostics.push(`opencode_app_mcp_restart_reason:${input.reason}`); + } this.handle = { - url: `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`, + url: transportEvidence.url, port, pid: child.pid ?? null, + generation, + urlHash: transportEvidence.urlHash, + transportEvidence, + diagnostics, }; logger.info(`Agent Teams MCP HTTP server running at ${this.handle.url}`); + for (const diagnostic of diagnostics) { + logger.warn(`Agent Teams MCP HTTP diagnostic: ${diagnostic}`); + } return this.handle; } } export const agentTeamsMcpHttpServer = new AgentTeamsMcpHttpServer(); + +export function getCurrentAgentTeamsMcpHttpTransportEvidence(): AgentTeamsMcpHttpTransportEvidence | null { + return agentTeamsMcpHttpServer.getCurrentHandle()?.transportEvidence ?? null; +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f5939599..d13581ad 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -190,16 +190,20 @@ import { parseBootstrapRuntimeProofDetail, validateBootstrapRuntimeProofEnvelope, } from './bootstrap/BootstrapProofValidation'; +import { getCurrentAgentTeamsMcpHttpTransportEvidence } from './AgentTeamsMcpHttpServer'; import { buildNativeAppManagedBootstrapSpecs, buildNativeAppManagedBootstrapSpecsWithDiagnostics, type NativeAppManagedBootstrapSpec, } from './bootstrap/NativeAppManagedBootstrapContextBuilder'; import { + buildOpenCodePromptDeliveryAttemptId, createOpenCodePromptDeliveryLedgerStore, hashOpenCodePromptDeliveryPayload, isOpenCodePromptDeliveryAttemptDue, isOpenCodePromptResponseStateResponded, + isOpenCodeSessionRefreshResponseState, + OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS, type OpenCodePromptDeliveryLedgerRecord, type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, @@ -8210,6 +8214,80 @@ export class TeamProvisioningService { ); } + private isOpenCodeNoAssistantDeliveryFailure( + record: OpenCodePromptDeliveryLedgerRecord + ): boolean { + if (record.inboxReadCommittedAt) { + return false; + } + + const noAssistantState = + record.responseState === 'empty_assistant_turn' || + record.responseState === 'prompt_delivered_no_assistant_message'; + const reasonText = [record.responseState, record.lastReason, ...record.diagnostics] + .filter((reason): reason is string => typeof reason === 'string' && reason.trim().length > 0) + .join('\n') + .toLowerCase(); + if ( + !noAssistantState && + !reasonText.includes('empty_assistant_turn') && + !reasonText.includes('prompt_delivered_no_assistant_message') && + !reasonText.includes('accepted the prompt, but no assistant turn was recorded') + ) { + return false; + } + + return ![record.lastReason, ...record.diagnostics].some((reason) => { + const reasonCode = classifyOpenCodeRuntimeDeliveryReasonCode(reason ?? undefined); + return ( + reasonCode === 'quota_exhausted' || + reasonCode === 'auth_error' || + reasonCode === 'filesystem_error' + ); + }); + } + + private isOpenCodeNoAssistantTerminalDeliveryFailure( + record: OpenCodePromptDeliveryLedgerRecord + ): boolean { + return ( + record.status === 'failed_terminal' && + record.attempts <= record.maxAttempts && + this.isOpenCodeNoAssistantDeliveryFailure(record) + ); + } + + private async requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded(input: { + ledger: OpenCodePromptDeliveryLedgerStore; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + }): Promise { + if (!this.isOpenCodeNoAssistantTerminalDeliveryFailure(input.ledgerRecord)) { + return input.ledgerRecord; + } + + const scheduledAt = nowIso(); + const requeued = await input.ledger.markNextAttemptScheduled({ + id: input.ledgerRecord.id, + status: 'retry_scheduled', + nextAttemptAt: scheduledAt, + reason: 'opencode_prompt_delivery_requeued_after_terminal_no_assistant_response', + scheduledAt, + }); + logger.info( + 'opencode_prompt_delivery_requeued_after_terminal_no_assistant_response', + JSON.stringify({ + teamName: requeued.teamName, + memberName: requeued.memberName, + laneId: requeued.laneId, + runId: requeued.runId, + inboxMessageId: requeued.inboxMessageId, + attempts: requeued.attempts, + maxAttempts: requeued.maxAttempts, + }) + ); + return requeued; + } + private async requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded(input: { ledger: OpenCodePromptDeliveryLedgerStore; ledgerRecord: OpenCodePromptDeliveryLedgerRecord; @@ -9259,7 +9337,69 @@ export class TeamProvisioningService { reason: string; }): Promise { const now = nowIso(); - if (input.retry && input.ledgerRecord.attempts >= input.ledgerRecord.maxAttempts) { + const sessionRefreshRetry = + input.retry && + isOpenCodeSessionRefreshResponseState({ + responseState: input.ledgerRecord.responseState, + reason: input.reason, + diagnostics: input.ledgerRecord.diagnostics, + }); + if (sessionRefreshRetry) { + const maxSessionRefreshAttempts = + input.ledgerRecord.maxSessionRefreshAttempts ?? + OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS; + if ((input.ledgerRecord.sessionRefreshAttempts ?? 0) >= maxSessionRefreshAttempts) { + return await input.ledger.markFailedTerminal({ + id: input.ledgerRecord.id, + reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed', + diagnostics: [ + input.reason, + `OpenCode session stayed stale after ${maxSessionRefreshAttempts} session refresh attempt(s).`, + ], + failedAt: now, + }); + } + const delayMs = this.getOpenCodeDeliveryNextDelayMs({ + responseState: input.ledgerRecord.responseState, + retry: input.retry, + ledgerRecord: input.ledgerRecord, + }); + const nextAttemptAt = new Date(Date.now() + delayMs).toISOString(); + const ledgerRecord = await input.ledger.markSessionRefreshScheduled({ + id: input.ledgerRecord.id, + nextAttemptAt, + reason: input.reason, + scheduledAt: now, + maxSessionRefreshAttempts, + diagnostics: ['opencode_session_refresh_scheduled_after_resolved_behavior_changed'], + }); + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_session_refresh_scheduled', + ledgerRecord, + { + retry: true, + reason: input.reason, + sessionRefreshAttempts: ledgerRecord.sessionRefreshAttempts ?? 0, + maxSessionRefreshAttempts, + } + ); + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName: input.teamName, + memberName: input.memberName, + messageId: input.ledgerRecord.inboxMessageId, + delayMs, + }); + return ledgerRecord; + } + const canScheduleNoAssistantRecoveryRetry = + input.retry && + input.ledgerRecord.attempts === input.ledgerRecord.maxAttempts && + this.isOpenCodeNoAssistantDeliveryFailure(input.ledgerRecord); + if ( + input.retry && + input.ledgerRecord.attempts >= input.ledgerRecord.maxAttempts && + !canScheduleNoAssistantRecoveryRetry + ) { return await input.ledger.markFailedTerminal({ id: input.ledgerRecord.id, reason: input.reason, @@ -10108,21 +10248,41 @@ export class TeamProvisioningService { return { delivered: false, reason: 'opencode_runtime_not_active' }; } - if (laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode') { - const bootstrapReady = await this.hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence({ + let legacyOpenCodeBootstrapSessionToStamp: OpenCodeCommittedBootstrapSessionRecord | null = + null; + let refreshedOpenCodeBootstrapSessionToStamp: OpenCodeCommittedBootstrapSessionRecord | null = + null; + let forceOpenCodeSessionRefreshReason: string | undefined; + if (laneIdentity.laneOwnerProviderId === 'opencode') { + const bootstrapSession = await this.findDeliverableOpenCodeRuntimeBootstrapSessionEvidence({ teamName, runId: runtimeRunId, laneId: laneIdentity.laneId, memberName: canonicalMemberName, }); - if (!bootstrapReady) { - return { - delivered: false, - reason: 'opencode_runtime_not_active', - diagnostics: [ - `OpenCode runtime bootstrap is not confirmed for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`, - ], - }; + if (!bootstrapSession) { + if (laneIdentity.laneKind === 'secondary') { + return { + delivered: false, + reason: 'opencode_runtime_not_active', + diagnostics: [ + `OpenCode runtime bootstrap is not confirmed for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`, + ], + }; + } + } else { + if (!bootstrapSession.appMcpTransportHash?.trim()) { + legacyOpenCodeBootstrapSessionToStamp = bootstrapSession; + } + const appMcpTransportMismatch = + this.getOpenCodeAppMcpTransportMismatchDiagnostic(bootstrapSession); + if (appMcpTransportMismatch) { + refreshedOpenCodeBootstrapSessionToStamp = bootstrapSession; + forceOpenCodeSessionRefreshReason = appMcpTransportMismatch; + logger.info( + `[${teamName}] OpenCode delivery detected stale app MCP transport for ${canonicalMemberName}; requesting bridge session refresh before send. ${appMcpTransportMismatch}` + ); + } } } @@ -10178,6 +10338,7 @@ export class TeamProvisioningService { workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, controlUrl: controlUrl ?? undefined, taskRefs: input.taskRefs, + forceSessionRefreshReason: forceOpenCodeSessionRefreshReason, }); await this.rememberOpenCodeRuntimePidFromBridge({ teamName, @@ -10188,6 +10349,20 @@ export class TeamProvisioningService { runtimePid: result.runtimePid, reason: 'opencode_delivery_runtime_pid_observed', }); + if (result.ok && legacyOpenCodeBootstrapSessionToStamp) { + await this.stampOpenCodeAppMcpTransportEvidenceIfMissing( + legacyOpenCodeBootstrapSessionToStamp + ); + } + if (result.ok && result.sessionId && refreshedOpenCodeBootstrapSessionToStamp) { + await this.stampOpenCodeAppMcpTransportEvidenceIfMissing( + refreshedOpenCodeBootstrapSessionToStamp, + { + overwriteExistingHash: true, + runtimeSessionId: result.sessionId, + } + ); + } const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( result.responseObservation ); @@ -10301,9 +10476,7 @@ export class TeamProvisioningService { this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_ledger_created', ledgerRecord); } const deliveryAttemptId = ledgerRecord - ? [ledgerRecord.id, ledgerRecord.attempts + 1, ledgerRecord.payloadHash.slice(0, 12)].join( - ':' - ) + ? buildOpenCodePromptDeliveryAttemptId(ledgerRecord) : undefined; if (ledgerRecord && ledger && messageId) { @@ -10422,7 +10595,19 @@ export class TeamProvisioningService { attemptDue, ledgerRecord, }); - if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) { + const retryShouldRefreshSessionBeforeObserve = + retryDueBeforeObserve && + ledgerRecord.status === 'retry_scheduled' && + isOpenCodeSessionRefreshResponseState({ + responseState: ledgerRecord.responseState, + reason: ledgerRecord.lastReason ?? undefined, + diagnostics: ledgerRecord.diagnostics, + }); + if ( + ledgerRecord.status !== 'pending' && + adapter.observeMessageDelivery && + !retryShouldRefreshSessionBeforeObserve + ) { const observed = await adapter.observeMessageDelivery({ ...(runtimeRunId ? { runId: runtimeRunId } : {}), teamName, @@ -10530,6 +10715,54 @@ export class TeamProvisioningService { readAllowed, }); const retryDue = retryDueBeforeObserve; + if ( + retryDue && + retryable && + isOpenCodeSessionRefreshResponseState({ + responseState: ledgerRecord.responseState, + reason: pendingReason, + diagnostics: ledgerRecord.diagnostics, + }) + ) { + ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({ + ledger, + ledgerRecord, + teamName, + memberName: canonicalMemberName, + retry: true, + reason: pendingReason, + }); + if (ledgerRecord.status === 'failed_terminal') { + return { + delivered: false, + accepted: true, + responsePending: false, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined, + reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal', + diagnostics: ledgerRecord.diagnostics.length + ? ledgerRecord.diagnostics + : [ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal'], + }; + } + return { + delivered: true, + accepted: true, + responsePending: true, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined, + reason: ledgerRecord.lastReason ?? 'opencode_delivery_response_pending', + diagnostics: ledgerRecord.diagnostics, + }; + } if (!retryDue || !retryable) { ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({ ledger, @@ -10604,6 +10837,7 @@ export class TeamProvisioningService { workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, controlUrl: controlUrl ?? undefined, taskRefs: input.taskRefs, + forceSessionRefreshReason: forceOpenCodeSessionRefreshReason, }); } catch (error) { const diagnostic = `opencode_message_delivery_exception: ${getErrorMessage(error)}`; @@ -10670,6 +10904,20 @@ export class TeamProvisioningService { runtimePid: result.runtimePid, reason: 'opencode_delivery_runtime_pid_observed', }); + if (result.ok && legacyOpenCodeBootstrapSessionToStamp) { + await this.stampOpenCodeAppMcpTransportEvidenceIfMissing( + legacyOpenCodeBootstrapSessionToStamp + ); + } + if (result.ok && result.sessionId && refreshedOpenCodeBootstrapSessionToStamp) { + await this.stampOpenCodeAppMcpTransportEvidenceIfMissing( + refreshedOpenCodeBootstrapSessionToStamp, + { + overwriteExistingHash: true, + runtimeSessionId: result.sessionId, + } + ); + } const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( result.responseObservation ); @@ -12843,6 +13091,8 @@ export class TeamProvisioningService { const sessionStorePath = path.join(runtimeDirectory, descriptor.relativePath); const existingSessions = await this.readOpenCodeRuntimeSessionStore(sessionStorePath); const source = input.source ?? 'runtime_bootstrap_checkin'; + const appMcpTransportEvidence = + source === 'app_managed_bootstrap' ? getCurrentAgentTeamsMcpHttpTransportEvidence() : null; const session = { id: input.runtimeSessionId, teamName: input.teamName, @@ -12855,6 +13105,12 @@ export class TeamProvisioningService { ...(source === 'app_managed_bootstrap' && input.appManagedBootstrapCandidate ? { appManagedBootstrapCandidate: input.appManagedBootstrapCandidate } : {}), + ...(appMcpTransportEvidence + ? { + appMcpTransportHash: appMcpTransportEvidence.urlHash, + appMcpTransportEvidence, + } + : {}), }; const sessions = this.mergeOpenCodeRuntimeSessionRecords(existingSessions, session); const manifestStore = createRuntimeStoreManifestStore({ @@ -12942,31 +13198,161 @@ export class TeamProvisioningService { }); } - private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: { + private getOpenCodeAppMcpTransportMismatchDiagnostic( + session: OpenCodeCommittedBootstrapSessionRecord + ): string | null { + const committedHash = session.appMcpTransportHash?.trim(); + const currentHash = getCurrentAgentTeamsMcpHttpTransportEvidence()?.urlHash?.trim(); + if (!committedHash || !currentHash || committedHash === currentHash) { + return null; + } + return `opencode_app_mcp_transport_changed:${committedHash}->${currentHash}`; + } + + private async stampOpenCodeAppMcpTransportEvidenceIfMissing( + session: OpenCodeCommittedBootstrapSessionRecord, + options: { + overwriteExistingHash?: boolean; + runtimeSessionId?: string | null; + } = {} + ): Promise { + const overwriteExistingHash = options.overwriteExistingHash === true; + const runtimeSessionId = options.runtimeSessionId?.trim() || null; + if (session.appMcpTransportHash?.trim() && !overwriteExistingHash) { + return; + } + const appMcpTransportEvidence = getCurrentAgentTeamsMcpHttpTransportEvidence(); + if (!appMcpTransportEvidence) { + return; + } + const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find( + (candidate) => candidate.schemaName === 'opencode.sessionStore' + ); + if (!descriptor) { + return; + } + + try { + const manifestPath = getOpenCodeRuntimeManifestPath( + getTeamsBasePath(), + session.teamName, + session.laneId + ); + const runtimeDirectory = path.dirname(manifestPath); + const sessionStorePath = path.join(runtimeDirectory, descriptor.relativePath); + const existingSessions = await this.readOpenCodeRuntimeSessionStore(sessionStorePath); + let changed = false; + const sessions = existingSessions + .filter((record) => { + if (!runtimeSessionId || runtimeSessionId === session.id) { + return true; + } + const recordId = typeof record.id === 'string' ? record.id : ''; + const recordRunId = typeof record.runId === 'string' ? record.runId : null; + const recordLaneId = typeof record.laneId === 'string' ? record.laneId : ''; + const recordMemberName = typeof record.memberName === 'string' ? record.memberName : ''; + return !( + recordId === runtimeSessionId && + recordRunId === session.runId && + recordLaneId === session.laneId && + namesMatchCaseInsensitive(recordMemberName, session.memberName) + ); + }) + .map((record) => { + const recordId = typeof record.id === 'string' ? record.id : ''; + const recordRunId = typeof record.runId === 'string' ? record.runId : null; + const recordLaneId = typeof record.laneId === 'string' ? record.laneId : ''; + const recordMemberName = typeof record.memberName === 'string' ? record.memberName : ''; + const hasTransportHash = + typeof record.appMcpTransportHash === 'string' && + record.appMcpTransportHash.trim().length > 0; + if ( + recordId !== session.id || + recordRunId !== session.runId || + recordLaneId !== session.laneId || + !namesMatchCaseInsensitive(recordMemberName, session.memberName) || + (hasTransportHash && !overwriteExistingHash) + ) { + return record; + } + changed = true; + return { + ...record, + ...(runtimeSessionId ? { id: runtimeSessionId } : {}), + appMcpTransportHash: appMcpTransportEvidence.urlHash, + appMcpTransportEvidence, + }; + }); + if (!changed) { + return; + } + + const manifestStore = createRuntimeStoreManifestStore({ + filePath: manifestPath, + teamName: session.teamName, + lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS, + }); + const receiptStore = createRuntimeStoreReceiptStore({ + filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'), + lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS, + }); + const writer = new RuntimeStoreBatchWriter(runtimeDirectory, manifestStore, receiptStore); + await writer.writeBatch({ + teamName: session.teamName, + runId: session.runId, + capabilitySnapshotId: null, + behaviorFingerprint: null, + reason: 'delivery_commit', + writes: [ + { + descriptor, + data: { sessions }, + }, + ], + }); + } catch (error) { + logger.warn( + `[${session.teamName}] Failed to stamp OpenCode app MCP transport evidence for ${session.memberName}: ${getErrorMessage(error)}` + ); + } + } + + private async findDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: { teamName: string; runId: string | null; laneId: string; memberName: string; - }): Promise { + }): Promise { const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ teamsBasePath: getTeamsBasePath(), teamName: input.teamName, laneId: input.laneId, }).catch(() => null); if (!evidence?.committed) { - return false; + return null; } const activeRunId = evidence.activeRunId?.trim() || null; if (activeRunId !== input.runId) { - return false; + return null; } - return evidence.sessions.some( - (session) => - session.runId === input.runId && - namesMatchCaseInsensitive(session.memberName, input.memberName) + return ( + evidence.sessions.find( + (session) => + session.runId === input.runId && + namesMatchCaseInsensitive(session.memberName, input.memberName) + ) ?? null ); } + private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: { + teamName: string; + runId: string | null; + laneId: string; + memberName: string; + }): Promise { + return (await this.findDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input)) != null; + } + private async readOpenCodeRuntimeSessionStore( filePath: string ): Promise[]> { @@ -23378,6 +23764,15 @@ export class TeamProvisioningService { existingRecord = requeuedRecord; } } + if (existingRecord?.status === 'failed_terminal') { + const requeuedRecord = await this.requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded({ + ledger: promptLedger, + ledgerRecord: existingRecord, + }); + if (requeuedRecord.status !== 'failed_terminal') { + existingRecord = requeuedRecord; + } + } if (existingRecord?.status === 'failed_terminal') { let recoveredRecord: OpenCodePromptDeliveryLedgerRecord | null = null; let recoveredVisibleReply: OpenCodeVisibleReplyProof | null = null; diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index cc9fee26..52490fca 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -9,7 +9,7 @@ import type { 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 const OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION = 1 as const; +export const OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION = 2 as const; export type OpenCodeBridgeCommandName = | 'opencode.handshake' @@ -172,6 +172,7 @@ export interface OpenCodeSendMessageCommandBody { text: string; messageId?: string; deliveryAttemptId?: string; + forceSessionRefreshReason?: string; payloadHash?: string; settlementMode?: 'observed' | 'acceptance'; fileParts?: { @@ -690,8 +691,7 @@ export function validateOpenCodeBridgeHandshake(input: { ) { return { ok: false, - reason: - 'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1. Falling back to observed delivery mode is required.', + reason: `OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version ${OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION}. Falling back to observed delivery mode is required.`, }; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 5e6f28ef..07588dd8 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -1,6 +1,9 @@ import { randomUUID } from 'crypto'; -import { stableHash } from './OpenCodeBridgeCommandContract'; +import { + OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION, + stableHash, +} from './OpenCodeBridgeCommandContract'; import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; import type { @@ -296,6 +299,9 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { ) { throw error; } + if (body.forceSessionRefreshReason?.trim()) { + return buildOpenCodeForceSessionRefreshUnsupportedData(body, error); + } activeRequestId = `${commandRequestId}-observed`; activeBody = { ...body, @@ -312,6 +318,9 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { activeBody.settlementMode === 'acceptance' && isOpenCodeAcceptanceContractMissingError(result.error.message) ) { + if (body.forceSessionRefreshReason?.trim()) { + return buildOpenCodeForceSessionRefreshUnsupportedData(body, result.error.message); + } activeRequestId = `${commandRequestId}-observed`; activeBody = { ...body, @@ -644,6 +653,36 @@ function isOpenCodeAcceptanceContractMissingError(error: unknown): boolean { return message.includes('OpenCode delivery acceptance mode is required'); } +function buildOpenCodeForceSessionRefreshUnsupportedData( + body: OpenCodeSendMessageCommandBody, + error: unknown +): OpenCodeSendMessageCommandData { + const detail = error instanceof Error ? error.message : String(error); + const reason = `OpenCode forced session refresh requires delivery acceptance contract version ${OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION}. Update agent_teams_orchestrator and restart the app.`; + return { + accepted: false, + memberName: body.memberName, + responseObservation: { + state: 'session_stale', + deliveredUserMessageId: null, + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason, + }, + diagnostics: [ + { + code: 'opencode_force_session_refresh_contract_missing', + severity: 'error', + message: `${reason} ${detail}`, + }, + ], + }; +} + function isOpenCodeCompletedCommandRecoveryError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return message.includes(OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE); diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 3fb7549c..064ae94d 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -11,6 +11,7 @@ import type { AgentActionMode, InboxMessage, InboxMessageKind, TaskRef } from '@ export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1; export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export const OPENCODE_PROMPT_DELIVERY_FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +export const OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS = 5; export type OpenCodePromptDeliveryStatus = | 'pending' @@ -46,6 +47,9 @@ export interface OpenCodePromptDeliveryLedgerRecord { responseState: OpenCodeDeliveryResponseState; attempts: number; maxAttempts: number; + sessionRefreshAttempts?: number; + maxSessionRefreshAttempts?: number; + lastSessionRefreshReason?: string | null; acceptanceUnknown: boolean; nextAttemptAt: string | null; lastAttemptAt: string | null; @@ -233,6 +237,9 @@ export class OpenCodePromptDeliveryLedgerStore { responseState: 'not_observed', attempts: 0, maxAttempts: input.maxAttempts ?? 3, + sessionRefreshAttempts: 0, + maxSessionRefreshAttempts: OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS, + lastSessionRefreshReason: null, acceptanceUnknown: false, nextAttemptAt: null, lastAttemptAt: null, @@ -310,6 +317,11 @@ export class OpenCodePromptDeliveryLedgerStore { const observation = input.responseObservation; const responseState = observation?.state ?? (input.accepted ? record.responseState : 'not_observed'); + const sessionRefreshState = isOpenCodeSessionRefreshResponseState({ + responseState, + reason: input.reason ?? observation?.reason ?? record.lastReason, + diagnostics: input.diagnostics, + }); const responded = isOpenCodePromptResponseStateResponded(responseState); const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState); const acceptedRuntimePromptMessageId = @@ -336,7 +348,8 @@ export class OpenCodePromptDeliveryLedgerStore { const shouldIncrementAttempts = (input.accepted || input.attempted === true) && !acceptedAttemptAlreadyRecorded && - !acceptedPromptAlreadyRecorded; + !acceptedPromptAlreadyRecorded && + !sessionRefreshState; const lastRuntimePromptMessageId = acceptedRuntimePromptMessageId ?? record.lastRuntimePromptMessageId ?? @@ -381,6 +394,9 @@ export class OpenCodePromptDeliveryLedgerStore { visibleReplyCorrelation: observation?.visibleReplyCorrelation ?? record.visibleReplyCorrelation, lastReason: input.reason ?? observation?.reason ?? record.lastReason, + lastSessionRefreshReason: sessionRefreshState + ? (input.reason ?? observation?.reason ?? record.lastSessionRefreshReason ?? null) + : (record.lastSessionRefreshReason ?? null), diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []), updatedAt: input.now, }; @@ -400,6 +416,11 @@ export class OpenCodePromptDeliveryLedgerStore { const unanswered = isOpenCodePromptDeliveryUnansweredResponseState( input.responseObservation.state ); + const sessionRefreshState = isOpenCodeSessionRefreshResponseState({ + responseState: input.responseObservation.state, + reason: input.responseObservation.reason ?? record.lastReason, + diagnostics: input.diagnostics, + }); const previousRuntimePromptMessageIds = getOpenCodeRuntimePromptMessageIds(record); const deliveredRuntimePromptMessageId = input.responseObservation.deliveredUserMessageId?.trim() || null; @@ -457,6 +478,9 @@ export class OpenCodePromptDeliveryLedgerStore { visibleReplyCorrelation: input.responseObservation.visibleReplyCorrelation ?? record.visibleReplyCorrelation, lastReason: input.responseObservation.reason ?? record.lastReason, + lastSessionRefreshReason: sessionRefreshState + ? (input.responseObservation.reason ?? record.lastSessionRefreshReason ?? null) + : (record.lastSessionRefreshReason ?? null), diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []), updatedAt: input.observedAt, }; @@ -527,6 +551,38 @@ export class OpenCodePromptDeliveryLedgerStore { })); } + async markSessionRefreshScheduled(input: { + id: string; + nextAttemptAt: string; + reason: string; + scheduledAt: string; + maxSessionRefreshAttempts?: number; + diagnostics?: string[]; + }): Promise { + return await this.updateExisting(input.id, (record) => { + const maxSessionRefreshAttempts = + record.maxSessionRefreshAttempts ?? + input.maxSessionRefreshAttempts ?? + OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS; + const sessionRefreshAttempts = (record.sessionRefreshAttempts ?? 0) + 1; + return { + ...record, + status: 'retry_scheduled', + responseState: 'session_stale', + nextAttemptAt: input.nextAttemptAt, + sessionRefreshAttempts, + maxSessionRefreshAttempts, + lastSessionRefreshReason: input.reason, + lastReason: input.reason, + diagnostics: mergeDiagnostics(record.diagnostics, [ + input.reason, + ...(input.diagnostics ?? []), + ]), + updatedAt: input.scheduledAt, + }; + }); + } + async markRetryAttempted(input: { id: string; attemptedAt: string; @@ -770,6 +826,20 @@ export function getLatestOpenCodeRuntimePromptMessageId( return ids[ids.length - 1] ?? null; } +export function buildOpenCodePromptDeliveryAttemptId( + record: Pick< + OpenCodePromptDeliveryLedgerRecord, + 'id' | 'attempts' | 'payloadHash' | 'sessionRefreshAttempts' + > +): string { + const base = [record.id, record.attempts + 1, record.payloadHash.slice(0, 12)]; + const sessionRefreshAttempts = record.sessionRefreshAttempts ?? 0; + if (sessionRefreshAttempts > 0) { + base.push(`refresh${sessionRefreshAttempts}`); + } + return base.join(':'); +} + export function isOpenCodePromptResponseStateResponded( state: OpenCodeDeliveryResponseState ): boolean { @@ -787,6 +857,39 @@ function isOpenCodePromptDeliveryUnansweredResponseState( return state === 'empty_assistant_turn' || state === 'prompt_delivered_no_assistant_message'; } +export function isOpenCodeResolvedBehaviorChangedReason( + reason: string | null | undefined +): boolean { + return /\bresolved_behavior_changed:[^\s]+->[^\s]+/i.test(reason?.trim() ?? ''); +} + +export function isOpenCodeSessionTransportChangedReason( + reason: string | null | undefined +): boolean { + return /\bopencode_app_mcp_transport_changed:[^\s]+->[^\s]+/i.test(reason?.trim() ?? ''); +} + +export function isOpenCodeSessionRefreshResponseState(input: { + responseState?: OpenCodeDeliveryResponseState; + reason?: string | null; + diagnostics?: readonly string[]; +}): boolean { + if (input.responseState === 'session_stale') { + return true; + } + if ( + isOpenCodeResolvedBehaviorChangedReason(input.reason) || + isOpenCodeSessionTransportChangedReason(input.reason) + ) { + return true; + } + return (input.diagnostics ?? []).some( + (diagnostic) => + isOpenCodeResolvedBehaviorChangedReason(diagnostic) || + isOpenCodeSessionTransportChangedReason(diagnostic) + ); +} + export function isOpenCodePromptDeliveryAttemptDue( record: OpenCodePromptDeliveryLedgerRecord, nowMs: number = Date.now() @@ -845,6 +948,9 @@ function isOpenCodePromptDeliveryLedgerRecord( isOpenCodeDeliveryResponseState(record.responseState) && isNonNegativeInteger(record.attempts) && isNonNegativeInteger(record.maxAttempts) && + isOptionalNonNegativeInteger(record.sessionRefreshAttempts) && + isOptionalNonNegativeInteger(record.maxSessionRefreshAttempts) && + isOptionalNullableString(record.lastSessionRefreshReason) && typeof record.acceptanceUnknown === 'boolean' && isOptionalNullableString(record.nextAttemptAt) && isOptionalNullableString(record.lastAttemptAt) && @@ -947,6 +1053,10 @@ function isNonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; } +function isOptionalNonNegativeInteger(value: unknown): value is number | undefined { + return value === undefined || isNonNegativeInteger(value); +} + function isTaskRefArray(value: unknown): value is TaskRef[] { return ( Array.isArray(value) && diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts index 91cbc8e5..3f331fcd 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -258,9 +258,6 @@ export function decideOpenCodePromptDeliveryRepair( if (input.status === 'failed_terminal') { return none('terminal_record'); } - if (input.attempts >= input.maxAttempts) { - return none('max_attempts_reached'); - } if (input.hardFailureKind !== 'none') { return none(`hard_failure:${input.hardFailureKind}`); } @@ -339,6 +336,10 @@ export function decideOpenCodePromptDeliveryRepair( ); } + if (input.attempts >= input.maxAttempts) { + return none('max_attempts_reached'); + } + if ( (input.responseState === 'responded_non_visible_tool' || input.responseState === 'responded_tool_call') && diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts index 0a0c5c67..8611571d 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -44,7 +44,12 @@ function isInformationalOpenCodeRuntimeDeliveryDiagnostic( normalized === 'opencode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.' || normalized === 'opencode session status busy' || - normalized === 'opencode_delivery_response_pending' + normalized === 'opencode_delivery_response_pending' || + normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' || + Boolean( + normalized?.startsWith('resolved_behavior_changed:') || + normalized?.includes('opencode_app_mcp_transport_changed:') + ) ); } @@ -87,6 +92,13 @@ function getOpenCodeRuntimeDeliveryStateFallback( ) { return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; } + if ( + state === 'session_stale' || + normalizedReason?.startsWith('resolved_behavior_changed:') || + normalizedReason?.startsWith('opencode_app_mcp_transport_changed:') + ) { + return 'OpenCode session changed; refreshing the session before retry.'; + } if ( normalizedReason === 'visible_reply_destination_not_found_yet' || normalizedReason === 'visible_reply_missing_relayofmessageid' diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index d650d669..c5dd280d 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -71,6 +71,8 @@ export interface OpenCodeCommittedBootstrapSessionRecord { observedAt: string | null; source: OpenCodeBootstrapEvidenceSource; appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate; + appMcpTransportHash?: string; + appMcpTransportEvidence?: Record; } export interface OpenCodeCommittedBootstrapSessionEvidence { @@ -320,6 +322,13 @@ function normalizeOpenCodeBootstrapSessionRecord( source === 'app_managed_bootstrap' ? normalizeAppManagedBootstrapCandidate(record.appManagedBootstrapCandidate) : undefined; + const appMcpTransportHash = normalizeNonEmptyStoreString(record.appMcpTransportHash); + const appMcpTransportEvidence = + record.appMcpTransportEvidence && + typeof record.appMcpTransportEvidence === 'object' && + !Array.isArray(record.appMcpTransportEvidence) + ? (record.appMcpTransportEvidence as Record) + : undefined; return { id, teamName, @@ -329,6 +338,8 @@ function normalizeOpenCodeBootstrapSessionRecord( observedAt, source, ...(appManagedBootstrapCandidate ? { appManagedBootstrapCandidate } : {}), + ...(appMcpTransportHash ? { appMcpTransportHash } : {}), + ...(appMcpTransportEvidence ? { appMcpTransportEvidence } : {}), }; } diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 0810a9ab..1d179f68 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -71,6 +71,7 @@ export interface OpenCodeTeamRuntimeMessageInput { workSyncReviewRequestEventIds?: string[]; controlUrl?: string; taskRefs?: TaskRef[]; + forceSessionRefreshReason?: string; bootstrapCheckinRetry?: { runtimeSessionId: string; reason?: string; @@ -381,6 +382,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { text: buildOpenCodeRuntimeMessageText(input), messageId: input.messageId, ...(input.deliveryAttemptId ? { deliveryAttemptId: input.deliveryAttemptId } : {}), + ...(input.forceSessionRefreshReason + ? { forceSessionRefreshReason: input.forceSessionRefreshReason } + : {}), settlementMode: resolveOpenCodeRuntimeSettlementMode(input), fileParts: input.fileParts, actionMode: input.actionMode, diff --git a/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts b/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts index 1aaf939f..cee46a7f 100644 --- a/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts +++ b/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts @@ -27,6 +27,8 @@ const DISK_FULL_MESSAGE = 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.'; const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE = 'OpenCode bridge outcome unknown after timeout, retrying/observing.'; +const OPENCODE_SESSION_REFRESH_MESSAGE = + 'OpenCode session changed; refreshing the session before retry.'; const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [ { @@ -80,6 +82,18 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [ tokens: ['codex native exec timed out'], priority: 80, }, + { + reasonCode: 'backend_error', + tokens: [ + 'resolved_behavior_changed', + 'opencode_app_mcp_transport_changed', + 'opencode session refresh scheduled after resolved behavior changed', + 'opencode_prompt_delivery_session_refresh_scheduled', + ], + priority: 20, + generic: true, + normalizeMessage: () => OPENCODE_SESSION_REFRESH_MESSAGE, + }, { reasonCode: 'backend_error', tokens: [ diff --git a/src/renderer/utils/memberLaunchDiagnostics.ts b/src/renderer/utils/memberLaunchDiagnostics.ts index 60a8df07..3a6111c8 100644 --- a/src/renderer/utils/memberLaunchDiagnostics.ts +++ b/src/renderer/utils/memberLaunchDiagnostics.ts @@ -128,6 +128,43 @@ function maybeString(value: string | undefined): string | undefined { return boundedString(value, 240); } +function isRuntimeDiagnosticCardError(params: { + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; + launchState?: MemberLaunchState; + spawnStatus?: MemberSpawnStatus; + hardFailure?: boolean; +}): boolean { + if (!params.runtimeDiagnostic) { + return false; + } + return ( + params.runtimeDiagnosticSeverity === 'error' || + params.launchState === 'failed_to_start' || + params.spawnStatus === 'error' || + params.hardFailure === true + ); +} + +function isRecoverableOpenCodeSessionRefreshText(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase() ?? ''; + return ( + normalized.includes('resolved_behavior_changed:') || + normalized.includes('opencode_app_mcp_transport_changed:') || + normalized.includes('opencode session changed; refreshing the session before retry') || + normalized.includes('opencode_session_refresh_scheduled_after_resolved_behavior_changed') + ); +} + +function isRuntimeAdvisoryCardError(runtimeAdvisory: MemberRuntimeAdvisory | undefined): boolean { + if (isRecoverableOpenCodeSessionRefreshText(runtimeAdvisory?.message)) { + return false; + } + return ( + runtimeAdvisory?.kind === 'api_error' && runtimeAdvisory.reasonCode !== 'protocol_proof_missing' + ); +} + export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null { const normalized = value ?.replace(/\s+/g, ' ') @@ -257,7 +294,29 @@ export function buildMemberLaunchDiagnosticsPayload(params: { const runtimeAdvisoryLabel = boundedString(params.runtimeAdvisoryLabel ?? undefined); const runtimeAdvisoryMessage = boundedString(runtimeAdvisory?.message); const runtimeAdvisoryCardError = - runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage; + isRuntimeAdvisoryCardError(runtimeAdvisory) && + ![runtimeAdvisoryTitle, runtimeAdvisoryLabel, runtimeAdvisoryMessage].some( + isRecoverableOpenCodeSessionRefreshText + ) + ? (runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage) + : undefined; + const runtimeDiagnosticSeverity = + spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity; + const spawnRuntimeDiagnosticCardError = isRuntimeDiagnosticCardError({ + runtimeDiagnostic: spawnEntry?.runtimeDiagnostic, + runtimeDiagnosticSeverity: spawnEntry?.runtimeDiagnosticSeverity, + launchState: spawnEntry?.launchState, + spawnStatus: spawnEntry?.status, + hardFailure: spawnEntry?.hardFailure, + }) + ? spawnEntry?.runtimeDiagnostic + : undefined; + const runtimeEntryDiagnosticCardError = isRuntimeDiagnosticCardError({ + runtimeDiagnostic: runtimeEntry?.runtimeDiagnostic, + runtimeDiagnosticSeverity: runtimeEntry?.runtimeDiagnosticSeverity, + }) + ? runtimeEntry?.runtimeDiagnostic + : undefined; const runtimeDiagnostic = boundedString(spawnEntry?.runtimeDiagnostic) ?? boundedString(runtimeEntry?.runtimeDiagnostic) ?? @@ -269,8 +328,8 @@ export function buildMemberLaunchDiagnosticsPayload(params: { normalizeMemberLaunchFailureReason( spawnEntry?.error ?? spawnEntry?.hardFailureReason ?? - spawnEntry?.runtimeDiagnostic ?? - runtimeEntry?.runtimeDiagnostic + spawnRuntimeDiagnosticCardError ?? + runtimeEntryDiagnosticCardError ) ?? undefined ) ?? runtimeAdvisoryCardError; const diagnostics = uniqueDiagnostics( @@ -384,10 +443,9 @@ export function buildMemberLaunchDiagnosticsPayload(params: { ? { rssBytes: boundedNumber(runtimeEntry?.rssBytes) } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), - ...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity) + ...(runtimeDiagnosticSeverity ? { - runtimeDiagnosticSeverity: - spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity, + runtimeDiagnosticSeverity, } : {}), ...(runtimeAdvisory?.kind ? { runtimeAdvisoryKind: runtimeAdvisory.kind } : {}), diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts index 2b407a1c..27c3b75e 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.integration.test.ts @@ -38,6 +38,15 @@ function readControl() { function isUnhealthy() { const control = readControl(); + if (control === 'unhealthy-once:' + port) { + try { + fs.writeFileSync(controlFile, 'healthy'); + } catch {} + return true; + } + if (control === 'unhealthy-pid:' + process.pid) { + return true; + } return control === 'unhealthy-all' || control === 'unhealthy-port:' + port; } @@ -304,11 +313,12 @@ describePosix('AgentTeamsMcpHttpServer integration', () => { const server = createControlledServer({ scriptPath, controlFile }); const first = await server.ensureStarted(); + const warnCountAfterFirstStart = vi.mocked(console.warn).mock.calls.length; const second = await server.ensureStarted(); expect(second).toEqual(first); expect(await readHealthStatus(first.url)).toBe(200); - expect(vi.mocked(console.warn).mock.calls).toEqual([]); + expect(vi.mocked(console.warn).mock.calls.slice(warnCountAfterFirstStart)).toEqual([]); }); it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => { @@ -323,12 +333,13 @@ describePosix('AgentTeamsMcpHttpServer integration', () => { }); const first = await server.ensureStarted(); - await writeFile(controlFile, `unhealthy-port:${first.port}`, 'utf8'); - expect(await readHealthStatus(first.url)).toBe(503); + expect(first.pid).toEqual(expect.any(Number)); + await writeFile(controlFile, `unhealthy-pid:${first.pid}`, 'utf8'); const second = await server.ensureStarted(); - expect(second.port).not.toBe(first.port); + expect(second.port).toBe(first.port); + expect(second.url).toBe(first.url); expect(second.pid).not.toBe(first.pid); expect(await readHealthStatus(second.url)).toBe(200); expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check'); @@ -358,7 +369,7 @@ describePosix('AgentTeamsMcpHttpServer integration', () => { expect(await readHealthStatus(second.url)).toBe(200); }); - it('passes a refreshed MCP URL into a real bridge child process after the cached URL goes stale', async () => { + it('passes a refreshed healthy MCP URL into a real bridge child process after cached health goes stale', async () => { const scriptPath = await writeFakeMcpHttpServer(tempDir!); const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!); const controlFile = path.join(tempDir!, 'health-control.txt'); @@ -404,12 +415,13 @@ describePosix('AgentTeamsMcpHttpServer integration', () => { if (!firstResult.ok) { throw new Error(firstResult.error.message); } + const firstHandle = server.getCurrentHandle(); + expect(firstHandle?.pid).toEqual(expect.any(Number)); await writeFile( controlFile, - `unhealthy-port:${new URL(firstResult.data.observedMcpUrl).port}`, + `unhealthy-pid:${firstHandle?.pid}`, 'utf8' ); - expect(await readHealthStatus(firstResult.data.observedMcpUrl)).toBe(503); const secondResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>( 'opencode.launchTeam', @@ -424,7 +436,7 @@ describePosix('AgentTeamsMcpHttpServer integration', () => { if (!secondResult.ok) { throw new Error(secondResult.error.message); } - expect(secondResult.data.observedMcpUrl).not.toBe(firstResult.data.observedMcpUrl); + expect(secondResult.data.observedMcpUrl).toBe(firstResult.data.observedMcpUrl); expect(await readHealthStatus(secondResult.data.observedMcpUrl)).toBe(200); }); diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts index d4ecb175..46741b4b 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -65,10 +65,23 @@ describe('AgentTeamsMcpHttpServer', () => { const handle = await server.ensureStarted(); - expect(handle).toEqual({ + expect(handle).toMatchObject({ url: 'http://127.0.0.1:41001/mcp', port: 41001, pid: 43123, + generation: 1, + diagnostics: [], + }); + expect(handle.urlHash).toMatch(/^[a-f0-9]{64}$/); + expect(handle.transportEvidence).toMatchObject({ + schemaVersion: 1, + transport: 'httpStream', + host: '127.0.0.1', + port: 41001, + endpoint: '/mcp', + url: 'http://127.0.0.1:41001/mcp', + urlHash: handle.urlHash, + generation: 1, }); expect(spawnProcess).toHaveBeenCalledWith( 'node', @@ -201,20 +214,94 @@ describe('AgentTeamsMcpHttpServer', () => { const second = await server.ensureStarted(); expect(first.url).toBe('http://127.0.0.1:41007/mcp'); - expect(second).toEqual({ - url: 'http://127.0.0.1:41008/mcp', - port: 41008, + expect(second).toMatchObject({ + url: 'http://127.0.0.1:41007/mcp', + port: 41007, pid: 43124, + generation: 2, + diagnostics: ['opencode_app_mcp_restart_reason:health_reuse_failed'], + }); + expect(second.transportEvidence).toMatchObject({ + port: 41007, + url: 'http://127.0.0.1:41007/mcp', + urlHash: second.urlHash, + generation: 2, }); expect(spawnProcess).toHaveBeenCalledTimes(2); + expect(allocatePort).toHaveBeenCalledTimes(1); expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL'); expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000); expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 3_000); - expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41008, 5_000); + expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000); expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check'); + expect(vi.mocked(console.warn).mock.calls[1]?.join(' ')).toContain( + 'opencode_app_mcp_restart_reason:health_reuse_failed' + ); vi.mocked(console.warn).mockClear(); }); + it('falls back without killing unknown processes when the preferred restart port stays occupied', async () => { + const firstChild = new FakeChildProcess(43123); + const secondChild = new FakeChildProcess(43124); + const blocker = net.createServer(); + const blockedPort = await new Promise((resolve, reject) => { + blocker.once('error', reject); + blocker.listen(0, '127.0.0.1', () => { + const address = blocker.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to allocate blocked test port')); + return; + } + resolve(address.port); + }); + }); + const fallbackPort = blockedPort === 65_535 ? blockedPort - 1 : blockedPort + 1; + const spawnProcess = vi + .fn() + .mockReturnValueOnce(firstChild as any) + .mockReturnValueOnce(secondChild as any); + const allocatePort = vi + .fn() + .mockResolvedValueOnce(blockedPort) + .mockResolvedValueOnce(fallbackPort); + const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => { + if (port === blockedPort && timeoutMs === 3_000) { + throw new Error('stale health check'); + } + }); + const server = new AgentTeamsMcpHttpServer({ + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort, + spawnProcess, + waitForPort, + }); + + try { + const first = await server.ensureStarted(); + const second = await server.ensureStarted(); + + expect(first.url).toBe(`http://127.0.0.1:${blockedPort}/mcp`); + expect(second).toMatchObject({ + url: `http://127.0.0.1:${fallbackPort}/mcp`, + port: fallbackPort, + pid: 43124, + generation: 2, + }); + expect(second.diagnostics).toContain('opencode_app_mcp_public_url_changed'); + expect(second.diagnostics).toContain( + `opencode_app_mcp_preferred_port_unavailable:${blockedPort}` + ); + expect(hoisted.killProcessTreeMock).toHaveBeenCalledTimes(1); + expect(allocatePort).toHaveBeenCalledTimes(2); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + vi.mocked(console.warn).mockClear(); + } + }); + it('fails startup promptly when the child exits before readiness', async () => { const child = new FakeChildProcess(); const server = new AgentTeamsMcpHttpServer({ diff --git a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts index 92e258e0..ec2aab58 100644 --- a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts @@ -277,7 +277,7 @@ describe('OpenCodeBridgeCommandContract', () => { ).toEqual({ ok: false, reason: - 'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1. Falling back to observed delivery mode is required.', + `OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version ${OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION}. Falling back to observed delivery mode is required.`, }); server.bridgeProtocol.opencodeDeliveryAcceptanceContractVersion = diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index f7f19172..065353c0 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -5,9 +5,11 @@ import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + buildOpenCodePromptDeliveryAttemptId, createOpenCodePromptDeliveryLedgerStore, hashOpenCodePromptDeliveryPayload, isOpenCodePromptDeliveryAttemptDue, + isOpenCodeSessionRefreshResponseState, } from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; describe('OpenCodePromptDeliveryLedger', () => { @@ -458,6 +460,67 @@ describe('OpenCodePromptDeliveryLedger', () => { expect(retryAccepted.lastRuntimePromptMessageId).toBe('msg_prompt_2'); }); + it('tracks session refresh retries without consuming normal delivery attempts', async () => { + const store = createStore(); + const record = await store.ensurePending({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + inboxMessageId: 'msg-session-stale', + inboxTimestamp: '2026-04-25T09:59:00.000Z', + source: 'watcher', + replyRecipient: 'user', + payloadHash: 'sha256:session-stale', + now: '2026-04-25T10:00:00.000Z', + }); + + expect(buildOpenCodePromptDeliveryAttemptId(record)).toBe( + `${record.id}:1:${record.payloadHash.slice(0, 12)}` + ); + + const stale = await store.applyDeliveryResult({ + id: record.id, + accepted: false, + attempted: true, + responseObservation: { + state: 'session_stale', + deliveredUserMessageId: null, + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'resolved_behavior_changed:old->new', + }, + diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'], + now: '2026-04-25T10:00:05.000Z', + }); + + expect(stale.attempts).toBe(0); + expect(stale.responseState).toBe('session_stale'); + expect(stale.lastSessionRefreshReason).toBe('resolved_behavior_changed:old->new'); + + const scheduled = await store.markSessionRefreshScheduled({ + id: record.id, + nextAttemptAt: '2026-04-25T10:00:10.000Z', + reason: 'resolved_behavior_changed:old->new', + scheduledAt: '2026-04-25T10:00:06.000Z', + }); + + expect(scheduled.status).toBe('retry_scheduled'); + expect(scheduled.attempts).toBe(0); + expect(scheduled.sessionRefreshAttempts).toBe(1); + expect(buildOpenCodePromptDeliveryAttemptId(scheduled)).toBe( + `${record.id}:1:${record.payloadHash.slice(0, 12)}:refresh1` + ); + expect( + isOpenCodeSessionRefreshResponseState({ + reason: 'opencode_app_mcp_transport_changed:old->new', + }) + ).toBe(true); + }); + it('keeps schema-1 legacy prompt-id fields compatible and normalizes when touched', async () => { const store = createStore(); const legacy = await store.ensurePending({ diff --git a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts index 7528b4e9..f895e792 100644 --- a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts @@ -40,6 +40,22 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => { expect(decision.controlText).toContain('relayOfMessageId="msg-1"'); }); + it('keeps no-assistant response repair available for a bounded max-attempt recovery retry', () => { + const decision = decideOpenCodePromptDeliveryRepair( + base({ + status: 'retry_scheduled', + attempts: 3, + maxAttempts: 3, + responseState: 'prompt_delivered_no_assistant_message', + pendingReason: 'prompt_delivered_no_assistant_message', + }) + ); + + expect(decision.kind).toBe('no_assistant_response'); + expect(decision.retryable).toBe(true); + expect(decision.controlText).toContain('You must not end this turn empty.'); + }); + it('requires member work sync status and report for work-sync nudges', () => { const decision = decideOpenCodePromptDeliveryRepair( base({ diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index fc803758..dae44327 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -366,6 +366,106 @@ describe('OpenCodeReadinessBridge', () => { }); }); + it('does not fall back to observed mode when forced session refresh contract is missing', async () => { + const execute = vi.fn().mockRejectedValueOnce( + new Error( + 'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 2.' + ) + ); + const executor = { + execute: execute as unknown as OpenCodeReadinessBridgeCommandExecutor['execute'] & + ReturnType, + }; + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.sendOpenCodeTeamMessage({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new', + settlementMode: 'acceptance', + }) + ).resolves.toMatchObject({ + accepted: false, + memberName: 'bob', + responseObservation: { + state: 'session_stale', + }, + diagnostics: [ + expect.objectContaining({ + code: 'opencode_force_session_refresh_contract_missing', + severity: 'error', + }), + ], + }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0]?.[1]).toMatchObject({ + settlementMode: 'acceptance', + forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new', + }); + }); + + it('includes forced session refresh reason in send payload hash', async () => { + const executor = fakeSequenceExecutor([ + bridgeCommandSuccess({ + command: 'opencode.sendMessage', + requestId: 'send-req-1', + data: { + accepted: true, + memberName: 'bob', + sessionId: 'session-bob-1', + diagnostics: [], + }, + }), + bridgeCommandSuccess({ + command: 'opencode.sendMessage', + requestId: 'send-req-2', + data: { + accepted: true, + memberName: 'bob', + sessionId: 'session-bob-2', + diagnostics: [], + }, + }), + ]); + const bridge = new OpenCodeReadinessBridge(executor); + const base = { + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + }; + + await bridge.sendOpenCodeTeamMessage(base); + await bridge.sendOpenCodeTeamMessage({ + ...base, + forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new', + }); + + const firstBody = executor.execute.mock.calls[0]?.[1] as { payloadHash?: string }; + const secondBody = executor.execute.mock.calls[1]?.[1] as { + payloadHash?: string; + forceSessionRefreshReason?: string; + }; + expect(secondBody.forceSessionRefreshReason).toBe( + 'opencode_app_mcp_transport_changed:old->new' + ); + expect(firstBody.payloadHash).toEqual(expect.any(String)); + expect(secondBody.payloadHash).toEqual(expect.any(String)); + expect(secondBody.payloadHash).not.toBe(firstBody.payloadHash); + }); + it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus by default', async () => { const executor = fakeSequenceExecutor([ bridgeFailure('timeout', 'OpenCode bridge command timed out', []), @@ -754,6 +854,54 @@ describe('OpenCodeReadinessBridge', () => { ); }); + it('does not use observed guarded fallback when forced session refresh contract is missing', async () => { + const executor = fakeExecutor( + bridgeFailure('internal_error', 'direct observed bridge must not run', []) + ); + const stateChangingExecute = vi + .fn() + .mockResolvedValueOnce( + bridgeCommandFailure({ + command: 'opencode.sendMessage', + requestId: 'guarded-send-acceptance', + kind: 'internal_error', + message: + 'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 2.', + }) + ); + const bridge = new OpenCodeReadinessBridge(executor, { + stateChangingCommands: { execute: stateChangingExecute }, + }); + + await expect( + bridge.sendOpenCodeTeamMessage({ + runId: 'run-1', + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + text: 'hello', + messageId: 'message-1', + deliveryAttemptId: 'ledger-1:1:payload', + forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new', + settlementMode: 'acceptance', + }) + ).resolves.toMatchObject({ + accepted: false, + responseObservation: { state: 'session_stale' }, + diagnostics: [ + expect.objectContaining({ + code: 'opencode_force_session_refresh_contract_missing', + severity: 'error', + }), + ], + }); + + expect(stateChangingExecute).toHaveBeenCalledTimes(1); + expect(executor.execute).not.toHaveBeenCalled(); + }); + it('routes state-changing launch commands through the guarded command service when configured', async () => { const executor = fakeExecutor( bridgeFailure('internal_error', 'direct bridge must not run', []) diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts index cffb1ce0..d9970f35 100644 --- a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts @@ -66,6 +66,41 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => { ); }); + it('formats resolved behavior changes as recoverable session refresh state', () => { + const record = { + diagnostics: [ + 'OpenCode session reconcile skipped because the stored session is stale', + 'resolved_behavior_changed:old->new', + ], + lastReason: 'resolved_behavior_changed:old->new', + responseState: 'session_stale', + status: 'retry_scheduled', + } as Parameters[0]; + + expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe( + 'OpenCode session changed; refreshing the session before retry.' + ); + expect( + isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record)) + ).toBe(false); + }); + + it('formats app MCP transport changes as recoverable session refresh state', () => { + const record = { + diagnostics: ['opencode_app_mcp_transport_changed:old->new'], + lastReason: 'opencode_app_mcp_transport_changed:old->new', + responseState: 'session_stale', + status: 'retry_scheduled', + } as Parameters[0]; + + expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe( + 'OpenCode session changed; refreshing the session before retry.' + ); + expect( + isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record)) + ).toBe(false); + }); + it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => { const record = { diagnostics: [ diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 88506cd6..4e4584da 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -531,6 +531,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { messageId: 'msg-1', replyRecipient: 'alice', actionMode: 'delegate', + forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new', taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }], }) ).resolves.toEqual({ @@ -553,6 +554,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { messageId: 'msg-1', settlementMode: 'acceptance', actionMode: 'delegate', + forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new', taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }], agent: 'teammate', }); diff --git a/test/main/services/team/RuntimeDiagnosticClassifier.test.ts b/test/main/services/team/RuntimeDiagnosticClassifier.test.ts index 778e49ed..ae5be9c2 100644 --- a/test/main/services/team/RuntimeDiagnosticClassifier.test.ts +++ b/test/main/services/team/RuntimeDiagnosticClassifier.test.ts @@ -144,4 +144,21 @@ describe('RuntimeDiagnosticClassifier', () => { generic: true, }); }); + + it('classifies resolved OpenCode behavior changes as recoverable generic refresh state', () => { + expect(classifyRuntimeDiagnostic('resolved_behavior_changed:old->new')).toMatchObject({ + reasonCode: 'backend_error', + normalizedMessage: 'OpenCode session changed; refreshing the session before retry.', + generic: true, + actionRequired: false, + }); + expect( + classifyRuntimeDiagnostic('opencode_app_mcp_transport_changed:old->new') + ).toMatchObject({ + reasonCode: 'backend_error', + normalizedMessage: 'OpenCode session changed; refreshing the session before retry.', + generic: true, + actionRequired: false, + }); + }); }); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 61dffec8..6296158d 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -39,9 +39,11 @@ import { setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; +import { agentTeamsMcpHttpServer } from '../../../../src/main/services/team/AgentTeamsMcpHttpServer'; import { getOpenCodeRuntimeManifestPath, getOpenCodeRuntimeLaneIndexPath, + readCommittedOpenCodeBootstrapSessionEvidence, readOpenCodeRuntimeLaneIndex, setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, @@ -10229,7 +10231,7 @@ describe('Team agent launch matrix safe e2e', () => { addGeminiPrimaryToMixedRun(currentRun); staleRun.runId = `run-${teamName}-stale`; currentRun.runId = `run-${teamName}-current`; - markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob'); + await markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob'); trackLiveRun(svc, staleRun); trackLiveRun(svc, currentRun); @@ -10257,6 +10259,89 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs[0]?.runId).not.toBe(staleRun.runId); }); + it('refreshes stale mixed OpenCode secondary session evidence before direct delivery when MCP transport changed', async () => { + const teamName = 'mixed-opencode-secondary-transport-refresh-safe-e2e'; + await writeMixedTeamConfig({ + teamName, + projectPath, + includeGeminiPrimary: true, + primaryProviderId: 'anthropic', + }); + await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' }); + await writeMembersMeta(teamName, { + includeGeminiPrimary: true, + primaryProviderId: 'anthropic', + }); + const adapter = new FakeOpenCodeRuntimeAdapter(); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + addGeminiPrimaryToMixedRun(run); + run.runId = `run-${teamName}-current`; + await markMixedOpenCodeLaneConfirmedForTest(run, 'bob', { + sessionId: 'oc-session-bob-stale-mixed-transport', + appMcpTransportHash: 'old-mixed-safe-e2e-transport-hash', + }); + trackLiveRun(svc, run); + + const transportSpy = vi + .spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle') + .mockReturnValue({ + url: 'http://127.0.0.1:43126/mcp', + port: 43126, + child: { pid: 43126 }, + generation: 5, + urlHash: 'current-mixed-safe-e2e-transport-hash', + transportEvidence: { + schemaVersion: 1, + transport: 'httpStream', + host: '127.0.0.1', + port: 43126, + endpoint: '/mcp', + url: 'http://127.0.0.1:43126/mcp', + urlHash: 'current-mixed-safe-e2e-transport-hash', + generation: 5, + observedAt: '2026-04-23T10:00:00.000Z', + }, + diagnostics: [], + } as any); + + try { + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'refresh stale mixed transport before opencode send', + messageId: 'msg-mixed-transport-refresh-safe-e2e', + }) + ).resolves.toEqual({ + delivered: true, + diagnostics: [], + }); + } finally { + transportSpy.mockRestore(); + } + + expect(adapter.messageInputs).toHaveLength(1); + expect(adapter.messageInputs[0]).toMatchObject({ + runId: run.runId, + teamName, + laneId: 'secondary:opencode:bob', + memberName: 'bob', + forceSessionRefreshReason: + 'opencode_app_mcp_transport_changed:old-mixed-safe-e2e-transport-hash->current-mixed-safe-e2e-transport-hash', + }); + + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:bob', + }); + expect(evidence.sessions[0]).toMatchObject({ + id: 'session-bob', + appMcpTransportHash: 'current-mixed-safe-e2e-transport-hash', + }); + }); + it('routes direct OpenCode member messages only to the targeted live mixed OpenCode lane', async () => { const firstTeamName = 'mixed-opencode-direct-message-live-team-a-safe-e2e'; const secondTeamName = 'mixed-opencode-direct-message-live-team-b-safe-e2e'; @@ -10299,7 +10384,7 @@ describe('Team agent launch matrix safe e2e', () => { addGeminiPrimaryToMixedRun(secondRun); firstRun.child = { stdin: { writable: true } }; secondRun.child = { stdin: { writable: true } }; - markMixedOpenCodeLaneConfirmedForTest(secondRun, 'bob'); + await markMixedOpenCodeLaneConfirmedForTest(secondRun, 'bob'); trackLiveRun(svc, firstRun); trackLiveRun(svc, secondRun); @@ -10590,7 +10675,7 @@ describe('Team agent launch matrix safe e2e', () => { const currentRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); addGeminiPrimaryToMixedRun(currentRun); currentRun.runId = `run-${teamName}-current`; - markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob'); + await markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob'); trackLiveRun(svc, currentRun); injectStaleTerminalProvisioningRun(svc, teamName, `run-${teamName}-stale`); @@ -10669,6 +10754,90 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId); }); + it('refreshes stale OpenCode session evidence before direct delivery when MCP transport changed', async () => { + const teamName = 'pure-opencode-direct-message-transport-refresh-safe-e2e'; + const adapter = new FakeOpenCodeRuntimeAdapter(); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + await writeOpenCodeBootstrapSessionEvidenceForTest({ + teamName, + laneId: 'primary', + runId: launch.runId, + memberName: 'alice', + sessionId: 'oc-session-alice-stale-transport', + appMcpTransportHash: 'old-safe-e2e-transport-hash', + }); + + const transportSpy = vi + .spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle') + .mockReturnValue({ + url: 'http://127.0.0.1:43125/mcp', + port: 43125, + child: { pid: 43125 }, + generation: 4, + urlHash: 'current-safe-e2e-transport-hash', + transportEvidence: { + schemaVersion: 1, + transport: 'httpStream', + host: '127.0.0.1', + port: 43125, + endpoint: '/mcp', + url: 'http://127.0.0.1:43125/mcp', + urlHash: 'current-safe-e2e-transport-hash', + generation: 4, + observedAt: '2026-04-23T10:00:00.000Z', + }, + diagnostics: [], + } as any); + + try { + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'refresh stale transport before pure opencode send', + messageId: 'msg-transport-refresh-safe-e2e', + }) + ).resolves.toEqual({ + delivered: true, + diagnostics: [], + }); + } finally { + transportSpy.mockRestore(); + } + + expect(adapter.messageInputs).toHaveLength(1); + expect(adapter.messageInputs[0]).toMatchObject({ + runId: launch.runId, + teamName, + laneId: 'primary', + memberName: 'alice', + forceSessionRefreshReason: + 'opencode_app_mcp_transport_changed:old-safe-e2e-transport-hash->current-safe-e2e-transport-hash', + }); + + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + }); + expect(evidence.sessions[0]).toMatchObject({ + id: 'session-alice', + appMcpTransportHash: 'current-safe-e2e-transport-hash', + }); + }); + it('routes direct OpenCode member messages only to the targeted live pure OpenCode team', async () => { const firstTeamName = 'pure-opencode-direct-message-live-team-a-safe-e2e'; const secondTeamName = 'pure-opencode-direct-message-live-team-b-safe-e2e'; @@ -13265,7 +13434,7 @@ describe('Team agent launch matrix safe e2e', () => { svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); addGeminiPrimaryToMixedRun(run); - markMixedOpenCodeLaneConfirmedForTest(run, 'tom'); + await markMixedOpenCodeLaneConfirmedForTest(run, 'tom'); trackLiveRun(svc, run); await expect( @@ -13321,7 +13490,7 @@ describe('Team agent launch matrix safe e2e', () => { svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); addGeminiPrimaryToMixedRun(run); - markMixedOpenCodeLaneConfirmedForTest(run, 'bob'); + await markMixedOpenCodeLaneConfirmedForTest(run, 'bob'); trackLiveRun(svc, run); await expect( @@ -18457,6 +18626,7 @@ async function writeOpenCodeBootstrapSessionEvidenceForTest(input: { runId?: string | null; memberName?: string; sessionId?: string; + appMcpTransportHash?: string; }): Promise { const runId = input.runId ?? null; const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find( @@ -18507,6 +18677,9 @@ async function writeOpenCodeBootstrapSessionEvidenceForTest(input: { runId, observedAt: '2026-04-23T10:00:00.000Z', source: 'runtime_bootstrap_checkin', + ...(input.appMcpTransportHash + ? { appMcpTransportHash: input.appMcpTransportHash } + : {}), }, ], }, @@ -18686,9 +18859,17 @@ function createMixedLiveRun(input: { }; } -function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): void { +async function markMixedOpenCodeLaneConfirmedForTest( + run: any, + memberName: string, + options: { + sessionId?: string; + appMcpTransportHash?: string; + } = {} +): Promise { const now = '2026-04-23T10:00:00.000Z'; const laneId = `secondary:opencode:${memberName}`; + const sessionId = options.sessionId ?? `session-${memberName}`; const lane = run.mixedSecondaryLanes?.find((candidate: any) => candidate.laneId === laneId); if (!lane) { throw new Error(`Missing mixed OpenCode lane fixture for ${memberName}`); @@ -18709,7 +18890,7 @@ function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): vo runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, - sessionId: `session-${memberName}`, + sessionId, runtimePid: 10_000, livenessKind: 'confirmed_bootstrap', pidSource: 'opencode_bridge', @@ -18720,6 +18901,14 @@ function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): vo warnings: [], diagnostics: ['fake OpenCode launch ready'], }; + await writeOpenCodeBootstrapSessionEvidenceForTest({ + teamName: run.teamName, + laneId, + runId: run.runId, + memberName, + sessionId, + appMcpTransportHash: options.appMcpTransportHash, + }); } function removeMixedOpenCodeLaneForTest(run: any, memberName: string): void { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 3abfc230..ef39084b 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -132,6 +132,7 @@ import { getMixedLaunchFallbackRecoveryError, TeamProvisioningService, } from '@main/services/team/TeamProvisioningService'; +import { agentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer'; import { TeamTaskActivityIntervalService } from '@main/services/team/TeamTaskActivityIntervalService'; import { clearAutoResumeService, @@ -148,10 +149,13 @@ import { getTeamLaunchSummaryPath, } from '@main/services/team/TeamLaunchStateStore'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; +import { OPEN_CODE_BRIDGE_SCHEMA_VERSION } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; +import { OpenCodeReadinessBridge } from '@main/services/team/opencode/bridge/OpenCodeReadinessBridge'; import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeRuntimeManifestPath, OpenCodeRuntimeManifestEvidenceReader, + readCommittedOpenCodeBootstrapSessionEvidence, readOpenCodeRuntimeLaneIndex, setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, @@ -164,6 +168,7 @@ import { RuntimeStoreBatchWriter, } from '@main/services/team/opencode/store/RuntimeStoreManifest'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { OpenCodeTeamRuntimeAdapter } from '@main/services/team/runtime/OpenCodeTeamRuntimeAdapter'; import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter'; import { spawnCli } from '@main/utils/childProcess'; import { killProcessByPid } from '@main/utils/processKill'; @@ -448,6 +453,7 @@ async function writeCommittedOpenCodeSessionStore(input: { teamName: string; laneId: string; runId: string; + batchKey?: string; sessions: unknown[]; }): Promise { const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find( @@ -465,8 +471,9 @@ async function writeCommittedOpenCodeSessionStore(input: { }), { clock: () => new Date('2026-04-22T12:00:00.000Z'), - batchIdFactory: () => `batch-${input.runId}`, - receiptIdFactory: () => `receipt-${input.runId}`, + batchIdFactory: () => `batch-${input.runId}${input.batchKey ? `-${input.batchKey}` : ''}`, + receiptIdFactory: () => + `receipt-${input.runId}${input.batchKey ? `-${input.batchKey}` : ''}`, } ); await writer.writeBatch({ @@ -766,6 +773,133 @@ describe('TeamProvisioningService', () => { }) ).toEqual({ state: 'none' }); }); + + it('schedules one bounded recovery retry before terminalizing no-assistant OpenCode delivery', async () => { + const svc = new TeamProvisioningService(); + (svc as any).scheduleOpenCodePromptDeliveryWatchdog = vi.fn(); + const record = { + id: 'opencode-prompt:test', + teamName: 'team-a', + memberName: 'atlas', + laneId: 'secondary:opencode:atlas', + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'msg-1', + inboxTimestamp: '2026-05-18T08:31:00.000Z', + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'accepted', + responseState: 'prompt_delivered_no_assistant_message', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-05-18T08:31:30.000Z', + lastObservedAt: '2026-05-18T08:31:45.000Z', + acceptedAt: '2026-05-18T08:31:30.000Z', + respondedAt: null, + failedAt: null, + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'delivered-1', + observedAssistantMessageId: null, + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'prompt_delivered_no_assistant_message', + diagnostics: ['prompt_delivered_no_assistant_message'], + createdAt: '2026-05-18T08:31:00.000Z', + updatedAt: '2026-05-18T08:31:45.000Z', + }; + const ledger = { + markFailedTerminal: vi.fn(), + markNextAttemptScheduled: vi.fn(async (input: any) => ({ + ...record, + status: input.status, + nextAttemptAt: input.nextAttemptAt, + lastReason: input.reason, + updatedAt: input.scheduledAt, + })), + }; + + const nextRecord = await (svc as any).scheduleOpenCodePromptLedgerFollowUp({ + ledger, + ledgerRecord: record, + teamName: 'team-a', + memberName: 'atlas', + retry: true, + reason: 'prompt_delivered_no_assistant_message', + }); + + expect(ledger.markFailedTerminal).not.toHaveBeenCalled(); + expect(ledger.markNextAttemptScheduled).toHaveBeenCalledWith( + expect.objectContaining({ + id: record.id, + status: 'retry_scheduled', + reason: 'prompt_delivered_no_assistant_message', + }) + ); + expect(nextRecord.status).toBe('retry_scheduled'); + }); + + it('does not requeue terminal no-assistant delivery after the bounded recovery retry is exhausted', async () => { + const svc = new TeamProvisioningService(); + const record = { + status: 'failed_terminal', + responseState: 'prompt_delivered_no_assistant_message', + attempts: 4, + maxAttempts: 3, + inboxReadCommittedAt: null, + lastReason: 'prompt_delivered_no_assistant_message', + diagnostics: ['prompt_delivered_no_assistant_message'], + }; + const ledger = { + markNextAttemptScheduled: vi.fn(), + }; + + const nextRecord = await (svc as any).requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded({ + ledger, + ledgerRecord: record, + }); + + expect(nextRecord).toBe(record); + expect(ledger.markNextAttemptScheduled).not.toHaveBeenCalled(); + }); + + it('does not requeue terminal no-assistant delivery when diagnostics contain action-required provider errors', async () => { + const svc = new TeamProvisioningService(); + const record = { + status: 'failed_terminal', + responseState: 'empty_assistant_turn', + attempts: 3, + maxAttempts: 3, + inboxReadCommittedAt: null, + lastReason: 'empty_assistant_turn', + diagnostics: [ + 'Insufficient credits. Add more using https://openrouter.ai/settings/credits', + ], + }; + const ledger = { + markNextAttemptScheduled: vi.fn(), + }; + + const nextRecord = await (svc as any).requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded({ + ledger, + ledgerRecord: record, + }); + + expect(nextRecord).toBe(record); + expect(ledger.markNextAttemptScheduled).not.toHaveBeenCalled(); + }); }); describe('team launch notifications', () => { @@ -7375,6 +7509,8 @@ describe('TeamProvisioningService', () => { ).resolves.toMatchObject({ delivered: true, responsePending: true, + responseState: 'session_stale', + ledgerStatus: 'retry_scheduled', }); expect(observeMessageDelivery).toHaveBeenCalledTimes(1); @@ -7385,6 +7521,38 @@ describe('TeamProvisioningService', () => { prePromptCursor: 'cursor-1', }) ); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + + const scheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array<{ + nextAttemptAt: string | null; + sessionRefreshAttempts?: number; + attempts: number; + maxAttempts: number; + }>; + }; + expect(scheduledEnvelope.data[0]).toMatchObject({ + attempts: 1, + maxAttempts: 3, + sessionRefreshAttempts: 1, + }); + scheduledEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z'; + await fsPromises.writeFile(ledgerPath, JSON.stringify(scheduledEnvelope, null, 2), 'utf8'); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-stale-session', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'pending', + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(2); expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({ runId: 'opencode-run-bob', @@ -7393,6 +7561,581 @@ describe('TeamProvisioningService', () => { memberName: 'bob', cwd: '/repo', messageId: 'msg-stale-session', + deliveryAttemptId: expect.stringContaining(':refresh1'), + }); + }); + + it('forces an OpenCode session refresh before send when app MCP transport evidence changed', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob-refreshed', + runtimePromptMessageId: 'msg_prompt_refreshed', + prePromptCursor: 'cursor-refreshed', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'oc-user-refreshed', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + await writeCommittedOpenCodeSessionStore({ + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + batchKey: 'transport-refresh', + sessions: [ + { + id: 'oc-session-bob', + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + source: 'app_managed_bootstrap', + appMcpTransportHash: 'old-transport-hash', + }, + ], + }); + + const currentTransportEvidence = { + schemaVersion: 1, + transport: 'httpStream', + host: '127.0.0.1', + port: 43123, + endpoint: '/mcp', + url: 'http://127.0.0.1:43123/mcp', + urlHash: 'current-transport-hash', + generation: 2, + observedAt: '2026-04-25T10:00:00.000Z', + }; + const transportSpy = vi + .spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle') + .mockReturnValue({ + url: currentTransportEvidence.url, + port: currentTransportEvidence.port, + child: { pid: 43123 }, + generation: currentTransportEvidence.generation, + urlHash: currentTransportEvidence.urlHash, + transportEvidence: currentTransportEvidence, + diagnostics: [], + } as any); + + try { + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello refreshed bob', + messageId: 'msg-refresh-transport', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'pending', + }); + } finally { + transportSpy.mockRestore(); + } + + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(sendMessageToMember.mock.calls[0]?.[0]).toMatchObject({ + runId: 'opencode-run-bob', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + forceSessionRefreshReason: + 'opencode_app_mcp_transport_changed:old-transport-hash->current-transport-hash', + }); + + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + }); + expect(evidence.sessions[0]).toMatchObject({ + id: 'oc-session-bob-refreshed', + appMcpTransportHash: 'current-transport-hash', + }); + }); + + it('routes app MCP transport mismatch through the production OpenCode adapter and bridge command', async () => { + const svc = new TeamProvisioningService(); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember: vi.fn() }); + await writeCommittedOpenCodeSessionStore({ + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + batchKey: 'production-adapter-refresh', + sessions: [ + { + id: 'oc-session-bob', + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + source: 'app_managed_bootstrap', + appMcpTransportHash: 'old-production-transport-hash', + }, + ], + }); + + const currentTransportEvidence = { + schemaVersion: 1, + transport: 'httpStream', + host: '127.0.0.1', + port: 43124, + endpoint: '/mcp', + url: 'http://127.0.0.1:43124/mcp', + urlHash: 'current-production-transport-hash', + generation: 3, + observedAt: '2026-04-25T10:00:00.000Z', + }; + const transportSpy = vi + .spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle') + .mockReturnValue({ + url: currentTransportEvidence.url, + port: currentTransportEvidence.port, + child: { pid: 43124 }, + generation: currentTransportEvidence.generation, + urlHash: currentTransportEvidence.urlHash, + transportEvidence: currentTransportEvidence, + diagnostics: [], + } as any); + const directBridgeExecute = vi.fn(async () => { + throw new Error('direct OpenCode bridge executor should not be used for acceptance send'); + }); + const stateChangingExecute = vi.fn(async (input: { + command: string; + body: Record; + }) => ({ + ok: true as const, + schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION, + requestId: 'send-refresh-command', + command: input.command as any, + completedAt: '2026-04-25T10:00:01.000Z', + durationMs: 10, + runtime: { + providerId: 'opencode' as const, + binaryPath: '/opt/homebrew/bin/opencode', + binaryFingerprint: 'test-opencode-binary', + version: '1.0.0', + capabilitySnapshotId: 'test-capability-snapshot', + }, + diagnostics: [], + data: { + accepted: true, + memberName: 'bob', + sessionId: 'oc-session-bob-production-refresh', + runtimePid: 456, + runtimePromptMessageId: 'msg_prompt_production_refresh', + prePromptCursor: 'cursor-production-refresh', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'oc-user-production-refresh', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + }, + })); + const productionBridge = new OpenCodeReadinessBridge( + { execute: directBridgeExecute }, + { stateChangingCommands: { execute: stateChangingExecute as any } } + ); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(productionBridge)]) + ); + + try { + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello production bob', + messageId: 'msg-production-refresh-transport', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'pending', + }); + } finally { + transportSpy.mockRestore(); + } + + expect(directBridgeExecute).not.toHaveBeenCalled(); + expect(stateChangingExecute).toHaveBeenCalledTimes(1); + const commandInput = stateChangingExecute.mock.calls[0]?.[0] as { + command: string; + teamName: string; + laneId: string; + runId: string; + cwd: string; + body: Record; + }; + expect(commandInput).toMatchObject({ + command: 'opencode.sendMessage', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + cwd: '/repo', + }); + expect(commandInput.body).toMatchObject({ + runId: 'opencode-run-bob', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + projectPath: '/repo', + memberName: 'bob', + messageId: 'msg-production-refresh-transport', + settlementMode: 'acceptance', + forceSessionRefreshReason: + 'opencode_app_mcp_transport_changed:old-production-transport-hash->current-production-transport-hash', + }); + expect(commandInput.body.payloadHash).toEqual(expect.any(String)); + expect(commandInput.body.deliveryAttemptId).toEqual(expect.any(String)); + expect(String(commandInput.body.text)).toContain('hello production bob'); + + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + }); + expect(evidence.sessions[0]).toMatchObject({ + id: 'oc-session-bob-production-refresh', + appMcpTransportHash: 'current-production-transport-hash', + }); + }); + + it('stamps current transport evidence when forced refresh succeeds with the same OpenCode session id', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + runtimePromptMessageId: 'msg_prompt_same_session_refresh', + prePromptCursor: 'cursor-same-session-refresh', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'oc-user-same-session-refresh', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + await writeCommittedOpenCodeSessionStore({ + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + batchKey: 'same-session-transport-refresh', + sessions: [ + { + id: 'oc-session-bob', + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + source: 'app_managed_bootstrap', + appMcpTransportHash: 'old-same-session-transport-hash', + }, + ], + }); + + const currentTransportEvidence = { + schemaVersion: 1, + transport: 'httpStream', + host: '127.0.0.1', + port: 43128, + endpoint: '/mcp', + url: 'http://127.0.0.1:43128/mcp', + urlHash: 'current-same-session-transport-hash', + generation: 7, + observedAt: '2026-04-25T10:00:00.000Z', + }; + const transportSpy = vi + .spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle') + .mockReturnValue({ + url: currentTransportEvidence.url, + port: currentTransportEvidence.port, + child: { pid: 43128 }, + generation: currentTransportEvidence.generation, + urlHash: currentTransportEvidence.urlHash, + transportEvidence: currentTransportEvidence, + diagnostics: [], + } as any); + + try { + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello same session refresh bob', + messageId: 'msg-same-session-refresh-transport', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'pending', + }); + } finally { + transportSpy.mockRestore(); + } + + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + forceSessionRefreshReason: + 'opencode_app_mcp_transport_changed:old-same-session-transport-hash->current-same-session-transport-hash', + }) + ); + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + }); + expect(evidence.sessions[0]).toMatchObject({ + id: 'oc-session-bob', + appMcpTransportHash: 'current-same-session-transport-hash', + }); + }); + + it('fails closed through the delivery ledger when forced refresh reaches an old OpenCode bridge contract', async () => { + const svc = new TeamProvisioningService(); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember: vi.fn() }); + await writeCommittedOpenCodeSessionStore({ + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + batchKey: 'production-adapter-refresh-contract-missing', + sessions: [ + { + id: 'oc-session-bob', + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + runId: 'opencode-run-bob', + source: 'app_managed_bootstrap', + appMcpTransportHash: 'old-contract-transport-hash', + }, + ], + }); + + const currentTransportEvidence = { + schemaVersion: 1, + transport: 'httpStream', + host: '127.0.0.1', + port: 43127, + endpoint: '/mcp', + url: 'http://127.0.0.1:43127/mcp', + urlHash: 'current-contract-transport-hash', + generation: 6, + observedAt: '2026-04-25T10:00:00.000Z', + }; + const transportSpy = vi + .spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle') + .mockReturnValue({ + url: currentTransportEvidence.url, + port: currentTransportEvidence.port, + child: { pid: 43127 }, + generation: currentTransportEvidence.generation, + urlHash: currentTransportEvidence.urlHash, + transportEvidence: currentTransportEvidence, + diagnostics: [], + } as any); + const directBridgeExecute = vi.fn(async () => { + throw new Error('direct OpenCode bridge executor should not be used for acceptance send'); + }); + const stateChangingExecute = vi.fn(async (input: { command: string }) => ({ + ok: false as const, + schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION, + requestId: 'send-refresh-contract-missing', + command: input.command as any, + completedAt: '2026-04-25T10:00:01.000Z', + durationMs: 10, + error: { + kind: 'contract_violation' as const, + message: + 'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 2.', + retryable: false, + }, + diagnostics: [], + })); + const productionBridge = new OpenCodeReadinessBridge( + { execute: directBridgeExecute }, + { stateChangingCommands: { execute: stateChangingExecute as any } } + ); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(productionBridge)]) + ); + + try { + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello old contract bob', + messageId: 'msg-production-refresh-contract-missing', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: false, + accepted: false, + responsePending: false, + responseState: 'session_stale', + ledgerStatus: 'retry_scheduled', + diagnostics: expect.arrayContaining([ + expect.stringContaining( + 'OpenCode forced session refresh requires delivery acceptance contract version 2' + ), + 'opencode_session_refresh_scheduled_after_resolved_behavior_changed', + ]), + }); + } finally { + transportSpy.mockRestore(); + } + + expect(directBridgeExecute).not.toHaveBeenCalled(); + expect(stateChangingExecute).toHaveBeenCalledTimes(1); + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + }); + expect(evidence.sessions[0]).toMatchObject({ + id: 'oc-session-bob', + appMcpTransportHash: 'old-contract-transport-hash', + }); + }); + + it('stops repeated OpenCode session refresh loops at the refresh cap', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + runtimePromptMessageId: 'msg_prompt_refresh_cap', + prePromptCursor: 'cursor-refresh-cap', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'oc-user-refresh-cap', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + })); + const observeMessageDelivery = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'session_stale', + deliveredUserMessageId: null, + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'resolved_behavior_changed:old->new', + }, + diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'], + })); + await configureOpenCodeBobDeliveryService({ + svc, + sendMessageToMember, + observeMessageDelivery, + }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-stale-session-cap', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'pending', + }); + + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + fileName: 'opencode-prompt-delivery-ledger.json', + }); + const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array>; + }; + Object.assign(ledgerEnvelope.data[0], { + status: 'accepted', + responseState: 'session_stale', + nextAttemptAt: '2000-01-01T00:00:00.000Z', + lastReason: 'resolved_behavior_changed:old->new', + sessionRefreshAttempts: 5, + maxSessionRefreshAttempts: 5, + }); + await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8'); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-stale-session-cap', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: false, + accepted: true, + responsePending: false, + responseState: 'session_stale', + ledgerStatus: 'failed_terminal', + reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed', + }); + + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledTimes(1); + const terminalEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array>; + }; + expect(terminalEnvelope.data[0]).toMatchObject({ + status: 'failed_terminal', + attempts: 1, + sessionRefreshAttempts: 5, + maxSessionRefreshAttempts: 5, + lastReason: 'opencode_session_refresh_loop_after_resolved_behavior_changed', }); }); @@ -9696,7 +10439,7 @@ describe('TeamProvisioningService', () => { }); }); - it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => { + it('marks OpenCode delivery terminal after bounded recovery instead of leaving it pending', async () => { const svc = new TeamProvisioningService(); const emptyResponseObservation = { state: 'empty_assistant_turn' as const, @@ -9811,6 +10554,15 @@ describe('TeamProvisioningService', () => { responseState: 'empty_assistant_turn', }); await forceDue(); + await expect(deliver()).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'empty_assistant_turn', + ledgerStatus: 'retry_scheduled', + reason: 'empty_assistant_turn', + }); + await forceDue(); await expect(deliver()).resolves.toMatchObject({ delivered: false, accepted: true, @@ -9850,8 +10602,8 @@ describe('TeamProvisioningService', () => { visibleReplyMessageId: 'reply-after-terminal', visibleReplyCorrelation: 'relayOfMessageId', }); - expect(sendMessageToMember).toHaveBeenCalledTimes(3); - expect(observeMessageDelivery).toHaveBeenCalledTimes(2); + expect(sendMessageToMember).toHaveBeenCalledTimes(4); + expect(observeMessageDelivery).toHaveBeenCalledTimes(3); }); it('queues newer OpenCode deliveries behind one active unresolved member delivery', async () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index f0582fac..17164503 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -2508,8 +2508,15 @@ Messages: vi.spyOn(service as any, 'getCurrentOpenCodeRuntimeRunId').mockReturnValue('opencode-run-1'); vi.spyOn( service as any, - 'hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence' - ).mockResolvedValue(true); + 'findDeliverableOpenCodeRuntimeBootstrapSessionEvidence' + ).mockResolvedValue({ + id: 'session-jack', + teamName, + memberName: 'jack', + laneId, + runId: 'opencode-run-1', + source: 'runtime_bootstrap_checkin', + }); vi.spyOn(service as any, 'applyOpenCodeVisibleDestinationProof').mockImplementation( async (input: any) => ({ ledgerRecord: input.ledgerRecord, diff --git a/test/renderer/utils/memberLaunchDiagnostics.test.ts b/test/renderer/utils/memberLaunchDiagnostics.test.ts index 2532067f..1ae632b0 100644 --- a/test/renderer/utils/memberLaunchDiagnostics.test.ts +++ b/test/renderer/utils/memberLaunchDiagnostics.test.ts @@ -114,4 +114,128 @@ describe('member launch diagnostics', () => { ); expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true); }); + + it('does not turn healthy info liveness diagnostics into member card errors', () => { + const payload = buildMemberLaunchDiagnosticsPayload({ + teamName: 'atlas-hq-5', + runId: '5a9ee2e5-a8cb-4559-b624-0dbf13ee4d11', + memberName: 'atlas', + spawnEntry: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'runtime_process', + livenessSource: 'heartbeat', + runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation', + runtimeDiagnosticSeverity: 'info', + updatedAt: '2026-05-18T08:13:23.902Z', + }, + runtimeEntry: { + memberName: 'atlas', + providerId: 'opencode', + alive: true, + restartable: false, + livenessKind: 'runtime_process', + runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation', + runtimeDiagnosticSeverity: 'info', + diagnostics: [ + 'OpenCode runtime process detected after bootstrap confirmation', + 'matched OpenCode runtime pid and process identity', + 'bootstrap confirmed', + ], + updatedAt: '2026-05-18T08:34:47.845Z', + }, + }); + + expect(payload.memberCardError).toBeUndefined(); + expect(payload.runtimeDiagnostic).toBe( + 'OpenCode runtime process detected after bootstrap confirmation' + ); + expect(payload.runtimeDiagnosticSeverity).toBe('info'); + expect(payload.diagnostics).toContain( + 'OpenCode runtime process detected after bootstrap confirmation' + ); + }); + + it('prefers advisory errors over healthy info liveness diagnostics', () => { + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'atlas', + runtimeAdvisoryLabel: 'OpenCode delivery error', + runtimeAdvisoryTitle: + 'OpenCode runtime delivery error. OpenCode accepted the prompt, but no assistant turn was recorded.', + spawnEntry: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessKind: 'runtime_process', + runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation', + runtimeDiagnosticSeverity: 'info', + updatedAt: '2026-05-18T08:13:23.902Z', + }, + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T08:31:46.075Z', + reasonCode: 'backend_error', + message: 'OpenCode accepted the prompt, but no assistant turn was recorded.', + }, + }); + + expect(payload.memberCardError).toBe( + 'OpenCode runtime delivery error. OpenCode accepted the prompt, but no assistant turn was recorded.' + ); + expect(payload.memberCardError).not.toBe( + 'OpenCode runtime process detected after bootstrap confirmation' + ); + }); + + it('does not surface recoverable OpenCode session refresh advisory as card error', () => { + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'tom', + runtimeAdvisoryLabel: 'OpenCode session refresh', + runtimeAdvisoryTitle: 'OpenCode session changed; refreshing the session before retry.', + spawnEntry: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation', + runtimeDiagnosticSeverity: 'info', + updatedAt: '2026-05-18T08:13:23.902Z', + }, + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T08:31:46.075Z', + reasonCode: 'backend_error', + message: 'OpenCode session changed; refreshing the session before retry.', + }, + }); + + expect(payload.memberCardError).toBeUndefined(); + expect(payload.diagnostics).toContain( + 'OpenCode session changed; refreshing the session before retry.' + ); + }); + + it('does not surface recoverable OpenCode transport refresh advisory as card error', () => { + const payload = buildMemberLaunchDiagnosticsPayload({ + memberName: 'tom', + runtimeAdvisoryLabel: 'OpenCode session refresh', + runtimeAdvisoryTitle: + 'OpenCode session changed; refreshing the session before retry.', + runtimeAdvisory: { + kind: 'api_error', + observedAt: '2026-05-18T08:31:46.075Z', + reasonCode: 'backend_error', + message: 'opencode_app_mcp_transport_changed:old->new', + }, + }); + + expect(payload.memberCardError).toBeUndefined(); + }); });