Skip to content

fix(codex): detect structural fork replay boundaries - #1457

Closed
MaxGhenis wants to merge 2 commits into
ccusage:mainfrom
MaxGhenis:codex-structural-replay-boundary
Closed

MaxGhenis wants to merge 2 commits into
ccusage:mainfrom
MaxGhenis:codex-structural-replay-boundary

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • replace the one-second token heuristic with the Codex fork structure: adjacent leaf/ancestor session_meta records identify copied history, and task_started.started_at versus the record clock identifies the native boundary
  • skip inherited prefixes even when they span multiple seconds, while carrying their terminal cumulative usage forward as the live baseline
  • retain genuine fork-local events that share a second, and cover both modern last_token_usage and legacy cumulative-only rollouts
  • preserve the same-second parent/child behavior established by #1218 and extended to forked_from_id by #1369

The reproduction and expected arithmetic follow the independent fixture/oracle approach documented in the Logpile accounting review. The exact 2026-07-18 matrix behind this change is published as the 2026-07-18 ccusage verification.

Testing

  • cargo +1.96.0 test --manifest-path rust/Cargo.toml --workspace --offline (445 passed)
  • rebuilt the audit parser harness against the patched sources
  • 12/12 modern/legacy matrix cells match the oracle
  • modern and legacy fork-local same-second probes match the oracle
  • existing parent/child same-second coverage remains green

Prepared with AI assistance; I reviewed the implementation and test results.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes Codex fork replay detection by using structural boundaries instead of a 1-second heuristic. Prevents double-counting and preserves real fork-local usage while skipping copied history across seconds.

  • Bug Fixes
    • Detect replays via adjacent leaf/ancestor session_meta or explicit source.subagent.thread_spawn.parent_thread_id; set the boundary at a native task_started whose started_at matches the record second; fall back to the leaf creation second for legacy streams.
    • Skip inherited token_count events before the boundary (even when they span multiple seconds) and carry their terminal cumulative totals forward as the live baseline.
    • Keep genuine fork-local same-second token_count events; support both modern last_token_usage and legacy cumulative-only logs.
    • Preserve established same-second parent/child behavior, including forked_from_id, and tolerate malformed task_started lines.

Written for commit ebd45b9. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved Codex replay handling and attribution for forked sessions across modern and legacy log formats.
    • Corrected how token usage totals are determined when events share the same timestamp or span multiple seconds.
    • Enhanced detection of session boundaries and more robust behavior around malformed task markers.
  • Tests

    • Expanded automated coverage with new Codex fixtures for replay, fork-local same-second cases, and multisecond inherited-prefix scenarios in both supported formats.

Prepared with AI assistance (audit + implementation: gpt-5.6-sol; review: Claude Fable 5); reviewed and tested before filing.

Co-Authored-By: gpt-5.6-sol <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
@github-actions

Copy link
Copy Markdown
Contributor

This PR was auto-closed. Only contributors approved with lgtm can open PRs. Open an issue first.

Maintainers review auto-closed issues and reopen worthwhile ones. Issues that do not meet the quality bar in CONTRIBUTING.md may not be reopened or receive a reply.

If a maintainer replies lgtmi, your future issues will stay open. If a maintainer replies lgtm, your future issues and PRs will stay open.

See CONTRIBUTING.md.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Codex JSONL parsing now detects replay boundaries using task_started markers and supports native line-index and legacy timestamp boundaries. Token totals are attributed without duplicate usage events, with modern and legacy fixture tests covering multisecond and same-second fork scenarios.

Changes

Codex replay attribution

