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) + }) +})