From 85ab72901be1ca4c8dc59eaf00025905ed7bdc8c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:35:26 -0700 Subject: [PATCH] Persist the AI Vault session parse cache across restarts (#9474) Cold launches re-parsed the entire agent-transcript corpus (measured 6.7 GB / 109 s upstream; 1.79 GB / 3.7 s locally) because the parse cache was an in-memory Map. Persist the reusable portion (mtime+size gated session entries, resume states dropped) to one JSON file under the canonical userData dir: lazy load before the first scan, debounced atomic save after scans that parsed anything. Restart scans now reuse unchanged files (measured 328 ms / 3 MB, reused=1036). --- .../session-parse-cache-persistence.test.ts | 437 ++++++++++++++++++ .../session-parse-cache-persistence.ts | 209 +++++++++ .../ai-vault/session-scanner-parse-cache.ts | 46 ++ src/main/ai-vault/session-scanner.ts | 9 + src/main/index.ts | 8 + 5 files changed, 709 insertions(+) create mode 100644 src/main/ai-vault/session-parse-cache-persistence.test.ts create mode 100644 src/main/ai-vault/session-parse-cache-persistence.ts diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts new file mode 100644 index 00000000000..4d7aa7113d6 --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts @@ -0,0 +1,437 @@ +import { existsSync } from 'node:fs' +import { + appendFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + utimes, + writeFile +} from 'node:fs/promises' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ensureSessionParseCacheLoaded, + flushSessionParseCachePersistForTests, + initSessionParseCachePersistence, + resetSessionParseCachePersistenceForTests, + scheduleSessionParseCachePersist +} from './session-parse-cache-persistence' +import { scanAiVaultSessions } from './session-scanner' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests, + seedSessionParseCache, + type PersistedSessionParseCacheEntry, + type SessionParseStats +} from './session-scanner-parse-cache' +import { isolatedScanRoots } from './session-scanner-test-fixtures' +import { parseClaudeSessionFile } from './session-scanner-primary-parsers' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' + +// Spy-wrap (real implementations still run) so the zero-disk-IO test can +// assert the uninitialized module never touches the filesystem. +vi.mock('node:fs/promises', { spy: true }) + +const APP_VERSION = '1.2.3-test' + +let tempRoots: string[] = [] + +beforeEach(() => { + resetSessionParseCacheForTests() + resetSessionParseCachePersistenceForTests() +}) + +afterEach(async () => { + resetSessionParseCachePersistenceForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-persist-')) + tempRoots.push(root) + return root +} + +async function claudeCandidate(path: string): Promise { + const fileStat = await stat(path) + const file: FileWithMtime = { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + } + return { agent: 'claude', file, codexHome: null } +} + +function userRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +function assistantRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text }], + usage: { input_tokens: 100, output_tokens: 40 } + } + }) +} + +async function writeTranscript(root: string): Promise { + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(path, `${userRecord(0, 'first question')}\n${assistantRecord(1, 'answer')}\n`) + return path +} + +async function parseAndPersist(path: string): Promise { + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + return stats +} + +// Clear the in-memory cache and the persistence module's memoized state, then +// re-enable persistence against the same file — a fresh launch, same profile. +function simulateRestart(cacheFile: string, appVersion = APP_VERSION): void { + resetSessionParseCacheForTests() + resetSessionParseCachePersistenceForTests() + initSessionParseCachePersistence({ filePath: cacheFile, appVersion }) +} + +async function coldParseStats(path: string): Promise { + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + return stats +} + +describe('session parse cache persistence', () => { + it('round-trips: a persisted entry is a reused hit after a restart, without reading the transcript', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'vault-state', 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(first).not.toBeNull() + expect(stats.fullParses).toBe(1) + + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + expect(existsSync(cacheFile)).toBe(true) + + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + + // Deleting the transcript proves the hit needs no transcript read at all. + await rm(transcript) + const reusedStats = createSessionParseStats() + const reused = await parseAgentSessionFileCached(candidate, process.platform, reusedStats) + expect(reusedStats.reused).toBe(1) + expect(reusedStats.fullParses).toBe(0) + expect(reusedStats.incremental).toBe(0) + expect(reusedStats.bytesRead).toBe(0) + expect(reused).toEqual(first) + }) + + it('ignores a corrupt cache file and scans cold', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + await writeFile(cacheFile, 'not json {{{') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + const transcript = await writeTranscript(root) + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('ignores a cache file with a mismatched schemaVersion', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + const persisted = JSON.parse(await readFile(cacheFile, 'utf-8')) + persisted.schemaVersion = 999 + await writeFile(cacheFile, JSON.stringify(persisted)) + + simulateRestart(cacheFile) + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('ignores a cache file written by a different app version', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + simulateRestart(cacheFile, '9.9.9-other') + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('seeding never clobbers a live in-memory entry', async () => { + const root = await makeTempDir() + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const live = await parseAgentSessionFileCached(candidate, process.platform) + expect(live).not.toBeNull() + + // A stale persisted entry for the same path (session: null marker). + seedSessionParseCache([ + [ + transcript, + { + mtimeMs: candidate.file.mtimeMs, + sizeBytes: candidate.file.sizeBytes ?? null, + platform: process.platform, + session: null + } + ] + ]) + + const stats = createSessionParseStats() + const after = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(stats.reused).toBe(1) + expect(after).toBe(live) + }) + + it('falls through to a full parse when a seeded file changed while the app was closed', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + + // Grown while "closed": seeded entries have no resume state, so this is a + // full parse (not incremental) whose result matches a cold parse. + await appendFile(transcript, `${userRecord(2, 'follow-up')}\n${assistantRecord(3, 'more')}\n`) + const stats = createSessionParseStats() + const reparsed = await parseAgentSessionFileCached( + await claudeCandidate(transcript), + process.platform, + stats + ) + expect(stats.fullParses).toBe(1) + expect(stats.incremental).toBe(0) + expect(stats.reused).toBe(0) + expect(reparsed).toEqual(await parseClaudeSessionFile((await claudeCandidate(transcript)).file)) + expect(reparsed?.messageCount).toBe(4) + }) + + it('performs zero disk IO when never initialized', async () => { + vi.clearAllMocks() + + await ensureSessionParseCacheLoaded() + scheduleSessionParseCachePersist({ reused: 0, incremental: 2, fullParses: 5, bytesRead: 10 }) + await flushSessionParseCachePersistForTests() + + expect(fsPromises.readFile).not.toHaveBeenCalled() + expect(fsPromises.writeFile).not.toHaveBeenCalled() + expect(fsPromises.mkdir).not.toHaveBeenCalled() + expect(fsPromises.rename).not.toHaveBeenCalled() + expect(fsPromises.rm).not.toHaveBeenCalled() + }) + + it('collapses back-to-back schedules into one write', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + const stats = await coldParseStats(transcript) + + vi.clearAllMocks() + scheduleSessionParseCachePersist(stats) + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + + expect(fsPromises.rename).toHaveBeenCalledTimes(1) + expect(existsSync(cacheFile)).toBe(true) + }) + + it('sweeps orphaned temp files from a prior crashed save on load', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + const orphan = join(root, 'session-parse-cache-12345-99.tmp') + await writeFile(orphan, '{"half":"written') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + await ensureSessionParseCacheLoaded() + expect(existsSync(orphan)).toBe(false) + }) + + it('scanAiVaultSessions seeds from the persisted cache and persists after parsing', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const roots = isolatedScanRoots(root) + const transcript = join(roots.claudeProjectsDir, 'project', 'scan-session.jsonl') + await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) + // Same-length markers so the rewrite below preserves sizeBytes exactly. + await writeFile( + transcript, + `${userRecord(0, 'persisted-scan-marker-AAAA')}\n${assistantRecord(1, 'answer')}\n` + ) + const pinnedMtime = new Date(1740000000000) + await utimes(transcript, pinnedMtime, pinnedMtime) + + const first = await scanAiVaultSessions(roots) + expect(first.sessions).toHaveLength(1) + expect(JSON.stringify(first.sessions[0])).toContain('persisted-scan-marker-AAAA') + // The scan itself (not a manual schedule call) must have queued the save. + await flushSessionParseCachePersistForTests() + expect(existsSync(cacheFile)).toBe(true) + + simulateRestart(cacheFile) + // Rewrite with identical length and mtime: only a seeded cache hit can + // still return the original marker; a cold re-parse would see BBBB. + await writeFile( + transcript, + `${userRecord(0, 'persisted-scan-marker-BBBB')}\n${assistantRecord(1, 'answer')}\n` + ) + await utimes(transcript, pinnedMtime, pinnedMtime) + + const second = await scanAiVaultSessions(roots) + expect(second.sessions).toHaveLength(1) + expect(JSON.stringify(second.sessions[0])).toContain('persisted-scan-marker-AAAA') + expect(second.sessions[0]).toEqual(first.sessions[0]) + }) + + it('an over-cap seed list keeps the newest tail of the snapshot order', async () => { + const root = await makeTempDir() + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + // Snapshot order is oldest→newest, so a foreign over-cap file must keep + // its newest (last) entries; the real transcript rides at the very end. + const fakes: [string, PersistedSessionParseCacheEntry][] = Array.from( + { length: 4100 }, + (_, index): [string, PersistedSessionParseCacheEntry] => [ + `/nonexistent/fake-${index}.jsonl`, + { mtimeMs: index, sizeBytes: 1, platform: process.platform, session: null } + ] + ) + seedSessionParseCache([ + ...fakes, + [ + transcript, + { + mtimeMs: candidate.file.mtimeMs, + sizeBytes: candidate.file.sizeBytes ?? null, + platform: process.platform, + session: null + } + ] + ]) + + const stats = createSessionParseStats() + await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(stats.reused).toBe(1) + expect(stats.fullParses).toBe(0) + }) + + it('a failing rename cleans up its temp file and keeps the previous snapshot usable', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + const previousSnapshot = await readFile(cacheFile, 'utf-8') + + // A second session parsed after the good save; its save's rename is + // rejected (the Windows EPERM/EBUSY story: target held open elsewhere). + const other = join(root, 'ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(other, `${userRecord(2, 'second session')}\n${assistantRecord(3, 'reply')}\n`) + const stats = createSessionParseStats() + await parseAgentSessionFileCached(await claudeCandidate(other), process.platform, stats) + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.mocked(fsPromises.rename).mockRejectedValueOnce( + Object.assign(new Error('EPERM: rename blocked'), { code: 'EPERM' }) + ) + scheduleSessionParseCachePersist(stats) + await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalled() + + // The temp file was written before the rename failed; it must not linger, + // and the previous snapshot must be byte-identical (never torn). + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + expect(await readFile(cacheFile, 'utf-8')).toBe(previousSnapshot) + + // That intact previous snapshot still round-trips on the next launch. + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + const reusedStats = createSessionParseStats() + await parseAgentSessionFileCached( + await claudeCandidate(transcript), + process.platform, + reusedStats + ) + expect(reusedStats.reused).toBe(1) + debugSpy.mockRestore() + }) + + it('swallows save failures and leaves scan results unaffected', async () => { + const root = await makeTempDir() + // A regular file where the cache directory should be makes mkdir fail on + // every platform (no chmod tricks, which don't hold on Windows or as root). + const blocker = join(root, 'blocker') + await writeFile(blocker, 'a file, not a directory') + initSessionParseCachePersistence({ + filePath: join(blocker, 'session-parse-cache.json'), + appVersion: APP_VERSION + }) + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) + + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + const session = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(session).not.toBeNull() + + scheduleSessionParseCachePersist(stats) + await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalled() + + // The in-memory cache still serves hits and no partial files were left behind. + const reusedStats = createSessionParseStats() + expect(await parseAgentSessionFileCached(candidate, process.platform, reusedStats)).toBe( + session + ) + expect(reusedStats.reused).toBe(1) + expect(await readdir(root)).toEqual(expect.arrayContaining(['blocker'])) + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + debugSpy.mockRestore() + }) +}) diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts new file mode 100644 index 00000000000..f4a924bd82f --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -0,0 +1,209 @@ +// Persists the reusable portion of the AI Vault session parse cache to one +// JSON file under userData so a fresh launch reuses prior parse work instead +// of re-reading the whole transcript corpus (issue #9210: 6.7 GB / 109 s cold +// scans). Disabled unless the composition root calls init; every failure mode +// degrades to today's cold-scan behavior. +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + seedSessionParseCache, + snapshotSessionParseCacheForPersistence, + type PersistedSessionParseCacheEntry, + type SessionParseStats +} from './session-scanner-parse-cache' + +// Bump when the persisted entry layout changes; a mismatched file is discarded whole. +const SCHEMA_VERSION = 1 +// Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write. +const SAVE_DEBOUNCE_MS = 1_500 +// The payload contains transcript-derived preview text; keep it user-only +// (mode bits are inert on Windows — the userData ACL grant is the boundary there). +const PRIVATE_DIRECTORY_MODE = 0o700 +const PRIVATE_FILE_MODE = 0o600 + +type SessionParseCachePersistenceOptions = { + filePath: string + appVersion: string +} + +let options: SessionParseCachePersistenceOptions | null = null +let loadPromise: Promise | null = null +let saveTimer: NodeJS.Timeout | null = null +let lastSave: Promise = Promise.resolve() + +/** Enable persistence. Called only from the composition root; every export is a no-op until then. */ +export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void { + options = next +} + +export function resetSessionParseCachePersistenceForTests(): void { + options = null + loadPromise = null + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + } + lastSave = Promise.resolve() +} + +/** + * Seed the in-memory parse cache from disk. Memoized: concurrent scans at + * startup all await the same load. Resolves immediately when uninitialized. + */ +export function ensureSessionParseCacheLoaded(): Promise { + if (options === null) { + return Promise.resolve() + } + loadPromise ??= loadPersistedEntries(options) + return loadPromise +} + +/** + * Schedule a debounced snapshot write after a scan that parsed something. + * Reused-only scans schedule no write (the file already reflects the cache). + */ +export function scheduleSessionParseCachePersist(stats: SessionParseStats): void { + if (options === null || stats.incremental + stats.fullParses <= 0) { + return + } + const current = options + if (saveTimer) { + clearTimeout(saveTimer) + } + saveTimer = setTimeout(() => { + saveTimer = null + // Chained so a slow write and a rescheduled save can't rename out of order + // (an older snapshot landing last); persistSnapshot never rejects. + lastSave = lastSave.then(() => persistSnapshot(current)) + }, SAVE_DEBOUNCE_MS) + // Why: a pending cache save must not keep a quitting process alive. + if (typeof saveTimer.unref === 'function') { + saveTimer.unref() + } +} + +/** Run any pending debounced save immediately and wait for it. Test-only. */ +export async function flushSessionParseCachePersistForTests(): Promise { + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + if (options !== null) { + const current = options + lastSave = lastSave.then(() => persistSnapshot(current)) + } + } + await lastSave +} + +async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise { + await sweepOrphanedTempFiles(current.filePath) + try { + const raw = await readFile(current.filePath, 'utf-8') + const entries = parsePersistedFile(JSON.parse(raw), current.appVersion) + if (entries) { + seedSessionParseCache(entries) + } + } catch { + // Why: a missing/corrupt/foreign cache file must never fail the scan; + // worst case is exactly today's cold scan. + } +} + +// A death between temp-write and rename orphans a uniquely named .tmp forever; +// sweep once per launch so they can't accumulate. Racing another instance's +// in-flight save at worst loses that save — the already-accepted rename trade. +async function sweepOrphanedTempFiles(filePath: string): Promise { + const directory = dirname(filePath) + try { + const names = await readdir(directory) + await Promise.all( + names + .filter((name) => name.startsWith('session-parse-cache-') && name.endsWith('.tmp')) + .map((name) => rm(join(directory, name), { force: true }).catch(() => {})) + ) + } catch { + // Directory missing or unreadable — nothing to sweep. + } +} + +function parsePersistedFile( + parsed: unknown, + appVersion: string +): [string, PersistedSessionParseCacheEntry][] | null { + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const file = parsed as Record + // Why: parser output shape/semantics may change between app versions, so a + // cross-version file is discarded — one cold scan per update is the price. + if (file.schemaVersion !== SCHEMA_VERSION || file.appVersion !== appVersion) { + return null + } + if (!Array.isArray(file.entries)) { + return null + } + const entries: [string, PersistedSessionParseCacheEntry][] = [] + for (const item of file.entries) { + const entry = parsePersistedEntry(item) + if (entry === null) { + // One malformed entry means the file can't be trusted; discard it whole. + return null + } + entries.push(entry) + } + return entries +} + +function parsePersistedEntry(item: unknown): [string, PersistedSessionParseCacheEntry] | null { + if (!Array.isArray(item) || item.length !== 2) { + return null + } + const [path, value] = item as [unknown, unknown] + if (typeof path !== 'string' || typeof value !== 'object' || value === null) { + return null + } + const entry = value as Record + if (typeof entry.mtimeMs !== 'number') { + return null + } + if (entry.sizeBytes !== null && typeof entry.sizeBytes !== 'number') { + return null + } + if (typeof entry.platform !== 'string') { + return null + } + if (entry.session !== null && typeof entry.session !== 'object') { + return null + } + return [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform as NodeJS.Platform, + session: entry.session as PersistedSessionParseCacheEntry['session'] + } + ] +} + +async function persistSnapshot(current: SessionParseCachePersistenceOptions): Promise { + const directory = dirname(current.filePath) + const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`) + try { + const payload = JSON.stringify({ + schemaVersion: SCHEMA_VERSION, + appVersion: current.appVersion, + entries: snapshotSessionParseCacheForPersistence() + }) + await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }) + await writeFile(tempPath, payload, { mode: PRIVATE_FILE_MODE }) + // Atomic on POSIX; on Windows a rename racing an open handle fails and is + // caught below (save lost, never a torn file). + await rename(tempPath, current.filePath) + } catch (err) { + // Why: the save runs from a timer — every error must be swallowed here or + // it becomes an unhandled rejection. Worst case is the no-file case. + await rm(tempPath, { force: true }).catch(() => {}) + console.debug('[ai-vault] session parse cache save failed', err) + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index d42da123437..b7aab770432 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -97,6 +97,52 @@ export function resetSessionParseCacheForTests(): void { cache.clear() } +// Persisted subset of a cache entry: the non-serializable `resume` parser +// state is dropped (see session-parse-cache-persistence.ts). +export type PersistedSessionParseCacheEntry = Omit + +export function snapshotSessionParseCacheForPersistence(): [ + string, + PersistedSessionParseCacheEntry +][] { + return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session + } + ]) +} + +// Seeded entries carry `resume: null`: after a restart an unchanged file is a +// cache hit; a file that changed while the app was closed pays one full +// (not incremental) re-parse. +export function seedSessionParseCache( + entries: Iterable<[string, PersistedSessionParseCacheEntry]> +): void { + const list = [...entries] + // Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest + // tail rather than seeding the oldest entries and dropping the tail. + for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) { + if (cache.size >= MAX_CACHE_ENTRIES) { + return + } + // In-process entries are always fresher than persisted ones; never clobber. + if (cache.has(path)) { + continue + } + cache.set(path, { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session, + resume: null + }) + } +} + function storeEntry(path: string, entry: SessionParseCacheEntry): void { cache.delete(path) cache.set(path, entry) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index ddb9ba7a861..795257c7408 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -13,6 +13,10 @@ import { } from './session-scanner-antigravity-history' import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { + ensureSessionParseCacheLoaded, + scheduleSessionParseCachePersist +} from './session-parse-cache-persistence' import { createSessionParseStats, parseAgentSessionFileCached, @@ -61,6 +65,9 @@ export async function scanAiVaultSessions( const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) + // Why: persisted entries must be seeded before any candidate is parsed, or + // the cold scan gains nothing from the cache file (#9210). + await ensureSessionParseCacheLoaded() const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -113,6 +120,8 @@ export async function scanAiVaultSessions( span.setAttribute('bytesRead', parseStats.bytesRead) span.setAttribute('issues', issues.length) + scheduleSessionParseCachePersist(parseStats) + return { sessions: mergeSessions(cappedSessions, scopeSessions), issues: issues.map((issue) => ({ executionHostId, ...issue })), diff --git a/src/main/index.ts b/src/main/index.ts index cc9fba24144..1b289f4102d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,6 +14,7 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' @@ -650,6 +651,13 @@ if (hasSingleInstanceLock) { // orca-dev in dev mode) but before app.setName('Orca') inside whenReady // (which would change the resolved path on case-sensitive filesystems). initDataPath() + // Why: the parse cache file must live under the canonical userData path + // captured above — late app.getPath('userData') can resolve differently + // across restarts, silently defeating persistence (reused=0 forever). + initSessionParseCachePersistence({ + filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), + appVersion: app.getVersion() + }) initOrcaProfilePaths() // Why: same timing constraint as initDataPath — capture the userData path // before app.setName changes it. See persistence.ts:20-28.