codeburn/packages/cli/tests/parser-network-readonly-completeness.test.ts
ozymandiashh 6f7a3cd0bc fix: recover a corrupt refresh lock instead of freezing ingestion
A lock file whose body never parses — the zero-byte leftover of a crash between
open and the body write, or a heartbeat truncated by a full disk — was
classified as 'unavailable'. Every later refresh then took the read-only path,
permanently. Nothing on the machine repairs that file, so new sessions stopped
being ingested, the menubar served the snapshot from before the crash forever,
and the only remedy was deleting the lock by hand.

Corruption is now its own class. A body that fails to parse is recovered
through the unmodified staleness gate, exactly like an abandoned lock: waited
out while fresh, because it may belong to a live owner whose heartbeat is about
to repair it, then taken over once its mtime ages past the stale window. That
bounds the freeze to one stale window instead of forever. 'unavailable' is
reserved for locks that genuinely cannot be read.

The takeover stability check now compares the raw bytes as well as token and
mtime: on filesystems with coarse mtime granularity a live owner's heartbeat
can rewrite a body without moving mtime, and token equality alone could not
tell "unchanged" from "rewritten".

The other half of the freeze was in the daily cache. A timed-out refresh serves
the prior snapshot; when anything changed underneath, that snapshot is partial,
and finalizing history off it advanced the watermark past days the parse never
covered. Since the gap scan starts after the watermark, the hole became
invisible to it forever and empty days froze into the trend. The parser now
reports a stale read-only serve as an incomplete hydration, and the daily cache
refuses to advance its watermark or mark itself complete unless the parse
behind it was complete. Caches already corrupted this way heal once: a
watermark that outruns its newest populated day is pulled back so the ordinary
gap parse re-derives the tail, and a trust stamp keeps a legitimately idle tail
from being re-derived on every launch.

That covers every file-backed source. The network-backed one (Vercel AI
Gateway) cannot be fingerprinted at all, so the staleness signal had to be
different: it has no file whose mtime proves the cached rows are current — the
report moves on the API's side, and the read-only path must never re-fetch it.
A read-only serve of a network source is therefore always unverifiable, and is
now always marked stale: it serves the cached rows (the snapshot is what
read-only runs are for) but reports an incomplete hydration, so a timed-out
refresh can no longer finalize daily history off network totals frozen at an
old report. The next full parse re-fetches and advances the watermark.

Two satellite fixes ride along. The context budget counted every skill and the
home CLAUDE.md twice when the scanned project IS the home directory; dedup is
now by resolved path, so a symlinked home is caught too. And the optimize
result-cache key was a projection of project count and api-call sum, so any two
datasets agreeing on those collided and served stale findings within the TTL;
it now folds in cost, savings and proxied cost.

Being accurate about that last one: the key is still a projection of five
aggregates, so datasets agreeing on all five — same totals, different per-model
distribution — can still collide, and the detectors that key off token ratios
and tool counts would differ. The 60-second TTL bounds it. The comment says so
rather than claiming the collision is closed.
2026-08-05 15:59:04 +03:00

114 lines
4.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
vi.mock('../src/cache-refresh-lock.js', () => ({
acquireCacheRefreshLock: async () => ({ outcome: 'timed-out' as const }),
}))
import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js'
import { getDashboardScanRange } from '../src/dashboard.js'
let cacheDir: string
const originalFetch = globalThis.fetch
const originalKey = process.env.AI_GATEWAY_API_KEY
const originalCacheDir = process.env.CODEBURN_CACHE_DIR
function reportRow(day: string, cost: number) {
return {
day,
model: 'openai/gpt-4o',
total_cost: cost,
input_tokens: 1000,
output_tokens: 500,
request_count: 3,
}
}
function totalCost(projects: Awaited<ReturnType<typeof parseAllSessions>>): number {
return projects.reduce((sum, p) => sum + p.totalCostUSD, 0)
}
beforeEach(async () => {
cacheDir = await mkdtemp(join(tmpdir(), 'cb-network-readonly-'))
process.env['CODEBURN_CACHE_DIR'] = cacheDir
process.env['AI_GATEWAY_API_KEY'] = 'test-key'
clearSessionCache()
})
afterEach(async () => {
globalThis.fetch = originalFetch
if (originalKey === undefined) delete process.env.AI_GATEWAY_API_KEY
else process.env.AI_GATEWAY_API_KEY = originalKey
if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR
else process.env.CODEBURN_CACHE_DIR = originalCacheDir
clearSessionCache()
vi.restoreAllMocks()
await rm(cacheDir, { recursive: true, force: true })
})
// The file-backed completeness rule is "a read-only run under which nothing
// changed is equivalent to a full parse". A network-backed source (Vercel AI
// Gateway) has no file to fingerprint, so a read-only run has NO WAY to
// establish "nothing changed": the report lives on the API and moves without
// touching any local mtime, and the read-only path deliberately never
// re-fetches. Unverifiable means partial — a read-only serve of a network
// source must never let the parse report a complete hydration, or a timed-out
// refresh would finalize daily history off network totals frozen at an old
// report (the same freeze this fix bounds for file-backed sources).
describe('network-backed source on the read-only path', () => {
it('serves the cached rows but never tags the parse complete when the report has moved on', async () => {
// Relative so the rolling six-month dashboard window always contains it.
const day = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ results: [reportRow(day, 12.34)] }),
}))
globalThis.fetch = fetchMock as unknown as typeof fetch
// Cold first parse: no lock contention, so the report is fetched, the rows
// are cached, and the hydration is complete.
const range = getDashboardScanRange('week', null, null)
const first = await parseAllSessions(range, 'vercel-gateway')
expect(totalCost(first)).toBeCloseTo(12.34, 2)
expect(isSessionHydrationComplete(first)).toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(1)
// The gateway now reports newer totals. A timed-out refresh serves the
// prior snapshot — and the stale serve must report an incomplete hydration,
// exactly like a changed file on the file-backed path.
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ results: [reportRow(day, 99.99)] }),
})
clearSessionCache()
const stale = await parseAllSessions(range, 'vercel-gateway')
expect(totalCost(stale)).toBeCloseTo(12.34, 2)
// The snapshot is served, never re-fetched, while the lock is unavailable.
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(isSessionHydrationComplete(stale)).toBe(false)
})
it('stays incomplete even when the snapshot happens to match the live report', async () => {
const day = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ results: [reportRow(day, 12.34)] }),
}))
globalThis.fetch = fetchMock as unknown as typeof fetch
const range = getDashboardScanRange('week', null, null)
await parseAllSessions(range, 'vercel-gateway')
// The API has NOT moved on — the report still says 12.34. The read-only
// serve is still unverifiable: there is no file whose fingerprint proves
// the cached rows are current, so it must not contribute to a complete
// tag. (The file-backed path can make that proof; the network path can't.)
clearSessionCache()
const stale = await parseAllSessions(range, 'vercel-gateway')
expect(totalCost(stale)).toBeCloseTo(12.34, 2)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(isSessionHydrationComplete(stale)).toBe(false)
})
})