perf: collapse the dashboard's repeated parses and hoist the PR-correlation loops

renderDashboard issued three parseAllSessions calls (scan range, plan window,
durable headline). They often share a START and differ only in where they end —
end-of-day vs each caller's own `new Date()` — which the exact-key memo cannot
match, so a warm render paid for the discovery sweep, the cache read and the
parse two or three times over.

withSinglePassParse lets a caller declare the widest range it will ask for. A
later request that is a PURE NARROWING of it is served by slicing that parse
instead of running the pipeline again. Pure narrowing is deliberately strict:
same start, an end that is inside, and the same month shard scope. Both extra
conditions are load-bearing, because a parse's file set is a function of its
range:
  - a changed file with `mtimeMs < range.start` is skipped without being parsed,
    so an earlier start reads files a later start never sees;
  - loadCache reads only the shards monthScopeForRange selects;
  - either way the extra files seed seenKeys/seenMsgIds BEFORE the range slice,
    and a seeded key suppresses the matching in-range turn in a provider parsed
    later — usage the narrower parse would have counted.
Holding start and month scope equal makes both parses see an identical file set,
leaving the range slice as the only difference — the same trade burstReuse
already makes for the mirror-image case (same start, later end).

Independently, correlateCrossProviderPrSessions ran two O(n*m) scans: a
`sessions.filter` per subagent child (now a one-shot agentId index) and a full
`launches.filter` per candidate (now a sorted array plus a windowed scan). Both
preserve the exact match set; launch order was never observable because the
matches collapse into a Map keyed by the sorted ref list.

Warm, 21k-file corpus, isolated cache, median of 5 interleaved:
  today  5.12s -> 3.81s (1.34x)   3 parses -> 2
  month  5.46s -> 3.41s (1.60x)   2 parses -> 1
  today --format json  4.11s -> 3.49s (1.18x, correlation hoist only)

vitest.config gains an explicit exclude so a .claude worktree's stale tests/
copy stops being swept into the run.

Refs #1106
This commit is contained in:
iamtoruk 2026-08-23 00:31:23 -07:00
parent d5e485f415
commit a8b14a7916
4 changed files with 274 additions and 17 deletions

View file

