Skip to content

dx(test): opt-in worker-count override for solo local vitest runs - #1964

Open
thymikee wants to merge 4 commits into
mainfrom
dx/1962-vitest-worker-override
Open

dx(test): opt-in worker-count override for solo local vitest runs#1964
thymikee wants to merge 4 commits into
mainfrom
dx/1962-vitest-worker-override

Conversation

@thymikee

@thymikee thymikee commented Aug 22, 2026

Copy link
Copy Markdown
Member

Summary

resolveVitestMaxWorkers() caps local Vitest runs at 2 workers so parallel worktrees and spawn-heavy tests keep host headroom. That default is correct, but a run that owns the whole machine pays 6x on a 12-core host for no benefit.

This adds an opt-in override: AGENT_DEVICE_VITEST_MAX_WORKERS=<n> pnpm test:unit.

  • Parsed from process.env, clamped to os.availableParallelism() (a value like 999 is not honored literally). availableParallelism() rather than cpus().length: Node documents the latter as unfit for sizing parallelism because it ignores CPU affinity and cgroup limits, which would inflate the very ceiling this clamp exists to enforce.
  • A no-op when CI === 'true' — CI already derives its own worker count from the isolated runner's CPU pool.
  • A missing, blank, non-numeric, non-integer, or non-positive value falls through to the existing default cap rather than throwing — this was the sane-fallback choice called out in the issue, so a typo degrades to the safe default instead of crashing the config load.
  • Unset-env-var default path is byte-for-byte unchanged (DEFAULT_VITEST_MAX_WORKERS = 2, same CI branch).

Also added a one-line mention in docs/agents/testing.md's "Speed rules" section, phrased to lead with the "solo run only" caveat and to stay under that file's 10,000-byte agent-guidance budget.

Catches / Evidence / Cost / Kill-criterion

  • Catches: nothing — this is a DX lever, not a gate.
  • Evidence: the import phase alone is ~40-50% of subset wall time at 2 workers; worker scaling is near-linear on the import+test phases.
  • Cost: a few lines in scripts/lib/vitest-concurrency.ts + a doc sentence in docs/agents/testing.md.
  • Kill criterion / caveat: if override use correlates with subprocess-lane timeout flakes (the known contention signature: timeouts, never assertion failures), document "solo runs only" harder or remove.

What I verified

  • No existing co-located test file for resolveVitestMaxWorkers(), so I added scripts/lib/vitest-concurrency.test.ts (wired into the unit-core Vitest project's explicit include list, matching the file's existing convention) covering: unset preserves default, CI ignores the override even when both signals are present, a valid override is honored, an override above available parallelism is clamped, and non-numeric / zero / negative / non-integer overrides fall through to the default.
  • Unit tests: 11/11 pass across scripts/lib/vitest-concurrency.test.ts and src/__tests__/hermetic-env-setup.test.ts (the latter has a pre-existing direct test of this function).
  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm build, pnpm check:agent-guidance (4/4) — all clean.
  • Real flow-through sanity check: dynamically imported the actual vitest.config.ts module with the env var set and confirmed test.maxWorkers on the resolved config object — 4 for =4, and 12 (not 999) for =999 on this 12-core host. This proves the var reaches Vitest's config and that the clamp applies there, not just in the resolver in isolation. Also ran a live vitest run --project unit-core subset with the override set, which passed.
  • pnpm check:affected fails open to the full 54-check gate set for this diff (touches vitest.config.ts and scripts/lib/, both flagged workflow-tooling), which is the documented behavior for tooling/config changes, not a regression.

Closes #1962

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.37 MB 2.37 MB 0 B
JS gzip 794.8 kB 794.8 kB 0 B
npm tarball 916.5 kB 916.5 kB 0 B
npm unpacked 3.18 MB 3.18 MB 0 B

npm unpacked components

Component Base Current Diff
JS / dist source 2.51 MB 2.51 MB 0 B
Apple runner source/project 564.2 kB 564.2 kB 0 B
macOS helper source 54.5 kB 54.5 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 44.4 kB 44.4 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.4 ms 27.8 ms -0.7 ms
CLI --help 82.4 ms 81.3 ms -1.1 ms

Top changed chunks: no changes in the largest emitted chunks.

Top changed packed files

No changed packed files.

@thymikee

Copy link
Copy Markdown
Member Author

Not ready at b061c39d:

  1. docs/agents/testing.md is now 10,065 bytes, so the exact-head Ownership and context budgets check deterministically fails. Please compress/replace existing speed-rule prose rather than raising the guidance budget.
  2. The safety clamp in scripts/lib/vitest-concurrency.ts should use os.availableParallelism(), not os.cpus().length. Node explicitly says cpus().length must not calculate application parallelism; it can overstate the usable pool under CPU affinity/cgroup constraints and defeats the promised safe clamp. Update the corresponding clamp test too.

The rest of the change is focused and matches #1962, but these two items block readiness.

@thymikee

Copy link
Copy Markdown
Member Author

Both items addressed.

1. Guidance budget (de8770c9) — compressed the note instead of raising the budget, as asked. The bullet is now one tighter line that leads with the constraint that actually matters:

  • Raise the two-worker local cap only for a solo run: AGENT_DEVICE_VITEST_MAX_WORKERS=<n>, clamped to host CPUs, ignored in CI.

docs/agents/testing.md is back to 9,985 bytes (was 10,065, budget 10,000) and pnpm check:agent-guidance passes locally, 4/4. Front-loading "only for a solo run" also states the issue's kill-criterion caveat in the doc rather than only in the PR.

2. os.availableParallelism() (062f4998) — agreed, and this was a real defect rather than a style preference: clamping against a number that ignores CPU affinity and cgroup limits would inflate the exact ceiling the clamp exists to enforce, so the "safe clamp" would not have been safe on a constrained host. Switched the clamp and the test, and recorded the reasoning in a comment at the clamp so it does not regress to cpus().length later.

Verified after both changes: check:agent-guidance 4/4, unit tests 11/11, typecheck, lint, format:check all clean. End-to-end flow-through re-checked by importing the real vitest.config.ts with AGENT_DEVICE_VITEST_MAX_WORKERS=999 set, which resolves maxWorkers to 12 on this 12-core host rather than honoring 999.

@thymikee

Copy link
Copy Markdown
Member Author

CI status on 062f4998: everything green except two mutation lanes — Mutants (kernel-errors) and Affected decision-kernel mutants. Those two are a pre-existing failure on main, not from this PR. Details, because the diagnosis is reusable:

Reproduction without any of my commits. Checked out e5bfde3d1 (current main tip) detached, clean tree, and ran pnpm mutation:run --modules kernel-errors. It fails identically:

ERROR DryRunExecutor One or more tests failed in the initial test run:
	no test file over the tripwire grows, and every pin matches its file exactly
		expected [ …(26) ] to deeply equal []

Mutation Affected last ran on 2026-08-18; this PR is the first run since, which is why it surfaced here.

Root cause. Stryker's dry run executes the suite inside its sandbox (.tmp/stryker/sandbox-XXXXXX). src/__tests__/test-file-size-ratchet.test.ts derives REPO_ROOT from import.meta.dirname, so inside the sandbox it walks and measures the sandbox copies, not the real tree. Stryker's disableTypeChecks default prepends // @ts-nocheck to every {test,src,lib}/**/*.{ts,…} file it copies, so every file is one line longer there. All 26 pinned files therefore read exactly +1 (two read +2, which also carry a rewritten @ts- directive), and each reports "grew to N lines (pinned N-1)". Dumped from a sandbox run:

src/__tests__/remote-connection.test.ts grew to 2974 lines (pinned 2973)
src/__tests__/client.test.ts grew to 1599 lines (pinned 1598)
src/commands/interaction/runtime/settle.test.ts grew to 2360 lines (pinned 2359)
…26 total, one per pin

measured.size was 1151 and REPO_ROOT was …/.tmp/stryker/sandbox-8zWNfk, confirming it measured the sandbox. The .tmp entry in SKIPPED_DIRECTORIES does not help: it is applied to paths relative to REPO_ROOT, which is the sandbox, so nothing is skipped.

The same test passes in the normal lanes on this PR (Coverage is green, and it passes locally), because there it measures the real tree.

Not fixing it here, since it is unrelated to #1962 and the fix is a judgment call I would rather not make inside a DX PR — the options I see are excluding the ratchet from the mutation test scope in scripts/mutation/test-scope.ts (which already carries documented exclusions, and this test cannot kill a mutant so excluding it hides nothing), or making the test detect the sandbox and skip. Happy to do either in its own PR, or leave it to you — say which and I will open it.

Everything else on this PR is green and both of your review items are addressed.

@thymikee

Copy link
Copy Markdown
Member Author

Prior findings are addressed, but this is not ready at 062f4998. The PR's vitest.config.ts change expands the kernel-errors mutation lane from 2 related tests on current main to 816 on this branch. The expanded dry run then fails test-file-size-ratchet.test.ts inside Stryker's sandbox; the aggregate failure is only the missing-report cascade. This is branch-caused, not infrastructure. Please keep the resolver cases in the already-included hermetic-env-setup.test.ts (or otherwise avoid expanding the mutation graph), then rerun the mutation lanes.

resolveVitestMaxWorkers() caps local runs at 2 workers so parallel
worktrees and spawn-heavy tests keep headroom, but a solo run that owns
the machine pays 6x on a 12-core host for no benefit.

Add AGENT_DEVICE_VITEST_MAX_WORKERS to opt in to a higher cap. It is
clamped to os.cpus().length so a runaway value can't oversubscribe the
host, and it is a no-op in CI (CI already derives its own worker count).
A missing, blank, non-numeric, non-integer, or non-positive value falls
through to the existing default cap rather than throwing. Default
(unset) behavior is unchanged.

Closes #1962
docs/agents/testing.md sits at a 10,000-byte per-file ceiling enforced by
check:agent-guidance, and the first phrasing pushed it to 10,065. Restate
the override in one tighter bullet that leads with the "solo run only"
caveat, which is the constraint a reader most needs.
Node documents cpus().length as unfit for sizing application parallelism:
it ignores CPU affinity and cgroup limits, so it can report a pool wider
than the process may actually use. Clamping against it would inflate the
very ceiling this override's safety clamp exists to enforce.

availableParallelism() honors those constraints, so the clamp now means
what it claims on constrained hosts. Test updated to match.
Review feedback: a new test file beside the resolver, plus its entry in
vitest.config.ts's unit-core include list, is a change to test discovery
that the mutation lane's `vitest related` graph reads. Fold the override
cases into src/__tests__/hermetic-env-setup.test.ts, which is already in
the unit suite and already imports the resolver, and drop the config edit
entirely so this PR no longer touches test discovery at all.

Same six assertions, no coverage lost.
@thymikee
thymikee force-pushed the dx/1962-vitest-worker-override branch from 062f499 to 8360daa Compare August 22, 2026 14:01
@thymikee

Copy link
Copy Markdown
Member Author

Code-review clean at 8360daa7; the prior PR-caused mutation-graph blocker is fixed. vitest.config.ts now matches current base, and the cases live in the already-included hermetic setup test. A detached exact-base (03c39840, post-#1969) reproduction yields the same 804 kernel-related files as this PR, so the expansion is current-main baseline debt, not caused here. The shard red is that baseline's Stryker dry-run failure in test-file-size-ratchet; the aggregate red is only the missing-report cascade. Repair/rerun the post-#1969 mutation baseline separately rather than adding more workaround here. Coverage and other completed checks are green; iOS is pending. I am not labeling merge-ready while the two required mutation checks remain red.

@thymikee

Copy link
Copy Markdown
Member Author

Done at 8360daa7, and I measured the scope instead of theorising about it.

Change made as asked. Rebased onto 03c398406. The override cases now live in src/__tests__/hermetic-env-setup.test.ts (already in the unit suite, already imports the resolver); scripts/lib/vitest-concurrency.test.ts is deleted and vitest.config.ts is byte-identical to main — this PR no longer touches test discovery at all. Diff is now 3 files: the resolver, that test, and one doc line.

Measured mutation scope, by calling the lane's own relatedTestFiles(expandMutateFiles(mutateGlobs(['kernel-errors']))) from scripts/mutation/test-scope.ts, vitest cache cleared before each run, same machine:

head sources related test files test-file-size-ratchet.test.ts in scope
03c398406 (main) 1 804 yes (line 191 of the sorted list)
8360daa7 (this PR) 1 804 yes

diff scope-main.txt scope-branch.txtzero lines differ; the two lists are identical. This PR adds no file to the lane's test set. src/__tests__/hermetic-env-setup.test.ts is not in the 804 at all, so the 30 lines I added there are outside the mutation lane's test set entirely.

Lane run, same command, same machine:

head result
03c398406 (main tip, clean tree, none of my commits) fails: 804 related, 26 ratchet findings
8360daa7 (this PR) fails: 804 related, 26 ratchet findings

So I could not reproduce the 2 → 816 expansion at this head — I get 804 on both sides, and the lane goes red on main's own tip. If you measured 2 on main, we have an environment difference I would like to find; the exact command I used is above and I am happy to run anything else you want.

Why the 26 fire. In the sandbox REPO_ROOT resolves to .tmp/stryker/sandbox-XXXXXX, so the ratchet measures Stryker's copies. disableTypeChecks (default on; not overridden in stryker.config.json) prepends // @ts-nocheck to every copied {test,src,lib} file, so all 26 pinned files read exactly +1 (two read +2, carrying a rewritten @ts- directive):

src/__tests__/remote-connection.test.ts grew to 2974 lines (pinned 2973)
src/__tests__/client.test.ts           grew to 1599 lines (pinned 1598)
…26 total, one per pin

The .tmp entry in SKIPPED_DIRECTORIES cannot help, because it is matched against paths relative to REPO_ROOT — which is the sandbox.

Proposed fix, not landed. You said the mutation-lane fix is a separate call, so I have not touched it. test-scope.ts already excludes tests that cannot survive the runner (threadHostileTestFiles, with its documented rationale); a repo-introspection test that measures files on disk is sandbox-hostile in the same way, and excluding it hides no mutant because it asserts nothing about a decision kernel. I am glad to open that as its own PR, or leave it to you — your call.

Everything else is green: 30 pass, 2 fail (both these lanes).

thymikee added a commit that referenced this pull request Aug 22, 2026
Stryker runs the suite from a sandbox copy under `.tmp/stryker/`, so a test that
asserts about the repository checkout itself — its files on disk, or its git
history — reads a repository that does not exist, and no answer it gives there
means anything.

`src/__tests__/test-file-size-ratchet.test.ts` is such a gate, and it fails in
the sandbox for two independent reasons. Stryker's `disableTypeChecks` default
prepends `// @ts-nocheck` to every copied file, so all 26 pinned files read one
line longer than they are; and the sandbox has no `origin/main`, so the gate's
history-backed half cannot resolve its merge-base. Fixing either leaves the
other — turning off the type-check rewrite was tried first, and CI then failed on
the missing ref. The gate's own `.tmp` skip entry cannot save it either: that is
matched relative to `REPO_ROOT`, which inside the sandbox *is* the sandbox.

Excluding it hides nothing, for the reason this module's header already gives: a
mutant only an excluded test could kill shows up as a survivor. A repo-shape gate
cannot kill a kernel mutant at all — it asserts about file lengths and history,
never about behavior under mutation.

Derived rather than listed, matching the neighbouring thread-hostile exclusion,
so a future sibling gate is caught the same way. Nothing was wrong with the
ratchet or with #1964; before #1969 rewired the module graph, nothing pulled this
gate into a lane's scope and the trap stayed hidden.

Verified with `pnpm mutation:run --modules kernel-errors`: scope 804 -> 803 test
files (exactly this one), dry run clean, lane `pass` in 560s, score 75.2%
(188 killed / 62 survived / 250). `pnpm mutation:test` 39/39.

`stryker.config.json` is deliberately untouched, so the config content hash the
report and lane envelope carry is unchanged and scores stay comparable.

Unblocks #1964, whose two mutation checks fail on main's tip without its code.
thymikee added a commit that referenced this pull request Aug 22, 2026
Stryker runs the suite from a sandbox copy under `.tmp/stryker/`, so a test that
asserts about the repository checkout itself — its files on disk, or its git
history — reads a repository that does not exist, and no answer it gives there
means anything.

`src/__tests__/test-file-size-ratchet.test.ts` is such a gate, and it fails in
the sandbox for two independent reasons. Stryker's `disableTypeChecks` default
prepends `// @ts-nocheck` to every copied file, so all 26 pinned files read one
line longer than they are; and the sandbox has no `origin/main`, so the gate's
history-backed half cannot resolve its merge-base. Fixing either leaves the
other — turning off the type-check rewrite was tried first, and CI then failed on
the missing ref. The gate's own `.tmp` skip entry cannot save it either: that is
matched relative to `REPO_ROOT`, which inside the sandbox *is* the sandbox.

Excluding it hides nothing, for the reason this module's header already gives: a
mutant only an excluded test could kill shows up as a survivor. A repo-shape gate
cannot kill a kernel mutant at all — it asserts about file lengths and history,
never about behavior under mutation.

Derived rather than listed, matching the neighbouring thread-hostile exclusion,
so a future sibling gate is caught the same way. Nothing was wrong with the
ratchet or with #1964; before #1969 rewired the module graph, nothing pulled this
gate into a lane's scope and the trap stayed hidden.

Verified with `pnpm mutation:run --modules kernel-errors`: scope 804 -> 803 test
files (exactly this one), dry run clean, lane `pass` in 560s, score 75.2%
(188 killed / 62 survived / 250). `pnpm mutation:test` 39/39.

`stryker.config.json` is deliberately untouched, so the config content hash the
report and lane envelope carry is unchanged and scores stay comparable.

Unblocks #1964, whose two mutation checks fail on main's tip without its code.
@thymikee

Copy link
Copy Markdown
Member Author

Code review is clean: the local-only worker override is focused, correctly clamped/validated, ignored in CI, covered in the existing hermetic config suite, documented at the testing-procedure owner, and has 0 B package impact. The two required mutation checks remain red, however. They are deterministic baseline/dependency failures—not this diff or infrastructure: Stryker’s dry run includes the repo size-ratchet test, whose sandbox-rewritten copies gain a line, and the aggregate job then has no mutation report. Land a corrected structural fix for #1977, rebase/update this branch onto it, and rerun mutation CI. No #1964 code changes are otherwise required.

thymikee added a commit that referenced this pull request Aug 22, 2026
Stryker runs the suite from a sandbox copy under `.tmp/stryker/`, so a test that
asserts about the repository checkout itself — its files on disk, or its git
history — reads a repository that does not exist.

`test-file-size-ratchet.test.ts` is such a gate, and it fails there for two
independent reasons: `disableTypeChecks` (Stryker's default) prepends
`// @ts-nocheck` to every copied file, so all 26 pinned files read one line
longer than they are; and the sandbox has no `origin/main`, so the gate's
history-backed half cannot resolve its merge-base. Fixing either leaves the
other. Its own `.tmp` skip entry cannot help: that is matched relative to
`REPO_ROOT`, which inside the sandbox *is* the sandbox.

Move it to `scripts/__tests__/` and include it explicitly in `unit-core`, the
address the repo already uses for maintained gates that are not `src` tests.
`KERNEL_TEST_FILE_RE` admits only root/package `src` tests, and its comment
already names `scripts/__tests__` as unreachable by construction — so the gate
leaves every mutation lane by virtue of where it lives, with no classifier to
recognise it and nothing to keep in sync.

This replaces the source-text scanner of the previous revision, which was the
wrong boundary: it sniffed for a single-quoted `walk-files` import or the string
`origin/main`, so a behavioral test could match and be silently excluded while an
equivalent repo gate using double quotes, another walker, or another base ref
would be missed. The scanner, its test, and its justifying comment are all gone.

`REPO_ROOT` and the walked roots are unchanged — both addresses are two levels
below the repo root, and `TEST_ROOTS` already included `scripts`, so the gate
measures exactly what it did before.

The one new assertion pins the invariant this now depends on: `isKernelTestFile`
accepts root/package `src` tests and rejects `scripts/__tests__`. Widening that
pattern would silently pull the gate back into every lane.

Verified with `pnpm mutation:run --modules kernel-errors`: scope 804 -> 803 test
files, dry run clean, lane `pass` at stage complete, score 74.8%
(187 killed / 63 survived / 250) — unchanged. `pnpm mutation:test` 39/39,
`pnpm check:layering` 181/181, `typecheck`, `lint`, `format` clean.
`stryker.config.json` is untouched, so scores stay comparable.

Unblocks #1964, whose two mutation checks fail on main's tip without its 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.

dx(test): opt-in worker-count override for solo local vitest runs

1 participant