From ff0543caf92583a87dd034ffbda697f8381b3e59 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 28 May 2026 21:30:32 +0300 Subject: [PATCH] perf(team): cache transcript and telemetry scans --- .../runtime/TmuxPlatformCommandExecutor.ts | 25 +- .../TmuxPlatformCommandExecutor.test.ts | 17 +- .../services/team/TeamProvisioningService.ts | 755 ++++++++++++++++-- .../team/TeamTranscriptProjectResolver.ts | 116 ++- .../TeamProvisioningPromptBuilders.ts | 58 +- src/renderer/store/index.ts | 2 +- src/renderer/store/slices/teamSlice.ts | 5 + ...ovisioningBootstrapTranscriptIndex.test.ts | 141 ++++ .../TeamProvisioningPromptBuilders.test.ts | 15 +- .../TeamTranscriptProjectResolver.test.ts | 43 +- 10 files changed, 1084 insertions(+), 93 deletions(-) create mode 100644 test/main/services/team/TeamProvisioningBootstrapTranscriptIndex.test.ts diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index a3409800..7509a220 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -28,17 +28,21 @@ export interface RuntimeProcessTableRow { pid: number; ppid: number; command: string; + cpuPercent?: number; + rssBytes?: number; } export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] { const rows: RuntimeProcessTableRow[] = []; for (const line of output.split('\n')) { - const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); + const match = /^\s*(\d+)\s+(\d+)\s+(?:(\d+(?:\.\d+)?)\s+(\d+)\s+)?(.*)$/.exec(line); if (!match) continue; const pid = Number.parseInt(match[1], 10); const ppid = Number.parseInt(match[2], 10); - const command = match[3]?.trim() ?? ''; + const cpuPercent = match[3] != null ? Number.parseFloat(match[3]) : Number.NaN; + const rssKb = match[4] != null ? Number.parseInt(match[4], 10) : Number.NaN; + const command = match[5]?.trim() ?? ''; if ( Number.isFinite(pid) && pid > 0 && @@ -46,7 +50,13 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow ppid >= 0 && command.length > 0 ) { - rows.push({ pid, ppid, command }); + rows.push({ + pid, + ppid, + command, + ...(Number.isFinite(cpuPercent) && cpuPercent >= 0 ? { cpuPercent } : {}), + ...(Number.isFinite(rssKb) && rssKb >= 0 ? { rssBytes: rssKb * 1024 } : {}), + }); } } return rows; @@ -169,7 +179,12 @@ export class TmuxPlatformCommandExecutor { async listRuntimeProcesses(): Promise { const result = process.platform === 'win32' - ? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command=']) + ? await this.#wslService.execInPreferredDistro([ + 'ps', + '-ax', + '-o', + 'pid=,ppid=,pcpu=,rss=,command=', + ]) : await this.#execNativePs(); if (result.exitCode !== 0) { throw new Error(result.stderr || 'Failed to list runtime processes'); @@ -251,7 +266,7 @@ export class TmuxPlatformCommandExecutor { return new Promise((resolve) => { execFile( 'ps', - ['-ax', '-o', 'pid=,ppid=,command='], + ['-ax', '-o', 'pid=,ppid=,pcpu=,rss=,command='], { env: process.env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 }, (error, stdout, stderr) => { const errorCode = diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts index bd9d3166..ddf60eb0 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -104,7 +104,7 @@ describe('TmuxPlatformCommandExecutor', () => { setPlatform('win32'); const execInPreferredDistro = vi.fn(async () => ({ exitCode: 0, - stdout: ' 42 1 opencode runtime --team-name demo\n', + stdout: ' 42 1 3.5 1024 opencode runtime --team-name demo\n', stderr: '', })); const executor = new TmuxPlatformCommandExecutor( @@ -116,9 +116,20 @@ describe('TmuxPlatformCommandExecutor', () => { ); await expect(executor.listRuntimeProcesses()).resolves.toEqual([ - { pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' }, + { + pid: 42, + ppid: 1, + command: 'opencode runtime --team-name demo', + cpuPercent: 3.5, + rssBytes: 1024 * 1024, + }, + ]); + expect(execInPreferredDistro).toHaveBeenCalledWith([ + 'ps', + '-ax', + '-o', + 'pid=,ppid=,pcpu=,rss=,command=', ]); - expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']); expect(childProcess.execFile).not.toHaveBeenCalled(); }); }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4dae1a17..e4024758 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -687,6 +687,26 @@ type BootstrapTranscriptOutcome = reason: string; }; +interface BootstrapTranscriptIndexedLine { + startOffset: number; + endOffset: number; + timestampMs: number; + timestampText?: string; + agentName?: string | null; + text?: string | null; +} + +interface BootstrapTranscriptFileIndex { + filePath: string; + size: number; + mtimeMs: number; + dev: number; + ino: number; + partialText: string; + partialStartOffset: number; + lines: BootstrapTranscriptIndexedLine[]; +} + import type { ActiveToolCall, AgentActionMode, @@ -754,6 +774,7 @@ import type { // its initial two-sample pass. Keep this above slow PowerShell startup time, or // the first sample can expire before the recursive second read and loop again. const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 }; +const READ_PROCESS_COMMAND_TIMEOUT_MS = 1_000; interface RuntimeProcessUsageStats { rssBytes?: number; @@ -3292,6 +3313,8 @@ export class TeamProvisioningService { private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60; 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_CACHE_TTL_MS = 2_000; + private static readonly RUNTIME_PROCESS_USAGE_CACHE_TTL_MS = 2_000; private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500; private static readonly RUNTIME_WINDOWS_PROCESS_TABLE_TIMEOUT_MS = 1_500; private static readonly RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS = 2_000; @@ -3302,6 +3325,8 @@ export class TeamProvisioningService { private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000; private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000; private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000; + private static readonly BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX = 20_000; + private static readonly BOOTSTRAP_TRANSCRIPT_FILE_INDEX_MAX = 512; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -3396,6 +3421,38 @@ export class TeamProvisioningService { includesWindowsHostRows: boolean; } >(); + private runtimeProcessTableCache: + | { + expiresAtMs: number; + rows: RuntimeTelemetryProcessTableRow[] | null; + } + | undefined; + private runtimeProcessTableInFlight: + | Promise + | undefined; + private readonly runtimeProcessUsageStatsCacheByPid = new Map< + number, + { + expiresAtMs: number; + stats: RuntimeProcessUsageStats | null; + } + >(); + private readonly bootstrapTranscriptOutcomeCache = new Map< + string, + BootstrapTranscriptOutcome | null + >(); + private readonly bootstrapTranscriptOutcomeInFlight = new Map< + string, + Promise + >(); + private readonly bootstrapTranscriptFileIndexByPath = new Map< + string, + BootstrapTranscriptFileIndex + >(); + private readonly bootstrapTranscriptFileIndexInFlight = new Map< + string, + Promise + >(); private readonly agentRuntimeSnapshotInFlightByTeam = new Map< string, { @@ -5126,6 +5183,7 @@ export class TeamProvisioningService { return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + timeout: READ_PROCESS_COMMAND_TIMEOUT_MS, }).trim(); } catch { return null; @@ -14084,8 +14142,9 @@ export class TeamProvisioningService { } let runtimeUsageTreesByRootPid = new Map(); let usageStatsByPid = new Map(); + let runtimeProcessRowsForSnapshot: RuntimeTelemetryProcessTableRow[] | null = null; try { - const runtimeProcessRows = + runtimeProcessRowsForSnapshot = runtimeRootOwnersByPid.size > 0 ? await this.readRuntimeProcessRowsForUsageSnapshot(teamName, { includeWindowsHostRows: process.platform === 'win32', @@ -14093,18 +14152,31 @@ export class TeamProvisioningService { : null; this.addRuntimeRootOwnersFromProcessRows( teamName, - runtimeProcessRows, + runtimeProcessRowsForSnapshot, runtimeRootOwnersByPid ); runtimeUsageTreesByRootPid = this.buildRuntimeUsageProcessTrees( [...runtimeUsageRootPids], - runtimeProcessRows, + runtimeProcessRowsForSnapshot, runtimeRootOwnersByPid ); const runtimeUsagePids = [ ...new Set([...runtimeUsageTreesByRootPid.values()].flatMap((tree) => tree.pids)), ]; - usageStatsByPid = await this.readProcessUsageStatsByPid(runtimeUsagePids); + usageStatsByPid = this.buildProcessUsageStatsFromRows( + runtimeProcessRowsForSnapshot, + runtimeUsagePids + ); + const pidsMissingUsageStats = + runtimeProcessRowsForSnapshot == null + ? runtimeUsagePids.filter((pid) => !usageStatsByPid.has(pid)) + : []; + if (pidsMissingUsageStats.length > 0) { + const sampledUsageStats = await this.readProcessUsageStatsByPid(pidsMissingUsageStats); + for (const [pid, stats] of sampledUsageStats) { + usageStatsByPid.set(pid, stats); + } + } } catch (error) { logger.debug( `[${teamName}] Runtime telemetry sampling failed; continuing without resource metrics: ${ @@ -14409,6 +14481,7 @@ export class TeamProvisioningService { rssPid && !usageStatsByPid.has(rssPid) && isSharedOpenCodeHost && + runtimeProcessRowsForSnapshot == null && typeof rssPid === 'number' && rssPid > 0 ) { @@ -25278,26 +25351,11 @@ export class TeamProvisioningService { } } - let processRows: RuntimeTelemetryProcessTableRow[] = []; - let processTableAvailable = true; - try { - processRows = - this.normalizeRuntimeProcessRowsForTelemetry( - await this.withRuntimeTelemetryTimeout( - listRuntimeProcessTableForCurrentPlatform(), - TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS, - 'process table runtime snapshot' - ), - process.platform === 'win32' ? 'wsl' : 'native' - ) ?? []; - } catch (error) { - processTableAvailable = false; - logger.debug( - `[${teamName}] Failed to read process table for runtime snapshot: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } + const currentProcessRows = await this.readCurrentRuntimeProcessTableRows( + 'process table runtime snapshot' + ); + const processRows = currentProcessRows ?? []; + const processTableAvailable = currentProcessRows !== null; this.runtimeProcessRowsForUsageSnapshotByTeam.set(teamName, { expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, generation: generationAtStart, @@ -25667,6 +25725,8 @@ export class TeamProvisioningService { const pid = normalizeRuntimeTelemetryNumber(candidate.pid); const ppid = normalizeRuntimeTelemetryNumber(candidate.ppid); const command = typeof candidate.command === 'string' ? candidate.command.trim() : ''; + const cpuPercent = normalizeRuntimeTelemetryNumber(candidate.cpuPercent); + const rssBytes = normalizeRuntimeTelemetryNumber(candidate.rssBytes); if (pid != null && pid > 0 && ppid != null && ppid >= 0 && command.length > 0) { const runtimeTelemetrySource = source ?? @@ -25679,6 +25739,8 @@ export class TeamProvisioningService { pid: Math.floor(pid), ppid: Math.floor(ppid), command, + ...(cpuPercent != null && cpuPercent >= 0 ? { cpuPercent } : {}), + ...(rssBytes != null && rssBytes >= 0 ? { rssBytes } : {}), ...(runtimeTelemetrySource ? { runtimeTelemetrySource } : {}), }); } @@ -25686,6 +25748,69 @@ export class TeamProvisioningService { return normalizedRows; } + private async readCurrentRuntimeProcessTableRows( + label: string + ): Promise { + const cached = this.runtimeProcessTableCache; + if (cached && cached.expiresAtMs > Date.now()) { + return cached.rows; + } + + if (this.runtimeProcessTableInFlight) { + return this.runtimeProcessTableInFlight; + } + + const request = this.withRuntimeTelemetryTimeout( + listRuntimeProcessTableForCurrentPlatform(), + TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS, + label + ) + .then( + (rows) => + this.normalizeRuntimeProcessRowsForTelemetry( + rows, + process.platform === 'win32' ? 'wsl' : 'native' + ) ?? [] + ) + .catch((error: unknown) => { + logger.debug( + `Failed to read process table for ${label}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + }) + .then((rows) => { + this.runtimeProcessTableCache = { + expiresAtMs: Date.now() + TeamProvisioningService.RUNTIME_PROCESS_TABLE_CACHE_TTL_MS, + rows, + }; + return rows; + }) + .finally(() => { + if (this.runtimeProcessTableInFlight === request) { + this.runtimeProcessTableInFlight = undefined; + } + }); + this.runtimeProcessTableInFlight = request; + return request; + } + + private findRuntimeProcessCommandByPid( + processRows: readonly RuntimeTelemetryProcessTableRow[] | null, + pid: number + ): string | null { + if (!Array.isArray(processRows) || !Number.isFinite(pid) || pid <= 0) { + return null; + } + for (const row of processRows) { + if (row.pid === pid) { + return row.command.trim() || null; + } + } + return null; + } + private async readRuntimeProcessRowsForUsageSnapshot( teamName: string, options: { includeWindowsHostRows?: boolean } = {} @@ -25709,16 +25834,9 @@ export class TeamProvisioningService { let runtimeProcessTableAvailable = rows != null; try { if (!rows) { - rows = - this.normalizeRuntimeProcessRowsForTelemetry( - await this.withRuntimeTelemetryTimeout( - listRuntimeProcessTableForCurrentPlatform(), - TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS, - 'process table runtime telemetry' - ), - process.platform === 'win32' ? 'wsl' : 'native' - ) ?? []; - runtimeProcessTableAvailable = true; + rows = await this.readCurrentRuntimeProcessTableRows('process table runtime telemetry'); + runtimeProcessTableAvailable = rows != null; + rows = rows ?? []; } } catch (error) { logger.debug( @@ -26046,6 +26164,28 @@ export class TeamProvisioningService { } } + private buildProcessUsageStatsFromRows( + processRows: readonly RuntimeTelemetryProcessTableRow[] | null, + pids: readonly number[] + ): Map { + const usageStatsByPid = new Map(); + const requestedPids = new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0)); + if (!Array.isArray(processRows) || requestedPids.size === 0) { + return usageStatsByPid; + } + + for (const row of processRows) { + if (!requestedPids.has(row.pid)) { + continue; + } + const usageStats = this.normalizeRuntimeProcessUsageStats(row); + if (usageStats) { + usageStatsByPid.set(row.pid, usageStats); + } + } + return usageStatsByPid; + } + private async readProcessUsageStatsByPid( pids: readonly number[] ): Promise> { @@ -26056,20 +26196,57 @@ export class TeamProvisioningService { } const usageStatsByPid = new Map(); + const pidsToRead: number[] = []; + const now = Date.now(); + for (const pid of uniquePids) { + const cached = this.runtimeProcessUsageStatsCacheByPid.get(pid); + if (cached && cached.expiresAtMs > now) { + if (cached.stats) { + usageStatsByPid.set(pid, { ...cached.stats }); + } + continue; + } + pidsToRead.push(pid); + } + if (pidsToRead.length === 0) { + return usageStatsByPid; + } + + const rememberUsageStats = ( + pid: number, + stats: RuntimeProcessUsageStats | null | undefined + ): void => { + const normalized = stats ? { ...stats } : null; + this.runtimeProcessUsageStatsCacheByPid.set(pid, { + expiresAtMs: Date.now() + TeamProvisioningService.RUNTIME_PROCESS_USAGE_CACHE_TTL_MS, + stats: normalized, + }); + if (normalized) { + usageStatsByPid.set(pid, { ...normalized }); + } + }; + const options = RUNTIME_PIDUSAGE_OPTIONS; try { const statsByPid = await this.withRuntimeTelemetryTimeout( - pidusage(uniquePids, options), + pidusage(pidsToRead, options), TeamProvisioningService.RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS, 'pidusage batch runtime telemetry' ); + const observedPids = new Set(); for (const [rawPid, stat] of Object.entries( statsByPid && typeof statsByPid === 'object' ? statsByPid : {} )) { const pid = Number.parseInt(rawPid, 10); const usageStats = this.normalizeRuntimeProcessUsageStats(stat); - if (Number.isFinite(pid) && pid > 0 && usageStats) { - usageStatsByPid.set(pid, usageStats); + if (Number.isFinite(pid) && pid > 0) { + observedPids.add(pid); + rememberUsageStats(pid, usageStats); + } + } + for (const pid of pidsToRead) { + if (!observedPids.has(pid)) { + rememberUsageStats(pid, null); } } return usageStatsByPid; @@ -26087,10 +26264,10 @@ export class TeamProvisioningService { for ( let offset = 0; - offset < uniquePids.length; + offset < pidsToRead.length; offset += TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY ) { - const chunk = uniquePids.slice( + const chunk = pidsToRead.slice( offset, offset + TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY ); @@ -26103,13 +26280,12 @@ export class TeamProvisioningService { `pidusage runtime telemetry pid=${pid}` ); const usageStats = this.normalizeRuntimeProcessUsageStats(stat); - if (usageStats) { - usageStatsByPid.set(pid, usageStats); - } + rememberUsageStats(pid, usageStats); } catch (error) { if (error instanceof RuntimeTelemetryTimeoutError) { logger.debug(error.message); } + rememberUsageStats(pid, null); // Process likely exited between discovery and sampling. } }) @@ -30026,7 +30202,6 @@ export class TeamProvisioningService { contextMemberNames?: readonly string[]; } = {} ): Promise { - let handle: fs.promises.FileHandle | null = null; const normalizedMemberName = memberName.trim().toLowerCase(); const contextMemberNames = Array.from( new Set( @@ -30035,14 +30210,472 @@ export class TeamProvisioningService { .filter(Boolean) ) ); + let stat: Awaited>; try { - handle = await fs.promises.open(filePath, 'r'); - const stat = await handle.stat(); + stat = await fs.promises.stat(filePath); if (!stat.isFile() || stat.size <= 0) { return null; } - const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES); - const buffer = Buffer.alloc(stat.size - start); + } catch { + return null; + } + + const cacheKey = [ + filePath, + stat.mtimeMs, + stat.size, + sinceMs ?? '', + memberName.trim().toLowerCase(), + teamName.trim().toLowerCase(), + options.allowAnonymousFailure === true ? 'anonymous' : 'named', + contextMemberNames + .map((name) => name.toLowerCase()) + .sort() + .join('\0'), + ].join('\0'); + if (this.bootstrapTranscriptOutcomeCache.has(cacheKey)) { + const cached = this.bootstrapTranscriptOutcomeCache.get(cacheKey); + return cached ? { ...cached } : null; + } + + const existing = this.bootstrapTranscriptOutcomeInFlight.get(cacheKey); + if (existing) { + const outcome = await existing; + return outcome ? { ...outcome } : null; + } + + const request = this.scanRecentBootstrapTranscriptOutcome({ + filePath, + statSize: stat.size, + statMtimeMs: stat.mtimeMs, + statDev: Number(stat.dev), + statIno: Number(stat.ino), + sinceMs, + normalizedMemberName, + contextMemberNames, + memberName, + teamName, + normalizedTeamName: teamName.trim().toLowerCase(), + allowAnonymousFailure: options.allowAnonymousFailure === true, + }) + .then((outcome) => { + this.rememberBootstrapTranscriptOutcome(cacheKey, outcome); + return outcome; + }) + .finally(() => { + if (this.bootstrapTranscriptOutcomeInFlight.get(cacheKey) === request) { + this.bootstrapTranscriptOutcomeInFlight.delete(cacheKey); + } + }); + this.bootstrapTranscriptOutcomeInFlight.set(cacheKey, request); + const outcome = await request; + return outcome ? { ...outcome } : null; + } + + private rememberBootstrapTranscriptOutcome( + cacheKey: string, + outcome: BootstrapTranscriptOutcome | null + ): void { + this.bootstrapTranscriptOutcomeCache.set(cacheKey, outcome ? { ...outcome } : null); + if ( + this.bootstrapTranscriptOutcomeCache.size <= + TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX + ) { + return; + } + const oldestKey = this.bootstrapTranscriptOutcomeCache.keys().next().value; + if (oldestKey) { + this.bootstrapTranscriptOutcomeCache.delete(oldestKey); + } + } + + private async scanRecentBootstrapTranscriptOutcome(input: { + filePath: string; + statSize: number; + statMtimeMs: number; + statDev: number; + statIno: number; + sinceMs: number | null; + normalizedMemberName: string; + contextMemberNames: readonly string[]; + memberName: string; + teamName: string; + normalizedTeamName: string; + allowAnonymousFailure: boolean; + }): Promise { + const indexedLines = await this.readBootstrapTranscriptIndexedLines(input).catch(() => null); + if (indexedLines) { + return this.selectBootstrapTranscriptOutcomeFromIndexedLines(indexedLines, input); + } + return this.scanRecentBootstrapTranscriptOutcomeFromFile(input); + } + + private async readBootstrapTranscriptIndexedLines(input: { + filePath: string; + statSize: number; + statMtimeMs: number; + statDev: number; + statIno: number; + }): Promise { + const cached = this.bootstrapTranscriptFileIndexByPath.get(input.filePath); + if ( + cached && + cached.size === input.statSize && + cached.mtimeMs === input.statMtimeMs && + cached.dev === input.statDev && + cached.ino === input.statIno + ) { + return cached.lines; + } + + const inFlightKey = `${input.filePath}\0${input.statSize}\0${input.statMtimeMs}`; + const existing = this.bootstrapTranscriptFileIndexInFlight.get(inFlightKey); + if (existing) { + const index = await existing; + return index?.lines ?? null; + } + + const request = this.readBootstrapTranscriptFileIndex(input) + .then((index) => { + if (index) { + this.rememberBootstrapTranscriptFileIndex(index); + } + return index; + }) + .finally(() => { + if (this.bootstrapTranscriptFileIndexInFlight.get(inFlightKey) === request) { + this.bootstrapTranscriptFileIndexInFlight.delete(inFlightKey); + } + }); + this.bootstrapTranscriptFileIndexInFlight.set(inFlightKey, request); + const index = await request; + return index?.lines ?? null; + } + + private rememberBootstrapTranscriptFileIndex(index: BootstrapTranscriptFileIndex): void { + this.bootstrapTranscriptFileIndexByPath.set(index.filePath, index); + if ( + this.bootstrapTranscriptFileIndexByPath.size <= + TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_FILE_INDEX_MAX + ) { + return; + } + const oldestKey = this.bootstrapTranscriptFileIndexByPath.keys().next().value; + if (oldestKey) { + this.bootstrapTranscriptFileIndexByPath.delete(oldestKey); + } + } + + private async readBootstrapTranscriptFileIndex(input: { + filePath: string; + statSize: number; + statMtimeMs: number; + statDev: number; + statIno: number; + }): Promise { + const cached = this.bootstrapTranscriptFileIndexByPath.get(input.filePath); + if ( + cached && + input.statSize > cached.size && + cached.dev === input.statDev && + cached.ino === input.statIno && + input.statMtimeMs >= cached.mtimeMs + ) { + const appended = await this.appendBootstrapTranscriptFileIndex(cached, input).catch( + () => null + ); + if (appended) { + return appended; + } + } + return this.rebuildBootstrapTranscriptFileIndex(input); + } + + private async rebuildBootstrapTranscriptFileIndex(input: { + filePath: string; + statSize: number; + statMtimeMs: number; + statDev: number; + statIno: number; + }): Promise { + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(input.filePath, 'r'); + const start = Math.max( + 0, + input.statSize - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES + ); + const buffer = Buffer.alloc(input.statSize - start); + if (buffer.length === 0) { + return { + filePath: input.filePath, + size: input.statSize, + mtimeMs: input.statMtimeMs, + dev: input.statDev, + ino: input.statIno, + partialText: '', + partialStartOffset: input.statSize, + lines: [], + }; + } + await handle.read(buffer, 0, buffer.length, start); + let text = buffer.toString('utf8'); + let offsetBase = start; + if (start > 0) { + const firstNewlineIndex = text.indexOf('\n'); + if (firstNewlineIndex < 0) { + return { + filePath: input.filePath, + size: input.statSize, + mtimeMs: input.statMtimeMs, + dev: input.statDev, + ino: input.statIno, + partialText: '', + partialStartOffset: input.statSize, + lines: [], + }; + } + const droppedText = text.slice(0, firstNewlineIndex + 1); + offsetBase += Buffer.byteLength(droppedText, 'utf8'); + text = text.slice(firstNewlineIndex + 1); + } + const parsed = this.parseBootstrapTranscriptIndexChunk(text, offsetBase); + const index: BootstrapTranscriptFileIndex = { + filePath: input.filePath, + size: input.statSize, + mtimeMs: input.statMtimeMs, + dev: input.statDev, + ino: input.statIno, + partialText: parsed.partialText, + partialStartOffset: parsed.partialStartOffset, + lines: parsed.lines, + }; + this.pruneBootstrapTranscriptFileIndex(index); + return index; + } finally { + await handle?.close().catch(() => undefined); + } + } + + private async appendBootstrapTranscriptFileIndex( + cached: BootstrapTranscriptFileIndex, + input: { + filePath: string; + statSize: number; + statMtimeMs: number; + statDev: number; + statIno: number; + } + ): Promise { + if (cached.filePath !== input.filePath || input.statSize <= cached.size) { + return null; + } + + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(input.filePath, 'r'); + const appendSize = input.statSize - cached.size; + const buffer = Buffer.alloc(appendSize); + await handle.read(buffer, 0, buffer.length, cached.size); + const parsed = this.parseBootstrapTranscriptIndexChunk( + `${cached.partialText}${buffer.toString('utf8')}`, + cached.partialStartOffset + ); + const index: BootstrapTranscriptFileIndex = { + filePath: input.filePath, + size: input.statSize, + mtimeMs: input.statMtimeMs, + dev: input.statDev, + ino: input.statIno, + partialText: parsed.partialText, + partialStartOffset: parsed.partialStartOffset, + lines: [...cached.lines, ...parsed.lines], + }; + this.pruneBootstrapTranscriptFileIndex(index); + return index; + } finally { + await handle?.close().catch(() => undefined); + } + } + + private parseBootstrapTranscriptIndexChunk( + text: string, + offsetBase: number + ): { + lines: BootstrapTranscriptIndexedLine[]; + partialText: string; + partialStartOffset: number; + } { + const lines: BootstrapTranscriptIndexedLine[] = []; + const parts = text.split('\n'); + const hasTrailingNewline = text.endsWith('\n'); + const completeCount = parts.length - 1; + let cursor = offsetBase; + + for (let index = 0; index < completeCount; index += 1) { + const rawLine = parts[index] ?? ''; + const lineStart = cursor; + const lineEnd = lineStart + Buffer.byteLength(rawLine, 'utf8') + 1; + const indexed = this.parseBootstrapTranscriptIndexedLine(rawLine, lineStart, lineEnd); + if (indexed) { + lines.push(indexed); + } + cursor = lineEnd; + } + + const finalLine = hasTrailingNewline ? '' : (parts[parts.length - 1] ?? ''); + if (!hasTrailingNewline && finalLine.length > 0) { + const lineStart = cursor; + const lineEnd = lineStart + Buffer.byteLength(finalLine, 'utf8'); + const indexed = this.parseBootstrapTranscriptIndexedLine(finalLine, lineStart, lineEnd); + if (indexed) { + lines.push(indexed); + return { lines, partialText: '', partialStartOffset: lineEnd }; + } + return { lines, partialText: finalLine, partialStartOffset: lineStart }; + } + + return { lines, partialText: '', partialStartOffset: cursor }; + } + + private parseBootstrapTranscriptIndexedLine( + rawLine: string, + startOffset: number, + endOffset: number + ): BootstrapTranscriptIndexedLine | null { + const line = rawLine.trim(); + if (!line) { + return null; + } + let parsed: { timestamp?: unknown } | null = null; + try { + parsed = JSON.parse(line) as { timestamp?: unknown }; + } catch { + return null; + } + const timestampText = + typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0 + ? parsed.timestamp.trim() + : undefined; + const timestampMs = timestampText ? Date.parse(timestampText) : Number.NaN; + const agentName = + typeof (parsed as { agentName?: unknown }).agentName === 'string' + ? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null + : null; + return { + startOffset, + endOffset, + timestampMs, + ...(timestampText ? { timestampText } : {}), + ...(agentName ? { agentName } : {}), + text: extractTranscriptMessageText(parsed), + }; + } + + private pruneBootstrapTranscriptFileIndex(index: BootstrapTranscriptFileIndex): void { + const cutoff = Math.max(0, index.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES); + index.lines = index.lines.filter((line) => line.startOffset >= cutoff); + if (index.partialStartOffset < cutoff) { + index.partialText = ''; + index.partialStartOffset = index.size; + } + } + + private selectBootstrapTranscriptOutcomeFromIndexedLines( + lines: readonly BootstrapTranscriptIndexedLine[], + input: { + sinceMs: number | null; + normalizedMemberName: string; + contextMemberNames: readonly string[]; + memberName: string; + teamName: string; + allowAnonymousFailure: boolean; + } + ): BootstrapTranscriptOutcome | null { + const bootstrapContextMembers = new Set(); + for (const line of lines) { + if ( + input.sinceMs != null && + (!Number.isFinite(line.timestampMs) || line.timestampMs < input.sinceMs) + ) { + continue; + } + if ( + line.agentName && + !matchesObservedMemberNameForExpected(line.agentName, input.normalizedMemberName) + ) { + continue; + } + if (!line.text) { + continue; + } + for (const contextMemberName of input.contextMemberNames) { + if (isBootstrapTranscriptContextText(line.text, input.teamName, contextMemberName)) { + bootstrapContextMembers.add(contextMemberName.trim().toLowerCase()); + } + } + } + + const hasUnambiguousMatchingBootstrapContext = + bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(input.normalizedMemberName); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!line) continue; + if (input.sinceMs != null) { + if (!Number.isFinite(line.timestampMs) || line.timestampMs < input.sinceMs) { + continue; + } + } + if ( + line.agentName && + !matchesObservedMemberNameForExpected(line.agentName, input.normalizedMemberName) + ) { + continue; + } + if (!line.text) continue; + const observedAt = line.timestampText ?? new Date().toISOString(); + const reason = extractBootstrapFailureReason(line.text); + if (reason) { + if ( + !line.agentName && + input.allowAnonymousFailure !== true && + !hasUnambiguousMatchingBootstrapContext + ) { + continue; + } + return { kind: 'failure', observedAt, reason }; + } + const successSource = getBootstrapTranscriptSuccessSource( + line.text, + input.teamName, + input.memberName + ); + if (successSource) { + return { kind: 'success', observedAt, source: successSource }; + } + } + + return null; + } + + private async scanRecentBootstrapTranscriptOutcomeFromFile(input: { + filePath: string; + statSize: number; + sinceMs: number | null; + normalizedMemberName: string; + contextMemberNames: readonly string[]; + memberName: string; + teamName: string; + allowAnonymousFailure: boolean; + }): Promise { + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(input.filePath, 'r'); + const start = Math.max( + 0, + input.statSize - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES + ); + const buffer = Buffer.alloc(input.statSize - start); if (buffer.length === 0) { return null; } @@ -30063,7 +30696,10 @@ export class TeamProvisioningService { } const timestampMs = typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; - if (sinceMs != null && (!Number.isFinite(timestampMs) || timestampMs < sinceMs)) { + if ( + input.sinceMs != null && + (!Number.isFinite(timestampMs) || timestampMs < input.sinceMs) + ) { continue; } const parsedAgentName = @@ -30072,7 +30708,7 @@ export class TeamProvisioningService { : null; if ( parsedAgentName && - !matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName) + !matchesObservedMemberNameForExpected(parsedAgentName, input.normalizedMemberName) ) { continue; } @@ -30080,14 +30716,15 @@ export class TeamProvisioningService { if (!text) { continue; } - for (const contextMemberName of contextMemberNames) { - if (isBootstrapTranscriptContextText(text, teamName, contextMemberName)) { + for (const contextMemberName of input.contextMemberNames) { + if (isBootstrapTranscriptContextText(text, input.teamName, contextMemberName)) { bootstrapContextMembers.add(contextMemberName.trim().toLowerCase()); } } } const hasUnambiguousMatchingBootstrapContext = - bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(normalizedMemberName); + bootstrapContextMembers.size === 1 && + bootstrapContextMembers.has(input.normalizedMemberName); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]?.trim(); if (!line) continue; @@ -30099,8 +30736,8 @@ export class TeamProvisioningService { } const timestampMs = typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; - if (sinceMs != null) { - if (!Number.isFinite(timestampMs) || timestampMs < sinceMs) { + if (input.sinceMs != null) { + if (!Number.isFinite(timestampMs) || timestampMs < input.sinceMs) { continue; } } @@ -30110,7 +30747,7 @@ export class TeamProvisioningService { : null; if ( parsedAgentName && - !matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName) + !matchesObservedMemberNameForExpected(parsedAgentName, input.normalizedMemberName) ) { continue; } @@ -30124,14 +30761,18 @@ export class TeamProvisioningService { if (reason) { if ( !parsedAgentName && - options.allowAnonymousFailure !== true && + input.allowAnonymousFailure !== true && !hasUnambiguousMatchingBootstrapContext ) { continue; } return { kind: 'failure', observedAt, reason }; } - const successSource = getBootstrapTranscriptSuccessSource(text, teamName, memberName); + const successSource = getBootstrapTranscriptSuccessSource( + text, + input.teamName, + input.memberName + ); if (successSource) { return { kind: 'success', observedAt, source: successSource }; } diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index b37bb4a1..99ea034c 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -21,6 +21,7 @@ const logger = createLogger('Service:TeamTranscriptProjectResolver'); const SESSION_DISCOVERY_CACHE_TTL = 30_000; const TEAM_AFFINITY_SCAN_LINES = 40; +const TEAM_AFFINITY_CACHE_MAX = 20_000; const ROOT_DISCOVERY_CONCURRENCY = 12; const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000; @@ -57,6 +58,21 @@ interface TeamTranscriptProjectContextOptions { includeTeamSubagentSessionDiscovery?: boolean; } +interface TeamAffinityCacheEntry { + value: boolean; + statMtimeMs: number; + statSize: number; + finalForGrowth: boolean; +} + +interface TeamAffinityObservedStat { + mtimeMs: number; + size: number; +} + +const teamAffinityCache = new Map(); +const teamAffinityInFlight = new Map>(); + type ScannedSessionProjectMatch = Omit & { projectPath?: string; }; @@ -200,6 +216,17 @@ function entryContainsNestedTeamName(value: unknown, teamName: string, depth: nu }); } +function rememberTeamAffinity(cacheKey: string, entry: TeamAffinityCacheEntry): void { + teamAffinityCache.set(cacheKey, entry); + if (teamAffinityCache.size <= TEAM_AFFINITY_CACHE_MAX) { + return; + } + const oldestKey = teamAffinityCache.keys().next().value; + if (oldestKey) { + teamAffinityCache.delete(oldestKey); + } +} + function collectKnownSessionIds(config: TeamConfig): string[] { const knownSessionIds = new Set(); const push = (value: unknown): void => { @@ -994,12 +1021,62 @@ export class TeamTranscriptProjectResolver { } private async fileBelongsToTeam(filePath: string, teamName: string): Promise { + const normalizedTeam = teamName.trim().toLowerCase(); + if (!normalizedTeam) { + return false; + } + + let stat: Awaited>; + try { + stat = await fs.stat(filePath); + if (!stat.isFile()) { + return false; + } + } catch { + return false; + } + const observedStat: TeamAffinityObservedStat = { + mtimeMs: Number(stat.mtimeMs), + size: Number(stat.size), + }; + + const cacheKey = `${filePath}\0${normalizedTeam}`; + const cached = teamAffinityCache.get(cacheKey); + if ( + cached && + (cached.finalForGrowth || + (cached.statMtimeMs === observedStat.mtimeMs && cached.statSize === observedStat.size)) + ) { + return cached.value; + } + + const inFlightKey = `${cacheKey}\0${observedStat.mtimeMs}\0${observedStat.size}`; + const existing = teamAffinityInFlight.get(inFlightKey); + if (existing) { + return (await existing).value; + } + + const promise = this.scanFileBelongsToTeam(filePath, normalizedTeam, observedStat).finally( + () => { + teamAffinityInFlight.delete(inFlightKey); + } + ); + teamAffinityInFlight.set(inFlightKey, promise); + const entry = await promise; + rememberTeamAffinity(cacheKey, entry); + return entry.value; + } + + private async scanFileBelongsToTeam( + filePath: string, + normalizedTeam: string, + stat: TeamAffinityObservedStat + ): Promise { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - const normalizedTeam = teamName.trim().toLowerCase(); + let inspected = 0; try { - let inspected = 0; for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) { @@ -1011,15 +1088,30 @@ export class TeamTranscriptProjectResolver { const entry = JSON.parse(trimmed) as Record; const directTeamName = extractDirectTeamName(entry); if (directTeamName === normalizedTeam) { - return true; + return { + value: true, + statMtimeMs: stat.mtimeMs, + statSize: stat.size, + finalForGrowth: true, + }; } if (entryContainsNestedTeamName(entry, normalizedTeam)) { - return true; + return { + value: true, + statMtimeMs: stat.mtimeMs, + statSize: stat.size, + finalForGrowth: true, + }; } const textContent = extractTextContent(entry); if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { - return true; + return { + value: true, + statMtimeMs: stat.mtimeMs, + statSize: stat.size, + finalForGrowth: true, + }; } } catch { // ignore malformed head lines @@ -1030,12 +1122,22 @@ export class TeamTranscriptProjectResolver { } } } catch { - return false; + return { + value: false, + statMtimeMs: stat.mtimeMs, + statSize: stat.size, + finalForGrowth: false, + }; } finally { rl.close(); stream.destroy(); } - return false; + return { + value: false, + statMtimeMs: stat.mtimeMs, + statSize: stat.size, + finalForGrowth: inspected >= TEAM_AFFINITY_SCAN_LINES, + }; } } diff --git a/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts index 4bc8c574..fa61e32e 100644 --- a/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts +++ b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts @@ -195,7 +195,6 @@ export function extractHeartbeatTimestamp(text: string, fallback?: string): stri export function extractBootstrapFailureReason(text: string): string | null { const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim(); if (!trimmed) return null; - if (isBootstrapInstructionPrompt(trimmed)) return null; const lower = trimmed.toLowerCase(); const looksLikeBootstrapFailure = lower.includes('bootstrap failed') || @@ -240,14 +239,16 @@ export function extractBootstrapFailureReason(text: string): string | null { lower.includes('not supported when using codex with a chatgpt account') || lower.includes('please check the provided tool list'); if (!looksLikeBootstrapFailure) return null; + if (isBootstrapInstructionPrompt(trimmed)) return null; return trimmed.slice(0, 280); } export function isBootstrapInstructionPrompt(text: string): boolean { - const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase(); - if (!normalized.startsWith('you are bootstrapping into team ')) { + const prefix = text.trimStart().slice(0, 200).replace(/\s+/g, ' ').toLowerCase(); + if (!prefix.startsWith('you are bootstrapping into team ')) { return false; } + const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase(); return ( normalized.includes('your first action is to call the mcp tool') && (normalized.includes('member_briefing') || normalized.includes('lead_briefing')) @@ -267,16 +268,29 @@ export function getBootstrapTranscriptSuccessSource( teamName: string, memberName: string ): BootstrapTranscriptSuccessSource | null { - const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); - if (!normalizedText) { - return null; - } - const normalizedTeamName = teamName.trim().toLowerCase(); const normalizedMemberName = memberName.trim().toLowerCase(); if (!normalizedTeamName || !normalizedMemberName) { return null; } + const lowerText = text.toLowerCase(); + if ( + !lowerText || + !lowerText.includes(normalizedTeamName) || + !lowerText.includes(normalizedMemberName) + ) { + return null; + } + if ( + !lowerText.includes('member briefing') && + !lowerText.includes('bootstrap выполнен') && + !lowerText.includes('команде') && + !lowerText.includes('briefing') + ) { + return null; + } + + const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); if ( normalizedText.startsWith( @@ -300,24 +314,32 @@ export function isBootstrapTranscriptContextText( teamName: string, memberName: string ): boolean { - const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); const normalizedTeamName = teamName.trim().toLowerCase(); const normalizedMemberName = memberName.trim().toLowerCase(); - if (!normalizedText || !normalizedTeamName || !normalizedMemberName) { + if (!normalizedTeamName || !normalizedMemberName) { return false; } + const lowerText = text.toLowerCase(); if ( - !normalizedText.includes(normalizedTeamName) || - !normalizedText.includes(normalizedMemberName) + !lowerText || + !lowerText.includes(normalizedTeamName) || + !lowerText.includes(normalizedMemberName) ) { return false; } - return ( - normalizedText.includes('bootstrap') || - normalizedText.includes('bootstrapping') || - normalizedText.includes('member briefing') || - normalizedText.includes('task briefing') - ); + if ( + lowerText.includes('bootstrap') || + lowerText.includes('bootstrapping') || + lowerText.includes('member briefing') || + lowerText.includes('task briefing') + ) { + return true; + } + if (!lowerText.includes('briefing')) { + return false; + } + const normalizedText = lowerText.replace(/\s+/g, ' ').trim(); + return normalizedText.includes('member briefing') || normalizedText.includes('task briefing'); } export function extractTranscriptTextContent(value: unknown): string[] { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 34839ee4..5a3d5097 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -362,7 +362,7 @@ export function initializeNotificationListeners(): () => void { const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400; const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; - const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; + const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 2000; const PROCESS_LITE_STRUCTURAL_RECONCILE_IDLE_MS = 2_500; const PROCESS_LITE_STRUCTURAL_RECONCILE_MAX_WAIT_MS = 15_000; const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index a2189160..602629c4 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -219,6 +219,7 @@ const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000; const TEAM_REFRESH_BURST_WINDOW_MS = 4_000; const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000; const POST_PAINT_TEAM_ENRICHMENT_FALLBACK_MS = 500; +const GLOBAL_TASKS_FOLLOW_UP_REFRESH_DELAY_MS = 1_500; const inFlightTeamDataRequests = new Map>(); const inFlightRefreshTeamDataCalls = new Map>(); const pendingFreshTeamDataRefreshes = new Set(); @@ -1543,6 +1544,10 @@ export const createTeamSlice: StateCreator = (set, const runRefresh = async (): Promise => { do { + const isFollowUpRefresh = pendingFreshGlobalTasksRefresh; + if (isFollowUpRefresh) { + await sleep(GLOBAL_TASKS_FOLLOW_UP_REFRESH_DELAY_MS); + } pendingFreshGlobalTasksRefresh = false; // Show skeleton only on the very first fetch — not on subsequent refreshes diff --git a/test/main/services/team/TeamProvisioningBootstrapTranscriptIndex.test.ts b/test/main/services/team/TeamProvisioningBootstrapTranscriptIndex.test.ts new file mode 100644 index 00000000..3ab9f722 --- /dev/null +++ b/test/main/services/team/TeamProvisioningBootstrapTranscriptIndex.test.ts @@ -0,0 +1,141 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@features/tmux-installer/main', () => ({ + killTmuxPaneForCurrentPlatformSync: vi.fn(), + listRuntimeProcessTableForCurrentPlatform: vi.fn(async () => []), + listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()), + listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()), + sendKeysToTmuxPaneForCurrentPlatform: vi.fn(async () => undefined), +})); + +vi.mock('pidusage', () => ({ + default: vi.fn(), +})); + +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; + +interface TranscriptIndexHarness { + bootstrapTranscriptOutcomeCache: Map; + bootstrapTranscriptOutcomeInFlight: Map>; + bootstrapTranscriptFileIndexByPath: Map; + bootstrapTranscriptFileIndexInFlight: Map>; + appendBootstrapTranscriptFileIndex: (...args: unknown[]) => Promise; + rebuildBootstrapTranscriptFileIndex: (...args: unknown[]) => Promise; + readRecentBootstrapTranscriptOutcome: ( + filePath: string, + sinceMs: number | null, + memberName: string, + teamName: string, + options?: { allowAnonymousFailure?: boolean; contextMemberNames?: readonly string[] } + ) => Promise; +} + +function createTranscriptIndexHarness(): TranscriptIndexHarness { + const service = Object.create( + TeamProvisioningService.prototype + ) as unknown as TranscriptIndexHarness; + service.bootstrapTranscriptOutcomeCache = new Map(); + service.bootstrapTranscriptOutcomeInFlight = new Map(); + service.bootstrapTranscriptFileIndexByPath = new Map(); + service.bootstrapTranscriptFileIndexInFlight = new Map(); + return service; +} + +function transcriptLine(input: { + timestamp: string; + agentName?: string; + text: string; +}): string { + return `${JSON.stringify({ + type: 'assistant', + timestamp: input.timestamp, + ...(input.agentName ? { agentName: input.agentName } : {}), + message: { + role: 'assistant', + content: [{ type: 'text', text: input.text }], + }, + })}\n`; +} + +describe('TeamProvisioningService bootstrap transcript index', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('updates the transcript outcome from appended lines using the incremental file index', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bootstrap-transcript-index-')); + const transcriptPath = path.join(tmpDir, 'session.jsonl'); + await fs.writeFile( + transcriptPath, + transcriptLine({ + timestamp: '2026-04-18T10:00:00.000Z', + agentName: 'alice', + text: 'Member briefing for alice on team "demo-team" (demo-team).', + }), + 'utf8' + ); + + const service = createTranscriptIndexHarness(); + const originalRebuild = service.rebuildBootstrapTranscriptFileIndex.bind(service); + const originalAppend = service.appendBootstrapTranscriptFileIndex.bind(service); + let rebuildCalls = 0; + let appendCalls = 0; + service.rebuildBootstrapTranscriptFileIndex = async (...args: unknown[]) => { + rebuildCalls += 1; + return originalRebuild(...args); + }; + service.appendBootstrapTranscriptFileIndex = async (...args: unknown[]) => { + appendCalls += 1; + return originalAppend(...args); + }; + + await expect( + service.readRecentBootstrapTranscriptOutcome( + transcriptPath, + null, + 'alice', + 'demo-team', + { contextMemberNames: ['alice'] } + ) + ).resolves.toEqual({ + kind: 'success', + observedAt: '2026-04-18T10:00:00.000Z', + source: 'member_briefing', + }); + expect(rebuildCalls).toBe(1); + expect(appendCalls).toBe(0); + + await fs.appendFile( + transcriptPath, + transcriptLine({ + timestamp: '2026-04-18T10:01:00.000Z', + text: 'Bootstrap failed: member_briefing tool is not available', + }), + 'utf8' + ); + + await expect( + service.readRecentBootstrapTranscriptOutcome( + transcriptPath, + null, + 'alice', + 'demo-team', + { contextMemberNames: ['alice'] } + ) + ).resolves.toEqual({ + kind: 'failure', + observedAt: '2026-04-18T10:01:00.000Z', + reason: 'Bootstrap failed: member_briefing tool is not available', + }); + expect(rebuildCalls).toBe(1); + expect(appendCalls).toBe(1); + }); +}); diff --git a/test/main/services/team/TeamProvisioningPromptBuilders.test.ts b/test/main/services/team/TeamProvisioningPromptBuilders.test.ts index f1d5ef9a..b347a81c 100644 --- a/test/main/services/team/TeamProvisioningPromptBuilders.test.ts +++ b/test/main/services/team/TeamProvisioningPromptBuilders.test.ts @@ -1,4 +1,7 @@ -import { buildGeminiPostLaunchHydrationPrompt } from '@main/services/team/provisioning/TeamProvisioningPromptBuilders'; +import { + buildGeminiPostLaunchHydrationPrompt, + getBootstrapTranscriptSuccessSource, +} from '@main/services/team/provisioning/TeamProvisioningPromptBuilders'; import { describe, expect, it } from 'vitest'; import type { MemberSpawnStatusEntry, TeamCreateRequest } from '@shared/types'; @@ -56,4 +59,14 @@ describe('TeamProvisioningPromptBuilders', () => { expect(prompt).toContain('- @tom: bootstrap confirmed'); expect(prompt).not.toContain('- @tom: failed to start'); }); + + it('recognizes bootstrap success text when member briefing is split by whitespace', () => { + expect( + getBootstrapTranscriptSuccessSource( + 'Member\nbriefing for alice on team "demo-team" (demo-team).', + 'demo-team', + 'alice' + ) + ).toBe('member_briefing'); + }); }); diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts index 699a915c..3a66303e 100644 --- a/test/main/services/team/TeamTranscriptProjectResolver.test.ts +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -1,7 +1,6 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, describe, expect, it, vi } from 'vitest'; import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver'; @@ -521,6 +520,48 @@ describe('TeamTranscriptProjectResolver', () => { expect(context?.config.projectPath).toBe(repairedProjectPath); }); + it('refreshes non-final team affinity cache when a short transcript grows', async () => { + await setupClaudeRoot(); + + const teamName = 'append-cache-team'; + const staleProjectPath = '/Users/test/stale-project'; + const repairedProjectPath = '/Users/test/repaired-project'; + const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); + await fs.mkdir(staleProjectDir, { recursive: true }); + const repaired = await createSessionFile(repairedProjectPath, 'member-session'); + + await writeTeamConfig(teamName, { + name: 'Append Cache Team', + projectPath: staleProjectPath, + members: [{ name: 'alice', agentType: 'general-purpose', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const firstContext = await resolver.getContext(teamName, { forceRefresh: true }); + expect(firstContext?.projectDir).toBe(staleProjectDir); + + await fs.appendFile( + repaired.jsonlPath, + `${JSON.stringify({ + type: 'user', + timestamp: '2026-04-18T10:01:00.000Z', + message: { + role: 'user', + content: [ + { + type: 'text', + text: `Current durable team context:\n- Team name: ${teamName}\n- Member: alice`, + }, + ], + }, + })}\n`, + 'utf8' + ); + + const secondContext = await resolver.getContext(teamName, { forceRefresh: true }); + expect(secondContext?.projectDir).toBe(repaired.projectDir); + }); + it('bounds root session discovery by team lifecycle in fast preview context', async () => { await setupClaudeRoot();