Skip to content

Test suite: 17 files side by side, the kit chain in forks, warm CLI processes - #128

Merged
hhff merged 13 commits into
mainfrom
test-speed
Nov 6, 2026
Merged

hhff merged 13 commits into
mainfrom
test-speed

Conversation

@hhff

@hhff hhff commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Makes the kit's suite several times faster in wall time on the same checks: the one-file suite is split into 17 files that run side by side, most of the "devteam kit" chain runs in forks of its fixture, and CLI calls go to node processes started ahead of time. No assertion is removed, loosened or skipped. docs/devteam/scripts/devteam.mjs is not changed.

Run it: node --test --test-concurrency=8 test/*.test.mjs (the README says so). DEVTEAM_TEST_COLD=1 spawns every CLI call fresh, as before.

Timings in CI (GitHub's 4-core runners)

Node main (one job) this branch at 7ea38ef (two jobs side by side: chain, rest)
18 4.6 min 1.7 min
20 5.1 min 1.6 min
22 4.7 min 1.4 min
24 5.6 min 1.4 min

Main's numbers are its run at 05:48Z today; this branch's are the slower of its two jobs. All 8 jobs green.

Timings (this Mac, back to back, one run at a time)

The machine was heavily loaded the whole time (load average 7–27 on 10 cores: other fleets, another session's kit suite, syspolicyd/Spotlight), and every node exec waited on macOS's security checks (0.3–3.7 s sequential). Absolute numbers are inflated; the pairs are what compare.

Node main (one file) this branch speed-up
24.14.1 1640 s (129/129) · 1740 s (128/129: link-probe budget flake) 283 s* · 318 s* · 530 s (129/129) 3.3–5.8×
20.19.6 2461 s (129/129) · 2545 s (129/129) 319 s (129/129) · 689 s (127/129: lost-Enter, below) 3.7–7.7×
18.20.4 4212 s (129/129) · 8489 s (129/129) 2889 s · 5463 s (127/129: lost-Enter) see below

* the 283 s run failed two dashboard tests on a missing import, and the 318 s run one test on a race in the warm-process runner; both fixed since (commits below).

CPU (user+sys) is about the same: Node 24 537 s (main) vs 601 s; Node 20 470 s vs 575 s. The gain is wall time from running in parallel, not less work.

Node 18: both Node 18 runs of this branch overlapped another session's Node 18 kit suite on the same machine, and the second ran 91 minutes. A single file on Node 18 runs within 1.4× of Node 20 (77–93 s vs 59 s), warm or cold, so I don't see a Node 18 defect in the approach, but I can't show a clean local Node 18 number. CI's numbers on this PR are the clean comparison (main's CI: 4.6–5.6 min per Node job).

What changed and why

  • Files that run side by side. test/kit.test.mjs held the whole suite, and node runs one file's tests in order. The 80 standalone tests moved, verbatim, into 16 files grouped by area; each heavy test (fleet park, coverage at landing, url links with no lines) has a file to itself. Helpers moved to test/lib/kit.mjs. Each test process gets its own tmux server (main's tmux shim, kept as the one shim) and its own block of 100 ports, claimed by lock file in test/lib/ports.mjs instead of pid % 290, so files (and another checkout's suite) never share one.
  • The "devteam kit" chain in forks. Its 48 steps (verbatim, in test/lib/chain.mjs) are one stateful story, so they can't simply be separate tests. Instead, at a forked step's place the run copies the fixture (snapshot), and a child process (test/lib/fork.mjs) runs that step on the copy (restore rewrites the copy's absolute paths in git's files and run/, in one pass). The forked step sees exactly the state it would have seen in line; the steps after it go on without it. 31 of 48 steps fork (the list and the rule are at the top of test/kit.test.mjs); consecutive steps that build on each other fork together (approve-plan → D4 → spend; the five artifact steps). Each forked step is still a subtest of "devteam kit" with its own name, pass/fail and diagnostics.
  • Warm CLI processes (test/lib/cli.mjs, test/lib/cli-runner.mjs). Most of a CLI call's cost here was starting node. Each test process keeps three node processes started ahead of time; a call hands one its cwd, environment and argv, it imports devteam.mjs exactly as node devteam.mjs … would and exits. One process per call, so every call is still fresh. Byte-identical stdout, stderr and exit status against spawnSync on status, metrics --json, validate and an unknown command; ~10× faster per call when exec is slow (1148 → 117 ms). Calls that pipe stdin spawn as before.
  • CI: each Node version runs as two jobs side by side, the chain and the rest; files are named, never discovered. test/bounds.test.mjs (the coverage function timed against 500 ms, moved verbatim) runs alone in its own step first: beside seven other test processes on a 4-core runner it read 501 ms. The bound is unchanged.
  • Review fixes (7ea38ef): the real tmux is found past any shim on PATH (forks and warm processes inherit their parent's); the shim lives at a fixed path named for its content and is never removed; port blocks stay in 20000–32699, below Linux's ephemeral range; hello asks one question before it counts the inbox (one line added at the top of that step: with the steps that left questions open now in forks, its two inbox checks compared 0 with 0).

Flakes made deterministic

  • Pane checks ("silent for N min", the API-error and permission-prompt panes, the usage-limit banner): the test polled the pane for at most 2 s before asserting what the kit saw in it; under load the pane wasn't drawn yet. They now wait until the pane shows what was drawn (up to 30 s, returning at once when it does).
  • Lost Enter, "exactly one Enter" / "a second pass presses nothing": a fixed 300 ms wait for a pressed key to reach the stand-in agent; now it waits for every press the kit logged to arrive.
  • The hub and dashboard tests: serverUp and until gave up after 30 s, shorter than a dashboard's first background hello on a loaded machine; now 2 min (a healthy run returns at once).
  • Link probes, second tick: the long budget from An owner's answer binds to the question as the owner saw it #116, kept.

Not fixed — needs a decision. Lost Enter, "sent seconds ago: a slow submit is not double-pressed" writes a record dated 5 s ago and expects the kit (threshold 30 s) not to press. When the supervisor's metrics takes more than 25 s to reach the pane check, the record is 30 s old and the kit rightly presses. It failed on main's suite under load too (once in my baseline runs), and fails more often here because the machine is busier while forks run. I moved the record's write to just before metrics (it used to be written before the pane even started). Making it deterministic needs either a clock the kit reads from the environment (a behaviour change to devteam.mjs, which this PR doesn't make) or dating the record in the future (which changes what the step checks). Reviewer's call.

Every assertion still runs

Test names: main has 129 (as of 012e082, #105 merged in) (devteam kit, its 48 subtests, 80 top-level tests); this branch has the same 129 names, none added or removed (checked by script).

Assert lines, textually: every line that calls assert. in main's test/kit.test.mjs (012e082), trimmed and sorted, against the same over this branch's test/*.test.mjs and test/lib/*.mjs:

main: 1713 assert lines; branch: 1713
diff: identical

#105's new checks (release decisions in the merge chain, hand-written default and release decisions, malformed release values) are in the chain steps, verbatim. One consequence: forge git now asserts release-production is below rung 3, which the decisions step's correction leaves, so decisions runs in line again (31 of 48 steps fork).

Assertion calls were traced by a preload that wraps every node:assert/strict method and records, per call site (the calling line's source text), each call and its normalized actual value (ids, shas, times, temp paths and ports masked). Node 24, complete runs:

assert calls: main 3705 at 1653 sites; this branch 3705 at 1653 sites
by method   : equal 2202, match 802, ok 271, deepEqual 168, doesNotMatch 155, notEqual 105, throws 1, doesNotThrow 1 — identical
sites with a different count: 0

Runs where a test failed differ only after the failing line (the main Node 24 run that hit the link-probe flake; the Node 20 branch run that hit lost Enter: 4 sites, all after it).

Same assertions, different values. Two runs of main's own suite differ in actual values at 19 sites (times, durations, ordering). Against main, this branch differs at 39, so about 20 come from forks: a forked step no longer sees what the other forked steps did. Every one still passes and checks the same thing; the notable ones:

  • hello: its inbox checks counted 4 open questions on main and would have counted 0 here; the step now asks one first, so they compare a real count (see review fixes).
  • log-check, goal list, roadmap, history, retro --print, knowledge --status accepted: the same lines asserted, among fewer others (another fork's goals, rules or log lines aren't there).
  • review-worktree's "and git forgets them", the artifact refusals: the same refusal or listing, with a settings-drift warning line or a shorter worktree list beside it.
  • acts(), a review draft's logLines(): equal before and after, on smaller logs.

Found on the way

  • tmux falls back to the machine's default server when TMUX_TMPDIR names a deleted directory; this suite's own exit handlers hit that once and killed a live fleet's sessions. Main's tmux-guard shim (merged since) is the one kept here; fleet fixtures stop their own server before removing its directory.
  • On this Mac, starting node from this terminal waits on syspolicyd/trustd (0.3–3.7 s sequentially, ~25–45 ms in parallel). Adding the terminal app under Privacy & Security → Developer Tools and restarting it should remove most of that, for every devteam fleet on the machine too.

Behaviour changes to devteam.mjs: none.

🤖 Generated with Claude Code

hhff and others added 10 commits November 5, 2026 20:30
…eaf steps run in forks

WIP: assertion-trace evidence and timings to follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r servers bounded at 2 min

Starting node is most of a CLI call's cost on a loaded Mac; each warm process still runs one command,
fresh, with its own cwd, environment and argv (output byte-identical to spawnSync). Calls with stdin
and DEVTEAM_TEST_COLD=1 spawn as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… only

The copy's name extends the original's, so a second pass rewrote paths it had already rewritten and
git saw the worktrees as prunable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…test file

main's tmux shim is kept (the one shim); the split's own is dropped. The 33 multi-repo tests land in
five files of their own; the artifact steps fork together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… place

The test process could read the file between its creation and its write, and take an empty code for 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…test file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the parallel files

It times the kit's coverage function against 500 ms; on a 4-core runner beside seven other test
processes it read 501. The bound is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hhff

hhff commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Review (upstream loop): CHANGES. One blocker. tmux safety and assertion coverage hold.

Blocker

  1. CI is red at 096b3f2. kit (20, rest) failed components.test.mjs:95 "coverage of 50k paths x 30 globs took 501 ms (bound 500)" (run 42229438868).
    • Cause: wall-clock bounds were set for running alone. Now up to 8 files share a 4-vCPU runner, so this will keep flaking.
    • Same risk: components.test.mjs:100 (500 ms) and chain.mjs:2220 (hello in under 300 ms, in line while about 20 forks run).
    • Fix: run the timed checks in a file that runs alone, or measure CPU time (process.cpuUsage) instead of wall time.

Should-fix

  1. Fork children resolve realTmux to the parent's shim. command -v tmux finds dt-tmuxshim-*/tmux first. When the parent exits, that path is gone, the child's kill-server fails, and its tmux server leaks. This fails safe, but 246369f's "find the real tmux past it" was lost when main's shim was merged in. Restore it: skip any tmux that is a shim.
  2. An orphan loses the shim when it's deleted at exit. A process that outlives its test (the dashboard's hello child) then finds the real tmux on PATH with a deleted TMUX_TMPDIR, so it reaches the default server. It only sends read-only list-* there, and main behaves the same. Still, keep the shim at a path that never goes away (a committed test/lib/bin/tmux).
  3. hello's Inbox checks compare 0 questions with 0. equal(h.inbox, …) and the deepEqual with /api/hello are now vacuous for questions. Leave one question open in line before hello.
  4. Port blocks span 20000–49099. On Linux, ephemeral ports start at 32768, so freePort() or a client socket can land in another file's block. Keep blocks below 32768 (127 blocks is plenty).
  5. The CI comment "files are named, never found" is wrong. The rest job finds files by glob, which is good, so new files can't be missed. Fix the comment.

