Skip to content

fix(codex): read Zstandard-compressed rollouts - #1890

Merged
ryoppippi merged 5 commits into
mainfrom
cursor/codex-zstd-rollouts-1144
Oct 14, 2026
Merged

ryoppippi merged 5 commits into
mainfrom
cursor/codex-zstd-rollouts-1144

Conversation

@ryoppippi

@ryoppippi ryoppippi commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Since the 2026-10-14 Codex update, Codex migrates cold rollouts under sessions/ and archived_sessions/ to .jsonl.zst. The Codex adapter only discovered .jsonl, so it skipped compressed sessions entirely and totals such as ccusage codex monthly came up short.

Fixes #1888

What changed

  • Discovery collects .jsonl and .jsonl.zst in one directory walk, using a new collect_files_matching helper in ccusage-adapter-common. Other .zst files are ignored.
  • A new reader.rs streams .jsonl.zst through the zstd decoder into the existing parser, the replay metadata probe, and rewritten-burst detection. Nothing is unpacked to disk, and concatenated frames are supported.
  • Session IDs and the cross-directory dedupe key use the plain .jsonl path, so IDs stay the same and replay/fork detection is unaffected.
  • When a plain and a compressed copy of the same rollout both exist, the rollout is counted once and the plain copy wins. This matches Codex, which hides a compressed sibling when the plain file exists and restores the plain file before deleting the .zst when a compressed session is resumed. The existing rule that sessions/ wins over archived_sessions/ now applies regardless of encoding.
  • If Codex compresses or restores a file between discovery and open, the reader falls back to the other encoding instead of dropping it. In that case, timestamp-less headless usage takes its fallback date from the file that was actually opened.
  • codex session --id also accepts a .jsonl.zst filename.
  • Docs: the Codex guide, the session-ID lookup note, and the adapter READMEs are updated.

Testing

  • cargo fmt --all, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, and cargo hawk check -D warnings (hawk 0.1.10) all pass. oxfmt --check and typos pass on the changed files. Nix isn't available on the machine this was developed on, so these ran directly against the pinned toolchain and LiteLLM snapshot.
  • New fixture-backed tests (compression_tests.rs, paths.rs, codex_cli.rs) cover: compressed-only rollouts, plain and compressed duplicates, compressed archived sessions, the archive using a different encoding than the active copy, concatenated and truncated frames, a file switching encoding mid-read (including the headless fallback timestamp), compressed fork/replay parents and children, a compressed parent outside the --since range, compaction records, and JSON parity with plain logs for daily, weekly, monthly, and session reports. 11 of these tests fail when discovery is reverted to .jsonl-only.
  • Smoke test with rollouts compressed by the zstd CLI: main reports 520 tokens where the plain copy has 1040; this branch reports 1040, and its daily and session JSON are identical to the plain logs.
  • Release binary is 4,926,168 bytes on main and 5,051,512 bytes on this branch (+122 KiB, about 2.5%). On 1,500 synthetic rollouts (83 MB plain), codex monthly --json takes about 210 ms on both main and this branch, and about 216 ms with the same data as .jsonl.zst.

Based on the reference implementation by @HenryTeo27 in HenryTeo27/ccusage@431a3087; each commit carries a Co-authored-by trailer for them.

Open in Web Open in Cursor 

Summary by cubic

Fixes Codex usage undercounting by reading Zstandard-compressed rollouts. Since the 2026-10-14 Codex update, cold sessions are migrated to .jsonl.zst; the adapter only discovered .jsonl, so compressed sessions were skipped entirely and totals like ccusage codex monthly came up short.

  • Discovers .jsonl and .jsonl.zst in one directory walk; other .zst files are ignored.
  • Streams compressed rollouts through the zstd decoder without unpacking to disk, including concatenated frames.
  • Session IDs use the plain .jsonl path, so IDs stay stable and replay/fork detection is unaffected.
  • When plain and compressed copies of the same rollout both exist, it counts once with the plain copy winning, matching Codex.
  • Falls back to the other encoding when a file switches between discovery and open, dating headless usage from the file actually opened; codex session --id accepts .jsonl.zst filenames.

Written for commit 6673fa0. Summary will update on new commits.

