mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
* Write session shards one member per line A shard is still one JSON object, so older builds read it unchanged, but it is written through a stream and never exists as one string. Past V8's string limit the save used to throw and the cache silently never persisted; reads of such a shard now go member by member. * Hold cached files a ranged query cannot report on as stubs A ranged load streams its in-scope shards and keeps in full only the files with a call in the range, spawn anchors, failure markers, turn-less files and WSL paths. Every other file becomes a stub: its fingerprint, bucket, PR flag, dedup keys per turn and where its member line lives. A stub stands in for its entry everywhere but the summaries it cannot contribute to: reconcile, the seenMsgIds and parser dedup pre-seeds, the query-time key replay in serve order, orphan surfacing, eviction and the emptiness checks. A stub whose file changed loads its member first, so the append or re-parse runs as before, and a wider request in the same process loads the stubs it now needs. A bucket holding stubs is rewritten as a full load would write it, copying the stubs' lines from the shard they came from; unchanged, the published shard is kept. A single-line shard from an older build is rewritten on the next save. Providers whose parse reads cached entries no report shows (hermes, quickdesk, antigravity, devin, gemini) still load in full. * Discover codex projects from a small index Discovery only needs each rollout's project, but it loaded the whole codex results file (hundreds of MB on a large corpus) on every run to get it. flushCodexCache now also writes codex-projects.v15.json, keyed by the same fingerprint, and discovery reads that. A missing index is built once from the results file. * Load the session cache once for status today and month status asks for today, then the month. A command can now declare the union of its ranges, so the first load already holds the month in full and the second request finds it there; each request keeps its own month scope and summaries. * Release the pre-lock cache view before reloading Once the refresh lock is acquired or another process completed the refresh, the snapshot loaded before the wait is never used again, but it stayed referenced while the reload read a second view. * Test the first-paint snapshot serves stubs in load order * Parse a shard whole when the range covers all its months A range that spans a shard's months needs nearly every member, and streaming it one member at a time only added allocation churn (about 200MB more RSS on report -p all over a 1GB cache). The streaming reader also reuses one read buffer instead of allocating a fresh one per chunk. * Parse a shard whole once the range reaches its oldest month The previous rule also required the range to reach past the shard's newest month, which a range ending now never does, so report -p all still streamed every shard. * Carry a shard line that spans reads as decoded text Members of a megabyte and more span the 1MB reads, and joining their pieces as Buffers allocated off-heap memory that only a GC returns: report -p all over a 1GB cache peaked about 170MB above main in RSS. Decoding the pieces with a StringDecoder keeps them on the heap and brings every streamed range below main, so the whole-file parse for wide ranges goes again. * Changelog: bounded session-cache load
140 lines
7.4 KiB
TypeScript
140 lines
7.4 KiB
TypeScript
// End to end through parseProviderSources: a ranged parse holds out-of-range
|
|
// rollouts as stubs, and a stub must still do everything its entry would
|
|
// outside the summaries (claim its dedup keys in serve order, be evicted from
|
|
// disk when its source is deleted). Codex captures CODEX_HOME when its module
|
|
// is first imported, hence the env before the dynamic imports.
|
|
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
import { readFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
import type { DateRange } from '../src/types.js'
|
|
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'bounded-load-'))
|
|
const savedCodexHome = process.env['CODEX_HOME']
|
|
process.env['CODEX_HOME'] = join(tmpDir, 'codex')
|
|
|
|
const { cacheStubs, clearLoadCacheMemo, loadCache, markCacheDirty, monthScopeForRange, saveCache, sessionCacheDir } = await import('../src/session-cache.js')
|
|
const { clearSessionCache, parseAllSessions, withColdFirstPaintFloor } = await import('../src/parser.js')
|
|
const { clearCodexMemCaches } = await import('../src/codex-cache.js')
|
|
|
|
const ENV_KEYS = ['CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'CODEBURN_CACHE_SCOPE'] as const
|
|
const saved = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
|
let run = 0
|
|
|
|
function rollout(day: string, sid: string, startIso: string, n: number): string {
|
|
const dir = join(tmpDir, 'codex', 'sessions', '2099', day.slice(0, 2), day.slice(3))
|
|
mkdirSync(dir, { recursive: true })
|
|
const path = join(dir, `rollout-2099-${day}T10-00-00-${sid}.jsonl`)
|
|
const lines = [
|
|
{ type: 'session_meta', timestamp: startIso, payload: { id: sid, session_id: sid, cwd: `/work/${sid}`, originator: 'codex_cli_rs', model: 'gpt-5.3-codex', cli_version: '0.50.0' } },
|
|
{ type: 'turn_context', timestamp: startIso, payload: { cwd: `/work/${sid}`, model: 'gpt-5.3-codex' } },
|
|
]
|
|
const total = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: 0 }
|
|
for (let i = 0; i < n; i++) {
|
|
const ts = new Date(Date.parse(startIso) + i * 60_000).toISOString()
|
|
const last = { input_tokens: 1000 + i, cached_input_tokens: 100, output_tokens: 50 + i, reasoning_output_tokens: 0, total_tokens: 1050 + 2 * i }
|
|
for (const k of Object.keys(total) as Array<keyof typeof total>) total[k] += last[k]
|
|
lines.push({ type: 'response_item', timestamp: ts, payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `step ${i}` }] } } as never)
|
|
lines.push({ type: 'event_msg', timestamp: ts, payload: { type: 'token_count', info: { last_token_usage: last, total_token_usage: { ...total } } } } as never)
|
|
}
|
|
writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n')
|
|
return path
|
|
}
|
|
|
|
const may1: DateRange = { start: new Date('2099-05-01T00:00:00.000Z'), end: new Date('2099-05-01T23:59:59.999Z') }
|
|
const calls = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number => projects.reduce((n, p) => n + p.totalApiCalls, 0)
|
|
const cost = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number => projects.reduce((n, p) => n + p.totalCostUSD, 0)
|
|
|
|
beforeEach(() => {
|
|
rmSync(join(tmpDir, 'codex'), { recursive: true, force: true })
|
|
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, `cache-${run++}`)
|
|
process.env['CLAUDE_CONFIG_DIR'] = join(tmpDir, 'no-claude')
|
|
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'no-desktop')
|
|
delete process.env['CLAUDE_CONFIG_DIRS']
|
|
delete process.env['CODEBURN_CACHE_SCOPE']
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
clearCodexMemCaches()
|
|
})
|
|
|
|
afterAll(() => {
|
|
for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k] }
|
|
if (savedCodexHome === undefined) delete process.env['CODEX_HOME']
|
|
else process.env['CODEX_HOME'] = savedCodexHome
|
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
async function reparse(range: DateRange, scope?: 'all'): Promise<Awaited<ReturnType<typeof parseAllSessions>>> {
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
clearCodexMemCaches()
|
|
if (scope) process.env['CODEBURN_CACHE_SCOPE'] = scope
|
|
else delete process.env['CODEBURN_CACHE_SCOPE']
|
|
return parseAllSessions(range, 'codex')
|
|
}
|
|
|
|
describe('bounded load through the provider parse', () => {
|
|
it('replays a stub\'s dedup keys in serve order, exactly as its full entry would', async () => {
|
|
const old = rollout('04-10', 'aaold', '2099-04-10T10:00:00.000Z', 3)
|
|
const today = rollout('05-01', 'bbtoday', '2099-05-01T10:00:00.000Z', 2)
|
|
await parseAllSessions(undefined, 'codex')
|
|
|
|
// Give the in-range file a turn that carries one of the old file's keys, as
|
|
// a file parsed before the other was cached can.
|
|
clearLoadCacheMemo()
|
|
const full = await loadCache()
|
|
const section = full.providers['codex']!
|
|
const oldKey = section.files[old]!.turns[0]!.calls[0]!.deduplicationKey
|
|
section.files[today]!.turns[1]!.calls[0]!.deduplicationKey = oldKey
|
|
markCacheDirty(full, 'codex', today)
|
|
expect(await saveCache(full)).toBe(true)
|
|
|
|
clearLoadCacheMemo()
|
|
const scoped = await loadCache(monthScopeForRange(may1.start, may1.end))
|
|
expect([...cacheStubs(scoped, 'codex')!.keys()]).toEqual([old])
|
|
|
|
const bounded = await reparse(may1)
|
|
const reference = await reparse(may1, 'all')
|
|
expect(calls(bounded)).toBe(1)
|
|
expect(calls(bounded)).toBe(calls(reference))
|
|
expect(cost(bounded)).toBe(cost(reference))
|
|
|
|
// The first-paint snapshot serves every cached entry as an orphan, in load
|
|
// order: the stub has to take its full entry's place in that order too.
|
|
const snapshot = async (scope?: 'all') => {
|
|
clearSessionCache(); clearLoadCacheMemo(); clearCodexMemCaches()
|
|
if (scope) process.env['CODEBURN_CACHE_SCOPE'] = scope
|
|
else delete process.env['CODEBURN_CACHE_SCOPE']
|
|
return (await withColdFirstPaintFloor(may1.start, () => parseAllSessions(may1, 'codex'), true, true)).result
|
|
}
|
|
const boundedSnapshot = await snapshot()
|
|
expect(calls(boundedSnapshot)).toBe(1)
|
|
expect(JSON.stringify(boundedSnapshot)).toBe(JSON.stringify(await snapshot('all')))
|
|
})
|
|
|
|
it('evicts a deleted transcript that was only a stub from its shard on disk', async () => {
|
|
const doomed = rollout('04-10', 'aadoomed', '2099-04-10T10:00:00.000Z', 2)
|
|
const kept = rollout('04-12', 'cckept', '2099-04-12T10:00:00.000Z', 2)
|
|
rollout('05-01', 'bbtoday', '2099-05-01T10:00:00.000Z', 2)
|
|
await parseAllSessions(undefined, 'codex')
|
|
const aprilShard = async (): Promise<string> => {
|
|
const envelope = JSON.parse(await readFile(join(sessionCacheDir(), 'envelope.json'), 'utf-8'))
|
|
return readFile(join(sessionCacheDir(), envelope.providers.codex.shards['2099-04'].name), 'utf-8')
|
|
}
|
|
const before = await aprilShard()
|
|
const line = (text: string, path: string) => text.split('\n').find(l => l.startsWith(JSON.stringify(path) + ':'))?.replace(/,$/, '')
|
|
expect(line(before, doomed)).toBeDefined()
|
|
|
|
clearLoadCacheMemo()
|
|
const scoped = await loadCache(monthScopeForRange(may1.start, may1.end))
|
|
expect([...cacheStubs(scoped, 'codex')!.keys()].sort()).toEqual([doomed, kept].sort())
|
|
|
|
rmSync(doomed)
|
|
await reparse(may1)
|
|
const after = await aprilShard()
|
|
expect(after).not.toContain(doomed)
|
|
expect(line(after, kept)).toBe(line(before, kept))
|
|
expect(Object.keys(JSON.parse(after))).toEqual([kept])
|
|
})
|
|
})
|