codeburn/packages/cli/tests/parser-cache-refresh-timeout.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

105 lines
4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, writeFile } 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 { sessionCachePath } from '../src/session-cache.js'
let root: string
let sessionPath: string
function output(projects: Awaited<ReturnType<typeof parseAllSessions>>): number {
return projects.flatMap(p => p.sessions).flatMap(s => s.turns)
.flatMap(t => t.assistantCalls).reduce((sum, call) => sum + call.usage.outputTokens, 0)
}
async function writeSession(value: number): Promise<void> {
await writeFile(sessionPath, JSON.stringify({
type: 'assistant',
sessionId: 'sess',
timestamp: '2026-05-15T10:00:00Z',
cwd: '/tmp/proj',
message: {
id: `msg-${value}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [], usage: { input_tokens: 100, output_tokens: value },
},
}) + '\n')
}
beforeEach(async () => {
clearSessionCache()
root = await mkdtemp(join(tmpdir(), 'cb-refresh-timeout-'))
const home = join(root, 'home')
const project = join(home, 'projects', 'proj')
await mkdir(project, { recursive: true })
sessionPath = join(project, 'sess.jsonl')
process.env['CLAUDE_CONFIG_DIR'] = home
process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(home, 'desktop-sessions')
})
afterEach(async () => {
clearSessionCache()
await rm(root, { recursive: true, force: true })
})
describe('parseAllSessions warm refresh timeout', () => {
it('serves the prior complete snapshot and leaves the holder cache untouched', async () => {
await writeSession(50)
expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50)
const before = await readFile(sessionCachePath(), 'utf-8')
await writeSession(5000)
clearSessionCache()
expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50)
expect(await readFile(sessionCachePath(), 'utf-8')).toBe(before)
})
// The snapshot a timed-out refresh serves is only as good as what has changed
// under it. Anything the daily backfill finalizes off a snapshot that skipped
// real files freezes those days out of history for good, so the completeness
// signal has to distinguish the two cases. It rides on the parse result: the
// backfill reads it off the exact array the parse returned.
it('does not report a complete hydration when the served snapshot is stale', async () => {
await writeSession(50)
const first = await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete(first)).toBe(true)
await writeSession(5000)
clearSessionCache()
const stale = await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete(stale)).toBe(false)
})
it('does not report a complete hydration when a session file is missing from the snapshot', async () => {
await writeSession(50)
await parseAllSessions(undefined, 'claude')
await writeFile(join(sessionPath, '..', 'other.jsonl'), JSON.stringify({
type: 'assistant',
sessionId: 'sess-2',
timestamp: '2026-05-16T10:00:00Z',
cwd: '/tmp/proj',
message: {
id: 'msg-other', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [], usage: { input_tokens: 100, output_tokens: 7 },
},
}) + '\n')
clearSessionCache()
const missing = await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete(missing)).toBe(false)
})
it('still reports a complete hydration when nothing changed under the snapshot', async () => {
await writeSession(50)
await parseAllSessions(undefined, 'claude')
clearSessionCache()
const unchanged = await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete(unchanged)).toBe(true)
})
})