Repository navigation
Description
This was generated by AI from a grilling session with the maintainer.
Problem Statement
The full test suite is too slow for the agent sessions inside a Run. With three Runs on a 4-core machine, a full cargo test took about 15–16 minutes (load 14–26). That is longer than the 600s Bash timeout the sessions run under, so the headless instruction to "run tests in the foreground" cannot be followed. Sessions then improvise waits that go wrong (#180), and each Run pays that cost again after every Repair. Every new Run worktree also starts with a cold build of about 10 minutes before the first test runs. CI, which every Run waits on at least once, pays the same costs.
Measured on 2026-09-30 (4 cores, load 14–26):
- Cold
cargo test --no-run: 616s wall, 329s user + 53s sys. Top units:aws-lc-sysbuild script 372s (via reqwest → rustls),moxcms113s,tokio75s,image56s,pxfm54s (image/moxcms/pxfmvia axoupdater → axoasset). - Warm rebuild after editing
src/: about 13s. The integration test crates never relink, because they use only the built binary. - Test run:
spec_run282s,merge_run140s,release110s,run_notification98s,ci56s, the rest under 40s each. - The time goes on starting processes, not on sleeps. Every scenario runs the real binary end to end, and it starts many
gitcalls and Python fakegh/claudecalls. The harness polls CI every 10ms, and each poll starts the fakeghagain. Tests that loop over cases or over Repair cycles multiply this. Python startup is very sensitive to load (python3 -c pass: 0.48s wall for 0.04s of CPU). - There is no
[profile.dev], so builds use full debuginfo: a 108MB binary and a 1.6GB target dir per worktree.
Solution
Make the suite cheaper to build and to run, and measure the result in a way that doesn't depend on machine load. A session can then run the full suite in the foreground again, and a fresh Run worktree becomes testable in a few minutes rather than ten. The full suite stays the local gate for sessions; this spec does not narrow what they run.
Done means all three:
- A warm full
cargo testfinishes in ≤ 3 minutes wall on an idle machine. - The warm full suite's total CPU time (user + sys) is at most half of a baseline recorded before any change.
- A cold
cargo test --no-runfinishes in ≤ 5 minutes wall on an idle machine.
User Stories
- As an agent session in a Run, I want the full test suite to finish within one foreground Bash call, so that I never have to wait on a backgrounded test run.
- As an agent session in a Run, I want a fresh worktree to build its test targets in a few minutes, so that my first test run doesn't use up most of my time.
- As an agent session in a Repair, I want re-running the suite after a fix to be quick, so that I can verify each fix before pushing.
- As the maintainer running several Runs at once, I want each suite run to use less CPU, so that parallel Runs slow each other down less.
- As the maintainer, I want less disk used per Run worktree, so that many worktrees can live side by side.
- As the maintainer, I want the speed-up measured in CPU time as well as wall time, so that I can tell a real gain from a quiet machine.
- As the maintainer, I want a recorded baseline and after-numbers in the PR, so that I can see what each change bought.
- As the maintainer, I want the shipped binary to keep verifying TLS the way it does now, so that switching the crypto provider changes build time only, not behavior.
- As the maintainer, I want self-update (axoupdater) to keep working, so that trimming dependencies doesn't break
thirdshift update. - As a contributor, I want the end-to-end tests to keep checking the same behaviors, so that a faster suite doesn't mean weaker coverage.
- As a contributor, I want argument-parsing cases covered by fast tests, so that adding a case doesn't add a full end-to-end Run.
- As a contributor, I want the test fakes for
ghandclaudeto stay easy to read and extend, so that new scenarios stay cheap to write. - As a contributor, I want backtraces from a failing test to still show file and line, so that cheaper debug builds don't make failures harder to find.
- As CI, I want shorter build and test jobs, so that a Run's CI wait is shorter.
Implementation Decisions
Apply the levers cheapest first, measuring after each. Once all three targets are met, the rest are optional, but record what was skipped and why.
- Baseline first. Before any change, record on the same machine: cold
cargo test --no-runwall time, warm fullcargo testwall time, and user + sys (e.g./usr/bin/time), with the load average at the time. Report before and after in the PR. - Cheaper debug builds. Add a dev profile with
debug = "line-tables-only"for the crate and no debuginfo for dependencies. Backtraces keep file and line; debugger locals are given up. - rustls crypto provider:
ring, not aws-lc. Configure reqwest/rustls soaws-lc-sysis no longer built at all (checkcargo tree -i aws-lc-syscomes back empty). TLS verification behavior stays the same. - axoupdater's image stack. Drop
image/moxcms/pxfmonly if axoupdater/axoasset offer a feature flag for it. If not, leave the dependency alone and note it in the PR as a follow-up (an upstream change or a replacement updater). Don't fork or replace axoupdater here. - Fewer processes per scenario (lever 1). The CI poll interval is already overridable through the existing environment variable the harness sets. Raise the harness value from 10ms to about 100ms, and adjust the CI grace period the harness sets if needed so CI-wait scenarios still see their states. No production change is needed.
- Fewer end-to-end scenarios (lever 2). Where a test loops over cases that differ only in what they exercise below the Run (e.g. every position
--emailcan take in the arguments), keep one end-to-end scenario and move the case list to unit tests of the argument parsing. The same goes for loops over signals or Repair and budget cycles, where one end-to-end case proves the wiring and the rest can be checked below the binary. Argument parsing has no unit tests today, so this adds that one new seam. - Cheaper fakes (lever 3, only if still needed). Replace the Python fake
gh/claudewith something cheaper to start, e.g. a small Rust helper binary built with the tests, keeping their current contract: state and records in the JSON files the harness names, and the same argv handling. - Don't merge the integration test files into one crate: they never relink when
src/changes, so there is little to gain.
Testing Decisions
- A good test here checks external behavior: the real binary run end to end against the fakes, asserting on exit code, stderr, git state, and the requests the fakes record. It doesn't check how the Run does it internally.
- The end-to-end harness stays the main seam. Every behavior an end-to-end test covers today must still be covered end to end or by a unit test at the level where the cases differ.
- The only new seam is unit tests of argument parsing, alongside the existing unit-test modules in the crate (prior art: the in-crate unit tests for config, email, notification, progress and worktree).
- If the fakes are rewritten, every existing scenario must pass unchanged against the new ones. They are the fakes' test.
- The measurements (baseline and after) are the acceptance test for speed. Include them in the PR.
Out of Scope
- Limiting how many builds run at once across Runs, and a Spec run's default parallelism (filed separately).
- The flaky 10s "the agent never started" deadline in the harness (filed separately).
- Narrowing what agent sessions run (targeted tests instead of the full suite).
- Language-neutral speed-ups in thirdshift itself, e.g. a worktree-setup hook or cache sharing for the repos it runs on.
- Changes to the CI workflow (caching, splitting jobs), beyond what it gains from a cheaper build and suite.
- Replacing or forking axoupdater.
- Merging integration test crates.
Further Notes
- Grilled on 2026-09-30 with the maintainer. Settled: the main aim is agent sessions' wait, with CI second; scope is this repo's own suite; sessions keep running the full suite; the time on an idle machine and the halved CPU time are both targets.
- Related: Sessions wait on a backgrounded command by its own task, not by process name #180 (sessions waiting on a backgrounded command by process name), which this makes rarer but doesn't replace.
Activity
[-]make tests faster[/-][+]make test runs faster[/+]JacobStephens2 commentedon Sep 30, 2026
Triage Notes
Categorized as
enhancementand left inneeds-triage. It needs a target and a decision on where to cut before an agent can take it.Evidence from the #148 Merge run (2026-09-30, running at the same time as the #147 and #158 runs):
cargo buildalone took about 6 minutes (11:45→11:51Z).cargo test --test spec_runtook about 6 more minutes (11:51→11:57Z).cargo testwent past the Bash tool's 600s limit, so it was moved to the background and finished at about 12:08Z. The prompts ask sessions to run tests in the foreground, and that is no longer possible when several sessions run at once. It also led to the session wasting about 7 minutes on a bad wait (filed separately).tests/is its own crate, linked separately against the full dependency tree (reqwest/aws-lc-sys and others). Under contention, that link and build cost is probably most of the time, more than the tests themselves.Still to decide (maintainer):
cargo testfrom a warm cache in under N minutes with 3 sessions running at once.CARGO_TARGET_DIRor sccache across worktrees so each Run doesn't rebuild dependencies from scratch, trim the dependency features the test build pulls in, or look at the slowest individual tests (e.g.merge_run,spec_run).[-]make test runs faster[/-][+]Make the test suite faster[/+]JacobStephens2 commentedon Sep 30, 2026
Baseline
Recorded for #202 on 2026-09-30, before any change (main at 55bcf6e). Every later ticket under this spec compares against these numbers.
Machine: 4 cores, Linux 6.8.0, rustc 1.98.1. Other Runs were active on the machine, so the load was not idle. Load is the 1-minute load average, sampled every 5s during each command.
cargo test --no-run(empty target dir)cargo testAlso:
target/is 1.5GB after the cold build, and the debug binary is 108MB.Commands:
/usr/bin/time -f "wall=%e user=%U sys=%S" cargo test --no-runfrom an emptytarget/, then/usr/bin/time … cargo teststraight after it.JacobStephens2 commentedon Sep 30, 2026
Follow-up from #203: aws-lc and the image stack both stay
Neither stack can be dropped with a feature flag. Both come in through axoupdater:
github_releases = ["axoasset/remote"]. It also usesaxoasset::reqwestwith no cfg gate, so it won't compile withoutremote.remote = ["reqwest", "image"], and it depends on reqwest withfeatures = ["json", "default-tls"]fixed in place. No axoasset feature leaves either out.default-tls = ["rustls"]andrustlsincludes__rustls-aws-lc-rs. Features unify, so switching thirdshift's own reqwest torustls-no-providerplus rustls'sringwould still build aws-lc (checked withcargo tree -i aws-lc-sys), and would build ring on top of it.What would remove them (all out of scope for #203):
imageout ofremote, and depend on reqwest withrustls-no-provider(or offer a TLS-provider feature) instead ofdefault-tls. That removes both stacks with no change here beyond a version bump.image/moxcms/pxfmand aws-lc together, and our reqwest could then move to ring.[patch.crates-io]on axoasset would work locally but is a fork, and it doesn't apply tocargo installfrom crates.io.rustls-tls-webpki-roots) is a downgrade and still brings inimage.#203 records this in a
Cargo.tomlcomment so nobody tries the flag route again.JacobStephens2 commentedon Sep 30, 2026
Result
All three targets are met. The Python fakes were the last big cost: before #207 they used about 352s of the warm suite's 650s CPU (4,940
ghcalls, 381claudecalls). #207 replaces them with a small std-only Rust executable that the tests build withrustc.Machine: 4 cores, Linux 6.8.0, rustc 1.98.1. None of these runs was on an idle machine. Other Runs were active, and the load is the 1-minute load average, sampled every 5s during each command. User + sys doesn't depend on load much, and even the wall times are under target under this load.
cargo test, wallcargo test, user + syscargo test --no-run, wallThe three "after" warm runs are the same code, run one after another. The second and third ran with a
python3stub first onPATHthat fails if called. Everything passed, and nothing called it.The cold
--no-runno longer covers everything a fresh worktree builds: the first test that needs the fakes builds them withrustc(about 1.2s wall, 1.7s CPU). They're then cached intarget/tmp/fakes-<hash of their sources>.Where the remaining time goes. No target is missed. Of the roughly 200s CPU left, about two thirds is sys time, spent starting processes: the real binary,
git, and thebashscripts that stand in for the agent. The slowest binaries arespec_run(35s),merge_run(14s) andrelease(14s). Everything else is under 5s.Commands:
/usr/bin/time -f "wall=%e user=%U sys=%S" cargo test --no-runfrom an empty target dir, and/usr/bin/time … cargo teston a warm one.