View guided diff Turn on auto-fix

Summary by CodeRabbit

  • New Features

    • Codex usage reports now include sessions stored as Zstandard-compressed .jsonl.zst files, alongside plain .jsonl files.
    • Compressed sessions can be selected by filename, with session IDs consistent with plain versions.
    • When duplicate copies exist, plain files take precedence within a directory, while active-session files take precedence over archived copies.
  • Documentation

    • Updated Codex guides to explain compressed session files, filename lookup, and duplicate handling.

cursoragent and others added 3 commits October 14, 2026 17:59
Add collect_files_matching so an adapter can gather files that do not
share one extension, such as plain and compressed rollouts, in a single
directory walk. collect_files_with_extension now delegates to it.

Co-authored-by: Henry Teo <[email protected]>
Since the 2026-10-14 Codex update, cold rollouts under sessions/ and
archived_sessions/ are migrated to .jsonl.zst. The adapter only
discovered .jsonl, so compressed sessions were skipped and totals such
as codex monthly lost a large share of their tokens.

Discover both encodings in one walk and stream .jsonl.zst through the
zstd decoder into the existing parser, replay metadata probe, and
rewritten-burst detection. Session IDs and the cross-directory dedupe
key use the plain .jsonl path, so IDs stay stable and a rollout with
both a plain and a compressed copy is counted once. The plain copy
wins, matching Codex, which restores it before deleting the .zst when a
compressed session is resumed. If a file switches encoding between
discovery and open, the other encoding is read instead.

Fixes #1888

Co-authored-by: Henry Teo <[email protected]>
Note that Codex session logs are read as plain .jsonl or .jsonl.zst,
how duplicate encodings are resolved, and that session lookup accepts
the .jsonl.zst filename.

Co-authored-by: Henry Teo <[email protected]>
@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: db1fd4d2-df29-4548-ac61-414ec0656bef
📥 Commits

Reviewing files that changed from the base of the PR and between bab3023 and 6673fa0.

📒 Files selected for processing: 6
  • docs/guide/codex/index.md
  • rust/adapters/codex/README.md
  • rust/adapters/codex/src/compression_tests.rs
  • rust/adapters/codex/src/parser.rs
  • rust/adapters/codex/src/reader.rs
  • rust/adapters/codex/src/replay.rs
🚧 Files skipped from review as they are similar to previous changes: 2
  • rust/adapters/codex/README.md
  • docs/guide/codex/index.md

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📝 Walkthrough

The Codex adapter now reads plain .jsonl and Zstandard-compressed .jsonl.zst rollouts. It normalizes session IDs across encodings, deduplicates equivalent rollout paths, and includes tests for compressed parsing and report output.

Changes: Codex rollout handling

Layer / File(s) Summary
Rollout reader and file collection
rust/adapters/common/src/lib.rs, rust/adapters/common/README.md, rust/Cargo.toml, rust/adapters/codex/Cargo.toml, rust/adapters/codex/src/reader.rs, rust/adapters/codex/src/lib.rs, rust/adapters/codex/README.md
The common collector now accepts a path predicate. The Codex adapter adds a reader for plain and compressed rollouts, with Zstandard decoding for .jsonl.zst files.
Rollout discovery and identity
rust/adapters/codex/src/paths.rs, rust/adapters/codex/src/lib.rs, rust/adapters/codex/src/parser.rs, rust/adapters/codex/src/README.md, docs/guide/codex/index.md, docs/guide/session-reports.md
Discovery recognizes both encodings and deduplicates normalized rollout paths. Session ID lookup accepts compressed filenames. Documentation describes encoding and directory precedence.
Compressed parsing and report validation
rust/adapters/codex/src/parser.rs, rust/adapters/codex/src/replay.rs, rust/adapters/codex/src/compression_tests.rs, rust/crates/ccusage/Cargo.toml, rust/crates/ccusage/tests/codex_cli.rs
Parser and replay metadata reads use the shared rollout reader. Tests cover compressed usage, replay and compaction cases, report output, and CLI lookup by compressed filename.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant CodexAdapter
  participant collect_codex_usage_files
  participant visit_codex_session_file_with_compaction_history
  participant open_rollout_reader
  participant ZstandardDecoder
  CodexAdapter->>collect_codex_usage_files: discover rollout paths
  CodexAdapter->>visit_codex_session_file_with_compaction_history: process selected rollout
  visit_codex_session_file_with_compaction_history->>open_rollout_reader: open rollout path
  open_rollout_reader->>ZstandardDecoder: decode .jsonl.zst stream
  ZstandardDecoder-->>open_rollout_reader: decoded JSONL stream
  open_rollout_reader-->>visit_codex_session_file_with_compaction_history: buffered reader
