mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 23:45:12 +00:00
The second half of #940 (@ozymandiashh): upstream `main` measures Codex
throughput — a task's wall time minus its recorded tool wait, divided across
the task's calls by generated tokens — and none of it exists on this branch.
Added on one side only, so a `main` merge would land `src/codex-throughput.ts`
at a path npm workspaces does not build.
Rehomed against main as it ships today, with one deliberate divergence from
#940, which the maintainer decided: main's resume design wins.
- Timing state is captured ONLY at a `task_started` boundary, where every
per-task accumulator is provably empty, and a task's calls are buffered
until its window is known. No recorded call is ever mutated after it has
been handed to the host. #940's alternative — threading the open task
window through the serialized state and back-patching earlier-pass calls
via applyCodexTimingPatches — is dropped in full.
- The branch's Phase-4 token-decode resume is untouched: it stays any-offset
and round-trip proven. Marrying the two needed one adaptation, since core
decodes records and never sees bytes: the decoder now reports its last
task_started as a `checkpoint` (record index + call count + state), and the
CLI turns that index into a byte offset and replays only the calls before
it, letting the still-open task re-derive. A pass that crosses no boundary
keeps the previous one; a cold decode of a file with no task_started at all
falls back to end-of-file with `taskOpen: false`, so a task_complete whose
window this pass never saw attributes nothing rather than spreading a whole
task's active time over part of its tokens.
Restores the three fad84662 review fixes that #940 reverted: the discovery
fast path already short-circuits on cachedProject before isValidCodexSession
(unchanged here, verified); payload-level `duration_ms` outranks any nested one
(`timingDuration ?? timingNumber('duration_ms')`), so a duration buried in an
oversized mcp_tool_call_end's invocation.arguments can no longer inflate tool
wait; and MIN_WIDE stays 90 with the Tok/s column behind a showTps gate rather
than jumping to 130 and costing 90-129 column terminals their two-column
dashboard. Also ports the fork-suppressed-task_started regression test and the
depth-1 payloadString helper (main 1d36f444/497f6556), which the branch lacked.
Scope discipline: main's codex pricing work (billableOutputTokens, #1078) is
NOT dragged along — that is #1083 — and neither are its unported parser
changes (custom-tool transport, exact token counts and MCP names on oversized
lines), so cost, calls and tokens are untouched. Verified on a 1326-session
real corpus: codex totals byte-identical to the base branch, with 1302 of 1328
model slices now carrying timing (36.5 Tok/s on GPT-5.5).
CODEX_CACHE_VERSION takes 12, clear of main's ladder (11 as of #1078) so a
cache written by either line can never be read as current by the other, and
the codex parse version bumps in lockstep so session-cache.json cannot keep
serving timing-less turns without invoking the parser.
110 lines
5.3 KiB
TypeScript
110 lines
5.3 KiB
TypeScript
// Regression for the stale-cache path of the codex active-timing port (same
|
|
// class as #478/#618). Two caches serve a codex session without ever invoking
|
|
// the decoder: session-cache.json (per provider, gated by envFingerprint) and
|
|
// codex-results.json (per file, gated by CODEX_CACHE_VERSION). A user upgrading
|
|
// into this change has both warm and both timing-less, so unless BOTH gates
|
|
// move, the dashboard's Tok/s column stays empty on every unchanged session
|
|
// forever. This drives the full parseAllSessions pipeline against caches seeded
|
|
// exactly as the pre-change release left them.
|
|
//
|
|
// Revert-proof: drop the `-active-timing-v1` suffix from the codex entry in
|
|
// PROVIDER_PARSE_VERSIONS and the seeded fingerprint matches again, the stale
|
|
// section is served, and the assertion below fails.
|
|
|
|
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
|
|
import { mkdir, rm, readFile, writeFile } from 'fs/promises'
|
|
import { createHash } from 'crypto'
|
|
import { join } from 'path'
|
|
|
|
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
|
import { sessionCachePath } from '../src/session-cache.js'
|
|
|
|
const testRoot = vi.hoisted(() => {
|
|
const root = `${process.env['TMPDIR'] || '/tmp'}/codex-timing-stale-${process.pid}-${Date.now()}`
|
|
process.env['HOME'] = `${root}/home`
|
|
process.env['USERPROFILE'] = `${root}/home`
|
|
process.env['CODEX_HOME'] = `${root}/codex`
|
|
return root
|
|
})
|
|
|
|
const CODEX_HOME = join(testRoot, 'codex')
|
|
const CACHE_DIR = join(testRoot, 'cache')
|
|
|
|
// computeEnvFingerprint('codex') as the pre-change release computed it: the
|
|
// same CODEX_HOME, the same parse version minus this change's suffix.
|
|
const PRE_CHANGE_PARSE_VERSION = 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1'
|
|
|
|
function preChangeFingerprint(): string {
|
|
const parts = [`CODEX_HOME=${CODEX_HOME}`, `parser=${PRE_CHANGE_PARSE_VERSION}`]
|
|
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
process.env['HOME'] = join(testRoot, 'home')
|
|
process.env['USERPROFILE'] = join(testRoot, 'home')
|
|
process.env['CODEX_HOME'] = CODEX_HOME
|
|
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await rm(testRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
function timingTotals(projects: Awaited<ReturnType<typeof parseAllSessions>>): number[] {
|
|
return projects.flatMap(p => p.sessions.flatMap(s => Object.values(s.modelBreakdown).map(m => m.activeDurationMs ?? 0)))
|
|
}
|
|
|
|
describe('codex active-timing invalidates both stale caches', () => {
|
|
it('re-parses an unchanged codex file cached by the pre-change release', async () => {
|
|
const sessionDir = join(CODEX_HOME, 'sessions', '2026', '04', '14')
|
|
await mkdir(sessionDir, { recursive: true })
|
|
await mkdir(CACHE_DIR, { recursive: true })
|
|
const lines = [
|
|
JSON.stringify({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { session_id: 'sess-timing-stale', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }),
|
|
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
|
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:01Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run it' }] } }),
|
|
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:08Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 300, output_tokens: 100 }, total_token_usage: { total_tokens: 400 } } } }),
|
|
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
|
]
|
|
await writeFile(join(sessionDir, 'rollout-timing-stale.jsonl'), lines.join('\n') + '\n')
|
|
|
|
// Run 1: cold cache, current code. Timing present (sanity).
|
|
clearSessionCache()
|
|
const fresh = await parseAllSessions(undefined, 'codex')
|
|
expect(timingTotals(fresh)).toEqual([10_000])
|
|
|
|
// Rewrite both caches as the pre-change release left them: the old provider
|
|
// envFingerprint, the old codex-results version, and cached calls/turns
|
|
// with no timing at all. The rollout file itself is untouched, so nothing
|
|
// but the two version gates can trigger a re-parse.
|
|
const cachePath = sessionCachePath()
|
|
const cache = JSON.parse(await readFile(cachePath, 'utf8'))
|
|
cache.providers.codex.envFingerprint = preChangeFingerprint()
|
|
for (const f of Object.values(cache.providers.codex.files) as any[]) {
|
|
for (const turn of f.turns) {
|
|
for (const call of turn.calls) {
|
|
delete call.activeDurationMs
|
|
delete call.activeGeneratedTokens
|
|
delete call.toolWaitMs
|
|
}
|
|
}
|
|
}
|
|
await writeFile(cachePath, JSON.stringify(cache))
|
|
|
|
const codexCachePath = join(CACHE_DIR, 'codex-results.json')
|
|
const codexCache = JSON.parse(await readFile(codexCachePath, 'utf8'))
|
|
codexCache.version = 8
|
|
for (const f of Object.values(codexCache.files) as any[]) {
|
|
for (const call of f.calls ?? []) {
|
|
delete call.activeDurationMs
|
|
delete call.activeGeneratedTokens
|
|
delete call.toolWaitMs
|
|
}
|
|
}
|
|
await writeFile(codexCachePath, JSON.stringify(codexCache))
|
|
|
|
clearSessionCache()
|
|
const second = await parseAllSessions(undefined, 'codex')
|
|
expect(timingTotals(second)).toEqual([10_000])
|
|
})
|
|
})
|