diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 20ba67b2..0f764b0a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1591,6 +1591,42 @@ interface MixedSecondaryRuntimeLaneState { result: TeamRuntimeLaunchResult | null; warnings: string[]; diagnostics: string[]; + launchScheduled?: boolean; + queuedAtMs?: number; + launchStartedAtMs?: number; + launchFinishedAtMs?: number; +} + +function formatOpenCodeLaneTimingMs(value: number | null | undefined): string { + return typeof value === 'number' && Number.isFinite(value) + ? `${Math.max(0, Math.round(value))}ms` + : 'n/a'; +} + +function appendDiagnosticOnce(diagnostics: readonly string[], diagnostic: string | null): string[] { + if (!diagnostic || diagnostics.includes(diagnostic)) { + return [...diagnostics]; + } + return [...diagnostics, diagnostic]; +} + +function buildOpenCodeSecondaryLaneTimingDiagnostic( + lane: MixedSecondaryRuntimeLaneState +): string | null { + if ( + typeof lane.queuedAtMs !== 'number' || + typeof lane.launchStartedAtMs !== 'number' || + typeof lane.launchFinishedAtMs !== 'number' + ) { + return null; + } + return [ + 'OpenCode secondary lane timing:', + `member=${lane.member.name}`, + `queueWaitMs=${formatOpenCodeLaneTimingMs(lane.launchStartedAtMs - lane.queuedAtMs)}`, + `launchMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.launchStartedAtMs)}`, + `totalMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.queuedAtMs)}`, + ].join(' '); } function createUnexpectedMixedSecondaryLaneFailureResult(input: { @@ -1738,6 +1774,18 @@ function collectRuntimeLaunchFailureDiagnostics( ); } +function collectOpenCodeSecondaryLaneFailureDiagnostics( + result: TeamRuntimeLaunchResult, + memberName: string, + prefixDiagnostics: readonly string[] +): string[] { + const diagnostics = [ + ...prefixDiagnostics, + ...collectRuntimeLaunchFailureDiagnostics(result, memberName), + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + return diagnostics.length > 0 ? diagnostics : ['OpenCode bridge reported member launch failure']; +} + function isReconciliableOpenCodeUnknownOutcome(diagnostics: readonly string[]): boolean { return diagnostics.some((diagnostic) => /outcome must be reconciled before retry/i.test(diagnostic) @@ -1760,7 +1808,10 @@ function isDefinitiveOpenCodePreLaunchFailure( member.agentToolAccepted || member.runtimeAlive || member.bootstrapConfirmed || - typeof member.sessionId === 'string'; + (typeof member.sessionId === 'string' && member.sessionId.trim().length > 0) || + (typeof member.runtimePid === 'number' && + Number.isFinite(member.runtimePid) && + member.runtimePid > 0); if (runtimeMaterialized) { return false; } @@ -19229,6 +19280,8 @@ export class TeamProvisioningService { run: ProvisioningRun, lane: MixedSecondaryRuntimeLaneState ): Promise { + lane.launchStartedAtMs = Date.now(); + lane.queuedAtMs = lane.queuedAtMs ?? lane.launchStartedAtMs; const requestedDiagnostics = [...lane.diagnostics]; const shouldAbortLaunch = (): boolean => run.cancelRequested || @@ -19250,6 +19303,8 @@ export class TeamProvisioningService { const adapter = this.getOpenCodeRuntimeAdapter(); if (!adapter) { const message = 'OpenCode runtime adapter is not registered for mixed team launch.'; + lane.launchFinishedAtMs = Date.now(); + const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane); lane.state = 'finished'; lane.result = { runId: lane.runId ?? randomUUID(), @@ -19266,14 +19321,14 @@ export class TeamProvisioningService { bootstrapConfirmed: false, hardFailure: true, hardFailureReason: 'opencode_runtime_adapter_missing', - diagnostics: [message], + diagnostics: appendDiagnosticOnce([message], timingDiagnostic), }, }, warnings: [], - diagnostics: [...requestedDiagnostics, message], + diagnostics: appendDiagnosticOnce([...requestedDiagnostics, message], timingDiagnostic), }; lane.warnings = []; - lane.diagnostics = [...requestedDiagnostics, message]; + lane.diagnostics = appendDiagnosticOnce([...requestedDiagnostics, message], timingDiagnostic); await this.publishMixedSecondaryLaneStatusChange(run, lane); lane.state = 'finished'; return; @@ -19340,6 +19395,8 @@ export class TeamProvisioningService { providerId: 'opencode', model: lane.member.model, effort: lane.member.effort, + runtimeOnly: true, + skipReadinessPreflight: true, skipPermissions: run.request.skipPermissions !== false, expectedMembers: [ { @@ -19369,16 +19426,49 @@ export class TeamProvisioningService { await finishCancelledLane(); return; } - lane.result = result; - lane.warnings = [...result.warnings]; - lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, ...result.diagnostics]; + lane.launchFinishedAtMs = Date.now(); + const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane); + const memberEvidence = result.members[lane.member.name]; + const resultWithTiming: TeamRuntimeLaunchResult = timingDiagnostic + ? { + ...result, + diagnostics: appendDiagnosticOnce(result.diagnostics, timingDiagnostic), + members: { + ...result.members, + ...(memberEvidence + ? { + [lane.member.name]: { + ...memberEvidence, + diagnostics: appendDiagnosticOnce( + memberEvidence.diagnostics ?? [], + timingDiagnostic + ), + }, + } + : {}), + }, + } + : result; + lane.result = resultWithTiming; + lane.warnings = [...resultWithTiming.warnings]; + const launchDiagnostics = appendDiagnosticOnce( + [...requestedDiagnostics, ...migration.diagnostics, ...resultWithTiming.diagnostics], + timingDiagnostic + ); + lane.diagnostics = launchDiagnostics; - if (isDefinitiveOpenCodePreLaunchFailure(result, lane.member.name)) { - const diagnostics = [ - ...requestedDiagnostics, - ...migration.diagnostics, - ...collectRuntimeLaunchFailureDiagnostics(result, lane.member.name), - ]; + if ( + isDefinitiveOpenCodePreLaunchFailure(resultWithTiming, lane.member.name) || + resultWithTiming.teamLaunchState === 'partial_failure' + ) { + const diagnostics = collectOpenCodeSecondaryLaneFailureDiagnostics( + resultWithTiming, + lane.member.name, + appendDiagnosticOnce( + [...requestedDiagnostics, ...migration.diagnostics], + timingDiagnostic + ) + ); await upsertOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: getTeamsBasePath(), teamName: run.teamName, @@ -19387,8 +19477,6 @@ export class TeamProvisioningService { diagnostics, }).catch(() => undefined); this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); - } else if (result.teamLaunchState === 'partial_failure') { - this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); } } catch (error) { if (shouldAbortLaunch()) { @@ -19396,6 +19484,8 @@ export class TeamProvisioningService { return; } const message = error instanceof Error ? error.message : String(error); + lane.launchFinishedAtMs = Date.now(); + const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane); lane.result = { runId: lane.runId, teamName: run.teamName, @@ -19411,20 +19501,23 @@ export class TeamProvisioningService { bootstrapConfirmed: false, hardFailure: true, hardFailureReason: message, - diagnostics: [message], + diagnostics: appendDiagnosticOnce([message], timingDiagnostic), }, }, warnings: [], - diagnostics: [message], + diagnostics: appendDiagnosticOnce([message], timingDiagnostic), }; lane.warnings = []; - lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, message]; + lane.diagnostics = appendDiagnosticOnce( + [...requestedDiagnostics, ...migration.diagnostics, message], + timingDiagnostic + ); await upsertOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: getTeamsBasePath(), teamName: run.teamName, laneId: lane.laneId, state: 'degraded', - diagnostics: [message], + diagnostics: appendDiagnosticOnce([message], timingDiagnostic), }).catch(() => undefined); this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); } @@ -19486,11 +19579,12 @@ export class TeamProvisioningService { run: ProvisioningRun, lane: MixedSecondaryRuntimeLaneState ): void { - if (lane.state !== 'queued') { + if (lane.state !== 'queued' || lane.launchScheduled) { return; } - lane.state = 'launching'; + lane.queuedAtMs = lane.queuedAtMs ?? Date.now(); + lane.launchScheduled = true; lane.runId = lane.runId ?? randomUUID(); const launch = async () => { @@ -19505,6 +19599,7 @@ export class TeamProvisioningService { lane.state = 'finished'; return; } + lane.state = 'launching'; await this.launchSingleMixedSecondaryLane(run, lane); } catch (error) { if (run.cancelRequested || run.processKilled) { diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 1df7928c..fab6fbc5 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -132,25 +132,33 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { ); } - const prepared = await this.prepare(input); - if (!prepared.ok) { - return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); + const skipReadinessPreflight = input.skipReadinessPreflight === true; + let selectedModel = input.model?.trim() ?? ''; + let launchWarnings: string[] = []; + if (!skipReadinessPreflight) { + const prepared = await this.prepare(input); + if (!prepared.ok) { + return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); + } + selectedModel = prepared.modelId ?? selectedModel; + launchWarnings = prepared.warnings; } if (!this.bridge.launchOpenCodeTeam) { return blockedLaunchResult(input, 'opencode_launch_bridge_missing', [ - 'OpenCode readiness passed, but the state-changing launch bridge is not registered.', + 'OpenCode state-changing launch bridge is not registered.', ]); } - const selectedModel = prepared.modelId ?? input.model?.trim() ?? ''; if (!selectedModel) { return blockedLaunchResult(input, 'opencode_model_unavailable', [ 'OpenCode launch requires a selected raw model id.', ]); } - const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; + const runtimeSnapshot = skipReadinessPreflight + ? null + : (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null); this.lastProjectPathByTeamName.set(input.teamName, input.cwd); const data = await this.bridge.launchOpenCodeTeam({ runId: input.runId, @@ -169,7 +177,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { manifestHighWatermark: null, }); - return mapOpenCodeLaunchDataToRuntimeResult(input, data, prepared.warnings); + return mapOpenCodeLaunchDataToRuntimeResult(input, data, launchWarnings); } async reconcile(input: TeamRuntimeReconcileInput): Promise { @@ -430,6 +438,9 @@ function mapOpenCodeLaunchDataToRuntimeResult( prepareWarnings: string[] ): TeamRuntimeLaunchResult { const bridgeDiagnostics = data.diagnostics.map(formatOpenCodeBridgeDiagnostic); + const memberBridgeDiagnostics = bridgeDiagnostics.filter( + (diagnostic) => !isOpenCodeLaunchTimingDiagnostic(diagnostic) + ); const checkpointNames = extractCheckpointNames(data); const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) => checkpointNames.has(name) @@ -492,7 +503,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( ...(bridgeMember?.evidence ?? []).map( (evidence) => `${evidence.kind} at ${evidence.observedAt}` ), - ...bridgeDiagnostics, + ...memberBridgeDiagnostics, ...checkpointDiagnostic, ...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []), ] @@ -724,6 +735,13 @@ function formatOpenCodeBridgeDiagnostic(diagnostic: { return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`; } +function isOpenCodeLaunchTimingDiagnostic(diagnostic: string): boolean { + return ( + diagnostic.startsWith('info:opencode_launch_member_timing:') || + diagnostic.startsWith('info:opencode_launch_total_timing:') + ); +} + function blockedLaunchResult( input: TeamRuntimeLaunchInput, reason: string, diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 1ce62ce3..b0b5b22e 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -35,10 +35,15 @@ export interface TeamRuntimeLaunchInput { model?: string; effort?: EffortLevel; /** - * Runtime-only preflight skips model-scoped execution/evidence checks. - * Use only for warm-up diagnostics before a concrete launch model is selected. + * Runtime-only readiness skips extra model execution probes. + * Use when a separate preflight or the real launch prompt is the authority. */ runtimeOnly?: boolean; + /** + * Skip the adapter-level readiness bridge before launch. + * Use only when the launch bridge performs equivalent runtime/provider/MCP validation. + */ + skipReadinessPreflight?: boolean; skipPermissions: boolean; expectedMembers: TeamRuntimeMemberSpec[]; previousLaunchState: PersistedTeamLaunchSnapshot | null; diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 47bbc2d9..4f64f6c5 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -86,6 +86,76 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); + it('can rely on the launch bridge as the only readiness authority', async () => { + const launchOpenCodeTeam = vi.fn< + NonNullable + >( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'ready', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'confirmed_alive', + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [ + { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + }, + }, + warnings: [], + diagnostics: [ + { + code: 'opencode_launch_total_timing', + severity: 'info', + message: 'total=12ms provisioningProbe=3ms members=1', + }, + { + code: 'member_reconcile', + severity: 'warning', + message: 'alice: sample reconcile diagnostic', + }, + ], + }) satisfies OpenCodeLaunchTeamCommandData + ); + const bridge = bridgePort( + readiness({ + state: 'unknown_error', + launchAllowed: false, + diagnostics: ['readiness should be skipped'], + }), + { launchOpenCodeTeam } + ); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); + + const result = await adapter.launch(launchInput({ skipReadinessPreflight: true })); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled(); + expect(launchOpenCodeTeam).toHaveBeenCalledWith( + expect.objectContaining({ + selectedModel: 'openai/gpt-5.4-mini', + expectedCapabilitySnapshotId: null, + }) + ); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + 'info:opencode_launch_total_timing: total=12ms provisioningProbe=3ms members=1', + ]) + ); + expect(result.members.alice?.diagnostics).not.toContain( + 'info:opencode_launch_total_timing: total=12ms provisioningProbe=3ms members=1' + ); + expect(result.members.alice?.diagnostics).toContain( + 'warning:member_reconcile: alice: sample reconcile diagnostic' + ); + }); + it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => { const launchOpenCodeTeam = vi.fn(); const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 67a6c146..4c89c11f 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3837,6 +3837,8 @@ describe('TeamProvisioningService', () => { providerId: 'opencode', model: 'minimax-m2.5-free', effort: 'medium', + runtimeOnly: true, + skipReadinessPreflight: true, cwd: '/tmp/mixed-team', expectedMembers: [ expect.objectContaining({ @@ -6552,6 +6554,117 @@ describe('TeamProvisioningService', () => { state: 'degraded', diagnostics: expect.arrayContaining([ 'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out', + expect.stringMatching( + /^OpenCode secondary lane timing: member=bob queueWaitMs=\d+ms launchMs=\d+ms totalMs=\d+ms$/ + ), + ]), + }, + }, + }); + }, + { timeout: 5000 } + ); + }); + + it('marks an OpenCode secondary lane degraded when launch fails after runtime materializes', async () => { + const teamName = 'mixed-runtime-materialized-failure'; + const svc = new TeamProvisioningService(); + const adapterLaunch = vi.fn(async (input: Record) => ({ + runId: String(input.runId), + teamName: String(input.teamName), + launchPhase: 'active', + teamLaunchState: 'partial_failure', + members: { + tom: { + memberName: 'tom', + providerId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + sessionId: 'ses_tom_materialized_without_bootstrap', + runtimePid: 71388, + livenessKind: 'runtime_process', + diagnostics: [ + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + ], + }, + }, + warnings: [], + diagnostics: ['OpenCode bridge reported member launch failure'], + })); + + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: adapterLaunch, + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]) + ); + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['bob'], + }); + run.isLaunch = true; + run.request = { + teamName, + cwd: '/tmp/mixed-runtime-materialized-failure', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + skipPermissions: true, + }; + run.effectiveMembers = [ + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + }, + ]; + run.mixedSecondaryLanes = [ + { + laneId: 'secondary:opencode:tom', + providerId: 'opencode', + member: { + name: 'tom', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + runId: null, + state: 'queued', + result: null, + warnings: [], + diagnostics: [], + }, + ]; + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await vi.waitFor( + async () => { + expect(adapterLaunch).toHaveBeenCalledTimes(1); + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: { + 'secondary:opencode:tom': { + state: 'degraded', + diagnostics: expect.arrayContaining([ + 'OpenCode bridge reported member launch failure', + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', ]), }, }, @@ -6670,13 +6783,18 @@ describe('TeamProvisioningService', () => { expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(1); expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ 'launching', - 'launching', - 'launching', + 'queued', + 'queued', ]); await expect(resultPromise).resolves.toBeNull(); expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(1); + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await Promise.resolve(); + expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(1); + expect(persistLaunchStateSnapshot).toHaveBeenCalledTimes(2); + resolveFirstLaunch(); await Promise.resolve(); await vi.waitFor(() => expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(3));