Loading
Merge Risk: ⚪ Minimal · up to 6673f

No actionable merge-blocking issue was identified for compressed Codex rollout support; normal checks remain appropriate before merging.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 48 functions across 8 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the primary change: adding support for Zstandard-compressed Codex rollout files.
Linked Issues check Passed Issue #1888 requires direct support for .jsonl and .jsonl.zst Codex rollouts, stable session IDs, replay handling, and deduplication. The PR discovers both formats, streams compressed data through…
Out of Scope Changes check Passed The changes remain within issue #1888. The shared file predicate, zstd dependency, Codex reader and path logic, regression tests, CLI test, and documentation directly support compressed rollout disc…
Full details: Docstring Coverage

Explanation

Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 48 functions across 8 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

🔧 Clippy (1.98.1)

Clippy execution failed

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.

@socket-security

socket-security Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​zstd@​0.14.210010093100100

View full report

@pkg-pr-new

pkg-pr-new Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

ccusage

npx https://pkg.pr.new/ccusage@1890

@ccusage/ccusage-darwin-arm64

npx https://pkg.pr.new/@ccusage/ccusage-darwin-arm64@1890

@ccusage/ccusage-darwin-x64

npx https://pkg.pr.new/@ccusage/ccusage-darwin-x64@1890

@ccusage/ccusage-linux-arm64

npx https://pkg.pr.new/@ccusage/ccusage-linux-arm64@1890

@ccusage/ccusage-linux-x64

npx https://pkg.pr.new/@ccusage/ccusage-linux-x64@1890

@ccusage/ccusage-win32-x64

npx https://pkg.pr.new/@ccusage/ccusage-win32-x64@1890

commit: 6673fa0

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: bab3023b5344
Base SHA: 8fc152d5d7e0

Performance comparison skipped.

Base package URL was not ready before 300.000s. Fixture performance comparison requires a base package when --base-dir is not provided.

Base package: 8fc152d5d7e0

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: bab3023b5344
Base SHA: 8fc152d5d7e0

Performance comparison skipped.

Base package URL was not ready before 300.000s. Fixture performance comparison requires a base package when --base-dir is not provided.

Base package: 8fc152d5d7e0

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
ccusage-guide 6673fa0 Commit Preview URL

Branch Preview URL
Oct 14 2026, 08:14 PM

@ryoppippi
ryoppippi marked this pull request as ready for review October 14, 2026 19:26

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 17 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="rust/adapters/codex/README.md">

<violation number="1" location="rust/adapters/codex/README.md:28">
P3: `loader.rs` implements ordered parallel reads itself; `ccusage-adapter-common` supplies walking and chunking. Attribute parallel reads to the adapter to avoid misleading maintainers about where this behavior lives.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/guide/codex/index.md Resolved
Comment thread rust/adapters/codex/src/parser.rs Resolved
Comment thread rust/adapters/codex/README.md Outdated
Comment on lines +28 to +29
Uses `ccusage-adapter-common` for walking, size-balanced chunking, and ordered parallel
reads; `reader.rs` streams `.jsonl.zst` rollouts through `zstd` without unpacking them to disk.

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.

P3: loader.rs implements ordered parallel reads itself; ccusage-adapter-common supplies walking and chunking. Attribute parallel reads to the adapter to avoid misleading maintainers about where this behavior lives.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At rust/adapters/codex/README.md, line 28:

<comment>`loader.rs` implements ordered parallel reads itself; `ccusage-adapter-common` supplies walking and chunking. Attribute parallel reads to the adapter to avoid misleading maintainers about where this behavior lives.</comment>

