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).
This commit is contained in:
Brennan Benson
2026-07-19 16:35:26 -07:00
committed by GitHub
parent 098db33638
commit 85ab72901b
5 changed files with 709 additions and 0 deletions
@@ -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<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-persist-'))
tempRoots.push(root)
return root
}
async function claudeCandidate(path: string): Promise<SessionFileCandidate> {
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<string> {
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<SessionParseStats> {
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<SessionParseStats> {
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()
})
})
@@ -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<void> | null = null
let saveTimer: NodeJS.Timeout | null = null
let lastSave: Promise<void> = 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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown>
// 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<string, unknown>
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<void> {
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)
}
}
@@ -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<SessionParseCacheEntry, 'resume'>
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)
+9
View File
@@ -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 })),
+8
View File
@@ -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.