From 601d51d54e0e81a008bac2278c53ecf87c85ff38 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 1 May 2026 20:10:14 +0300 Subject: [PATCH] fix(team): harden launch reconciliation and diagnostics --- .../composition/createCodexAccountFeature.ts | 106 ++++-- .../CodexAccountAppServerClient.ts | 53 ++- .../CodexAccountAppServerClient.test.ts | 157 +++++++++ .../services/team/TeamMemberLogsFinder.ts | 139 ++++++++ .../team/TeamMemberRuntimeAdvisoryService.ts | 112 +++++- .../services/team/TeamProvisioningService.ts | 74 +++- .../main/createCodexAccountFeature.test.ts | 323 ++++++++++++++++++ .../team/TeamMemberLogsFinder.test.ts | 133 ++++++++ ...mMemberRuntimeAdvisoryService.live.test.ts | 71 ++++ .../TeamMemberRuntimeAdvisoryService.test.ts | 58 ++++ .../team/TeamProvisioningService.test.ts | 145 +++++++- 11 files changed, 1316 insertions(+), 55 deletions(-) create mode 100644 src/features/codex-account/main/infrastructure/__tests__/CodexAccountAppServerClient.test.ts create mode 100644 test/main/services/team/TeamMemberRuntimeAdvisoryService.live.test.ts diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 9e5a54b6..728e4c16 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -260,6 +260,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { private mutationQueue: Promise = Promise.resolve(); private mutationQueueRelease: (() => void) | null = null; private activeMutationCount = 0; + private disposed = false; constructor( private readonly logger: LoggerPort, @@ -290,8 +291,12 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { } async getSnapshot(): Promise { - if (this.snapshotCache && Date.now() - this.snapshotObservedAt <= SNAPSHOT_CACHE_TTL_MS) { - return deepClone(this.snapshotCache); + const cached = this.getCachedSnapshotForOptions({ + includeRateLimits: false, + forceRefreshToken: false, + }); + if (cached) { + return cached; } return this.refreshSnapshot(); @@ -301,10 +306,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { includeRateLimits?: boolean; forceRefreshToken?: boolean; }): Promise { - this.pendingRefreshOptions = mergeRefreshOptions( - this.pendingRefreshOptions, - normalizeRefreshOptions(options) - ); + const normalizedOptions = normalizeRefreshOptions(options); + const cached = this.getCachedSnapshotForOptions(normalizedOptions); + if (cached) { + return cached; + } + + this.pendingRefreshOptions = mergeRefreshOptions(this.pendingRefreshOptions, normalizedOptions); if (!this.refreshPromise) { this.refreshPromise = this.drainRefreshQueue().finally(() => { @@ -386,6 +394,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { } async dispose(): Promise { + this.disposed = true; await this.loginSessionManager.dispose(); this.listeners.clear(); this.snapshotCache = null; @@ -410,7 +419,8 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { this.pendingRefreshOptions = null; await this.mutationQueue.catch(() => undefined); - lastSnapshot = await this.loadSnapshot(nextOptions); + lastSnapshot = + this.getCachedSnapshotForOptions(nextOptions) ?? (await this.loadSnapshot(nextOptions)); } if (!lastSnapshot) { @@ -464,12 +474,17 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { let accountPayload = this.lastKnownAccount?.payload ?? null; let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null; let runtimeContext = createRuntimeContext(binaryPath, null); + const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now); + const shouldRequestRateLimits = + options?.includeRateLimits === true && !cachedRateLimitsAreFresh; + let rateLimitsReadFailure: unknown | null = null; try { - const accountResult = await this.appServerClient.readAccount({ + const accountResult = await this.appServerClient.readAccountSnapshot({ binaryPath, env, refreshToken: options?.forceRefreshToken ?? false, + includeRateLimits: shouldRequestRateLimits, }); runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome); if (runtimeContext.codexHome) { @@ -498,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { observedAt: now, }; } + if (accountResult.rateLimits?.ok) { + this.lastKnownRateLimits = { + payload: accountResult.rateLimits.payload, + observedAt: now, + }; + } else if (accountResult.rateLimits) { + rateLimitsReadFailure = accountResult.rateLimits.error; + } } catch (error) { const failure = classifyAppServerFailure(error); appServerState = failure.appServerState; @@ -525,35 +548,18 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { let rateLimits: CodexRateLimitSnapshotDto | null = null; const shouldLoadRateLimits = - options?.includeRateLimits === true || - (this.lastKnownRateLimits !== null && - now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS); + options?.includeRateLimits === true || this.hasFreshRateLimits(now); if (shouldLoadRateLimits) { - try { - if ( - this.lastKnownRateLimits && - now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS - ) { - rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits); - } else { - const rateLimitsPayload = await this.appServerClient.readRateLimits({ - binaryPath, - env, - }); - this.lastKnownRateLimits = { - payload: rateLimitsPayload, - observedAt: now, - }; - rateLimits = asRateLimits(rateLimitsPayload.rateLimits); - } - } catch (error) { + if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) { + rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits); + } else if (rateLimitsReadFailure) { this.logger.warn('codex account rate limits refresh failed', { - error: error instanceof Error ? error.message : String(error), + error: + rateLimitsReadFailure instanceof Error + ? rateLimitsReadFailure.message + : String(rateLimitsReadFailure), }); - rateLimits = this.lastKnownRateLimits - ? asRateLimits(this.lastKnownRateLimits.payload.rateLimits) - : null; } } @@ -590,6 +596,10 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { } private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto { + if (this.disposed) { + return deepClone(nextSnapshot); + } + this.snapshotCache = deepClone(nextSnapshot); this.snapshotObservedAt = Date.now(); const snapshot = deepClone(nextSnapshot); @@ -600,6 +610,36 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { return snapshot; } + private getCachedSnapshotForOptions( + options: CodexSnapshotRefreshOptions + ): CodexAccountSnapshotDto | null { + if ( + this.hasPendingMutation() || + options.forceRefreshToken || + !this.snapshotCache || + Date.now() - this.snapshotObservedAt > SNAPSHOT_CACHE_TTL_MS + ) { + return null; + } + + if (options.includeRateLimits && !this.hasFreshRateLimits(Date.now())) { + return null; + } + + return deepClone(this.snapshotCache); + } + + private hasPendingMutation(): boolean { + return this.activeMutationCount > 0 || this.mutationQueueRelease !== null; + } + + private hasFreshRateLimits(now: number): boolean { + return ( + this.lastKnownRateLimits !== null && + now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS + ); + } + private async emitCurrentSnapshot(): Promise { if (!this.snapshotCache) { return this.refreshSnapshot(); diff --git a/src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts b/src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts index 14ba5e3f..8bab6989 100644 --- a/src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts +++ b/src/features/codex-account/main/infrastructure/CodexAccountAppServerClient.ts @@ -12,26 +12,39 @@ const ACCOUNT_RATE_LIMITS_TIMEOUT_MS = 4_500; const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500; const INITIALIZE_TIMEOUT_MS = 6_000; const TOTAL_TIMEOUT_MS = 9_000; +const TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS = 15_000; + +type CodexAccountRateLimitsReadResult = + | { ok: true; payload: CodexAppServerGetAccountRateLimitsResponse } + | { ok: false; error: unknown }; export class CodexAccountAppServerClient { constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {} - async readAccount(options: { + async readAccountSnapshot(options: { binaryPath: string; env: NodeJS.ProcessEnv; refreshToken?: boolean; + includeRateLimits?: boolean; }): Promise<{ account: CodexAppServerGetAccountResponse; + rateLimits: CodexAccountRateLimitsReadResult | null; initialize: { codexHome: string; platformFamily: string; platformOs: string }; }> { + const includeRateLimits = options.includeRateLimits === true; + return this.sessionFactory.withSession( { binaryPath: options.binaryPath, env: options.env, - requestTimeoutMs: ACCOUNT_READ_TIMEOUT_MS, + requestTimeoutMs: includeRateLimits + ? ACCOUNT_RATE_LIMITS_TIMEOUT_MS + : ACCOUNT_READ_TIMEOUT_MS, initializeTimeoutMs: INITIALIZE_TIMEOUT_MS, - totalTimeoutMs: TOTAL_TIMEOUT_MS, - label: 'codex app-server account/read', + totalTimeoutMs: includeRateLimits ? TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS : TOTAL_TIMEOUT_MS, + label: includeRateLimits + ? 'codex app-server account/read with rateLimits/read' + : 'codex app-server account/read', }, async (session) => { const account = await session.request( @@ -42,8 +55,25 @@ export class CodexAccountAppServerClient { ACCOUNT_READ_TIMEOUT_MS ); + let rateLimits: CodexAccountRateLimitsReadResult | null = null; + if (includeRateLimits) { + try { + rateLimits = { + ok: true, + payload: await session.request( + 'account/rateLimits/read', + undefined, + ACCOUNT_RATE_LIMITS_TIMEOUT_MS + ), + }; + } catch (error) { + rateLimits = { ok: false, error }; + } + } + return { account, + rateLimits, initialize: { codexHome: session.initializeResponse.codexHome, platformFamily: session.initializeResponse.platformFamily, @@ -54,6 +84,21 @@ export class CodexAccountAppServerClient { ); } + async readAccount(options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + refreshToken?: boolean; + }): Promise<{ + account: CodexAppServerGetAccountResponse; + initialize: { codexHome: string; platformFamily: string; platformOs: string }; + }> { + const result = await this.readAccountSnapshot(options); + return { + account: result.account, + initialize: result.initialize, + }; + } + async readRateLimits(options: { binaryPath: string; env: NodeJS.ProcessEnv; diff --git a/src/features/codex-account/main/infrastructure/__tests__/CodexAccountAppServerClient.test.ts b/src/features/codex-account/main/infrastructure/__tests__/CodexAccountAppServerClient.test.ts new file mode 100644 index 00000000..6a063e1f --- /dev/null +++ b/src/features/codex-account/main/infrastructure/__tests__/CodexAccountAppServerClient.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { CodexAccountAppServerClient } from '../CodexAccountAppServerClient'; + +import type { + CodexAppServerSession, + CodexAppServerSessionFactory, +} from '@main/services/infrastructure/codexAppServer'; + +function createFactory(request: CodexAppServerSession['request']): { + factory: CodexAppServerSessionFactory; + requests: { method: string; params: unknown }[]; +} { + const requests: { method: string; params: unknown }[] = []; + const session: CodexAppServerSession = { + initializeResponse: { + userAgent: 'codex-cli 0.117.0', + codexHome: '/Users/me/.codex', + platformFamily: 'macos', + platformOs: 'darwin', + }, + request: async (method: string, params?: unknown, timeoutMs?: number) => { + requests.push({ method, params }); + return request(method, params, timeoutMs); + }, + notify: async () => undefined, + onNotification: () => () => undefined, + close: async () => undefined, + }; + + const factory = { + withSession: async ( + _options: unknown, + handler: (session: CodexAppServerSession) => Promise + ): Promise => handler(session), + } as unknown as CodexAppServerSessionFactory; + + return { factory, requests }; +} + +describe('CodexAccountAppServerClient', () => { + it('reads account and optional rate limits in one app-server session', async () => { + let sessionCount = 0; + const { factory, requests } = createFactory(async (method: string) => { + if (method === 'account/read') { + return { + account: { type: 'chatgpt', email: 'user@example.com', planType: 'pro' }, + requiresOpenaiAuth: true, + } as TResult; + } + if (method === 'account/rateLimits/read') { + return { + rateLimits: { + limitId: 'codex', + limitName: null, + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null }, + secondary: null, + credits: null, + planType: 'pro', + }, + rateLimitsByLimitId: null, + } as TResult; + } + throw new Error(`Unexpected method ${method}`); + }); + const countedFactory = { + withSession: async ( + options: unknown, + handler: (session: CodexAppServerSession) => Promise + ): Promise => { + sessionCount += 1; + return factory.withSession(options as never, handler); + }, + } as unknown as CodexAppServerSessionFactory; + + const client = new CodexAccountAppServerClient(countedFactory); + const result = await client.readAccountSnapshot({ + binaryPath: '/usr/local/bin/codex', + env: {}, + refreshToken: true, + includeRateLimits: true, + }); + + expect(sessionCount).toBe(1); + expect(result.account.account).toMatchObject({ + type: 'chatgpt', + email: 'user@example.com', + }); + expect(result.rateLimits).toMatchObject({ + ok: true, + payload: { + rateLimits: { + primary: { usedPercent: 42 }, + }, + }, + }); + expect(result.initialize.codexHome).toBe('/Users/me/.codex'); + expect(requests).toEqual([ + { method: 'account/read', params: { refreshToken: true } }, + { method: 'account/rateLimits/read', params: undefined }, + ]); + }); + + it('keeps a successful account read when optional rate limits fail', async () => { + const rateLimitError = new Error('rate limits failed'); + const { factory } = createFactory(async (method: string) => { + if (method === 'account/read') { + return { + account: { type: 'apiKey' }, + requiresOpenaiAuth: false, + } as TResult; + } + if (method === 'account/rateLimits/read') { + throw rateLimitError; + } + throw new Error(`Unexpected method ${method}`); + }); + + const client = new CodexAccountAppServerClient(factory); + const result = await client.readAccountSnapshot({ + binaryPath: '/usr/local/bin/codex', + env: {}, + includeRateLimits: true, + }); + + expect(result.account).toEqual({ + account: { type: 'apiKey' }, + requiresOpenaiAuth: false, + }); + expect(result.rateLimits).toEqual({ + ok: false, + error: rateLimitError, + }); + }); + + it('surfaces account read failures without attempting rate limits', async () => { + const requests: string[] = []; + const { factory } = createFactory(async (method: string) => { + requests.push(method); + if (method === 'account/read') { + throw new Error('account failed'); + } + return {} as TResult; + }); + + const client = new CodexAccountAppServerClient(factory); + + await expect( + client.readAccountSnapshot({ + binaryPath: '/usr/local/bin/codex', + env: {}, + includeRateLimits: true, + }) + ).rejects.toThrow('account failed'); + expect(requests).toEqual(['account/read']); + }); +}); diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index b2dfdbe9..f5ed4005 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -100,6 +100,35 @@ type LogCandidate = fileName: string; }; +export interface MemberLogFileRef { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; +} + +async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length); + let index = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = new Array(workerCount).fill(0).map(async () => { + while (true) { + const currentIndex = index; + index += 1; + if (currentIndex >= items.length) { + return; + } + results[currentIndex] = await fn(items[currentIndex], currentIndex); + } + }); + await Promise.all(workers); + return results; +} + export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); private readonly attributionCache = new Map< @@ -849,6 +878,116 @@ export class TeamMemberLogsFinder { return this.listAttributedMemberFiles(teamName); } + async findRecentMemberLogFileRefsByMember( + teamName: string, + memberNames: readonly string[], + mtimeSinceMs?: number | null + ): Promise { + const requestedMembersByKey = new Map(); + for (const memberName of memberNames) { + const trimmed = memberName.trim(); + if (!trimmed) { + continue; + } + const key = trimmed.toLowerCase(); + if (!requestedMembersByKey.has(key)) { + requestedMembersByKey.set(key, trimmed); + } + } + if (requestedMembersByKey.size === 0) { + return []; + } + + const discovery = await this.discoverProjectSessions(teamName); + if (!discovery) { + return []; + } + + const { projectDir, sessionIds, knownMembers, config } = discovery; + const refs: MemberLogFileRef[] = []; + const seenFilePaths = new Set(); + const pushRef = (ref: MemberLogFileRef): void => { + if (seenFilePaths.has(ref.filePath)) { + return; + } + seenFilePaths.add(ref.filePath); + refs.push(ref); + }; + + const leadMemberName = + config.members?.find((member) => isLeadMemberCheck(member))?.name?.trim() || 'team-lead'; + const leadKey = leadMemberName.toLowerCase(); + if (requestedMembersByKey.has(leadKey) && config.leadSessionId) { + const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); + try { + const stat = await fs.stat(leadJsonl); + if (stat.isFile()) { + pushRef({ + memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName, + sessionId: config.leadSessionId, + filePath: leadJsonl, + mtimeMs: stat.mtimeMs, + }); + } + } catch { + // missing lead transcript + } + } + + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); + const settled = await mapLimit(candidates, SCAN_CONCURRENCY, async (candidate) => { + try { + const stat = await fs.stat(candidate.filePath); + if (!stat.isFile()) { + return null; + } + if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) { + return null; + } + const attribution = + candidate.kind === 'subagent' + ? await this.getCachedSubagentAttribution( + candidate.filePath, + knownMembers, + stat.mtimeMs + ) + : await this.getCachedMemberSessionAttribution( + candidate.filePath, + teamName, + knownMembers, + stat.mtimeMs + ); + if (!attribution) { + return null; + } + const memberKey = attribution.detectedMember.trim().toLowerCase(); + const requestedMemberName = requestedMembersByKey.get(memberKey); + if (!requestedMemberName) { + return null; + } + return { + memberName: requestedMemberName, + sessionId: candidate.sessionId, + filePath: candidate.filePath, + mtimeMs: stat.mtimeMs, + } satisfies MemberLogFileRef; + } catch { + return null; + } + }); + + for (const ref of settled) { + if (ref) { + pushRef(ref); + } + } + + return refs.sort((left, right) => { + const byTime = right.mtimeMs - left.mtimeMs; + return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath); + }); + } + /** * Fast marker probe for task-related logs. * Prefer structured MCP/TaskUpdate markers for modern sessions. diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 96ad6c3d..6e843e5c 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -3,7 +3,26 @@ import * as fs from 'fs/promises'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; -import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types'; +import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types'; + +interface RuntimeAdvisoryLogFileRef { + memberName: string; + filePath: string; + mtimeMs: number; +} + +interface RuntimeAdvisoryLogsFinder { + findMemberLogs( + teamName: string, + memberName: string, + mtimeSinceMs?: number | null + ): Promise[]>; + findRecentMemberLogFileRefsByMember?( + teamName: string, + memberNames: readonly string[], + mtimeSinceMs?: number | null + ): Promise; +} const LOOKBACK_MS = 10 * 60 * 1000; const CACHE_TTL_MS = 30_000; @@ -125,7 +144,9 @@ export class TeamMemberRuntimeAdvisoryService { Promise> >(); - constructor(private readonly logsFinder: TeamMemberLogsFinder = new TeamMemberLogsFinder()) {} + constructor( + private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder() + ) {} async getMemberAdvisories( teamName: string, @@ -210,14 +231,7 @@ export class TeamMemberRuntimeAdvisoryService { } if (membersToFetch.length > 0) { - const fetched = await mapLimit( - membersToFetch, - ADVISORY_FETCH_CONCURRENCY, - async (memberName) => { - const advisory = await this.findRecentMemberAdvisory(teamName, memberName); - return [memberName, advisory] as const; - } - ); + const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch); const fetchedAt = Date.now(); for (const [memberName, advisory] of fetched) { const normalizedMemberName = this.normalizeToken(memberName); @@ -296,11 +310,83 @@ export class TeamMemberRuntimeAdvisoryService { memberName, Date.now() - LOOKBACK_MS ); - for (const summary of summaries) { - if (!summary.filePath) { + return this.findRecentMemberAdvisoryInFiles( + summaries.flatMap((summary) => summary.filePath ?? []) + ); + } + + private async findRecentMemberAdvisories( + teamName: string, + memberNames: readonly string[] + ): Promise { + if (this.logsFinder.findRecentMemberLogFileRefsByMember) { + try { + return await this.findRecentMemberAdvisoriesFromBatchRefs(teamName, memberNames); + } catch (error) { + logger.warn('batch member runtime advisory log lookup failed; falling back', { + teamName, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => { + const advisory = await this.findRecentMemberAdvisory(teamName, memberName); + return [memberName, advisory] as const; + }); + } + + private async findRecentMemberAdvisoriesFromBatchRefs( + teamName: string, + memberNames: readonly string[] + ): Promise { + const memberNamesByKey = new Map(); + for (const memberName of memberNames) { + const normalized = this.normalizeToken(memberName); + if (!memberNamesByKey.has(normalized)) { + memberNamesByKey.set(normalized, memberName); + } + } + + const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember!( + teamName, + memberNames, + Date.now() - LOOKBACK_MS + ); + const refsByMember = new Map(); + for (const ref of refs) { + const normalizedMemberName = this.normalizeToken(ref.memberName); + if (!memberNamesByKey.has(normalizedMemberName)) { continue; } - const advisory = await this.readRecentApiRetryAdvisory(summary.filePath); + const bucket = refsByMember.get(normalizedMemberName) ?? []; + bucket.push(ref); + refsByMember.set(normalizedMemberName, bucket); + } + + return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => { + const normalizedMemberName = this.normalizeToken(memberName); + const refsForMember = refsByMember.get(normalizedMemberName) ?? []; + const seenFilePaths = new Set(); + const filePaths = refsForMember + .slice() + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .flatMap((ref) => { + if (!ref.filePath || seenFilePaths.has(ref.filePath)) { + return []; + } + seenFilePaths.add(ref.filePath); + return [ref.filePath]; + }); + return [memberName, await this.findRecentMemberAdvisoryInFiles(filePaths)] as const; + }); + } + + private async findRecentMemberAdvisoryInFiles( + filePaths: readonly string[] + ): Promise { + for (const filePath of filePaths) { + const advisory = await this.readRecentApiRetryAdvisory(filePath); if (advisory) { return advisory; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 04b706c1..a9eafad7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19384,6 +19384,70 @@ export class TeamProvisioningService { return false; } + private async applyBootstrapTranscriptEvidenceOverlay( + snapshot: PersistedTeamLaunchSnapshot | null + ): Promise { + if (!snapshot) { + return null; + } + + let changed = false; + const nextMembers: Record = { ...snapshot.members }; + for (const expected of this.getPersistedLaunchMemberNames(snapshot)) { + const current = nextMembers[expected]; + if ( + !current || + current.bootstrapConfirmed || + isPersistedOpenCodeSecondaryLaneMember(current) + ) { + continue; + } + + const acceptedAtMs = + current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const transcriptOutcome = await this.findBootstrapTranscriptOutcome( + snapshot.teamName, + expected, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + if (transcriptOutcome?.kind !== 'success') { + continue; + } + + const nextMember: PersistedTeamLaunchMemberState = { + ...current, + agentToolAccepted: true, + bootstrapConfirmed: true, + hardFailure: false, + hardFailureReason: undefined, + lastHeartbeatAt: current.lastHeartbeatAt ?? transcriptOutcome.observedAt, + lastEvaluatedAt: nowIso(), + sources: { + ...(current.sources ?? {}), + hardFailureSignal: undefined, + }, + diagnostics: undefined, + }; + nextMember.launchState = deriveMemberLaunchState(nextMember); + nextMembers[expected] = nextMember; + changed = true; + } + + if (!changed) { + return snapshot; + } + + return createPersistedLaunchSnapshot({ + teamName: snapshot.teamName, + expectedMembers: snapshot.expectedMembers, + bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers, + leadSessionId: snapshot.leadSessionId, + launchPhase: snapshot.launchPhase, + members: nextMembers, + updatedAt: nowIso(), + }); + } + private needsBootstrapAcceptanceReconcile( snapshot: PersistedTeamLaunchSnapshot | null, bootstrapSnapshot: PersistedTeamLaunchSnapshot | null @@ -19449,11 +19513,13 @@ export class TeamProvisioningService { const filteredBootstrapSnapshot = bootstrapSnapshot ? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers) : null; + const overlaidBootstrapSnapshot = + await this.applyBootstrapTranscriptEvidenceOverlay(filteredBootstrapSnapshot); if ( stableRecoveredMixedSnapshot && !this.needsBootstrapAcceptanceReconcile( stableRecoveredMixedSnapshot, - filteredBootstrapSnapshot + overlaidBootstrapSnapshot ) && !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(stableRecoveredMixedSnapshot)) ) { @@ -19480,10 +19546,10 @@ export class TeamProvisioningService { ? await this.writeLaunchStateSnapshot(teamName, filteredPersisted) : filteredPersisted; const preferredSnapshot = choosePreferredLaunchSnapshot( - filteredBootstrapSnapshot, + overlaidBootstrapSnapshot, persistedWithCommittedEvidence ); - if (preferredSnapshot && preferredSnapshot === filteredBootstrapSnapshot) { + if (preferredSnapshot && preferredSnapshot === overlaidBootstrapSnapshot) { return { snapshot: preferredSnapshot, statuses: snapshotToMemberSpawnStatuses(preferredSnapshot), @@ -19529,7 +19595,7 @@ export class TeamProvisioningService { ) && !this.needsBootstrapAcceptanceReconcile( persistedWithCommittedEvidence, - filteredBootstrapSnapshot + overlaidBootstrapSnapshot ) && !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(persistedWithCommittedEvidence)) ) { diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index 5ea072af..4d4b802e 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -21,6 +21,7 @@ const { loginStateListeners, logoutMock, readAccountMock, + readAccountSnapshotMock, readRateLimitsMock, } = vi.hoisted(() => ({ binaryResolveMock: vi.fn(), @@ -28,6 +29,7 @@ const { detectLocalAccountStateMock: vi.fn(), getCachedShellEnvMock: vi.fn(), readAccountMock: vi.fn(), + readAccountSnapshotMock: vi.fn(), readRateLimitsMock: vi.fn(), logoutMock: vi.fn(), loginStartMock: vi.fn(), @@ -89,6 +91,7 @@ vi.mock( '../../../../src/features/codex-account/main/infrastructure/CodexAccountAppServerClient', () => ({ CodexAccountAppServerClient: class MockCodexAccountAppServerClient { + readAccountSnapshot = readAccountSnapshotMock; readAccount = readAccountMock; readRateLimits = readRateLimitsMock; logout = logoutMock; @@ -192,6 +195,29 @@ function createRateLimitsResponse() { }; } +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (error: unknown) => void; +} { + let resolve: ((value: T | PromiseLike) => void) | null = null; + let reject: ((error: unknown) => void) | null = null; + const promise = new Promise((fulfill, fail) => { + resolve = fulfill; + reject = fail; + }); + + if (!resolve || !reject) { + throw new Error('Failed to create deferred promise.'); + } + + return { + promise, + resolve, + reject, + }; +} + describe('createCodexAccountFeature', () => { beforeEach(() => { vi.clearAllMocks(); @@ -204,6 +230,7 @@ describe('createCodexAccountFeature', () => { hasActiveChatgptAccount: false, }); getCachedShellEnvMock.mockReturnValue({}); + readAccountSnapshotMock.mockReset(); readAccountMock.mockReset(); readRateLimitsMock.mockReset(); logoutMock.mockReset(); @@ -217,6 +244,40 @@ describe('createCodexAccountFeature', () => { }; loginStateListeners.clear(); loginSettledListeners.clear(); + readAccountSnapshotMock.mockImplementation( + async (options: { + binaryPath: string; + env: NodeJS.ProcessEnv; + refreshToken?: boolean; + includeRateLimits?: boolean; + }) => { + const account = await readAccountMock(options); + if (options.includeRateLimits !== true) { + return { + ...account, + rateLimits: null, + }; + } + + try { + return { + ...account, + rateLimits: { + ok: true, + payload: await readRateLimitsMock(options), + }, + }; + } catch (error) { + return { + ...account, + rateLimits: { + ok: false, + error, + }, + }; + } + } + ); }); afterAll(() => { @@ -290,6 +351,263 @@ describe('createCodexAccountFeature', () => { } }); + it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => { + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const firstSnapshot = await feature.refreshSnapshot(); + const secondSnapshot = await feature.refreshSnapshot(); + + expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); + expect(secondSnapshot.managedAccount?.email).toBe('user@example.com'); + expect(readAccountMock).toHaveBeenCalledTimes(1); + expect(readAccountSnapshotMock).toHaveBeenCalledTimes(1); + } finally { + await feature.dispose(); + } + }); + + it('does not reuse a snapshot without rate limits for an includeRateLimits refresh', async () => { + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const firstSnapshot = await feature.refreshSnapshot(); + const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true }); + + expect(firstSnapshot.rateLimits).toBeNull(); + expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77); + expect(readAccountMock).toHaveBeenCalledTimes(2); + expect(readRateLimitsMock).toHaveBeenCalledTimes(1); + expect(readAccountSnapshotMock.mock.calls[1]?.[0]).toMatchObject({ + includeRateLimits: true, + }); + } finally { + await feature.dispose(); + } + }); + + it('force-refreshes account truth even when a fresh snapshot exists', async () => { + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + await feature.refreshSnapshot(); + await feature.refreshSnapshot({ forceRefreshToken: true }); + + expect(readAccountMock).toHaveBeenCalledTimes(2); + expect(readAccountMock.mock.calls[1]?.[0]).toMatchObject({ + refreshToken: true, + }); + } finally { + await feature.dispose(); + } + }); + + it('does not serve stale cached auth state while logout mutation is active', async () => { + const logoutDeferred = createDeferred(); + readAccountMock + .mockResolvedValueOnce({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }) + .mockResolvedValue({ + account: createAccountResponse({ account: null, requiresOpenaiAuth: false }), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + logoutMock.mockReturnValue(logoutDeferred.promise); + readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const initialSnapshot = await feature.refreshSnapshot(); + expect(initialSnapshot.managedAccount?.email).toBe('user@example.com'); + + const logoutPromise = feature.logout(); + await vi.waitFor(() => { + expect(logoutMock).toHaveBeenCalledTimes(1); + }); + + let refreshSettled = false; + const refreshDuringLogout = feature.refreshSnapshot().then((snapshot) => { + refreshSettled = true; + return snapshot; + }); + await Promise.resolve(); + + expect(refreshSettled).toBe(false); + + logoutDeferred.resolve(); + const [duringLogoutSnapshot, afterLogoutSnapshot] = await Promise.all([ + refreshDuringLogout, + logoutPromise, + ]); + + expect(duringLogoutSnapshot.managedAccount).toBeNull(); + expect(afterLogoutSnapshot.managedAccount).toBeNull(); + expect(afterLogoutSnapshot.requiresOpenaiAuth).toBe(false); + expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({ + refreshToken: true, + }); + } finally { + await feature.dispose(); + } + }); + + it('coalesces mixed snapshot callers and preserves auth truth across logout end-to-end', async () => { + const firstRead = createDeferred<{ + account: ReturnType; + initialize: { codexHome: string; platformFamily: string; platformOs: string }; + }>(); + const healthyRead = { + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }; + readAccountMock + .mockReturnValueOnce(firstRead.promise) + .mockResolvedValueOnce(healthyRead) + .mockResolvedValueOnce({ + account: createAccountResponse({ account: null, requiresOpenaiAuth: false }), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock.mockResolvedValue(createRateLimitsResponse()); + logoutMock.mockResolvedValue({}); + + const feature = createCodexAccountFeature({ + logger: createLoggerPort(), + configManager: createConfigManager('chatgpt'), + }); + + try { + const passiveSnapshot = feature.getSnapshot(); + const rateLimitedSnapshot = feature.refreshSnapshot({ includeRateLimits: true }); + const launchReadiness = feature.getLaunchReadiness(); + + await vi.waitFor(() => { + expect(readAccountMock).toHaveBeenCalledTimes(1); + }); + firstRead.resolve(healthyRead); + + const [passiveResult, rateLimitedResult, readinessResult] = await Promise.all([ + passiveSnapshot, + rateLimitedSnapshot, + launchReadiness, + ]); + + expect(passiveResult.managedAccount?.email).toBe('user@example.com'); + expect(rateLimitedResult.rateLimits?.primary?.usedPercent).toBe(77); + expect(readinessResult.launchAllowed).toBe(true); + expect(readAccountMock).toHaveBeenCalledTimes(2); + expect(readRateLimitsMock).toHaveBeenCalledTimes(1); + + const cachedRateLimitedResult = await feature.refreshSnapshot({ includeRateLimits: true }); + expect(cachedRateLimitedResult.rateLimits?.primary?.usedPercent).toBe(77); + expect(readAccountMock).toHaveBeenCalledTimes(2); + expect(readRateLimitsMock).toHaveBeenCalledTimes(1); + + const logoutResult = await feature.logout(); + expect(logoutResult.managedAccount).toBeNull(); + expect(logoutResult.requiresOpenaiAuth).toBe(false); + expect(logoutResult.launchAllowed).toBe(false); + expect(readAccountMock).toHaveBeenCalledTimes(3); + expect(readAccountMock.mock.calls.at(-1)?.[0]).toMatchObject({ + refreshToken: true, + }); + + const cachedLoggedOutResult = await feature.getSnapshot(); + expect(cachedLoggedOutResult.managedAccount).toBeNull(); + expect(readAccountMock).toHaveBeenCalledTimes(3); + } finally { + await feature.dispose(); + } + }); + + it('keeps account snapshot healthy when the optional rate limits read fails', async () => { + readAccountMock.mockResolvedValue({ + account: createAccountResponse(), + initialize: { + codexHome: '/Users/test/.codex', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + readRateLimitsMock.mockRejectedValue(new Error('rate limit service unavailable')); + const logger = createLoggerPort(); + + const feature = createCodexAccountFeature({ + logger, + configManager: createConfigManager('chatgpt'), + }); + + try { + const snapshot = await feature.refreshSnapshot({ includeRateLimits: true }); + + expect(snapshot.appServerState).toBe('healthy'); + expect(snapshot.managedAccount?.email).toBe('user@example.com'); + expect(snapshot.rateLimits).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith('codex account rate limits refresh failed', { + error: 'rate limit service unavailable', + }); + } finally { + await feature.dispose(); + } + }); + it('keeps the last known managed account during a transient degraded read', async () => { readAccountMock .mockResolvedValueOnce({ @@ -360,9 +678,12 @@ describe('createCodexAccountFeature', () => { logger: createLoggerPort(), configManager: createConfigManager('chatgpt'), }); + const dateNowSpy = vi.spyOn(Date, 'now'); try { + dateNowSpy.mockReturnValue(1_776_000_000_000); const firstSnapshot = await feature.refreshSnapshot(); + dateNowSpy.mockReturnValue(1_776_000_006_000); const secondSnapshot = await feature.refreshSnapshot(); expect(firstSnapshot.managedAccount?.email).toBe('user@example.com'); @@ -373,7 +694,9 @@ describe('createCodexAccountFeature', () => { expect(secondSnapshot.launchAllowed).toBe(true); expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt'); expect(secondSnapshot.launchIssueMessage).toBeNull(); + expect(readAccountMock).toHaveBeenCalledTimes(2); } finally { + dateNowSpy.mockRestore(); await feature.dispose(); } }); diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index a8c02313..c553ebc9 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -209,6 +209,139 @@ describe('TeamMemberLogsFinder', () => { ]); }); + it('returns recent attributed member log file refs in one batch for advisory scans', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'runtime-advisory-batch'; + const projectPath = '/Users/test/runtime-advisory-batch'; + const projectId = '-Users-test-runtime-advisory-batch'; + const leadSessionId = 'lead-session'; + const bobSessionId = 'member-bob-session'; + const now = new Date(); + const old = new Date(Date.now() - 30 * 60_000); + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify( + { + name: teamName, + projectPath, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'Alice', agentType: 'general-purpose' }, + { name: 'Bob', agentType: 'general-purpose' }, + { name: 'Tom', agentType: 'general-purpose' }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`); + await fs.writeFile( + leadPath, + [ + JSON.stringify({ + timestamp: now.toISOString(), + type: 'user', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }), + JSON.stringify({ + timestamp: now.toISOString(), + type: 'system', + subtype: 'api_error', + retryInMs: 45_000, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const alicePath = path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice.jsonl'); + await fs.writeFile( + alicePath, + [ + JSON.stringify({ + timestamp: now.toISOString(), + type: 'user', + message: { + role: 'user', + content: `You are Alice, a reviewer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: now.toISOString(), + type: 'system', + subtype: 'api_error', + retryInMs: 45_000, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const bobPath = path.join(projectRoot, `${bobSessionId}.jsonl`); + await fs.writeFile( + bobPath, + [ + JSON.stringify({ + timestamp: now.toISOString(), + type: 'user', + teamName, + agentName: 'Bob', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "Bob".`, + }, + }), + JSON.stringify({ + timestamp: now.toISOString(), + type: 'system', + subtype: 'api_error', + retryInMs: 45_000, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const tomOldPath = path.join(projectRoot, leadSessionId, 'subagents', 'agent-tom.jsonl'); + await fs.writeFile( + tomOldPath, + JSON.stringify({ + timestamp: old.toISOString(), + type: 'user', + message: { + role: 'user', + content: `You are Tom, a developer on team "${teamName}" (${teamName}).`, + }, + }) + '\n', + 'utf8' + ); + await fs.utimes(tomOldPath, old, old); + + const finder = new TeamMemberLogsFinder(); + const refs = await finder.findRecentMemberLogFileRefsByMember( + teamName, + ['team-lead', 'Alice', 'Bob', 'Tom'], + Date.now() - 10 * 60_000 + ); + + expect(refs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ memberName: 'team-lead', filePath: leadPath }), + expect.objectContaining({ memberName: 'Alice', filePath: alicePath }), + expect.objectContaining({ memberName: 'Bob', filePath: bobPath }), + ]) + ); + expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false); + }); + it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.live.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.live.test.ts new file mode 100644 index 00000000..17f2b999 --- /dev/null +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.live.test.ts @@ -0,0 +1,71 @@ +import * as os from 'os'; +import * as path from 'path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; +import { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder'; +import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +const LIVE_TEAM = process.env.LIVE_RUNTIME_ADVISORY_TEAM?.trim(); +const LIVE_CLAUDE_BASE = + process.env.LIVE_RUNTIME_ADVISORY_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude'); + +const describeLive = LIVE_TEAM && LIVE_CLAUDE_BASE ? describe : describe.skip; + +describeLive('TeamMemberRuntimeAdvisoryService live logs smoke', () => { + beforeAll(() => { + setClaudeBasePathOverride(LIVE_CLAUDE_BASE); + }); + + afterAll(() => { + setClaudeBasePathOverride(null); + }); + + it('matches legacy member log attribution on real team logs', async () => { + const config = await new TeamConfigReader().getConfig(LIVE_TEAM!); + const memberNames = (config?.members ?? []) + .filter((member) => member.name && member.name !== 'user' && !member.removedAt) + .map((member) => member.name); + + expect(memberNames.length).toBeGreaterThan(0); + + const finder = new TeamMemberLogsFinder(); + const batchRefs = await finder.findRecentMemberLogFileRefsByMember( + LIVE_TEAM!, + memberNames, + null + ); + const batchFilesByMember = new Map>(); + for (const ref of batchRefs) { + const files = batchFilesByMember.get(ref.memberName) ?? new Set(); + files.add(ref.filePath); + batchFilesByMember.set(ref.memberName, files); + } + + for (const memberName of memberNames) { + const legacyFiles = new Set( + (await finder.findMemberLogs(LIVE_TEAM!, memberName, null)) + .map((summary) => summary.filePath) + .filter(Boolean) + ); + const batchFiles = batchFilesByMember.get(memberName) ?? new Set(); + + expect([...legacyFiles].sort()).toEqual([...batchFiles].sort()); + } + }); + + it('loads runtime advisories through the batch path without failing on real team logs', async () => { + const config = await new TeamConfigReader().getConfig(LIVE_TEAM!); + const members = (config?.members ?? []) + .filter((member) => member.name && member.name !== 'user' && !member.removedAt) + .map((member) => ({ name: member.name, removedAt: member.removedAt })); + + const advisories = await new TeamMemberRuntimeAdvisoryService( + new TeamMemberLogsFinder() + ).getMemberAdvisories(LIVE_TEAM!, members); + + expect(advisories).toBeInstanceOf(Map); + }); +}); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index 9a32eb42..a6a2d4a8 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -48,6 +48,9 @@ function createStubbedServiceHarness() { findMemberLogs: vi.fn(async (_teamName: string, memberName: string) => [ { filePath: `/logs/${memberName}.jsonl` }, ]), + findRecentMemberLogFileRefsByMember: undefined as + | undefined + | ReturnType Promise>>, }; const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never); const advisoryByFilePath = new Map(); @@ -386,6 +389,61 @@ describe('TeamMemberRuntimeAdvisoryService', () => { expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob', 'Charlie']); }); + it('uses batch member log refs once instead of scanning logs per missing member', async () => { + const { service, logsFinder, advisoryByFilePath } = createStubbedServiceHarness(); + logsFinder.findRecentMemberLogFileRefsByMember = vi.fn(async () => [ + { memberName: 'Alice', filePath: '/logs/alice-new.jsonl', mtimeMs: 300 }, + { memberName: 'Alice', filePath: '/logs/alice-old.jsonl', mtimeMs: 100 }, + { memberName: 'Bob', filePath: '/logs/bob.jsonl', mtimeMs: 200 }, + ]); + advisoryByFilePath.set('/logs/alice-new.jsonl', null); + advisoryByFilePath.set('/logs/alice-old.jsonl', buildRetryingAdvisory('alice-old')); + advisoryByFilePath.set('/logs/bob.jsonl', buildRetryingAdvisory('bob')); + + const advisories = await service.getMemberAdvisories('signal-ops', [ + buildMember('Alice'), + buildMember('Bob'), + buildMember('Charlie'), + ]); + + expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1); + expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledWith( + 'signal-ops', + ['Alice', 'Bob', 'Charlie'], + expect.any(Number) + ); + expect(logsFinder.findMemberLogs).not.toHaveBeenCalled(); + expect(advisories.get('Alice')?.message).toBe('retry:alice-old'); + expect(advisories.get('Bob')?.message).toBe('retry:bob'); + expect(advisories.has('Charlie')).toBe(false); + + await service.getMemberAdvisories('signal-ops', [ + buildMember('Alice'), + buildMember('Bob'), + buildMember('Charlie'), + ]); + expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1); + }); + + it('falls back to per-member log scans when the batch log ref lookup fails', async () => { + const { service, logsFinder } = createStubbedServiceHarness(); + logsFinder.findRecentMemberLogFileRefsByMember = vi.fn(async () => { + throw new Error('batch unavailable'); + }); + + const advisories = await service.getMemberAdvisories('signal-ops', [ + buildMember('Alice'), + buildMember('Bob'), + ]); + + expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1); + expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual([ + 'Alice', + 'Bob', + ]); + expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob']); + }); + it('limits concurrent member advisory log scans', async () => { const { service, logsFinder } = createStubbedServiceHarness(); let activeScans = 0; diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 52950e8f..17b236dd 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -264,7 +264,13 @@ function writeLaunchState( function writeBootstrapState( teamName: string, - members: { name: string; status: string; lastAttemptAt?: number; lastObservedAt?: number }[], + members: { + name: string; + status: string; + lastAttemptAt?: number; + lastObservedAt?: number; + failureReason?: string; + }[], updatedAt = new Date().toISOString() ): void { fs.writeFileSync( @@ -10108,6 +10114,143 @@ describe('TeamProvisioningService', () => { expect(result.statuses.alice?.error).toBeUndefined(); }); + it('heals terminal bootstrap-state failures when transcript shows successful member_briefing', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-transcript-heals'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const successAt = new Date(Date.now() - 60_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: successAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'item_1', + content: `Member briefing for jack on team "${teamName}" (${teamName}).\nTask briefing for jack:\nNo actionable tasks.`, + is_error: false, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.jack).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + hardFailure: false, + error: undefined, + }); + }); + + it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const staleSuccessAt = new Date(Date.now() - 180_000).toISOString(); + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + JSON.stringify({ + timestamp: staleSuccessAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'item_1', + content: `Member briefing for jack on team "${teamName}" (${teamName}).\nTask briefing for jack:\nNo actionable tasks.`, + is_error: false, + }, + ], + }, + }) + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.statuses.jack).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + bootstrapConfirmed: false, + hardFailure: true, + }); + }); + it('does not classify the bootstrap instruction prompt as a member launch failure', async () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-prompt-not-failure';