<file context>
@@ -19,12 +20,13 @@ Anything that is not specific to this source belongs in `ccusage-core` or
 
-Reads plain files through `ccusage-adapter-common`, which handles walking, size-balanced
-chunking, and ordered parallel reads.
+Uses `ccusage-adapter-common` for walking, size-balanced chunking, and ordered parallel
+reads; `reader.rs` streams `.jsonl.zst` rollouts through `zstd` without unpacking them to disk.
 
</file context>
Suggested change
Uses `ccusage-adapter-common` for walking, size-balanced chunking, and ordered parallel
reads; `reader.rs` streams `.jsonl.zst` rollouts through `zstd` without unpacking them to disk.
Uses `ccusage-adapter-common` for walking and size-balanced chunking; the adapter performs ordered
parallel reads, while `reader.rs` streams `.jsonl.zst` rollouts through `zstd` without unpacking them to disk.

Comment thread rust/adapters/codex/README.md Resolved

@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 new issues found. Not approving per repository policy; mergeable as-is.

Reviewed changes

Reviewed bab3023 against main, covering Zstandard-compressed Codex rollout support end to end.

  • Discovery covers both encodings: collect_files_matching helper plus is_rollout_file predicate collect .jsonl and .jsonl.zst in one walk while ignoring other .zst files.
  • Streaming decompression: new reader.rs decodes .jsonl.zst through zstd into the existing parser, replay metadata probe, and rewritten-burst detection, with concatenated-frame and truncated-tail coverage.
  • Stable identity and dedupe: session IDs and cross-directory dedupe keys normalize to the plain .jsonl path; plain beats compressed for the same rollout and sessions/ beats archived_sessions/ regardless of encoding.
  • Race fallback and lookup: missing-file opens fall back to the other encoding, and codex session --id accepts .jsonl.zst filenames.
  • Tests and docs: fixture-backed compression, dedupe, replay, and CLI tests plus Codex guide and adapter README updates.

Pullfrog  | Fix it ➔ | View workflow run | Using muse-spark-1.3-contributor (free via Pullfrog for OSS) | 𝕏

cursoragent and others added 2 commits October 14, 2026 20:14
When a discovered rollout is compressed or restored before it is read,
the reader opens the other encoding, but the fallback timestamp for
timestamp-less headless usage still came from the vanished path and
fell back to 1970. Return the opened path from open_rollout_reader and
take the modification time from it.

Reported by cubic on #1890.

Co-authored-by: Henry Teo <[email protected]>
Scope plain-over-compressed precedence to one directory and state that
sessions/ beats archived_sessions/ regardless of encoding. List every
recursive rollout path, and attribute ordered parallel reads to
loader.rs rather than ccusage-adapter-common.

Reported by cubic on #1890.

Co-authored-by: Henry Teo <[email protected]>

@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 new issues found. Not approving per repository policy; mergeable as-is.

Reviewed changes

Reviewed the delta since the prior pullfrog review (bab3023..6673fa0, 2 commits).

  • Fixed headless fallback dating to use the mtime of the rollout file actually opened after an encoding race, with a regression test covering plain-to-compressed migration.
  • Clarified rollout precedence docs to state plain-wins within a directory and sessions/-wins across directories regardless of encoding.
  • Corrected the adapter README to attribute ordered parallel reads to loader.rs rather than ccusage-adapter-common.

Pullfrog  | Fix it ➔ | View workflow run | Using muse-spark-1.3-contributor (free via Pullfrog for OSS) | 𝕏

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: 6673fa01360d
Base SHA: 8fc152d5d7e0

Performance comparison skipped.

Base package URL was not ready before 300.000s. Fixture performance comparison requires a base package when --base-dir is not provided.

Base package: 8fc152d5d7e0

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: 6673fa01360d
Base SHA: 8fc152d5d7e0

Performance comparison skipped.

Base package URL was not ready before 300.000s. Fixture performance comparison requires a base package when --base-dir is not provided.

Base package: 8fc152d5d7e0

