diff --git a/src/main/services/team/TeamBootstrapStateReader.ts b/src/main/services/team/TeamBootstrapStateReader.ts index 66144d3d..6226a206 100644 --- a/src/main/services/team/TeamBootstrapStateReader.ts +++ b/src/main/services/team/TeamBootstrapStateReader.ts @@ -332,7 +332,8 @@ function toIso(value: unknown, fallback: string): string { function normalizeBootstrapMemberState( memberName: string, raw: RawBootstrapMemberState, - updatedAt: string + updatedAt: string, + runtimeRunId?: string ): PersistedTeamLaunchMemberState { const status = typeof raw.status === 'string' ? raw.status : 'pending'; const hardFailure = status === 'failed'; @@ -363,6 +364,7 @@ function normalizeBootstrapMemberState( typeof raw.failureReason === 'string' && raw.failureReason.trim().length > 0 ? raw.failureReason.trim() : undefined, + ...(runtimeRunId ? { runtimeRunId } : {}), firstSpawnAcceptedAt: agentToolAccepted ? toIso(raw.lastAttemptAt, updatedAt) : undefined, lastHeartbeatAt: bootstrapConfirmed ? toIso(raw.lastObservedAt, updatedAt) : undefined, lastRuntimeAliveAt: runtimeAlive ? toIso(raw.lastObservedAt, updatedAt) : undefined, @@ -622,6 +624,7 @@ export async function readBootstrapLaunchSnapshot( } try { const updatedAt = toIso(raw.updatedAt, new Date().toISOString()); + const runtimeRunId = typeof raw.runId === 'string' ? raw.runId.trim() : ''; const rawMembers = Array.isArray(raw.members) ? raw.members : []; const members: Record = {}; const expectedMembers: string[] = []; @@ -632,7 +635,12 @@ export async function readBootstrapLaunchSnapshot( const memberName = typeof rawMember.name === 'string' ? rawMember.name.trim() : ''; if (!memberName || memberName === 'team-lead' || memberName === 'user') continue; expectedMembers.push(memberName); - members[memberName] = normalizeBootstrapMemberState(memberName, rawMember, updatedAt); + members[memberName] = normalizeBootstrapMemberState( + memberName, + rawMember, + updatedAt, + runtimeRunId || undefined + ); } const terminal = diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 511754d1..0a60789d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -939,6 +939,17 @@ function buildRuntimeDiagnosticForSpawn( : 'process table unavailable'; } +function isConfirmedBootstrapStaleRuntimeDiagnostic(reason?: string): boolean { + const text = reason?.trim(); + return text === 'persisted runtime pid is not alive'; +} + +function shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(reason?: string): boolean { + return ( + isAutoClearableLaunchFailureReason(reason) || isConfirmedBootstrapStaleRuntimeDiagnostic(reason) + ); +} + function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined { const refs = normalizeRuntimeStringArray(value); return refs.length > 0 @@ -9676,9 +9687,8 @@ export class TeamProvisioningService { } private getRunLeadName(run: ProvisioningRun): string { - return ( - run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead' - ); + const members = Array.isArray(run.request?.members) ? run.request.members : []; + return members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; } private rememberRecentCrossTeamLeadDeliveryMessageIds( @@ -23396,34 +23406,62 @@ export class TeamProvisioningService { (current.launchState === 'runtime_pending_bootstrap' || current.launchState === 'failed_to_start') && isProcessBootstrapTransportDiagnostic(current.runtimeDiagnostic); - const runtimeDiagnostic = shouldPreserveProcessBootstrapTransportDiagnostic - ? current.runtimeDiagnostic - : buildRuntimeDiagnosticForSpawn(metadata); - const metadataLivenessKind = - current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive' - ? metadata.livenessKind === 'runtime_process' || - metadata.livenessKind === 'confirmed_bootstrap' - ? metadata.livenessKind - : current.livenessKind - : metadata.livenessKind; + const hasStrongEvidence = isStrongRuntimeEvidence(metadata); + const hasConfirmedBootstrap = + current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'; + const shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap = + hasConfirmedBootstrap && !hasStrongEvidence; + let runtimeDiagnostic: string | undefined; + let runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity | undefined; + if (shouldPreserveProcessBootstrapTransportDiagnostic) { + runtimeDiagnostic = current.runtimeDiagnostic; + runtimeDiagnosticSeverity = current.runtimeDiagnosticSeverity; + } else if (shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap) { + if ( + current.runtimeDiagnostic && + !shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(current.runtimeDiagnostic) + ) { + runtimeDiagnostic = current.runtimeDiagnostic; + runtimeDiagnosticSeverity = current.runtimeDiagnosticSeverity; + } else { + const metadataRuntimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); + if ( + metadataRuntimeDiagnostic && + !shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(metadataRuntimeDiagnostic) + ) { + runtimeDiagnostic = metadataRuntimeDiagnostic; + runtimeDiagnosticSeverity = metadata.runtimeDiagnosticSeverity; + } + } + } else { + runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); + runtimeDiagnosticSeverity = metadata.runtimeDiagnosticSeverity; + } + const metadataLivenessKind = hasConfirmedBootstrap + ? metadata.livenessKind === 'runtime_process' || + metadata.livenessKind === 'confirmed_bootstrap' + ? metadata.livenessKind + : current.livenessKind === 'stale_metadata' || current.livenessKind === 'registered_only' + ? 'confirmed_bootstrap' + : (current.livenessKind ?? 'confirmed_bootstrap') + : metadata.livenessKind; const nextEntry: MemberSpawnStatusEntry = { ...current, ...(metadata.model ? { runtimeModel: metadata.model } : {}), ...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}), - ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), + ...(runtimeDiagnostic || shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap + ? { runtimeDiagnostic } + : {}), ...(shouldPreserveProcessBootstrapTransportDiagnostic - ? { runtimeDiagnosticSeverity: current.runtimeDiagnosticSeverity } - : metadata.runtimeDiagnosticSeverity - ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } + ? { runtimeDiagnosticSeverity } + : runtimeDiagnosticSeverity || shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap + ? { runtimeDiagnosticSeverity } : {}), livenessLastCheckedAt: nowIso(), }; const failureReason = current.hardFailureReason ?? current.error; - const hasStrongEvidence = isStrongRuntimeEvidence(metadata); const hasWeakEvidence = - metadata.livenessKind != null && - !isStrongRuntimeEvidence(metadata) && - current.bootstrapConfirmed !== true; + metadata.livenessKind != null && !hasStrongEvidence && current.bootstrapConfirmed !== true; if ( hasStrongEvidence && !openCodeSecondaryBootstrapPending && @@ -25827,7 +25865,13 @@ export class TeamProvisioningService { } const current = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); - if (!isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')) { + if ( + !isBootstrapMemberEvidenceCurrentForMember( + { ...current, runtimeRunId: run.runId }, + bootstrapMember, + 'confirmation' + ) + ) { continue; } if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) { @@ -25920,7 +25964,13 @@ export class TeamProvisioningService { if (!current || bootstrapMember?.bootstrapConfirmed !== true) { continue; } - if (!isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')) { + if ( + !isBootstrapMemberEvidenceCurrentForMember( + { ...current, runtimeRunId: run.runId }, + bootstrapMember, + 'confirmation' + ) + ) { continue; } if ( @@ -28278,6 +28328,105 @@ export class TeamProvisioningService { return false; } + private needsConfirmedBootstrapDiagnosticReconcile( + snapshot: PersistedTeamLaunchSnapshot | null + ): boolean { + if (!snapshot) { + return false; + } + for (const member of Object.values(snapshot.members)) { + if ( + member?.bootstrapConfirmed !== true || + member.hardFailure === true || + isPersistedOpenCodeSecondaryLaneMember(member) + ) { + continue; + } + if ( + member.livenessKind === 'stale_metadata' || + member.livenessKind === 'registered_only' || + member.pidSource === 'persisted_metadata' || + shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(member.runtimeDiagnostic) + ) { + return true; + } + } + return false; + } + + private cleanConfirmedBootstrapRuntimeDiagnostics( + snapshot: PersistedTeamLaunchSnapshot | null + ): PersistedTeamLaunchSnapshot | null { + if (!snapshot) { + return null; + } + + let changed = false; + const updatedAt = nowIso(); + const members: Record = { ...snapshot.members }; + for (const memberName of this.getPersistedLaunchMemberNames(snapshot)) { + const current = members[memberName]; + if ( + !current || + current.bootstrapConfirmed !== true || + current.hardFailure === true || + isPersistedOpenCodeSecondaryLaneMember(current) + ) { + continue; + } + + const hasConfirmedBootstrapStaleRuntimeState = + current.livenessKind === 'stale_metadata' || + current.livenessKind === 'registered_only' || + current.pidSource === 'persisted_metadata' || + shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(current.runtimeDiagnostic) || + current.bootstrapStalled === true; + if (!hasConfirmedBootstrapStaleRuntimeState) { + continue; + } + + const next: PersistedTeamLaunchMemberState = { + ...current, + livenessKind: + current.livenessKind === 'stale_metadata' || + current.livenessKind === 'registered_only' || + current.livenessKind == null + ? 'confirmed_bootstrap' + : current.livenessKind, + pidSource: + current.pidSource === 'persisted_metadata' || current.pidSource == null + ? 'runtime_bootstrap' + : current.pidSource, + bootstrapStalled: undefined, + diagnostics: undefined, + lastEvaluatedAt: updatedAt, + }; + if (shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(next.runtimeDiagnostic)) { + next.runtimeDiagnostic = undefined; + next.runtimeDiagnosticSeverity = undefined; + } else if (!next.runtimeDiagnostic) { + next.runtimeDiagnosticSeverity = undefined; + } + next.launchState = deriveMemberLaunchState(next); + members[memberName] = next; + changed = true; + } + + if (!changed) { + return snapshot; + } + + return createPersistedLaunchSnapshot({ + teamName: snapshot.teamName, + expectedMembers: snapshot.expectedMembers, + bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers, + leadSessionId: snapshot.leadSessionId, + launchPhase: snapshot.launchPhase, + members, + updatedAt, + }); + } + private async reconcilePersistedLaunchState(teamName: string): Promise<{ snapshot: ReturnType | null; statuses: Record; @@ -28315,11 +28464,15 @@ export class TeamProvisioningService { const promotedRecoveredMixedSnapshot = promoteOpenCodePersistedFailureReasonsFromDiagnostics( stableRecoveredMixedSnapshotWithCommittedEvidence ); + const cleanedRecoveredMixedSnapshot = this.cleanConfirmedBootstrapRuntimeDiagnostics( + promotedRecoveredMixedSnapshot + ); const stableRecoveredMixedSnapshot = - promotedRecoveredMixedSnapshot && - promotedRecoveredMixedSnapshot !== stableRecoveredMixedSnapshotWithCommittedEvidence - ? await this.writeLaunchStateSnapshot(teamName, promotedRecoveredMixedSnapshot) - : promotedRecoveredMixedSnapshot; + cleanedRecoveredMixedSnapshot && + (promotedRecoveredMixedSnapshot !== stableRecoveredMixedSnapshotWithCommittedEvidence || + cleanedRecoveredMixedSnapshot !== promotedRecoveredMixedSnapshot) + ? await this.writeLaunchStateSnapshot(teamName, cleanedRecoveredMixedSnapshot) + : cleanedRecoveredMixedSnapshot; const filteredBootstrapSnapshot = bootstrapSnapshot ? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers) : null; @@ -28331,6 +28484,7 @@ export class TeamProvisioningService { stableRecoveredMixedSnapshot, overlaidBootstrapSnapshot ) && + !this.needsConfirmedBootstrapDiagnosticReconcile(stableRecoveredMixedSnapshot) && !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(stableRecoveredMixedSnapshot)) ) { return { @@ -28361,15 +28515,18 @@ export class TeamProvisioningService { ); const shouldPersistFailureReasonPromotion = promotedPersisted !== filteredPersistedWithBootstrapStall; + const cleanedPersisted = this.cleanConfirmedBootstrapRuntimeDiagnostics(promotedPersisted); + const shouldPersistConfirmedBootstrapDiagnosticCleanup = cleanedPersisted !== promotedPersisted; const shouldPersistBootstrapStallOverlay = filteredPersistedWithBootstrapStall !== filteredPersisted; const persistedWithCommittedEvidence = - promotedPersisted && + cleanedPersisted && (shouldPersistCommittedEvidenceOverlay || shouldPersistFailureReasonPromotion || + shouldPersistConfirmedBootstrapDiagnosticCleanup || shouldPersistBootstrapStallOverlay) - ? await this.writeLaunchStateSnapshot(teamName, promotedPersisted) - : promotedPersisted; + ? await this.writeLaunchStateSnapshot(teamName, cleanedPersisted) + : cleanedPersisted; const preferredSnapshot = choosePreferredLaunchSnapshot( overlaidBootstrapSnapshot, persistedWithCommittedEvidence @@ -28413,6 +28570,7 @@ export class TeamProvisioningService { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); let configMembers = new Set(); + let configBootstrapRunIds = new Map(); let leadName = 'team-lead'; try { const raw = await tryReadRegularFileUtf8(configPath, { @@ -28421,14 +28579,26 @@ export class TeamProvisioningService { }); if (raw) { const config = JSON.parse(raw) as { - members?: { name?: string; agentType?: string }[]; + members?: { name?: string; agentType?: string; bootstrapRunId?: string }[]; }; - leadName = config.members?.find((member) => isLeadMember(member))?.name?.trim() || leadName; + const configuredMembers = config.members ?? []; + leadName = + configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || leadName; configMembers = new Set( - (config.members ?? []) + configuredMembers .map((member) => (typeof member?.name === 'string' ? member.name.trim() : '')) .filter((name) => name.length > 0 && !isLeadMember({ name })) ); + configBootstrapRunIds = new Map( + configuredMembers.flatMap((member) => { + const name = typeof member?.name === 'string' ? member.name.trim() : ''; + const runId = + typeof member?.bootstrapRunId === 'string' ? member.bootstrapRunId.trim() : ''; + return name.length > 0 && runId.length > 0 && !isLeadMember({ name }) + ? [[name, runId] as const] + : []; + }) + ); } } catch { // best-effort @@ -28449,6 +28619,7 @@ export class TeamProvisioningService { persistedWithCommittedEvidence, overlaidBootstrapSnapshot ) && + !this.needsConfirmedBootstrapDiagnosticReconcile(persistedWithCommittedEvidence) && !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(persistedWithCommittedEvidence)) ) { return { @@ -28473,10 +28644,23 @@ export class TeamProvisioningService { lastEvaluatedAt: now, }; const isOpenCodeSecondaryLaneMember = isPersistedOpenCodeSecondaryLaneMember(current); + const matchedConfigNames = [...configMembers].filter((name) => + matchesObservedMemberNameForExpected(name, expected) + ); + const configBootstrapRunId = matchedConfigNames + .map((name) => configBootstrapRunIds.get(name)) + .find((runId): runId is string => typeof runId === 'string' && runId.length > 0); + const currentBootstrapEvidenceBoundary = configBootstrapRunId + ? { ...current, runtimeRunId: configBootstrapRunId } + : current; if ( bootstrapMember?.agentToolAccepted && !current.agentToolAccepted && - isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'acceptance') + isBootstrapMemberEvidenceCurrentForMember( + currentBootstrapEvidenceBoundary, + bootstrapMember, + 'acceptance' + ) ) { current.agentToolAccepted = true; current.firstSpawnAcceptedAt = @@ -28486,14 +28670,15 @@ export class TeamProvisioningService { bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed && !isOpenCodeSecondaryLaneMember && - isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation') + isBootstrapMemberEvidenceCurrentForMember( + currentBootstrapEvidenceBoundary, + bootstrapMember, + 'confirmation' + ) ) { current.bootstrapConfirmed = true; current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt; } - const matchedConfigNames = [...configMembers].filter((name) => - matchesObservedMemberNameForExpected(name, expected) - ); const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) => matchesObservedMemberNameForExpected(name, expected) ); @@ -28619,6 +28804,25 @@ export class TeamProvisioningService { finalTimeoutReached: graceExpired, }); } + if (current.bootstrapConfirmed && !current.hardFailure && !isOpenCodeSecondaryLaneMember) { + current.livenessKind = + current.livenessKind === 'stale_metadata' || + current.livenessKind === 'registered_only' || + current.livenessKind == null + ? 'confirmed_bootstrap' + : current.livenessKind; + current.pidSource = + current.pidSource === 'persisted_metadata' || current.pidSource == null + ? 'runtime_bootstrap' + : current.pidSource; + if (shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(current.runtimeDiagnostic)) { + current.runtimeDiagnostic = undefined; + current.runtimeDiagnosticSeverity = undefined; + } else if (!current.runtimeDiagnostic) { + current.runtimeDiagnosticSeverity = undefined; + } + current.bootstrapStalled = undefined; + } if ( isOpenCodeSecondaryLaneMember && shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now()) diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts index 13e9d308..6e972fd7 100644 --- a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts +++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts @@ -23,6 +23,7 @@ export const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC = export const OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC = 'OpenCode app-managed bootstrap evidence is pending after materialized session.'; +const BOOTSTRAP_EVIDENCE_BOUNDARY_SKEW_MS = 10_000; const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN = /\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i; @@ -584,13 +585,29 @@ export function isRecoverableOpenCodeRuntimeEvidence( } export function isBootstrapMemberEvidenceCurrentForMember( - current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string }, + current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string; runtimeRunId?: string }, bootstrapMember: Pick< PersistedTeamLaunchMemberState, - 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'lastRuntimeAliveAt' | 'lastEvaluatedAt' + | 'firstSpawnAcceptedAt' + | 'lastHeartbeatAt' + | 'lastRuntimeAliveAt' + | 'lastEvaluatedAt' + | 'runtimeRunId' >, evidenceKind: 'acceptance' | 'confirmation' ): boolean { + const currentRuntimeRunId = + typeof current.runtimeRunId === 'string' ? current.runtimeRunId.trim() : ''; + const bootstrapRuntimeRunId = + typeof bootstrapMember.runtimeRunId === 'string' ? bootstrapMember.runtimeRunId.trim() : ''; + if ( + currentRuntimeRunId.length > 0 && + bootstrapRuntimeRunId.length > 0 && + currentRuntimeRunId !== bootstrapRuntimeRunId + ) { + return false; + } + const bootstrapFirstSpawnAcceptedMs = Date.parse(bootstrapMember.firstSpawnAcceptedAt ?? ''); const bootstrapLastEvaluatedMs = Date.parse(bootstrapMember.lastEvaluatedAt ?? ''); const hasDurableBootstrapSpawnAcceptedAt = @@ -615,5 +632,15 @@ export function isBootstrapMemberEvidenceCurrentForMember( Number.isFinite(firstSpawnAcceptedMs) && (!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs); const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN; - return !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs; + const hasCompatibleRuntimeRunIdForSkew = + currentRuntimeRunId.length === 0 || + (bootstrapRuntimeRunId.length > 0 && currentRuntimeRunId === bootstrapRuntimeRunId); + const withinBootstrapConfirmationClockSkew = + evidenceKind === 'confirmation' && + Number.isFinite(boundaryMs) && + boundaryMs - evidenceMs <= BOOTSTRAP_EVIDENCE_BOUNDARY_SKEW_MS && + hasCompatibleRuntimeRunIdForSkew; + return ( + !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs || withinBootstrapConfirmationClockSkew + ); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 8a96ae5d..cff49c08 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1123,7 +1123,7 @@ export interface PersistedTeamLaunchMemberState { hardFailureReason?: string; pendingPermissionRequestIds?: string[]; runtimePid?: number; - /** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */ + /** Runtime/bootstrap run id that produced current liveness or bootstrap evidence. */ runtimeRunId?: string; runtimeSessionId?: string; bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource; diff --git a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts index 90f37cc1..5708ed81 100644 --- a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts +++ b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts @@ -316,6 +316,85 @@ describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => { expect(hasRecoverableOpenCodeBootstrapDiagnostic([])).toBe(false); }); + it('accepts bootstrap evidence that slightly predates delayed spawn acceptance', () => { + expect( + isBootstrapMemberEvidenceCurrentForMember( + { + firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z', + lastEvaluatedAt: '2026-01-01T00:01:00.000Z', + runtimeRunId: 'run-new', + }, + { + firstSpawnAcceptedAt: '2026-01-01T00:00:33.000Z', + lastHeartbeatAt: '2026-01-01T00:00:42.500Z', + lastEvaluatedAt: '2026-01-01T00:00:42.500Z', + }, + 'confirmation' + ) + ).toBe(false); + + expect( + isBootstrapMemberEvidenceCurrentForMember( + { + firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z', + lastEvaluatedAt: '2026-01-01T00:01:00.000Z', + runtimeRunId: 'run-new', + }, + { + firstSpawnAcceptedAt: '2026-01-01T00:00:33.000Z', + lastHeartbeatAt: '2026-01-01T00:00:42.500Z', + lastEvaluatedAt: '2026-01-01T00:00:42.500Z', + runtimeRunId: 'run-old', + }, + 'confirmation' + ) + ).toBe(false); + + expect( + isBootstrapMemberEvidenceCurrentForMember( + { + firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z', + lastEvaluatedAt: '2026-01-01T00:01:00.000Z', + }, + { + firstSpawnAcceptedAt: '2026-01-01T00:00:33.000Z', + lastHeartbeatAt: '2026-01-01T00:00:42.500Z', + lastEvaluatedAt: '2026-01-01T00:00:42.500Z', + }, + 'confirmation' + ) + ).toBe(true); + + expect( + isBootstrapMemberEvidenceCurrentForMember( + { + firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z', + lastEvaluatedAt: '2026-01-01T00:01:00.000Z', + }, + { + firstSpawnAcceptedAt: '2026-01-01T00:00:20.000Z', + lastHeartbeatAt: '2026-01-01T00:00:20.000Z', + lastEvaluatedAt: '2026-01-01T00:00:20.000Z', + }, + 'confirmation' + ) + ).toBe(false); + + expect( + isBootstrapMemberEvidenceCurrentForMember( + { + firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z', + lastEvaluatedAt: '2026-01-01T00:01:00.000Z', + }, + { + firstSpawnAcceptedAt: '2026-01-01T00:00:42.500Z', + lastEvaluatedAt: '2026-01-01T00:00:42.500Z', + }, + 'acceptance' + ) + ).toBe(false); + }); + it('classifies recoverable persisted OpenCode runtime candidates', () => { expect( isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ runtimeSessionId: 'rt-1' })) diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 9318c8c3..23cc5a35 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -371,13 +371,15 @@ function writeBootstrapState( lastObservedAt?: number; failureReason?: string; }[], - updatedAt = new Date().toISOString() + updatedAt = new Date().toISOString(), + options?: { runId?: string } ): void { fs.writeFileSync( getTeamBootstrapStatePath(teamName), `${JSON.stringify( { version: 1, + ...(options?.runId ? { runId: options.runId } : {}), teamName, updatedAt, phase: 'completed', @@ -390,6 +392,17 @@ function writeBootstrapState( ); } +function writeMemberBootstrapRunId(teamName: string, memberName: string, runId: string): void { + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members?: Array>; + }; + config.members = (config.members ?? []).map((member) => + member.name === memberName ? { ...member, bootstrapRunId: runId } : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); +} + function writeAliveProcessRegistry(teamName: string): void { const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(teamDir, { recursive: true }); @@ -20028,10 +20041,293 @@ describe('TeamProvisioningService', () => { status: 'online', launchState: 'confirmed_alive', bootstrapConfirmed: true, + runtimeAlive: false, + livenessKind: 'confirmed_bootstrap', hardFailure: false, error: undefined, }); expect(result.statuses.jack?.hardFailureReason).toBeUndefined(); + expect(result.statuses.jack?.runtimeDiagnostic).toBeUndefined(); + expect(result.statuses.jack?.runtimeDiagnosticSeverity).toBeUndefined(); + }); + + it('heals process-table unavailable failure when Anthropic bootstrap confirmation slightly predates delayed app acceptance', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-process-table-unavailable-bootstrap-skew-heals'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const bootstrapAttemptAt = '2026-05-24T09:25:33.388Z'; + const bootstrapConfirmedAt = '2026-05-24T09:25:42.494Z'; + const appAcceptedAt = '2026-05-24T09:25:45.178Z'; + const cleanupAt = '2026-05-24T09:31:05.525Z'; + const runtimePid = 97_255; + const bootstrapRunId = 'run-process-table-unavailable-skew'; + const reason = 'runtime pid could not be verified because process table is unavailable'; + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']); + writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId); + writeLaunchState( + teamName, + leadSessionId, + { + tom: { + providerId: 'anthropic', + model: 'haiku', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + livenessKind: 'registered_only', + runtimeDiagnostic: reason, + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: appAcceptedAt, + runtimeLastSeenAt: cleanupAt, + lastEvaluatedAt: cleanupAt, + }, + }, + { launchPhase: 'finished', updatedAt: cleanupAt } + ); + writeBootstrapState( + teamName, + [ + { + name: 'tom', + status: 'bootstrap_confirmed', + lastAttemptAt: Date.parse(bootstrapAttemptAt), + lastObservedAt: Date.parse(bootstrapConfirmedAt), + }, + ], + cleanupAt, + { runId: bootstrapRunId } + ); + + const svc = new TeamProvisioningService(); + privateHarness(svc).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'tom', + { + alive: false, + backendType: 'process', + providerId: 'anthropic', + livenessKind: 'registered_only', + pidSource: 'persisted_metadata', + runtimeDiagnostic: reason, + runtimeDiagnosticSeverity: 'warning', + metricsPid: runtimePid, + model: 'haiku', + }, + ], + ]) + ); + + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: false, + livenessKind: 'confirmed_bootstrap', + hardFailure: false, + error: undefined, + }); + expect(result.statuses.tom?.hardFailureReason).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined(); + }); + + it('does not heal rapid relaunch failures from previous bootstrap-state run id', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-process-table-unavailable-stale-rapid-run-ignored'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const bootstrapAttemptAt = '2026-05-24T09:25:33.388Z'; + const bootstrapConfirmedAt = '2026-05-24T09:25:42.494Z'; + const appAcceptedAt = '2026-05-24T09:25:45.178Z'; + const cleanupAt = '2026-05-24T09:31:05.525Z'; + const runtimePid = 97_255; + const currentRunId = 'run-new-process-table-unavailable'; + const staleRunId = 'run-old-process-table-unavailable'; + const reason = 'runtime pid could not be verified because process table is unavailable'; + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']); + writeMemberBootstrapRunId(teamName, 'tom', currentRunId); + writeLaunchState( + teamName, + leadSessionId, + { + tom: { + providerId: 'anthropic', + model: 'haiku', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + livenessKind: 'registered_only', + runtimeDiagnostic: reason, + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: appAcceptedAt, + runtimeLastSeenAt: cleanupAt, + lastEvaluatedAt: cleanupAt, + }, + }, + { launchPhase: 'finished', updatedAt: cleanupAt } + ); + writeBootstrapState( + teamName, + [ + { + name: 'tom', + status: 'bootstrap_confirmed', + lastAttemptAt: Date.parse(bootstrapAttemptAt), + lastObservedAt: Date.parse(bootstrapConfirmedAt), + }, + ], + cleanupAt, + { runId: staleRunId } + ); + + const svc = new TeamProvisioningService(); + privateHarness(svc).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'tom', + { + alive: false, + backendType: 'process', + providerId: 'anthropic', + livenessKind: 'registered_only', + pidSource: 'persisted_metadata', + runtimeDiagnostic: reason, + runtimeDiagnosticSeverity: 'warning', + metricsPid: runtimePid, + model: 'haiku', + }, + ], + ]) + ); + + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.statuses.tom).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + bootstrapConfirmed: false, + hardFailure: true, + }); + }); + + it('heals post-stop stale pid diagnostics when bootstrap-state already confirmed the Anthropic member', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-post-stop-stale-pid-bootstrap-skew-heals'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const bootstrapAttemptAt = '2026-05-24T09:25:33.388Z'; + const bootstrapConfirmedAt = '2026-05-24T09:25:42.904Z'; + const appAcceptedAt = '2026-05-24T09:25:45.178Z'; + const originalFailureAt = '2026-05-24T09:31:05.525Z'; + const postStopRefreshAt = '2026-05-24T11:36:56.881Z'; + const runtimePid = 97_255; + const bootstrapRunId = 'run-post-stop-stale-pid-bootstrap-skew'; + const originalReason = 'runtime pid could not be verified because process table is unavailable'; + const postStopDiagnostic = 'persisted runtime pid is not alive'; + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']); + writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId); + writeLaunchState( + teamName, + leadSessionId, + { + tom: { + providerId: 'anthropic', + model: 'haiku', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: originalReason, + livenessKind: 'stale_metadata', + runtimeDiagnostic: postStopDiagnostic, + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: appAcceptedAt, + runtimeLastSeenAt: originalFailureAt, + lastEvaluatedAt: originalFailureAt, + }, + }, + { launchPhase: 'finished', updatedAt: postStopRefreshAt } + ); + writeBootstrapState( + teamName, + [ + { + name: 'tom', + status: 'bootstrap_confirmed', + lastAttemptAt: Date.parse(bootstrapAttemptAt), + lastObservedAt: Date.parse(bootstrapConfirmedAt), + }, + ], + '2026-05-24T09:26:08.090Z', + { runId: bootstrapRunId } + ); + + const svc = new TeamProvisioningService(); + privateHarness(svc).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'tom', + { + alive: false, + backendType: 'process', + providerId: 'anthropic', + livenessKind: 'stale_metadata', + pidSource: 'persisted_metadata', + runtimeDiagnostic: postStopDiagnostic, + runtimeDiagnosticSeverity: 'warning', + metricsPid: runtimePid, + model: 'haiku', + }, + ], + ]) + ); + + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: false, + livenessKind: 'confirmed_bootstrap', + hardFailure: false, + error: undefined, + }); + expect(result.statuses.tom?.hardFailureReason).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined(); }); it('does not heal cleanup-finalized launch failures from stale bootstrap-state confirmation', async () => { @@ -25884,6 +26180,292 @@ describe('TeamProvisioningService', () => { }); }); + it('reconciles mixed launch when Anthropic primary bootstrap confirmation slightly predates delayed app acceptance', async () => { + const teamName = 'mixed-anthropic-primary-bootstrap-skew-heals'; + const reason = 'runtime pid could not be verified because process table is unavailable'; + const postStopDiagnostic = 'persisted runtime pid is not alive'; + const bootstrapRunId = 'run-mixed-anthropic-primary-bootstrap-skew'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + }); + writeMembersMeta(teamName, [ + { name: 'alice', providerId: 'codex', model: 'gpt-5.5' }, + { name: 'tom', providerId: 'anthropic', model: 'haiku' }, + { name: 'bob', providerId: 'opencode', model: 'opencode/deepseek-v4-flash-free' }, + { name: 'jack', providerId: 'opencode', model: 'opencode/big-pickle' }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['alice', 'tom']); + writeMemberBootstrapRunId(teamName, 'alice', bootstrapRunId); + writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId); + writeBootstrapState( + teamName, + [ + { + name: 'alice', + status: 'bootstrap_confirmed', + lastAttemptAt: Date.parse('2026-05-24T09:25:28.034Z'), + lastObservedAt: Date.parse('2026-05-24T09:26:07.735Z'), + }, + { + name: 'tom', + status: 'bootstrap_confirmed', + lastAttemptAt: Date.parse('2026-05-24T09:25:33.388Z'), + lastObservedAt: Date.parse('2026-05-24T09:25:42.494Z'), + }, + ], + '2026-05-24T09:26:08.090Z', + { runId: bootstrapRunId } + ); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'finished', + expectedMembers: ['alice', 'tom', 'bob', 'jack'], + bootstrapExpectedMembers: ['alice', 'tom'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + firstSpawnAcceptedAt: '2026-05-24T09:25:45.176Z', + lastHeartbeatAt: '2026-05-24T09:26:07.735Z', + lastEvaluatedAt: '2026-05-24T09:26:09.249Z', + }, + tom: { + name: 'tom', + providerId: 'anthropic', + model: 'haiku', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid: 97_255, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + livenessKind: 'stale_metadata', + runtimeDiagnostic: postStopDiagnostic, + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: '2026-05-24T09:25:45.178Z', + runtimeLastSeenAt: '2026-05-24T09:31:05.525Z', + lastEvaluatedAt: '2026-05-24T09:31:05.525Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/deepseek-v4-flash-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 2_756, + runtimeSessionId: 'ses_bob', + livenessKind: 'confirmed_bootstrap', + lastHeartbeatAt: '2026-05-24T09:31:39.741Z', + lastEvaluatedAt: '2026-05-24T09:31:39.741Z', + }, + jack: { + name: 'jack', + providerId: 'opencode', + model: 'opencode/big-pickle', + laneId: 'secondary:opencode:jack', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 2_756, + runtimeSessionId: 'ses_jack', + livenessKind: 'confirmed_bootstrap', + lastHeartbeatAt: '2026-05-24T09:31:39.741Z', + lastEvaluatedAt: '2026-05-24T09:31:39.741Z', + }, + }, + updatedAt: '2026-05-24T11:36:56.881Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: false, + livenessKind: 'confirmed_bootstrap', + hardFailure: false, + error: undefined, + }); + expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined(); + expect(result.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + }); + expect(result.statuses.jack).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + }); + }); + + it('cleans stale confirmed primary diagnostics from an already successful mixed launch', async () => { + const teamName = 'mixed-confirmed-primary-stale-diagnostic-cleans'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + }); + writeMembersMeta(teamName, [ + { name: 'alice', providerId: 'codex', model: 'gpt-5.5' }, + { name: 'tom', providerId: 'anthropic', model: 'haiku' }, + { name: 'bob', providerId: 'opencode', model: 'opencode/deepseek-v4-flash-free' }, + { name: 'jack', providerId: 'opencode', model: 'opencode/big-pickle' }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['alice', 'tom']); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'finished', + expectedMembers: ['alice', 'tom', 'bob', 'jack'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-05-24T12:04:48.900Z', + }, + tom: { + name: 'tom', + providerId: 'anthropic', + model: 'haiku', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid: 97_255, + bootstrapConfirmed: true, + hardFailure: false, + livenessKind: 'stale_metadata', + pidSource: 'persisted_metadata', + runtimeDiagnostic: 'persisted runtime pid is not alive', + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: '2026-05-24T09:25:45.178Z', + lastHeartbeatAt: '2026-05-24T09:25:42.904Z', + runtimeLastSeenAt: '2026-05-24T09:31:05.525Z', + lastEvaluatedAt: '2026-05-24T12:04:48.900Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/deepseek-v4-flash-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 2_756, + runtimeSessionId: 'ses_bob', + livenessKind: 'confirmed_bootstrap', + lastHeartbeatAt: '2026-05-24T09:31:39.741Z', + lastEvaluatedAt: '2026-05-24T09:31:39.741Z', + }, + jack: { + name: 'jack', + providerId: 'opencode', + model: 'opencode/big-pickle', + laneId: 'secondary:opencode:jack', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 2_756, + runtimeSessionId: 'ses_jack', + livenessKind: 'confirmed_bootstrap', + lastHeartbeatAt: '2026-05-24T09:31:39.741Z', + lastEvaluatedAt: '2026-05-24T09:31:39.741Z', + }, + }, + updatedAt: '2026-05-24T12:04:48.900Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: false, + livenessKind: 'confirmed_bootstrap', + hardFailure: false, + error: undefined, + }); + expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined(); + const persisted = JSON.parse( + await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8') + ); + expect(persisted.members.tom).toMatchObject({ + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + livenessKind: 'confirmed_bootstrap', + }); + expect(persisted.members.tom.runtimeDiagnostic).toBeUndefined(); + expect(persisted.members.tom.runtimeDiagnosticSeverity).toBeUndefined(); + }); + it('does not collapse persisted mixed secondary failures when primary bootstrap snapshot is clean and richer', async () => { const teamName = 'mixed-clean-bootstrap-does-not-collapse-secondary-failure'; writeMembersMeta(teamName, [ diff --git a/test/renderer/utils/memberLaunchDiagnostics.test.ts b/test/renderer/utils/memberLaunchDiagnostics.test.ts index 3875a14a..80b8ec8e 100644 --- a/test/renderer/utils/memberLaunchDiagnostics.test.ts +++ b/test/renderer/utils/memberLaunchDiagnostics.test.ts @@ -91,6 +91,38 @@ describe('member launch diagnostics', () => { expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"'); }); + it('does not surface post-stop stale runtime warnings as confirmed member card errors', () => { + const payload = buildMemberLaunchDiagnosticsPayload({ + teamName: 'forge-labs-11', + runId: 'e90c7699-54d7-449e-8a4a-6a3276396926', + memberName: 'tom', + spawnEntry: { + status: 'online', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: true, + hardFailure: false, + livenessKind: 'confirmed_bootstrap', + updatedAt: '2026-05-24T12:04:48.900Z', + }, + runtimeEntry: { + memberName: 'tom', + alive: false, + restartable: true, + livenessKind: 'stale_metadata', + runtimeDiagnostic: 'persisted runtime pid is not alive', + runtimeDiagnosticSeverity: 'warning', + updatedAt: '2026-05-24T12:04:48.900Z', + }, + }); + + expect(payload.memberCardError).toBeUndefined(); + expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false); + expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBeUndefined(); + expect(payload.runtimeDiagnostic).toBe('persisted runtime pid is not alive'); + }); + it('includes runtime advisory evidence in copy diagnostics', () => { const payload = buildMemberLaunchDiagnosticsPayload({ memberName: 'alice',