diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 471be252..526346ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5017,7 +5017,11 @@ export class TeamProvisioningService { } private readPersistedTeamProcessRows(teamName: string): unknown[] | null { - const processesPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'processes.json'); + const processesPath = this.resolveSafeTeamStoragePath( + getTeamsBasePath(), + teamName, + 'processes.json' + ); let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(processesPath, 'utf8')) as unknown; @@ -14768,7 +14772,11 @@ export class TeamProvisioningService { bootstrapContextHash?: string; bootstrapBriefingHash?: string; }): Promise { - const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), input.teamName, 'config.json'); + const configPath = this.resolveSafeTeamStoragePath( + getTeamsBasePath(), + input.teamName, + 'config.json' + ); const raw = await tryReadRegularFileUtf8(configPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, maxBytes: TEAM_CONFIG_MAX_BYTES, @@ -19982,7 +19990,11 @@ export class TeamProvisioningService { try { const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); for (const probe of teamsBasePathsToProbe) { - const configPath = this.resolveSafeTeamStoragePath(probe.basePath, request.teamName, 'config.json'); + const configPath = this.resolveSafeTeamStoragePath( + probe.basePath, + request.teamName, + 'config.json' + ); if (await this.pathExists(configPath)) { const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`; throw new Error(`Team already exists${suffix}`); @@ -20602,7 +20614,11 @@ export class TeamProvisioningService { ): Promise { const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); for (const probe of teamsBasePathsToProbe) { - const configPath = this.resolveSafeTeamStoragePath(probe.basePath, request.teamName, 'config.json'); + const configPath = this.resolveSafeTeamStoragePath( + probe.basePath, + request.teamName, + 'config.json' + ); if (await this.pathExists(configPath)) { const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`; throw new Error(`Team already exists${suffix}`); @@ -20661,7 +20677,11 @@ export class TeamProvisioningService { request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void ): Promise { - const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json'); + const configPath = this.resolveSafeTeamStoragePath( + getTeamsBasePath(), + request.teamName, + 'config.json' + ); const configRaw = await tryReadRegularFileUtf8(configPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, maxBytes: TEAM_CONFIG_MAX_BYTES, @@ -20943,7 +20963,11 @@ export class TeamProvisioningService { request: TeamCreateRequest, members: TeamCreateRequest['members'] ): Promise { - const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json'); + const configPath = this.resolveSafeTeamStoragePath( + getTeamsBasePath(), + request.teamName, + 'config.json' + ); const config: TeamConfig = { name: request.displayName?.trim() || request.teamName, description: request.description, @@ -21182,7 +21206,11 @@ export class TeamProvisioningService { try { // Verify config.json exists — team must already be provisioned - const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json'); + const configPath = this.resolveSafeTeamStoragePath( + getTeamsBasePath(), + request.teamName, + 'config.json' + ); const configRaw = await tryReadRegularFileUtf8(configPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, maxBytes: TEAM_CONFIG_MAX_BYTES, @@ -29399,17 +29427,41 @@ export class TeamProvisioningService { ); } + private resolveBootstrapRuntimeEvidenceBoundaryMs( + member: Pick, + runtimeMember: PersistedRuntimeMemberLike | undefined + ): number { + const firstSpawnAcceptedMs = Date.parse(member.firstSpawnAcceptedAt ?? ''); + const bootstrapExpectedAfterMs = Date.parse(runtimeMember?.bootstrapExpectedAfter ?? ''); + if (!Number.isFinite(firstSpawnAcceptedMs)) { + return Number.isFinite(bootstrapExpectedAfterMs) ? bootstrapExpectedAfterMs : Number.NaN; + } + if (!Number.isFinite(bootstrapExpectedAfterMs)) { + return firstSpawnAcceptedMs; + } + + const proofToken = runtimeMember?.bootstrapProofToken?.trim() ?? ''; + const memberRunId = typeof member.runtimeRunId === 'string' ? member.runtimeRunId.trim() : ''; + const runtimeRunId = runtimeMember?.bootstrapRunId?.trim() ?? ''; + const runIdsCompatible = + memberRunId.length === 0 || runtimeRunId.length === 0 || memberRunId === runtimeRunId; + if (proofToken.length === 0 || !runIdsCompatible) { + return firstSpawnAcceptedMs; + } + + return Math.min(firstSpawnAcceptedMs, bootstrapExpectedAfterMs); + } + private async findBootstrapRuntimeProofObservedAt( teamName: string, memberName: string, member: Pick< PersistedTeamLaunchMemberState, - 'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason' + 'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason' | 'runtimeRunId' > ): Promise { const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName); - const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter; - const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN; + const boundaryMs = this.resolveBootstrapRuntimeEvidenceBoundaryMs(member, runtimeMember); if (!runtimeMember?.bootstrapProofToken && !Number.isFinite(boundaryMs)) { return null; } @@ -29510,8 +29562,7 @@ export class TeamProvisioningService { if (runtimeBackendType !== 'process' && !processPaneId?.startsWith('process:')) { return null; } - const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter; - const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN; + const boundaryMs = this.resolveBootstrapRuntimeEvidenceBoundaryMs(member, runtimeMember); const expectedPid = typeof member.runtimePid === 'number' && member.runtimePid > 0 ? member.runtimePid @@ -34451,7 +34502,11 @@ export class TeamProvisioningService { // Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate // a stale config.json was present during launch (double-launch race). try { - const postLaunchConfigPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName, 'config.json'); + const postLaunchConfigPath = this.resolveSafeTeamStoragePath( + getTeamsBasePath(), + run.teamName, + 'config.json' + ); const raw = await tryReadRegularFileUtf8(postLaunchConfigPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, maxBytes: TEAM_CONFIG_MAX_BYTES, @@ -35787,9 +35842,17 @@ export class TeamProvisioningService { } if (code === 0) { - const configuredConfigPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName, 'config.json'); + const configuredConfigPath = this.resolveSafeTeamStoragePath( + getTeamsBasePath(), + run.teamName, + 'config.json' + ); const defaultTeamsBasePath = path.join(getAutoDetectedClaudeBasePath(), 'teams'); - const defaultConfigPath = this.resolveSafeTeamStoragePath(defaultTeamsBasePath, run.teamName, 'config.json'); + const defaultConfigPath = this.resolveSafeTeamStoragePath( + defaultTeamsBasePath, + run.teamName, + 'config.json' + ); const combinedLogs = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer); const cleanupHint = logsSuggestShutdownOrCleanup(combinedLogs) ? ' CLI output suggests the team was shut down / cleaned up, so no persisted config was left on disk.' diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts index 6e972fd7..4a392162 100644 --- a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts +++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts @@ -600,6 +600,10 @@ export function isBootstrapMemberEvidenceCurrentForMember( typeof current.runtimeRunId === 'string' ? current.runtimeRunId.trim() : ''; const bootstrapRuntimeRunId = typeof bootstrapMember.runtimeRunId === 'string' ? bootstrapMember.runtimeRunId.trim() : ''; + const hasSameRuntimeRunId = + currentRuntimeRunId.length > 0 && + bootstrapRuntimeRunId.length > 0 && + currentRuntimeRunId === bootstrapRuntimeRunId; if ( currentRuntimeRunId.length > 0 && bootstrapRuntimeRunId.length > 0 && @@ -631,10 +635,18 @@ export function isBootstrapMemberEvidenceCurrentForMember( const hasDurableSpawnBoundary = Number.isFinite(firstSpawnAcceptedMs) && (!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs); - const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN; - const hasCompatibleRuntimeRunIdForSkew = - currentRuntimeRunId.length === 0 || - (bootstrapRuntimeRunId.length > 0 && currentRuntimeRunId === bootstrapRuntimeRunId); + const currentBoundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN; + const sameRunBootstrapBoundaryMs = + evidenceKind === 'confirmation' && hasSameRuntimeRunId && hasDurableBootstrapSpawnAcceptedAt + ? bootstrapFirstSpawnAcceptedMs + : NaN; + const boundaryMs = + Number.isFinite(currentBoundaryMs) && Number.isFinite(sameRunBootstrapBoundaryMs) + ? Math.min(currentBoundaryMs, sameRunBootstrapBoundaryMs) + : Number.isFinite(currentBoundaryMs) + ? currentBoundaryMs + : sameRunBootstrapBoundaryMs; + const hasCompatibleRuntimeRunIdForSkew = currentRuntimeRunId.length === 0 || hasSameRuntimeRunId; const withinBootstrapConfirmationClockSkew = evidenceKind === 'confirmation' && Number.isFinite(boundaryMs) && diff --git a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts index 5708ed81..cf39ae37 100644 --- a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts +++ b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts @@ -395,6 +395,35 @@ describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => { ).toBe(false); }); + it('accepts same-run bootstrap confirmation before delayed app acceptance past skew', () => { + const current = { + firstSpawnAcceptedAt: '2026-05-24T09:25:52.497Z', + lastEvaluatedAt: '2026-05-24T09:31:05.525Z', + runtimeRunId: 'run-process-table-unavailable-skew', + }; + const bootstrapMember = { + firstSpawnAcceptedAt: '2026-05-24T09:25:33.388Z', + lastHeartbeatAt: '2026-05-24T09:25:42.494Z', + lastRuntimeAliveAt: '2026-05-24T09:25:42.494Z', + lastEvaluatedAt: '2026-05-24T09:25:42.494Z', + runtimeRunId: 'run-process-table-unavailable-skew', + }; + + expect( + isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation') + ).toBe(true); + expect( + isBootstrapMemberEvidenceCurrentForMember( + current, + { ...bootstrapMember, runtimeRunId: 'previous-run' }, + 'confirmation' + ) + ).toBe(false); + expect(isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, '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 ea8c7ac6..7cbea2ac 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -20579,6 +20579,105 @@ describe('TeamProvisioningService', () => { }); }); + it('heals native runtime proof that predates delayed app acceptance but follows bootstrap boundary', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-native-proof-delayed-app-acceptance-heals'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const bootstrapExpectedAfter = '2026-05-28T14:32:47.928Z'; + const proofAt = '2026-05-28T14:32:54.123Z'; + const appAcceptedAt = '2026-05-28T14:33:04.126Z'; + const cleanupAt = '2026-05-28T14:39:22.768Z'; + const proofToken = 'proof-token-tom-native'; + const bootstrapRunId = 'run-native-delayed-acceptance'; + const contextHash = 'c'.repeat(64); + const briefingHash = 'd'.repeat(64); + const runtimePid = 48_518; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'tom.runtime.jsonl'); + const processTableReason = + 'runtime pid could not be verified because process table is unavailable'; + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']); + 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 === 'tom' + ? { + ...member, + agentId: `tom@${teamName}`, + backendType: 'process', + tmuxPaneId: `process:${runtimePid}`, + runtimePid, + bootstrapExpectedAfter, + bootstrapProofToken: proofToken, + bootstrapRunId, + bootstrapProofMode: 'native_app_managed_context', + bootstrapContextHash: contextHash, + bootstrapBriefingHash: briefingHash, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + const snapshot = createPersistedLaunchSnapshot({ + teamName, + leadSessionId, + launchPhase: 'finished', + expectedMembers: ['tom'], + members: { + tom: { + name: 'tom', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: processTableReason, + livenessKind: 'registered_only', + runtimeDiagnostic: processTableReason, + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: appAcceptedAt, + runtimeLastSeenAt: cleanupAt, + lastEvaluatedAt: cleanupAt, + }, + }, + }); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: runtimePid, + teamName, + agentName: 'tom', + agentId: `tom@${teamName}`, + runId: bootstrapRunId, + bootstrapRunId, + source: 'native_app_managed_bootstrap_private_turn', + bootstrapProofToken: proofToken, + contextHash, + briefingHash, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await privateHarness(svc).applyBootstrapTranscriptEvidenceOverlay(snapshot); + + expect(result?.members.tom).toMatchObject({ + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + hardFailure: false, + hardFailureReason: undefined, + }); + }); + it('heals cleanup-finalized launch failures when bootstrap-state confirms an Anthropic primary member', async () => { allowConsoleLogs(); const teamName = 'zz-unit-cleanup-finalized-bootstrap-state-heals';