diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6d006174..6785e8f2 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -693,6 +693,11 @@ interface BootstrapTranscriptOutcomeCacheEntry { outcome: BootstrapTranscriptOutcome | null; } +interface BootstrapTranscriptOutcomeLookupCacheEntry { + expiresAtMs: number; + outcome: BootstrapTranscriptOutcome | null; +} + import type { ActiveToolCall, AgentActionMode, @@ -3295,9 +3300,10 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; - private static readonly PERSISTED_AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 5_000; + private static readonly PERSISTED_AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 10_000; private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60; private static readonly BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES = 2_048; + private static readonly PERSISTED_BOOTSTRAP_TRANSCRIPT_OUTCOME_LOOKUP_CACHE_TTL_MS = 10_000; 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; @@ -3363,6 +3369,10 @@ export class TeamProvisioningService { string, BootstrapTranscriptOutcomeCacheEntry >(); + private readonly bootstrapTranscriptOutcomeLookupCache = new Map< + string, + BootstrapTranscriptOutcomeLookupCacheEntry + >(); private readonly teamOpLocks = new Map>(); private readonly leadInboxRelayInFlight = new Map>(); private readonly relayedLeadInboxMessageIds = new Map>(); @@ -30101,6 +30111,19 @@ export class TeamProvisioningService { memberName: string, sinceMs: number | null ): Promise { + const lookupCacheKey = this.buildBootstrapTranscriptOutcomeLookupCacheKey( + teamName, + memberName, + sinceMs + ); + const cachedLookup = this.getPersistedBootstrapTranscriptOutcomeLookupCacheEntry( + teamName, + lookupCacheKey + ); + if (cachedLookup !== undefined) { + return this.cloneBootstrapTranscriptOutcome(cachedLookup); + } + let summaries: Awaited>; try { summaries = await this.memberLogsFinder.findMemberLogs(teamName, memberName, sinceMs); @@ -30127,7 +30150,69 @@ export class TeamProvisioningService { ...(await this.readBootstrapTranscriptOutcomesInProjectRoot(teamName, memberName, sinceMs)) ); - return this.selectLatestBootstrapTranscriptOutcome(outcomes); + const outcome = this.selectLatestBootstrapTranscriptOutcome(outcomes); + this.setPersistedBootstrapTranscriptOutcomeLookupCacheEntry(teamName, lookupCacheKey, outcome); + return outcome; + } + + private cloneBootstrapTranscriptOutcome( + outcome: BootstrapTranscriptOutcome | null + ): BootstrapTranscriptOutcome | null { + return outcome ? { ...outcome } : null; + } + + private buildBootstrapTranscriptOutcomeLookupCacheKey( + teamName: string, + memberName: string, + sinceMs: number | null + ): string { + return [teamName.trim().toLowerCase(), memberName.trim().toLowerCase(), sinceMs ?? ''].join( + '\0' + ); + } + + private getPersistedBootstrapTranscriptOutcomeLookupCacheEntry( + teamName: string, + cacheKey: string + ): BootstrapTranscriptOutcome | null | undefined { + if (this.getTrackedRunId(teamName) || this.runtimeAdapterRunByTeam.has(teamName)) { + return undefined; + } + const cached = this.bootstrapTranscriptOutcomeLookupCache.get(cacheKey); + if (!cached) { + return undefined; + } + if (cached.expiresAtMs <= Date.now()) { + this.bootstrapTranscriptOutcomeLookupCache.delete(cacheKey); + return undefined; + } + return cached.outcome; + } + + private setPersistedBootstrapTranscriptOutcomeLookupCacheEntry( + teamName: string, + cacheKey: string, + outcome: BootstrapTranscriptOutcome | null + ): void { + if (this.getTrackedRunId(teamName) || this.runtimeAdapterRunByTeam.has(teamName)) { + return; + } + if ( + !this.bootstrapTranscriptOutcomeLookupCache.has(cacheKey) && + this.bootstrapTranscriptOutcomeLookupCache.size >= + TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.bootstrapTranscriptOutcomeLookupCache.keys().next().value; + if (oldestKey) { + this.bootstrapTranscriptOutcomeLookupCache.delete(oldestKey); + } + } + this.bootstrapTranscriptOutcomeLookupCache.set(cacheKey, { + expiresAtMs: + Date.now() + + TeamProvisioningService.PERSISTED_BOOTSTRAP_TRANSCRIPT_OUTCOME_LOOKUP_CACHE_TTL_MS, + outcome: this.cloneBootstrapTranscriptOutcome(outcome), + }); } private async readRecentBootstrapTranscriptOutcome( diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index b049b846..40ea9195 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3405,6 +3405,11 @@ describe('TeamProvisioningService', () => { vi.setSystemTime(new Date('2026-05-03T12:00:06.000Z')); await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + expect(listRuntimeProcessTableForCurrentPlatform).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date('2026-05-03T12:00:11.000Z')); + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + expect(listRuntimeProcessTableForCurrentPlatform).toHaveBeenCalledTimes(2); }); @@ -21134,6 +21139,66 @@ describe('TeamProvisioningService', () => { }); }); + it('caches persisted bootstrap transcript outcome lookup between close polling reads', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-03T12:00:00.000Z')); + const teamName = 'zz-unit-bootstrap-transcript-lookup-cache'; + const memberName = 'tom'; + const transcriptPath = path.join(tempProjectsBase, 'bootstrap-lookup-cache.jsonl'); + const svc = new TeamProvisioningService(); + const harness = svc as any; + const findMemberLogs = vi.fn(async () => [{ filePath: transcriptPath }]); + const readRecentBootstrapTranscriptOutcome = vi.fn(async () => ({ + kind: 'success', + observedAt: '2026-05-24T09:25:42.904Z', + source: 'member_briefing', + })); + const readBootstrapTranscriptOutcomesInProjectRoot = vi.fn(async () => []); + harness.memberLogsFinder = { findMemberLogs }; + harness.readRecentBootstrapTranscriptOutcome = readRecentBootstrapTranscriptOutcome; + harness.readBootstrapTranscriptOutcomesInProjectRoot = + readBootstrapTranscriptOutcomesInProjectRoot; + + const firstOutcome = await harness.findBootstrapTranscriptOutcome(teamName, memberName, 123); + vi.setSystemTime(new Date('2026-05-03T12:00:06.000Z')); + const secondOutcome = await harness.findBootstrapTranscriptOutcome(teamName, memberName, 123); + + expect(secondOutcome).toEqual(firstOutcome); + expect(findMemberLogs).toHaveBeenCalledTimes(1); + expect(readRecentBootstrapTranscriptOutcome).toHaveBeenCalledTimes(1); + expect(readBootstrapTranscriptOutcomesInProjectRoot).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date('2026-05-03T12:00:11.000Z')); + await harness.findBootstrapTranscriptOutcome(teamName, memberName, 123); + + expect(findMemberLogs).toHaveBeenCalledTimes(2); + expect(readRecentBootstrapTranscriptOutcome).toHaveBeenCalledTimes(2); + }); + + it('does not use persisted bootstrap transcript outcome lookup cache for tracked runs', async () => { + const teamName = 'zz-unit-bootstrap-transcript-active-lookup-cache'; + const memberName = 'tom'; + const transcriptPath = path.join(tempProjectsBase, 'bootstrap-active-lookup-cache.jsonl'); + const svc = new TeamProvisioningService(); + const harness = svc as any; + const findMemberLogs = vi.fn(async () => [{ filePath: transcriptPath }]); + const readRecentBootstrapTranscriptOutcome = vi.fn(async () => ({ + kind: 'success', + observedAt: '2026-05-24T09:25:42.904Z', + source: 'member_briefing', + })); + harness.memberLogsFinder = { findMemberLogs }; + harness.readRecentBootstrapTranscriptOutcome = readRecentBootstrapTranscriptOutcome; + harness.readBootstrapTranscriptOutcomesInProjectRoot = vi.fn(async () => []); + harness.aliveRunByTeam.set(teamName, 'run-1'); + + await harness.findBootstrapTranscriptOutcome(teamName, memberName, 123); + await harness.findBootstrapTranscriptOutcome(teamName, memberName, 123); + + expect(findMemberLogs).toHaveBeenCalledTimes(2); + expect(readRecentBootstrapTranscriptOutcome).toHaveBeenCalledTimes(2); + }); + it('caches persisted member spawn statuses between close polling reads', async () => { const teamName = 'zz-unit-persisted-status-cache'; const svc = new TeamProvisioningService();