From ae18b1fd915d0e2504194af1137936c6c54f51d1 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Thu, 16 Jul 2026 15:37:03 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20audit=20batch=20=E2=80=94=20prefetch=20o?= =?UTF-8?q?ne-shot,=20turn-anchored=20day=20bucketing,=20warm-path=20colla?= =?UTF-8?q?pse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prefetch fires exactly once per provider per session; memo sized to detected providers so base keys never evict (was: 12 full-history parses every 30s forever from LRU thrash re-triggering the effect) - day-aggregator buckets whole turns by user-message timestamp, matching the report/headline path: trend bars and provider breakdown now reconcile exactly (midnight-straddling turns were split); DAILY_CACHE_VERSION 13 with an IANA tz guard that rehydrates when the cache crosses timezones - overview's redundant second all-provider scan collapsed (~11% faster warm); numbers parity-guarded - lock takeover cleans stale hydrating.lock; SIGINT/SIGTERM release it - scanner skips EPERM/EACCES provider dirs (progress shows skipped) instead of aborting; onboarding hints Full Disk Access - rateLimited quota copy render tests; export -f json emits valid empty JSON when no data Root 2023 (+4), app 327 (+3), typechecks clean. --- app/renderer/App.test.tsx | 76 ++++++++++++++- app/renderer/App.tsx | 33 ++++++- app/renderer/components/Onboarding.tsx | 3 + app/renderer/components/Splash.tsx | 4 +- app/renderer/hooks/usePolled.ts | 30 +++++- app/renderer/lib/types.ts | 2 +- app/renderer/sections/Plans.test.tsx | 27 ++++++ app/renderer/styles/plain.css | 1 + src/daily-cache.ts | 48 ++++++++-- src/day-aggregator.ts | 34 ++++--- src/main.ts | 5 +- src/parser.ts | 37 ++++++-- src/session-cache.ts | 44 ++++++++- src/usage-aggregator.ts | 35 +++++-- tests/daily-cache.test.ts | 42 +++++++++ tests/day-aggregator.test.ts | 126 ++++++++++++++++++++++--- tests/parser-hydration-lock.test.ts | 24 +++++ 17 files changed, 499 insertions(+), 72 deletions(-) diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 8aac9e0c..db04a937 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { App } from './App' +import { App, overviewMemoKey } from './App' +import { __resetPolledMemo, hasPolledMemo } from './hooks/usePolled' import type { DateRange, MenubarPayload, OptimizeJsonReport, SpendFlow } from './lib/types' const stored = new Map() @@ -429,3 +430,74 @@ describe('App shortcuts', () => { await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument()) }) }) + +describe('provider prefetch storm', () => { + const PROVIDERS = [ + 'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid', + 'hermes', 'zcode', 'cursor', 'kiro', 'codewhale', 'openrouter', + ] + + function manyProviderPayload(): MenubarPayload { + const base = overviewPayload() + const providers: Record = {} + PROVIDERS.forEach((id, i) => { providers[id] = PROVIDERS.length - i }) + return { ...base, current: { ...base.current, providers } } + } + + beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset() + mocks.getOverview.mockResolvedValue(manyProviderPayload()) + mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } }) + mocks.getYield.mockResolvedValue({ + period: { label: 'Last 30 days', start: '', end: '' }, + summary: { + productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 }, + reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 }, + abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 }, + total: { costUSD: 0, sessions: 0 }, + productiveToRevertedCostRatio: null, + }, + details: [], + }) + localStorage.clear() + __resetPolledMemo() + }) + + // With MEMO_MAX too small (< providers + base keys) the base overview key + // LRU-evicts between polls, blanking the overview and re-arming the prefetch + // every 30s cycle: 12 redundant full-history parses forever. This asserts the + // fix — each provider is prefetched EXACTLY ONCE total across three cycles. + it('prefetches each detected provider exactly once across 3 poll cycles', async () => { + vi.useFakeTimers() + try { + render() + // Let the mount overview resolve so `ready` flips and the prefetch arms. + await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) + // Three full 30s poll cycles plus the prefetch start delay and staggered + // per-provider warms (12 × 400ms). A re-arming storm would re-spawn some + // providers on cycles 2 and 3; the once-per-key guard must prevent it. + await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3 + 12_000) }) + + for (const id of PROVIDERS) { + const spawns = mocks.getOverview.mock.calls.filter( + c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined, + ) + expect(spawns.length, `prefetch spawns for ${id}`).toBe(1) + } + + // Sanity: the active 'all' view was polled every cycle (not prefetch-gated). + const allPolls = mocks.getOverview.mock.calls.filter(c => c[1] === 'all') + expect(allPolls.length).toBeGreaterThanOrEqual(3) + + // The memo must be sized to hold every warmed provider so none LRU-evict + // between polls — the eviction that (in the real app) blanked the base + // overview key and re-armed the prefetch. Under the old fixed cap of 8, + // 7 of these 12 keys would have been evicted by soak's end. + for (const id of PROVIDERS) { + expect(hasPolledMemo(overviewMemoKey(id, '30days', null, null)), `warm key ${id}`).toBe(true) + } + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 8fde55a9..5ab2f227 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EmptyNote } from './components/EmptyState' import { ErrorBoundary } from './components/ErrorBoundary' @@ -10,7 +10,7 @@ import { Splash } from './components/Splash' import { ToastHost } from './components/ToastHost' import { rangeLabel, TopBar } from './components/TopBar' import { Window } from './components/Window' -import { hasPolledMemo, primePolledMemo, usePolled } from './hooks/usePolled' +import { hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled' import { readDailyBudget } from './lib/budget' import { formatCompact, formatUsd, setActiveCurrency } from './lib/format' import { motionClass } from './lib/motion' @@ -78,8 +78,9 @@ const PERIOD_LABELS: Record = { const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] // Instant-switch memo key for an overview result. Shared by the overview poll -// and the provider prefetcher so the two never drift out of sync. -function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string { +// and the provider prefetcher so the two never drift out of sync. Exported so +// the prefetch-storm test can assert warmed keys survive between polls. +export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string { return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}` } @@ -88,6 +89,13 @@ function overviewMemoKey(provider: string, period: Period, range: DateRange | nu // the interaction the user is actually having. const PREFETCH_START_DELAY_MS = 1500 const PREFETCH_STAGGER_MS = 400 +// Base instant-switch memo keys live during overview polling besides the per- +// provider prefetch entries: `overview|all`, `overview-act`, `overview-yield`, +// plus one slot of headroom for section navigation. The memo cap is sized to +// (detected providers + this) so warmed entries — and the base overview key — +// never LRU-evict between polls (which would blank the overview and re-arm the +// prefetch every cycle). +const BASE_MEMO_KEYS = 4 function isPeriod(value: string): value is Period { return (STANDARD_PERIODS as string[]).includes(value) @@ -252,6 +260,12 @@ function AppMain() { setCurrencyTick(tick => tick + 1) }, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol]) + // Size the instant-switch memo to hold every prefetched provider overview plus + // the base keys, so warmed entries survive between polls instead of evicting. + useEffect(() => { + setPolledMemoMax(detectedProviders.length + BASE_MEMO_KEYS) + }, [detectedProviders.length]) + // Prefetch for millisecond switches: once the first overview has resolved, // quietly warm the instant-switch memo for every OTHER detected provider at the // current period, so a picker switch to one paints from memory instead of @@ -260,6 +274,14 @@ function AppMain() { // actually toggles between. The CLI's own read-cache + in-flight coalescing keep // this from double-spawning against a live user fetch; hasPolledMemo skips any // provider already warm (including one warmed by a real visit). + // + // `warmedKeys` is a session-lifetime once-per-key guard: each (provider,period) + // memo key is marked BEFORE its spawn, so an effect re-run — e.g. an overview + // poll that momentarily blanked `overview.data` — can never re-spawn a provider + // already warmed. New keys (a new provider id, or a period switch) still warm + // exactly once. Without this the prefetch re-fired every poll: 12 redundant + // full-history CLI parses every 30s, forever. + const warmedKeys = useRef>(new Set()) useEffect(() => { if (!ready || overview.data == null || customRange || claudeConfigSource) return const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider) @@ -269,7 +291,8 @@ function AppMain() { for (const id of targets) { if (cancelled) return const key = overviewMemoKey(id, period, null, null) - if (hasPolledMemo(key)) continue + if (warmedKeys.current.has(key) || hasPolledMemo(key)) continue + warmedKeys.current.add(key) try { const value = await codeburn.getOverview(period, id) if (!cancelled) primePolledMemo(key, value) diff --git a/app/renderer/components/Onboarding.tsx b/app/renderer/components/Onboarding.tsx index dd5e87d1..83542ee0 100644 --- a/app/renderer/components/Onboarding.tsx +++ b/app/renderer/components/Onboarding.tsx @@ -77,6 +77,9 @@ export function Onboarding({ defaultEnabled, onDone }: { defaultEnabled: boolean +

+ Tip: if a provider looks empty, grant Full Disk Access in System Settings › Privacy & Security. +

) : ( <> diff --git a/app/renderer/components/Splash.tsx b/app/renderer/components/Splash.tsx index 0b00b73d..b7b81927 100644 --- a/app/renderer/components/Splash.tsx +++ b/app/renderer/components/Splash.tsx @@ -40,7 +40,9 @@ function reduceProgress(state: Progress, event: ScanProgressEvent): Progress { } case 'provider': { const order = state.order.includes(event.provider) ? state.order : [...state.order, event.provider] - const next: ProvStatus = event.state === 'done' ? 'done' : 'active' + // 'skipped' (a permission-locked provider) is terminal like 'done' so it + // settles the strip instead of showing as perpetually active. + const next: ProvStatus = event.state === 'done' || event.state === 'skipped' ? 'done' : 'active' return { ...state, order, status: { ...state.status, [event.provider]: next } } } case 'tick': diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts index 6a7b22c8..e992ab77 100644 --- a/app/renderer/hooks/usePolled.ts +++ b/app/renderer/hooks/usePolled.ts @@ -20,11 +20,32 @@ export type Polled = { // Module-level LRU of last-good results per memoKey. A section that switches deps // to a previously-seen key (e.g. a provider switch, or a switch-back) paints the // cached result in the same frame while a fresh fetch runs behind it — no blank, -// no stale-freeze. ~8 entries is plenty for the handful of live (provider × -// period × range) combinations a user cycles through. -const MEMO_MAX = 8 +// no stale-freeze. +// +// The cap must comfortably hold every key live at once: the base overview/act/ +// yield polls PLUS one prefetched overview per detected provider. Sized too small +// it LRU-evicts the base `overview|all` key between polls, which blanks the +// overview and re-triggers the provider prefetch every cycle (the prefetch +// storm). The App raises it via setPolledMemoMax to (detected providers + base +// keys); DEFAULT_MEMO_MAX is the floor for isolated hook/component tests. +const DEFAULT_MEMO_MAX = 8 +const MEMO_MAX_CAP = 24 +let memoMax = DEFAULT_MEMO_MAX const memoStore = new Map() +/** Raise (or lower) the instant-switch memo cap so warmed entries survive between + * polls. Clamped to [DEFAULT_MEMO_MAX, MEMO_MAX_CAP]; trims immediately if the + * new cap is smaller than the current contents. Called by the App as the set of + * detected providers grows. */ +export function setPolledMemoMax(n: number): void { + memoMax = Math.max(DEFAULT_MEMO_MAX, Math.min(MEMO_MAX_CAP, Math.floor(n))) + while (memoStore.size > memoMax) { + const oldest = memoStore.keys().next().value + if (oldest === undefined) break + memoStore.delete(oldest) + } +} + function memoGet(key: string): T | undefined { if (!memoStore.has(key)) return undefined const value = memoStore.get(key) as T @@ -37,7 +58,7 @@ function memoGet(key: string): T | undefined { function memoSet(key: string, value: unknown): void { if (memoStore.has(key)) memoStore.delete(key) memoStore.set(key, value) - while (memoStore.size > MEMO_MAX) { + while (memoStore.size > memoMax) { const oldest = memoStore.keys().next().value if (oldest === undefined) break memoStore.delete(oldest) @@ -48,6 +69,7 @@ function memoSet(key: string, value: unknown): void { * one test never bleed into the next. */ export function __resetPolledMemo(): void { memoStore.clear() + memoMax = DEFAULT_MEMO_MAX } /** Seed the instant-switch memo out of band. The prefetcher (App.tsx) warms the diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index b12e9af4..bc6f4d31 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -566,7 +566,7 @@ export type TelemetryStatus = { * a warm launch's incremental re-parse emits the same events without it. */ export type ScanProgressEvent = | { kind: 'providers'; providers: string[]; cold?: boolean } - | { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number } + | { kind: 'provider'; provider: string; state: 'start' | 'done' | 'skipped'; files?: number } | { kind: 'tick'; provider: string; done: number; total: number } | { kind: 'done' } diff --git a/app/renderer/sections/Plans.test.tsx b/app/renderer/sections/Plans.test.tsx index a5bb9d3d..e3701696 100644 --- a/app/renderer/sections/Plans.test.tsx +++ b/app/renderer/sections/Plans.test.tsx @@ -258,6 +258,33 @@ describe('Plans', () => { await waitFor(() => expect(getQuota).toHaveBeenCalledWith(true)) }) + it('renders the honest rate-limited note on a 429 backoff, per provider owner', async () => { + getPlans.mockResolvedValue(baseStatus) + getQuota.mockResolvedValue([ + { provider: 'claude', connection: 'transientFailure', rateLimited: true, primary: null, details: [], planLabel: null, footerLines: [] }, + { provider: 'codex', connection: 'stale', rateLimited: true, primary: null, details: [], planLabel: null, footerLines: [] }, + ]) + + render() + + expect(await screen.findByText('Anthropic rate limited the quota endpoint, retrying in a few minutes')).toBeInTheDocument() + expect(screen.getByText('OpenAI rate limited the quota endpoint, retrying in a few minutes')).toBeInTheDocument() + // The rate-limited note replaces the generic waiting copy. + expect(screen.queryByText('waiting on the CLI…')).not.toBeInTheDocument() + }) + + it('falls back to the generic waiting note when a transient failure is not rate limited', async () => { + getPlans.mockResolvedValue(baseStatus) + getQuota.mockResolvedValue([ + { provider: 'claude', connection: 'transientFailure', rateLimited: false, primary: null, details: [], planLabel: null, footerLines: [] }, + ]) + + render() + + expect(await screen.findByText('waiting on the CLI…')).toBeInTheDocument() + expect(screen.queryByText(/rate limited the quota endpoint/)).not.toBeInTheDocument() + }) + it('renders the keychain access-denied state with recovery copy and a locked indicator', async () => { getPlans.mockResolvedValue(statusWithPlans) getQuota.mockResolvedValue([ diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index fcecf7eb..20943ad0 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -965,6 +965,7 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } font: inherit; font-size: var(--fs-label); color: #b3a595; text-decoration: underline; text-underline-offset: 3px; } .onboard-link:hover, .onboard-link:focus-visible { color: #f4f1ec; outline: none; } +.onboard-hint { margin: 2px 0 0; max-width: 320px; font-size: var(--fs-label); line-height: 1.5; color: #8a7d6f; } .onboard-controls { margin-top: 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; } diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 13cd998c..502bb1ed 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,7 +5,14 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 12: CodeWhale support adds historical usage that earlier rollups +// Bumped to 13: day bucketing is now TURN-anchored (a turn's whole cost/calls +// land on the day of its user-message timestamp) to match the live headline/ +// report rollup. v12 bucketed each call by its own timestamp, so a midnight- +// straddling turn split across two days and history.daily / the provider +// breakdown never reconciled to current.cost. Raising MIN_SUPPORTED_VERSION +// forces the one-time re-hydration that rebuilds history under turn bucketing. +// +// v12: CodeWhale support adds historical usage that earlier rollups // did not contain. Both the CodeWhale branch and the kiro credit-pricing // change (below) claimed v11 independently, so v12 is the first version that // contains both; raising MIN_SUPPORTED_VERSION forces the one-time @@ -26,8 +33,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 12 -const MIN_SUPPORTED_VERSION = 12 +export const DAILY_CACHE_VERSION = 13 +const MIN_SUPPORTED_VERSION = 13 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename. const DAILY_CACHE_FILENAME = `daily-cache.v${DAILY_CACHE_VERSION}.json` @@ -68,6 +75,12 @@ export type DailyCache = { /// hash mismatches and `ensureCacheHydrated` discards the cached days /// so historical savings are recomputed against the current mapping. savingsConfigHash: string + /// IANA local timezone the days were bucketed under (day boundaries are + /// local-time). If the machine's timezone changes, previously-cached days are + /// bucketed against the wrong midnight, so a mismatch forces a full re-hydrate + /// (same self-heal as `savingsConfigHash`). Absent on caches written before + /// this field existed → not treated as a mismatch (no gratuitous rebuild). + tzKey?: string lastComputedDate: string | null days: DailyEntry[] /// True only once the full backfill window has been hydrated from a COMPLETE @@ -82,6 +95,12 @@ function getCacheDir(): string { return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') } +/** IANA name of the current local timezone (respects the TZ env var). Days are + * bucketed by local midnight, so this tags the cache for TZ-change invalidation. */ +export function currentTzKey(): string { + try { return Intl.DateTimeFormat().resolvedOptions().timeZone || '' } catch { return '' } +} + function getCachePath(): string { return join(getCacheDir(), DAILY_CACHE_FILENAME) } @@ -96,10 +115,10 @@ export function dailyCachePath(): string { } export function emptyCache(savingsConfigHash = ''): DailyCache { - return { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [], complete: false } + return { version: DAILY_CACHE_VERSION, savingsConfigHash, tzKey: currentTzKey(), lastComputedDate: null, days: [], complete: false } } -function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[]; complete?: boolean } { +function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean } { if (!parsed || typeof parsed !== 'object') return false const c = parsed as Partial if (typeof c.version !== 'number') return false @@ -126,10 +145,11 @@ function migrateDays(days: Record[]): DailyEntry[] { })) } -function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[]; complete?: boolean }): DailyCache { +function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean }): DailyCache { return { version: DAILY_CACHE_VERSION, savingsConfigHash: parsed.savingsConfigHash ?? '', + tzKey: parsed.tzKey, lastComputedDate: parsed.lastComputedDate, days: migrateDays(parsed.days), // Only a cache explicitly marked complete stays trusted; one written before @@ -222,6 +242,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate return { version: DAILY_CACHE_VERSION, savingsConfigHash: cache.savingsConfigHash, + tzKey: cache.tzKey, lastComputedDate: nextLast, days: pruned, complete: cache.complete, @@ -274,7 +295,7 @@ export async function ensureCacheHydrated( return withDailyCacheLock(async () => { let c = await loadDailyCache() - // Two reasons to drop the cached days and re-hydrate the whole retention + // Three reasons to drop the cached days and re-hydrate the whole retention // window: // 1. Savings config changed — the cached `savingsUSD` totals are stale, and // we can't cheaply recompute them per historical day without re-parsing. @@ -282,14 +303,25 @@ export async function ensureCacheHydrated( // pre-marker cache, or one frozen from a partial/interrupted hydration). // Its older days may be empty or partial; trusting `lastComputedDate` // would leave that gap forever (the "first ~20 days missing" bug). - if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true) { + // 3. The local timezone changed — days are bucketed by local midnight, so a + // TZ change mis-buckets every cached day. Only invalidate when a tzKey is + // present and differs (a cache written before this field, or a test + // fixture, has none → left alone rather than force a spurious rebuild). + const tzKey = currentTzKey() + const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey + if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) { c = { version: DAILY_CACHE_VERSION, savingsConfigHash, + tzKey, lastComputedDate: null, days: [], complete: false, } + } else if (c.tzKey === undefined) { + // First write under the tzKey scheme: tag the cache so a later TZ change is + // detectable, without discarding the (still-valid, same-TZ) cached days. + c = { ...c, tzKey } } // Drop any cached entry dated today or later. The cache only ever stores diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index f8cea1ca..79ca7805 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -41,7 +41,15 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr for (const turn of session.turns) { if (turn.assistantCalls.length === 0) continue - const turnDate = dateKey(turn.assistantCalls[0]!.timestamp) + // Turn-anchored bucketing: attribute the WHOLE turn — every one of its + // calls — to the day of the turn's user-message timestamp, matching the + // live headline/report rollup (main.ts daily). Falls back to the first + // assistant-call timestamp when the user line is missing (continuation + // sessions that begin mid-conversation). Previously the calls were + // bucketed per-call by each call's own timestamp, so a midnight- + // straddling turn split across two days and history.daily / the provider + // breakdown never reconciled to current.cost (a constant offset). + const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp) const turnDay = ensure(turnDate) const editTurns = turn.hasEdits ? 1 : 0 @@ -61,19 +69,17 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr turnDay.categories[turn.category] = cat for (const call of turn.assistantCalls) { - const callDate = dateKey(call.timestamp) - const callDay = ensure(callDate) const callSavings = call.savingsUSD ?? 0 - callDay.cost += call.costUSD - callDay.savingsUSD += callSavings - callDay.calls += 1 - callDay.inputTokens += call.usage.inputTokens - callDay.outputTokens += call.usage.outputTokens - callDay.cacheReadTokens += call.usage.cacheReadInputTokens - callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens + turnDay.cost += call.costUSD + turnDay.savingsUSD += callSavings + turnDay.calls += 1 + turnDay.inputTokens += call.usage.inputTokens + turnDay.outputTokens += call.usage.outputTokens + turnDay.cacheReadTokens += call.usage.cacheReadInputTokens + turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens - const model = callDay.models[call.model] ?? { + const model = turnDay.models[call.model] ?? { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, @@ -85,13 +91,13 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr model.outputTokens += call.usage.outputTokens model.cacheReadTokens += call.usage.cacheReadInputTokens model.cacheWriteTokens += call.usage.cacheCreationInputTokens - callDay.models[call.model] = model + turnDay.models[call.model] = model - const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 } + const provider = turnDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 } provider.calls += 1 provider.cost += call.costUSD provider.savingsUSD += callSavings - callDay.providers[call.provider] = provider + turnDay.providers[call.provider] = provider } } } diff --git a/src/main.ts b/src/main.ts index 1e622315..bde0f92b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1204,7 +1204,10 @@ program ] } - if (periods.every(p => p.projects.length === 0)) { + if (periods.every(p => p.projects.length === 0) && opts.format !== 'json') { + // Human-readable prose for CSV / interactive use. JSON falls through and + // writes a valid, schema-matching file with empty arrays so programmatic + // consumers always get parseable output, never prose. console.log('\n No usage data found.\n') return } diff --git a/src/parser.ts b/src/parser.ts index 5cdc5489..96c72dda 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2057,6 +2057,16 @@ function warnProviderParseFailure(providerName: string, sourcePath: string, err: ) } +// A permission error (EPERM/EACCES) on a provider's data — e.g. a directory or +// SQLite DB the OS won't let us read without Full Disk Access. Per-file and +// discovery errors are already isolated; this catches a provider-level throw so +// one locked provider skips-and-continues instead of aborting the whole +// hydration (which would empty the cache/daily backfill for every provider). +function isPermissionError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + return code === 'EPERM' || code === 'EACCES' +} + // A cold-cache scan over a large ~/.claude/projects tree (hundreds of project // dirs, e.g. a git-worktree-per-task workflow) can run long enough that it // looks hung, and is CPU-heavy enough on a single thread to visibly compete @@ -2103,7 +2113,7 @@ export type ScanProgressEvent = // still emits `providers`/`tick`, so consumers must gate any "indexing" UI on // this flag, not on the mere presence of tick work. | { kind: 'providers'; providers: string[]; cold?: boolean } - | { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number } + | { kind: 'provider'; provider: string; state: 'start' | 'done' | 'skipped'; files?: number } | { kind: 'tick'; provider: string; done: number; total: number } export function emitScanProgress(event: ScanProgressEvent): void { @@ -2639,15 +2649,30 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa : undefined, })) if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' }) - const claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress) - if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) + let claudeProjects: ProjectSummary[] = [] + try { + claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress) + if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) + } catch (err) { + if (!isPermissionError(err)) throw err + process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`) + emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' }) + } const otherProjects: ProjectSummary[] = [] for (const [providerName, sources] of providerGroups) { emitScanProgress({ kind: 'provider', provider: providerName, state: 'start' }) - const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange) - emitScanProgress({ kind: 'provider', provider: providerName, state: 'done', files: sources.length }) - otherProjects.push(...projects) + try { + const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange) + emitScanProgress({ kind: 'provider', provider: providerName, state: 'done', files: sources.length }) + otherProjects.push(...projects) + } catch (err) { + // A permission-locked provider skips-and-continues; any other error is a + // real bug and still aborts (per-file/DB-lock cases are handled deeper). + if (!isPermissionError(err)) throw err + process.stderr.write(`codeburn: skipped ${providerName} data (permission denied; grant Full Disk Access to include it)\n`) + emitScanProgress({ kind: 'provider', provider: providerName, state: 'skipped' }) + } await saveProgress() } diff --git a/src/session-cache.ts b/src/session-cache.ts index 2d5c2658..d189251a 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -1,5 +1,5 @@ import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises' -import { existsSync } from 'fs' +import { existsSync, readFileSync, unlinkSync } from 'fs' import { createHash, randomBytes } from 'crypto' import { join } from 'path' import { homedir } from 'os' @@ -546,6 +546,31 @@ async function removeOurLock(): Promise { } catch { /* best-effort; a leaked lock is reclaimed as stale next cold start */ } } +// Synchronous variant for the signal path: a handler can't await, so read + unlink +// synchronously. Only unlinks a lock we actually own. +function removeOurLockSync(): void { + try { + const parsed = JSON.parse(readFileSync(lockPath(), 'utf-8')) as Partial + if (parsed?.pid === process.pid) unlinkSync(lockPath()) + } catch { /* best-effort; nothing to clean or already gone */ } +} + +// Arm once, only while we hold the lock: on a catchable termination (Ctrl-C, or a +// SIGTERM from a parent) clean our lock before dying so a killed cold parse leaves +// no leftover. SIGKILL can't be caught, so that path still relies on the next cold +// start's stale-lock takeover. process.once + re-raise preserves the default exit. +let signalCleanupArmed = false +function armSignalCleanup(): void { + if (signalCleanupArmed) return + signalCleanupArmed = true + for (const sig of ['SIGINT', 'SIGTERM'] as const) { + process.once(sig, () => { + removeOurLockSync() + process.kill(process.pid, sig) + }) + } +} + const releaseHandle: HydrationHandle = { waited: false, release: removeOurLock } function sleep(ms: number): Promise { @@ -567,19 +592,28 @@ function sleep(ms: number): Promise { export async function beginColdHydration(isCold: boolean): Promise { if (!isCold) return NOOP_HANDLE try { - if (await writeOurLock()) return releaseHandle + if (await writeOurLock()) { armSignalCleanup(); return releaseHandle } const existing = await readLockRecord() const fresh = existing !== null && Date.now() - existing.at < LOCK_FRESH_MS if (existing && fresh && pidLooksAlive(existing.pid)) { // Another live process owns a fresh lock: wait for it to release, go stale, - // or die, then let the caller reload the warm cache. + // or die. A CLEAN release means the cache is warm — reload it. Going stale or + // dying (e.g. a SIGKILLed cold scan) means the holder left partial data AND a + // leftover lock file: take over — clean the stale lock and re-acquire — so we + // re-parse under our own lock and remove the leftover on release, instead of + // leaving it for the next cold start to reclaim. const deadline = Date.now() + LOCK_WAIT_MAX_MS + let takeover = false while (Date.now() < deadline) { await sleep(LOCK_POLL_MS) const cur = await readLockRecord() if (!cur) break - if (Date.now() - cur.at >= LOCK_FRESH_MS) break - if (!pidLooksAlive(cur.pid)) break + if (Date.now() - cur.at >= LOCK_FRESH_MS) { takeover = true; break } + if (!pidLooksAlive(cur.pid)) { takeover = true; break } + } + if (takeover) { + try { await unlink(lockPath()) } catch { /* another process may have; fine */ } + if (await writeOurLock()) { armSignalCleanup(); return releaseHandle } } return { waited: true, release: async () => {} } } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 2f0f780b..e8dd2400 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -240,7 +240,31 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: if (!effectivelyScoped) { if (isAllProviders) { cache = await hydrateCache() - const todayProjects = await getTodayAllProjects() + const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr + // Single all-provider scan for the requested window. Previously the overview + // parsed every provider TWICE — once over todayRange and once over the full + // period — even though the period scan already contains today's turns. Scan + // once here and derive the "today" slice from it below: aggregateProjects- + // IntoDays buckets by each turn's own timestamp, so a period scan's today + // slice is identical to a dedicated today scan's. A purely historical window + // (ends before today) never reaches today, so its separate today scan — the + // one the always-on daily-history strip needs — is left to run. + let rawScan: ProjectSummary[] + if (isTodayOnly) { + rawScan = fp(await parseAllSessions(todayRange, 'all')) + scanProjects = rawScan + scanRange = todayRange + } else { + rawScan = fp(await parseAllSessions(periodInfo.range, 'all')) + scanProjects = daysSelection ? filterProjectsByDays(rawScan, daysSelection.days) : rawScan + scanRange = periodInfo.range + } + // Seed the today memo from the un-daysSelection rawScan so the always-on + // daily-history strip still shows today even when the heatmap day filter + // excludes it (matching the old dedicated today scan, which ignored it). + if (rangeEndStr >= todayStr) { + todayAllDays = aggregateProjectsIntoDays(rawScan).filter(d => d.date === todayStr) + } const todayDays = await getTodayAllDays() const historicalDays = rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) @@ -249,15 +273,6 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays currentData = buildPeriodDataFromDays(allDays, periodInfo.label) - const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr - if (isTodayOnly) { - scanProjects = todayProjects - scanRange = todayRange - } else { - const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all')) - scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects - scanRange = periodInfo.range - } } else { cache = await loadDailyCache() const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf)) diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index e7949c49..9b69153a 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -8,6 +8,7 @@ import type { ProjectSummary } from '../src/types.js' import { addNewDays, + currentTzKey, dailyCachePath, DAILY_CACHE_VERSION, type DailyCache, @@ -309,6 +310,7 @@ describe('ensureCacheHydrated', () => { const saved: DailyCache = { version: DAILY_CACHE_VERSION, savingsConfigHash: '', + tzKey: currentTzKey(), lastComputedDate: '2026-06-11', days: [emptyDay('2026-06-11', 5, 10)], complete: true, @@ -414,3 +416,43 @@ describe('ensureCacheHydrated: savings config invalidation', () => { expect(preserved.days[0]!.date).toBe(twoDaysAgoStr) }) }) + +describe('ensureCacheHydrated: timezone invalidation', () => { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + const twoDaysAgoStr = `${twoDaysAgo.getFullYear()}-${String(twoDaysAgo.getMonth() + 1).padStart(2, '0')}-${String(twoDaysAgo.getDate()).padStart(2, '0')}` + const parseSessions = async (): Promise => [] + const aggregateDays = (): DailyEntry[] => [] + + it('re-hydrates when the cached tzKey differs from the current timezone', async () => { + // Days are bucketed by local midnight, so a cache tagged under a different + // timezone mis-buckets every day and must be discarded (like a savings-hash + // mismatch). 'Test/OtherZone' can never equal a real IANA zone. + const seeded: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: 'Test/OtherZone', + lastComputedDate: twoDaysAgoStr, + days: [emptyDay(twoDaysAgoStr, 1.5, 3)], + complete: true, + } + await saveDailyCache(seeded) + const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, '') + expect(rehydrated.tzKey).toBe(currentTzKey()) + expect(rehydrated.days).toEqual([]) + }) + + it('keeps cached days when the tzKey matches the current timezone', async () => { + const seeded: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: twoDaysAgoStr, + days: [emptyDay(twoDaysAgoStr, 1.5, 3)], + complete: true, + } + await saveDailyCache(seeded) + const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, '') + expect(preserved.days).toHaveLength(1) + expect(preserved.days[0]!.date).toBe(twoDaysAgoStr) + }) +}) diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index d20eb065..ca8ba777 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -40,7 +40,10 @@ function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provid } describe('aggregateProjectsIntoDays', () => { - it('buckets api calls by calendar date derived from timestamp', () => { + it('buckets a whole turn (all its calls) on the turn user-message date', () => { + // Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly + // on the day of its user-message timestamp — matching the live headline/ + // report rollup — instead of splitting per-call across two days. const projects: ProjectSummary[] = [ makeProject({ sessions: [{ @@ -79,11 +82,9 @@ describe('aggregateProjectsIntoDays', () => { ] const days = aggregateProjectsIntoDays(projects) - expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10']) - expect(days[0]!.cost).toBe(4) - expect(days[0]!.calls).toBe(1) - expect(days[1]!.cost).toBe(6) - expect(days[1]!.calls).toBe(1) + expect(days.map(d => d.date)).toEqual(['2026-04-09']) + expect(days[0]!.cost).toBe(10) + expect(days[0]!.calls).toBe(2) }) it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => { @@ -263,17 +264,17 @@ describe('buildPeriodDataFromDays', () => { expect(pd.models).toEqual([]) }) - it('attributes a midnight-straddling turn to the first assistant call date, not the user message date', () => { - // Regression for the bug that shipped in 0.8.2-0.8.4: when a user message - // sat on one side of midnight and the assistant response landed on the other, - // day-aggregator.ts bucketed by assistant time but renderStatusBar bucketed - // by user time, so the menubar and `codeburn status` disagreed on Today. - // The invariant for both surfaces: a turn is counted on the day its first - // assistant call actually ran. + it('attributes a midnight-straddling turn to the user-message date, matching the live report', () => { + // A turn whose user message sits on one side of midnight and whose assistant + // response lands on the other must bucket by the USER-MESSAGE timestamp, so + // the daily cache (history.daily + provider breakdown) reconciles exactly to + // the live headline/report rollup (main.ts daily), which anchors on the same + // turn timestamp. The prior per-call bucketing split such turns and left a + // constant offset between the trend bars and current.cost. const userTs = '2026-04-20T23:58:00Z' const assistantTs = '2026-04-21T00:30:00Z' - const assistantLocal = new Date(assistantTs) - const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}` + const userLocal = new Date(userTs) + const expectedDate = `${userLocal.getFullYear()}-${String(userLocal.getMonth() + 1).padStart(2, '0')}-${String(userLocal.getDate()).padStart(2, '0')}` const projects: ProjectSummary[] = [ makeProject({ @@ -308,3 +309,98 @@ describe('buildPeriodDataFromDays', () => { expect(costDay!.calls).toBe(1) }) }) + +describe('daily-cache ↔ report daily-bucket parity', () => { + // The daily cache (history.daily + provider breakdown) and the live report / + // headline (main.ts daily rollup) must bucket days by the SAME rule, or their + // per-day totals drift and their period sums diverge from current.cost at + // window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both + // are now TURN-anchored: this asserts per-day equality against a reference + // that mirrors main.ts:486-499 (turn.timestamp anchor), plus the invariant + // history.daily Σ == report.daily Σ == total call cost. + + // Mirrors the live report/headline daily rollup in src/main.ts (bucket the + // whole turn — all its calls — on the turn's user-message date). + function reportDailyByDate(projects: ProjectSummary[]): Record { + const byDate: Record = {} + for (const p of projects) { + for (const sess of p.sessions) { + for (const turn of sess.turns) { + if (turn.assistantCalls.length === 0) continue + const ts = turn.timestamp || turn.assistantCalls[0]!.timestamp + const day = dateKey(ts) + for (const call of turn.assistantCalls) byDate[day] = (byDate[day] ?? 0) + call.costUSD + } + } + } + return byDate + } + + it('buckets each day identically to the report and reconciles the period total', () => { + // Construct in LOCAL time so the turn genuinely straddles local midnight on + // any machine TZ (UTC-literal timestamps would straddle in some zones only). + const iso = (y: number, mo: number, d: number, h: number, mi: number) => + new Date(y, mo, d, h, mi, 0).toISOString() + const turnTs = iso(2026, 3, 16, 23, 50) // day A, late evening + const call1Ts = iso(2026, 3, 16, 23, 55) // day A + const call2Ts = iso(2026, 3, 17, 0, 10) // day A+1 (turn straddles midnight) + const turn2Ts = iso(2026, 3, 17, 9, 0) // day A+1 + const dayA = dateKey(turnTs) + const dayB = dateKey(call2Ts) + expect(dayA).not.toBe(dayB) // sanity: the fixture really straddles local midnight + + // A midnight-straddling turn (calls on both days) plus a same-day turn, so + // per-CALL bucketing would produce DIFFERENT per-day totals than the turn- + // anchored report — the case the old code got wrong. + const projects: ProjectSummary[] = [ + makeProject({ + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: turnTs, + lastTimestamp: turn2Ts, + totalCostUSD: 12, + totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 3, + turns: [ + { + userMessage: 'straddles into next day', timestamp: turnTs, sessionId: 's1', + category: 'coding', retries: 0, hasEdits: false, + assistantCalls: [makeCall(call1Ts, 2), makeCall(call2Ts, 3)], + }, + { + userMessage: 'later same day', timestamp: turn2Ts, sessionId: 's1', + category: 'coding', retries: 0, hasEdits: false, + assistantCalls: [makeCall(turn2Ts, 7)], + }, + ], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }), + ] + + const historyDaily = aggregateProjectsIntoDays(projects) + const historyByDate = Object.fromEntries(historyDaily.map(d => [d.date, d.cost])) + const reportByDate = reportDailyByDate(projects) + + // history.daily (cache path) buckets each day EXACTLY as the report does. + expect(historyByDate).toEqual(reportByDate) + // And the provider breakdown, summed per day, matches too (same bug root). + for (const d of historyDaily) { + const providerSum = Object.values(d.providers).reduce((s, pr) => s + pr.cost, 0) + expect(providerSum).toBeCloseTo(d.cost, 10) + } + + // history.daily Σ == report.daily Σ == current.cost (total of all call costs). + const historySum = historyDaily.reduce((s, d) => s + d.cost, 0) + const reportSum = Object.values(reportByDate).reduce((s, c) => s + c, 0) + const totalCallCost = 2 + 3 + 7 + expect(historySum).toBeCloseTo(totalCallCost, 10) + expect(reportSum).toBeCloseTo(totalCallCost, 10) + // Day A owns the WHOLE straddling turn (2+3=5), not just its first call (2). + expect(historyByDate[dayA]).toBe(5) + expect(historyByDate[dayB]).toBe(7) + }) +}) diff --git a/tests/parser-hydration-lock.test.ts b/tests/parser-hydration-lock.test.ts index e5bdbe90..79e14983 100644 --- a/tests/parser-hydration-lock.test.ts +++ b/tests/parser-hydration-lock.test.ts @@ -135,4 +135,28 @@ describe('parseAllSessions hydration lock', () => { expect(totalOutput(result)).toBe(50) expect(existsSync(lockPath())).toBe(false) }) + + it('takes over a lock whose live holder dies mid-wait, then cleans it up', async () => { + await writeClaudeSession(50) + await mkdir(cacheDir, { recursive: true }) + + // A fresh lock held by a live process (pid 1): the cold parse starts waiting. + await writeFile(lockPath(), JSON.stringify({ pid: 1, at: Date.now() })) + let resolved = false + const promise = parseAllSessions(undefined, 'claude').then(r => { resolved = true; return r }) + await delay(60) + expect(resolved).toBe(false) // still blocked on the live foreign lock + + // The holder is SIGKILLed mid-scan: its pid goes dead and its lock is left + // behind (no clean release). The waiter must detect the dead pid, take over + // (re-parse under its own lock), and remove the leftover on release. + await writeFile(lockPath(), JSON.stringify({ pid: 2_147_483_646, at: Date.now() })) + + const result = await promise + expect(resolved).toBe(true) + // Took over and re-parsed the source (50) rather than trusting a partial cache. + expect(totalOutput(result)).toBe(50) + // No leftover lock: the takeover acquired it and released it in the finally. + expect(existsSync(lockPath())).toBe(false) + }) })