Skip to content

Stop AI Vault cold scans from re-reading the entire transcript corpus on every launch - #9474

Merged
brennanb2025 merged 1 commit into
mainfrom
brennanb2025/fix-9210-ai-vault-cold-scan
Jul 19, 2026
Merged

brennanb2025 merged 1 commit into
mainfrom
brennanb2025/fix-9210-ai-vault-cold-scan

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9210

What changes

On a fresh launch, the AI Vault (Agent Session History) scan re-parsed the user's entire transcript corpus — the reporter measured 6.75 GB read over 109 s with reused=0, and every launch paid it again. The parse cache already made in-process rescans nearly free, but it was an in-memory Map that died with the process.

This PR persists the cache's reusable portion ({mtimeMs, sizeBytes, platform, session} per transcript, ≤4096 entries, resume parser states dropped) to one JSON file under the canonical userData dir (ai-vault/session-parse-cache.json, dir 0o700/file 0o600). It loads lazily before the first scan and saves via a debounced (1.5 s) atomic temp+rename write after scans that parsed anything. Unchanged files (mtime+size+platform exact match — the same reuse gate as before, no new invalidation logic) become cache hits; files changed while the app was closed pay one full re-parse.

Measured on this machine (real corpus, dev build, fresh profile):

duration reused fullParses bytesRead
Cold launch, before fix (and first-ever launch after) 3.7 s 0 1039 1.79 GB
First scan after restart, with fix 328 ms 1036 3 3.1 MB

What does NOT change

  • The reporter's RPC-stall / GUI-freeze correlation is unverified upstream; this PR targets the measured scan cost only and makes no claim about the freeze.
  • Parsing still runs on the Electron main process. The true first launch (no cache file) and the first launch after each app update (cache discarded on appVersion mismatch, by design) still pay one full parse. Offloading to a worker/utilityProcess and the issue's watchdog suggestion are deferred follow-ups.
  • SSH-host scans (remote-session-scanner) don't use this cache and are untouched. Serve-mode/remote-runtime hosts gain persistence on their own main process (init runs in both desktop and --serve modes).
  • No renderer, preload, shared-type, mobile, or RPC-schema changes; no UI change beyond the panel populating at warm-scan speed.

