fix(codex-usage): count fork-copied Codex token events once (#33478)

Codex usage no longer double-counts forked sessions: forks copy the parent's token_count events with fresh timestamps, so cumulative events are now keyed on turn id + totals (timestamp fallback when no turn id); the turn id is kept in the incremental-scan checkpoint and the usage cache is rebuilt once.

Fixes #19139
Supersedes #19152

Co-authored-by: Jio Kim <26668612+MeroZemory@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-25 01:53:58 -07:00
committed by GitHub
co-authored by Jio Kim
parent aec646fac8
commit 8e601e338a
8 changed files with 51 additions and 6 deletions
@@ -76,6 +76,7 @@ function createParseContext(
sessionCwd: resume.sessionCwd,
currentCwd: resume.currentCwd,
currentModel: resume.currentModel,
currentTurnId: resume.currentTurnId,
previousTotals: resume.previousTotals,
totalOnlyBaselinePending: false
}
@@ -29,6 +29,7 @@ export async function buildCodexRolloutResumeState(
sessionCwd: context.sessionCwd,
currentCwd: context.currentCwd,
currentModel: context.currentModel,
currentTurnId: context.currentTurnId,
previousTotals: context.previousTotals
}
}
+3 -2
View File
@@ -2,8 +2,9 @@ import type { UsageProvider } from '../usage/usage-provider-contract'
import { scanCodexUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
import type { CodexUsageDailyAggregate, CodexUsageSession } from './types'
// Rescan older caches: v5 deduplicates forks across sessions; v6 adds per-request long-context counts.
export const CODEX_USAGE_SCHEMA_VERSION = 6
// Rescan older caches: v5 deduplicates forks across sessions; v6 adds per-request long-context counts;
// v7 keys fork copies on turn id because forks rewrite copied timestamps.
export const CODEX_USAGE_SCHEMA_VERSION = 7
export const codexUsageProvider = {
id: 'codex',
@@ -19,6 +19,7 @@ export type CodexUsageParseContext = {
sessionCwd: string | null
currentCwd: string | null
currentModel: string | null
currentTurnId?: string | null
previousTotals: CodexUsageRawUsage | null
totalOnlyBaselinePending?: boolean
}
@@ -86,6 +87,7 @@ export function parseCodexUsageRecord(
}
if (parsed.type === 'turn_context') {
context.currentTurnId = extractString(parsed.payload.turn_id)
context.currentCwd =
extractString(parsed.payload.cwd) ?? context.currentCwd ?? context.sessionCwd
context.currentModel = extractModel(parsed.payload) ?? context.currentModel
@@ -149,7 +151,12 @@ export function parseCodexUsageRecord(
return {
sessionId: context.sessionId,
timestamp: parsed.timestamp,
eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage),
eventKey: buildCodexUsageEventKey(
// Fork copies rewrite timestamps but keep turn ids; last-only deltas need the timestamp to stay distinct.
totalUsage && context.currentTurnId ? context.currentTurnId : parsed.timestamp,
totalUsage,
lastUsage
),
cwd: context.currentCwd ?? context.sessionCwd,
model: resolvedModel,
hasInferredPricing,
@@ -153,7 +153,7 @@ export function resolveCodexUsageDelta(
}
export function buildCodexUsageEventKey(
timestamp: string,
recordIdentity: string,
totalUsage: CodexUsageRawUsage | null,
lastUsage: CodexUsageRawUsage | null
): string {
@@ -168,5 +168,5 @@ export function buildCodexUsageEventKey(
usage.totalTokens
].join(',')
: ''
return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|')
return [recordIdentity, tupleOf(totalUsage), tupleOf(lastUsage)].join('|')
}
@@ -464,6 +464,40 @@ describe('listCodexSessionFiles', () => {
expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true)
})
it('dedupes fork copies whose timestamps Codex rewrote to the fork time', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const meta = (id: string): string =>
`${JSON.stringify({ type: 'session_meta', payload: { id, cwd: join(fakeHomeDir, 'repo') } })}\n`
const turn = (turnId: string): string =>
`${JSON.stringify({ type: 'turn_context', payload: { turn_id: turnId, cwd: join(fakeHomeDir, 'repo') } })}\n`
const parentTurns = (at: string): string =>
[turn('turn-1'), usageRecord(at, 10), turn('turn-2'), usageRecord(at, 5, 15)].join('')
writeFileSync(
join(sessionsDir, 'aaaa-parent.jsonl'),
`${meta('parent')}${parentTurns('2026-05-25T12:00:00.000Z')}`
)
// Each fork copies the parent with rewritten timestamps, then runs an identical new turn.
for (const [name, turnId] of [
['bbbb-fork', 'turn-b'],
['cccc-fork', 'turn-c']
]) {
writeFileSync(
join(sessionsDir, `${name}.jsonl`),
`${meta(name)}${meta('parent')}${parentTurns('2026-05-27T12:00:00.000Z')}${turn(turnId)}${usageRecord('2026-05-27T12:01:00.000Z', 7, 22)}`
)
}
const result = await scanCodexUsageFiles([], [])
expect(
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(29)
expect(
result.dailyAggregates.find((aggregate) => aggregate.day === '2026-05-27')?.totalTokens
).toBe(14)
})
it('does not re-count the cumulative session for copied total-only records', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
@@ -88,7 +88,7 @@ describe('CodexUsageStore', () => {
} as unknown as CodexUsagePersistedState)
expect(normalized).toEqual({
schemaVersion: 6,
schemaVersion: CODEX_USAGE_SCHEMA_VERSION,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
+1
View File
@@ -29,6 +29,7 @@ export type CodexUsageParseResumeState = {
sessionCwd: string | null
currentCwd: string | null
currentModel: string | null
currentTurnId?: string | null
previousTotals: CodexUsageRawUsage | null
}