From 3e4f35a8fe828906163a9724de606775c729f736 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Wed, 22 Jul 2026 07:34:10 +0700 Subject: [PATCH] fix(codex): one-time rebuild of fork-inflated Codex aggregates (#75) The parser fix (PR #77) only corrects go-forward parsing. Files ingested before it sit at offset = EOF and are never re-read, so their fork-inflated contribution (up to ~40x Cache Read on the local corpus) stays baked into the persisted aggregates and the dashboard. This adds the one-time rebuild that retracts that inflated history and forces a clean re-parse. migrateCodexForkOvercountRebuild (run-once via a migrations sentinel, modeled on migrateRolloutCumulativeDeltaBuckets, scoped to source=codex/every-code so no other provider is touched): - Drops the per-file cursors so the same-run parseRolloutIncremental re-parses from offset 0 with the fixed accounting (and clears the obsolete codexFork field). - Retracts the inflated hourly buckets: deletes them from the accumulator and appends zero rows to queue.jsonl. The dashboard keep-lasts per source|model|hour_start, so the re-parse's corrected row supersedes the zero, or the zero stands for a file no longer on disk. - Resets the project accumulator buckets WITHOUT writing zero rows. Files still on disk rebuild and their corrected project row wins via keep-last; a bucket whose file was deleted or whose repo moved keeps its prior queue value rather than being zeroed. Unlike the hourly path (whose orphans were already retracted by the earlier delta migration), project aggregates were never retracted before, so a zero here would be a brand-new, unrecoverable loss. Local-only: no network. The acceptance test runs cmdSync with a rejecting fetch stub and asserts zero network calls, that the seeded 8.2M-token inflated codex bucket becomes child-only (cached 80 / total 130) on the queue.jsonl read path, and that a non-codex row is byte-identical. Additional tests cover the direct migration (codex reset, other providers untouched), orphaned project buckets (queue row preserved, no loss), and sentinel idempotency. Refs #75 Co-authored-by: itarun.p --- src/commands/sync.js | 134 +++++++++ test/sync-codex-fork-migration.test.js | 397 +++++++++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 test/sync-codex-fork-migration.test.js diff --git a/src/commands/sync.js b/src/commands/sync.js index 489356cc..86709304 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -73,6 +73,13 @@ const { resolveTrackerPaths } = require("../lib/tracker-paths"); const CURSOR_UNKNOWN_MIGRATION_KEY = "cursorUnknownPurge_2026_04"; const ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY = "rolloutCumulativeDeltaReparse_2026_05"; +// #75: rebuild Codex/every-code aggregates that were accumulated with the +// pre-fix parser, which counted replayed subagent-fork parent history as child +// consumption (up to ~40x Cache Read inflation on the local fork corpus). The +// rolloutCumulativeDeltaReparse migration above already rebuilt these once, but +// with the still-buggy parser, so a fresh key is required to rebuild again now +// that the parser is fixed. +const CODEX_FORK_OVERCOUNT_MIGRATION_KEY = "codexForkOvercountRebuild_2026_07"; const CLAUDE_MEM_OBSERVER_REINCLUDE_KEY = "claudeMemObserverReinclude_2026_05_v3"; const GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY = "grokAppendOnlyRepair_2026_05_v4"; const CLAUDE_MEM_OBSERVER_PATH_SEGMENT = "--claude-mem-observer-sessions"; @@ -188,6 +195,7 @@ async function cmdSync(argv) { } await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles }); + await migrateCodexForkOvercountRebuild({ cursors, queuePath, projectQueuePath, rolloutFiles }); const openclawFiles = openclawSignal?.sessionFile ? [{ path: openclawSignal.sessionFile, source: "openclaw" }] @@ -1020,10 +1028,12 @@ module.exports = { cmdSync, migrateCursorUnknownBuckets, migrateRolloutCumulativeDeltaBuckets, + migrateCodexForkOvercountRebuild, reincludeClaudeMemObserverFiles, repairGrokQueueFromSessionSnapshots, CURSOR_UNKNOWN_MIGRATION_KEY, ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY, + CODEX_FORK_OVERCOUNT_MIGRATION_KEY, CLAUDE_MEM_OBSERVER_REINCLUDE_KEY, GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY, }; @@ -1695,6 +1705,130 @@ async function migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rollou cursors.migrations[ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY] = new Date().toISOString(); } +// One-time rebuild for issue #75. The prior parser counted a Codex subagent +// fork's replayed parent token_count history as the child's consumption. After +// the parser fix (PR #77) that only corrects go-forward parsing — already- +// ingested files sit at offset = EOF and are never re-read — so their inflated +// contribution stays baked into cursors.hourly / project aggregates and the +// dashboard until this migration retracts it and forces a clean re-parse. +// +// Choreography mirrors migrateRolloutCumulativeDeltaBuckets (codex-scoped, so +// no other provider is touched): (1) drop the per-file cursors so the same-run +// sync re-parses from offset 0 with the fixed accounting; (2) delete the +// inflated hourly buckets and append zero rows to queue.jsonl — the dashboard +// keep-lasts per source|model|hour_start (local-api.js readQueueData), so the +// re-parse's corrected row supersedes the zero, or the zero stands for a file +// no longer on disk; (3) do the same for project aggregates, which are on by +// default (sync passes projectQueuePath) and would otherwise double-count on +// re-parse — readProjectQueueData also keep-lasts per project_key|source| +// hour_start. Run-once via the migrations sentinel (the issue asks for a +// one-time rebuild, not a repeatable command). +// +// Project aggregates are handled conservatively: the accumulator buckets are +// reset (so files still on disk rebuild clean and their corrected row wins via +// keep-last), but no zero rows are written. A project bucket whose file was +// deleted or whose repo moved keeps its prior queue row rather than being +// zeroed — we never destroy project history we can no longer recompute. Main- +// dashboard (source) totals are always rebuilt regardless of project +// resolution. See step (3). +async function migrateCodexForkOvercountRebuild({ + cursors, + queuePath, + projectQueuePath, + rolloutFiles, +}) { + if (!cursors || typeof cursors !== "object") return; + cursors.migrations = cursors.migrations || {}; + if (cursors.migrations[CODEX_FORK_OVERCOUNT_MIGRATION_KEY]) return; + + const rolloutPaths = new Set(); + for (const entry of Array.isArray(rolloutFiles) ? rolloutFiles : []) { + const filePath = typeof entry === "string" ? entry : entry?.path; + const source = typeof entry === "string" ? "codex" : String(entry?.source || "codex"); + if (filePath && (source === "codex" || source === "every-code")) { + rolloutPaths.add(filePath); + } + } + + // (1) Drop per-file cursors -> clean re-parse from offset 0 with the fix. + if (cursors.files && typeof cursors.files === "object") { + for (const filePath of rolloutPaths) delete cursors.files[filePath]; + } + + // (2) Retract + delete inflated hourly codex/every-code buckets. + const hourlyRetractions = []; + const buckets = cursors.hourly?.buckets; + if (buckets && typeof buckets === "object") { + for (const key of Object.keys(buckets)) { + const [source, model, ...hourParts] = key.split("|"); + if (source !== "codex" && source !== "every-code") continue; + hourlyRetractions.push( + JSON.stringify({ + source, + model: model || "unknown", + hour_start: hourParts.join("|"), + input_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 0, + billable_total_tokens: 0, + conversation_count: 0, + }), + ); + delete buckets[key]; + } + } + const groupQueued = cursors.hourly?.groupQueued; + if (groupQueued && typeof groupQueued === "object") { + for (const key of Object.keys(groupQueued)) { + if (key.startsWith("codex|") || key.startsWith("every-code|")) { + delete groupQueued[key]; + } + } + } + if (hourlyRetractions.length > 0) { + await ensureDir(path.dirname(queuePath)); + await fs.appendFile(queuePath, hourlyRetractions.join("\n") + "\n"); + } + + // (3) Reset the inflated project codex/every-code accumulator buckets, but + // do NOT append zero rows to project.queue.jsonl. Deleting the in-memory + // bucket is what prevents the double-count: the same-run re-parse of a file + // still on disk starts that project bucket fresh from zero and appends a + // corrected row, which the keep-last project reader (local-api.js + // readProjectQueueData, per project_key|source|hour_start) picks over the old + // inflated row. A bucket whose file has since been deleted or whose repo + // moved is NOT re-parsed; leaving its prior queue row in place keeps that + // (possibly fork-inflated) historical value visible rather than zeroing data + // we can no longer recompute. Unlike the hourly path, project aggregates were + // never retracted by the earlier delta migration, so a zero here would be a + // brand-new, unrecoverable loss for orphaned files — hence the conservative + // reset-accumulator-only approach. + let projectReset = 0; + const projectBuckets = cursors.projectHourly?.buckets; + if (projectQueuePath && projectBuckets && typeof projectBuckets === "object") { + for (const key of Object.keys(projectBuckets)) { + const source = projectBuckets[key]?.source; + if (source !== "codex" && source !== "every-code") continue; + delete projectBuckets[key]; + projectReset += 1; + } + } + + if (process.env.TOKENTRACKER_DEBUG) { + process.stderr.write( + `[codex-fork] #75 rebuild: reset ${rolloutPaths.size} codex/every-code file ` + + `cursors, retracted ${hourlyRetractions.length} hourly bucket(s), reset ` + + `${projectReset} project accumulator bucket(s) (no project rows zeroed); ` + + `this sync re-parses files still on disk with the corrected accounting\n`, + ); + } + + cursors.migrations[CODEX_FORK_OVERCOUNT_MIGRATION_KEY] = new Date().toISOString(); +} + // One-time repair migration: rebuild source=claude rows in queue.jsonl from // the actual jsonl files using ccusage's algorithm (msgId+reqId global // dedup). Earlier `reincludeClaudeMemObserverFiles` versions (v1/v2/v3) each diff --git a/test/sync-codex-fork-migration.test.js b/test/sync-codex-fork-migration.test.js new file mode 100644 index 00000000..7fa070cb --- /dev/null +++ b/test/sync-codex-fork-migration.test.js @@ -0,0 +1,397 @@ +// Acceptance tests for the issue #75 one-time rebuild migration +// (migrateCodexForkOvercountRebuild). The parser fix (PR #77) only corrects +// go-forward parsing; this migration retracts the already-inflated Codex/ +// every-code aggregates and forces a clean re-parse so the dashboard read path +// (queue.jsonl / project.queue.jsonl, keep-last per bucket) shows corrected +// numbers. Everything here is strictly local — no network, no cloud. + +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { + cmdSync, + migrateCodexForkOvercountRebuild, + CODEX_FORK_OVERCOUNT_MIGRATION_KEY, + ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY, +} = require("../src/commands/sync"); + +async function makeTempDir() { + return await fs.mkdtemp(path.join(os.tmpdir(), "tt-fork-mig-")); +} + +// keep-last per source|model|hour_start — mirrors local-api.js readQueueData. +function keepLastQueue(text) { + const seen = new Map(); + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const r = JSON.parse(line); + seen.set(`${r.source}|${r.model}|${r.hour_start}`, r); + } + return Array.from(seen.values()); +} +// keep-last per project_key|source|hour_start — mirrors readProjectQueueData. +function keepLastProjectQueue(text) { + const seen = new Map(); + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const r = JSON.parse(line); + seen.set(`${r.project_key || ""}|${r.source || ""}|${r.hour_start || ""}`, r); + } + return Array.from(seen.values()); +} + +// --- unit: direct migration call ------------------------------------------ + +describe("migrateCodexForkOvercountRebuild", () => { + it("retracts codex/every-code hourly + project buckets and file cursors, leaves other providers untouched", async () => { + const dir = await makeTempDir(); + try { + const queuePath = path.join(dir, "queue.jsonl"); + const projectQueuePath = path.join(dir, "project.queue.jsonl"); + const codexPath = path.join(dir, ".codex", "sessions", "rollout-codex.jsonl"); + const everyCodePath = path.join(dir, ".code", "sessions", "rollout-every.jsonl"); + const claudePath = path.join(dir, ".claude", "projects", "session.jsonl"); + + const cursors = { + files: { + [codexPath]: { offset: 100, codexFork: "child" }, + [everyCodePath]: { offset: 200 }, + [claudePath]: { offset: 300 }, + }, + hourly: { + groupQueued: { + "codex|2026-07-01T00:00:00.000Z": "old-codex", + "every-code|2026-07-01T00:30:00.000Z": "old-every", + "claude|2026-07-01T00:00:00.000Z": "old-claude", + }, + buckets: { + "codex|gpt-5.5|2026-07-01T00:00:00.000Z": { totals: { total_tokens: 9_000_000, cached_input_tokens: 8_000_000 } }, + "every-code|gpt-5.5|2026-07-01T00:30:00.000Z": { totals: { total_tokens: 50 } }, + "claude|opus|2026-07-01T00:00:00.000Z": { totals: { total_tokens: 25 } }, + }, + }, + projectHourly: { + buckets: { + "proj-a|codex|2026-07-01T00:00:00.000Z": { + totals: { total_tokens: 9_000_000, cached_input_tokens: 8_000_000 }, + project_ref: "github.com/x/a", + project_key: "proj-a", + source: "codex", + hour_start: "2026-07-01T00:00:00.000Z", + }, + "proj-b|claude|2026-07-01T00:00:00.000Z": { + totals: { total_tokens: 25 }, + project_ref: "github.com/x/b", + project_key: "proj-b", + source: "claude", + hour_start: "2026-07-01T00:00:00.000Z", + }, + }, + }, + }; + + await migrateCodexForkOvercountRebuild({ + cursors, + queuePath, + projectQueuePath, + rolloutFiles: [ + { path: codexPath, source: "codex" }, + { path: everyCodePath, source: "every-code" }, + ], + }); + + // file cursors: codex/every-code dropped, claude kept. + assert.equal(cursors.files[codexPath], undefined); + assert.equal(cursors.files[everyCodePath], undefined); + assert.equal(cursors.files[claudePath].offset, 300); + + // hourly buckets: codex/every-code deleted, claude untouched. + assert.equal(cursors.hourly.buckets["codex|gpt-5.5|2026-07-01T00:00:00.000Z"], undefined); + assert.equal(cursors.hourly.buckets["every-code|gpt-5.5|2026-07-01T00:30:00.000Z"], undefined); + assert.ok(cursors.hourly.buckets["claude|opus|2026-07-01T00:00:00.000Z"]); + assert.equal(cursors.hourly.buckets["claude|opus|2026-07-01T00:00:00.000Z"].totals.total_tokens, 25); + + // groupQueued: codex/every-code cleared, claude kept. + assert.equal(cursors.hourly.groupQueued["codex|2026-07-01T00:00:00.000Z"], undefined); + assert.equal(cursors.hourly.groupQueued["every-code|2026-07-01T00:30:00.000Z"], undefined); + assert.equal(cursors.hourly.groupQueued["claude|2026-07-01T00:00:00.000Z"], "old-claude"); + + // project accumulator buckets: codex reset, claude untouched. + assert.equal(cursors.projectHourly.buckets["proj-a|codex|2026-07-01T00:00:00.000Z"], undefined); + assert.ok(cursors.projectHourly.buckets["proj-b|claude|2026-07-01T00:00:00.000Z"]); + + assert.ok(cursors.migrations[CODEX_FORK_OVERCOUNT_MIGRATION_KEY]); + + // queue.jsonl: exactly two zero-retraction rows (codex + every-code). + const qRows = keepLastQueue(await fs.readFile(queuePath, "utf8")); + assert.deepEqual( + qRows.map((r) => `${r.source}|${r.model}|${r.total_tokens}|${r.cached_input_tokens}`).sort(), + ["codex|gpt-5.5|0|0", "every-code|gpt-5.5|0|0"], + ); + + // project.queue.jsonl is NOT written — the migration never zeroes project + // rows (conservative: files still on disk rebuild via re-parse; orphaned + // ones keep their prior value rather than losing it). No file created. + assert.equal( + await fs.access(projectQueuePath).then(() => true).catch(() => false), + false, + "migration must not write project.queue.jsonl", + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("does not zero an orphaned codex project bucket — its queue row is preserved (no loss)", async () => { + const dir = await makeTempDir(); + try { + const queuePath = path.join(dir, "queue.jsonl"); + const projectQueuePath = path.join(dir, "project.queue.jsonl"); + // A codex project bucket whose session file is no longer on disk (absent + // from rolloutFiles) — the loss-mode the review flagged. Its prior queue + // row (possibly fork-inflated) must survive rather than be zeroed, since + // it can no longer be recomputed. + const orphanRow = { + project_ref: "github.com/x/orphan", + project_key: "proj-orphan", + source: "codex", + hour_start: "2026-07-01T00:00:00.000Z", + input_tokens: 500, + cached_input_tokens: 9_000_000, + cache_creation_input_tokens: 0, + output_tokens: 40, + reasoning_output_tokens: 0, + total_tokens: 9_000_540, + billable_total_tokens: 9_000_540, + conversation_count: 3, + }; + const geminiRow = { ...orphanRow, project_key: "proj-g", project_ref: "github.com/x/g", source: "gemini", total_tokens: 7 }; + const existingQueue = [JSON.stringify(orphanRow), JSON.stringify(geminiRow)].join("\n") + "\n"; + await fs.writeFile(projectQueuePath, existingQueue, "utf8"); + + const cursors = { + files: {}, + hourly: { buckets: {} }, + projectHourly: { + buckets: { + "proj-orphan|codex|2026-07-01T00:00:00.000Z": { + totals: { total_tokens: 9_000_540, cached_input_tokens: 9_000_000 }, + project_ref: "github.com/x/orphan", + project_key: "proj-orphan", + source: "codex", + hour_start: "2026-07-01T00:00:00.000Z", + }, + }, + }, + }; + + await migrateCodexForkOvercountRebuild({ + cursors, + queuePath, + projectQueuePath, + rolloutFiles: [], // orphan: the file backing the bucket is gone + }); + + // Accumulator bucket is reset (so a returning file can't double-count)... + assert.equal(cursors.projectHourly.buckets["proj-orphan|codex|2026-07-01T00:00:00.000Z"], undefined); + // ...but project.queue.jsonl is byte-identical — nothing zeroed, no loss. + assert.equal(await fs.readFile(projectQueuePath, "utf8"), existingQueue); + const pRows = keepLastProjectQueue(existingQueue); + assert.equal(pRows.find((r) => r.project_key === "proj-orphan").total_tokens, 9_000_540); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("is idempotent — the sentinel blocks a second run", async () => { + const dir = await makeTempDir(); + try { + const queuePath = path.join(dir, "queue.jsonl"); + const projectQueuePath = path.join(dir, "project.queue.jsonl"); + const cursors = { + files: {}, + hourly: { buckets: { "codex|gpt-5.5|2026-07-01T00:00:00.000Z": { totals: { total_tokens: 1 } } } }, + projectHourly: { buckets: {} }, + }; + await migrateCodexForkOvercountRebuild({ cursors, queuePath, projectQueuePath, rolloutFiles: [] }); + const firstSize = (await fs.readFile(queuePath, "utf8")).length; + + // Re-add an inflated bucket; the sentinel must prevent re-retraction. + cursors.hourly.buckets["codex|gpt-5.5|2026-07-02T00:00:00.000Z"] = { totals: { total_tokens: 999 } }; + await migrateCodexForkOvercountRebuild({ cursors, queuePath, projectQueuePath, rolloutFiles: [] }); + const secondSize = (await fs.readFile(queuePath, "utf8")).length; + + assert.equal(secondSize, firstSize); + assert.ok(cursors.hourly.buckets["codex|gpt-5.5|2026-07-02T00:00:00.000Z"]); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); + +// --- end to end: cmdSync corrects the dashboard read path, no network ------ + +describe("codex fork overcount rebuild — end to end (no network)", () => { + it("cmdSync migrates inflated codex history and re-parses child-only, leaving other providers untouched", async () => { + const dir = await makeTempDir(); + const prevHome = process.env.HOME; + const prevToken = process.env.TOKENTRACKER_DEVICE_TOKEN; + const prevCodexHome = process.env.CODEX_HOME; + const realFetch = global.fetch; + let networkCalls = 0; + try { + process.env.HOME = dir; + delete process.env.TOKENTRACKER_DEVICE_TOKEN; + process.env.CODEX_HOME = path.join(dir, ".codex"); + // Fail loudly on any network access — proves the local-only requirement. + global.fetch = (...args) => { + networkCalls += 1; + return Promise.reject(new Error(`unexpected network call: ${args[0]}`)); + }; + + // A real-shaped subagent fork rollout: session_meta(thread_spawn) + replay + // (cumulative climbing) + boundary + two genuine child turns. + const u = (i, c, o) => ({ + input_tokens: i, + cached_input_tokens: c, + output_tokens: o, + reasoning_output_tokens: 0, + total_tokens: i + o, + }); + const tc = (ts, last, cum) => + JSON.stringify({ + type: "event_msg", + timestamp: ts, + payload: { type: "token_count", info: { last_token_usage: last, total_token_usage: cum } }, + }); + const forkDoc = [ + JSON.stringify({ + type: "session_meta", + timestamp: "2026-07-20T10:51:35.000Z", + payload: { + id: "child-1", + cwd: dir, + model: "gpt-5.5", + model_provider: "openai", + source: { subagent: { thread_spawn: { parent_thread_id: "p", depth: 1 } } }, + }, + }), + JSON.stringify({ type: "turn_context", timestamp: "2026-07-20T10:51:36.000Z", payload: { model: "gpt-5.5" } }), + tc("2026-07-20T10:51:40.000Z", u(100, 90, 10), u(100, 90, 10)), + tc("2026-07-20T10:51:50.000Z", u(300, 280, 30), u(600, 560, 60)), + JSON.stringify({ + type: "inter_agent_communication_metadata", + timestamp: "2026-07-20T10:52:00.000Z", + payload: { trigger_turn: true }, + }), + tc("2026-07-20T10:52:05.000Z", u(50, 40, 10), u(650, 600, 70)), + tc("2026-07-20T10:52:10.000Z", u(50, 40, 20), u(700, 640, 90)), + ].join("\n") + "\n"; + + const rolloutPath = path.join(dir, ".codex", "sessions", "2026", "07", "20", "rollout-child-1.jsonl"); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.writeFile(rolloutPath, forkDoc, "utf8"); + + // Seed inflated state as if the buggy parser had already ingested it: + // file cursor at EOF (so it would never be re-read without the migration), + // an inflated hourly bucket, matching inflated queue rows, plus an + // untouched non-codex (claude) row that must survive verbatim. + const trackerDir = path.join(dir, ".tokentracker", "tracker"); + await fs.mkdir(trackerDir, { recursive: true }); + const eof = Buffer.byteLength(forkDoc, "utf8"); + const cursorsPath = path.join(trackerDir, "cursors.json"); + const queuePath = path.join(trackerDir, "queue.jsonl"); + const hourStart = "2026-07-20T10:30:00.000Z"; + await fs.writeFile( + cursorsPath, + JSON.stringify({ + version: 1, + // Pre-set the earlier migration's sentinel so ONLY the #75 rebuild runs. + migrations: { [ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY]: "2026-05-01T00:00:00.000Z" }, + files: { [rolloutPath]: { inode: 0, offset: eof, lastTotal: null, head: "seed" } }, + hourly: { + version: 3, + buckets: { + [`codex|gpt-5.5|${hourStart}`]: { + totals: { + input_tokens: 200_000, + cached_input_tokens: 8_000_000, + cache_creation_input_tokens: 0, + output_tokens: 60, + reasoning_output_tokens: 0, + total_tokens: 8_200_060, + billable_total_tokens: 8_200_060, + conversation_count: 5, + }, + }, + }, + }, + }), + "utf8", + ); + const inflatedCodexRow = { + source: "codex", + model: "gpt-5.5", + hour_start: hourStart, + input_tokens: 200_000, + cached_input_tokens: 8_000_000, + cache_creation_input_tokens: 0, + output_tokens: 60, + reasoning_output_tokens: 0, + total_tokens: 8_200_060, + billable_total_tokens: 8_200_060, + conversation_count: 5, + }; + const otherRow = { + source: "gemini", + model: "gemini-2.5-pro", + hour_start: hourStart, + input_tokens: 111, + cached_input_tokens: 222, + cache_creation_input_tokens: 0, + output_tokens: 333, + reasoning_output_tokens: 0, + total_tokens: 666, + billable_total_tokens: 666, + conversation_count: 3, + }; + await fs.writeFile( + queuePath, + [JSON.stringify(inflatedCodexRow), JSON.stringify(otherRow)].join("\n") + "\n", + "utf8", + ); + + await cmdSync(["--auto"]); + + assert.equal(networkCalls, 0, "sync must complete with zero network calls"); + + const finalQueue = keepLastQueue(await fs.readFile(queuePath, "utf8")); + const codex = finalQueue.find((r) => r.source === "codex"); + const other = finalQueue.find((r) => r.source === "gemini"); + + // Codex is now child-only: cached 80, total 130 (the ~8M replay is gone). + assert.ok(codex, "a corrected codex row must exist"); + assert.equal(codex.cached_input_tokens, 80); + assert.equal(codex.total_tokens, 130); + + // The non-codex row is byte-identical — the migration never touched it. + assert.deepEqual(other, otherRow); + + // The migration sentinel is recorded so it never runs again. + const finalCursors = JSON.parse(await fs.readFile(cursorsPath, "utf8")); + assert.ok(finalCursors.migrations[CODEX_FORK_OVERCOUNT_MIGRATION_KEY]); + } finally { + global.fetch = realFetch; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevToken === undefined) delete process.env.TOKENTRACKER_DEVICE_TOKEN; + else process.env.TOKENTRACKER_DEVICE_TOKEN = prevToken; + if (prevCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodexHome; + await fs.rm(dir, { recursive: true, force: true }); + } + }); +});