Design & review process (Brennan's PR process)

  • Design: scratch design doc (gitignored, not shipped) chose persistence over offload/result-cache persistence/resume-state serialization; failure mode of every new path is "behave like today's cold scan". Cache file discarded whole on schemaVersion/appVersion mismatch or any malformed entry.
  • Design review: delegated loop ran 2 iterations (opus + gpt-5.5 reviewers) — 8 findings fixed in-doc (canonical-userData path precedent, Windows rename semantics, honest cold≈warm bounds, watchdog deferral made explicit, size math), 1 rejected; verification round clean.
  • Implementation vs doc deviations: one extra test-only export (flushSessionParseCachePersistForTests) for deterministic debounce testing; snapshot/seed tests live in the new test file rather than extending the old one.
  • Completeness verification: independent read-only pass — no blockers; concerns (envelope-only session validation, save-failure injection shape, foreign over-cap files) all subsequently addressed or adjudicated in review.
  • Headline behavior: implemented (live A/B proven, table above).
  • Broad app consistency: desktop panel and mobile session-history share this exact scan via one cache module; a persisted hit returns the identical session object an in-process hit returns, so no ordering/label/state changes and no new cross-surface disagreement.
  • Perf audit: verdict acceptable; budget claims verified ~10× conservative (worst-case payload 7.98 MB, stringify 5.6 ms, parse 7.0 ms at the 4096 cap); audit NITs fixed in-branch (orphaned .tmp sweep on load, serialized save chain, debounce-collapse test).
  • Code-review loop: 2 rounds × 2 independent reviewers. Round 1: 1 Medium (scanner hooks had zero test coverage — proven by reverting the hunk with all tests staying green) + 2 actionable Lows, all fixed with revert-sensitive tests; 1 advisory rejected (below). Round 2: both reviewers clean (166/166 tests across shuffled/isolated/fd-limited runs, zero unhandled rejections, all fixes independently re-proven revert-sensitive).

Risks / accepted trades

  • Persisted session objects are envelope-validated (per-field types on the entry, whole-file discard on any anomaly) but the session interior is trusted — same trust domain as the transcripts themselves (user-only file under userData); reviewers traced every consumer and found no crash path. Cache-file strings bypass the normalization real transcripts get.
  • Quit within ~1.5 s of a scan can lose that save (next launch cold for the delta). Dev builds have a static version, so the appVersion discard is inert across dev parser changes (matches the existing in-process staleness trade).
  • Multi-instance: atomic rename; on Windows a rename racing an open handle fails and is swallowed (save lost, never a torn file).

Validation

  • 166/166 ai-vault tests (13 new: round-trip proving reused=1, bytesRead=0 with the transcript deleted; corrupt/schema/appVersion discard; seed-no-clobber; changed-while-closed full parse; zero disk IO uninitialized; failing mkdir and failing rename both swallowed with temp cleanup; debounce collapse; orphan sweep; over-cap newest-tail; scanner-hook integration test proven to fail if either hook is dropped). pnpm typecheck:node and oxlint clean.
  • Live Electron A/B on this machine's real corpus (evidence and screenshots in the description below).
  • Not exercised: the reporter's 6.7 GB corpus, live Windows/WSL runs (code path platform-neutral; per-entry platform gate has existing tests), and the standalone merge-confidence validation pass was compressed into the live A/B above by coordinator instruction — flagging as the one process gap.

Follow-up review: live restart validation (2026-07-19)

Revalidated after rebasing onto current main (including the OpenCode SQLite worker change) with a fresh isolated dev profile and the real local transcript corpus. The panel populated with 500 sessions on both launches; the persisted cache was 1,327,760 bytes with mode 0600.

launch duration reused full parses bytes read
First launch 2,487 ms 0 520 941,154,261
Restart, same profile 137 ms 513 7 20,144,932
Next steady refresh 93 ms 514 0 22,595

The seven restart parses were transcripts that changed while the app was closed; unchanged entries came from disk as intended.

First launch — populated Agent Session History after creating the cache

First launch AI Vault panel

Restart — the same panel restored after disk-backed cache reuse

Restart AI Vault panel

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live re-verification evidence (dev build from this branch, real local corpus)

Method: launched the dev build with a fresh temp profile via CDP, triggered aiVault:listSessions over IPC, read the aiVault.scan spans from logs/main.trace.ndjson in that profile, quit the app, relaunched with the same profile, and repeated.

Run 1 — fresh profile (no cache file; equals pre-fix behavior on every launch):

aiVault.scan  durationMs=3696  candidates=1501  reused=0  incremental=0  fullParses=1039  bytesRead=1788507131  (~1.79 GB)

Cache file written by the debounced save after the scan:

<userData>/ai-vault/session-parse-cache.json   2,649,381 bytes   mode 0600 (dir 0700)
schemaVersion=1  appVersion=1.4.146-rc.0  entries=1039

Run 2 — first scan after quitting and relaunching the app (the #9210 scenario):

aiVault.scan  durationMs=328  candidates=1501  reused=1036  incremental=0  fullParses=3  bytesRead=3097122  (~3.1 MB)

Restart cold scan went from 3.7 s / 1.79 GB / reused=0 to 328 ms / 3.1 MB / reused=1036 — the 3 full parses are transcripts that genuinely changed between the runs (this session's own activity). Renderer-side IPC round-trip: 3708 ms → 337 ms.

Baseline HEAD repro for comparison (same rig, before the fix): cold durationMs=4574, reused=0, bytesRead=1792029210; warm same-process forced rescan durationMs=72, reused=1031 — the fix brings the restart scan to within ~4× of the in-process warm scan, with the remainder being stat/discovery of 1501 candidates plus the 3 changed files.

Full-window screenshots of both runs captured at /tmp/orca-9210-run1-cold.png and /tmp/orca-9210-run2-warm-restart.png (kept out of the repo per evidence policy; available on request).

@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b10511ed-d816-4685-8eb2-2e14fa50d513

📥 Commits

Reviewing files that changed from the base of the PR and between 2a13b28734e2bb2bb8b20582fb8c7a131621b632 and efb686c.

📒 Files selected for processing (5)
  • src/main/ai-vault/session-parse-cache-persistence.test.ts
  • src/main/ai-vault/session-parse-cache-persistence.ts
  • src/main/ai-vault/session-scanner-parse-cache.ts
  • src/main/ai-vault/session-scanner.ts
  • src/main/index.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/main/ai-vault/session-scanner.ts
  • src/main/index.ts
  • src/main/ai-vault/session-scanner-parse-cache.ts
  • src/main/ai-vault/session-parse-cache-persistence.ts
  • src/main/ai-vault/session-parse-cache-persistence.test.ts

📝 Walkthrough

Walkthrough

Adds persistent JSON snapshots for the AI Vault session parse cache, including strict validation, restart loading, capped seeding, debounced persistence, atomic replacement, and temporary-file cleanup. Session scanning now loads cached entries before parsing and schedules saves afterward. Application startup initializes persistence using the canonical user data path and app version. Tests cover cache reuse, invalid snapshots, scan integration, capacity limits, and filesystem failure handling.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template headings or include the requested screenshots/testing sections. Rewrite it using the repository template with Summary, Screenshots, Testing, AI Review Report, Security Audit, and Notes.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: persisting AI Vault scan caches to avoid rereading transcripts on launch.
Linked Issues check ✅ Passed The PR implements the core linked requirement: persisting the reusable AI Vault parse cache across launches with validation and reuse.
Out of Scope Changes check ✅ Passed The change set stays focused on AI Vault cache persistence, scanner integration, startup init, and tests; no unrelated code paths were added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/main/ai-vault/session-parse-cache-persistence.test.ts (1)

254-266: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optionally assert readdir is not called to fully cover the "zero disk IO" claim.

sweepOrphanedTempFiles (invoked from ensureSessionParseCacheLoaded when initialized) reads the directory via readdir. Since this test relies on the uninitialized early-return path, adding a readdir assertion guards against a regression where the sweep runs before the options === null check.

♻️ Optional: add readdir assertion
   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()
+  expect(fsPromises.readdir).not.toHaveBeenCalled()
src/main/ai-vault/session-scanner-parse-cache.ts (1)

122-144: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Ensure correct LRU order if the cache is already populated.

If the cache is already partially populated, the current logic inadvertently inverts the LRU eviction order and drops the newest seeded entries:

  1. Because Map iteration order is insertion order, appending seeded entries after existing in-process entries makes the seeded items appear newer than the in-process items.
  2. Stopping when cache.size >= MAX_CACHE_ENTRIES discards the newest seeded entries instead of the oldest.

While the current startup sequence likely guarantees an empty cache during seeding, rebuilding the map defensively ensures correct LRU behavior if this is ever called concurrently or if the initialization order changes.

♻️ Proposed refactor to rebuild the map
 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
-    }
+
+  const existing = new Map(cache)
+  cache.clear()
+
+  const newEntries = list.filter(([path]) => !existing.has(path))
+  const sliceStart = Math.max(0, newEntries.length - (MAX_CACHE_ENTRIES - existing.size))
+
+  for (const [path, entry] of newEntries.slice(sliceStart)) {
     cache.set(path, {
       mtimeMs: entry.mtimeMs,
       sizeBytes: entry.sizeBytes,
       platform: entry.platform,
       session: entry.session,
       resume: null
     })
   }
+
+  // Re-insert existing entries so they remain the newest (freshest)
+  for (const [path, entry] of existing) {
+    cache.set(path, entry)
+  }
 }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7830e843-6915-4823-addd-2ac8e22a4283

📥 Commits

Reviewing files that changed from the base of the PR and between 624c8d4 and 2a13b28734e2bb2bb8b20582fb8c7a131621b632.

📒 Files selected for processing (5)
  • src/main/ai-vault/session-parse-cache-persistence.test.ts
  • src/main/ai-vault/session-parse-cache-persistence.ts
  • src/main/ai-vault/session-scanner-parse-cache.ts
  • src/main/ai-vault/session-scanner.ts
  • src/main/index.ts

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Responding to the two optional nitpicks:

  1. readdir assertion in the zero-disk-IO test — agreed it would tighten the guard (the orphan-.tmp sweep is the one path that calls readdir, and it's gated behind the same options === null early return the test pins). Deferring rather than re-spinning CI for one assertion line; will fold into the next touch of this file.

  2. Rebuild-the-map seeding refactor — declining. The pre-populated-cache case is unreachable in production: parseAgentSessionFileCached has exactly one production caller (session-scanner.ts), and the scan awaits ensureSessionParseCacheLoaded() before any parse, so the cache is always empty at seed time; two independent review lanes verified this and stress-tested the live-entry+over-cap case (live entries survive and still hit — the failure mode is only fewer seeds, i.e. extra full parses, never wrong results). The proposed diff also drops pre-existing live entries on the floor (existing is built but never re-inserted), so as written it would trade a theoretical ordering wrinkle for real cache loss.

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).
@brennanb2025
brennanb2025 force-pushed the brennanb2025/fix-9210-ai-vault-cold-scan branch from 2a13b28 to efb686c Compare July 19, 2026 23:10
@brennanb2025
brennanb2025 merged commit 85ab729 into main Jul 19, 2026
2 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cold AI Vault scan reads 6.7 GB for ~109 s with zero cache reuse; RPC/UI stalls observed around the same incident

1 participant