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
151 model: string;151 model: string;
152 sessionId: string;152 sessionId: string;
153 lastUsageSignature: string | null;153 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;
154}158}
155 159
156export function initialCodexScanState(): CodexScanState {160export 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 */
178const FORK_COPY_MAX_GAP_MS = 1000;
179
180/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */
181function 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";
158}190}
159 191
160/**192/**
@@ -181,8 +213,18 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord · 20 unmodified lines
181 const payloadType = payloadRecord["type"];213 const payloadType = payloadRecord["type"];
182 214
183 if (record["type"] === "session_meta") {215 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;
184 const id = payloadRecord["id"] ?? payloadRecord["session_id"];221 const id = payloadRecord["id"] ?? payloadRecord["session_id"];
185 if (typeof id === "string") state.sessionId = id;222 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 }
186 return null;228 return null;
187 }229 }
188 230
@@ -213,6 +255,17 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord · 24 unmodified lines
213 if (signature === state.lastUsageSignature) return null;255 if (signature === state.lastUsageSignature) return null;
214 state.lastUsageSignature = signature;256 state.lastUsageSignature = signature;
215 257
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
216 const inputTokens = int(lastRecord["input_tokens"]);269 const inputTokens = int(lastRecord["input_tokens"]);
217 const cachedInputTokens = int(lastRecord["cached_input_tokens"]);270 const cachedInputTokens = int(lastRecord["cached_input_tokens"]);
218 const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]);271 const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]);
@@ -238,7 +291,8 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord · 19 unmodified lines
238 totals,291 totals,
239 // Codex does not report cost in the rollout.292 // Codex does not report cost in the rollout.
240 reportedCostUsd: null,293 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.
242 dedupeKey: null,296 dedupeKey: null,
243 };297 };
244}298}