diff --git a/src/parser.ts b/src/parser.ts index 2120bd0b3..13449e5e0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3205,6 +3205,20 @@ function parseBurstWindowMs(): number { return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60_000) : 0 } +// A resident process (codeburn serve) can install a validator that answers +// "has any watched session root changed since this timestamp?" — typically +// backed by fs.watch over every provider's probeRoots(). While the validator +// reports clean, a previous parse stays reusable well past the burst window, +// bounded by a hard cap so a missed filesystem event self-heals instead of +// pinning stale data forever. Null (the default everywhere but serve) keeps +// reuse strictly inside the burst window. +let parseReuseValidator: ((sinceTs: number) => boolean) | null = null +const VALIDATED_REUSE_CAP_MS = 5 * 60 * 1000 + +export function setParseReuseValidator(validator: ((sinceTs: number) => boolean) | null): void { + parseReuseValidator = validator +} + function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null { const windowMs = parseBurstWindowMs() if (windowMs <= 0) return null @@ -3213,8 +3227,11 @@ function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null const endMs = dateRange.end.getTime() for (const entry of sessionCache.values()) { if (entry.sig !== sig || entry.startMs !== startMs || entry.endMs === undefined) continue - if (now - entry.ts > windowMs) continue - if (endMs < entry.endMs || endMs - entry.endMs > windowMs) continue + const age = now - entry.ts + const insideBurst = age <= windowMs + const validatedClean = parseReuseValidator !== null && age <= VALIDATED_REUSE_CAP_MS && parseReuseValidator(entry.ts) + if (!insideBurst && !validatedClean) continue + if (endMs < entry.endMs || endMs - entry.endMs > Math.max(windowMs, validatedClean ? VALIDATED_REUSE_CAP_MS : 0)) continue return filterProjectsByDateRange(entry.data, dateRange) } return null diff --git a/src/serve.ts b/src/serve.ts index 7d9c0e5ba..dc80e391a 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -1,3 +1,5 @@ +import { watch, type FSWatcher } from 'fs' +import { stat } from 'fs/promises' import { createInterface } from 'readline' import type { Command } from 'commander' @@ -88,12 +90,69 @@ async function runCaptured(buildProgram: () => Command, args: string[]): Promise } } +/// Watch every provider's probe roots (the same paths codeburn doctor reports +/// as "where discovery looks") so the parse-reuse validator can answer "did +/// any session data change since T?" without a stat sweep. macOS fs.watch +/// rides FSEvents and supports recursive directory watches; a root that fails +/// to watch is simply not covered, which only shortens reuse (the burst +/// window and the hard cap still apply), never staleness. +async function startRootWatchers(): Promise<{ startedAt: number; lastEventAt: () => number; close: () => void }> { + let lastEventAt = 0 + const startedAt = Date.now() + const watchers: FSWatcher[] = [] + try { + const { getAllProviders } = await import('./providers/index.js') + const providers = await getAllProviders() + const roots = new Set() + for (const provider of providers) { + if (!provider.probeRoots) continue + try { + for (const root of await provider.probeRoots()) roots.add(root.path) + } catch { /* a failing probe just goes unwatched */ } + } + for (const root of roots) { + try { + const info = await stat(root) + const watcher = watch(root, { recursive: info.isDirectory() }, () => { lastEventAt = Date.now() }) + watcher.on('error', () => { /* dropped watcher = shorter reuse, never staleness */ }) + watchers.push(watcher) + } catch { /* nonexistent root: nothing to watch */ } + } + } catch { /* watcherless serve still works via the burst window */ } + return { + startedAt, + lastEventAt: () => lastEventAt, + close: () => { for (const w of watchers) w.close() }, + } +} + export async function runStdioServe(buildProgram: () => Command): Promise { // Panel bursts (the app fetching every panel for one period) reuse a parse // whose through-now range end differs by less than this window, instead of // re-running the discovery sweep per panel. Serve-only: one-shot CLI runs // never set this, so their results stay byte-exact. if (!process.env['CODEBURN_PARSE_BURST_MS']) process.env['CODEBURN_PARSE_BURST_MS'] = '10000' + // Event-driven reuse: while no watched session root has changed, a previous + // parse stays valid past the burst window (capped in parser.ts, so a missed + // filesystem event self-heals within minutes). This is what turns a warm + // no-change fetch into a no-op instead of a stat sweep. + let rootsQuietSince: ((sinceTs: number) => boolean) | null = null + void startRootWatchers().then(async (w) => { + const { setParseReuseValidator } = await import('./parser.js') + // Clean means: the watchers were already armed when the parse happened, + // and no filesystem event has landed since. lastEventAt of 0 is a quiet + // system (clean for anything parsed after arming), not an unknown. + const quiet = (sinceTs: number): boolean => sinceTs >= w.startedAt && w.lastEventAt() < sinceTs + rootsQuietSince = quiet + setParseReuseValidator(quiet) + }).catch(() => { /* watcherless serve still works via the burst window */ }) + + // Output-level memo: an identical panel query while the roots are quiet + // returns the previous stdout verbatim - the aggregation work is skipped + // too, not just the parse. Invalidation is the same event-or-cap rule the + // parse reuse uses. + const OUTPUT_MEMO_CAP_MS = 5 * 60 * 1000 + const outputMemo = new Map() if (process.stdin.isTTY) { process.stderr.write('codeburn serve speaks JSON over stdio and exists for the desktop app to hold warm.\nNothing interactive happens here; press Ctrl+C to exit.\n') } @@ -124,9 +183,22 @@ export async function runStdioServe(buildProgram: () => Command): Promise write({ id: request.id, ok: false, refused: true, error: 'command not served' }) return } + const memoKey = request.args.join('\u0000') + const memoHit = outputMemo.get(memoKey) + if (memoHit && Date.now() - memoHit.at < OUTPUT_MEMO_CAP_MS && rootsQuietSince?.(memoHit.at)) { + write({ id: request.id, ok: true, output: memoHit.output }) + return + } try { const { output, code } = await runCaptured(buildProgram, request.args) - if (code === 0) write({ id: request.id, ok: true, output }) + if (code === 0) { + outputMemo.set(memoKey, { at: Date.now(), output }) + if (outputMemo.size > 32) { + const oldest = [...outputMemo.entries()].sort((a, b) => a[1].at - b[1].at)[0] + if (oldest) outputMemo.delete(oldest[0]) + } + write({ id: request.id, ok: true, output }) + } else write({ id: request.id, ok: false, error: `exit ${code}`, output }) } catch (err) { write({ id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) }) diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 726049c36..4bd0c5c2e 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -13,7 +13,7 @@ import { join } from 'path' import { createRequire } from 'node:module' import { isSqliteAvailable } from '../src/sqlite.js' -import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { clearSessionCache, parseAllSessions, setParseReuseValidator } from '../src/parser.js' import { loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js' @@ -726,3 +726,44 @@ describe('(q) parse burst reuse (CODEBURN_PARSE_BURST_MS)', () => { _synthYields = [] }) }) + +describe('(r) validated parse reuse (setParseReuseValidator)', () => { + it('reuses past the burst window while the validator reports quiet, never when dirty', async () => { + vi.stubEnv('CODEBURN_PARSE_BURST_MS', '1') + clearSessionCache() + const start = new Date(Date.now() - 60 * 60 * 1000) + const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const synthFile = join(tmpHome, 'synth-validated.txt') + await writeFile(synthFile, 'placeholder') + _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'synth-model', + inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], + timestamp: ts, speed: 'standard', deduplicationKey: 'synth-val-1', userMessage: 'hi', sessionId: 'sv-1', + }] as never + + const first = await parseAllSessions({ start, end: new Date() }, 'test-synthetic') + expect(totalOutput(first)).toBe(5) + + // 1ms burst window has certainly elapsed; with a quiet validator the + // previous parse is still served (world changed, result must not). + await new Promise(r => setTimeout(r, 5)) + setParseReuseValidator(() => true) + _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-val-2', outputTokens: 7 }] as never + await writeFile(synthFile, 'placeholder v2') + const second = await parseAllSessions({ start, end: new Date(Date.now() + 500) }, 'test-synthetic') + expect(totalOutput(second)).toBe(5) + + // A dirty validator ends the reuse: fresh parse sees the new call. + setParseReuseValidator(() => false) + const third = await parseAllSessions({ start, end: new Date(Date.now() + 1000) }, 'test-synthetic') + expect(totalOutput(third)).toBe(12) + + setParseReuseValidator(null) + vi.unstubAllEnvs() + _synthSources = [] + _synthYields = [] + }) +})