diff --git a/src/main/codex-usage/codex-rollout-file-parse.ts b/src/main/codex-usage/codex-rollout-file-parse.ts index a7364945bd3..30dab4b2b71 100644 --- a/src/main/codex-usage/codex-rollout-file-parse.ts +++ b/src/main/codex-usage/codex-rollout-file-parse.ts @@ -76,6 +76,7 @@ function createParseContext( sessionCwd: resume.sessionCwd, currentCwd: resume.currentCwd, currentModel: resume.currentModel, + currentTurnId: resume.currentTurnId, previousTotals: resume.previousTotals, totalOnlyBaselinePending: false } diff --git a/src/main/codex-usage/codex-rollout-resume-state.ts b/src/main/codex-usage/codex-rollout-resume-state.ts index b531ae74c9c..e6592a97d60 100644 --- a/src/main/codex-usage/codex-rollout-resume-state.ts +++ b/src/main/codex-usage/codex-rollout-resume-state.ts @@ -29,6 +29,7 @@ export async function buildCodexRolloutResumeState( sessionCwd: context.sessionCwd, currentCwd: context.currentCwd, currentModel: context.currentModel, + currentTurnId: context.currentTurnId, previousTotals: context.previousTotals } } diff --git a/src/main/codex-usage/codex-usage-provider.ts b/src/main/codex-usage/codex-usage-provider.ts index c47c7ecef86..ab8d0f4e7bc 100644 --- a/src/main/codex-usage/codex-usage-provider.ts +++ b/src/main/codex-usage/codex-usage-provider.ts @@ -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', diff --git a/src/main/codex-usage/codex-usage-record-parser.ts b/src/main/codex-usage/codex-usage-record-parser.ts index e4f63c82877..bd0de5b94cd 100644 --- a/src/main/codex-usage/codex-usage-record-parser.ts +++ b/src/main/codex-usage/codex-usage-record-parser.ts @@ -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, diff --git a/src/main/codex-usage/codex-usage-token-delta.ts b/src/main/codex-usage/codex-usage-token-delta.ts index d503df77982..9d391661bf9 100644 --- a/src/main/codex-usage/codex-usage-token-delta.ts +++ b/src/main/codex-usage/codex-usage-token-delta.ts @@ -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('|') } diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts index dc99838dd1a..0378e736abd 100644 --- a/src/main/codex-usage/scanner-paths.test.ts +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -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 }) diff --git a/src/main/codex-usage/store-persistence.test.ts b/src/main/codex-usage/store-persistence.test.ts index 311d4c8414f..474a2b68440 100644 --- a/src/main/codex-usage/store-persistence.test.ts +++ b/src/main/codex-usage/store-persistence.test.ts @@ -88,7 +88,7 @@ describe('CodexUsageStore', () => { } as unknown as CodexUsagePersistedState) expect(normalized).toEqual({ - schemaVersion: 6, + schemaVersion: CODEX_USAGE_SCHEMA_VERSION, worktreeFingerprint: null, processedFiles: [], sessions: [], diff --git a/src/main/codex-usage/types.ts b/src/main/codex-usage/types.ts index afc825f0c62..8787d4ea742 100644 --- a/src/main/codex-usage/types.ts +++ b/src/main/codex-usage/types.ts @@ -29,6 +29,7 @@ export type CodexUsageParseResumeState = { sessionCwd: string | null currentCwd: string | null currentModel: string | null + currentTurnId?: string | null previousTotals: CodexUsageRawUsage | null }