From 5ec82ea18c1d6c4c26ba1840835eb1890af1df79 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:36:15 +0530 Subject: [PATCH 1/2] fix(cache): version-suffix provider result caches Mixed-version binaries were clobbering the unsuffixed Codex, Cursor, and Antigravity result files and each re-parsed the whole corpus. Write *-results.v.json like the daily cache. Leave the unsuffixed file for older binaries; adopt a matching-version copy once. --- CHANGELOG.md | 1 + README.md | 2 +- docs/architecture.md | 4 +- docs/providers/antigravity.md | 2 +- docs/providers/codex.md | 2 +- docs/providers/cursor.md | 2 +- src/codex-cache.ts | 29 ++- src/cursor-cache.ts | 50 +++-- src/providers/antigravity.ts | 28 ++- tests/cache-directory-switch.test.ts | 26 +-- tests/codex-cache-invalidation.test.ts | 3 +- tests/parse-workers.test.ts | 3 +- tests/providers/codex-resume.test.ts | 3 +- tests/providers/cursor.test.ts | 5 +- tests/result-cache-suffix.test.ts | 253 +++++++++++++++++++++++++ 15 files changed, 369 insertions(+), 44 deletions(-) create mode 100644 tests/result-cache-suffix.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db988cf..b741c511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed +- **Mixed-version installs no longer thrash the Codex / Cursor / Antigravity result caches.** Daily and session caches already own a version-suffixed file so an old desktop binary and a newer CLI cannot clobber each other. The three per-provider result caches still used one unsuffixed filename with an internal version field, so a v10 and a v11 binary rewrote the same `codex-results.json` (and the Cursor / Antigravity siblings) on every run and each re-parsed its whole corpus. They now write `*-results.v.json` the same way the daily cache does. The unsuffixed file is left for older binaries; a matching-version copy is adopted once and never overwritten. (#1082) - **Codex spend no longer counts reasoning tokens twice, and cache writes are priced only where OpenAI actually charges for them.** OpenAI bills reasoning tokens as *part of* `output_tokens`, not on top of it — on a 1,396-rollout corpus all 134,316 events carrying a total satisfy `input + output == total` — but CodeBurn added `reasoning_output_tokens` to output when pricing a Codex call and again in the models, audit and per-model displays. Every Codex number was therefore too high: on that corpus **cost by $166.03 (3.5%)** and **displayed Output tokens by 34.6%** ($4,713.12 -> $4,547.09; 22.6M -> 16.8M output tokens). The raw `reasoningTokens` figure is unchanged and still reported on its own; only the double-count is gone. Both places that price a Codex call — the parser and the cache-rehydration re-price — now go through one shared `billableOutputTokens` helper, so a cold run and a warm run can never disagree. Separately, Codex's `cache_write_input_tokens` (new in codex PR #33454) was never read and cache-creation tokens were hardcoded to 0; they are now carved out of the uncached-input bucket and clamped so they can never exceed it. That carve-out happens **only on models whose pricing source publishes a real cache-write rate** — gpt-5.6 and its terra/sol/luna variants charge 1.25x input for a cache write, everything before it charges nothing extra — because CodeBurn fabricates a 1.25x rate when a source omits one, and charging that would have invented a surcharge on gpt-5.5, gpt-5.4, gpt-5.3-codex and gpt-5. On models without an explicit rate the tokens stay in the plain input bucket and the price is unchanged to the cent. The field is new enough that today's impact is $0 on that corpus. Codex sessions re-parse once and the daily cache re-derives once off the warm session cache (a global re-derivation of every day and every provider, since it has no per-provider invalidation); no other provider's numbers move. Days whose Codex transcripts have since aged out are held by the same never-lose guard #1040 relies on: a re-derivation that finds fewer calls than the settled baseline keeps the older, pre-fix (double-counted) total rather than truncating it, so those days do not pick up the repricing until their sources are re-derived with equal or greater evidence. Long-context pricing tiers from the same report are tracked separately in #1076 and the missing `gpt-5.6-codex` snapshot rows in #1077. Thanks @chr-evensen. (#1075) - **Codex calls attributed from session metadata no longer carry a stale model.** The Buffer fast path scanned `session_meta` for the first `"model"` string anywhere in the payload, so a nested `base_instructions.provenance.model` was read as if it were `payload.model` — and since the model is last-writer-wins state, that wrong value was credited to every call before the rollout's first `turn_context` and to every call after any mid-file `session_meta` (29 of 1380 rollouts on one real corpus carry a late `session_meta`, and 57 record usage before any `turn_context`). Direct payload fields are now read depth-aware, which is what the non-fast `JSON.parse` path always did. Codex sessions re-parse once (~9s on a 4 GB rollout corpus) and the daily cache re-derives once off the warm session cache, a global re-derivation of every day and every provider since it has no per-provider invalidation; it moves per-model attribution, and clears any rollup an earlier parse change had left stale. Days whose transcripts have partly aged out are held by the never-lose guard: on a real 110-day cache no day lost value and none disappeared — 100 days came back identical and 9 grok days rose by $19.80 in total. Thanks @timdp. (#1040) - **Codex `session_meta` cwd / session id / originator follow the same depth-1 window as `model`.** #1040 fixed nested `provenance.model`; the compact Buffer path still took the first `cwd`, `session_id`, `originator`, `name`, `forked_from_id` or `model_provider` anywhere in the payload, so a `dynamic_tools[].name` (or any same-named nested key) could steal the top-level field. Those strings now use the existing payload-depth-1 scan. Function-call `name` on other event types is unchanged. Codex sessions re-parse once. (#1045) diff --git a/README.md b/README.md index b04e01db..b53a88b0 100644 --- a/README.md +++ b/README.md @@ -699,7 +699,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta | **Claude Code** | `~/.claude/projects//.jsonl` | Each assistant entry carries model name, token usage (input, output, cache read, cache write), `tool_use` blocks, and timestamps. | | **Claude (multiple config dirs)** | Set via `CLAUDE_CONFIG_DIRS` (e.g. `~/.claude-work:~/.claude-personal`) | Scans every listed directory and merges sessions into one row per project so totals reflect all your Claude usage. Use `:` on POSIX, `;` on Windows; overrides `CLAUDE_CONFIG_DIR`. Missing or unreadable directories are skipped. | | **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, `~/.codex/archived_sessions/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. | -| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; the first run on a large database can take a minute. | +| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.v.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; the first run on a large database can take a minute. | | **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` or file-based `~/.local/share/opencode/storage/` (respects `XDG_DATA_HOME`; `OPENCODE_DATA_DIR`/`OPENCODE_DB_PREFIX` for renamed/forked builds) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. | | **Gemini CLI** | `~/.gemini/tmp//chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. | | **Antigravity (CLI & IDE)** | Session files under `.gemini/` folders, plus the running language server | Pulls granular trajectory and pricing from the language server process. For the short-lived CLI, optionally install a status-line hook with `codeburn antigravity-hook install` so usage is captured between menubar refreshes. The IDE is detected via the `--app-data-dir antigravity-ide` flag on Windows. | diff --git a/docs/architecture.md b/docs/architecture.md index 5ec39e28..5bb015a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,8 +136,8 @@ Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`): | File | Owner | Invalidation | |---|---|---| -| `codex-results.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. | -| `cursor-results.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. | +| `codex-results.v.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. Unsuffixed `codex-results.json` is adopted when versions match and never overwritten. | +| `cursor-results.v.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. Unsuffixed `cursor-results.json` is adopted when versions match and never overwritten. | | `daily-cache.json` | `src/daily-cache.ts` | Tracks `lastComputedDate`; new days are backfilled, old days are reused. | All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run. diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index c308c5fc..70a6c46c 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -40,7 +40,7 @@ Protobuf. Cascade and response objects map to `ParsedProviderCall` directly. ## Caching -Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. +Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.v.json` (defaults to `~/.cache/codeburn/`). The unsuffixed `antigravity-results.json` is left for older binaries; a matching-version copy is adopted once and never overwritten. The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. ## Deduplication diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 1ba46fdd..5bc7e953 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -34,7 +34,7 @@ The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ ## Caching -`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.json` (or `$CODEBURN_CACHE_DIR/codex-results.json`). Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale. +`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.v.json` (or `$CODEBURN_CACHE_DIR/codex-results.v.json`). The unsuffixed `codex-results.json` is left for older binaries; a matching-version copy is adopted once and never overwritten. Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale. A session that yielded zero parseable lines does **not** write to the cache (`codex.ts:419`); this prevents a transient read failure from pinning an empty result against a fingerprint. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 8ccf6c48..381e450c 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -27,7 +27,7 @@ The parser tries both and dedupes via `seenKeys`. ## Caching -`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.json` (override with `$CODEBURN_CACHE_DIR`). The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename. +`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.v.json` (override with `$CODEBURN_CACHE_DIR`). The unsuffixed `cursor-results.json` is left for older binaries; a matching-version copy is adopted once and never overwritten. The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename. ## Deduplication diff --git a/src/codex-cache.ts b/src/codex-cache.ts index bef56d07..fc04d400 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -27,8 +27,11 @@ import type { ParsedProviderCall } from './providers/types.js' // output, and cache_write_input_tokens is carved out of the input bucket. This // file stores each call's costUSD and token buckets verbatim, so entries // written by v10 carry the old (overstated) cost and must be re-derived. -const CODEX_CACHE_VERSION = 11 -const CACHE_FILE = 'codex-results.json' +export const CODEX_CACHE_VERSION = 11 +export const CODEX_LEGACY_CACHE_FILE = 'codex-results.json' +export function codexCacheFileName(version = CODEX_CACHE_VERSION): string { + return `codex-results.v${version}.json` +} export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number } type FileFingerprint = CodexFileFingerprint @@ -74,7 +77,15 @@ export function withCodexCacheDirectory(cacheDir: string, operation: () => T) } function getCachePath(cacheDir: string): string { - return join(cacheDir, CACHE_FILE) + return join(cacheDir, codexCacheFileName()) +} + +function getLegacyCachePath(cacheDir: string): string { + return join(cacheDir, CODEX_LEGACY_CACHE_FILE) +} + +function isCurrentCache(cache: ResultCache): boolean { + return cache.version === CODEX_CACHE_VERSION && !!cache.files && typeof cache.files === 'object' } // Embedded consumers can change CODEBURN_CACHE_DIR without reloading this @@ -94,7 +105,17 @@ async function loadCache(cacheDir: string): Promise { try { const raw = await readFile(getCachePath(cacheDir), 'utf-8') const cache = JSON.parse(raw) as ResultCache - if (cache.version === CODEX_CACHE_VERSION && cache.files && typeof cache.files === 'object') { + if (isCurrentCache(cache)) { + memCaches.set(cacheDir, cache) + return cache + } + } catch {} + // Versioned file missing/unreadable. Adopt the unsuffixed file only when its + // version matches — old binaries still own that path; we never write or delete it. + try { + const raw = await readFile(getLegacyCachePath(cacheDir), 'utf-8') + const cache = JSON.parse(raw) as ResultCache + if (isCurrentCache(cache)) { memCaches.set(cacheDir, cache) return cache } diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index d48ca33c..4517007c 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -19,7 +19,11 @@ import type { ParsedProviderCall } from './providers/types.js' // (cursor:composer-input:) with per-conversation source selection, the // agent stream regained tool/system context and stream-only sessions, and // tool names are canonicalized. v5 results mix crediting regimes. -const CURSOR_CACHE_VERSION = 6 +export const CURSOR_CACHE_VERSION = 6 +export const CURSOR_LEGACY_CACHE_FILE = 'cursor-results.json' +export function cursorCacheFileName(version = CURSOR_CACHE_VERSION): string { + return `cursor-results.v${version}.json` +} type ResultCache = { version?: number @@ -29,10 +33,30 @@ type ResultCache = { calls: ParsedProviderCall[] } -const CACHE_FILE = 'cursor-results.json' - function getCachePath(): string { - return join(getCodeburnCacheDir(), CACHE_FILE) + return join(getCodeburnCacheDir(), cursorCacheFileName()) +} + +function getLegacyCachePath(): string { + return join(getCodeburnCacheDir(), CURSOR_LEGACY_CACHE_FILE) +} + +function isCurrentHit(cache: ResultCache, fp: { mtimeMs: number; size: number }, requestedFloor: string): boolean { + return ( + cache.version === CURSOR_CACHE_VERSION + && cache.dbMtimeMs === fp.mtimeMs + && cache.dbSizeBytes === fp.size + && typeof cache.lookbackFloor === 'string' + && cache.lookbackFloor <= requestedFloor + ) +} + +async function readCacheFile(path: string): Promise { + try { + const cache = JSON.parse(await readFile(path, 'utf-8')) as ResultCache + if (cache && typeof cache === 'object') return cache + } catch {} + return null } async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size: number } | null> { @@ -52,18 +76,14 @@ export async function readCachedResults( const fp = await getDbFingerprint(dbPath) if (!fp) return null - const raw = await readFile(getCachePath(), 'utf-8') - const cache = JSON.parse(raw) as ResultCache + const versioned = await readCacheFile(getCachePath()) + if (versioned && isCurrentHit(versioned, fp, requestedFloor)) return versioned.calls + if (versioned) return null - if ( - cache.version === CURSOR_CACHE_VERSION && - cache.dbMtimeMs === fp.mtimeMs && - cache.dbSizeBytes === fp.size && - typeof cache.lookbackFloor === 'string' && - cache.lookbackFloor <= requestedFloor - ) { - return cache.calls - } + // Versioned file missing. Adopt the unsuffixed file only when its version + // and fingerprint match — old binaries still own that path. + const legacy = await readCacheFile(getLegacyCachePath()) + if (legacy && isCurrentHit(legacy, fp, requestedFloor)) return legacy.calls return null } catch { return null diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index fad0f39d..d4c5ae56 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -50,6 +50,11 @@ function conversationRoots(): readonly AntigravityConversationRoot[] { ] } const CACHE_VERSION = 5 +export const ANTIGRAVITY_CACHE_VERSION = CACHE_VERSION +export const ANTIGRAVITY_LEGACY_CACHE_FILE = 'antigravity-results.json' +export function antigravityCacheFileName(version = CACHE_VERSION): string { + return `antigravity-results.v${version}.json` +} const RPC_TIMEOUT_MS = 5000 const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -190,7 +195,15 @@ function currentCacheDir(): string { } function getCachePath(cacheDir: string): string { - return join(cacheDir, 'antigravity-results.json') + return join(cacheDir, antigravityCacheFileName()) +} + +function getLegacyCachePath(cacheDir: string): string { + return join(cacheDir, ANTIGRAVITY_LEGACY_CACHE_FILE) +} + +function isCurrentCache(cache: AntigravityCache): boolean { + return cache.version === CACHE_VERSION && !!cache.cascades && typeof cache.cascades === 'object' } export function getAntigravityStatusLineEventsPath(): string { @@ -339,12 +352,23 @@ async function loadCache(cacheDir: string): Promise { try { const raw = await readFile(getCachePath(cacheDir), 'utf-8') const cache = JSON.parse(raw) as AntigravityCache - if (cache.version === CACHE_VERSION && cache.cascades && typeof cache.cascades === 'object') { + if (isCurrentCache(cache)) { const state = { cache, dirty: false } cacheStates.set(cacheDir, state) return state } } catch { /* no cache or invalid */ } + // Versioned file missing/unreadable. Adopt the unsuffixed file only when its + // version matches — old binaries still own that path; we never write or delete it. + try { + const raw = await readFile(getLegacyCachePath(cacheDir), 'utf-8') + const cache = JSON.parse(raw) as AntigravityCache + if (isCurrentCache(cache)) { + const state = { cache, dirty: false } + cacheStates.set(cacheDir, state) + return state + } + } catch { /* no legacy cache or invalid */ } const state: AntigravityCacheState = { cache: { version: CACHE_VERSION, cascades: {} }, dirty: false, diff --git a/tests/cache-directory-switch.test.ts b/tests/cache-directory-switch.test.ts index 175cd828..240c9831 100644 --- a/tests/cache-directory-switch.test.ts +++ b/tests/cache-directory-switch.test.ts @@ -7,12 +7,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { clearCodexMemCaches, + codexCacheFileName, fingerprintFile, flushCodexCache, readCachedCodexResults, writeCachedCodexResults, } from '../src/codex-cache.js' import { + antigravityCacheFileName, clearAntigravityCacheStates, createAntigravityProvider, flushAntigravityCache, @@ -53,7 +55,7 @@ async function seedAntigravityCache( ): Promise { const sourceStat = await stat(sourcePath) await mkdir(cacheDir, { recursive: true }) - await writeFile(join(cacheDir, 'antigravity-results.json'), JSON.stringify({ + await writeFile(join(cacheDir, antigravityCacheFileName()), JSON.stringify({ version: 5, cascades: { shared: { @@ -107,7 +109,7 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { await writeCachedCodexResults(sourcePath, 'project-b', [call('codex', 'from-b')], fingerprint!) await flushCodexCache() - const diskB = JSON.parse(await readFile(join(cacheB, 'codex-results.json'), 'utf8')) + const diskB = JSON.parse(await readFile(join(cacheB, codexCacheFileName()), 'utf8')) expect(diskB.files[sourcePath].calls.map((entry: ParsedProviderCall) => entry.model)).toEqual(['from-b']) process.env['CODEBURN_CACHE_DIR'] = cacheA @@ -129,12 +131,12 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { await writeCachedCodexResults(sourceB, 'project-b', [call('codex', 'dirty-b')], (await fingerprintFile(sourceB))!) await flushCodexCache() - const diskB = JSON.parse(await readFile(join(cacheB, 'codex-results.json'), 'utf8')) + const diskB = JSON.parse(await readFile(join(cacheB, codexCacheFileName()), 'utf8')) expect(Object.keys(diskB.files)).toEqual([sourceB]) process.env['CODEBURN_CACHE_DIR'] = cacheA await flushCodexCache() - const diskA = JSON.parse(await readFile(join(cacheA, 'codex-results.json'), 'utf8')) + const diskA = JSON.parse(await readFile(join(cacheA, codexCacheFileName()), 'utf8')) expect(Object.keys(diskA.files)).toEqual([sourceA]) }) @@ -200,9 +202,9 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { expect(projects.some(project => project.sessions.some(session => session.turns.some(turn => turn.assistantCalls.some(entry => entry.provider === 'codex')) ))).toBe(true) - expect(existsSync(join(cacheA, 'codex-results.json'))).toBe(true) - expect(existsSync(join(cacheB, 'codex-results.json'))).toBe(false) - const diskA = JSON.parse(await readFile(join(cacheA, 'codex-results.json'), 'utf8')) + expect(existsSync(join(cacheA, codexCacheFileName()))).toBe(true) + expect(existsSync(join(cacheB, codexCacheFileName()))).toBe(false) + const diskA = JSON.parse(await readFile(join(cacheA, codexCacheFileName()), 'utf8')) expect(diskA.files[sourcePath].calls).toHaveLength(1) clearSessionCache() }) @@ -238,10 +240,10 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { process.env['CODEBURN_CACHE_DIR'] = cacheB await flushAntigravityCache(new Set(), cacheA) - expect(existsSync(join(cacheB, 'antigravity-results.json'))).toBe(true) - const diskB = JSON.parse(await readFile(join(cacheB, 'antigravity-results.json'), 'utf8')) + expect(existsSync(join(cacheB, antigravityCacheFileName()))).toBe(true) + const diskB = JSON.parse(await readFile(join(cacheB, antigravityCacheFileName()), 'utf8')) expect(diskB.cascades.shared.calls[0].model).toBe('from-b') - const diskA = JSON.parse(await readFile(join(cacheA, 'antigravity-results.json'), 'utf8')) + const diskA = JSON.parse(await readFile(join(cacheA, antigravityCacheFileName()), 'utf8')) expect(diskA.cascades).toEqual({}) }) @@ -261,9 +263,9 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { // Another process republishes both cache files. Without the clear, the // resident keeps serving its warm copies. await seedAntigravityCache(cacheDir, antigravitySource, 'after') - const codexDisk = JSON.parse(await readFile(join(cacheDir, 'codex-results.json'), 'utf8')) + const codexDisk = JSON.parse(await readFile(join(cacheDir, codexCacheFileName()), 'utf8')) codexDisk.files[codexSource].calls[0].model = 'after' - await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify(codexDisk)) + await writeFile(join(cacheDir, codexCacheFileName()), JSON.stringify(codexDisk)) expect((await readCachedCodexResults(codexSource))?.calls.map(entry => entry.model)).toEqual(['before']) expect(await readAntigravityModel(antigravitySource)).toBe('before') diff --git a/tests/codex-cache-invalidation.test.ts b/tests/codex-cache-invalidation.test.ts index 35b80f7f..1dabc9a1 100644 --- a/tests/codex-cache-invalidation.test.ts +++ b/tests/codex-cache-invalidation.test.ts @@ -88,7 +88,8 @@ describe('codex parser change invalidates stale session-cache (#478/#513)', () = } } await writeCacheOnDisk(cache) - const codexCachePath = join(CACHE_DIR, 'codex-results.json') + const { codexCacheFileName } = await import('../src/codex-cache.js') + const codexCachePath = join(CACHE_DIR, codexCacheFileName()) const codexCache = JSON.parse(await readFile(codexCachePath, 'utf8')) codexCache.version = 4 for (const f of Object.values(codexCache.files) as any[]) { diff --git a/tests/parse-workers.test.ts b/tests/parse-workers.test.ts index 4185be59..c182b1ff 100644 --- a/tests/parse-workers.test.ts +++ b/tests/parse-workers.test.ts @@ -236,7 +236,8 @@ async function shardBodies(cacheDir: string): Promise> { /// The Codex incremental cache is a single JSON file; both runs read the same /// rollouts, so it must come out identical byte for byte. async function codexResults(cacheDir: string): Promise { - return readFile(join(cacheDir, 'codex-results.json'), 'utf-8').catch(() => null) + const { codexCacheFileName } = await import('../src/codex-cache.js') + return readFile(join(cacheDir, codexCacheFileName()), 'utf-8').catch(() => null) } function runCli(args: string[], home: string, extraEnv: Record) { diff --git a/tests/providers/codex-resume.test.ts b/tests/providers/codex-resume.test.ts index e63e41f8..a774fa0f 100644 --- a/tests/providers/codex-resume.test.ts +++ b/tests/providers/codex-resume.test.ts @@ -183,7 +183,8 @@ describe('codex incremental resume', () => { sessionPath = await writeRollout([meta(), ...tasks(1, 2)]) await parse(cacheDir) - const cachePath = join(cacheDir, 'codex-results.json') + const { codexCacheFileName } = await import('../../src/codex-cache.js') + const cachePath = join(cacheDir, codexCacheFileName()) const { readFile } = await import('fs/promises') const raw = JSON.parse(await readFile(cachePath, 'utf-8')) raw.files[sessionPath].resumeState = { garbage: true } diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts index 34c28d88..14a184ce 100644 --- a/tests/providers/cursor.test.ts +++ b/tests/providers/cursor.test.ts @@ -106,8 +106,9 @@ describe('cursor cache', () => { process.env['CODEBURN_CACHE_DIR'] = secondCacheDir await writeCachedResults(dbPath, [], secondFloor) - const firstPath = join(firstCacheDir, 'cursor-results.json') - const secondPath = join(secondCacheDir, 'cursor-results.json') + const { cursorCacheFileName } = await import('../../src/cursor-cache.js') + const firstPath = join(firstCacheDir, cursorCacheFileName()) + const secondPath = join(secondCacheDir, cursorCacheFileName()) const first = JSON.parse(await readFile(firstPath, 'utf-8')) as { lookbackFloor: string } const second = JSON.parse(await readFile(secondPath, 'utf-8')) as { lookbackFloor: string } expect(first.lookbackFloor).toBe(firstFloor) diff --git a/tests/result-cache-suffix.test.ts b/tests/result-cache-suffix.test.ts new file mode 100644 index 00000000..36bfe2cb --- /dev/null +++ b/tests/result-cache-suffix.test.ts @@ -0,0 +1,253 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + CODEX_CACHE_VERSION, + CODEX_LEGACY_CACHE_FILE, + clearCodexMemCaches, + codexCacheFileName, + fingerprintFile, + flushCodexCache, + readCachedCodexResults, + writeCachedCodexResults, +} from '../src/codex-cache.js' +import { + CURSOR_CACHE_VERSION, + CURSOR_LEGACY_CACHE_FILE, + cursorCacheFileName, + readCachedResults, + writeCachedResults, +} from '../src/cursor-cache.js' +import { + ANTIGRAVITY_CACHE_VERSION, + ANTIGRAVITY_LEGACY_CACHE_FILE, + antigravityCacheFileName, + clearAntigravityCacheStates, + createAntigravityProvider, +} from '../src/providers/antigravity.js' +import type { ParsedProviderCall } from '../src/providers/types.js' + +const originalCacheDir = process.env['CODEBURN_CACHE_DIR'] +let root: string + +function call(provider: string, marker: string): ParsedProviderCall { + return { + provider, + model: marker, + inputTokens: 1, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0, + tools: [], + bashCommands: [], + timestamp: '2026-08-22T00:00:00.000Z', + speed: 'standard', + deduplicationKey: `${provider}:${marker}`, + userMessage: '', + sessionId: marker, + } +} + +async function readAntigravityModel(sourcePath: string): Promise { + const parser = createAntigravityProvider().createSessionParser({ + path: sourcePath, + project: 'fixture', + provider: 'antigravity', + }, new Set()) + for await (const parsed of parser.parse()) return parsed.model + return undefined +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codeburn-result-suffix-')) + process.env['CODEBURN_CACHE_DIR'] = root + clearCodexMemCaches() + clearAntigravityCacheStates() +}) + +afterEach(async () => { + if (originalCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = originalCacheDir + clearCodexMemCaches() + clearAntigravityCacheStates() + await rm(root, { recursive: true, force: true }) +}) + +describe('unsuffixed result caches coexist with mixed-version binaries', () => { + it('publishes Codex to the versioned file and leaves an older unsuffixed file intact', async () => { + const sourcePath = join(root, 'rollout.jsonl') + await writeFile(sourcePath, '{}\n') + const fingerprint = await fingerprintFile(sourcePath) + expect(fingerprint).not.toBeNull() + + const legacyPath = join(root, CODEX_LEGACY_CACHE_FILE) + await writeFile(legacyPath, JSON.stringify({ + version: CODEX_CACHE_VERSION - 1, + files: { [sourcePath]: { mtimeMs: 1, sizeBytes: 1, project: 'old', calls: [call('codex', 'old-binary')] } }, + })) + + await writeCachedCodexResults(sourcePath, 'project', [call('codex', 'new-binary')], fingerprint!) + await flushCodexCache() + + expect(existsSync(join(root, codexCacheFileName()))).toBe(true) + expect(existsSync(legacyPath)).toBe(true) + const versioned = JSON.parse(await readFile(join(root, codexCacheFileName()), 'utf8')) + const legacy = JSON.parse(await readFile(legacyPath, 'utf8')) + expect(versioned.version).toBe(CODEX_CACHE_VERSION) + expect(versioned.files[sourcePath].calls[0].model).toBe('new-binary') + expect(legacy.version).toBe(CODEX_CACHE_VERSION - 1) + expect(legacy.files[sourcePath].calls[0].model).toBe('old-binary') + }) + + it('adopts a matching-version unsuffixed Codex cache and ignores a mismatched one', async () => { + const sourcePath = join(root, 'rollout.jsonl') + await writeFile(sourcePath, '{}\n') + const fingerprint = await fingerprintFile(sourcePath) + expect(fingerprint).not.toBeNull() + + await writeFile(join(root, CODEX_LEGACY_CACHE_FILE), JSON.stringify({ + version: CODEX_CACHE_VERSION, + files: { + [sourcePath]: { + dev: fingerprint!.dev, + ino: fingerprint!.ino, + mtimeMs: fingerprint!.mtimeMs, + sizeBytes: fingerprint!.sizeBytes, + project: 'adopted', + calls: [call('codex', 'adopt-me')], + }, + }, + })) + + expect((await readCachedCodexResults(sourcePath))?.calls.map(entry => entry.model)).toEqual(['adopt-me']) + expect(existsSync(join(root, CODEX_LEGACY_CACHE_FILE))).toBe(true) + expect(existsSync(join(root, codexCacheFileName()))).toBe(false) + + clearCodexMemCaches() + await writeFile(join(root, CODEX_LEGACY_CACHE_FILE), JSON.stringify({ + version: CODEX_CACHE_VERSION - 1, + files: { + [sourcePath]: { + dev: fingerprint!.dev, + ino: fingerprint!.ino, + mtimeMs: fingerprint!.mtimeMs, + sizeBytes: fingerprint!.sizeBytes, + project: 'stale', + calls: [call('codex', 'do-not-adopt')], + }, + }, + })) + expect(await readCachedCodexResults(sourcePath)).toBeNull() + }) + + it('publishes Cursor to the versioned file and leaves an older unsuffixed file intact', async () => { + const dbPath = join(root, 'state.vscdb') + await writeFile(dbPath, 'cursor-db') + const floor = '2026-01-01T00:00:00.000Z' + const legacyPath = join(root, CURSOR_LEGACY_CACHE_FILE) + await writeFile(legacyPath, JSON.stringify({ + version: CURSOR_CACHE_VERSION - 1, + dbMtimeMs: 1, + dbSizeBytes: 1, + lookbackFloor: floor, + calls: [call('cursor', 'old-binary')], + })) + + await writeCachedResults(dbPath, [call('cursor', 'new-binary')], floor) + + expect(existsSync(join(root, cursorCacheFileName()))).toBe(true) + expect(existsSync(legacyPath)).toBe(true) + const versioned = JSON.parse(await readFile(join(root, cursorCacheFileName()), 'utf8')) + const legacy = JSON.parse(await readFile(legacyPath, 'utf8')) + expect(versioned.version).toBe(CURSOR_CACHE_VERSION) + expect(versioned.calls[0].model).toBe('new-binary') + expect(legacy.version).toBe(CURSOR_CACHE_VERSION - 1) + expect(legacy.calls[0].model).toBe('old-binary') + }) + + it('adopts a matching-version unsuffixed Cursor cache and ignores a mismatched one', async () => { + const dbPath = join(root, 'state.vscdb') + await writeFile(dbPath, 'cursor-db') + const { stat } = await import('fs/promises') + const fp = await stat(dbPath) + const floor = '2026-01-01T00:00:00.000Z' + + await writeFile(join(root, CURSOR_LEGACY_CACHE_FILE), JSON.stringify({ + version: CURSOR_CACHE_VERSION, + dbMtimeMs: fp.mtimeMs, + dbSizeBytes: fp.size, + lookbackFloor: floor, + calls: [call('cursor', 'adopt-me')], + })) + expect((await readCachedResults(dbPath, floor))?.map(entry => entry.model)).toEqual(['adopt-me']) + expect(existsSync(join(root, cursorCacheFileName()))).toBe(false) + + await writeFile(join(root, CURSOR_LEGACY_CACHE_FILE), JSON.stringify({ + version: CURSOR_CACHE_VERSION - 1, + dbMtimeMs: fp.mtimeMs, + dbSizeBytes: fp.size, + lookbackFloor: floor, + calls: [call('cursor', 'do-not-adopt')], + })) + expect(await readCachedResults(dbPath, floor)).toBeNull() + }) + + it('publishes Antigravity to the versioned file and leaves an older unsuffixed file intact', async () => { + const sourcePath = join(root, 'shared.pb') + await writeFile(sourcePath, 'fixture') + const { stat } = await import('fs/promises') + const sourceStat = await stat(sourcePath) + const legacyPath = join(root, ANTIGRAVITY_LEGACY_CACHE_FILE) + await mkdir(root, { recursive: true }) + await writeFile(legacyPath, JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION - 1, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'old-binary')] }, + }, + })) + + await writeFile(join(root, antigravityCacheFileName()), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'new-binary')] }, + }, + })) + + expect(await readAntigravityModel(sourcePath)).toBe('new-binary') + const legacy = JSON.parse(await readFile(legacyPath, 'utf8')) + expect(legacy.cascades.shared.calls[0].model).toBe('old-binary') + }) + + it('adopts a matching-version unsuffixed Antigravity cache and ignores a mismatched one', async () => { + const sourcePath = join(root, 'shared.pb') + await writeFile(sourcePath, 'fixture') + const { stat } = await import('fs/promises') + const sourceStat = await stat(sourcePath) + + await writeFile(join(root, ANTIGRAVITY_LEGACY_CACHE_FILE), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'adopt-me')] }, + }, + })) + expect(await readAntigravityModel(sourcePath)).toBe('adopt-me') + expect(existsSync(join(root, antigravityCacheFileName()))).toBe(false) + + clearAntigravityCacheStates() + await writeFile(join(root, ANTIGRAVITY_LEGACY_CACHE_FILE), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION - 1, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'do-not-adopt')] }, + }, + })) + expect(await readAntigravityModel(sourcePath)).toBeUndefined() + }) +}) From add21524dac25a4b742f9471d986fd0a05c57267 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:49:39 +0530 Subject: [PATCH 2/2] fix(cache): treat a present versioned result file as exclusive --- src/cache-dir.ts | 16 ++++++++ src/codex-cache.ts | 30 +++++++++------ src/cursor-cache.ts | 18 ++++++--- src/providers/antigravity.ts | 36 ++++++++++++----- tests/result-cache-suffix.test.ts | 64 +++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 27 deletions(-) diff --git a/src/cache-dir.ts b/src/cache-dir.ts index a202be05..c76d0f31 100644 --- a/src/cache-dir.ts +++ b/src/cache-dir.ts @@ -1,3 +1,4 @@ +import { readFile } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' @@ -11,3 +12,18 @@ export function getCodeburnCacheDir(): string { const override = process.env['CODEBURN_CACHE_DIR'] return override?.trim() ? override : join(homedir(), '.cache', 'codeburn') } + +/** A versioned cache file is the only source when it exists. Legacy adoption is ENOENT-only. */ +export type ExistingTextFile = + | { status: 'absent' } + | { status: 'unreadable' } + | { status: 'ok'; text: string } + +export async function readExistingTextFile(path: string): Promise { + try { + return { status: 'ok', text: await readFile(path, 'utf-8') } + } catch (err) { + const code = err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined + return { status: code === 'ENOENT' ? 'absent' : 'unreadable' } + } +} diff --git a/src/codex-cache.ts b/src/codex-cache.ts index fc04d400..70cc8e2b 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -4,7 +4,7 @@ import { randomBytes } from 'crypto' import { join, resolve } from 'path' import { AsyncLocalStorage } from 'node:async_hooks' -import { getCodeburnCacheDir } from './cache-dir.js' +import { getCodeburnCacheDir, readExistingTextFile } from './cache-dir.js' import type { ParsedProviderCall } from './providers/types.js' // v4: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478). @@ -102,15 +102,24 @@ export function clearCodexMemCaches(): void { async function loadCache(cacheDir: string): Promise { const inMemory = memCaches.get(cacheDir) if (inMemory) return inMemory - try { - const raw = await readFile(getCachePath(cacheDir), 'utf-8') - const cache = JSON.parse(raw) as ResultCache - if (isCurrentCache(cache)) { - memCaches.set(cacheDir, cache) - return cache - } - } catch {} - // Versioned file missing/unreadable. Adopt the unsuffixed file only when its + const empty = { version: CODEX_CACHE_VERSION, files: {} } + const versioned = await readExistingTextFile(getCachePath(cacheDir)) + if (versioned.status === 'ok') { + try { + const cache = JSON.parse(versioned.text) as ResultCache + if (isCurrentCache(cache)) { + memCaches.set(cacheDir, cache) + return cache + } + } catch {} + memCaches.set(cacheDir, empty) + return empty + } + if (versioned.status === 'unreadable') { + memCaches.set(cacheDir, empty) + return empty + } + // Versioned file is absent (ENOENT). Adopt the unsuffixed file only when its // version matches — old binaries still own that path; we never write or delete it. try { const raw = await readFile(getLegacyCachePath(cacheDir), 'utf-8') @@ -120,7 +129,6 @@ async function loadCache(cacheDir: string): Promise { return cache } } catch {} - const empty = { version: CODEX_CACHE_VERSION, files: {} } memCaches.set(cacheDir, empty) return empty } diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index 4517007c..cd262a7c 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises' import { join } from 'path' import { randomBytes } from 'crypto' -import { getCodeburnCacheDir } from './cache-dir.js' +import { getCodeburnCacheDir, readExistingTextFile } from './cache-dir.js' import type { ParsedProviderCall } from './providers/types.js' // Bumped to 3 for the workspace-aware breakdown change: the cursor parser @@ -76,12 +76,18 @@ export async function readCachedResults( const fp = await getDbFingerprint(dbPath) if (!fp) return null - const versioned = await readCacheFile(getCachePath()) - if (versioned && isCurrentHit(versioned, fp, requestedFloor)) return versioned.calls - if (versioned) return null + const versioned = await readExistingTextFile(getCachePath()) + if (versioned.status === 'ok') { + try { + const cache = JSON.parse(versioned.text) as ResultCache + if (cache && typeof cache === 'object' && isCurrentHit(cache, fp, requestedFloor)) return cache.calls + } catch {} + return null + } + if (versioned.status === 'unreadable') return null - // Versioned file missing. Adopt the unsuffixed file only when its version - // and fingerprint match — old binaries still own that path. + // Versioned file is absent (ENOENT). Adopt the unsuffixed file only when its + // version and fingerprint match — old binaries still own that path. const legacy = await readCacheFile(getLegacyCachePath()) if (legacy && isCurrentHit(legacy, fp, requestedFloor)) return legacy.calls return null diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index d4c5ae56..7924d104 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -6,7 +6,7 @@ import { homedir } from 'os' import { fileURLToPath } from 'url' import https from 'https' -import { getCodeburnCacheDir } from '../cache-dir.js' +import { getCodeburnCacheDir, readExistingTextFile } from '../cache-dir.js' import { calculateCost } from '../models.js' import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js' import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -349,16 +349,32 @@ export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMet async function loadCache(cacheDir: string): Promise { const inMemory = cacheStates.get(cacheDir) if (inMemory) return inMemory - try { - const raw = await readFile(getCachePath(cacheDir), 'utf-8') - const cache = JSON.parse(raw) as AntigravityCache - if (isCurrentCache(cache)) { - const state = { cache, dirty: false } - cacheStates.set(cacheDir, state) - return state + const versioned = await readExistingTextFile(getCachePath(cacheDir)) + if (versioned.status === 'ok') { + try { + const cache = JSON.parse(versioned.text) as AntigravityCache + if (isCurrentCache(cache)) { + const state = { cache, dirty: false } + cacheStates.set(cacheDir, state) + return state + } + } catch { /* present but invalid */ } + const invalid: AntigravityCacheState = { + cache: { version: CACHE_VERSION, cascades: {} }, + dirty: false, } - } catch { /* no cache or invalid */ } - // Versioned file missing/unreadable. Adopt the unsuffixed file only when its + cacheStates.set(cacheDir, invalid) + return invalid + } + if (versioned.status === 'unreadable') { + const invalid: AntigravityCacheState = { + cache: { version: CACHE_VERSION, cascades: {} }, + dirty: false, + } + cacheStates.set(cacheDir, invalid) + return invalid + } + // Versioned file is absent (ENOENT). Adopt the unsuffixed file only when its // version matches — old binaries still own that path; we never write or delete it. try { const raw = await readFile(getLegacyCachePath(cacheDir), 'utf-8') diff --git a/tests/result-cache-suffix.test.ts b/tests/result-cache-suffix.test.ts index 36bfe2cb..4cf7ea97 100644 --- a/tests/result-cache-suffix.test.ts +++ b/tests/result-cache-suffix.test.ts @@ -250,4 +250,68 @@ describe('unsuffixed result caches coexist with mixed-version binaries', () => { })) expect(await readAntigravityModel(sourcePath)).toBeUndefined() }) + + it('never serves matching legacy when a versioned file is present but invalid', async () => { + const sourcePath = join(root, 'rollout.jsonl') + const dbPath = join(root, 'state.vscdb') + const pbPath = join(root, 'shared.pb') + await writeFile(sourcePath, '{}\n') + await writeFile(dbPath, 'cursor-db') + await writeFile(pbPath, 'fixture') + const fingerprint = await fingerprintFile(sourcePath) + const { stat } = await import('fs/promises') + const dbStat = await stat(dbPath) + const pbStat = await stat(pbPath) + const floor = '2026-01-01T00:00:00.000Z' + expect(fingerprint).not.toBeNull() + + await writeFile(join(root, CODEX_LEGACY_CACHE_FILE), JSON.stringify({ + version: CODEX_CACHE_VERSION, + files: { + [sourcePath]: { + dev: fingerprint!.dev, + ino: fingerprint!.ino, + mtimeMs: fingerprint!.mtimeMs, + sizeBytes: fingerprint!.sizeBytes, + project: 'legacy', + calls: [call('codex', 'legacy-served')], + }, + }, + })) + await writeFile(join(root, CURSOR_LEGACY_CACHE_FILE), JSON.stringify({ + version: CURSOR_CACHE_VERSION, + dbMtimeMs: dbStat.mtimeMs, + dbSizeBytes: dbStat.size, + lookbackFloor: floor, + calls: [call('cursor', 'legacy-served')], + })) + await writeFile(join(root, ANTIGRAVITY_LEGACY_CACHE_FILE), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION, + cascades: { + shared: { mtimeMs: pbStat.mtimeMs, sizeBytes: pbStat.size, calls: [call('antigravity', 'legacy-served')] }, + }, + })) + + await writeFile(join(root, codexCacheFileName()), '{not-json') + await writeFile(join(root, cursorCacheFileName()), '{not-json') + await writeFile(join(root, antigravityCacheFileName()), '{not-json') + expect(await readCachedCodexResults(sourcePath)).toBeNull() + expect(await readCachedResults(dbPath, floor)).toBeNull() + expect(await readAntigravityModel(pbPath)).toBeUndefined() + + clearCodexMemCaches() + clearAntigravityCacheStates() + await writeFile(join(root, codexCacheFileName()), JSON.stringify({ version: CODEX_CACHE_VERSION - 1, files: {} })) + await writeFile(join(root, cursorCacheFileName()), JSON.stringify({ + version: CURSOR_CACHE_VERSION - 1, + dbMtimeMs: dbStat.mtimeMs, + dbSizeBytes: dbStat.size, + lookbackFloor: floor, + calls: [call('cursor', 'wrong-version')], + })) + await writeFile(join(root, antigravityCacheFileName()), JSON.stringify({ version: ANTIGRAVITY_CACHE_VERSION - 1, cascades: {} })) + expect(await readCachedCodexResults(sourcePath)).toBeNull() + expect(await readCachedResults(dbPath, floor)).toBeNull() + expect(await readAntigravityModel(pbPath)).toBeUndefined() + }) })