From 6f7a3cd0bc8fb64197c162fd2bb9565a0c65c4bd Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:00:05 +0300 Subject: [PATCH] fix: recover a corrupt refresh lock instead of freezing ingestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/cli/src/cache-refresh-lock.ts | 100 +++++- packages/cli/src/context-budget.ts | 38 ++- packages/cli/src/daily-cache.ts | 78 ++++- packages/cli/src/optimize.ts | 23 +- packages/cli/src/parser.ts | 81 ++++- packages/cli/src/usage-aggregator.ts | 7 +- .../cache-refresh-lock-corrupt-body.test.ts | 121 +++++++ .../tests/cache-refresh-lock-process.test.ts | 35 ++ packages/cli/tests/cache-refresh-lock.test.ts | 298 +++++++++++++++++- .../cli/tests/context-budget-home.test.ts | 67 ++++ .../tests/daily-cache-carry-forward.test.ts | 1 + .../daily-cache-degraded-completeness.test.ts | 187 +++++++++++ packages/cli/tests/daily-cache.test.ts | 2 + .../fixtures/cache-refresh-corrupt-owner.ts | 22 ++ .../fixtures/cache-refresh-slow-owner.ts | 25 ++ packages/cli/tests/optimize.test.ts | 44 +++ .../parser-cache-refresh-timeout.test.ts | 45 ++- .../tests/parser-memo-completeness.test.ts | 98 ++++++ ...rser-network-readonly-completeness.test.ts | 114 +++++++ 19 files changed, 1332 insertions(+), 54 deletions(-) create mode 100644 packages/cli/tests/cache-refresh-lock-corrupt-body.test.ts create mode 100644 packages/cli/tests/context-budget-home.test.ts create mode 100644 packages/cli/tests/daily-cache-degraded-completeness.test.ts create mode 100644 packages/cli/tests/fixtures/cache-refresh-corrupt-owner.ts create mode 100644 packages/cli/tests/fixtures/cache-refresh-slow-owner.ts create mode 100644 packages/cli/tests/parser-memo-completeness.test.ts create mode 100644 packages/cli/tests/parser-network-readonly-completeness.test.ts diff --git a/packages/cli/src/cache-refresh-lock.ts b/packages/cli/src/cache-refresh-lock.ts index a0120098..e183be31 100644 --- a/packages/cli/src/cache-refresh-lock.ts +++ b/packages/cli/src/cache-refresh-lock.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'crypto' +import { createHash, randomBytes } from 'crypto' import { existsSync } from 'fs' import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises' import { homedir } from 'os' @@ -81,6 +81,13 @@ async function retryWindowsMutation(operation: () => Promise, sleep: (ms: return false } +// The directory entry becomes visible before the awaited body write, so the +// file is briefly observable at zero bytes. Deliberately left as is: a corrupt +// body is only ever recovered once its mtime is older than staleMs, and this +// window is milliseconds wide on a file whose mtime is by definition now, so +// no observer can reach the age gate through it. Closing it would mean +// link()ing a temp file into place, which is not portable to filesystems +// without hard links. async function createExclusive(path: string, body: string): Promise<'created' | 'exists' | 'unavailable'> { try { const handle = await open(path, 'wx', 0o600) @@ -92,7 +99,25 @@ async function createExclusive(path: string, body: string): Promise<'created' | } } -type Observation = { record: LockRecord; mtimeMs: number } +// A null record is a body whose stat bracket agreed across the read and that +// still does not parse into a lock record: a corrupt leftover of 0 bytes, a +// truncation, or a wrong shape. The bracket is a heuristic, not proof that the +// read was whole — a same-size rewrite moves neither size nor (on a coarse +// filesystem) mtime — which is why nothing here treats a single read as +// authoritative. It owns nothing, but it is a real file with a +// real mtime, not an infrastructure failure — classifying it 'unavailable' +// routed every later refresh to the read-only path and froze ingestion. It +// carries no authority: it is only ever recovered through the unmodified +// staleness gate, exactly like an abandoned but well-formed lock. +// +// `digest` fingerprints the exact bytes. A corrupt body has no token, so +// token equality between two corrupt observations degenerates to +// `undefined === undefined`. And mtime granularity is coarse on some +// filesystems — upstream measured on macOS a 2s grid on FAT32, 10ms on +// exFAT, and sub-ms on APFS, and on all three a same-size rewrite moves +// neither mtime nor size — so mtime is not a reliable change signal on its +// own: sameObservation must compare the digest too. +type Observation = { record: LockRecord | null; mtimeMs: number; digest: string } type ObservationResult = Observation | 'missing' | 'changing' | 'unavailable' async function observe(path: string): Promise { @@ -100,6 +125,7 @@ async function observe(path: string): Promise { // written, and heartbeat rewrites briefly truncate it. Treat that bounded // transition as contention, not broken infrastructure. let sawChange = false + let corrupt: Observation | null = null for (let attempt = 0; attempt < 3; attempt++) { try { const before = await stat(path) @@ -110,22 +136,49 @@ async function observe(path: string): Promise { await delay(1) continue } - const parsed = JSON.parse(raw) as Partial - if (typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') { - return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs } + const digest = createHash('sha1').update(raw).digest('hex') + // A body that is valid JSON of the wrong shape is corrupt like any other, + // including one written by a future version with a different record + // shape. That is safe precisely because staleness is never waived: a + // foreign version's LIVE lock keeps its mtime fresh through its own + // heartbeat, so it is never taken — both versions just degrade to the + // read-only path. Only an abandoned one is recovered, and a lock record + // is per-run state with nothing in it worth preserving. + let parsed: Partial | undefined + try { parsed = JSON.parse(raw) as Partial } catch { parsed = undefined } + if (parsed && typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') { + return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs, digest } } + // Keep the most recent corrupt read. It is not evidence of stability on + // its own: tryTakeover re-observes under the guard and compares with + // sameObservation before acting, so stability is proven there, not here. + corrupt = { record: null, mtimeMs: after.mtimeMs, digest } } catch (err) { if (isMissingError(err)) return 'missing' const code = (err as NodeJS.ErrnoException | undefined)?.code if (code === 'EACCES' || code === 'EPERM') return 'unavailable' + // A hard I/O error (EIO, EISDIR, ...) invalidates an earlier corrupt + // read: the file may have been replaced or the filesystem degraded since + // those bytes were read, so they are not evidence about the current + // lock. Only a clean final read may report corruption; a lock whose last + // attempt hard-errored genuinely cannot be read and is 'unavailable'. + corrupt = null } await delay(1) } - return sawChange ? 'changing' : 'unavailable' + // Contention outranks corruption: a body seen mid-rewrite is a live owner's, + // and the caller must poll rather than treat it as recoverable. + if (sawChange) return 'changing' + return corrupt ?? 'unavailable' } function sameObservation(a: Observation, b: Observation): boolean { - return a.record.token === b.record.token && a.mtimeMs === b.mtimeMs + // Token equality already separates an owned record from a corrupt body: a + // null record yields `undefined`, which never equals a real token. What it + // cannot do is tell two corrupt bodies apart — both sides are `undefined` — + // so the bytes themselves must match too; "unchanged" then survives a coarse + // mtime (see the digest note above). + return a.record?.token === b.record?.token && a.mtimeMs === b.mtimeMs && a.digest === b.digest } let singleFlightTail: Promise = Promise.resolve() @@ -209,7 +262,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}): if (current === 'missing') return true if (current === 'changing') return false if (current === 'unavailable') return false - if (current.record.token !== token) return true + if (current.record?.token !== token) return true return retryWindowsMutation(() => unlink(lockPath), sleep) } finally { await retryWindowsMutation(() => unlink(takeoverPath), sleep) @@ -221,7 +274,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}): if (guard !== 'created') return false try { const current = await observe(lockPath) - return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record.token === token + return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record?.token === token } finally { await retryWindowsMutation(() => unlink(takeoverPath), sleep) } @@ -238,7 +291,23 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}): if (guard !== 'created') { heartbeatRunning = false; return } try { const current = await observe(lockPath) - if (current === 'missing' || current === 'changing' || current === 'unavailable' || current.record.token !== token) return + if (current === 'missing' || current === 'changing' || current === 'unavailable') return + // A corrupt body is NOT ours to rewrite, even though no parseable + // token contradicts us. Holding the takeover guard excludes the other + // guard-takers, but NOT createExclusive, which publishes a directory + // entry before its body — so an unparseable body may be a successor's + // lock a millisecond from being written, or a foreign version's whose + // record shape we cannot read. Stamping our token over it made this + // process an owner again after it had been legitimately replaced: + // verifyStillOwner then answered true for a displaced writer, and + // release()'s removeIfOwned deleted the live successor's lock. + // + // So a body we cannot prove is ours ends our ownership. The mtime + // stops advancing, the fence refuses to publish (the parse is + // discarded, which is the fail-safe direction), and a successor + // recovers the lock one staleMs later through the age gate. Losing a + // parse is the correct price for never having two owners. + if (current.record === null || current.record.token !== token) return await writeFile(lockPath, body(), { encoding: 'utf-8' }) const now = new Date(clock.wallNow()) await utimes(lockPath, now, now) @@ -325,6 +394,17 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}): continue } + // A corrupt observation takes this path unchanged. Staleness is never + // waived for it: an abandoned corrupt lock is older than staleMs and is + // recovered here, while a corrupt body younger than that is waited out + // and left alone. No owner repairs its own corrupt body — the heartbeat + // refuses to rewrite a body it cannot prove is its own — so the wait is + // not about repair: a fresh mtime is evidence that something live is + // touching the file (a successor whose createExclusive body write has + // not landed yet, or a foreign-version owner heartbeating a record + // shape we cannot parse), and stealing it would be stealing from that. + // Worst case we time out and serve the prior snapshot read-only for one + // staleMs window instead of freezing forever. const age = Math.max(0, clock.wallNow() - observation.mtimeMs) if (age > staleMs) { const takeover = await tryTakeover(observation) diff --git a/packages/cli/src/context-budget.ts b/packages/cli/src/context-budget.ts index 38ab0265..e08f0e30 100644 --- a/packages/cli/src/context-budget.ts +++ b/packages/cli/src/context-budget.ts @@ -1,4 +1,4 @@ -import { readdir } from 'fs/promises' +import { readdir, realpath } from 'fs/promises' import { existsSync } from 'fs' import { join } from 'path' import { homedir } from 'os' @@ -60,8 +60,19 @@ async function countMcpTools(projectPath?: string): Promise { } async function countSkills(projectPath?: string): Promise { - const dirs = [join(homedir(), '.claude', 'skills')] - if (projectPath) dirs.push(join(projectPath, '.claude', 'skills')) + // Dedupe by resolved path: when the project IS the home dir (or a symlink + // into it), the home and project skills dirs are the same directory, and + // counting both double-counts every skill (and inflates the context budget). + // realpath collapses the symlinked spellings; a dir that no longer exists + // falls back to its raw path, which the existsSync below skips anyway. + const rawDirs = [ + join(homedir(), '.claude', 'skills'), + ...(projectPath ? [join(projectPath, '.claude', 'skills')] : []), + ] + const resolved = await Promise.all(rawDirs.map(async dir => { + try { return await realpath(dir) } catch { return dir } + })) + const dirs = [...new Set(resolved)] let count = 0 for (const dir of dirs) { @@ -81,17 +92,28 @@ async function countSkills(projectPath?: string): Promise { async function scanMemoryFiles(projectPath?: string): Promise> { const home = homedir() const files: Array<{ name: string; tokens: number }> = [] - const paths: Array<{ path: string; name: string }> = [ + const rawPaths: Array<{ path: string; name: string }> = [ { path: join(home, '.claude', 'CLAUDE.md'), name: '~/.claude/CLAUDE.md' }, ] if (projectPath) { - paths.push({ path: join(projectPath, 'CLAUDE.md'), name: 'CLAUDE.md' }) - paths.push({ path: join(projectPath, '.claude', 'CLAUDE.md'), name: '.claude/CLAUDE.md' }) - paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' }) + rawPaths.push({ path: join(projectPath, 'CLAUDE.md'), name: 'CLAUDE.md' }) + rawPaths.push({ path: join(projectPath, '.claude', 'CLAUDE.md'), name: '.claude/CLAUDE.md' }) + rawPaths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' }) } - for (const { path, name } of paths) { + // Dedupe by resolved path, like countSkills: when the project IS the home + // dir (or a symlink into it), the project and home spellings are the same + // file, and reading both double-counts its tokens. realpath collapses the + // symlinked spellings; a path that no longer exists falls back to its raw + // form, which the existsSync below skips anyway. + const resolved = await Promise.all(rawPaths.map(async ({ path, name }) => { + try { return { path: await realpath(path), name } } catch { return { path, name } } + })) + const seenPaths = new Set() + for (const { path, name } of resolved) { + if (seenPaths.has(path)) continue + seenPaths.add(path) if (!existsSync(path)) continue const content = await readSessionFile(path) if (content === null) continue diff --git a/packages/cli/src/daily-cache.ts b/packages/cli/src/daily-cache.ts index c5439c34..a5ce8e08 100644 --- a/packages/cli/src/daily-cache.ts +++ b/packages/cli/src/daily-cache.ts @@ -150,6 +150,14 @@ export type DailyCache = { /// as incomplete and is fully re-backfilled. Absent on caches written before /// this field existed → treated as incomplete (one self-healing re-backfill). complete?: boolean + /// True once a COMPLETE parse finalized this watermark. The pull-back below + /// only distrusts caches WITHOUT this stamp: a degraded parse can no longer + /// set `complete`, so a stamped cache whose watermark sits past its newest + /// populated day is a legitimately idle tail (recent days had no activity), + /// not a frozen hole, and re-deriving it every launch is pure waste. Absent + /// on caches written before this field: distrusted once (one healing + /// pull-back), then stamped. + watermarkTrusted?: boolean } function getCacheDir(): string { @@ -294,7 +302,7 @@ function migrateDays(days: Record[]): DailyEntry[] { })) } -function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean }): DailyCache { +function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache { return { version: DAILY_CACHE_VERSION, savingsConfigHash: parsed.savingsConfigHash ?? '', @@ -306,6 +314,9 @@ function migratedFrom(parsed: { version: number; lastComputedDate: string | null // Only a cache explicitly marked complete stays trusted; one written before // the marker existed reads false and is re-backfilled once. complete: parsed.complete === true, + // Absent on a pre-fix cache: the watermark is distrusted once (healing + // pull-back), then re-stamped by the finalize that follows. + watermarkTrusted: parsed.watermarkTrusted === true, } } @@ -409,6 +420,7 @@ async function adoptOlderDailyCaches(): Promise { // accounting: leave complete unset so the next hydration re-derives every // day whose sources survive (the merge keeps the rest). complete: rest.length === candidates.length ? false : base.complete, + watermarkTrusted: rest.length === candidates.length ? false : base.watermarkTrusted, } await saveDailyCache(adopted).catch(() => {}) return adopted @@ -451,6 +463,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate lastComputedDate: nextLast, days: applyRetention(merged, newestDate), complete: cache.complete, + watermarkTrusted: cache.watermarkTrusted, } } @@ -647,9 +660,12 @@ export async function ensureCacheHydrated( /// Whether the session parse that fed this backfill left the session cache /// fully hydrated. A partial (interrupted) session cache yields empty/partial /// older days; finalizing them would freeze that gap into the daily history. - /// So the backfill is only marked `complete` when this returns true. Defaults - /// to a trusting `true` for callers that don't (or can't) supply it. - sessionComplete: () => boolean = () => true, + /// So the backfill is only marked `complete` when this returns true. The + /// parse result is passed in because completeness travels WITH the data: + /// parser.ts tags each result array, and a memo hit can otherwise report + /// another parse's state. Defaults to a trusting `true` for callers that + /// don't (or can't) supply it. + sessionComplete: (projects: ProjectSummary[]) => boolean = () => true, ): Promise { const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) @@ -671,6 +687,27 @@ export async function ensureCacheHydrated( c = { ...c, days: freshDays, lastComputedDate: latestFresh } } + // A cache can claim `complete` while its watermark points PAST its newest + // populated day — what a run finalizing off a degraded (read-only) parse + // leaves behind: it advanced lastComputedDate over days the parse never + // covered. Since gapStart is lastComputedDate + 1, that hole is invisible + // to the gap logic forever. Trust the DATA over the marker: pull the + // watermark back to the newest day actually present so the ordinary gap + // parse re-derives the tail. Nothing is dropped — the cached days all stay. + // + // Only UNSTAMPED caches are distrusted here. A degraded parse can no longer + // set `complete` (that is this fix), so the corrupt state can only be + // written by pre-fix code: an unstamped cache. A stamped one whose watermark + // outruns its newest day is a legitimately idle tail (recent days had no + // activity), and re-deriving that empty tail on every launch is the + // regression this guard avoids. A cache with NO days is exempt: it has no + // newest day to trust, and a machine with no history at all must still be + // able to finalize (below) rather than re-backfill on every launch. + const newestCachedDate = c.days.reduce((max, d) => (max === null || d.date > max ? d.date : max), null) + if (c.watermarkTrusted !== true && newestCachedDate !== null && c.lastComputedDate !== null && c.lastComputedDate > newestCachedDate) { + c = { ...c, lastComputedDate: newestCachedDate } + } + // Three reasons to re-derive the whole retention window: // 1. Savings config changed — cached `savingsUSD` totals are stale. // 2. The cache was never finalized against a COMPLETE session parse (an old @@ -691,12 +728,15 @@ export async function ensureCacheHydrated( const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) { const baseline = c.days + const priorWatermark = c.lastComputedDate const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS) let freshDays: DailyEntry[] = [] + let freshProjects: ProjectSummary[] = [] if (backfillStart.getTime() <= yesterdayEnd.getTime()) { - freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd })) + freshProjects = await parseSessions({ start: backfillStart, end: yesterdayEnd }) + freshDays = aggregateDays(freshProjects) } - const parseWasComplete = sessionComplete() + const parseWasComplete = sessionComplete(freshProjects) // A PARTIAL parse must not overwrite finalized baseline days with // undercounts (if their sources die before the next complete parse, the // undercount would be what survives). Partial fresh data only fills days @@ -708,9 +748,18 @@ export async function ensureCacheHydrated( version: DAILY_CACHE_VERSION, savingsConfigHash, tzKey, - lastComputedDate: yesterdayStr, + // The watermark records how far history has actually been derived, so + // only a COMPLETE parse may advance it. A partial one produced no data + // for whatever it could not read; moving the watermark to yesterday + // anyway would place those days behind the next run's gapStart and + // freeze the hole in (retention still anchors on yesterdayStr — the + // real calendar edge — so holding the watermark can't evict anything). + lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark, days: applyRetention(merged, yesterdayStr), complete: parseWasComplete, + // Stamp the watermark as trusted only when a COMPLETE parse produced it, + // so a later idle tail under this watermark is not distrusted above. + watermarkTrusted: parseWasComplete, } await saveDailyCache(c) return c @@ -733,18 +782,17 @@ export async function ensureCacheHydrated( const gapRange: DateRange = { start: gapStart, end: yesterdayEnd } const gapProjects = await parseSessions(gapRange) const gapDays = aggregateDays(gapProjects) + const parseWasComplete = sessionComplete(gapProjects) + const priorWatermark = c.lastComputedDate c = addNewDays(c, gapDays, yesterdayStr) // Finalize as complete ONLY when the session parse that produced these days // was itself complete. If it was partial, leave `complete: false` so the // next launch (once the session cache is whole) re-backfills instead of - // freezing the partial history. - c = { ...c, complete: sessionComplete() } - await saveDailyCache(c) - } else if (c.complete !== true && sessionComplete()) { - // No gap to fill (already current through yesterday) but not yet marked — - // e.g. a brand-new machine whose only data is today. Finalize so future - // launches don't re-backfill the whole window every time. - c = { ...c, complete: true } + // freezing the partial history — and hold the watermark where it was, for + // the same reason as the re-derive path above: a partial parse cannot + // vouch for the days it never read, and gapStart is the only thing that + // will ever bring them back. + c = { ...c, lastComputedDate: parseWasComplete ? c.lastComputedDate : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete } await saveDailyCache(c) } return c diff --git a/packages/cli/src/optimize.ts b/packages/cli/src/optimize.ts index 053ad0da..4192f4e4 100644 --- a/packages/cli/src/optimize.ts +++ b/packages/cli/src/optimize.ts @@ -3063,9 +3063,28 @@ export function computeInputCostRate(projects: ProjectSummary[]): number { type CacheEntry = { data: OptimizeResult; ts: number } const resultCache = new Map() -function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { +export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' - const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0) + // The key is a projection of five aggregates (project count, call count, + // cost, savings, proxied cost), not a fingerprint of the dataset. What it + // buys: it closes the project-count + call-sum collision that served stale + // findings after a re-price, where cost/tokens moved while the call count + // held - reachable in the long-lived menubar process within the 60s TTL. + // What it still does NOT cover: any two datasets agreeing on those five + // numbers collide, even when their per-session distribution differs (e.g. + // ten calls on one model vs. the same ten split across two models at equal + // total cost) - and the token- and tool-derived detectors are per-session, + // so a collided hit can serve findings computed from a different + // distribution. The 60s TTL (RESULT_CACHE_TTL_MS) bounds that damage. + let calls = 0, cost = 0, savings = 0, proxied = 0 + for (const p of projects) { + calls += p.totalApiCalls + cost += p.totalCostUSD + savings += p.totalSavingsUSD + proxied += p.totalProxiedCostUSD + } + // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. + const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` return `${dr}:${fingerprint}` } diff --git a/packages/cli/src/parser.ts b/packages/cli/src/parser.ts index 71d2f599..26aca019 100644 --- a/packages/cli/src/parser.ts +++ b/packages/cli/src/parser.ts @@ -576,6 +576,7 @@ async function scanProjectDirs( const cached = section.files[filePath] const action = reconcileFile(fp, cached) if (cached && (readOnly || action.action === 'unchanged')) { + if (readOnly && action.action !== 'unchanged') readOnlyServedStale = true unchangedFiles.push({ filePath, dirName, source, cached: section.files[filePath]! }) } else if (!readOnly) { if (action.action === 'appended') { @@ -587,6 +588,10 @@ async function scanProjectDirs( continue } changedFiles.push({ filePath, info: { dirName, fp, source } }) + } else { + // Read-only with no cache entry at all: this file is dropped from what + // we serve, so the snapshot under-reports whatever days it covers. + readOnlyServedStale = true } } dirsDone++ @@ -1452,7 +1457,21 @@ async function parseProviderSources( // comes from a live API fetch in createSessionParser. There's nothing to // fingerprint or incrementally cache, so re-fetch every run with a synthetic // fingerprint (mtime=now so the date-range filter below never excludes it). - if (provider.network && !readOnly) { + // The read-only path must never re-fetch (it serves the published snapshot + // while another process owns the lock), and with no file to fingerprint the + // cached rows' freshness is UNVERIFIABLE — the report changes on the API's + // side, not on any local mtime. A read-only serve is therefore always a + // stale serve, exactly like a changed file on the file-backed path: serve + // the rows, but mark the serve stale so the parse reports an incomplete + // hydration and the daily cache holds its watermark instead of finalizing + // history off totals frozen at an old report. + if (provider.network) { + if (readOnly) { + readOnlyServedStale = true + const cached = section.files[source.path] + if (cached) unchangedSources.push({ source, cached }) + continue + } changedSources.push({ source, fp: { dev: 0, ino: 0, mtimeMs: Date.now(), sizeBytes: 0 } }) continue } @@ -1466,9 +1485,13 @@ async function parseProviderSources( // re-read a file that already threw and hasn't changed. It re-parses only // when the file changes (then `reconcileFile` reports non-'unchanged'). if (cached && (readOnly || (action.action === 'unchanged' && (cached.failed || !cachedFileNeedsProviderReparse(providerName, source.path, cached))))) { + if (readOnly && action.action !== 'unchanged') readOnlyServedStale = true unchangedSources.push({ source, cached }) } else if (!readOnly) { changedSources.push({ source, fp }) + } else { + // Read-only with no cache entry at all — see scanProjectDirs. + readOnlyServedStale = true } } @@ -1742,7 +1765,7 @@ async function parseProviderSources( const CACHE_TTL_MS = 180_000 const MAX_CACHE_ENTRIES = 10 -const sessionCache = new Map() +const sessionCache = new Map() function cacheKey(dateRange?: DateRange, providerFilter?: string): string { const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none' @@ -1759,7 +1782,7 @@ export function clearSessionCache(): void { sessionCache.clear() } -function cachePut(key: string, data: ProjectSummary[]) { +function cachePut(key: string, data: HydrationCompleteProjects) { const now = Date.now() for (const [k, v] of sessionCache) { if (now - v.ts > CACHE_TTL_MS) sessionCache.delete(k) @@ -2159,16 +2182,39 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) } -// Reflects whether the most recently completed parse left the session cache -// fully hydrated. The daily backfill reads this so it never finalizes history -// built on a partial (interrupted) session cache. Set only at the end of a -// runParse that reaches completion; a killed run leaves it false. -let sessionHydrationComplete = false -export function isSessionHydrationComplete(): boolean { - return sessionHydrationComplete +// Completeness travels WITH the data, not in a module global: parseAllSessions +// memoizes results per (range, provider) for 180 seconds, and a global set by +// whichever parse ran LAST would describe a different parse than the one a +// memo hit returns — a read-only stale gap parse can memoize partial data and +// an unrelated later full parse can flip the global to complete, letting the +// daily backfill finalize history off the partial snapshot. Each result array +// is tagged at the end of the runParse that produced it (a run killed before +// that point never tags anything), the memo keeps the same array object, and +// the daily backfill reads the tag off the exact array the parse returned. An +// untagged array — one that did not come from a parse — reads as incomplete, +// which is the fail-safe direction. +export type HydrationCompleteProjects = ProjectSummary[] & { readonly sessionHydrationComplete: boolean } +const HYDRATION_COMPLETE_TAG = 'sessionHydrationComplete' + +export function isSessionHydrationComplete(projects: ProjectSummary[]): boolean { + return (projects as Partial)[HYDRATION_COMPLETE_TAG] === true +} + +function tagHydrationComplete(projects: ProjectSummary[], complete: boolean): HydrationCompleteProjects { + Object.defineProperty(projects, HYDRATION_COMPLETE_TAG, { value: complete, enumerable: false, configurable: true }) + return projects as HydrationCompleteProjects } -export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { +// Set by the read-only serving paths when the snapshot they served did NOT +// match what is on disk: in read-only mode a changed file is served at its +// stale fingerprint and a file with no cache entry is skipped entirely. A +// read-only run under which nothing changed is equivalent to a full parse and +// stays trustworthy; one that skipped real data is a PARTIAL hydration, and +// finalizing daily history off it freezes the days it never saw out of the +// chart (gapStart = lastComputedDate + 1 never looks back at them). +let readOnlyServedStale = false + +export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { const key = cacheKey(dateRange, providerFilter) const cached = sessionCache.get(key) if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data @@ -2235,8 +2281,9 @@ async function runParse( dateRange?: DateRange, providerFilter?: string, options: RunParseOptions = {}, -): Promise { +): Promise { const { isCold = false, readOnly = false, refreshLock } = options + readOnlyServedStale = false const seenMsgIds = new Set() const seenKeys = new Set() const allSources = await discoverAllSessions(providerFilter) @@ -2341,7 +2388,6 @@ async function runParse( if (refreshLock) throw new RefreshPublicationUnavailableError() } } - sessionHydrationComplete = true // Merge across providers by normalised project path so the same repository // is not double-counted when it was worked on with more than one tool @@ -2381,6 +2427,11 @@ async function runParse( const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD) correlateCrossProviderPrSessions(result) - cachePut(key, result) - return result + // Tag the completeness of THIS parse onto the data before it enters the memo + // (see isSessionHydrationComplete): a read-only run that served a stale + // snapshot or skipped files reports incomplete; one under which nothing + // changed is equivalent to a full parse and stays trustworthy. + const tagged = tagHydrationComplete(result, !readOnly || !readOnlyServedStale) + cachePut(key, tagged) + return tagged } diff --git a/packages/cli/src/usage-aggregator.ts b/packages/cli/src/usage-aggregator.ts index baf4c319..13ec3f5f 100644 --- a/packages/cli/src/usage-aggregator.ts +++ b/packages/cli/src/usage-aggregator.ts @@ -93,8 +93,11 @@ async function hydrateCache(): Promise { aggregateProjectsIntoDays, getDailyCacheConfigHash(), // Never finalize the daily history off a partial (interrupted) session - // hydration — that is what froze empty older days into the chart. - isSessionHydrationComplete, + // hydration — that is what froze empty older days into the chart. The + // completeness signal rides on the array parseAllSessions returns (a + // memo hit can otherwise report another parse's state), so the callback + // reads it off the exact parse result, not any process-global state. + (projects) => isSessionHydrationComplete(projects), ) } catch (err) { // Previously swallowed silently, which turned any backfill failure into an diff --git a/packages/cli/tests/cache-refresh-lock-corrupt-body.test.ts b/packages/cli/tests/cache-refresh-lock-corrupt-body.test.ts new file mode 100644 index 00000000..c4a77021 --- /dev/null +++ b/packages/cli/tests/cache-refresh-lock-corrupt-body.test.ts @@ -0,0 +1,121 @@ +import { spawn, type ChildProcess } from 'child_process' +import { existsSync } from 'fs' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' + +import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js' + +// Recovering a corrupt session-refresh.lock must never cost the two design +// commitments the lock exists for: it may not fail open into mutation, and it +// may not be stolen from a live heartbeating owner. An earlier fix waived the +// staleness gate for a corrupt body once the contender's wait expired, which +// handed two processes the lock at the same time in both directions below. +// Corruption is recovered only through the unmodified staleness gate. + +const roots: string[] = [] +afterEach(async () => { + while (roots.length) await rm(roots.pop()!, { recursive: true, force: true }) +}) + +async function tempCase(prefix: string): Promise<{ cacheDir: string; barriers: string; lockPath: string }> { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + const cacheDir = join(root, 'cache') + const barriers = join(root, 'barriers') + await mkdir(cacheDir, { recursive: true }) + await mkdir(barriers, { recursive: true }) + return { cacheDir, barriers, lockPath: join(cacheDir, 'session-refresh.lock') } +} + +function spawnFixture(fixture: string, args: string[], env: NodeJS.ProcessEnv = {}): ChildProcess { + return spawn(process.execPath, ['--import', 'tsx', join(process.cwd(), 'tests/fixtures', fixture), ...args], { + cwd: process.cwd(), + stdio: ['ignore', 'ignore', 'inherit'], + env: { ...process.env, ...env }, + }) +} + +async function waitForFile(path: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(path)) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`) + await new Promise(resolve => { setTimeout(resolve, 5) }) + } +} + +const exited = (child: ChildProcess): Promise => new Promise(resolve => child.once('exit', resolve)) + +describe('warm refresh lock: a corrupt body never displaces a live owner', () => { + it('leaves the zero-byte lock alone while its creator is still inside createExclusive', async () => { + const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-race-') + // UV_THREADPOOL_SIZE=1 plus the fixture's pbkdf2 churn stretches the gap + // between open(path,'wx') and the awaited body write, so the lock is + // genuinely observable at zero bytes with no external corruption at all. + const owner = spawnFixture('cache-refresh-slow-owner.ts', [cacheDir, barriers], { UV_THREADPOOL_SIZE: '1' }) + try { + const deadline = Date.now() + 20_000 + let sawZeroByteLock = false + while (Date.now() < deadline) { + const info = await stat(lockPath).catch(() => null) + if (info) { sawZeroByteLock = info.size === 0; break } + await new Promise(resolve => { setTimeout(resolve, 1) }) + } + expect(sawZeroByteLock, 'never caught the owner mid-createExclusive').toBe(true) + + const contender = await acquireCacheRefreshLock({ cacheDir, waitMs: 40, pollMs: 5, staleMs: 90_000 }) + if (contender.outcome === 'acquired') await contender.handle.release() + expect(contender.outcome).toBe('timed-out') + } finally { + await exited(owner) + } + // The owner kept the lock end to end, so its publication fence held. + expect(existsSync(join(barriers, 'owner.acquired'))).toBe(true) + expect(existsSync(join(barriers, 'owner.verify.true')), 'owner lost its own lock').toBe(true) + }, 60_000) + + it('leaves a live owner alone after its body is truncated, however long the wait', async () => { + const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-live-') + const owner = spawnFixture('cache-refresh-corrupt-owner.ts', [cacheDir, barriers, '4000', '100']) + try { + await waitForFile(join(barriers, 'owner.acquired')) + const ownerToken = await readFile(join(barriers, 'owner.acquired'), 'utf-8') + + // The state a heartbeat leaves when writeFile() truncates and then fails + // (ENOSPC/EIO, swallowed by the heartbeat's own catch). No guard is held, + // and the body carries no token to compare against. + await writeFile(lockPath, '') + // This wait is shorter than one heartbeat period, so the body is still + // truncated throughout: the contender has nothing but the fresh mtime to + // go on, and that alone must keep it out. + const early = await acquireCacheRefreshLock({ cacheDir, waitMs: 80, pollMs: 5, staleMs: 90_000 }) + if (early.outcome === 'acquired') await early.handle.release() + expect(early.outcome).toBe('timed-out') + + // Past the age gate, the successor SHOULD win. Only the heartbeat + // advances mtime and it refuses to rewrite a body it cannot prove is + // ours, so a corrupt body freezes its own mtime and ages out. That is the + // intended end state, not a displacement to be prevented: an owner that + // cannot prove ownership must not publish, and the alternative — letting + // the heartbeat restamp its token over an unparseable body — resurrected + // legitimately-replaced writers and let release() delete a live + // successor's lock. + await writeFile(lockPath, '') + const late = await acquireCacheRefreshLock({ cacheDir, waitMs: 2_400, pollMs: 10, staleMs: 400 }) + expect(late.outcome).toBe('acquired') + if (late.outcome === 'acquired') { + // The successor owns it outright: the body carries its token, not the + // original owner's. + expect(JSON.parse(await readFile(lockPath, 'utf-8')).token).not.toBe(ownerToken) + await late.handle.release() + } + } finally { + await exited(owner) + } + // The displaced owner's fence must refuse. Discarding its parse is the + // fail-safe direction; two writers believing they own the lock is not. + expect(existsSync(join(barriers, 'owner.verify.false')), 'displaced owner still passed its fence').toBe(true) + expect(existsSync(join(barriers, 'owner.verify.true'))).toBe(false) + }, 30_000) +}) diff --git a/packages/cli/tests/cache-refresh-lock-process.test.ts b/packages/cli/tests/cache-refresh-lock-process.test.ts index cf907c31..d837f81e 100644 --- a/packages/cli/tests/cache-refresh-lock-process.test.ts +++ b/packages/cli/tests/cache-refresh-lock-process.test.ts @@ -85,6 +85,41 @@ describe('warm refresh child-process regression', () => { await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('gives exactly one contender ownership of a stale zero-byte lock', async () => { + const root = await mkdtemp(join(tmpdir(), 'cb-refresh-corrupt-')) + roots.push(root) + const cacheDir = join(root, 'cache') + const barriers = join(root, 'barriers') + await mkdir(cacheDir, { recursive: true }) + await mkdir(barriers, { recursive: true }) + process.env['CODEBURN_CACHE_DIR'] = cacheDir + const initial = emptyCache() + initial.complete = true + await saveCache(initial) + const corruptPath = join(cacheDir, 'session-refresh.lock') + await writeFile(corruptPath, '') + await utimes(corruptPath, new Date(1), new Date(1)) + const source = join(root, 'changed.json') + await writeFile(source, JSON.stringify({ output: 404 })) + + const a = worker(cacheDir, barriers, 'a', source) + const b = worker(cacheDir, barriers, 'b', source) + const winner = await Promise.race([ + waitFor(join(barriers, 'a.parsed')).then(() => 'a'), + waitFor(join(barriers, 'b.parsed')).then(() => 'b'), + ]) + const loser = winner === 'a' ? 'b' : 'a' + expect(Number(existsSync(join(barriers, 'a.parsed'))) + Number(existsSync(join(barriers, 'b.parsed')))).toBe(1) + const loserOutcome = await waitForAny(barriers, [ + `${loser}.timed-out`, `${loser}.parsed`, `${loser}.completed-by-other`, `${loser}.unavailable`, + ]) + expect(loserOutcome, (await readdir(barriers)).join(',')).toBe(`${loser}.timed-out`) + await writeFile(join(barriers, `${winner}.save`), '') + await Promise.all([waitForExit(a), waitForExit(b)]) + await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(Object.keys((await loadCache()).providers['regression']?.files ?? {})).toEqual([source]) + }) + it('serializes disjoint parsed updates so the later publication cannot drop the first', async () => { const root = await mkdtemp(join(tmpdir(), 'cb-refresh-process-')) roots.push(root) diff --git a/packages/cli/tests/cache-refresh-lock.test.ts b/packages/cli/tests/cache-refresh-lock.test.ts index bb8e44b5..1f88a3ca 100644 --- a/packages/cli/tests/cache-refresh-lock.test.ts +++ b/packages/cli/tests/cache-refresh-lock.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdir, mkdtemp, readFile, rm, stat, unlink, utimes, writeFile } from 'fs/promises' +import { writeFileSync, utimesSync } from 'fs' +import { chmod, mkdir, mkdtemp, readFile, rm, stat, unlink, utimes, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' @@ -8,6 +9,7 @@ import { type RefreshLockClock, } from '../src/cache-refresh-lock.js' import { emptyCache, loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' +import { clearSessionCache, parseAllSessions } from '../src/parser.js' const dirs: string[] = [] @@ -223,3 +225,297 @@ describe('warm session-cache refresh lock', () => { await rm(dir, { recursive: true, force: true }) }) }) + +// A lock body that never parses into a record is a corrupt leftover, not an +// unusable filesystem: classifying it as 'unavailable' routed every subsequent +// refresh to the read-only path and froze ingestion permanently. +// Real-fs recovery tests: under a saturated full-suite run an fs op can starve +// and the acquire fails closed (correct, but not what these measure), so they +// retry to ride out the environmental blip. A real regression fails every +// attempt because the takeover assertion is deterministic given the fixture. +describe('warm session-cache refresh lock: corrupt lock recovery', { retry: 6 }, () => { + it('takes over a stale zero-byte lock', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + const path = lockPath(dir) + await writeFile(path, '') + const old = new Date(1) + await utimes(path, old, old) + + const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token) + await result.handle.release() + await expect(stat(join(dir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('takes over a stale malformed-JSON lock', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + const path = lockPath(dir) + await writeFile(path, '{"pid":1,"token":') + const old = new Date(1) + await utimes(path, old, old) + + const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token) + await result.handle.release() + }) + + it('takes over a stale lock whose body parses but has the wrong shape', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + const path = lockPath(dir) + await writeFile(path, JSON.stringify({ pid: 'one', token: 7 })) + const old = new Date(1) + await utimes(path, old, old) + + const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 }) + expect(result.outcome).toBe('acquired') + if (result.outcome === 'acquired') await result.handle.release() + }) + + // Staleness is never waived for a corrupt body, so a FRESH one is waited out + // and left alone. No owner repairs its own corrupt body (the heartbeat + // refuses to rewrite what it cannot prove is its own), so the wait is not + // about repair: a fresh mtime is evidence something live is touching the + // file — a successor's body write still landing, or a foreign-version owner + // heartbeating a shape we cannot parse. The resulting freeze is bounded by + // staleMs rather than permanent, which is the whole of the reported defect. + it('waits out a fresh malformed lock, then recovers it once it ages past staleMs', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + const path = lockPath(dir) + await writeFile(path, '') + const now = new Date(clock.wallNow()) + await utimes(path, now, now) + + let polls = 0 + const fresh = await acquireCacheRefreshLock({ + cacheDir: dir, + clock, + staleMs: 90_000, + waitMs: 50, + pollMs: 10, + sleep: async ms => { polls++; clock.advance(ms) }, + }) + expect(polls).toBeGreaterThan(0) + expect(fresh).toEqual({ outcome: 'timed-out' }) + expect(await readFile(path, 'utf-8')).toBe('') + + // Nothing rewrote the body, so its mtime is still frozen. One stale window + // later the very next run recovers it through the unmodified age gate. + clock.advance(90_001) + const later = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90_000, waitMs: 50, pollMs: 10 }) + expect(later.outcome).toBe('acquired') + if (later.outcome !== 'acquired') return + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(later.handle.token) + await later.handle.release() + }) + + it('never steals a fresh malformed lock whose valid body lands mid-wait', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + const path = lockPath(dir) + await writeFile(path, '') + const now = new Date(clock.wallNow()) + await utimes(path, now, now) + + let polls = 0 + const result = await acquireCacheRefreshLock({ + cacheDir: dir, + clock, + staleMs: 90_000, + waitMs: 50, + pollMs: 10, + sleep: async ms => { + if (++polls === 1) { + await writeFile(path, JSON.stringify({ pid: 1, token: 'holder', at: clock.wallNow() })) + const t = new Date(clock.wallNow()) + await utimes(path, t, t) + } + clock.advance(ms) + }, + }) + expect(result).toEqual({ outcome: 'timed-out' }) + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder') + }) + + // The digest half of the takeover stability check: on a coarse-mtime + // filesystem a live owner's heartbeat can rewrite the body without moving + // mtime, so between the wait loop's observe and tryTakeover's re-observe the + // file can change to bytes that share the token AND the mtime — only the + // digest still says "rewritten". Pins sameObservation's digest comparison: + // deleting it leaves every other lock test green because they never rewrite + // inside that window. + it('aborts the takeover when the body is rewritten with the same mtime and token between observe and re-observe', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + const path = lockPath(dir) + // Two VALID records, same token, same length — a coarse-filesystem + // heartbeat rewrite in miniature: size and (via utimes) mtime both hold, + // only the bytes differ. token+mtime equality says "unchanged"; only the + // digest can tell them apart. + const first = JSON.stringify({ pid: 1, token: 'holder', at: 1 }) + const second = JSON.stringify({ pid: 1, token: 'holder', at: 2 }) + expect(second.length).toBe(first.length) + await writeFile(path, first) + const old = new Date(1) + await utimes(path, old, old) + + // Inject the rewrite at the first wallNow call AFTER the wait loop's + // observe of the stale lock — that is the age check, which is wallNow + // call #2 (call #1 is the discarded tryCreateOwner body). The rewrite is + // synchronous, so the re-observe inside tryTakeover deterministically + // sees the new bytes. + let wallCalls = 0 + let rewrote = false + const triggerClock: RefreshLockClock = { + wallNow: () => { + wallCalls++ + if (wallCalls === 2 && !rewrote) { + rewrote = true + writeFileSync(path, second) + utimesSync(path, old, old) + } + return clock.wallNow() + }, + monotonicNow: () => clock.monotonicNow(), + } + + let polls = 0 + const result = await acquireCacheRefreshLock({ + cacheDir: dir, + clock: triggerClock, + staleMs: 90, + waitMs: 100, + pollMs: 10, + sleep: async ms => { polls++; clock.advance(ms) }, + }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + try { + // The first attempt must have aborted on the digest mismatch (same + // token, same mtime, different bytes): one poll separates it from the + // stable re-takeover. Without the digest comparison the takeover would + // succeed on the first attempt and polls would be 0. + expect(polls).toBe(1) + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token) + } finally { + await result.handle.release() + } + }) + + it('treats a body truncated mid-heartbeat as contention, not corruption', async () => { + const dir = await tempDir() + const path = lockPath(dir) + const record = (): string => JSON.stringify({ pid: 1, token: 'holder', at: Date.now() }) + await writeFile(path, record()) + // A real heartbeat rewrite exposes a zero-length body for an instant. The + // waiter must keep polling and leave the live owner alone. + const heartbeat = setInterval(() => { + void (async () => { + try { + await writeFile(path, '') + await writeFile(path, record()) + } catch { /* the winner may have replaced the file */ } + })() + }, 1) + let result + try { + result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 30, waitMs: 120, pollMs: 5 }) + } finally { + clearInterval(heartbeat) + } + await new Promise(resolve => { setTimeout(resolve, 20) }) + expect(result).toEqual({ outcome: 'timed-out' }) + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder') + }) + + // The sidecar is created by the same createExclusive as the lock, so it can be + // left 0-byte by exactly the same crash. Before observe() separated corrupt + // from unreadable this returned 'unavailable' from acquireTakeoverGuard and + // froze recovery even when the primary lock was perfectly fine. + it('reclaims a stale zero-byte takeover sidecar', async () => { + const dir = await tempDir() + const path = lockPath(dir) + const sidecar = join(dir, 'session-refresh.lock.takeover') + await writeFile(path, '') + await writeFile(sidecar, '') + const old = new Date(1) + await utimes(path, old, old) + await utimes(sidecar, old, old) + + const result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 90, waitMs: 200, pollMs: 5 }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token) + await result.handle.release() + await expect(stat(sidecar)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('still reports unavailable when the lock body cannot be read', async () => { + if (process.getuid?.() === 0) return + const dir = await tempDir() + const path = lockPath(dir) + await writeFile(path, '') + await chmod(path, 0o000) + const old = new Date(1) + await utimes(path, old, old) + try { + const result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 1, waitMs: 50, pollMs: 5 }) + expect(result).toEqual({ outcome: 'unavailable' }) + } finally { + await chmod(path, 0o600) + } + }) + + it('resumes ingesting changed sources after a corrupt lock froze the refresh', async () => { + const root = await tempDir() + const cacheDir = join(root, 'cache') + const config = join(root, 'claude') + const projectDir = join(config, 'projects', 'frozen-proj') + await mkdir(cacheDir, { recursive: true }) + await mkdir(projectDir, { recursive: true }) + process.env['CODEBURN_CACHE_DIR'] = cacheDir + process.env['CLAUDE_CONFIG_DIR'] = config + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(root, 'desktop-sessions') + + const session = (id: string, ts: string): string => [ + JSON.stringify({ type: 'user', sessionId: id, timestamp: ts, cwd: '/tmp/frozen-proj', message: { role: 'user', content: 'hi' } }), + JSON.stringify({ + type: 'assistant', sessionId: id, timestamp: ts, cwd: '/tmp/frozen-proj', + message: { id: `msg-${id}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 100, output_tokens: 20 } }, + }), + ].join('\n') + '\n' + + await writeFile(join(projectDir, 'sess-1.jsonl'), session('sess-1', '2026-05-01T10:00:00.000Z')) + clearSessionCache() + const warm = await parseAllSessions() + expect(warm[0]?.sessions.map(s => s.sessionId)).toEqual(['sess-1']) + + // The field state: a 0-byte lock AND the takeover sidecar of the dead owner + // that was mid-recovery when it died, both older than the stale window. + // Nothing else on the machine ever repairs either file, so on every later + // run the lock read as 'unavailable', the parser fell back to a read-only + // re-parse, and sess-2 was never ingested. + const old = new Date(1) + for (const name of ['session-refresh.lock', 'session-refresh.lock.takeover']) { + await writeFile(join(cacheDir, name), name.endsWith('.takeover') ? JSON.stringify({ pid: 999_999, token: 'dead-owner', at: 1 }) : '') + await utimes(join(cacheDir, name), old, old) + } + + await writeFile(join(projectDir, 'sess-2.jsonl'), session('sess-2', '2026-05-01T11:00:00.000Z')) + clearSessionCache() + const after = await parseAllSessions() + expect(after[0]?.sessions.map(s => s.sessionId).sort()).toEqual(['sess-1', 'sess-2']) + expect(Object.keys((await loadCache()).providers['claude']?.files ?? {}).length).toBe(2) + + clearSessionCache() + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] + }) +}) diff --git a/packages/cli/tests/context-budget-home.test.ts b/packages/cli/tests/context-budget-home.test.ts new file mode 100644 index 00000000..e8a1bb9c --- /dev/null +++ b/packages/cli/tests/context-budget-home.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'fs' +import { join } from 'path' + +// Mock homedir to a temp dir so "project == home" is reproducible. +import { vi } from 'vitest' +vi.mock('os', async () => { + const actual = await vi.importActual('os') + const fs = await vi.importActual('fs') + const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/cb-ctxbudget-home-') + process.env['CB_CTXBUDGET_FAKE_HOME'] = fakeHome + return { ...actual, homedir: () => fakeHome } +}) + +const HOME = process.env['CB_CTXBUDGET_FAKE_HOME']! + +import { estimateContextBudget } from '../src/context-budget.js' + +describe('context budget: no double-count when the project IS the home dir', () => { + beforeEach(() => { + rmSync(join(HOME, '.claude'), { recursive: true, force: true }) + mkdirSync(join(HOME, '.claude', 'skills', 'my-skill'), { recursive: true }) + writeFileSync(join(HOME, '.claude', 'skills', 'my-skill', 'SKILL.md'), '# Skill') + writeFileSync(join(HOME, '.claude', 'CLAUDE.md'), 'home memory') + }) + + it('counts the one home skill once, not twice, when projectPath is home', async () => { + // With projectPath === home, the home and project skills dirs resolve to + // the same directory; the unfixed code pushed both and counted every skill + // twice (and read ~/.claude/CLAUDE.md twice). + const budget = await estimateContextBudget(HOME) + expect(budget.skills.count).toBe(1) + // ~/.claude/CLAUDE.md must appear once in the memory file list. + const homeMemory = budget.memory.files.filter(f => f.name.includes('.claude/CLAUDE.md')) + expect(homeMemory).toHaveLength(1) + }) + + it('still counts a distinct project skill separately from a home skill', async () => { + const proj = mkdtempSync(join(HOME, '..', 'cb-ctxbudget-proj-')) + mkdirSync(join(proj, '.claude', 'skills', 'proj-skill'), { recursive: true }) + writeFileSync(join(proj, '.claude', 'skills', 'proj-skill', 'SKILL.md'), '# Proj') + const budget = await estimateContextBudget(proj) + expect(budget.skills.count).toBe(2) // home skill + project skill + rmSync(proj, { recursive: true, force: true }) + }) + + it('counts the home skill once when projectPath is a symlink into home', async () => { + // Raw string equality misses this: a session cwd spelled through a link + // (e.g. /tmp -> /private/tmp, or ~/code -> /Volumes/.../code) points at the + // same physical skills dir as the home one. Resolving must collapse it or + // every skill is counted twice. + const alias = join(HOME, '..', 'cb-ctxbudget-home-alias') + rmSync(alias, { recursive: true, force: true }) + symlinkSync(HOME, alias, 'dir') + try { + const budget = await estimateContextBudget(alias) + expect(budget.skills.count).toBe(1) + // Same dedup must hold for memory: the alias spelling of the home + // .claude/CLAUDE.md resolves to the same file and must be read once. + const homeMemory = budget.memory.files.filter(f => f.name.includes('.claude/CLAUDE.md')) + expect(homeMemory).toHaveLength(1) + expect(budget.memory.count).toBe(1) + } finally { + rmSync(alias, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cli/tests/daily-cache-carry-forward.test.ts b/packages/cli/tests/daily-cache-carry-forward.test.ts index 62197fae..a3056b80 100644 --- a/packages/cli/tests/daily-cache-carry-forward.test.ts +++ b/packages/cli/tests/daily-cache-carry-forward.test.ts @@ -609,6 +609,7 @@ describe('adoption union across older cache files', () => { lastComputedDate: daysAgoStr(1), days: [rich], complete: true, + watermarkTrusted: true, } await saveDailyCache(cache) const loaded = await loadDailyCache() diff --git a/packages/cli/tests/daily-cache-degraded-completeness.test.ts b/packages/cli/tests/daily-cache-degraded-completeness.test.ts new file mode 100644 index 00000000..d3df6cce --- /dev/null +++ b/packages/cli/tests/daily-cache-degraded-completeness.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, rm } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import type { DateRange, ProjectSummary } from '../src/types.js' + +import { + DAILY_CACHE_VERSION, + type DailyCache, + type DailyEntry, + type ProviderDaySlice, + currentTzKey, + ensureCacheHydrated, + saveDailyCache, +} from '../src/daily-cache.js' + +const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-degraded-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + +beforeEach(async () => { + process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT + await mkdir(TMP_CACHE_ROOT, { recursive: true }) +}) + +afterEach(async () => { + if (existsSync(TMP_CACHE_ROOT)) { + await rm(TMP_CACHE_ROOT, { recursive: true, force: true }) + } +}) + +function slice(cost: number, calls: number, extra: Partial = {}): ProviderDaySlice { + return { cost, calls, savingsUSD: 0, ...extra } +} + +function day(date: string, providers: Record, overrides: Partial = {}): DailyEntry { + const cost = Object.values(providers).reduce((s, p) => s + p.cost, 0) + const calls = Object.values(providers).reduce((s, p) => s + p.calls, 0) + return { + date, + cost, + savingsUSD: 0, + calls, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers, + ...overrides, + } +} + +function daysAgoStr(n: number): string { + const d = new Date() + d.setDate(d.getDate() - n) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +const noSessions = async (): Promise => [] + +/// The day whose session files are long gone: it exists in the daily cache and +/// nowhere else, so every path below must still hand it back untouched. +const VANISHED = day(daysAgoStr(40), { claude: slice(399.70, 1572) }, { carried: true }) + +async function seed(overrides: Partial = {}): Promise { + await saveDailyCache({ + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(4), + days: [VANISHED, day(daysAgoStr(4), { claude: slice(120, 900) })], + complete: true, + ...overrides, + }) +} + +/** The vanished-sources day is still there, with its original accounting. */ +function expectPreserved(cache: DailyCache): void { + const kept = cache.days.find(d => d.date === VANISHED.date) + expect(kept).toMatchObject({ cost: 399.70, calls: 1572 }) + expect(kept!.providers['claude']!.cost).toBe(399.70) +} + +describe('daily cache: a degraded session parse never finalizes history', () => { + it('does not publish complete, and does not advance the watermark past what it covered', async () => { + await seed() + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false) + // The parse covered nothing it can vouch for, so the watermark stays put: + // advancing it to yesterday would put the missed days behind gapStart + // (lastComputedDate + 1) forever. + expect(out.lastComputedDate).toBe(daysAgoStr(4)) + expect(out.complete).toBe(false) + expectPreserved(out) + }) + + it('does not advance the watermark on the full re-derive path either', async () => { + await seed({ complete: false }) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false) + expect(out.lastComputedDate).toBe(daysAgoStr(4)) + expect(out.complete).toBe(false) + expectPreserved(out) + }) + + it('a later healthy run rebuilds the days the degraded run missed', async () => { + await seed() + await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false) + const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) })) + const healed = await ensureCacheHydrated(noSessions, () => missed, 'cfg-A', () => true) + expect(healed.days.map(d => d.date)).toEqual([ + daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1), + ]) + expect(healed.lastComputedDate).toBe(daysAgoStr(1)) + expect(healed.complete).toBe(true) + expectPreserved(healed) + }) +}) + +describe('daily cache: a complete cache that outruns its own data is not trusted', () => { + it('re-derives the days between the newest entry and the watermark', async () => { + // The field artifact: complete: true, lastComputedDate yesterday, entries + // stopping four days earlier — written by a run that finalized off a parse + // which never covered those days. + await seed({ lastComputedDate: daysAgoStr(1) }) + const ranges: DateRange[] = [] + const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) })) + const out = await ensureCacheHydrated( + async (range) => { ranges.push(range); return [] }, + () => missed, + 'cfg-A', + () => true, + ) + expect(ranges).toHaveLength(1) + expect(out.days.map(d => d.date)).toEqual([ + daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1), + ]) + expect(out.days.find(d => d.date === daysAgoStr(2))!.cost).toBe(20) + expect(out.complete).toBe(true) + expectPreserved(out) + }) + + it('trusts a stamped watermark over an idle tail — no re-derive treadmill', async () => { + // Same shape as the corrupt case above (watermark past the newest populated + // day), but stamped by a COMPLETE parse: the recent days are genuinely + // empty, not a frozen hole. A degraded parse can no longer produce this + // state, so the stamp means the watermark is trustworthy and re-deriving the + // empty tail on every launch (the perf regression) must not happen. + await seed({ lastComputedDate: daysAgoStr(1), watermarkTrusted: true }) + let parses = 0 + const out = await ensureCacheHydrated( + async () => { parses += 1; return [] }, + () => [], + 'cfg-A', + () => true, + ) + expect(parses).toBe(0) + expect(out.lastComputedDate).toBe(daysAgoStr(1)) + expect(out.complete).toBe(true) + expectPreserved(out) + }) + + it('a degraded re-derivation of those days still keeps every carried day', async () => { + await seed({ lastComputedDate: daysAgoStr(1) }) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false) + expect(out.complete).toBe(false) + expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(4)]) + expectPreserved(out) + }) + + it('an empty cache still finalizes — no re-parse treadmill on a machine with no history', async () => { + await seed({ days: [], lastComputedDate: daysAgoStr(1) }) + let parses = 0 + const out = await ensureCacheHydrated( + async () => { parses += 1; return [] }, + () => [], + 'cfg-A', + () => true, + ) + expect(parses).toBe(0) + expect(out.lastComputedDate).toBe(daysAgoStr(1)) + expect(out.complete).toBe(true) + }) +}) diff --git a/packages/cli/tests/daily-cache.test.ts b/packages/cli/tests/daily-cache.test.ts index 6ae07079..d887d256 100644 --- a/packages/cli/tests/daily-cache.test.ts +++ b/packages/cli/tests/daily-cache.test.ts @@ -183,6 +183,7 @@ describe('loadDailyCache', () => { lastComputedDate: '2026-04-10', days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)], complete: true, + watermarkTrusted: true, } await saveDailyCache(saved) const loaded = await loadDailyCache() @@ -318,6 +319,7 @@ describe('ensureCacheHydrated', () => { lastComputedDate: '2026-06-11', days: [emptyDay('2026-06-11', 5, 10)], complete: true, + watermarkTrusted: true, } await saveDailyCache(saved) diff --git a/packages/cli/tests/fixtures/cache-refresh-corrupt-owner.ts b/packages/cli/tests/fixtures/cache-refresh-corrupt-owner.ts new file mode 100644 index 00000000..dfacd261 --- /dev/null +++ b/packages/cli/tests/fixtures/cache-refresh-corrupt-owner.ts @@ -0,0 +1,22 @@ +import { writeFile } from 'fs/promises' +import { join } from 'path' + +import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js' + +// A plain owner in its own process. It records its outcome, its token, and the +// result of the publication fence into the barrier directory so the parent can +// assert what the owner itself believed while a contender was racing it. +const [cacheDir, barrierDir, holdMs, heartbeatMs = '200'] = process.argv.slice(2) +if (!cacheDir || !barrierDir || !holdMs) throw new Error('missing owner argument') + +const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: Number(heartbeatMs) }) +if (refresh.outcome !== 'acquired') { + await writeFile(join(barrierDir, `owner.${refresh.outcome}`), '') + process.exit(0) +} +await writeFile(join(barrierDir, 'owner.acquired'), refresh.handle.token) +await new Promise(resolve => { setTimeout(resolve, Number(holdMs)) }) +// The publication fence, exactly as parser.ts uses it before saving. +await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '') +await refresh.handle.release() +await writeFile(join(barrierDir, 'owner.done'), '') diff --git a/packages/cli/tests/fixtures/cache-refresh-slow-owner.ts b/packages/cli/tests/fixtures/cache-refresh-slow-owner.ts new file mode 100644 index 00000000..70b57555 --- /dev/null +++ b/packages/cli/tests/fixtures/cache-refresh-slow-owner.ts @@ -0,0 +1,25 @@ +import { pbkdf2 } from 'crypto' +import { writeFile } from 'fs/promises' +import { join } from 'path' + +import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js' + +// Saturate the (size-1) libuv threadpool so every fs operation inside +// createExclusive queues behind a pbkdf2 round. No test hook and no patched +// module: this is what an ordinary process looks like mid cold parse, and it +// widens the window in which the lock exists at zero bytes -- between +// open(path,'wx') and the awaited body write -- to something observable. +const [cacheDir, barrierDir] = process.argv.slice(2) +if (!cacheDir || !barrierDir) throw new Error('missing owner argument') + +let stop = false +const churn = (): void => { if (stop) return; pbkdf2('p', 's', 400_000, 32, 'sha512', () => churn()) } +churn() + +const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: 10_000 }) +stop = true +await writeFile(join(barrierDir, `owner.${refresh.outcome}`), refresh.outcome === 'acquired' ? refresh.handle.token : '') +if (refresh.outcome === 'acquired') { + await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '') + await refresh.handle.release() +} diff --git a/packages/cli/tests/optimize.test.ts b/packages/cli/tests/optimize.test.ts index fdbd4926..8cb132d1 100644 --- a/packages/cli/tests/optimize.test.ts +++ b/packages/cli/tests/optimize.test.ts @@ -22,6 +22,7 @@ import { detectLowWorthSessions, detectSessionOutliers, scanAndDetect, + cacheKey, computeHealth, computeTrend, buildOptimizeJsonReport, @@ -1041,6 +1042,49 @@ describe('detectSessionOutliers', () => { }) }) +describe('optimize cacheKey collision resistance', () => { + it('does not collide two datasets that share project count and api-call sum', () => { + // The old fingerprint was projectCount + sum(api calls) only, so any two + // datasets agreeing on those two numbers shared one cached OptimizeResult - + // the second scan got the first's findings. Same shape, different spend must + // now key differently. + const a = projectWithSessions([100, 1, 1, 1]) // 4 calls, cost 103 + const b = projectWithSessions([1, 1, 1, 1]) // 4 calls, cost 4 + const range = optimizeDateRange(4) + expect(a.totalApiCalls).toBe(b.totalApiCalls) + expect(cacheKey([a], range)).not.toBe(cacheKey([b], range)) + }) + + it('is stable for the identical dataset (still caches a genuine repeat)', () => { + const a = projectWithSessions([5, 3, 2]) + const range = optimizeDateRange(3) + expect(cacheKey([a], range)).toBe(cacheKey([projectWithSessions([5, 3, 2])], range)) + }) + + it('separates a re-price that leaves call count unchanged', () => { + // A dataset re-priced (cost moves, calls do not) must not serve stale findings. + const before = projectWithSessions([10, 10]) + const after = projectWithSessions([25, 10]) // same 2 calls, higher cost + const range = optimizeDateRange(2) + expect(cacheKey([before], range)).not.toBe(cacheKey([after], range)) + }) + + it('still collides datasets that agree on the five aggregates but differ per session', () => { + // The key is a projection, not a fingerprint: per-session distribution is + // deliberately outside it, so ten calls on one model and the same ten split + // across two models at equal total cost share one key. The 60s TTL bounds + // what a collided hit can serve. Pin the boundary so a future key change + // that alters it is a visible contract change, not a silent one. + const oneModel = projectWithSessions([10, 10, 10]) + const split = projectWithSessions([5, 5, 20]) + for (const p of [oneModel, split]) { p.totalSavingsUSD = 0; p.totalProxiedCostUSD = 0 } + const range = optimizeDateRange(3) + expect(oneModel.totalApiCalls).toBe(split.totalApiCalls) + expect(oneModel.totalCostUSD).toBe(split.totalCostUSD) + expect(cacheKey([oneModel], range)).toBe(cacheKey([split], range)) + }) +}) + describe('computeHealth', () => { it('returns A with 100 for no findings', () => { const { score, grade } = computeHealth([]) diff --git a/packages/cli/tests/parser-cache-refresh-timeout.test.ts b/packages/cli/tests/parser-cache-refresh-timeout.test.ts index 92415775..7de83904 100644 --- a/packages/cli/tests/parser-cache-refresh-timeout.test.ts +++ b/packages/cli/tests/parser-cache-refresh-timeout.test.ts @@ -7,7 +7,7 @@ vi.mock('../src/cache-refresh-lock.js', () => ({ acquireCacheRefreshLock: async () => ({ outcome: 'timed-out' as const }), })) -import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js' import { sessionCachePath } from '../src/session-cache.js' let root: string @@ -59,4 +59,47 @@ describe('parseAllSessions warm refresh timeout', () => { 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) + }) }) diff --git a/packages/cli/tests/parser-memo-completeness.test.ts b/packages/cli/tests/parser-memo-completeness.test.ts new file mode 100644 index 00000000..678893e5 --- /dev/null +++ b/packages/cli/tests/parser-memo-completeness.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js' + +let root: string +let sessionPath: string + +function output(projects: Awaited>): number { + return projects.flatMap(p => p.sessions).flatMap(s => s.turns) + .flatMap(t => t.assistantCalls).reduce((sum, call) => sum + call.usage.outputTokens, 0) +} + +function sessionBody(value: number): string { + return 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' +} + +async function writeSession(value: number): Promise { + await writeFile(sessionPath, sessionBody(value)) +} + +// A rename gives the file a fresh inode, so reconcileFile classifies it +// 'modified' and re-parses it. A same-inode rewrite that only grows is +// classified 'appended' instead: the parse resumes at the cached byte offset +// and merges with the cached turns, which is not what this test needs. +async function replaceSession(value: number): Promise { + const incoming = sessionPath + '.incoming' + await writeFile(incoming, sessionBody(value)) + await rename(incoming, sessionPath) +} + +beforeEach(async () => { + clearSessionCache() + root = await mkdtemp(join(tmpdir(), 'cb-memo-completeness-')) + 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 }) +}) + +// Completeness must travel WITH the data, not in a module global: +// parseAllSessions memoizes results per (range, provider) for 180 seconds, and +// a global set by whichever parse ran LAST would describe a different parse +// than the one a memo hit returns. A read-only stale serve memoizes PARTIAL +// data; a later full parse reports complete; a third call inside the memo +// window hits the partial data. The daily backfill reading a global would +// finalize history off the partial snapshot. +describe('parseAllSessions completeness travels with the memoized data', () => { + it('a memoized incomplete snapshot keeps reporting incomplete after a later full parse', async () => { + await writeSession(50) + const first = await parseAllSessions(undefined, 'claude') + expect(isSessionHydrationComplete(first)).toBe(true) + + // A lock that cannot be read (a directory at the lock path) forces the + // read-only path, and the changed file makes that serve a stale snapshot: + // an incomplete hydration, memoized under this (range, provider) key. + await writeSession(5000) + clearSessionCache() + const lockDir = join(process.env['CODEBURN_CACHE_DIR']!, 'session-refresh.lock') + await mkdir(lockDir) + const stale = await parseAllSessions(undefined, 'claude') + expect(output(stale)).toBe(50) + expect(isSessionHydrationComplete(stale)).toBe(false) + await rm(lockDir, { recursive: true, force: true }) + + // With the lock obstacle gone, a later FULL parse (different range, so its + // own memo key) ingests the change and reports complete. + await replaceSession(7000) + const full = await parseAllSessions({ start: new Date('2026-01-01'), end: new Date('2026-12-31') }, 'claude') + expect(output(full)).toBe(7000) + expect(isSessionHydrationComplete(full)).toBe(true) + + // Memo hit on the STALE key: the partial array comes back with its own + // incomplete tag, not the full parse's state. + const again = await parseAllSessions(undefined, 'claude') + expect(again).toBe(stale) + expect(isSessionHydrationComplete(again)).toBe(false) + }) +}) diff --git a/packages/cli/tests/parser-network-readonly-completeness.test.ts b/packages/cli/tests/parser-network-readonly-completeness.test.ts new file mode 100644 index 00000000..d309ebd2 --- /dev/null +++ b/packages/cli/tests/parser-network-readonly-completeness.test.ts @@ -0,0 +1,114 @@ +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>): 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) + }) +})