Showing the changes to apps/server/src/usage/usageTranscripts.ts (1 of 3 files). Show all
Unified · Split
| @@ -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 | ||
| 156 | export function initialCodexScanState(): CodexScanState { | 160 | 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"; | ||
| 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 | } |