Nits

  • Forks start with no cap on how many run at once, and the parent has no timeout. Cap them near os.availableParallelism() and add a timeout.
  • --test-concurrency=8 does nothing for the single-file chain job.
  • Warm stdout goes to a file, not a pipe. On macOS a truncating process.exit() could pass warm and fail cold; Linux CI is unaffected.

What held

  • tmux:
    • every kill-server runs with the shim first on PATH and an existing private dir, and each dir is deleted only after its own server;
    • every PATH override adds to the front;
    • warm runners and fork children carry their own live dt-tmux-* dir (checked with ps eww; an outer guard shim refused nothing);
    • idle warm runners never call tmux.
  • Coverage: all 129 test names exist, CI runs every file, and the README command runs all files. devteam.mjs is unchanged against main.
  • Assertions: the 1696 assert.* lines are textually identical to main's. Chain step bodies match, apart from longer pane-poll limits and the lost-Enter settle. Main's new assertions from Every lesson waiting on the owner reaches the Inbox #122–A decision on a default needs rung 2, the deadline passed, and the answer unused #127 are carried over verbatim.
  • Warm runner: one process per call, with a fresh module cache, exit code and cwd, and the env replaced. Stdin is /dev/null. DEVTEAM_TEST_COLD=1 works.
  • CI isolation: each job has its own VM.

