mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 23:45:12 +00:00
fix(codex): restore discovery fast path, duration precedence, and dashboard width
Review fixes on top of #805. Discovery: getCachedCodexProject short-circuits again without opening the file. The PR read the first line of every rollout to collect session_id, which cost +129ms on an 827-file session directory with a warm cache. Archived duplicates are now filtered by basename, which needs no file I/O. Timing: on oversized mcp_tool_call_end lines a `duration_ms` key inside invocation.arguments outranked the payload-level duration, inflating tool wait. The depth-aware payload value wins; the naive scan stays as the fallback for task_complete. Layout: MIN_WIDE goes back to 90 so 90-129 column terminals keep the two-column dashboard. The By Model panel drops the Tok/s column (and its footnote) when the panel has fewer than 61 inner columns or when no model recorded active timing, so narrow terminals do not truncate and non-Codex users get no dead column.
This commit is contained in:
parent
a6bf81f756
commit
fad846622f
5 changed files with 102 additions and 40 deletions
|
|
@ -1,5 +1,13 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added (CLI)
|
||||
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
|
||||
|
||||
### Fixed (CLI)
|
||||
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
|
||||
|
||||
## 0.9.19 - 2026-07-20
|
||||
|
||||
One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19.
|
||||
|
|
|
|||
|
|
@ -46,10 +46,9 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean,
|
|||
return historyProjectCount === 0 && !historyLoading
|
||||
}
|
||||
|
||||
// The By Model panel now carries six numeric columns. Keep panels stacked until
|
||||
// each half has enough room for those columns instead of truncating Tok/s at
|
||||
// ordinary 100–120 column terminals.
|
||||
const MIN_WIDE = 130
|
||||
// The By Model panel drops the Tok/s column when the panel is too narrow, so
|
||||
// the wider two-column layout can still activate at ordinary terminal widths.
|
||||
const MIN_WIDE = 90
|
||||
const ORANGE = '#FF8C42'
|
||||
const DIM = '#555555'
|
||||
const GOLD = '#FFD700'
|
||||
|
|
@ -198,9 +197,9 @@ function nextTick(): Promise<void> {
|
|||
return new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
|
||||
function getLayout(columns?: number): Layout {
|
||||
export function getLayout(columns?: number): Layout {
|
||||
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
|
||||
const dashWidth = Math.min(160, termWidth)
|
||||
const wide = dashWidth >= MIN_WIDE
|
||||
|
|
@ -453,6 +452,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
const modelEfficiency = aggregateModelEfficiency(projects)
|
||||
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
|
||||
const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0)
|
||||
// The Tok/s column needs 61 inner columns for the full row; hide it on narrower
|
||||
// panels and when no model has timing data (non-Codex users get no dead column).
|
||||
const showTps = pw - PANEL_CHROME >= 61 && anyActiveTiming
|
||||
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
|
||||
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
|
||||
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
|
||||
|
|
@ -464,7 +466,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
|
||||
return (
|
||||
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{'Tok/s'.padStart(MODEL_COL_TPS)}</Text>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''}</Text>
|
||||
{sorted.map(([model, data], i) => {
|
||||
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
|
||||
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
|
||||
|
|
@ -484,7 +486,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
|
||||
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
|
||||
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
|
||||
<Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>
|
||||
{showTps && <Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
|
@ -496,7 +498,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
{anyEstimated && (
|
||||
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
|
||||
)}
|
||||
{anyActiveTiming && (
|
||||
{showTps && (
|
||||
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
|
||||
)}
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -438,7 +438,11 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
invocation,
|
||||
call_id: getRawJsonStringField(pHead, 'call_id'),
|
||||
turn_id: getRawJsonStringField(pHead, 'turn_id'),
|
||||
duration_ms: timingNumber('duration_ms') ?? timingDuration,
|
||||
// On mcp_tool_call_end a coincidental `duration_ms` inside the large
|
||||
// invocation.arguments object can shadow the payload-level duration, so the
|
||||
// depth-aware value wins. The naive scan stays as the fallback for
|
||||
// task_complete, which records duration_ms at the payload level directly.
|
||||
duration_ms: timingDuration ?? timingNumber('duration_ms'),
|
||||
started_at: timingNumber('started_at'),
|
||||
info: compactInfo,
|
||||
},
|
||||
|
|
@ -453,48 +457,34 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
return entry
|
||||
}
|
||||
|
||||
type DiscoveredCodexSession = {
|
||||
source: SessionSource
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
async function discoverSessionFile(filePath: string): Promise<DiscoveredCodexSession | null> {
|
||||
async function discoverSessionFile(filePath: string): Promise<SessionSource | null> {
|
||||
const s = await stat(filePath).catch(() => null)
|
||||
if (!s?.isFile()) return null
|
||||
|
||||
// Fast path: cached results already know the project, so avoid opening the
|
||||
// file. This keeps discovery cheap on large session directories.
|
||||
const cachedProject = await getCachedCodexProject(filePath)
|
||||
const { valid, meta } = await isValidCodexSession(filePath)
|
||||
if (cachedProject) {
|
||||
return {
|
||||
source: { path: filePath, project: cachedProject, provider: 'codex' },
|
||||
sessionId: valid ? meta?.payload?.session_id : undefined,
|
||||
}
|
||||
return { path: filePath, project: cachedProject, provider: 'codex' }
|
||||
}
|
||||
|
||||
const { valid, meta } = await isValidCodexSession(filePath)
|
||||
if (!valid || !meta) return null
|
||||
|
||||
const cwd = meta.payload?.cwd ?? 'unknown'
|
||||
return {
|
||||
source: { path: filePath, project: sanitizeProject(cwd), provider: 'codex' },
|
||||
sessionId: meta.payload?.session_id,
|
||||
}
|
||||
return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' }
|
||||
}
|
||||
|
||||
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
// A rollout can exist in both roots during/after archiving. The active root
|
||||
// is scanned first, and session_id keeps the archived copy from resurfacing.
|
||||
const seenSessionIds = new Set<string>()
|
||||
// Codex archives a session by moving it from sessions/YYYY/MM/DD/ to
|
||||
// archived_sessions/, keeping the same basename. Deduplicate by basename so
|
||||
// a session does not appear twice while it exists in both roots. This avoids
|
||||
// reading every file to extract session_id and preserves the cheap cached
|
||||
// fast path.
|
||||
const seenBasenames = new Set<string>()
|
||||
const sessionsDir = join(codexDir, 'sessions')
|
||||
|
||||
const addSession = (discovered: DiscoveredCodexSession | null): void => {
|
||||
if (!discovered) return
|
||||
const sessionId = discovered.sessionId?.trim()
|
||||
if (sessionId && seenSessionIds.has(sessionId)) return
|
||||
if (sessionId) seenSessionIds.add(sessionId)
|
||||
sources.push(discovered.source)
|
||||
}
|
||||
|
||||
const years = await readdir(sessionsDir).catch(() => [] as string[])
|
||||
|
||||
for (const year of years) {
|
||||
|
|
@ -514,8 +504,10 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
|
|||
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
|
||||
const filePath = join(dayDir, file)
|
||||
addSession(await discoverSessionFile(filePath))
|
||||
if (seenBasenames.has(file)) continue
|
||||
seenBasenames.add(file)
|
||||
const source = await discoverSessionFile(join(dayDir, file))
|
||||
if (source) sources.push(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -523,11 +515,16 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
|
|||
|
||||
// Codex moves archived sessions into a flat directory. Keep them in usage
|
||||
// reports so archiving a conversation does not erase its historical usage.
|
||||
// Call-level deduplication (seenKeys) already collapses any remaining
|
||||
// archived copies, while basename dedup above prevents double discovery.
|
||||
const archivedDir = join(codexDir, 'archived_sessions')
|
||||
const archivedFiles = await readdir(archivedDir).catch(() => [] as string[])
|
||||
for (const file of archivedFiles) {
|
||||
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
|
||||
addSession(await discoverSessionFile(join(archivedDir, file)))
|
||||
if (seenBasenames.has(file)) continue
|
||||
seenBasenames.add(file)
|
||||
const source = await discoverSessionFile(join(archivedDir, file))
|
||||
if (source) sources.push(source)
|
||||
}
|
||||
|
||||
return sources
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { getDailyActivityRows, getDashboardScanRange, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
|
||||
import { getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
import { formatCost } from '../src/format.js'
|
||||
import type { ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
|
@ -248,3 +248,25 @@ describe('showEmptyState', () => {
|
|||
expect(showEmptyState(2, false, 0, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLayout - dashboard width breakpoints', () => {
|
||||
it('uses a single column at 89 columns or below', () => {
|
||||
expect(getLayout(89)).toMatchObject({ dashWidth: 89, wide: false, halfWidth: 89 })
|
||||
})
|
||||
|
||||
it('switches to two columns at 90 columns', () => {
|
||||
expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 })
|
||||
})
|
||||
|
||||
it('keeps two columns at 120 columns but the By-Model panel is too narrow for Tok/s', () => {
|
||||
// Inner panel width is halfWidth - PANEL_CHROME (4). At 120 cols halfWidth=60,
|
||||
// inner=56, below the 61-col threshold where Tok/s renders.
|
||||
expect(getLayout(120)).toMatchObject({ dashWidth: 120, wide: true, halfWidth: 60 })
|
||||
expect(getLayout(120).halfWidth - 4).toBeLessThan(61)
|
||||
})
|
||||
|
||||
it('keeps two columns and has enough room for Tok/s at 130 columns', () => {
|
||||
expect(getLayout(130)).toMatchObject({ dashWidth: 130, wide: true, halfWidth: 65 })
|
||||
expect(getLayout(130).halfWidth - 4).toBeGreaterThanOrEqual(61)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -551,6 +551,39 @@ describe('codex provider - JSONL parsing', () => {
|
|||
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('prefers payload-level duration over a nested duration_ms in large mcp_tool_call_end lines', async () => {
|
||||
// Regression guard: a naive first-match regex would pick up the
|
||||
// `duration_ms: 9999` inside invocation.arguments instead of the payload-level
|
||||
// `duration: { secs: 3 }`. The depth-aware payload scan must win.
|
||||
const largeMcpLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:05Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'mcp-duration-collision',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { duration_ms: 9999, body: 'x'.repeat(40_000) } },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-duration-collision.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp-duration-collision', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('look up the issue'),
|
||||
largeMcpLine,
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('omits active timing when recorded tool wait consumes the task duration', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue