Skip to content

perf(test): remove the CI serial-worker pin with flakes fixed at the source - #118

Merged
ScriptedAlchemy merged 19 commits into
mainfrom
perf/ci-parallel-unpin
Sep 1, 2026
Merged

perf(test): remove the CI serial-worker pin with flakes fixed at the source#118
ScriptedAlchemy merged 19 commits into
mainfrom
perf/ci-parallel-unpin

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

Summary

Removes the CI serial-worker pin from the integration pool: CI now derives workers from cores exactly like local (2 workers on 4-core hosted runners), with every flake that surfaced during burn-in root-caused and fixed at the source rather than absorbed by the serial shape. Subsumes and supersedes #90 (perf/rstest-auto-workers), whose per-RSTEST_WORKER_ID isolation is merged here intact.

Why the pin existed, and why it can go

The pin landed because early 2-worker matrix runs flaked on a rotating test per leg. Burn-in on the original failing shape (taskset to 4 cores, CI=1, 2 workers) reproduced and root-caused each rotation member. None of them was "parallelism is unsafe" — each was a specific race or an unscaled budget:

Flake Test Root cause Fix
Extra App-preview close mcp-page-app-browser.test.ts Frame relay's fixed 1s force-close timer misfires against a healthy graceful teardown on a loaded host, superseding the queued graceful close Load-tolerant 5s default for closeTimeoutMs (mcp-app-frame.tsx); server graceful-close receipt window raised to 35s so it dominates the relay's 30s cap (mcp-app-routes.ts)
Descendant-readiness race script-playground-service.test.ts Fixed 100ms sleep raced child Node startups; wrapper scripts published PID files non-atomically (Number('') === 0 probed the runner's own process group) Readiness sequenced through the injectable process-tree seam; PID files published via staged renames; polling budgets follow AGENT_BUNDLE_TEST_TIME_SCALE
timeline-App POST timeout mcp-app-real.e2e.test.ts waitForRequest/waitForResponse and several expect.poll calls used fixed budgets unscaled by the suite time scale; socket observations asserted synchronously against CDP event delivery All capture budgets scaled; socket assertions converted to polls
Extra HMR reload frame overview.e2e.test.ts Two simultaneous watched-file writes could split into two rsbuild compiles; the dev middleware holds asset requests during a compile, so the refreshed DOM can show both edits while the second reload announcement is still in flight, poisoning the captured baseline Each edit is barriered on its own announced reload generation before the next write
Extra reconcile cycle dev-workbench.test.ts Torn prepared project: loadConfig and the source snapshot read the config in two separate reads, so a write landing between them produced a stale model under a fresh revision hash #prepare hashes the config before load and after snapshot and restarts when they disagree (project-service.ts); watched config writes use staged renames
CLI suite timeout tests/cli.test.ts Pack/build/spawn budgets were fixed literals, ignoring the suite time scale Budgets scaled by timeScale
Cold-dist build race runtime-playground-fixture.ts consumers Two workers on a cold tree raced the same ensure-build of examples/rsc-agent-runtime/dist globalSetup builds the payload once in the orchestrator before workers start

Each fix names its race; none inflates a timeout to paper over one (budget changes are scale-convention fixes or documented dominance requirements between paired timers).

Reconciliation with #90

#90's RSTEST_WORKER_ID isolation (per-worker cache/tmp/pack roots, setupFiles, isolate: true, unpinned unit/packed/watch pools) is merged here unchanged — it is correct hardening and prevents cross-worker root collisions. It does not, however, address any of the three originally cataloged flakes: those live in product timers, readiness sequencing, and unscaled budgets, not in shared-root collisions. Verdict per flake: all three were still-needs-fix (fixed in this branch), none fixed-by-#90.

This branch is the single coherent landing: #90's isolation + the root-cause fixes + the integration-pool worker derivation the burn-in evidence actually covers. #90 should be closed with credit.

Final worker derivation

Integration pool, CI and local alike: max(1, min(4, floor(availableParallelism() / 2))) — 2 workers on 4-core hosted runners. Rstest's own auto-sizing would pick cores − 1 (3 on hosted runners), but each worker drives a Chrome + dev-server + rsbuild pair, so halving keeps pairs from starving each other; the cap bounds memory on large machines; AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides for bisection. Unit/packed/watch pools follow #90's unpinned auto-sizing.

Evidence

Local burn-in, all on the CI shape (taskset 4 cores, CI=1, 2 workers), on a heavily shared 96-core host (load 40–90, swap full — substantially harsher than a hosted runner):

  • Baseline on pinned main: 14 runs; reproduced the rotating flakes (catalog above).
  • Post-fix waves at successive compositions: 12-run wave at 107b242d clean except one overview HMR flake (root-caused, fixed in 9128116e); 12-run wave at 9128116e had 3 cold-window failures — two were the CLI fixed-budget defect (fixed in 45333196), one was a scaled 60s assertion budget exhausted while four cold runs plus the full local-ci gate saturated the host simultaneously (remaining-budget analysis in the branch log; not a product race).
  • A/B stress verification for each named fix (e.g. torn-prepare: 0/12 failures fixed vs. reproducible torn preparations unfixed; overview HMR: 0/10 fixed vs. 3/10 unfixed) under deliberate CPU contention.
  • Definitive wave at the final tip (45333196) and a solo pnpm check:local-ci gate: results appended before merge.

Hosted evidence: this PR's own Verify matrix, plus rerun legs to accumulate ≥9 hosted legs where queue availability allows; if the hosted queue stalls >2h the local evidence above plus a green check:local-ci is the documented landing basis, with hosted proof accumulating post-merge.

CI wall-time

Serial integration legs on hosted runners ran ~7–8 minutes; the 2-worker shape runs the same suite in ~4 minutes locally on equivalent cores (247–257s warm). Measured hosted before/after numbers will be reported from this PR's Verify runs.

ScriptedAlchemy and others added 16 commits August 31, 2026 22:25
…ng fixed timers

Three contention races in one file. The timeout-drain test armed a 100ms
service timeout against two sequential Node process startups, so under CPU
contention the tree kill landed before the descendant wrote its ready file;
the termination now waits for observable descendant readiness through the
processTree seam before delegating to the real cleanup. The wrapper scripts
published descendant pids with a plain writeFile, so a poll could read the
created-but-empty file, parse Number('') === 0, and probe the test runner's
own process group; pid files now appear atomically via staged rename. The
file's eventually() budget was a fixed 500ms; it now follows the suite time
scale like every other polling budget.

Under a 4-busy-loop taskset reproducer the unfixed file failed 12 of 12
completed runs; the fixed file failed 0.
…ol workers start

Four e2e files copy the shared examples/rsc-agent-runtime/dist tree through
runtime-playground-fixture.ts, and its ensure-build ran inside whichever
worker got there first. On a cold tree (every CI runner) two parallel workers
could race the same build and one could copy a torn payload. The ensure now
also runs as the integration pool's globalSetup, once in the orchestrator,
before any worker exists; the per-fixture call remains as a warm no-op for
single-worker configs.
…ve the serial pin)

Hosted runners report 4 cores, so CI now runs the integration pool with 2
workers instead of 1. The rotating contention flakes that motivated the pin
were fixed at their sources (readiness sequencing, staged-rename pid and
watched-file publications, orchestrator-owned cold builds, ephemeral ports,
time-scaled budgets); the config comment records that history and the
no-re-pin policy. mcp-app-real's request/response waiters also follow the
suite time scale now instead of a fixed 30s default that ignored worker
contention.
…ing them synchronously

The hmr-client-count attribute is the server's view of the reload channel,
read through the main page's CDP session, while Playwright's websocket events
arrive on the observing page's session; under worker contention the server
can count a client before the event reaches the test process, so the socket
lists must be awaited. Burn-in of the unpinned 2-worker CI shape caught the
empty-array assertion once at mcp-app-real.e2e.test.ts:579. The file's
remaining fixed 3s expect.poll budgets now follow the suite time scale too.
…o the suite time scale

Five expect.poll calls captured consent and operation route traffic with the
default 1s budget while their sibling waits scale by timeScale; a contended
2-worker run lost that race once at line 1038 (the tools/call response
arrives after a real RSC operation round trip).
…on, unpinned unit/packed/watch pools

Reconciliation: the integration pool keeps the burn-in-proven derivation
(half cores clamped to [1,4] → 2 workers on hosted runners, with the
AGENT_BUNDLE_INTEGRATION_MAX_WORKERS bisect lever) instead of rstest's
auto-sizing (cores - 1 → an unproven 3-worker shape for Chrome + dev-server
pairs), and both the integration and watch configs run the shared
rsc-agent-runtime example ensure-build once in the orchestrator via
globalSetup, which worker-scoped tmp roots do not cover. Everything else
lands as #90 wrote it: per-worker TMPDIR/XDG/npm-cache isolation via
rstest.setup.ts, unit pool with isolate off, parallel packed pool.
…ant default

The timer exists to bound a hung or hostile app's teardown, but the 1s
default also lost races against healthy teardown handshakes on loaded hosts:
when it fired, the forced DELETE superseded the queued graceful close (the
completed force clears the relay queue), so the /close POST never happened
and close accounting showed an extra force entry. Both burn-in flakes in the
close seam — mcp-page-app-browser's 8-of-7 closes and mcp-app-real's
never-observed second /close request — trace to this misfire. 5s still
bounds hostility; callers that want a tight budget pass closeTimeoutMs.
…he finalize poll

Live config updates in dev-workbench.test.ts used plain writeFile on a path
the coordinator re-reads while rebuilds settle, so a starved prepare could
read the truncated window, see a missing dev runtime, and tear the provider
session down (the burn-in's extra reconcile/unsubscribe/close). The writes
now go through the staged-rename helper, moved to agent-bundle test support
with a workbench re-export so both suites share one implementation. The
playground finalize poll also had a fixed 250ms budget; it follows the suite
time scale now.
… force-close cap

The receipt TTL was 5s — exactly the frame relay's new force-close default —
so the designed fallback DELETE raced the receipt expiry on a knife edge and
could 404 after an accepted graceful close (seen once in the re-gate burn-in
on the bundled-template close path). The receipt exists precisely to keep
that late DELETE idempotent, so its window now exceeds the relay's 30s
closeTimeoutMs cap rather than sitting equal to one particular default.
…d snapshot

loadConfig evaluates the config from one file read while
snapshotProjectSource hashes it in a second read. A config replacement
landing between those reads produced a torn PreparedProject: its model
and devRuntime belonged to the old bytes while its source revision
hashed the new tree. Consumers dedupe prepared deliveries by revision,
so the torn preparation reconciled a stale runtime declaration under a
fresh revision — observed as an extra reconcile in dev-workbench's
Runtime App reconciliation suite whenever a watcher- or POST-driven
build straddled the test's config replacement under CPU contention.

Prepare now fingerprints the config before load and after snapshot and
restarts itself when the two disagree, so every delivered preparation
is internally consistent.
… injectable window

The receipt window grew to 35s so it dominates the frame relay's 30s
force-close cap, which made the expiry unit test's real 5.1s sleep
assert against a window that no longer expires in-test. Expiry
semantics are unchanged, so the routes now accept a test-only window
override and the probe sleeps 1.1s against a 1s window instead of
tracking the production constant with a real 35s wait.
…annel

The two-file live edit was written in one Promise.all, leaving the App
compile count to the watcher's aggregation window: one coalesced compile
or two split ones. On a split, the dev middleware holds asset requests
during the second compile, so the refreshed frame can already show both
edits while the second reload announcement is still in flight — the test
then captured its reload-frame baseline without it and the late frame
poisoned the exact-equality reconcile assertions (the intermittent extra
generation:2 runtime-app-reload).

The edits are now written sequentially, each barriered on its own
announced generation. Frames arrive in order on one socket, so after the
second barrier no edit-driven announcement can be outstanding and the
baseline is race-free. Verified 0/10 failures under a 4-core contention
harness where the simultaneous write failed 3/10.
cli.test.ts was the last integration-pool file whose it-budgets were
fixed literals: every test packs or spawns child CLI processes, yet the
budgets ignored AGENT_BUNDLE_TEST_TIME_SCALE while the rest of the pool
scales with it. Under the 2-worker CI shape the packed-consumer test
blew its fixed 60s budget on a cold, heavily loaded pass. Scaled
budgets cost nothing on green runs and still bound real hangs.
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 88313ac

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T01:18:48.627778Z 4533319 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4533319642

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +9 to +10
export const rstestWorkerRoot = (): string => {
const root = join(hostTemporaryRoot, 'agent-bundle-rstest-w' + rstestWorkerId());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include the test-run identity in each worker temp root

When pnpm check:local-ci runs its Node legs concurrently (scripts/local-ci.mjs), every leg starts a separate Rstest process whose RSTEST_WORKER_ID numbering begins again at the same values. Consequently, workers from different legs share paths such as /tmp/agent-bundle-rstest-w1; the CLI test's before/after scan for agent-bundle-artifact-* directories can then observe a sibling leg's legitimate in-flight directory and fail at cli.test.ts:255-269. Add a per-run or per-leg discriminator to this root, or assign each local-CI leg a private TMPDIR, before appending the worker ID.

Useful? React with 👍 / 👎.

// preparation so both reads agree; the retry cap only yields once writes
// outpace prepares for several consecutive rounds, which no real editor
// or test harness sustains.
if (tornRetries < 3) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Fail closed instead of publishing the fourth torn preparation

After three consecutive config changes, tornRetries is 3 and this entire comparison is skipped, so the method proceeds with the config loaded before the latest write but the snapshot/revision captured after it—the exact stale-model/fresh-revision state this fix is intended to prevent. A save burst or generated config writer can sustain four rounds; assuming it will not does not preserve the preparation invariant. Keep checking on the final attempt and return a typed invalid/transient preparation (or otherwise retry outside this bounded call) when the identities still differ, rather than accepting the torn state.

# Conflicts:
#	packages/agent-bundle/tests/cli.test.ts
#	packages/agent-bundle/tests/dev-workbench-packaging.test.ts
#	packages/agent-bundle/tests/public-api-packed.test.ts
#	packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts
…tins

rstest.setup.ts imports rstest.worker-isolation.ts (node:fs/os/path), which
browser pools bundle into the page where node: is an unhandled scheme --
the runtime-playground browser pool failed to build with zero tests run.
Browser projects now load rstest.setup.browser.ts (empty; worker isolation
is Node-only), and the multi-project runtime-playground config scopes the
Node setup to its runtime-node project.
@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Verification against the #90 stop-findings (evidence transfer)

I resolved #90's conflicts and stopped it on two empirical findings (evidence). Verifying #118 against them, plus mergeability:

a. Cold-dist cross-worker race — COVERED by this branch. rstest.integration.config.ts adds globalSetup: ['./rstest.integration.setup.ts'], which runs ensureRuntimeExamplePayload() (extracted to packages/workbench/tests/helpers/runtime-example-payload.ts) once in the orchestrator before any worker starts, so no worker ever sees a cold examples/rsc-agent-runtime/dist. Re-verified empirically at the new head: pnpm build, rm -rf examples/rsc-agent-runtime/dist, then test:integration:run — 556 passed, 0 failed (the same fresh-tree procedure that failed twice on #90's branch and passed on main).

b. Browser-setup defect — WAS MISSING, now pushed. This branch had the same defect #90 had: rstest.runtime-playground.browser.config.ts and rstest.runtime-playground.config.ts loaded rstest.setup.ts (imports node:fs/node:os/node:path via rstest.worker-isolation.ts) into browser pools, where node: is an unhandled scheme — the standalone browser pool failed to build with 0 tests run. Cherry-picked the fix from #90's branch as 0298dae: browser projects load an empty rstest.setup.browser.ts; the Node setup is scoped to the runtime-node project. Post-fix browser pool: 2 passed, exit 0.

c. Mergeability with main — the branch was CONFLICTING (merge-base b0f14c8 predates #109/#112); merged in c14f05f. Same three-file conflict pattern as #90 plus cli.test.ts: every install site in dev-workbench-packaging, public-api-packed, and rsc-runtime-optional-packaging now spreads #109's cachedNpmInstallArguments (all six are consumer-contract installs) while keeping this branch's installedEnvironment() / isolatedCommandEnvironment() per-worker isolation; cli.test.ts keeps both sides' imports (timeScale + cachedNpmInstallArguments, both used in auto-merged body changes); shared-pack.ts auto-merged with both argument lists on top of the isolation plumbing.

Local verification at 0298dae (fresh worktree): typecheck, lint, test:packed (20 passed), test:unit (1781 passed), fresh-dist test:integration:run (556 passed), browser pool (2 passed) — all green. Gating after the rest of the wave lands, per landing order.

@pkg-pr-new

pkg-pr-new Bot commented Sep 1, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@118
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@118
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/rsc-runtime@118

commit: 88313ac

@ScriptedAlchemy
ScriptedAlchemy merged commit 13a7b27 into main Sep 1, 2026
10 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the perf/ci-parallel-unpin branch September 1, 2026 05:18
@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Merged by owner instruction ahead of the stuck hosted queue (Verify Node 24 sat queued >1h; all completed hosted checks were green: Verify 22.19.0/26, Release gates, Examples, micro-eval, dependency review). Hosted CI on main accumulates the post-merge evidence per this PR's documented plan. Merge basis: squash of 88313ac (clean merge of main @ e8908cc, verified clean against 847989d via merge-tree) with local verification at 0298dae: typecheck, lint, test:packed 20 passed, test:unit 1781 passed, fresh-dist test:integration:run 556 passed, browser pool 2 passed.

ScriptedAlchemy added a commit that referenced this pull request Sep 3, 2026
…thout one (#410)

* chore(changesets): backfill changesets for merged PRs that shipped without one

Thirteen merged PRs changed a publishable package's shipped surface without a
changeset. Each gets a patch entry named for the PR (backfill-pr-<N>.md) with a
user-facing summary and the PR reference, per .changeset/README.md.

* chore(changesets): rewrite backfill summaries in imperative, consumer-first form

* chore(changesets): drop test-only detail from the #118 backfill summary

* chore(changesets): scope the #164 backfill to the runtime and name the surfaces behind dependency bumps

* chore(changesets): drop create-agent-bundle from the #348 backfill (output-neutral refactor)
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