fix(team): gate launch readiness on first real turn
This commit is contained in:
parent
555e805f7b
commit
f882970b6c
2 changed files with 428 additions and 65 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue