feat(token-usage): match forked Codex sessions against their parent's usage prefix - #2236
feat(token-usage): match forked Codex sessions against their parent's usage prefix#2236svarlamov-git-ai wants to merge 3 commits into
Conversation
79f5f72 to
d1617b2
Compare
d1617b2 to
b734a55
Compare
b734a55 to
20847b8
Compare
20847b8 to
48cd594
Compare
48cd594 to
159b3bf
Compare
| } | ||
| } | ||
|
|
||
| fn provide_parent_prefix(&mut self, prefix: Option<Vec<UsageSignature>>) { |
There was a problem hiding this comment.
[code-review] Some(vec![]) — a resolved parent with zero pre-fork usage — takes the burst fallback and can drop the child's real leading turns
With an empty prefix, MatchingParent { remaining: [], matched: false } means the child's first real delta mismatches (remaining.front() is None) and is parked as AwaitingSecond { pending: Some(real event) }; a second usage event within 1s transitions to SkippingBurst and both real events (plus any chain spaced <= 1s) are permanently dropped, and even the single-turn case defers emission to the wall-clock flush. But an empty prefix is the one case where the parent rollout was resolved and provably contained nothing to replay — ReplayState::Done is strictly safer than the burst heuristic here. Trigger: a fork/thread_spawn taken before the parent's first token_count (collect_parent_prefix truncates at fork_ts_ms and returns []). The comment claims ccusage parity, but parity with a heuristic is a weak reason to skip usage we can prove is genuine.
There was a problem hiding this comment.
Fixed in 7dc11c3: a successfully read parent with zero pre-fork usage now goes straight to Done — nothing was replayed, so the child's real back-to-back first turns count instead of being parked by the burst heuristic. Documented as an intentional deviation from ccusage, which shares the fallback hole; the heuristic stays reserved for genuinely unresolvable parents.
| /// `archived_sessions`, where Codex moves archived rollouts — for a filename | ||
| /// carrying the parent id (codex rollout filenames embed the session uuid), | ||
| /// confirming by the recorded `session_meta` id. | ||
| fn scan_sessions_tree(child_path: &Path, parent_id: &str) -> Option<PathBuf> { |
There was a problem hiding this comment.
[code-review] Anchor on an ancestor literally named sessions cannot serve archived/relocated children, and re-encodes Codex's on-disk layout
child_path.ancestors().find(|dir| ... == Some("sessions"))? returns None for any child path without a sessions ancestor (e.g. a rollout processed from archived_sessions, or a custom layout), so the parent is unresolvable and the extractor permanently falls back to the burst heuristic — re-opening the replayed-history overcount this PR eliminates. Today the trigger is narrow (checkpoints fire on live rollouts under sessions/), but the sibling knowledge ("archived_sessions" lives next to "sessions", filenames embed the session uuid) is now encoded here and in CodexAgent::find_rollout_path_for_session_in_home / model_extraction — three copies that can drift. Anchoring via codex_home_from_transcript_path (already imported in this file, and it handles archived paths) instead of the literal sessions ancestor would remove both the gap and one copy of the layout knowledge.
There was a problem hiding this comment.
Fixed in 7dc11c3: scan roots now come from the codex home via the shared codex_home_from_transcript_path resolution (covering sessions and archived_sessions from either side, so an archived child can resolve an untracked parent), with the ancestor-name anchor kept only as the fallback for homes that can't be resolved.
| /// fork instant, so its delta would go unmatched either way here); | ||
| /// collection stops at the first usage event past `fork_ts_ms` (usage the | ||
| /// parent recorded after the fork was never replayed). | ||
| pub fn collect_parent_prefix( |
There was a problem hiding this comment.
[code-review] No bound on the parent prefix: whole-parent read plus an unbounded signature deque persisted in state_json
collect_parent_prefix reads the parent rollout to the fork instant and returns one UsageSignature per non-zero delta, and the unmatched remainder persists as a serialized VecDeque in tracked_files.state_json on every batch commit while the child is in MatchingParent. A fork taken at the end of a months-long parent (tens of thousands of turns) means a full parent-file read on the notification path plus a multi-MB state_json row rewritten per pass until the replay resolves. PARENT_SCAN_MAX_ENTRIES caps directory scanning but nothing caps this. A cap on collected signatures (falling back to the burst heuristic beyond it, like an unresolvable parent) would bound both.
There was a problem hiding this comment.
Fixed in 7dc11c3: collect_parent_prefix is capped at 50k signatures — beyond that the parent counts as unresolvable (burst fallback) instead of bloating every state_json write. The whole-parent read itself matches ccusage and stays a one-time bounded read per fork (the shrinking remainder persists in state, so it is never re-read across passes).
159b3bf to
7dc11c3
Compare
7dc11c3 to
4c8f9f4
Compare
4c8f9f4 to
38b9482
Compare
… usage prefix A forked rollout replays its parent's history with timestamps rewritten to the fork instant. The rewritten-burst heuristic (skip leading events spaced <= 1s apart) misidentifies a real fork whose own first turn follows the replay after a longer pause — observed in the wild as a 16,508-token replayed event counted as genuine usage 6.975s before the child's first own turn. Port ccusage's parent-prefix matching into the incremental extractor: a fork now parks in AwaitingParent until the worker resolves the parent rollout and answers with its pre-fork usage signatures (per-event deltas, timestamps excluded; collected without replay filtering so a prefix the parent itself replayed stays included, truncated at the child's session_meta timestamp). The child's leading events consume the matching front of that prefix; a mismatch after any match means the replayed history ended, and a mismatch with nothing matched — or an unresolvable parent — falls back to the burst heuristic exactly as before (never retried, matching ccusage's unavailable-parent path). The unmatched remainder persists in the extractor state, so the parent is read once per fork, not per pass or restart. The worker serves prefix requests after restoring state and after every consumed line. Parent resolution tries files tracked under the parent's external session id, then a bounded scan of the codex sessions tree, always confirming a candidate by its recorded session_meta id (ccusage locates parents by id, never filename) — a bounded single-file read in the async worker. The spec's fork section is rewritten and the split-burst accepted limitation narrows to the unresolvable-parent fallback path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex moves archived rollouts to archived_sessions, a sibling of the sessions tree: a fork whose parent was archived after the fork (and is not in the token DB) was unresolvable and fell back to the rewritten- burst heuristic — the exact double-count parent-prefix matching exists to eliminate. The scan now also queues that directory, uses the free readdir file_type() instead of a stat per entry, and the collect_parent_prefix comment states the malformed-timestamp deviation from ccusage precisely (corruption-only; behavior unchanged). Test fixtures reuse the shared codex_usage_line helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three review findings on fork resolution: - A successfully read parent with zero pre-fork usage proves nothing was replayed, but the empty prefix fell through to the burst heuristic, which can park — and drop — a child's real first turns when they arrive within a second of each other (a subagent spawned during the parent's first turn hits exactly this). An empty resolved prefix now goes straight to Done; the heuristic stays reserved for genuinely unresolvable parents. (Intentional deviation from ccusage, which shares the fallback hole.) - The fallback scan anchored on an ancestor literally named `sessions`, so a child processed from `archived_sessions` (or a custom layout) could never scan-resolve an untracked parent. Roots now come from the codex home via the shared `codex_home_from_transcript_path` resolution, covering `sessions` and `archived_sessions` from either side, with the ancestor anchor kept as the fallback. - collect_parent_prefix had no size bound while the unmatched remainder persists in state_json per batch commit; prefixes beyond 50k events (pathological rollouts) now count as unresolvable instead of bloating every state write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
38b9482 to
e42170b
Compare
A forked rollout replays its parent's history with timestamps rewritten
to the fork instant. The rewritten-burst heuristic (skip leading events
spaced <= 1s apart) misidentifies a real fork whose own first turn
follows the replay after a longer pause — observed in the wild as a
16,508-token replayed event counted as genuine usage 6.975s before the
child's first own turn.
Port ccusage's parent-prefix matching into the incremental extractor:
a fork now parks in AwaitingParent until the worker resolves the parent
rollout and answers with its pre-fork usage signatures (per-event
deltas, timestamps excluded; collected without replay filtering so a
prefix the parent itself replayed stays included, truncated at the
child's session_meta timestamp). The child's leading events consume the
matching front of that prefix; a mismatch after any match means the
replayed history ended, and a mismatch with nothing matched — or an
unresolvable parent — falls back to the burst heuristic exactly as
before (never retried, matching ccusage's unavailable-parent path).
The unmatched remainder persists in the extractor state, so the parent
is read once per fork, not per pass or restart.
The worker serves prefix requests after restoring state and after every
consumed line. Parent resolution tries files tracked under the parent's
external session id, then a bounded scan of the codex sessions tree,
always confirming a candidate by its recorded session_meta id (ccusage
locates parents by id, never filename) — a bounded single-file read in
the async worker.
The spec's fork section is rewritten and the split-burst accepted
limitation narrows to the unresolvable-parent fallback path.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Stack created with GitHub Stacks CLI • Give Feedback 💬
🤖 Generated with Claude Code