History

fix(server): usage no longer double-counts forked Codex sessions (#5887)

Theo Browne committed 3 months ago

Commit 0d388661 parent f993fa13 files · +159 −3Browse files

Showing the changes to apps/server/src/usage/usageTranscripts.ts (1 of 3 files). Show all

Unified · Split

apps/server/src/usage/usageTranscripts.ts+56−2
@@ -151,10 +151,42 @@ export interface CodexScanState { · 150 unmodified lines
151151 model: string;
152152 sessionId: string;
153153 lastUsageSignature: string | null;
154+ sawSessionMeta: boolean;
155+ /** While true, leading usage events are re-stamped copies of parent history. */
156+ suppressingForkCopies: boolean;
157+ forkCopyAnchorMs: number;
154158 }
155159
156160 export function initialCodexScanState(): CodexScanState {
157− return { model: "", sessionId: "", lastUsageSignature: null };
161+ return {
162+ model: "",
163+ sessionId: "",
164+ lastUsageSignature: null,
165+ sawSessionMeta: false,
166+ suppressingForkCopies: false,
167+ forkCopyAnchorMs: 0,
168+ };
169+}
170+
171+/**
172+ * A forked or subagent rollout opens with the parent's full history copied in,
173+ * every line re-stamped to the fork instant. Those copies are written in one
174+ * synchronous burst (observed gaps 0-40ms), while the child's first genuine
175+ * usage event only lands after a real model turn (observed 5s+). One second of
176+ * separation splits the two cleanly; `ccusage` uses the same threshold.
177+ */
178+const FORK_COPY_MAX_GAP_MS = 1000;
179+
180+/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */
181+function isForkedSessionMeta(payload: Record<string, unknown>): boolean {
182+ if (typeof payload["forked_from_id"] === "string") return true;
183+ const source = payload["source"];
184+ if (typeof source !== "object" || source === null) return false;
185+ const subagent = (source as Record<string, unknown>)["subagent"];
186+ if (typeof subagent !== "object" || subagent === null) return false;
187+ const spawn = (subagent as Record<string, unknown>)["thread_spawn"];
188+ if (typeof spawn !== "object" || spawn === null) return false;
189+ return typeof (spawn as Record<string, unknown>)["parent_thread_id"] === "string";
158190 }
159191
160192 /**
@@ -181,8 +213,18 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord · 20 unmodified lines
181213 const payloadType = payloadRecord["type"];
182214
183215 if (record["type"] === "session_meta") {
216+ // Only the first meta describes this file's own session. A forked rollout
217+ // repeats the ancestors' metas right after it; letting those through would
218+ // reassign every subsequent record to an ancestor session.
219+ if (state.sawSessionMeta) return null;
220+ state.sawSessionMeta = true;
184221 const id = payloadRecord["id"] ?? payloadRecord["session_id"];
185222 if (typeof id === "string") state.sessionId = id;
223+ const metaTimestampMs = parseTimestampMs(record["timestamp"]);
224+ if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) {
225+ state.suppressingForkCopies = true;
226+ state.forkCopyAnchorMs = metaTimestampMs;
227+ }
186228 return null;
187229 }
188230
@@ -213,6 +255,17 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord · 24 unmodified lines
213255 if (signature === state.lastUsageSignature) return null;
214256 state.lastUsageSignature = signature;
215257
258+ // In a forked rollout the copied parent history was already counted from the
259+ // parent's own file. Drop the leading burst; the first usage event separated
260+ // from its predecessor by a real turn's worth of time ends it for good.
261+ if (state.suppressingForkCopies) {
262+ if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) {
263+ state.forkCopyAnchorMs = timestampMs;
264+ return null;
265+ }
266+ state.suppressingForkCopies = false;
267+ }
268+
216269 const inputTokens = int(lastRecord["input_tokens"]);
217270 const cachedInputTokens = int(lastRecord["cached_input_tokens"]);
218271 const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]);
@@ -238,7 +291,8 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord · 19 unmodified lines
238291 totals,
239292 // Codex does not report cost in the rollout.
240293 reportedCostUsd: null,
241− // Rollout files are unique per session, so events need no global dedup.
294+ // Events surviving the fork-copy suppression above are unique to this
295+ // rollout, so they need no global dedup.
242296 dedupeKey: null,
243297 };
244298 }