Merge pull request #711 from getagentseal/fix/audit-batch

Audit batch: prefetch storm, day-bucketing unification, warm-path efficiency
This commit is contained in:
Resham Joshi 2026-07-16 15:37:42 -07:00 committed by GitHub
commit db289fab65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 499 additions and 72 deletions

View file

@ -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<string, string>()
@ -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<string, number> = {}
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(<App />)
// 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()
}
})
})

View file

@ -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<Period, string> = {
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<Set<string>>(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)

View file

@ -77,6 +77,9 @@ export function Onboarding({ defaultEnabled, onDone }: { defaultEnabled: boolean
<button type="button" className="onboard-link" onClick={() => { void codeburn.openExternal?.(COLLECT_URL) }}>
What data we collect
</button>
<p className="onboard-hint">
Tip: if a provider looks empty, grant Full Disk Access in System Settings Privacy &amp; Security.
</p>
</>
) : (
<>

View file

@ -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':

View file

@ -20,11 +20,32 @@ export type Polled<T> = {
// 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<string, unknown>()
/** 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<T>(key: string): T | undefined {
if (!memoStore.has(key)) return undefined
const value = memoStore.get(key) as T
@ -37,7 +58,7 @@ function memoGet<T>(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

View file

@ -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' }

View file

@ -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(<Plans period="30days" />)
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(<Plans period="30days" />)
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([

View file

@ -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%;
}

View file

@ -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<string, unknown>[]; complete?: boolean } {
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean } {
if (!parsed || typeof parsed !== 'object') return false
const c = parsed as Partial<DailyCache>
if (typeof c.version !== 'number') return false
@ -126,10 +145,11 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
}))
}
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record<string, unknown>[]; complete?: boolean }): DailyCache {
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; 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

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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<void> {
} 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<LockRecord>
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<void> {
@ -567,19 +592,28 @@ function sleep(ms: number): Promise<void> {
export async function beginColdHydration(isCold: boolean): Promise<HydrationHandle> {
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 () => {} }
}

View file

@ -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))

View file

@ -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<ProjectSummary[]> => []
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)
})
})

View file

@ -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<string, number> {
const byDate: Record<string, number> = {}
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)
})
})

View file

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