@ -6,7 +6,7 @@ import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, typ
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js'
import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI, withSinglePassParse } from './parser.js'
import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.js'
import { aggregateModelTotals } from './model-breakdown.js'
import { buildDurablePeriod } from './usage-aggregator.js'
@ -307,6 +307,10 @@ export type DurableOverview = {
carriedCostUSD: number
}
function getDurableRange(period: Period, customRange: DateRange | null | undefined, day: string | null): DateRange {
return day ? getDayRange(day) : customRange ?? getPeriodRange(period)
}
async function computeDurableOverview(
period: Period,
provider: string,
@ -315,7 +319,7 @@ async function computeDurableOverview(
customRange: DateRange | null | undefined,
day: string | null,
): Promise<DurableOverview> {
const range = day ? getDayRange(day) : customRange ?? getPeriodRange(period)
const range = getDurableRange(period, customRange, day)
const { data, carriedCostUSD } = await buildDurablePeriod(
{ range, label: PERIOD_LABELS[period] },
{ provider, project: projectFilter ?? [], exclude: excludeFilter ?? [] },
@ -1867,6 +1871,42 @@ function StaticDashboard({ projects, period, activeProvider, planUsages, label,
)
}
/// The initial paint's data, assembled under one declared parse scope.
///
/// The scan parse, the plan window and the durable headline each ran the whole
/// pipeline: three ranges that often share a start and differ only in where
/// they end (end-of-day vs each caller's own `new Date()`), which the exact-key
/// memo cannot match. Declaring the widest of them lets `withSinglePassParse`
/// serve the rest by slicing it — but only the ones that are a pure narrowing,
/// so a range that genuinely needs its own file set (a plan window starting
/// before the scan range, a past `--day`) still parses on its own.
async function assembleDashboardData(
period: Period,
provider: string,
projectFilter: string[] | undefined,
excludeFilter: string[] | undefined,
customRange: DateRange | null | undefined,
initialDay: string | null,
scrollableDailyHistory: boolean,
): Promise<{ scannedProjects: ProjectSummary[]; filteredProjects: ProjectSummary[]; planUsages: PlanUsage[]; initialDurable: DurableOverview }> {
const range = getDashboardScanRange(period, customRange, initialDay, scrollableDailyHistory)
const durableRange = getDurableRange(period, customRange, initialDay)
const superset: DateRange = {
start: new Date(Math.min(range.start.getTime(), durableRange.start.getTime())),
end: new Date(Math.max(range.end.getTime(), durableRange.end.getTime())),
}
return withSinglePassParse(superset, async () => {
const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory)
const planUsages = await getPlanUsages()
// Durable headline totals for the initial paint (carry-forward cache + today),
// matching the menubar/report. The interactive tree recomputes this on every
// period/provider/refresh change; the static one-shot render uses just this.
const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay)
return { scannedProjects, filteredProjects, planUsages, initialDurable }
})
}
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise<void> {
// Interactive Ink UI: it renders to the same terminal and has its own in-frame
// loading state, so the CLI scan-progress line must stay silent for its whole
@ -1878,13 +1918,8 @@ export async function renderDashboard(period: Period = 'week', provider: string
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY)
const scrollableDailyHistory = isTTY && dayRange == null && customRange == null
const range = getDashboardScanRange(period, customRange, initialDay ?? null, scrollableDailyHistory)
const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory)
const planUsages = await getPlanUsages()
// Durable headline totals for the initial paint (carry-forward cache + today),
// matching the menubar/report. The interactive tree recomputes this on every
// period/provider/refresh change; the static one-shot render uses just this.
const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null)
const { scannedProjects, filteredProjects, planUsages, initialDurable } =
await assembleDashboardData(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null, scrollableDailyHistory)
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
patchStdoutForWindows()
if (isTTY) {

View file

@ -4213,6 +4213,7 @@ function cacheKey(dateRange: DateRange | undefined, providerFilter: string | und
export function clearSessionCache(): void {
sessionCache.clear()
canonicalPathCache.clear()
singlePassScope?.parses.clear()
}
function cachePut(key: string, data: ProjectSummary[], parseStartedAt: number) {
@ -4499,14 +4500,24 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
// mutating the child. This lets a Codex/Gemini/etc. review launched inside a
// Claude subagent inherit the parent turn's PR while the subagent itself still
// folds exactly once under the existing accounting model.
//
// Indexed once rather than re-filtered per child: the loop below only writes
// to `evidence`, so the unlinked set is fixed for its whole duration.
const unlinkedByAgentId = new Map<string | undefined, SessionSummary[]>()
for (const s of sessions) {
if (s.prLinks?.length) continue
const bucket = unlinkedByAgentId.get(s.agentId)
if (bucket) bucket.push(s)
else unlinkedByAgentId.set(s.agentId, [s])
}
for (const resolved of resolveSubagentAttribution(projects).values()) {
for (const child of resolved) {
// A multi-PR spawn set is valid for folding the child's own cost, but is
// too broad to identify which PR an independently saved nested review was
// about. Require one PR for cross-provider propagation.
if (child.unlinked || child.prSet?.length !== 1) continue
const matches = sessions.filter(s => !s.prLinks?.length && s.agentId === child.fold.agentId)
if (matches.length === 1) evidence.set(matches[0]!, child.prSet)
const matches = unlinkedByAgentId.get(child.fold.agentId)
if (matches?.length === 1) evidence.set(matches[0]!, child.prSet)
}
}
@ -4536,6 +4547,21 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
const PROMPT_PREFIX = 160
const PROMPT_MIN = 80
const LAUNCH_WINDOW_MS = 15 * 60 * 1000
// Sorted once so each candidate scans only the launches inside its own
// window instead of the whole array. Launch order is not observable: the
// match set is collapsed into a Map keyed by the sorted ref list, and
// assignCorrelatedPrs re-sorts what it is handed.
launches.sort((a, b) => a.atMs - b.atMs)
const firstLaunchAtOrAfter = (atMs: number): number => {
let lo = 0
let hi = launches.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (launches[mid]!.atMs < atMs) lo = mid + 1
else hi = mid
}
return lo
}
for (const session of candidates) {
const provider = summaryProvider(session)
const prompt = session.turns
@ -4545,12 +4571,14 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
const prefix = prompt.slice(0, PROMPT_PREFIX)
const startedMs = Date.parse(session.firstTimestamp)
if (!Number.isFinite(startedMs)) continue
const matches = launches.filter(launch =>
launch.provider !== provider
&& Math.abs(launch.atMs - startedMs) <= LAUNCH_WINDOW_MS
&& launch.commands.some(command => command.includes(prefix))
)
const refSets = new Map(matches.map(m => [m.refs.slice().sort().join('\0'), m.refs]))
const refSets = new Map<string, string[]>()
for (let i = firstLaunchAtOrAfter(startedMs - LAUNCH_WINDOW_MS); i < launches.length; i++) {
const launch = launches[i]!
if (launch.atMs - startedMs > LAUNCH_WINDOW_MS) break
if (launch.provider === provider) continue
if (!launch.commands.some(command => command.includes(prefix))) continue
refSets.set(launch.refs.slice().sort().join('\0'), launch.refs)
}
if (refSets.size === 1) {
assignCorrelatedPrs(session, [...refSets.values()][0]!, 'launcher-prompt')
if (session.prLinks?.length) evidence.set(session, session.prLinks)
@ -4677,7 +4705,65 @@ let readOnlyServedStale = false
// new data, so the run must not report hydration complete even in write mode.
let deferredRetryableSource = false
// One command invocation that renders a dashboard asks for several ranges that
// differ only in where they END — the scan range runs to end-of-day, the
// durable headline re-anchors on its own `new Date()`. The exact-key memo needs
// both endpoints equal, so it never hits and each ran the whole pipeline again.
// Inside this scope the declared range is parsed ONCE per provider filter and a
// request that is a pure NARROWING of it is served by slicing that result.
// Anything else parses normally.
//
// Pure narrowing means: same start, an end that is inside, and the same month
// shard scope. All three are load-bearing, because a parse's file set is a
// function of its range, not just its output:
// - a CHANGED file with `mtimeMs < range.start` is skipped without being
// parsed, so an earlier start pulls in files a later start never reads;
// - `loadCache` reads only the shards `monthScopeForRange` selects, so a wider
// month span hands the query loop cached files a narrower span never sees;
// - either way those extra files seed `seenKeys` / `seenMsgIds` BEFORE the
// range slice runs, and a seeded key SUPPRESSES the matching in-range turn
// in a provider parsed later — usage the narrower parse would have counted.
// Holding start and month scope equal makes both parses see an identical file
// set in an identical order, which leaves the range slice as the only
// difference — applied after the parse instead of during it, as burstReuse
// already does for the mirror-image case (same start, LATER end).
type SinglePassScope = { range: DateRange; parses: Map<string, Promise<ProjectSummary[]>> }
let singlePassScope: SinglePassScope | null = null
export async function withSinglePassParse<T>(range: DateRange, fn: () => Promise<T>): Promise<T> {
const outer = singlePassScope
singlePassScope = { range, parses: new Map() }
try {
return await fn()
} finally {
singlePassScope = outer
}
}
function singlePassParse(dateRange: DateRange | undefined, providerFilter: string | undefined): Promise<ProjectSummary[]> | null {
const scope = singlePassScope
if (!scope || !dateRange) return null
if (dateRange.start.getTime() !== scope.range.start.getTime()) return null
if (dateRange.end.getTime() > scope.range.end.getTime()) return null
const wide = monthScopeForRange(scope.range.start, scope.range.end)
const narrow = monthScopeForRange(dateRange.start, dateRange.end)
if (wide.fromMonth !== narrow.fromMonth || wide.toMonth !== narrow.toMonth) return null
const key = providerFilter ?? 'all'
let parsed = scope.parses.get(key)
if (!parsed) {
const codexCacheDir = getCodeburnCacheDir()
parsed = withCodexCacheDirectory(codexCacheDir, () => parseAllSessionsInCacheScope(scope.range, providerFilter))
scope.parses.set(key, parsed)
}
// The declared range itself is served verbatim, exactly as an unscoped run
// would have produced it; only a strictly narrower end pays for a slice.
if (dateRange.end.getTime() === scope.range.end.getTime()) return parsed
return parsed.then(projects => filterProjectsByDateRange(projects, dateRange))
}
export function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
const scoped = singlePassParse(dateRange, providerFilter)
if (scoped) return scoped
// Capture synchronously, before the first await. AsyncLocalStorage keeps all
// Codex cache reads, dirty writes, and the final flush on this call-time
// directory even if an embedding host changes the process env mid-parse.

View file

@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
// Counts how many times the parse pipeline actually runs. A scope hit must
// serve from an earlier run rather than starting another one.
let discoveries = 0
vi.mock('../src/providers/index.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/providers/index.js')>()
return {
...actual,
discoverAllSessions: (...args: Parameters<typeof actual.discoverAllSessions>) => {
discoveries++
return actual.discoverAllSessions(...args)
},
}
})
import { parseAllSessions, clearSessionCache, withSinglePassParse } from '../src/parser.js'
import type { DateRange, ProjectSummary } from '../src/types.js'
const CWD = '/tmp/single-pass-proj'
let tmpDir: string
const userLine = (ts: string, text: string) => JSON.stringify({
type: 'user', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
message: { role: 'user', content: text },
})
const asstLine = (id: string, ts: string, outputTokens: number) => JSON.stringify({
type: 'assistant', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
message: { id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 100, output_tokens: outputTokens } },
})
// Three turns on the same day: 09:00, 12:00 and 21:00 UTC. A range that ends at
// midday must keep the first two and drop the third, whether it was applied
// during the parse or as a slice afterwards.
const DAY = '2026-08-20'
const range = (startIso: string, endIso: string): DateRange => ({ start: new Date(startIso), end: new Date(endIso) })
const DAY_START = `${DAY}T00:00:00.000Z`
const DAY_END = `${DAY}T23:59:59.999Z`
const MIDDAY = `${DAY}T12:30:00.000Z`
const shape = (projects: ProjectSummary[]) => projects.map(p => ({
project: p.project,
cost: p.totalCostUSD,
calls: p.totalApiCalls,
turns: p.sessions.flatMap(s => s.turns.map(t => t.timestamp)).sort(),
}))
beforeEach(async () => {
clearSessionCache()
discoveries = 0
tmpDir = await mkdtemp(join(tmpdir(), 'single-pass-'))
const projectDir = join(tmpDir, 'projects', 'single-pass-proj')
await mkdir(projectDir, { recursive: true })
await writeFile(join(projectDir, 'sess-1.jsonl'), [
userLine(`${DAY}T09:00:00.000Z`, 'morning task'),
asstLine('msg-a', `${DAY}T09:00:01.000Z`, 20),
userLine(`${DAY}T12:00:00.000Z`, 'noon task'),
asstLine('msg-b', `${DAY}T12:00:01.000Z`, 30),
userLine(`${DAY}T21:00:00.000Z`, 'evening task'),
asstLine('msg-c', `${DAY}T21:00:01.000Z`, 40),
].join('\n') + '\n')
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
})
afterEach(async () => {
clearSessionCache()
delete process.env['CLAUDE_CONFIG_DIR']
delete process.env['CODEBURN_CACHE_DIR']
delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
await rm(tmpDir, { recursive: true, force: true })
})
describe('withSinglePassParse', () => {
it('serves a narrower end from the declared parse, matching an unscoped parse of that range', async () => {
const unscoped = shape(await parseAllSessions(range(DAY_START, MIDDAY)))
clearSessionCache()
discoveries = 0
const { wide, narrow } = await withSinglePassParse(range(DAY_START, DAY_END), async () => ({
wide: shape(await parseAllSessions(range(DAY_START, DAY_END))),
narrow: shape(await parseAllSessions(range(DAY_START, MIDDAY))),
}))
expect(discoveries).toBe(1)
expect(narrow).toEqual(unscoped)
// Non-vacuous: the narrow view really is a strict subset of the wide one.
expect(narrow[0]!.turns).toEqual([`${DAY}T09:00:00.000Z`, `${DAY}T12:00:00.000Z`])
expect(wide[0]!.turns).toHaveLength(3)
expect(narrow[0]!.cost).toBeLessThan(wide[0]!.cost)
})
it('re-parses when the start differs, because a start also decides which files are read', async () => {
await withSinglePassParse(range(DAY_START, DAY_END), async () => {
await parseAllSessions(range(DAY_START, DAY_END))
await parseAllSessions(range(`${DAY}T10:00:00.000Z`, MIDDAY))
})
expect(discoveries).toBe(2)
})
it('re-parses when the declared end lands in a later month than the request', async () => {
// Local end-of-day on the last of a month is next month in UTC, which is
// what monthScopeForRange keys on: the two loads would read different shards.
const start = '2026-08-31T00:00:00.000Z'
await withSinglePassParse(range(start, '2026-09-01T00:30:00.000Z'), async () => {
await parseAllSessions(range(start, '2026-09-01T00:30:00.000Z'))
await parseAllSessions(range(start, '2026-08-31T23:00:00.000Z'))
})
expect(discoveries).toBe(2)
})
it('leaves a request that reaches past the declared end alone', async () => {
await withSinglePassParse(range(DAY_START, MIDDAY), async () => {
await parseAllSessions(range(DAY_START, MIDDAY))
await parseAllSessions(range(DAY_START, DAY_END))
})
expect(discoveries).toBe(2)
})
it('scopes nothing outside the callback', async () => {
await withSinglePassParse(range(DAY_START, DAY_END), async () => {
await parseAllSessions(range(DAY_START, DAY_END))
})
clearSessionCache()
discoveries = 0
await parseAllSessions(range(DAY_START, MIDDAY))
expect(discoveries).toBe(1)
})
})

View file

@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// A worktree under .claude/ carries its own tests/ copy. They are stale by
// definition (another branch's checkout) and are not this run's subject.
exclude: ['**/node_modules/**', '**/dist/**', '**/.claude/worktrees/**'],
// Runs once per worker before any test. Scrubs the developer's shell so
// session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every
// provider-specific *_HOME) don't bleed real local data into fixtures.