From 1a872b592ad7f61546b8842a175d7da43b31f4e0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 3 May 2026 15:00:16 +0300 Subject: [PATCH] fix: clear stale team launch reconciliation --- .../buildMixedPersistedLaunchSnapshot.ts | 3 + src/main/index.ts | 2 + .../infrastructure/NotificationManager.ts | 35 ++ .../runtime/ProviderConnectionService.ts | 20 + .../services/team/TeamLaunchStateEvaluator.ts | 3 + .../team/TeamLaunchSummaryProjection.ts | 54 ++- .../services/team/TeamProvisioningService.ts | 256 ++++++++++- .../team/TeamFsWorker.integration.test.ts | 115 ++++- .../team/TeamLaunchSummaryProjection.test.ts | 187 ++++++++ .../team/TeamProvisioningService.test.ts | 412 +++++++++++++++++- 10 files changed, 1073 insertions(+), 14 deletions(-) diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 94263c35..dff6ca79 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -94,6 +94,7 @@ function buildDiagnostics( | 'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' + | 'hardFailure' | 'hardFailureReason' | 'sources' | 'pendingPermissionRequestIds' @@ -108,6 +109,8 @@ function buildDiagnostics( diagnostics.push('waiting for permission approval'); } else if (member.bootstrapStalled) { diagnostics.push('opencode_bootstrap_stalled'); + } else if (member.hardFailure && member.runtimeAlive && !member.bootstrapConfirmed) { + diagnostics.push('bootstrap failed while runtime process was still alive'); } else if (member.runtimeAlive && !member.bootstrapConfirmed) { diagnostics.push('waiting for teammate check-in'); } diff --git a/src/main/index.ts b/src/main/index.ts index 01ddf9d7..91667982 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2113,6 +2113,8 @@ app.on('before-quit', (event) => { event.preventDefault(); + notificationManager.closeActiveNativeNotifications('app-before-quit'); + for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { win.hide(); diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 99737b09..a0b29ebb 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -1111,6 +1111,17 @@ export class NotificationManager extends EventEmitter { * Shared click handler for native notifications — focuses window and emits deep-link. */ private handleNativeNotificationClick(stored: StoredNotification): void { + const isDevRuntime = + process.env.NODE_ENV !== 'production' || + Boolean((process as typeof process & { defaultApp?: boolean }).defaultApp); + if (isDevRuntime) { + const notificationType = stored.teamEventType ?? stored.category ?? 'error'; + const notificationTitle = stored.triggerName ?? stored.message; + logger.info( + `[notification-click] delivered in-process id=${stored.id} type=${notificationType} title="${notificationTitle}"` + ); + } + if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.show(); this.mainWindow.focus(); @@ -1119,6 +1130,30 @@ export class NotificationManager extends EventEmitter { this.emit('notification-clicked', stored); } + /** + * Closes active OS notifications so macOS does not keep stale dev toasts that + * can relaunch raw Electron without the app path. + */ + closeActiveNativeNotifications(reason: string = 'manual'): number { + const notifications = Array.from(this.activeNotifications); + for (const notification of notifications) { + try { + (notification as NotificationInstance & { close?: () => void }).close?.(); + } catch (error) { + logger.debug( + `[notification] failed to close active notification during ${reason}: ${String(error)}` + ); + } + } + this.activeNotifications.clear(); + if (notifications.length > 0) { + logger.debug( + `[notification] closed ${notifications.length} active notification(s): ${reason}` + ); + } + return notifications.length; + } + /** * Guard: checks if Electron's Notification API is available. */ diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 2d7bf80f..dadd89ab 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -68,6 +68,7 @@ const PROVIDER_API_KEY_ENV_VARS: Partial> = { const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY'; const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH'; const CODEX_HOME_ENV_VAR = 'CODEX_HOME'; +const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD'; const CODEX_NATIVE_BACKEND_ID = 'codex-native'; function isCodexExecBinary(binaryPath?: string | null): boolean { @@ -106,6 +107,18 @@ function applyCodexRuntimeContextEnv( } } +function applyCodexForcedLoginMethodEnv( + env: NodeJS.ProcessEnv, + loginMethod: 'chatgpt' | 'api' | null +): void { + if (loginMethod) { + env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR] = loginMethod; + return; + } + + delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR]; +} + export class ProviderConnectionService { private static instance: ProviderConnectionService | null = null; private codexAccountFeature: Pick | null = null; @@ -213,6 +226,7 @@ export class ProviderConnectionService { if (readiness.effectiveAuthMode === 'chatgpt') { delete env.OPENAI_API_KEY; delete env[CODEX_NATIVE_API_KEY_ENV_VAR]; + applyCodexForcedLoginMethodEnv(env, 'chatgpt'); return env; } @@ -220,6 +234,7 @@ export class ProviderConnectionService { if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) { env.OPENAI_API_KEY = resolvedApiKey; env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey; + applyCodexForcedLoginMethodEnv(env, 'api'); return env; } @@ -227,6 +242,7 @@ export class ProviderConnectionService { delete env.OPENAI_API_KEY; } delete env[CODEX_NATIVE_API_KEY_ENV_VAR]; + applyCodexForcedLoginMethodEnv(env, null); return env; } @@ -274,6 +290,7 @@ export class ProviderConnectionService { if (readiness.effectiveAuthMode === 'chatgpt') { delete env.OPENAI_API_KEY; delete env[CODEX_NATIVE_API_KEY_ENV_VAR]; + applyCodexForcedLoginMethodEnv(env, 'chatgpt'); return env; } @@ -281,8 +298,11 @@ export class ProviderConnectionService { if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) { env.OPENAI_API_KEY = resolvedApiKey; env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey; + applyCodexForcedLoginMethodEnv(env, 'api'); + return env; } + applyCodexForcedLoginMethodEnv(env, null); return env; } diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index f19c8b74..10ebf1ac 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -235,6 +235,7 @@ function buildDiagnostics( | 'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' + | 'hardFailure' | 'hardFailureReason' | 'skippedForLaunch' | 'skipReason' @@ -251,6 +252,8 @@ function buildDiagnostics( diagnostics.push('waiting for permission approval'); } else if (member.bootstrapStalled) { diagnostics.push('opencode_bootstrap_stalled'); + } else if (member.hardFailure && member.runtimeAlive && !member.bootstrapConfirmed) { + diagnostics.push('bootstrap failed while runtime process was still alive'); } else if (member.runtimeAlive && !member.bootstrapConfirmed) { diagnostics.push('waiting for teammate check-in'); } diff --git a/src/main/services/team/TeamLaunchSummaryProjection.ts b/src/main/services/team/TeamLaunchSummaryProjection.ts index 48345c60..ac29deef 100644 --- a/src/main/services/team/TeamLaunchSummaryProjection.ts +++ b/src/main/services/team/TeamLaunchSummaryProjection.ts @@ -7,6 +7,7 @@ import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator'; import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types'; export const TEAM_LAUNCH_SUMMARY_FILE = 'launch-summary.json'; +const STALE_PENDING_SUMMARY_GRACE_MS = 5 * 60 * 1000; export interface LaunchStateSummary { partialLaunchFailure?: true; @@ -32,6 +33,7 @@ export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary version: 1; teamName: string; updatedAt: string; + launchPhase?: PersistedTeamLaunchSnapshot['launchPhase']; mixedAware?: true; } @@ -101,6 +103,7 @@ export function createPersistedLaunchSummaryProjection( version: 1, teamName: snapshot.teamName, updatedAt: snapshot.updatedAt, + launchPhase: snapshot.launchPhase, ...(hasMixedPersistedLaunchMetadata(snapshot) ? { mixedAware: true as const } : {}), ...createLaunchStateSummary(snapshot), }; @@ -128,6 +131,13 @@ export function normalizePersistedLaunchSummaryProjection( updatedAt, ...(record.mixedAware === true ? { mixedAware: true as const } : {}), }; + if ( + record.launchPhase === 'active' || + record.launchPhase === 'finished' || + record.launchPhase === 'reconciled' + ) { + normalized.launchPhase = record.launchPhase; + } if (record.partialLaunchFailure === true) { normalized.partialLaunchFailure = true; @@ -202,17 +212,55 @@ export function normalizePersistedLaunchSummaryProjection( return normalized; } +function shouldIgnoreStalePendingSummaryProjection( + projection: PersistedTeamLaunchSummaryProjection, + nowMs: number = Date.now() +): boolean { + if (projection.teamLaunchState !== 'partial_pending') { + return false; + } + if ((projection.permissionPendingCount ?? 0) > 0) { + return false; + } + + const updatedAtMs = toMillis(projection.launchUpdatedAt ?? projection.updatedAt); + return Number.isFinite(updatedAtMs) && nowMs - updatedAtMs >= STALE_PENDING_SUMMARY_GRACE_MS; +} + +function shouldIgnoreStalePendingLaunchSnapshotSummary( + snapshot: PersistedTeamLaunchSnapshot, + nowMs: number = Date.now() +): boolean { + if (snapshot.teamLaunchState !== 'partial_pending') { + return false; + } + if ((snapshot.summary.permissionPendingCount ?? 0) > 0) { + return false; + } + + const updatedAtMs = toMillis(snapshot.updatedAt); + return Number.isFinite(updatedAtMs) && nowMs - updatedAtMs >= STALE_PENDING_SUMMARY_GRACE_MS; +} + export function choosePreferredLaunchStateSummary(params: { bootstrapSnapshot?: PersistedTeamLaunchSnapshot | null; launchSnapshot?: PersistedTeamLaunchSnapshot | null; launchSummaryProjection?: PersistedTeamLaunchSummaryProjection | null; }): LaunchStateSummary | null { - if (params.launchSnapshot) { - return createLaunchStateSummary(params.launchSnapshot); + const launchSnapshot = + params.launchSnapshot && shouldIgnoreStalePendingLaunchSnapshotSummary(params.launchSnapshot) + ? null + : (params.launchSnapshot ?? null); + if (launchSnapshot) { + return createLaunchStateSummary(launchSnapshot); } const bootstrapSnapshot = params.bootstrapSnapshot ?? null; - const projection = params.launchSummaryProjection ?? null; + const projection = + params.launchSummaryProjection && + shouldIgnoreStalePendingSummaryProjection(params.launchSummaryProjection) + ? null + : (params.launchSummaryProjection ?? null); if (!bootstrapSnapshot) { return projection; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index dce403c8..1b3c7f91 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2753,6 +2753,20 @@ function normalizeTeamProviderLike(providerId: unknown): TeamProviderId | undefi ); } +function teamRequestIncludesCodexMember( + request: Pick & Partial> +): boolean { + const defaultProviderId = normalizeTeamMemberProviderId(request.providerId) ?? 'anthropic'; + const members = Array.isArray(request.members) ? request.members : []; + return members.some((member) => { + const memberProviderId = + normalizeTeamMemberProviderId(member.providerId) ?? + normalizeTeamMemberProviderId((member as { provider?: unknown }).provider) ?? + defaultProviderId; + return memberProviderId === 'codex'; + }); +} + function buildEffectiveTeamMemberSpec( member: TeamMemberInput, defaults: { @@ -6469,6 +6483,113 @@ export class TeamProvisioningService { return ledgerRecord; } + private async rememberOpenCodeRuntimePidFromBridge(input: { + teamName: string; + memberName: string; + laneId: string; + runId?: string | null; + runtimeSessionId?: string | null; + runtimePid?: number; + reason: string; + }): Promise { + const runtimePid = normalizeRuntimePositiveInteger(input.runtimePid); + if (!runtimePid) { + return; + } + + const command = this.readProcessCommandByPid(runtimePid); + if (!command || !this.isOpenCodeServeCommand(command)) { + logger.debug( + `[${input.teamName}] Ignoring OpenCode bridge runtime pid ${runtimePid} for ${input.memberName}: process identity is not an active opencode serve host.` + ); + return; + } + + const observedAt = nowIso(); + try { + const changed = await this.enqueueLaunchStateStoreOperation(input.teamName, async () => { + const previous = await this.launchStateStore.read(input.teamName).catch(() => null); + const directMember = previous?.members[input.memberName]; + const laneMemberEntry = Object.entries(previous?.members ?? {}).find( + ([, member]) => member.laneId === input.laneId + ); + const previousMember = directMember ?? laneMemberEntry?.[1]; + const previousMemberKey = directMember ? input.memberName : laneMemberEntry?.[0]; + if (!previous || !previousMember) { + return false; + } + if (!isPersistedOpenCodeSecondaryLaneMember(previousMember)) { + return false; + } + if (previousMember.laneId && previousMember.laneId !== input.laneId) { + return false; + } + const previousRunId = previousMember.runtimeRunId?.trim(); + const incomingRunId = input.runId?.trim(); + if (previousRunId && incomingRunId && previousRunId !== incomingRunId) { + return false; + } + const previousSessionId = previousMember.runtimeSessionId?.trim(); + const incomingSessionId = input.runtimeSessionId?.trim(); + if (previousSessionId && incomingSessionId && previousSessionId !== incomingSessionId) { + return false; + } + if ( + previousMember.runtimePid === runtimePid && + previousMember.pidSource === 'opencode_bridge' + ) { + return false; + } + + const nextMember: PersistedTeamLaunchMemberState = { + ...previousMember, + runtimePid, + ...(incomingRunId ? { runtimeRunId: incomingRunId } : {}), + ...(incomingSessionId ? { runtimeSessionId: incomingSessionId } : {}), + pidSource: 'opencode_bridge', + lastRuntimeAliveAt: observedAt, + lastEvaluatedAt: observedAt, + sources: { + ...(previousMember.sources ?? {}), + processAlive: true, + }, + diagnostics: mergeRuntimeDiagnostics( + previousMember.diagnostics, + [`runtime pid: ${runtimePid}`, input.reason], + previousMember.runtimeDiagnostic + ), + }; + const nextSnapshot = createPersistedLaunchSnapshot({ + teamName: previous.teamName, + expectedMembers: previous.expectedMembers, + bootstrapExpectedMembers: previous.bootstrapExpectedMembers, + leadSessionId: previous.leadSessionId, + launchPhase: previous.launchPhase, + members: { + ...previous.members, + [previousMemberKey ?? previousMember.name]: nextMember, + }, + updatedAt: observedAt, + }); + await this.writeLaunchStateSnapshotNow(input.teamName, nextSnapshot); + return true; + }); + if (changed) { + this.invalidateRuntimeSnapshotCaches(input.teamName); + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: input.teamName, + ...(input.runId ? { runId: input.runId } : {}), + detail: input.memberName, + }); + } + } catch (error) { + logger.debug( + `[${input.teamName}] Failed to persist OpenCode bridge runtime pid ${runtimePid} for ${input.memberName}: ${getErrorMessage(error)}` + ); + } + } + private logOpenCodePromptDeliveryEvent( event: string, record: OpenCodePromptDeliveryLedgerRecord, @@ -6847,6 +6968,15 @@ export class TeamProvisioningService { actionMode: input.actionMode, taskRefs: input.taskRefs, }); + await this.rememberOpenCodeRuntimePidFromBridge({ + teamName, + memberName: canonicalMemberName, + laneId: laneIdentity.laneId, + runId: runtimeRunId, + runtimeSessionId: result.sessionId, + runtimePid: result.runtimePid, + reason: 'opencode_delivery_runtime_pid_observed', + }); return { delivered: result.ok, accepted: result.ok, @@ -7063,6 +7193,15 @@ export class TeamProvisioningService { taskRefs: input.taskRefs, prePromptCursor: ledgerRecord.prePromptCursor, }); + await this.rememberOpenCodeRuntimePidFromBridge({ + teamName, + memberName: canonicalMemberName, + laneId: laneIdentity.laneId, + runId: runtimeRunId, + runtimeSessionId: observed.sessionId, + runtimePid: observed.runtimePid, + reason: 'opencode_delivery_observe_runtime_pid_observed', + }); ledgerRecord = await ledger.applyObservation({ id: ledgerRecord.id, responseObservation: observed.responseObservation ?? { @@ -7170,6 +7309,15 @@ export class TeamProvisioningService { actionMode: input.actionMode, taskRefs: input.taskRefs, }); + await this.rememberOpenCodeRuntimePidFromBridge({ + teamName, + memberName: canonicalMemberName, + laneId: laneIdentity.laneId, + runId: runtimeRunId, + runtimeSessionId: result.sessionId, + runtimePid: result.runtimePid, + reason: 'opencode_delivery_runtime_pid_observed', + }); if (ledgerRecord && ledger) { ledgerRecord = await ledger.applyDeliveryResult({ id: ledgerRecord.id, @@ -14335,7 +14483,8 @@ export class TeamProvisioningService { const provisioningEnv = await this.buildProvisioningEnv( request.providerId, - request.providerBackendId + request.providerBackendId, + { includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) } ); const { env: shellEnv, @@ -15318,14 +15467,27 @@ export class TeamProvisioningService { member.bootstrapConfirmed !== true ); }; + const updatedAtMs = Date.parse(persistedLaunchState.updatedAt); + const activeLaunchLooksStale = + launchPhase === 'active' && + (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs >= MEMBER_LAUNCH_GRACE_MS); + const launchOutcomeIsSettledOrStale = launchPhase !== 'active' || activeLaunchLooksStale; + const hasPreviousExpectedTeammates = prevExpected.length > 0; + const previousTeammates = prevExpected.map((name) => prevMembers[name]); + const staleActiveLaunchHasNoLiveTeammates = + activeLaunchLooksStale && + hasPreviousExpectedTeammates && + !previousTeammates.some( + (member) => member?.runtimeAlive === true || member?.bootstrapConfirmed === true + ); const allTeammatesNeverSpawned = - launchPhase !== 'active' && - prevExpected.length > 0 && - prevExpected.every((name) => teammateWasNeverSpawned(prevMembers[name])); - if (allTeammatesNeverSpawned) { + launchOutcomeIsSettledOrStale && + hasPreviousExpectedTeammates && + previousTeammates.every(teammateWasNeverSpawned); + if (allTeammatesNeverSpawned || staleActiveLaunchHasNoLiveTeammates) { skipResume = true; logger.info( - `[${request.teamName}] Previous launch had no teammates successfully spawned — ` + + `[${request.teamName}] Previous launch cannot be resumed safely - ` + `skipping session resume to allow full bootstrap` ); } @@ -15428,7 +15590,8 @@ export class TeamProvisioningService { const provisioningEnv = await this.buildProvisioningEnv( request.providerId, - request.providerBackendId + request.providerBackendId, + { includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) } ); const { env: shellEnv, @@ -17797,6 +17960,53 @@ export class TeamProvisioningService { } } + private markUnconfirmedBootstrapMembersFailed( + run: ProvisioningRun, + reason: string, + options?: { cleanupRequested?: boolean } + ): void { + const failedAt = nowIso(); + const baseReason = reason.trim() || 'Deterministic bootstrap failed before teammate check-in.'; + for (const expected of run.expectedMembers) { + const prev = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry(); + if (prev.bootstrapConfirmed || prev.skippedForLaunch) { + continue; + } + + const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process'; + const hardFailureReason = runtimeWasAlive + ? `${baseReason} Runtime process was alive after bootstrap failure${ + options?.cleanupRequested ? '; launch-owned cleanup requested.' : '.' + }` + : baseReason; + const next: MemberSpawnStatusEntry = { + ...prev, + status: 'error', + updatedAt: failedAt, + error: hardFailureReason, + hardFailure: true, + hardFailureReason, + bootstrapConfirmed: false, + bootstrapStalled: undefined, + runtimeAlive: options?.cleanupRequested ? false : prev.runtimeAlive, + livenessSource: options?.cleanupRequested ? undefined : prev.livenessSource, + runtimeDiagnostic: runtimeWasAlive + ? options?.cleanupRequested + ? 'Bootstrap failed before teammate check-in; launch-owned runtime cleanup requested.' + : 'Bootstrap failed before teammate check-in while runtime process was still alive.' + : prev.runtimeDiagnostic, + runtimeDiagnosticSeverity: runtimeWasAlive ? 'warning' : prev.runtimeDiagnosticSeverity, + launchState: 'failed_to_start', + }; + + run.memberSpawnStatuses.set(expected, next); + this.appendMemberBootstrapDiagnostic(run, expected, hardFailureReason); + if (this.isCurrentTrackedRun(run)) { + this.emitMemberSpawnChange(run, expected); + } + } + } + private async attachLiveRuntimeMetadataToStatuses( teamName: string, statuses: Record, @@ -22293,8 +22503,25 @@ export class TeamProvisioningService { cliLogsTail: extractCliLogsFromRun(run), }); run.onProgress(progress); + const hasConfirmedBootstrapMember = Array.from(run.memberSpawnStatuses.values()).some( + (member) => member.bootstrapConfirmed === true + ); + const shouldCleanupUnconfirmedLaunchRuntimes = run.isLaunch && !hasConfirmedBootstrapMember; + this.markUnconfirmedBootstrapMembersFailed(run, classification.normalizedReason, { + cleanupRequested: shouldCleanupUnconfirmedLaunchRuntimes, + }); + if (shouldCleanupUnconfirmedLaunchRuntimes) { + this.stopPersistentTeamMembers(run.teamName); + } run.processKilled = true; killTeamProcess(run.child); + void this.persistLaunchStateSnapshot(run, 'finished').catch((error: unknown) => { + logger.warn( + `[${run.teamName}] Failed to persist failed bootstrap launch snapshot: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); this.cleanupRun(run); return true; } @@ -24691,6 +24918,11 @@ export class TeamProvisioningService { } if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) { + this.markUnconfirmedBootstrapMembersFailed( + run, + 'Launch ended before teammate bootstrap completed.', + { cleanupRequested: true } + ); void this.persistLaunchStateSnapshot(run, 'finished'); } this.resetRuntimeToolActivity(run); @@ -25261,7 +25493,8 @@ export class TeamProvisioningService { private async buildProvisioningEnv( providerId: TeamProviderId | undefined = 'anthropic', - providerBackendId?: string | null + providerBackendId?: string | null, + options?: { includeCodexTeammateAuth?: boolean } ): Promise { const shellEnv = await resolveInteractiveShellEnv(); // getHomeDir() uses Electron's app.getPath('home') which handles Unicode @@ -25313,6 +25546,13 @@ export class TeamProvisioningService { }); const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId]; const providerEnv = providerEnvResult.env; + if (options?.includeCodexTeammateAuth && resolvedProviderId !== 'codex') { + await this.providerConnectionService.augmentConfiguredConnectionEnv( + providerEnv, + 'codex', + getConfiguredRuntimeBackend('codex') + ); + } Object.assign(providerEnv, await this.buildRuntimeTurnSettledEnvironment(resolvedProviderId)); const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); diff --git a/test/main/services/team/TeamFsWorker.integration.test.ts b/test/main/services/team/TeamFsWorker.integration.test.ts index e2085b93..5ffa9e57 100644 --- a/test/main/services/team/TeamFsWorker.integration.test.ts +++ b/test/main/services/team/TeamFsWorker.integration.test.ts @@ -343,7 +343,8 @@ describe('team-fs-worker integration', () => { JSON.stringify({ version: 1, teamName, - updatedAt: '2026-05-02T12:00:00.000Z', + updatedAt: new Date().toISOString(), + launchPhase: 'active', teamLaunchState: 'partial_pending', expectedMemberCount: 1, pendingCount: 1, @@ -376,6 +377,118 @@ describe('team-fs-worker integration', () => { } }); + it('ignores stale pending launch-summary fallbacks so offline teams do not stay reconciling', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'stale-pending-summary-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Stale Pending Summary Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'launch-summary.json'), + JSON.stringify({ + version: 1, + teamName, + updatedAt: '2026-04-09T20:35:57.962Z', + launchUpdatedAt: '2026-04-09T20:35:57.962Z', + teamLaunchState: 'partial_pending', + expectedMemberCount: 1, + pendingCount: 1, + permissionPendingCount: 0, + }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + const first = await callListTeams(worker, tempDir); + expect(first.teams[0]).toMatchObject({ teamName }); + expect(first.teams[0]).not.toMatchObject({ + teamLaunchState: 'partial_pending', + }); + expect(first.diag?.cacheMisses).toBe(1); + expect(first.diag?.cacheWriteSkips).toBe(0); + } finally { + await worker.terminate(); + } + }); + + it('rereads launch-summary after caching a stale pending fallback as settled', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'stale-pending-cache-invalidation-team'; + const teamDir = path.join(tempDir, teamName); + const launchSummaryPath = path.join(teamDir, 'launch-summary.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Stale Pending Cache Invalidation Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + launchSummaryPath, + JSON.stringify({ + version: 1, + teamName, + updatedAt: '2026-04-09T20:35:57.962Z', + launchUpdatedAt: '2026-04-09T20:35:57.962Z', + teamLaunchState: 'partial_pending', + expectedMemberCount: 1, + pendingCount: 1, + permissionPendingCount: 0, + }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + const stale = await callListTeams(worker, tempDir); + expect(stale.teams[0]).toMatchObject({ teamName }); + expect(stale.teams[0]).not.toMatchObject({ + teamLaunchState: 'partial_pending', + }); + expect(stale.diag?.cacheMisses).toBe(1); + expect(stale.diag?.cacheWriteSkips).toBe(0); + + await fs.writeFile( + launchSummaryPath, + JSON.stringify({ + version: 1, + teamName, + updatedAt: new Date().toISOString(), + launchPhase: 'active', + teamLaunchState: 'partial_pending', + expectedMemberCount: 1, + pendingCount: 1, + permissionPendingCount: 0, + }), + 'utf8' + ); + + const fresh = await callListTeams(worker, tempDir); + expect(fresh.teams[0]).toMatchObject({ + teamName, + teamLaunchState: 'partial_pending', + pendingCount: 1, + }); + expect(fresh.diag?.cacheHits).toBe(0); + expect(fresh.diag?.cacheMisses).toBe(1); + expect(fresh.diag?.cacheWriteSkips).toBe(1); + } finally { + await worker.terminate(); + } + }); + it('reuses unchanged parsed tasks and rereads changed task files by fingerprint', async () => { const workerPath = await getWorkerPath(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); diff --git a/test/main/services/team/TeamLaunchSummaryProjection.test.ts b/test/main/services/team/TeamLaunchSummaryProjection.test.ts index abbdc3fe..b9319eb5 100644 --- a/test/main/services/team/TeamLaunchSummaryProjection.test.ts +++ b/test/main/services/team/TeamLaunchSummaryProjection.test.ts @@ -49,6 +49,192 @@ describe('TeamLaunchSummaryProjection', () => { expect(summary).toBeNull(); }); + it('ignores stale terminal launch-state pending summaries before projecting renderer copy', () => { + const summary = choosePreferredLaunchStateSummary({ + launchSnapshot: { + version: 2, + teamName: 'atlas-hq-2', + updatedAt: '2026-04-09T20:35:57.962Z', + launchPhase: 'finished', + expectedMembers: ['alice', 'jack'], + members: { + alice: { + name: 'alice', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + jack: { + name: 'jack', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 2, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_pending', + } as never, + bootstrapSnapshot: null, + launchSummaryProjection: null, + }); + + expect(summary).toBeNull(); + }); + + it('ignores stale active launch-state pending summaries without outstanding permissions', () => { + const summary = choosePreferredLaunchStateSummary({ + launchSnapshot: { + version: 2, + teamName: 'vector-room-13', + updatedAt: '2026-04-09T20:35:57.962Z', + launchPhase: 'active', + expectedMembers: ['alice', 'bob'], + members: { + alice: { + name: 'alice', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + bob: { + name: 'bob', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 2, + failedCount: 0, + runtimeAlivePendingCount: 0, + permissionPendingCount: 0, + }, + teamLaunchState: 'partial_pending', + } as never, + launchSummaryProjection: null, + }); + + expect(summary).toBeNull(); + }); + + it('keeps stale launch-state pending summaries when permission approval is outstanding', () => { + const summary = choosePreferredLaunchStateSummary({ + launchSnapshot: { + version: 2, + teamName: 'permission-team', + updatedAt: '2026-04-09T20:35:57.962Z', + launchPhase: 'active', + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'permission_pending', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-09T20:35:57.962Z', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + permissionPendingCount: 1, + }, + teamLaunchState: 'partial_pending', + } as never, + launchSummaryProjection: null, + }); + + expect(summary).toMatchObject({ + teamLaunchState: 'partial_pending', + permissionPendingCount: 1, + }); + }); + + it('ignores stale pending launch-summary projections when canonical launch truth is missing', () => { + const summary = choosePreferredLaunchStateSummary({ + launchSummaryProjection: { + version: 1, + teamName: 'atlas-hq-2', + updatedAt: '2026-04-09T20:35:57.962Z', + launchUpdatedAt: '2026-04-09T20:35:57.962Z', + teamLaunchState: 'partial_pending', + expectedMemberCount: 2, + confirmedCount: 0, + pendingCount: 2, + failedCount: 0, + runtimeProcessPendingCount: 0, + permissionPendingCount: 0, + }, + }); + + expect(summary).toBeNull(); + }); + + it('ignores stale active pending launch-summary projections without outstanding permissions', () => { + const summary = choosePreferredLaunchStateSummary({ + launchSummaryProjection: { + version: 1, + teamName: 'vector-room-13', + updatedAt: '2026-04-09T20:35:57.962Z', + launchUpdatedAt: '2026-04-09T20:35:57.962Z', + launchPhase: 'active', + teamLaunchState: 'partial_pending', + expectedMemberCount: 2, + confirmedCount: 0, + pendingCount: 2, + failedCount: 0, + runtimeProcessPendingCount: 0, + permissionPendingCount: 0, + }, + }); + + expect(summary).toBeNull(); + }); + + it('keeps stale pending launch-summary projections when permission approval is outstanding', () => { + const summary = choosePreferredLaunchStateSummary({ + launchSummaryProjection: { + version: 1, + teamName: 'permission-team', + updatedAt: '2026-04-09T20:35:57.962Z', + launchUpdatedAt: '2026-04-09T20:35:57.962Z', + teamLaunchState: 'partial_pending', + expectedMemberCount: 1, + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + permissionPendingCount: 1, + }, + }); + + expect(summary).toMatchObject({ + teamLaunchState: 'partial_pending', + permissionPendingCount: 1, + }); + }); + it('prefers a mixed-aware persisted summary projection over a newer but poorer bootstrap snapshot', () => { const bootstrapSnapshot = { version: 2, @@ -219,6 +405,7 @@ describe('TeamLaunchSummaryProjection', () => { confirmedMemberCount: 1, missingMembers: ['bob'], failedCount: 1, + launchPhase: 'finished', teamLaunchState: 'partial_failure', }); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 9d763590..2b5ecbe3 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -245,12 +245,16 @@ function writeLaunchConfig( function writeLaunchState( teamName: string, leadSessionId: string, - members: Record> + members: Record>, + options?: { + launchPhase?: 'active' | 'finished'; + updatedAt?: string; + } ): void { const snapshot = createPersistedLaunchSnapshot({ teamName, leadSessionId, - launchPhase: 'finished', + launchPhase: options?.launchPhase ?? 'finished', expectedMembers: Object.keys(members), members: Object.fromEntries( Object.entries(members).map(([name, member]) => [ @@ -268,6 +272,7 @@ function writeLaunchState( }, ]) ) as any, + ...(options?.updatedAt ? { updatedAt: options.updatedAt } : {}), }); fs.writeFileSync( getTeamLaunchStatePath(teamName), @@ -709,6 +714,65 @@ describe('TeamProvisioningService', () => { expect(complete).toHaveBeenCalledWith(run); }); + + it('finalizes unconfirmed launch members as failed before cleanup removes the run', () => { + const svc = new TeamProvisioningService(); + const run = createClaudeLogsRun({ + runId: 'run-cleanup-finalizes-launch', + teamName: 'cleanup-finalizes-launch-team', + isLaunch: true, + provisioningComplete: false, + cancelRequested: false, + expectedMembers: ['alice', 'bob'], + provisioningOutputParts: [], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + }), + ], + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + livenessSource: 'process', + }), + ], + ]), + }); + const persist = vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null); + + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + + (svc as any).cleanupRun(run); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + }); + expect((svc as any).buildLiveLaunchSnapshotForRun(run, 'finished')).toMatchObject({ + teamLaunchState: 'partial_failure', + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + }, + }); + expect(persist).toHaveBeenCalledWith(run, 'finished'); + }); }); describe('member spawn status launch reads', () => { @@ -4069,6 +4133,220 @@ describe('TeamProvisioningService', () => { }); }); + it('persists verified OpenCode bridge runtime pids so member cards can show memory', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + runtimePid: 456, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + vi.spyOn(svc as any, 'readProcessCommandByPid').mockReturnValue( + '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 45678' + ); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + writeLaunchState('team-a', 'lead-session', { + bob: { + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: 'opencode-run-bob', + runtimeSessionId: 'oc-session-bob', + livenessKind: 'confirmed_bootstrap', + }, + }); + await writeDefaultBobOpenCodeBootstrapEvidence(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([ + { + pid: 456, + ppid: 1, + command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 45678', + }, + ]); + vi.mocked(pidusage).mockReset(); + vi.mocked(pidusage).mockImplementation( + async (target: number | string | Array) => { + if (Array.isArray(target)) { + return { + '456': createPidusageStat(456, 456_000_000), + } as any; + } + if (target === 456) { + return createPidusageStat(456, 456_000_000) as any; + } + throw new Error(`Unexpected pidusage target: ${String(target)}`); + } + ); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-1', + }) + ).resolves.toMatchObject({ delivered: true }); + + const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath('team-a'), 'utf8')); + expect(persisted.members.bob).toMatchObject({ + runtimePid: 456, + pidSource: 'opencode_bridge', + runtimeSessionId: 'oc-session-bob', + }); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('team-a'); + expect(snapshot.members.bob).toMatchObject({ + memberName: 'bob', + providerId: 'opencode', + pid: 456, + runtimePid: 456, + rssBytes: 456_000_000, + livenessKind: 'runtime_process_candidate', + }); + }); + + it('does not persist OpenCode bridge runtime pids when process identity is not verified', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + runtimePid: 456, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + vi.spyOn(svc as any, 'readProcessCommandByPid').mockReturnValue('/usr/bin/yes'); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + writeLaunchState('team-a', 'lead-session', { + bob: { + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: 'opencode-run-bob', + runtimeSessionId: 'oc-session-bob', + livenessKind: 'confirmed_bootstrap', + }, + }); + await writeDefaultBobOpenCodeBootstrapEvidence(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-1', + }) + ).resolves.toMatchObject({ delivered: true }); + + const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath('team-a'), 'utf8')); + expect(persisted.members.bob.runtimePid).toBeUndefined(); + expect(persisted.members.bob.pidSource).toBeUndefined(); + }); + it('uses snapshot config reads for OpenCode member delivery routing', async () => { const getConfig = vi.fn(async () => { throw new Error('verified config read should not be used for delivery routing'); @@ -10988,6 +11266,136 @@ describe('TeamProvisioningService', () => { expect(launchArgs).not.toContain(leadSessionId); }); + it('skips --resume when a stale active launch state shows no teammate ever spawned', async () => { + allowConsoleLogs(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z')); + + const teamName = 'resume-skip-stale-active-team'; + const leadSessionId = 'lead-session-skip-stale-active'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']); + writeLaunchState( + teamName, + leadSessionId, + { + alice: { + launchState: 'starting', + hardFailure: false, + }, + bob: { + launchState: 'starting', + hardFailure: false, + }, + }, + { + launchPhase: 'active', + updatedAt: '2026-05-03T12:00:00.000Z', + } + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('launch spawn EINVAL'); + }); + + 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' }, { name: 'bob' }], + 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).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + + await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow( + 'launch spawn EINVAL' + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toBeTruthy(); + expect(launchArgs).not.toContain('--resume'); + expect(launchArgs).not.toContain(leadSessionId); + }); + + it('skips --resume when a stale active launch has accepted but no live teammates', async () => { + allowConsoleLogs(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z')); + + const teamName = 'resume-skip-stale-active-dead-team'; + const leadSessionId = 'lead-session-skip-stale-active-dead'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']); + writeLaunchState( + teamName, + leadSessionId, + { + alice: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-05-03T12:00:15.000Z', + hardFailure: false, + }, + bob: { + launchState: 'starting', + hardFailure: false, + }, + }, + { + launchPhase: 'active', + updatedAt: '2026-05-03T12:00:00.000Z', + } + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('launch spawn EINVAL'); + }); + + 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' }, { name: 'bob' }], + 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).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + + await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow( + 'launch spawn EINVAL' + ); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toBeTruthy(); + expect(launchArgs).not.toContain('--resume'); + expect(launchArgs).not.toContain(leadSessionId); + }); + it('keeps --resume when a teammate had an accepted spawn before failing bootstrap', async () => { allowConsoleLogs(); const teamName = 'resume-keep-team';