@ryoppippi
ryoppippi merged commit 44a3aa8 into main Oct 14, 2026
45 checks passed
@ryoppippi
ryoppippi deleted the cursor/codex-zstd-rollouts-1144 branch October 14, 2026 20:40
ryoppippi added a commit that referenced this pull request Nov 10, 2026
The generated Codex fixture is mostly zstd-compressed rollouts, some in
`archived_sessions/`. Releases before #1890 skip `.jsonl.zst` files
(and before #1230 skip archived sessions), so a history run across
that change compared an old ref reading a fraction of the input with a
new ref reading all of it, and reported the difference as a CPU and
RSS regression.

The generator gains `--codex-layout legacy`, which writes the same
rollouts uncompressed under `sessions/`. bench-history keeps the
current layout when every ref is a commit containing #1890, and
otherwise generates the legacy layout and says so at the top of the
report; binary paths count as unknown. With a custom Codex fixture
that holds `.zst` files and a ref that cannot read them, and for
`--real` runs across the change, the report leads with a warning
instead. The PR comparison always builds refs that read the current
layout, so CI keeps compressed coverage.

Reported by CodeRabbit on #2061.
ryoppippi added a commit that referenced this pull request Nov 10, 2026
The compatibility check only looked for `.jsonl.zst` files, but
releases before #1230 also skip `archived_sessions/`, so a custom
fixture with archived plain rollouts got no warning and a history run
across #1230 compared different input.

Replace the single zstd commit with a table of Codex input classes taken
from the Codex loader's history (`paths.rs`, `reader.rs`): archived
sessions (a9b12c4, #1230, v20.0.7) and zstd-compressed rollouts
(44a3aa8, #1890, v20.0.33). Each ref is checked per class by git
ancestry; the generated fixture switches to the legacy layout when any
ref skips any class, and a custom fixture or `--real` run warns per
class with the refs that skip it. Claude discovery has scanned
`projects/` recursively throughout v20 and the date-window skips (#1665,
#1794) do not apply to unbounded reports, so they are not classes.
Probing each ref's source for the reader code was rejected: the same
strings appear in tests and comments.

Reported by Cubic on #2061.
ryoppippi added a commit that referenced this pull request Nov 10, 2026
#2061)

* build(perf): shape the large fixture like current Claude Code and Codex logs

The CI performance comparison missed the double parse that #1719 added
to every Claude line containing `null`, because the generated fixture
never contained `null`: each line was a minimal assistant usage record
padded with a single text block of `x` characters. Real Claude Code
lines now carry `stop_sequence`, `container`, `stop_details` and other
null-valued fields, nested `usage.iterations` and cache-creation TTL
splits, and most lines are not usage records at all. On the old fixture
v20.0.20 -> v20.0.21 moved CPU time by +8%; on real-shaped lines it is
+55%.

The generator now writes sessions whose line types, per-type byte
share, and field shapes follow aggregate statistics taken from real
logs (structure only; every value is synthesized from a seeded PRNG,
so the output is deterministic):

- Claude: user/tool-result, attachment, api-request(-blob) and metadata
  lines around assistant responses written one line per content block
  with repeated usage, null-valued fields, `usage.iterations` including
  occasional advisor iterations, 5m/1h cache-creation split, escaped
  and non-ASCII text, 48 projects, sidechain `subagents/agent-*.jsonl`
  files that replay parent message IDs, and legacy files with
  `progress` lines wrapping AgentProgress assistant messages.
- Codex: dated `sessions/YYYY/MM/DD/rollout-*.jsonl` files with
  session_meta, turn_context, reasoning, tool call/output and
  item_completed lines, plus token_count (with null-heavy rate limits)
  and token_usage_record per response. As in real Codex homes, rollouts
  older than two weeks are zstd-compressed (`.jsonl.zst`) and some sit
  in `archived_sessions/`; on real data about 90% of Codex bytes are
  now read through zstd, a path the old fixture never touched.

The file-size distribution (of uncompressed bytes) and the CLI are
unchanged.

* build(perf): build base and PR from source and judge them by CPU time

The PR performance comment could not show a regression or a real
improvement:

- Unequal artifacts. Base ran the published `ccusage` npm package (Node
  launcher spawning the native binary) while the Rust leg ran the bare
  native binary, so every PR showed a ~2.6x "win" on the committed
  fixtures that was only launcher startup.
- One sample. The large fixture ran with 0 warmups and 1 run, and the
  base and PR runs were not interleaved, so runner noise dominated.
- The committed fixtures are a few KiB and measure process startup.
- No verdict. Nothing said whether a number was a regression.

The job now builds both binaries with `nix build .#ccusage-static`, the
derivation the Linux x64 native package ships, on the same runner: the
PR head from the checkout and the base from
`github.event.pull_request.base.sha`, the same base commit as before.
The walk back over pricing-only commits only existed to find a commit
with a pkg.pr.new package, so it goes. The job reuses the
build-native-packages sticky disk for dependency artifacts and no
longer waits for pkg.pr.new, so the JS launcher leg, the
committed-fixture section, and the package diagnostics are gone too.

The harness runs `claude daily`, `claude session`, `codex daily` and
the unified `daily` over the realistic generated fixture in interleaved
rounds (each round one hyperfine invocation with both binaries, order
rotated), recording wall time, CPU time, peak RSS and, where the
platform exposes it, instructions. It reports medians with min-max
spread and per-round PR/base ratios. A command is flagged when the
median CPU-time or peak-RSS ratio moves past 10% and at least four in
five rounds agree; the verdict line and a GitHub warning annotation say
so. Wall time is shown but not judged: it moves with runner load, while
CPU time paired within a round does not.

The check warns rather than fails: a failing check needs a calibrated
false-positive rate across many runs on these runners and an escape
hatch for intentional trade-offs, and neither exists yet.

* build(perf): add `just bench-history` to compare refs

Finding which release introduced a slowdown meant building each tag by
hand and timing it ad hoc. `just bench-history <ref|binary>...` builds
each ref in release mode into `rust/target/bench-history` (one shared
cargo target dir, binaries cached by commit), generates the realistic
fixture once per generator revision and size, and runs every report
command over all refs in the same interleaved rounds as the PR
comparison. Each ref is judged against the previous one with the same
CPU-time/peak-RSS rule, so a regression shows up at the step that
introduced it. `--real` adds a section over the logs the current
environment points at.

It reuses the PR harness modules instead of adding a parallel tool, and
the profile skill now points at it.

* fix(perf): print the regression annotation on stdout

GitHub Actions reads workflow commands only from stdout, but the
`::warning` line was printed while `*out*` was bound to `*err*`, so a
flagged regression never produced an annotation. Print it on stdout and
pass the GitHub Actions check in, so a test can assert the stream.

Reported by Pullfrog on #2061.

* fix(perf): reject bench-history runs below the minimum

`parse-history-cli` validated a copy with `:runs` raised to 3 but
returned the original options, so `--runs 2` passed validation and then
measured two rounds, too few for the four-in-five agreement rule.
Validate the options that are returned, so fewer than three rounds is
rejected as in the PR comparison, and say so in both `--runs` helps.

Reported by Pullfrog on #2061.

* fix(perf): run fixture benchmarks in an empty environment

The fixture runs pointed Claude and Codex at the fixture and moved HOME
and XDG_* to an empty directory, but every other agent has its own
data-dir variable (AMP_DATA_DIR, OPENCODE_DATA_DIR, PI_AGENT_DIR,
GEMINI_DATA_DIR and a dozen more), and a value set in the caller's shell
still pointed the unified `daily` report at real logs. Listing every
variable would rot as agents are added, so the fixture commands now run
under `env -i` with only the variables the run needs; with XDG_* unset
the agents fall back to the empty HOME. The local-logs mode keeps the
caller's environment on purpose.

Reported by Cubic on #2061.

* fix(perf): make bench-history safe to point at binaries and run twice

Four ways `just bench-history` could measure the wrong thing or fail:

- Binary-path variants were labelled by basename, so two builds at
  `a/ccusage` and `b/ccusage` collided in hyperfine's results. Label
  them by the argument as given, and reject a ref or path passed twice.
- `--fixture-dir` alone silently reused the Claude logs as the Codex
  fixture. Require both directories or neither.
- The cached binary was copied without attributes; copy them so the
  executable bit is kept on every platform.
- Every run checked refs out into the shared `<cache>/src` worktree and
  removed it on exit, so a second run sharing the cache deleted the
  first run's checkout mid-build. Each run now uses its own temporary
  worktree; the shared cargo target is safe because cargo locks it.

The first three were reported by Cubic on #2061; the fourth showed up
while bisecting with two runs at once.

* fix(perf): report memory-only improvements and history instruction ratios

The overall verdict counted only CPU improvements, so a PR that cut
peak RSS read "no significant change" above a table saying "less
memory". Count CPU and RSS improvements alike and name what improved
per command. The history tables also dropped the instruction-count
ratio the PR comparison shows; add it as a column whenever hardware
counters are available.

Reported by Cubic on #2061.

* test(perf): restore duplicate-name and time-unit hyperfine cases

The hyperfine reader rewrite kept the missing-name and malformed-output
cases but lost the duplicate-name and non-second-unit ones, so a change
that accepted ambiguous or mis-scaled samples would pass. Restore both
against the new per-run reader.

Reported by Cubic on #2061.

* fix(perf): print the generated fixture's size on disk

With zstd-compressed Codex rollouts the generator's `Size` line showed
the uncompressed bytes, about four times what it wrote. Print the size
on disk from `Bun.write` and the uncompressed size separately.

Reported by Cubic on #2061.

* fix(ci): measure the PR merged onto its base commit

The job built the PR side from `pull_request.head.sha` and the base
from `pull_request.base.sha`. When a branch is behind its base, the two
binaries differ by the base's newer commits as well as the PR's own
change, and those commits show up as PR regressions or improvements.

Build the checked-out merge commit (`github.sha`) instead. It is the PR
applied on top of the base commit, so the diff measured is exactly the
PR. A step fails the job if the base is not an ancestor of the built
commit, so the report's "merged onto Base:" line always names what was
actually built. BASE_SHA/HEAD_SHA fall back to `merge_group.base_sha`
and `github.sha`, and the PR-comment step is limited to pull_request
events, so the job also works if a merge_group trigger is added.

Reported by CodeRabbit on #2061.

* fix(perf): cache built bench-history binaries atomically

`build-commit!` copied the release binary straight to the cache path
that later runs trust when it exists, so an interrupted copy left a
truncated binary that every later run reused. Copy to a `.partial`
sibling and rename it into place with an atomic move.

Reported by CodeRabbit on #2061.

* fix(perf): start fixture generation from an empty directory

A cached fixture without its `.complete` marker was regenerated into
the same directory, so files from an interrupted run could remain next
to the new output. Delete the directory first, so the marker always
describes exactly what the generator wrote.

Reported by CodeRabbit on #2061.

* fix(perf): order Codex usage records like real rollouts and add compactions

The generator wrote each response's `token_count` before its
`token_usage_record`. Real rollouts write the record first, and the
parser settles a pending record only when a later `token_count`
advances the cumulative total, so every synthetic record stayed in the
compaction pending map until the end of its file. That inflated peak
RSS in a way real logs never do: on the corrected fixture `codex daily`
peaks at 56 MiB on main instead of 69 MiB.

Write the record before the `token_count`, as Codex does, and add the
remote compactions real rollouts contain: about one turn in 30 ends
with a usage record whose usage is not in the cumulative total,
followed by a `compacted` marker naming it and carrying a large
replacement history. In real logs every marker names an earlier record.

Reported by Cubic on #2061.

* fix(ci): gate the perf job on preflight and keep its token read-only

The old perf job depended on the pkg.pr.new publish job and so, through
it, on `preflight` (secret scan and workflow lint); building from
source dropped that dependency, letting a PR that fails the scan run
the benchmark anyway. Depend on `preflight` directly.

The job also ran PR-built binaries, the fixture generator and the
harness with `pull-requests: write` and `issues: write`. Split it:
`ccusage-perf` builds and measures with a read-only token and uploads
the report, and `ccusage-perf-comment` downloads it and only runs the
comment upsert with the write token.

Reported by Cubic on #2061.

* fix(perf): lock bench-history builds and fixture generation

Runs sharing the cache could interleave writes: one build could replace
`target/release/ccusage` in the shared cargo target before another run
copied it into its commit's cache slot, and two runs could delete and
regenerate the same fixture under each other. Build-and-copy now runs
under a cache-wide file lock, and each fixture key under its own, both
re-checking the cache once they hold the lock. A per-commit target dir
would avoid the lock but rebuild every dependency for every ref.

The lock is a FileChannel lock released by closing the channel, and a
test runs three processes against it and checks their critical
sections never overlap.

Reported by Cubic on #2061.

* fix(ci): drop the perf job's unreachable merge_group fallbacks

The perf job only runs on `pull_request`, and #2062, which adds the
`merge_group` trigger, keeps the perf comment PR-only, so the
`merge_group.base_sha` and `github.sha` fallbacks and the comment
promising merge_group support could never apply. Remove them.

Reported by Cubic on #2061.

* fix(perf): give bench-history a Codex fixture every compared ref reads

The generated Codex fixture is mostly zstd-compressed rollouts, some in
`archived_sessions/`. Releases before #1890 skip `.jsonl.zst` files
(and before #1230 skip archived sessions), so a history run across
that change compared an old ref reading a fraction of the input with a
new ref reading all of it, and reported the difference as a CPU and
RSS regression.

The generator gains `--codex-layout legacy`, which writes the same
rollouts uncompressed under `sessions/`. bench-history keeps the
current layout when every ref is a commit containing #1890, and
otherwise generates the legacy layout and says so at the top of the
report; binary paths count as unknown. With a custom Codex fixture
that holds `.zst` files and a ref that cannot read them, and for
`--real` runs across the change, the report leads with a warning
instead. The PR comparison always builds refs that read the current
layout, so CI keeps compressed coverage.

Reported by CodeRabbit on #2061.

* fix(perf): check every Codex input class a compared ref may skip

The compatibility check only looked for `.jsonl.zst` files, but
releases before #1230 also skip `archived_sessions/`, so a custom
fixture with archived plain rollouts got no warning and a history run
across #1230 compared different input.

Replace the single zstd commit with a table of Codex input classes taken
from the Codex loader's history (`paths.rs`, `reader.rs`): archived
sessions (a9b12c4, #1230, v20.0.7) and zstd-compressed rollouts
(44a3aa8, #1890, v20.0.33). Each ref is checked per class by git
ancestry; the generated fixture switches to the legacy layout when any
ref skips any class, and a custom fixture or `--real` run warns per
class with the refs that skip it. Claude discovery has scanned
`projects/` recursively throughout v20 and the date-window skips (#1665,
#1794) do not apply to unbounded reports, so they are not classes.
Probing each ref's source for the reader code was rejected: the same
strings appear in tests and comments.

Reported by Cubic on #2061.

* fix(ci): run the perf comment upsert from the base commit

The comment job holds `pull-requests: write` and `issues: write`, but it
checked out the PR merge commit and ran that tree's setup action and
`upsert-pr-comment.nu`, so a PR that edited either ran them with the
write token. Check out `pull_request.base.sha` instead, still without
persisted credentials. The PR's report reaches the job only as an
artifact the script reads as text.

This PR does not change `upsert-pr-comment.nu`, and the base version
reads the same inputs (COMMENT_FILE as raw markdown, COMMENT_MARKER,
PR_NUMBER, GITHUB_REPOSITORY, GITHUB_TOKEN), so the first runs after
merge need no fallback.

The workflow file itself still comes from the PR on `pull_request`
events, so this removes the PR's scripts from the write job rather than
making the job a hard trust boundary.

Reported by CodeRabbit on #2061.

* fix(perf): match Codex input classes to the rollouts the loader reads

The input-class matchers looked at absolute paths: any `.zst` file
counted as a compressed rollout, and any path containing
`/archived_sessions/`, including a parent of the fixture, counted as
an archived session. Match paths relative to the Codex home against
what the loader reads: `*.jsonl` and `*.jsonl.zst` (reader.rs) under
`sessions/` and `archived_sessions/` (paths.rs). Tests cover
non-rollout `.zst` files, rollouts outside those directories,
non-rollout files in `archived_sessions/`, and a fixture under a
directory named `archived_sessions`.

Reported by Cubic on #2061.
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.

Codex compressed rollouts are skipped after a Codex update

2 participants