Layer / File(s) Summary
Replay boundary detection
rust/crates/ccusage/src/adapter/codex/parser.rs
Introduces replay parsing types and detects native or legacy boundaries from session metadata and task_started markers.
Token event attribution
rust/crates/ccusage/src/adapter/codex/parser.rs
Tracks line indexes and updates prior totals for replay token rows without emitting duplicate usage events.
Replay compatibility tests
rust/crates/ccusage/src/adapter/codex/parser.rs, rust/crates/ccusage/tests/fixtures/codex/*
Adds helper assertions and modern/legacy fixtures for multisecond replay and same-second fork-local cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CodexLog
  participant visit_codex_session_file
  participant CodexReplayBoundary
  participant UsageAccounting
  CodexLog->>visit_codex_session_file: read session metadata and token_count rows
  visit_codex_session_file->>CodexReplayBoundary: check line index and token totals
  CodexReplayBoundary-->>visit_codex_session_file: classify replay token row
  visit_codex_session_file->>UsageAccounting: replace previous totals or emit usage
Loading

Possibly related PRs

Suggested reviewers: pullfrog

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: structural detection of Codex fork replay boundaries.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ryoppippi

Copy link
Copy Markdown
Member

@pullfrog review it
@codesmith review it
@cubic review it
@coderabbitai review it

@blacksmith-sh

blacksmith-sh Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Got it, I'm reviewing the PR changes now.

View with Codesmith

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

@pullfrog review it
@codesmith review it
@cubic review it
@coderabbitai review it

@ryoppippi I have started the AI code review. It will take a few minutes to complete.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

@ryoppippi, I’ll review the changes in #1457.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pullfrog pullfrog 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.

ℹ️ No critical issues — minor suggestions inline.

Reviewed changes — this PR replaces the one-second replay heuristic in the Codex adapter with structural detection based on adjacent leaf/ancestor session_meta records and a native task_started whose started_at matches the record clock. It also carries inherited cumulative totals forward as a baseline and adds fixture-backed coverage for modern last_token_usage and legacy cumulative-only streams.

  • Detect replay boundary structurally in rust/crates/ccusage/src/adapter/codex/parser.rs — introduced detect_replay_boundary, CodexReplayBoundary, and helpers to identify forked sessions by forked_from_id or source.subagent.thread_spawn.parent_thread_id.
  • Skip inherited token_count events before the native boundary while updating previous_totals with the terminal cumulative usage.
  • Preserve same-second native events when a task_started proves the boundary is within the creation second.
  • Add regression fixtures for multi-second replay and fork-local same-second usage in both modern and legacy formats.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

if !replay_candidate {
return None;
}
leaf_creation_second.map(CodexReplayBoundary::LegacyCreationSecond)

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.

The LegacyCreationSecond fallback path has no fixture coverage. Please add a test case for streams that omit task_started after adjacent ancestry metadata, or clarify why this path is intentionally untested.

Technical details
# LegacyCreationSecond is untested

## Affected sites
- `rust/crates/ccusage/src/adapter/codex/parser.rs:190` — returns `LegacyCreationSecond(leaf_creation_second)`
- `rust/crates/ccusage/src/adapter/codex/parser.rs:99-110` — `contains_token_event` skips token_count events whose timestamp second equals the leaf creation second

## Required outcome
- Either add a fixture + test exercising the `LegacyCreationSecond` branch, or document that the branch is intentionally defensive/untested.
- If a test is added, verify the skip semantics: the current code skips events *at* the leaf creation second, whereas the rest of the PR treats events *at or after* a `NativeLine` boundary as native. Confirm this asymmetry matches real Codex legacy log structure.

## Suggested approach
Add a `replay-no-task-started-legacy.jsonl` fixture that has adjacent `session_meta` records but no `task_started` marker, then assert which token_count events are kept/skipped. This protects the fallback from silent drift.

## Open questions for the human
- Is skipping events *at* the leaf creation second (rather than *before* it) the intended semantics for the legacy fallback?

@blacksmith-sh

blacksmith-sh Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

@ryoppippi

@pullfrog review it
@codesmith review it
@cubic review it
@coderabbitai review it

Reviewed the PR: pulled the branch, read the full parser.rs rewrite against the base, traced all four fixtures plus the pre-existing loader.rs replay tests by hand, and ran the suite. Built clean on 1.96.0 (no warnings), and all 48 codex-adapter tests pass, including the 4 new fixtures and the #1218/#1369 regressions.

Verdict: solid and well-tested. The structural approach is a real improvement over the 1-second heuristic, and the modern/legacy × multisecond/fork-local matrix is good coverage. A few things worth confirming before merge, none blocking.

1. The forked_from_id path hard-requires the ancestor session_meta at exactly line index 1. In detect_replay_boundary, when line 0 carries forked_from_id but no thread_spawn, replay_candidate stays false, so detection hinges entirely on the 1 => arm finding an adjacent ancestor (parser.rs:159-172):

let has_adjacent_parent = value.entry_type.as_deref() == Some("session_meta")
    && ancestor_id == parent_id.as_deref();
if !has_adjacent_parent && !explicit_thread_spawn {
    return None; // -> copied history is NOT skipped -> double count
}

If Codex ever emits any record between the leaf session_meta (line 0) and the ancestor session_meta (e.g. a git_info/turn_context line), has_adjacent_parent is false and, absent thread_spawn, the whole prefix gets counted again. This is stricter than the old scan-first-16KB detection. Can you confirm real fork rollouts always place the ancestor session_meta immediately at line 1? If a preceding record is possible, this path needs to scan forward for the ancestor rather than pin it to index 1.

2. LegacyCreationSecond still carries the exact multi-second limitation this PR fixes. The multi-second skip only kicks in via the NativeLine boundary; streams with no native task_started fall back to LegacyCreationSecond (parser.rs:106-108, :190), which only skips events sharing the creation second. That's the documented compatibility case and not a regression, but the PR framing ("skip inherited prefixes even when they span multiple seconds") is only true for streams that emit a native task_started; legacy-without-task_started multi-second replays still under/over-count. Worth stating explicitly.

3. The started_at vs record-clock signal is an assumption about Codex's replay writer. The native-boundary detection (codex_is_native_task_start, parser.rs:219-230) relies on replayed task_started markers retaining their original started_at while the record timestamp is rewritten to fork-creation time, so they diverge for copied turns and coincide for the live turn. It's a clean signal and the fixtures encode it, but it's load-bearing; if a future Codex version rewrites started_at on replay too, the boundary silently collapses to the first turn.

Minor:

  • source: Option<Value> (parser.rs:86) breaks the otherwise fully-borrowed struct and allocates a serde_json::Value per session_meta. Only lines 0-1 are parsed this way so the cost is negligible, but given the repo's zero-copy/min-size posture you could reach parent_thread_id with a small borrowed struct instead.
  • The line_index counters in detect_replay_boundary and visit_codex_session_file must stay in lockstep for NativeLine(idx) to line up. I verified each path increments exactly once per line read, but it's an implicit cross-function invariant; a one-line comment noting "must count lines identically to visit_codex_session_file" would protect it from future edits.

No code changes made; this is review-only.

View with Codesmith

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
rust/crates/ccusage/src/adapter/codex/parser.rs (1)

89-96: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Share the replay line counter or document the lockstep invariant. Both paths count every physical line today, but the boundary logic still depends on that shared ordering; a short comment or helper would make the contract harder to break.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/crates/ccusage/src/adapter/codex/parser.rs` around lines 89 - 96,
Document the lockstep invariant for CodexReplayBoundary: NativeLine and
LegacyCreationSecond must be derived from the same physical replay-line counter
and remain aligned with each input line. Add a concise comment near the enum or
boundary calculation, or introduce a shared helper, so future changes cannot
update one counting path without the other.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rust/crates/ccusage/src/adapter/codex/parser.rs`:
- Around line 89-96: Document the lockstep invariant for CodexReplayBoundary:
NativeLine and LegacyCreationSecond must be derived from the same physical
replay-line counter and remain aligned with each input line. Add a concise
comment near the enum or boundary calculation, or introduce a shared helper, so
future changes cannot update one counting path without the other.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c583e65b-9722-4f20-8430-4fcbeabb4ccd

📥 Commits

Reviewing files that changed from the base of the PR and between bd6dd52 and d31f1e0.

📒 Files selected for processing (5)
  • rust/crates/ccusage/src/adapter/codex/parser.rs
  • rust/crates/ccusage/tests/fixtures/codex/fork-local-same-second-legacy.jsonl
  • rust/crates/ccusage/tests/fixtures/codex/fork-local-same-second-modern.jsonl
  • rust/crates/ccusage/tests/fixtures/codex/replay-multisecond-legacy.jsonl
  • rust/crates/ccusage/tests/fixtures/codex/replay-multisecond-modern.jsonl

@ryoppippi

Copy link
Copy Markdown
Member

@pullfrog read all reviews from them and fix them

@pullfrog

pullfrog Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

I addressed both review findings in commit 4a7d161d on the local pr-1457 branch:

  • Added a lockstep invariant comment on CodexReplayBoundary::NativeLine documenting that its physical line index must stay aligned with the counter in visit_codex_session_file.
  • Added replay-no-task-started-legacy.jsonl and a test falls_back_to_leaf_creation_second_without_task_started covering the LegacyCreationSecond fallback path, including that events at the leaf creation second are skipped and their cumulative total becomes the baseline.

Validation passed: cargo +1.96.0 test --manifest-path rust/Cargo.toml --workspace (all green).

However, pushing the commit failed because the PR branch lives on the fork MaxGhenis/ccusage, and pullfrog[bot] does not have push access there:

remote: Permission to MaxGhenis/ccusage.git denied to pullfrog[bot].
fatal: unable to access 'https://github.com/MaxGhenis/ccusage.git/': The requested URL returned error: 403

I did not reply to or resolve the review threads because the fixes are not yet live on the remote. If you grant the bot push access to the fork, or if a maintainer can push this commit manually, the review comments are ready to be resolved.

Task list (6/6 completed)
  • Checkout PR 1457 branch
  • Read all review comments
  • Verify findings and implement minimal fixes
  • Run quality checks/tests
  • Commit changes
  • Push changes to PR branch

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | via Pullfrog | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

@pullfrog

pullfrog Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔ | View workflow run | via Pullfrog | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

@ryoppippi

Copy link
Copy Markdown
Member

Thanks for digging into this. The underlying bug was real, but this PR has been superseded — the one-second heuristic it replaces no longer exists on main.

#1435 and #1438 replaced it with CodexReplayPlan in rust/adapters/codex/src/replay.rs, which reads the parent session log and subtracts the exact replayed prefix instead of guessing a boundary from a single file. #1428 also moved the adapter to rust/adapters/codex/, so the parser.rs code this PR rewrites is no longer there in that form.

I checked your replay-multisecond-modern.jsonl scenario against current main rather than relying on the diff:

Scenario Reported total Correct
Parent log present in the scanned set parent 238.7M counted once, child adds 5,500 yes
Parent log absent from the scanned set 238,705,500 no — should be 5,500

So the common case is already fixed. What remains is narrower than this PR assumes: when the referenced parent log is unavailable, replay_prefix returns an empty slice and the parser falls back to the old rewritten-second heuristic, which still misses an inherited prefix spanning multiple seconds. That is a genuine gap and worth closing.

It is better closed inside the replay.rs fallback than by reinstating a single-file boundary scan, though. This PR's boundary signal — task_started.started_at matching the record second — is itself a heuristic, and a weaker one than reading the parent log, so layering it back over the current design would trade a verified mechanism for an inferred one.

Closing this as superseded. A small PR scoped to just that fallback path would be very welcome, and I would review it happily.

One process note for next time: the verification links point at docs/reviews/ in your own repository, where the fixtures and the expected values are both authored by the same pass. That is not independent confirmation, and it is what let the multisecond claim stand against a tree that had already moved on. For #1458 I was able to confirm the invariant against 21,815 real token_count records, which is the kind of evidence that settles it.

@ryoppippi

Copy link
Copy Markdown
Member

Following up: I closed the fallback gap myself in #1501, so you are not left holding it.

The measurements that shaped it, in case they are useful for your accounting work:

  • The burst Codex rewrites to the fork instant spans 10 to 40ms across the 9 fork sessions with one in a real log directory, while the child's own first turn follows a pause of 5.8 to 15.3 seconds. Following the run rather than the recorded second separates those two populations by two orders of magnitude, and it is why the multi-second case you found was reachable at all.
  • I did not adopt the task_started.started_at signal. Of 1,833 real task_started records in fork sessions, 135 have started_at differing from their record second, and some of those differences are exactly 1 second on native records that merely crossed a tick — so strict equality misclassifies them. That is the kind of thing only real logs surface.
  • Before the change, 1 of 210 real fork sessions over-counted by 27,354,644 tokens (2.4x) through the fallback. After it, all 210 agree with exact prefix subtraction.

Thanks for pointing at the right place even though the patch had moved on.

ryoppippi added a commit that referenced this pull request Jul 27, 2026
When a forked session's parent log is in the scanned set, `CodexReplayPlan` subtracts the exact replayed prefix. When it is not, the parser falls back to skipping the burst Codex rewrote to the fork instant, and that fallback bucketed events by their recorded second. Codex writes the replayed history in a few milliseconds, so a fork landing late in a second straddles the tick and everything past it was counted as the child's own usage.

One real subagent log reports 47,175,282 tokens through the fallback against 19,820,638 with the parent log present, a 2.4x over-count from 315 replayed records on the far side of the tick.

Follow the run rather than the second: skip while successive events stay within a second of each other and move forward, carrying the last skipped timestamp instead of a fixed second. Measured across the fork logs on hand, a rewritten burst spans 10 to 40ms while the child's own first turn follows a pause of 5.8 to 15.3 seconds, so a second sits two orders of magnitude above the burst and well below the pause. Requiring the step to be forward keeps a malformed log from walking the window backwards and silently dropping events.

Verified against 210 real fork sessions whose parent log could be located, each scanned alone and beside its parent: the fallback now agrees with exact prefix subtraction for all 210, against 209 before. A full scan of the same directory is unchanged down to the cost figure, so the normal path is untouched.

`skips_missing_parent_replay_when_duplicate_snapshot_is_suppressed` placed the child's own turn 800ms after the burst, which only read as the child's own under second bucketing. Its subject is snapshot suppression, so the fixture now uses a pause a real log would show and keeps testing that.

A fork whose own first turn begins within the pause is still skipped. The shortest real pause observed is 5.8 seconds and the previous code carried the same class of exposure, so this narrows the window rather than closing it; the parent-log path has no such limit.

Closes the gap left by #1457.

Co-authored-by: Codesmith <[email protected]>
@ryoppippi

Copy link
Copy Markdown
Member

Historical audit: this pull request was auto-closed by the legacy contributor gate. That closure did not assess technical importance.

Audit result: needs review. The current state does not prove resolution, but a fresh technical or product check is required before deciding whether the underlying request is still relevant. This closed PR will not be revived as-is; create a new PR only after reviewing the related issue and current main.

@ryoppippi ryoppippi added the triage:needs-review Requires a fresh technical or product decision. label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage:needs-review Requires a fresh technical or product decision.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants