Skip to content

fix(ci): shard the hosted test suite so a slow runner cannot hit the 60 min limit - #442

Merged
roodboi merged 4 commits into
nextfrom
wf/g63-shard-hosted-test
Oct 24, 2026
Merged

roodboi merged 4 commits into
nextfrom
wf/g63-shard-hosted-test

Conversation

@roodboi

@roodboi roodboi commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

The hosted test job ran every bun run test file (568 root tests/ files plus packages/db) in one Turbo task on blacksmith-6vcpu-macos-15. That step alone took about 39 min (next run 39873410893: Tests (Turbo) 23:42:18 to 00:21:20). Slower runners reached the 60 min job limit and were cancelled. Turbo buffers task output until the end, so a cancelled run did not show which file was running.

Fix

  • scripts/test-shard-plan.ts finds the same file set that bun run test runs: root tests/ (with bunfig.toml root = "./tests" and its preload) and packages/db (from its own directory). It splits the files into shards, longest first. Durations come from scripts/test-shard-durations.json. That table was first built from the per-test times in the hosted log above. A file with no recorded duration is planned at 5 s, and each file adds 0.3 s for process start. The split is deterministic.
  • scripts/test-shard.ts run --shard=i/4:
    • runs check:bun-version and test:preflight first, as bun run test does;
    • runs each file with bun test ./<file> in its own process, from the file's suite directory, so the bunfig preload and the db path aliases apply;
    • prints start and PASS/FAIL lines with the file's duration and the shard's elapsed time, and a heartbeat every 2 min while a file runs;
    • stops a file after 12 min, and stops the shard at 30 min or on SIGINT/SIGTERM (which is what a GitHub cancellation or timeout sends). It then prints the running file, the files not started and the 10 slowest files;
    • fails when a file fails, times out, or Bun does not report exactly 1 file run;
    • writes per-file durations, uploaded as the test-shard-durations-<i> artifact. update-durations merges those artifacts back into the table.
  • Coverage check, run by every shard before it starts and by verify --count=4: the files found on disk must equal the files git ls-files lists. The union of all shards must equal that set, with no file in two shards. The set of workspaces with a test script, and the bunfig test root, must match the known suites.
  • .github/workflows/ci.yml:
    • adds the test-shard matrix (test shard i/4, fail-fast: false, timeout-minutes: 40). It keeps the existing runner, Bun 1.4.2, frozen install, Rust 1.97.1 and the compiler build on every shard. Typecheck, privacy, lint, the CLI build and the release smoke run on shard 1 only.
    • test is now a fan-in job (needs: test-shard, if: always()). It passes only when every shard succeeded, so the required check name test does not change. No admin change to the ruleset is needed.
  • scripts/prerelease-plan.ts CI_JOBS now also requires each test shard i/4 job, the same way it requires the docker-e2e shards.

Tests

tests/test-shard.test.ts (37 tests) covers:

  • shard parsing and test-file name matching;
  • partition determinism, exact cover, balance, the fallback duration, and zero-second files being spread across shards;
  • the coverage check, with drop, duplicate and extra-file cases;
  • fixture git checkouts for drift, ignored files and a missing suite root;
  • the current checkout's 4-shard plan against git ls-files;
  • the workspace and bunfig checks;
  • the runner: per-file lines, file timeout, deadline, interrupt, the stop report, and the file-count rule;
  • real bun test child processes: exit status, suite cwd, stopping a hung file, and not waiting for a leftover process that holds stderr open;
  • the workflow wiring and the prerelease gate.

Mutation proofs (each mutation was applied, the focused file was run, then the file was restored). Each one made at least one test fail:

Mutation Failing tests
partition drops the last file 7
no per-file start cost added 2
coverage ignores missing files 2
coverage ignores duplicate files 1
no disk-vs-git drift check 1
no workspace check 1
any file count counts as a pass 1
no file timeout 2
stop report omits the running file 1
test fan-in without always() 1
prerelease gate drops one shard 1
wait for stderr end of file 1
no SIGTERM on abort 1
wrong suite cwd 1

CLI mutation: when the partition drops a file, bun scripts/test-shard.ts verify --count=4 exits 2 with not run by any shard (1): tests/zig-aarch64-musl-linker.test.ts. Restored, it exits 0 with all files in 4 shards (571 files when first written, 573 at 503dc18d, 576 at 57736870; planned about 10m25s to 10m32s per shard).

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2), Rust 1.97.1.
  • Local (loaded M5 Mac, private HOME/HACK_HOME/DOCKER_CONFIG, HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1):
    • bun run check (includes privacy:check) passed;
    • bun run --cwd packages/cli typecheck passed;
    • actionlint 1.7.12 found nothing on the 4 workflows;
    • focused tests passed: test-shard, prerelease-plan, e2e-shard, native-plan-cli-wiring, ci-bun-crash-census and agent-instruction-source (135 tests);
    • gitleaks 8.30.1 over origin/next..HEAD found no leaks.
  • Full suite: hosted CI is the full-suite evidence for this unit, so no M3 run is needed. Each hosted run executes every test file across the 4 shards; the fan-in test check passes only when all 4 shards pass.
  • Hosted shard runs: see "Hosted runs" below (3 runs, 2 of them green).

Hosted runs

Times are the Tests (shard) step; the whole job adds about 1 to 1.5 min of setup. Every shard job has timeout-minutes: 40.

Run Head Shard 1/4 Shard 2/4 Shard 3/4 Shard 4/4 Result
39888691204 a33511f4 11m48s (failed, test bug) 11m39s 10m32s 12m57s red: one test, see below
39977193863 503dc18d 11m49s 11m30s 11m44s 12m16s green, all 20 checks
40057462690 57736870 10m12s 10m39s 9m49s 11m29s green, all 20 checks

Whole shard jobs took 10m34s to 13m54s across the 3 runs, well under the 40 min timeout. For comparison, next's serial test job (39916341281, 7d9c89f8) took 40m17s, with Tests (Turbo) at 39m21s, and run 39783802503 was cancelled at 60m25s.

Same file set as the serial job: the shard logs of run 39977193863 list 573 unique PASS files. next's serial log (39916341281) lists 572 files; the extra one is the new tests/test-shard.test.ts. Unique skipped tests are the same 157 names on both. Passed tests are 8057 on next and 8094 on the PR; the 37 extra are the new test-shard tests. Running each file in its own process did not skip anything new.

Why run 39888691204 was red

Shard 1 failed one test in tests/project-exec-command.test.ts: "exec warns value-free on stderr only after the service env changed since start". The clack CoreDNS/Caddy warnings went to stdout instead of stderr.

This was a test that depended on file order, not a sharding bug and not a known flake. In the old single bun test process, an earlier file had already set HACK_LOGGER=console. Run on its own, on a host without gum, the logger falls back to clack, which writes to stdout. Repro on origin/next, running only that file with gum removed from PATH: 4 pass, 1 fail, the same test.

Fix (503dc18d, test: pin the console logger in the exec env-change test): the test file sets HACK_LOGGER=console in beforeAll and restores the previous value in afterAll. With the fix the file gives 5 pass on its own; with next's version of the file put back it gives 4 pass, 1 fail again. Run 39977193863 at 503dc18d is green.

Round 2 (verifier follow-up)

  • The verifier found that this body still said PENDING for the hosted and full-suite evidence, quoted 571 files instead of the plan's count, and did not explain the red run. This body now lists the hosted runs, the red-run cause and fix, and drops the M3 item.
  • File counts: the plan had 571 files when the CLI mutation above was first written, 573 at 503dc18d, and 576 after merging next at 5208ee54 (which adds test: fail and contain process-state leaks between test files #405, the global-state-guard preload, and fix(agents): name the permission fix and keep a value-free cause on input access refusals #452).
  • New head 57736870: merge of next 5208ee54 into 503dc18d. The merge was clean; no PR file changed.
  • Local gates at 57736870 (Bun 1.4.2, private HOME/HACK_HOME/DOCKER_CONFIG, HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1, a bogus DOCKER_HOST):
    • gitleaks 8.30.1 dir over the git log -p origin/next..HEAD export: no leaks;
    • bun run check (includes privacy:check): 2 of 2 tasks passed;
    • bun run --cwd packages/cli typecheck: exit 0;
    • focused tests test-shard, prerelease-plan and project-exec-command: 99 pass, 0 fail;
    • bun scripts/test-shard.ts verify --count=4: 576 files in 4 shards, each file exactly once, planned about 10m31s per shard. 7 files from next have no recorded duration yet and use the 5 s fallback.
  • Hosted CI at 57736870: run 40057462690: all 20 checks green, including test shard 1/4 to 4/4 and the test fan-in. The shard logs list 576 unique PASS files and no FAIL line, which matches the local plan. Shard jobs took 10m34s to 12m11s.
  • Required check: still test (ruleset on next). The fan-in job keeps that name, so no admin change is needed.

Round 3 (re-check)

  • Remote head is still 57736870, and next is still 5208ee54, which is an ancestor of the head. No merge or new commit was needed, so nothing was pushed this round.
  • Re-checked this body against GitHub: gh pr checks 442 shows all 20 checks green at 57736870. The Tests (shard) step times of run 40057462690 match the table (10m12s, 10m39s, 9m49s, 11m29s). Its shard logs list 576 unique PASS files and no FAIL line.
  • bun scripts/test-shard.ts verify --count=4 at 57736870: 576 files in 4 shards, each exactly once, planned 10m31s to 10m32s per shard.
  • The Tests section above now gives the file count at each head instead of only the first count (571).

Gaps

  • Each file now runs in its own process instead of sharing one bun test process. Files no longer share module state or global hooks across files. That is stricter isolation, so a test that depended on another file running first in the same process would now fail. The red run 39888691204 was such a case: a test passed in the shared process only because an earlier file had set HACK_LOGGER=console. It is fixed in 503dc18d (see "Why run 39888691204 was red"). The green hosted runs are the evidence for the current tree.
  • scripts/ci-bun-crash-census.ts matches one exact job name, and its default is test. For runs after this change the Bun logs are in the test shard i/4 jobs, so the census needs --job "test shard i/4" once per shard. This is documented in the verify skill. The script itself is not changed here.
  • After a stop, a child that ignores SIGTERM is SIGKILLed after 10 s. Its own descendants are not process-group killed, because changing process groups could change the lifecycle tests.

Refs HACK-1381

🤖 Generated with Claude Code

…60 min limit

The serial `test` job ran all ~570 bun test files in one Turbo task and took
37-42 min, so slow hosted runners were cancelled at GitHub's 60 min job
limit, and Turbo's buffered output could not name the file that hung.

CI now runs four `test shard i/4` jobs. scripts/test-shard-plan.ts splits the
`bun run test` file set (root tests/ with bunfig preloads, plus packages/db)
by the checked-in durations in scripts/test-shard-durations.json, longest
first, with a default for new files. scripts/test-shard.ts runs each file in
its own `bun test` process from its suite directory, prints a start and a
result line per file, stops a file after 12 min and the shard at 30 min or on
SIGINT/SIGTERM, and then names the running file, the unstarted files and the
slowest files. Every shard first verifies that the four shards cover the
files git lists exactly once. The required `test` check is now a fan-in that
passes only when every shard succeeded; the prerelease gate also requires
each shard by name.

Refs HACK-1381

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

linear-code Bot commented Oct 23, 2026

Copy link
Copy Markdown

HACK-1381

roodboi and others added 3 commits October 23, 2026 10:48
The exec env-change test asserted an empty stdout but relied on an
earlier test file leaking HACK_LOGGER=console into the shared bun
process. Run alone (as each test shard now runs files), the logger fell
back to clack on hosts without gum, which writes the CoreDNS/Caddy
warnings to stdout, so the assertion failed. Set HACK_LOGGER=console in
beforeAll and restore it in afterAll so the file passes in any order.

Refs HACK-1381

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #405 (global-state-guard preload) and #452. The shard plan now covers 576 files exactly once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 24, 2026 05:49
@roodboi
roodboi merged commit 6e4bcf2 into next Oct 24, 2026
21 checks passed
@roodboi
roodboi deleted the wf/g63-shard-hosted-test branch October 24, 2026 05:49
roodboi added a commit that referenced this pull request Oct 24, 2026
…le CI test job docs (#476)

Follow-ups to #442 (CI sharding, merged as `6e4bcf2b`). Parts (1) and (2) are fixed here. Part
(3), the release workflow, is a **proposed diff only**: it is not part of this PR. The reason is
in that section below.

## Root cause

Since #442, the hosted suite runs in four `test shard i/4` jobs. The `test` job is now only the
required fan-in. It needs `test-shard` and runs no Bun.

1. Two docs still said a test "runs in the CI `test` job". 2. `bun run ci:bun-crash-census`
defaulted to `--job test`, so it read the fan-in. A failed fan-in log has no Bun output, so the
census reported zero crashes for it and gave no warning. `workflowJobBunVersion(ci.yml, "test")`
found the fan-in's block, which has no `bun-version`, and `"test shard 1/4"` matched no workflow
key at all. The workaround in `.ai/skills/hack-repo-verify/SKILL.md` (`--job "test shard i/4"`)
did not work either. The `--job` check refused `/`, so the census exited with `Invalid argument`
(reproduced on `origin/next`). 3. `.github/workflows/release-prepare.yml` still runs the serial
`bun run turbo:test`. It has no `timeout-minutes`, so GitHub's 360 min default applies, and
Turbo buffers the output.

## Fix

**(1) Docs.** `docs/security/env-secrets-threat-model.md:99` and `docs/reference/shared-host-
lifecycle.md:230` now say the test runs in one of the hosted `test shard i/4` jobs, and that the
required `test` check passes only when every shard passes.

**(2) Crash census** (`scripts/ci-bun-crash-census.ts`):
- **Default jobs:** with no `--job`, the census reads every `test shard i/n` job of a run. A run
  with no shard job is from before the split, so its `test` job ran the suite and is read
  instead. The same window can span both kinds of run.
- **`--job` values:** `--job` now accepts `/` and a `*` pattern. `--job "test shard 1/4"` and
  `--job "test shard *"` both work. Other characters are still refused.
- **Workflow Bun version:** `workflowJobBunVersion` now matches a job by its `name:` template,
  read only under `jobs:`. A `${{ ... }}` expression in the template matches any text. So `test
  shard 2/4` finds the `test-shard` matrix job's pin, and `test` still returns null.
- **Warnings:** the report adds a closed `warning:` line when no job matched the selector, or
  when a selected job shows no Bun setup in its log or workflow block. The same line goes to
  stderr, and `--json` gets a `warnings` array. The warning contains no log content.
- **Docs:** `.ai/skills/hack-repo-verify/SKILL.md` now describes the new default.

## Tests and evidence

**Focused tests:** `tests/ci-bun-crash-census.test.ts` (16 pass). New cases:
- Default selection on a mixed window: shard jobs are read, the fan-in and `docker-e2e shard`
  jobs are skipped, and a run from before the split is still read.
- Explicit `--job test` on a sharded run warns, and the fan-in log content is not printed.
- A selector that matches no job warns.
- The name-template lookup, including quoted templates and `on:` keys.
- A contract check against the real `.github/workflows/ci.yml`: all four shard names resolve to
  the `package.json` `packageManager` Bun pin, and `test` resolves to none.
- `--job` with `/` and `*` is accepted; `;` and values over 100 characters are refused.

**Mutation proof:** each of these 8 mutations makes the suite fail:
- the default selector reads `test`;
- the name template is ignored;
- no warnings are returned;
- the `no_bun_setup` warning is dropped;
- the old `--job` check is restored;
- the workflow version is looked up under the selector name instead of the job's name;
- the default job is `test` again;
- warnings are not rendered.

**Live read-only census** (GitHub API GET requests only; window 2026-10-24 05:00Z to 12:00Z, 17
CI runs):

| Version | Selector | Jobs | Result | | --- | --- | ---: | --- | | `origin/next` | default
(`test`) | 2 | One is the fan-in with `bun=unknown`; no warning. | | This branch | default | 5 |
Every job has a Bun version (`log:1,workflow:4,unknown:0`); no warning. | | This branch | `--job
test` | 2 | The fan-in prints a `warning: 1 of 2 selected jobs show no Bun setup ...` line. | |
This branch | `--job "test shard 1/4"` | 1 | Runs; this value was refused before. |

**Other checks:**
- Root `bun run check` passes, including `privacy:check`.
- `bun run --cwd packages/cli typecheck` passes with 0 errors.
- `bunx ultracite check` on the changed files passes.
- Neighbouring tests pass: `shared-host-lifecycle`, `aes-gcm-v1-context-binding`, `bun-crash-
  loop` and `agent-instruction-source`, which reads the verify skill.
- gitleaks 8.30.1 over `origin/next..HEAD` found no leaks.

**Full suite:** the hosted CI `test` check (all four shards) at this PR's head.

## (3) release-prepare: proposed diff, not applied

**Why it is not applied.** The change cannot be validated without a release dispatch or a push
to `main`, and both are outside this unit. `release-prepare.yml` runs only on `push: main` and
`workflow_dispatch`. A pull request never runs it, so no hosted check in this PR would exercise
the change. Two findings make a run-free change unsafe to claim:

- **`next`'s release-prepare cannot pass as written.** On `next`, `turbo:test` is `bun run
  test:preflight && turbo run test`. The preflight needs the matching native config compiler
  (`dist/hack-config-compiler`). `release-prepare.yml` installs no Rust toolchain and never runs
  `bun run build:config-compiler`, so the preflight fails before any test starts. On `main`,
  `turbo:test` has no preflight. The last `main` release-prepare (`40050242500`) ran 165 files
  in 30 s. `next` has 579 files, planned at about 42 min on the macOS shard runner.
- **No hosted job runs `next`'s full suite on Linux.** The `test shard` jobs run on
  `blacksmith-6vcpu-macos-15`, and `linux-process-lifetime` runs only 6 files. release-prepare
  runs on `blacksmith-4vcpu-ubuntu-2404`. So the Linux duration and the Linux pass/fail result
  of the whole suite are both unmeasured.

The change only matters when `next` is merged into `main`. The workflow that runs is the copy on
`main`, and `scripts/test-shard.ts` exists only on `next`, so this diff must not be applied to
`main` by itself. It applies cleanly to `next` at `6e4bcf2b`:

```diff
--- a/.github/workflows/release-prepare.yml
+++ b/.github/workflows/release-prepare.yml
@@ -16,6 +16,9 @@
 jobs:
   semantic-release:
     runs-on: blacksmith-4vcpu-ubuntu-2404
+    # GitHub's default limit is 360 min. The suite's own 90 min deadline stops
+    # first and names the running file (HACK-1424).
+    timeout-minutes: 120
     env:
       FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
     steps:
@@ -47,8 +50,17 @@
       - name: Quality checks (Turbo)
         run: bun run turbo:check

-      - name: Tests (Turbo)
-        run: bun run turbo:test
+      # test:preflight needs the matching native config compiler (HACK-1424).
+      - uses: dtolnay/rust-toolchain@1.97.1
+
+      - name: Prepare matching config compiler
+        run: bun run build:config-compiler
+
+      # The `bun run test` file set, Bun pin check and compiler preflight, one
+      # `bun test` process per file with start/PASS/FAIL lines, a 12 min file
+      # limit and a 90 min deadline that names the running file (HACK-1381).
+      - name: Tests (per file)
+        run: bun scripts/test-shard.ts run --shard=1/1 --deadline-seconds=5400

       - name: Run semantic-release
         env:
```

**What stays the same:**
- **Gates:** typecheck, privacy check, quality checks, then the tests. `semantic-release` runs
  only if every earlier step passes.
- **The test run still fails when it should.** The runner exits non-zero when any file fails,
  times out, reports a file count other than 1, or reaches the deadline.
- **Exact-head semantics:** one job, one checkout of the pushed SHA, the same `concurrency`
  group, `cancel-in-progress: false`.
- **Outputs:** the same `semantic-release` step with the same token. No new outputs, artifacts
  or permissions.

**What is new:**
- `check:bun-version` now runs before the tests. It already runs before every `bun run test` and
  every CI shard.
- The `git ls-files` coverage check: `verify --count=1` on this head reports "579 files in 1
  shards cover the full test set exactly once", planned at 42m22s.
- Per-file progress lines, a heartbeat every 2 min, a 12 min limit per file, a 90 min limit for
  the run, and a 120 min limit for the job.

**Validated statically:**
- actionlint 1.7.12 with the repository config passes on both the current and the proposed file.
- `bun scripts/test-shard.ts verify --count=1` and `plan --shard=1/1` succeed on this head.

**Not validated:** the run itself on the Linux release runner (compiler build, Linux suite
result and duration). That needs one `workflow_dispatch` of `release-prepare.yml` against
`next`, or the next-to-main merge. On `next`, a dispatched run would also reach `bunx semantic-
release` if the tests pass, so a dry run would need `--dry-run` or a temporary branch filter. I
did not dispatch it.

## Gaps

- (3) is proposed, not applied. HACK-1424 acceptance allows "explicitly scheduled with a
  reason", and the reason is above.
- The census's live evidence covers a 7-hour window around the #442 merge, not a long history.
- A job whose workflow fetch fails is also counted as "no Bun setup" in the warning. This is
  intended: in both cases the zero is not evidence.

Refs HACK-1424

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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