On the "sent seconds ago" lost-Enter case: I'd keep it as written, since a kit clock override is a separate change.

Method: read-only review, plus one test file (registry-no-lines.test.mjs, passed) in a scratch worktree with an outer guard shim. Nothing else was run.

hhff and others added 3 commits November 6, 2026 09:09
…hat stays, hello counts an open question, ports below 32768

- a fork or warm process inherits its parent's PATH, shim first: the real tmux is the first on PATH
  that isn't a shim
- the shim is written once, whole, at a path named for its content, and never removed
- hello's inbox checks compared 0 with 0 once the steps that left questions open moved to forks;
  it asks one first
- port blocks stay in 20000-32699, below Linux's ephemeral range
- the CI comment names where the shim is

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sions runs in line

forge git now asserts release-production is below rung 3, which the decisions step's correction
leaves: a later step reads it, so decisions no longer forks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hhff

hhff commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Merging (upstream loop). Re-review: every finding addressed (bounds checks alone in bounds.test.mjs, fixed-path shim, real tmux found past the shim, hello asks a real question, ports below 32768, CI comment). I verified independently at 38cde42:

  • the sorted assert lines are identical to main (1713 each, same hash);
  • docs/ is unchanged against main;
  • CI is green on all 8 jobs.
    The lost-Enter "sent seconds ago" case stays as is (a kit clock would be a separate change).

@hhff
hhff merged commit efe3df2 into main Nov 6, 2026
8 checks passed
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.

1 participant