fix(team): gate launch readiness on first real turn

This commit is contained in:
777genius 2026-05-15 21:25:35 +03:00
parent 555e805f7b
commit f882970b6c
2 changed files with 428 additions and 65 deletions

View file

@ -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<void> {
@ -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;
}
}

View file

@ -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';