Skip to content

feat(token-usage): match forked Codex sessions against their parent's usage prefix - #2236

Open
svarlamov-git-ai wants to merge 3 commits into
token-usage/wire-tier-datafrom
token-usage/fork-parent-prefix
Open

feat(token-usage): match forked Codex sessions against their parent's usage prefix#2236
svarlamov-git-ai wants to merge 3 commits into
token-usage/wire-tier-datafrom
token-usage/fork-parent-prefix

Conversation

@svarlamov-git-ai

@svarlamov-git-ai svarlamov-git-ai commented Aug 27, 2026

Copy link
Copy Markdown

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 CLIGive Feedback 💬

🤖 Generated with Claude Code

@svarlamov-git-ai
svarlamov-git-ai marked this pull request as ready for review August 27, 2026 03:17
@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from 79f5f72 to d1617b2 Compare August 27, 2026 03:19

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from d1617b2 to b734a55 Compare August 27, 2026 03:30
@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from b734a55 to 20847b8 Compare August 27, 2026 03:40
@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from 20847b8 to 48cd594 Compare August 27, 2026 06:07
@svarlamov
svarlamov force-pushed the token-usage/fork-parent-prefix branch from 48cd594 to 159b3bf Compare August 27, 2026 16:27
Comment thread src/token_usage/codex.rs
}
}

fn provide_parent_prefix(&mut self, prefix: Option<Vec<UsageSignature>>) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/token_usage/codex.rs
/// 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(

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from 159b3bf to 7dc11c3 Compare August 27, 2026 21:25
@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from 7dc11c3 to 4c8f9f4 Compare August 27, 2026 21:30
@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from 4c8f9f4 to 38b9482 Compare August 27, 2026 21:43
svarlamov and others added 3 commits August 28, 2026 17:54
… 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>
@svarlamov-git-ai
svarlamov-git-ai force-pushed the token-usage/fork-parent-prefix branch from 38b9482 to e42170b Compare August 28, 2026 17:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants