diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ff28873b..c26a6b74 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -687,6 +687,12 @@ type BootstrapTranscriptOutcome = reason: string; }; +interface BootstrapTranscriptOutcomeCacheEntry { + mtimeMs: number; + size: number; + outcome: BootstrapTranscriptOutcome | null; +} + import type { ActiveToolCall, AgentActionMode, @@ -3290,6 +3296,7 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60; + private static readonly BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES = 2_048; private static readonly MAX_RUNTIME_TREE_PIDS_PER_ROOT = 64; private static readonly MAX_RUNTIME_USAGE_PIDS_PER_SNAPSHOT = 512; private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500; @@ -3350,6 +3357,10 @@ export class TeamProvisioningService { string, PersistedTranscriptClaudeLogsCacheEntry >(); + private readonly bootstrapTranscriptOutcomeCache = new Map< + string, + BootstrapTranscriptOutcomeCacheEntry + >(); private readonly teamOpLocks = new Map>(); private readonly leadInboxRelayInFlight = new Map>(); private readonly relayedLeadInboxMessageIds = new Map>(); @@ -30102,12 +30113,24 @@ export class TeamProvisioningService { .filter(Boolean) ) ); + const cacheKey = this.buildBootstrapTranscriptOutcomeCacheKey({ + filePath, + sinceMs, + memberName: normalizedMemberName, + teamName, + allowAnonymousFailure: options.allowAnonymousFailure === true, + contextMemberNames, + }); try { handle = await fs.promises.open(filePath, 'r'); const stat = await handle.stat(); if (!stat.isFile() || stat.size <= 0) { return null; } + const cached = this.bootstrapTranscriptOutcomeCache.get(cacheKey); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached.outcome; + } const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES); const buffer = Buffer.alloc(stat.size - start); if (buffer.length === 0) { @@ -30155,6 +30178,7 @@ export class TeamProvisioningService { } const hasUnambiguousMatchingBootstrapContext = bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(normalizedMemberName); + let outcome: BootstrapTranscriptOutcome | null = null; for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]?.trim(); if (!line) continue; @@ -30196,13 +30220,21 @@ export class TeamProvisioningService { ) { continue; } - return { kind: 'failure', observedAt, reason }; + outcome = { kind: 'failure', observedAt, reason }; + break; } const successSource = getBootstrapTranscriptSuccessSource(text, teamName, memberName); if (successSource) { - return { kind: 'success', observedAt, source: successSource }; + outcome = { kind: 'success', observedAt, source: successSource }; + break; } } + this.setBootstrapTranscriptOutcomeCacheEntry(cacheKey, { + mtimeMs: stat.mtimeMs, + size: stat.size, + outcome, + }); + return outcome; } catch { return null; } finally { @@ -30212,6 +30244,46 @@ export class TeamProvisioningService { return null; } + private buildBootstrapTranscriptOutcomeCacheKey(input: { + filePath: string; + sinceMs: number | null; + memberName: string; + teamName: string; + allowAnonymousFailure: boolean; + contextMemberNames: readonly string[]; + }): string { + const normalizedContextMembers = Array.from( + new Set(input.contextMemberNames.map((name) => name.trim().toLowerCase()).filter(Boolean)) + ) + .sort() + .join('\0'); + return [ + input.filePath, + input.sinceMs ?? '', + input.memberName, + input.teamName.trim().toLowerCase(), + input.allowAnonymousFailure ? '1' : '0', + normalizedContextMembers, + ].join('\0'); + } + + private setBootstrapTranscriptOutcomeCacheEntry( + cacheKey: string, + entry: BootstrapTranscriptOutcomeCacheEntry + ): void { + if ( + !this.bootstrapTranscriptOutcomeCache.has(cacheKey) && + this.bootstrapTranscriptOutcomeCache.size >= + TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.bootstrapTranscriptOutcomeCache.keys().next().value; + if (oldestKey) { + this.bootstrapTranscriptOutcomeCache.delete(oldestKey); + } + } + this.bootstrapTranscriptOutcomeCache.set(cacheKey, entry); + } + private async readBootstrapTranscriptOutcomesInProjectRoot( teamName: string, memberName: string, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index bfcf798f..8d03a06c 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -623,6 +623,13 @@ type TeamProvisioningServicePrivateHarness = { readProcessUsageStatsByPid: ( pids: readonly number[] ) => Promise>; + readRecentBootstrapTranscriptOutcome: ( + filePath: string, + sinceMs: number | null, + memberName: string, + teamName: string, + options?: { allowAnonymousFailure?: boolean; contextMemberNames?: readonly string[] } + ) => Promise<{ kind: string; observedAt: string; source?: string; reason?: string } | null>; }; function privateHarness(svc: TeamProvisioningService): TeamProvisioningServicePrivateHarness { @@ -21025,6 +21032,57 @@ describe('TeamProvisioningService', () => { expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined(); }); + it('refreshes cached bootstrap transcript outcome when the transcript file changes', async () => { + const teamName = 'zz-unit-bootstrap-transcript-cache-refresh'; + const memberName = 'tom'; + const transcriptPath = path.join(tempProjectsBase, 'bootstrap-cache.jsonl'); + const writeTranscriptText = async (text: string, timestamp: string): Promise => { + await fsPromises.writeFile( + transcriptPath, + `${JSON.stringify({ + timestamp, + agentName: memberName, + text, + })}\n`, + 'utf8' + ); + const updatedAt = new Date(Date.now() + 5_000); + await fsPromises.utimes(transcriptPath, updatedAt, updatedAt); + }; + + await writeTranscriptText( + `member briefing for ${memberName} on team "${teamName}" (${teamName}). Ready.`, + '2026-05-24T09:25:42.904Z' + ); + + const svc = new TeamProvisioningService(); + const firstOutcome = await privateHarness(svc).readRecentBootstrapTranscriptOutcome( + transcriptPath, + null, + memberName, + teamName + ); + + expect(firstOutcome).toMatchObject({ kind: 'success', source: 'member_briefing' }); + + await writeTranscriptText( + 'bootstrap failed: model not found during teammate startup', + '2026-05-24T09:26:42.904Z' + ); + + const secondOutcome = await privateHarness(svc).readRecentBootstrapTranscriptOutcome( + transcriptPath, + null, + memberName, + teamName + ); + + expect(secondOutcome).toMatchObject({ + kind: 'failure', + reason: 'bootstrap failed: model not found during teammate startup', + }); + }); + it('does not heal cleanup-finalized launch failures from stale bootstrap-state confirmation', async () => { allowConsoleLogs(); const teamName = 'zz-unit-cleanup-finalized-stale-bootstrap-ignored';