diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4842161e..2780d69c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1062,6 +1062,15 @@ function getRunRuntimeFailureLabel(run: ProvisioningRun): string { return getCliFlavorUiOptions(getConfiguredCliFlavor()).displayName; } +function buildMissingCliError(): Error { + if (getConfiguredCliFlavor() === 'agent_teams_orchestrator') { + return new Error( + 'Multimodel runtime not found. The packaged app must include resources/runtime/claude-multimodel, or development must provide CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH.' + ); + } + return new Error('Claude CLI not found; install it or provide a valid path'); +} + function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): string[] { return mergeJsonSettingsArgs([...providerArgs, ...args]); } @@ -1861,6 +1870,9 @@ interface ProvisioningRun { fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; waitingTasksSince: number | null; provisioningComplete: boolean; + processClosed: boolean; + requiresFirstRealTurnSuccess: boolean; + firstRealTurnSucceeded: boolean; /** Path to the generated MCP config file for later cleanup. */ mcpConfigPath: string | null; /** Path to the deterministic bootstrap spec file for later cleanup. */ @@ -15344,7 +15356,7 @@ export class TeamProvisioningService { const providerId = resolveTeamProviderId(input.configuredMember.providerId); const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); + throw buildMissingCliError(); } const cwd = this.resolveDirectRestartRuntimeCwd({ @@ -15486,7 +15498,7 @@ export class TeamProvisioningService { const providerId = resolveTeamProviderId(input.configuredMember.providerId); const claudePath = input.run.spawnContext?.claudePath ?? (await ClaudeBinaryResolver.resolve()); if (!claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); + throw buildMissingCliError(); } const cwd = this.resolveDirectRestartRuntimeCwd({ @@ -17895,7 +17907,7 @@ export class TeamProvisioningService { const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId); const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId)); if (!probeResult?.claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); + throw buildMissingCliError(); } const providerLabel = getTeamProviderLabel(providerId); @@ -19594,6 +19606,7 @@ export class TeamProvisioningService { `[${run.teamName}] Respawned CLI process after auth failure (pid=${child.pid ?? '?'})` ); run.child = child; + run.processClosed = false; run.authRetryInProgress = false; updateProgress(run, 'spawning', 'CLI respawned — sending prompt', { @@ -19897,7 +19910,7 @@ export class TeamProvisioningService { const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); + throw buildMissingCliError(); } const runtimeAuthMaterialId = randomUUID(); @@ -20087,6 +20100,9 @@ export class TeamProvisioningService { apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, + processClosed: false, + requiresFirstRealTurnSuccess: false, + firstRealTurnSucceeded: false, mcpConfigPath: null, bootstrapSpecPath: null, bootstrapUserPromptPath: null, @@ -20250,6 +20266,7 @@ export class TeamProvisioningService { bootstrapUserPromptPath = await writeDeterministicBootstrapUserPromptFile(initialUserPrompt); run.bootstrapUserPromptPath = bootstrapUserPromptPath; + run.requiresFirstRealTurnSuccess = true; } emitProvisioningCheckpoint(run, 'Writing MCP config file'); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); @@ -20397,6 +20414,7 @@ export class TeamProvisioningService { }); run.onProgress(run.progress); run.child = child; + run.processClosed = false; run.spawnContext = { claudePath, args: spawnArgs, @@ -21127,7 +21145,7 @@ export class TeamProvisioningService { claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); + throw buildMissingCliError(); } } catch (error) { // Restore pre-launch backup so config.json is not left in normalized (lead-only) state @@ -21352,6 +21370,9 @@ export class TeamProvisioningService { apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, + processClosed: false, + requiresFirstRealTurnSuccess: false, + firstRealTurnSucceeded: false, mcpConfigPath: null, bootstrapSpecPath: null, bootstrapUserPromptPath: null, @@ -21510,6 +21531,7 @@ export class TeamProvisioningService { ); bootstrapUserPromptPath = await writeDeterministicBootstrapUserPromptFile(prompt); run.bootstrapUserPromptPath = bootstrapUserPromptPath; + run.requiresFirstRealTurnSuccess = true; emitProvisioningCheckpoint(run, 'Writing MCP config file'); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; @@ -21688,6 +21710,7 @@ export class TeamProvisioningService { }); run.onProgress(run.progress); run.child = child; + run.processClosed = false; run.spawnContext = { claudePath, args: finalLaunchArgs, @@ -26200,6 +26223,8 @@ export class TeamProvisioningService { run.processKilled || isTerminalFailureProvisioningState(run.progress.state) || this.isProvisioningRunPromotedToAlive(run) || + this.hasPendingDeterministicFirstRealTurn(run) || + !this.isProvisioningRunStillPromotable(run) || this.provisioningRunByTeam.get(run.teamName) !== run.runId ) { return; @@ -26210,6 +26235,9 @@ export class TeamProvisioningService { } const snapshot = await readBootstrapLaunchSnapshot(run.teamName).catch(() => null); + if (!this.isProvisioningRunStillPromotable(run)) { + return; + } if ( !snapshot || (snapshot.launchPhase !== 'finished' && snapshot.launchPhase !== 'reconciled') @@ -26240,6 +26268,9 @@ export class TeamProvisioningService { )}` ); }); + if (!this.isProvisioningRunStillPromotable(run)) { + return; + } const failedSpawnMembers = memberNames .filter((memberName) => snapshot.members[memberName]?.launchState === 'failed_to_start') @@ -26300,6 +26331,46 @@ export class TeamProvisioningService { ); } + private hasPendingDeterministicFirstRealTurn(run: ProvisioningRun): boolean { + return ( + run.deterministicBootstrap && run.requiresFirstRealTurnSuccess && !run.firstRealTurnSucceeded + ); + } + + private isProvisioningRunStillPromotable(run: ProvisioningRun): boolean { + if (this.runs.get(run.runId) !== run) return false; + if (this.provisioningRunByTeam.get(run.teamName) !== run.runId) return false; + if ( + run.cancelRequested || + run.processKilled || + run.processClosed || + run.finalizingByTimeout || + run.authRetryInProgress + ) { + return false; + } + if ( + run.progress.state === 'ready' || + run.progress.state === 'disconnected' || + run.progress.state === 'cancelled' || + isTerminalFailureProvisioningState(run.progress.state) + ) { + return false; + } + if (!run.child || run.child.killed) return false; + const stdin = run.child.stdin as + | (NodeJS.WritableStream & { + destroyed?: boolean; + writableEnded?: boolean; + writable?: boolean; + }) + | null + | undefined; + if (!stdin) return false; + if (stdin.destroyed || stdin.writableEnded || stdin.writable === false) return false; + return true; + } + private syncRunMemberSpawnStatusesFromSnapshot( run: ProvisioningRun, snapshot: PersistedTeamLaunchSnapshot @@ -30027,7 +30098,7 @@ export class TeamProvisioningService { ); } } - if (!run.provisioningComplete && !run.cancelRequested) { + if (!run.requiresFirstRealTurnSuccess && !run.provisioningComplete && !run.cancelRequested) { void this.handleProvisioningTurnComplete(run).catch((error: unknown) => { logger.error( `[${run.teamName}] deterministic bootstrap completion handler failed: ${ @@ -30305,6 +30376,9 @@ export class TeamProvisioningService { })(); if (subtype === 'success') { logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`); + if (!run.provisioningComplete) { + run.firstRealTurnSucceeded = true; + } // Extract contextWindow from modelUsage if available (SDKResultSuccess.modelUsage) const modelUsageObj = (msg.modelUsage ?? @@ -31741,8 +31815,8 @@ export class TeamProvisioningService { } /** - * Called when the first stream-json turn completes successfully. - * Verifies provisioning files exist and marks as ready. + * Called once provisioning has a promotable readiness signal. + * For deterministic runs with a deferred first task, that signal must be result.success. * Process stays alive for subsequent tasks. */ private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { @@ -31755,6 +31829,12 @@ export class TeamProvisioningService { run.progress.state === 'failed' ) return; + if ( + this.hasPendingDeterministicFirstRealTurn(run) || + !this.isProvisioningRunStillPromotable(run) + ) { + return; + } // Prevent false "ready" when auth failure was printed in CLI output but the filesystem monitor // already observed files on disk. We only re-check stderr plus a trailing non-JSON stdout @@ -31856,7 +31936,10 @@ export class TeamProvisioningService { const hasPendingBootstrap = !hasSpawnFailures && this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); - if (this.isProvisioningRunPromotedToAlive(run)) { + if ( + this.isProvisioningRunPromotedToAlive(run) || + !this.isProvisioningRunStillPromotable(run) + ) { return; } const readyMessage = hasSpawnFailures @@ -32039,7 +32122,7 @@ export class TeamProvisioningService { const hasPendingBootstrap = !hasSpawnFailures && this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); - if (this.isProvisioningRunPromotedToAlive(run)) { + if (this.isProvisioningRunPromotedToAlive(run) || !this.isProvisioningRunStillPromotable(run)) { return; } const progress = updateProgress( @@ -32894,9 +32977,6 @@ export class TeamProvisioningService { if (registeredMembers >= primaryProvisioningMemberCount) { run.fsPhase = 'all_files_found'; - if (!run.provisioningComplete) { - void this.handleProvisioningTurnComplete(run); - } return; } } @@ -32904,9 +32984,6 @@ export class TeamProvisioningService { if (primaryProvisioningMemberCount === 0) { if (run.deterministicBootstrap) { run.fsPhase = 'all_files_found'; - if (!run.provisioningComplete) { - void this.handleProvisioningTurnComplete(run); - } } else { run.fsPhase = 'waiting_tasks'; const progress = updateProgress(run, 'finalizing', 'Solo team, preparing workspace'); @@ -32946,10 +33023,9 @@ export class TeamProvisioningService { if (taskFound || taskFallbackExpired) { run.fsPhase = 'all_files_found'; - // Mark provisioning complete early — files are on disk, - // no need to wait for stream-json result.success. + // Legacy filesystem fallback - deterministic bootstrap waits for stream-json success. // The process stays alive for subsequent tasks. - if (!run.provisioningComplete) { + if (!run.deterministicBootstrap && !run.provisioningComplete) { void this.handleProvisioningTurnComplete(run); } } @@ -32996,7 +33072,6 @@ export class TeamProvisioningService { ); return; } - if ( (typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry.trim() : '') && !run.stdoutParserCarryIsCompleteJson && @@ -33008,6 +33083,7 @@ export class TeamProvisioningService { ); } this.flushStdoutParserCarry(run); + run.processClosed = true; if ( this.isProvisioningRunFailed(run) || run.cancelRequested || @@ -33571,14 +33647,17 @@ export class TeamProvisioningService { } try { - return await this.controlApiBaseUrlResolver(); + const baseUrl = await this.controlApiBaseUrlResolver(); + if (!baseUrl) { + throw new Error('Team control API resolver returned no base URL after startup.'); + } + return baseUrl; } catch (error) { - logger.warn( - `Failed to resolve team control API base URL: ${ - error instanceof Error ? error.message : String(error) - }` + const message = error instanceof Error ? error.message : String(error); + logger.error(`Failed to resolve team control API base URL: ${message}`); + throw new Error( + `Team control API failed to start or publish its base URL. Team runtime commands require the desktop Control API. ${message}` ); - return null; } } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 2660bd08..85cdc375 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -5336,9 +5336,9 @@ describe('TeamProvisioningService', () => { ]; await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await vi.waitFor(() => { - expect(adapterLaunch).toHaveBeenCalledTimes(1); - }); + await run.mixedSecondaryLaneLaunchQueue; + + expect(adapterLaunch).toHaveBeenCalledTimes(1); expect(adapterLaunch).toHaveBeenCalledWith( expect.objectContaining({ laneId: 'secondary:opencode:bob', @@ -12798,7 +12798,7 @@ describe('TeamProvisioningService', () => { }); describe('safe app launch matrix', () => { - it('does not wait for OpenCode secondary inboxes before completing primary filesystem readiness', async () => { + it('does not wait for OpenCode secondary inboxes before marking primary filesystem readiness', async () => { const teamName = 'mixed-secondary-fs-readiness'; const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true }); @@ -12848,8 +12848,8 @@ describe('TeamProvisioningService', () => { ], }); - await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1)); - expect(run.fsPhase).toBe('all_files_found'); + await vi.waitFor(() => expect(run.fsPhase).toBe('all_files_found')); + expect(complete).not.toHaveBeenCalled(); expect(run.onProgress).not.toHaveBeenCalledWith( expect.objectContaining({ message: 'Prepared communication channels for 1/2 members', @@ -14802,7 +14802,7 @@ describe('TeamProvisioningService', () => { await svc.cancelProvisioning(runId); }); - it('flushes a final newline-less bootstrap completion event before handling launch close', async () => { + it('flushes a final newline-less bootstrap completion event without promoting launch ready', async () => { allowConsoleLogs(); const teamName = 'launch-close-flushes-final-json-team'; const leadSessionId = 'lead-session-final-json-flush'; @@ -14837,11 +14837,11 @@ describe('TeamProvisioningService', () => { ); const complete = vi .spyOn(svc as any, 'handleProvisioningTurnComplete') - .mockImplementation(async (run: any) => { - run.provisioningComplete = true; - }); + .mockResolvedValue(undefined); const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {}); + const run = (svc as any).runs.get(runId); + expect(run).toBeTruthy(); child.stdout.emit( 'data', @@ -14861,14 +14861,236 @@ describe('TeamProvisioningService', () => { await Promise.resolve(); expect(complete).not.toHaveBeenCalled(); + (svc as any).flushStdoutParserCarry(run); + + expect(complete).not.toHaveBeenCalled(); + expect(run.lastDeterministicBootstrapSeq).toBe(1); + await svc.cancelProvisioning(runId); + }); + + it('flushes a final newline-less success result and completes deterministic launch', async () => { + allowConsoleLogs(); + const teamName = 'launch-close-flushes-final-success-team'; + const leadSessionId = 'lead-session-final-success-flush'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + const child = createRunningChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + const complete = vi + .spyOn(svc as any, 'handleProvisioningTurnComplete') + .mockImplementation(async (run: any) => { + expect(run.processClosed).toBe(false); + expect(run.firstRealTurnSucceeded).toBe(true); + run.provisioningComplete = true; + }); + + const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {}); + + child.stdout.emit( + 'data', + Buffer.from( + JSON.stringify({ + type: 'result', + subtype: 'success', + }), + 'utf8' + ) + ); + await Promise.resolve(); + expect(complete).not.toHaveBeenCalled(); + child.emit('close', 0); await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1)); }); - it('recovers ready progress when deterministic create finalization stalls after completed bootstrap-state', async () => { + it('does not promote deterministic launch from bootstrap completed before first real turn succeeds', async () => { + allowConsoleLogs(); + const teamName = 'bootstrap-completed-before-first-turn-team'; + const leadSessionId = 'lead-session-bootstrap-only'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + const child = createRunningChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + const complete = vi + .spyOn(svc as any, 'handleProvisioningTurnComplete') + .mockResolvedValue(undefined); + + let runId = ''; + try { + const launch = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {}); + runId = launch.runId; + + child.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + type: 'system', + subtype: 'team_bootstrap', + event: 'completed', + run_id: runId, + team_name: teamName, + seq: 1, + failed_members: [], + })}\n`, + 'utf8' + ) + ); + + await Promise.resolve(); + expect(complete).not.toHaveBeenCalled(); + } finally { + if (runId) { + await svc.cancelProvisioning(runId).catch(() => undefined); + } + } + }); + + it('promotes deterministic create bootstrap completion when no first turn was enqueued', async () => { + allowConsoleLogs(); + const teamName = 'bootstrap-completed-no-first-turn-team'; + const child = createRunningChild(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const mcpConfigBuilder = { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'), + removeConfigFile: vi.fn(async () => {}), + }; + const membersMetaStore = { + writeMembers: vi.fn(async () => {}), + getMembers: vi.fn(async () => []), + }; + const teamMetaStore = { + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + getMeta: vi.fn(async () => null), + }; + + const svc = new TeamProvisioningService( + undefined, + undefined, + membersMetaStore as any, + undefined, + mcpConfigBuilder as any, + teamMetaStore as any + ); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { CODEX_API_KEY: 'test' }, + authSource: 'codex_runtime', + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).startStallWatchdog = vi.fn(); + (svc as any).stopStallWatchdog = vi.fn(); + (svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({ + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.5', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'gpt-5.5', + catalogId: 'gpt-5.5', + catalogSource: 'test', + catalogFetchedAt: '2026-05-07T00:00:00.000Z', + selectedEffort: 'medium', + resolvedEffort: 'medium', + selectedFastMode: null, + resolvedFastMode: null, + fastResolutionReason: null, + })); + const complete = vi + .spyOn(svc as any, 'handleProvisioningTurnComplete') + .mockResolvedValue(undefined); + + const { runId } = await svc.createTeam( + { + teamName, + cwd: tempClaudeRoot, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + members: [{ name: 'alice' }], + }, + () => {} + ); + const run = (svc as any).runs.get(runId); + expect(run).toBeTruthy(); + expect(run.requiresFirstRealTurnSuccess).toBe(false); + + child.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + type: 'system', + subtype: 'team_bootstrap', + event: 'completed', + run_id: runId, + team_name: teamName, + seq: 1, + failed_members: [], + })}\n`, + 'utf8' + ) + ); + + await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1)); + expect(complete).toHaveBeenCalledWith(run); + await svc.cancelProvisioning(runId); + }); + + it('recovers ready progress when deterministic finalization stalls after first real turn success', async () => { allowConsoleLogs(); - vi.useFakeTimers(); const teamName = 'create-completed-bootstrap-finalization-stall'; const child = createRunningChild(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); @@ -14920,9 +15142,6 @@ describe('TeamProvisioningService', () => { resolvedFastMode: null, fastResolutionReason: null, })); - const waitForValidConfig = vi.fn(() => new Promise(() => {})); - (svc as any).waitForValidConfig = waitForValidConfig; - const progressStates: string[] = []; const { runId } = await svc.createTeam( { @@ -14940,10 +15159,9 @@ describe('TeamProvisioningService', () => { const run = (svc as any).runs.get(runId); expect(run).toBeTruthy(); run.deterministicBootstrap = true; - const scheduleRecovery = vi.spyOn( - svc as any, - 'scheduleDeterministicBootstrapCompletionRecovery' - ); + run.requiresFirstRealTurnSuccess = true; + run.firstRealTurnSucceeded = true; + run.provisioningComplete = true; writeBootstrapState( teamName, @@ -14954,26 +15172,6 @@ describe('TeamProvisioningService', () => { new Date(Date.now() + 1_000).toISOString() ); - child.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - type: 'system', - subtype: 'team_bootstrap', - event: 'completed', - run_id: runId, - team_name: teamName, - seq: 1, - failed_members: [], - })}\n`, - 'utf8' - ) - ); - - await Promise.resolve(); - await Promise.resolve(); - expect(waitForValidConfig).toHaveBeenCalledTimes(1); - expect(scheduleRecovery).toHaveBeenCalledWith(run); expect(progressStates.at(-1)).not.toBe('ready'); await (svc as any).recoverDeterministicBootstrapCompletion(run); @@ -14982,6 +15180,92 @@ describe('TeamProvisioningService', () => { expect((svc as any).aliveRunByTeam.get(teamName)).toBe(runId); }); + it('does not recover ready progress from completed bootstrap-state when the lead child is gone', async () => { + allowConsoleLogs(); + const teamName = 'create-completed-bootstrap-dead-lead'; + const child = createRunningChild(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const mcpConfigBuilder = { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'), + removeConfigFile: vi.fn(async () => {}), + }; + const membersMetaStore = { + writeMembers: vi.fn(async () => {}), + getMembers: vi.fn(async () => []), + }; + const teamMetaStore = { + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + getMeta: vi.fn(async () => null), + }; + + const svc = new TeamProvisioningService( + undefined, + undefined, + membersMetaStore as any, + undefined, + mcpConfigBuilder as any, + teamMetaStore as any + ); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { CODEX_API_KEY: 'test' }, + authSource: 'codex_runtime', + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).startStallWatchdog = vi.fn(); + (svc as any).stopStallWatchdog = vi.fn(); + (svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({ + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.5', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'gpt-5.5', + catalogId: 'gpt-5.5', + catalogSource: 'test', + catalogFetchedAt: '2026-05-07T00:00:00.000Z', + selectedEffort: 'medium', + resolvedEffort: 'medium', + selectedFastMode: null, + resolvedFastMode: null, + fastResolutionReason: null, + })); + + const progressStates: string[] = []; + const { runId } = await svc.createTeam( + { + teamName, + cwd: tempClaudeRoot, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + members: [{ name: 'alice' }], + }, + (progress) => { + progressStates.push(progress.state); + } + ); + const run = (svc as any).runs.get(runId); + expect(run).toBeTruthy(); + run.deterministicBootstrap = true; + run.provisioningComplete = true; + run.child = null; + + writeBootstrapState( + teamName, + [{ name: 'alice', status: 'bootstrap_confirmed' }], + new Date(Date.now() + 1_000).toISOString() + ); + + await (svc as any).recoverDeterministicBootstrapCompletion(run); + + expect(progressStates).not.toContain('ready'); + expect((svc as any).aliveRunByTeam.get(teamName)).toBeUndefined(); + }); + it('does not verify provisioning again after flushing a final newline-less error result', async () => { allowConsoleLogs(); const teamName = 'launch-close-flushes-final-error-team';