mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user