Skip to content

test: wait for events instead of fixed times; ci: run the whole suite once, on Linux at Node 22.12.0 - #884

Open
nissa-seru wants to merge 28 commits into
anima-research:mainfrom
nissa-seru:alfred/suite-speed
Open

nissa-seru wants to merge 28 commits into
anima-research:mainfrom
nissa-seru:alfred/suite-speed

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

This pull request changes tests and CI so the suite spends less time waiting, and CI runs the whole suite once, on Linux at Node 22.12.0. It replaces many fixed-time waits in the tests it touches with waits for the event they stand for, a mocked clock, retries with no delay, or a 50 ms grace where the test itself keeps what the grace waits for from happening. Changes says which waits stay as they were. Eight test files gain LONGER_THAN_ANY_START_MS, a 60 s give-up, for waits in them. On macOS, and on Ubuntu at Node 24 and the newest release, new Install & start legs check that the packed package installs on its own and loads. Run alone, the fourteen files we timed both ways took 582.4 s on main and 260.8 s with an earlier version of this change (under Tests). The change is tests and CI, plus two setters in src/ for tests, which src/index.ts doesn't re-export.

Problem

At main's 62a126c7, run alone, the fourteen test files this change touches and we timed took 582.4 s, against 260.8 s with an earlier version of this change (the table under Tests). Their tests wait out fixed times: stop graces for children the tests made unkillable, abandon graces for turns the tests hold past them, the timeout of the initial policy request (featureSets/update) where the fixture never answers it, retry delays and fixed sleeps. CI's Build & Test runs the whole suite on Ubuntu and macOS at several Node versions.

Changes

Waits for an event, not for time:

  • Stop graces. src/mcpl/transport.ts adds setStdioExitGraceForTests(child, ms), which keeps ms in a WeakMap that :521 reads in place of STDIO_EXIT_GRACE_MS (2 s) for a child that has an entry. mcp-server-ownership, mcp-modern-outcomes, mcpl-legacy-lifetime and mcpl-stderr-tail give 50 ms to children whose kill() the test made a no-op, and to no other child. Two tests keep 2 s on such a child.
  • The abandon grace. src/framework.ts adds setAbandonGraceForTests(framework, ms), whose doc says it's for tests only, and that framework's abandon then takes ms as its grace in place of ABANDON_GRACE_MS (10 s). Four tests that hold a turn past the grace take 50 ms, in host-quiesce, stop-without-stream and turn-continuation.
  • Fixtures that answer. host-quiesce's MCPL push fixture and mcpl-runtime-lifecycle's hot-connect fixture now advertise MCPL 0.5 and answer the initial policy request (featureSets/update). The host-quiesce test also asserts that the flushed push is answered with no error and accepted: true.
  • Posts and timers.
    • reply-server-routes waits for the agent's queued posts (postsSettled) in place of three loops of three 300 ms sleeps, and drops three 500 ms sleeps and one more 300 ms sleep.
    • event-gate-selfwake's eight tests mock setTimeout and tick it.
    • provider-acceleration-cooldown mocks Date to step the cooldown.
    • event-gate-judge's judge-timeout test waits for the abort.
    • mcp-recorded-text counts name probes at two sizes in place of a wall-clock bound.
  • Retries. explicit-prose-routing's error-retry tests give AgentFramework.create the errorPolicy retryAtOnce: maxRetries: 1, and an onInferenceError that answers attempt < 1 ? { retry: true, delayMs: 0 } : { retry: false }.
  • workspace-three-way. Five tests drop or shorten fixed waits. One sets the seeded file's times 60 s back with utimes, one moves Date.now() past the racy window, two hold chokidar's directory reads until the passes started by then have ended, and one takes its siblings' 50 ms scan deadline.

Give-ups. diagnostics-signal, event-gate-judge, failed-create-releases, liveness-watchdog, mcp-modern-connection, mcp-server-bin, mcpl-legacy-lifetime and provider-acceleration-cooldown gain LONGER_THAN_ANY_START_MS (60 s) as the give-up of waits in them. pack-installs-alone's install-limit case answers npm pack itself, and its load-limit case times each of its five checks from the start of that check's own hang.

pack-installs-alone's 27 cases run four at a time (describe("pack-installs-alone", { concurrency: 4 }, …)).

The workspace fingerprint gets a test file of its own, test/workspace-fingerprint.test.ts, with one test for each term of what a fingerprint vouches for and each edge of its window. workspace-three-way's two outside cases move there, and its fingerprints test goes. This file adds coverage, not speed. It comes with this change because it takes over those cases from workspace-three-way, whose other tests this change also edits.

CI (.github/workflows/ci.yml, with CONTRIBUTING.md to match):

  • Build & Test (ubuntu-latest, node 22.12.0) keeps its steps: typecheck, build, npm test, the unforced files and the diagnostic-report check. It's now one leg.
  • Install & start (<os>, node <version>) is new. It runs npm install, the build and node scripts/pack-installs-alone.mjs, the check pack-check and the publish workflow already run.
    • On a push to main: macOS at Node 22.12.0, 24 and the newest release, and Ubuntu at 24 and the newest release.
    • On a pull request: the same, but with macOS at 22.12.0 only, as Build & Test's macOS legs were.
  • pack-check and code-execution-bun are unchanged.
  • The check names change. Build & Test (ubuntu-latest, node 22.12.0) stays, and the other Build & Test names go. GitHub's API gives "protected": false for main, and no rules for it.

So the suite no longer runs on macOS, or at Node 24 and the newest release. Two comments this change removes said why those legs ran it:

  • the Test step's "process/stdio behaviour differs (pipes are asynchronous on macOS), and ubuntu-only runs have missed that";
  • the matrix's "stdio flushing semantics around process exit differ between versions and platforms".

Open pull requests. By git merge-tree --write-tree at 23:27 UTC, two of the 55 open pull requests merge cleanly onto main's 62a126c7 and conflict with this head: #864, in test/workspace-three-way.test.ts, and #850, in test/event-gate-judge.test.ts. Four that already conflict with main conflict differently with this head. #194 also conflicts in test/event-gate-selfwake.test.ts. In #155 and #116 the conflict regions in src/framework.ts, and in #370 those in .github/workflows/ci.yml, differ from the ones against main.

Left as they are:

  • mcpl-stderr-tail's "a handshake that times out carries the last of the server's stderr" still waits out the handshake's 30 s timeout, which its comment names as what it waits for (:108).
  • Nothing in this change tests that setAbandonGraceForTests takes effect.
  • Other waits in the files this change touches are as on main.

Tests

Each changed test file alone, on main and with this change. On the files of an earlier version of this change, from a clean checkout of a commit that isn't in this pull request's history, and on main's 62a126c7, each built on its own: Linux, Node 24.22.1, npm 11.19.1 and TypeScript 7.0.2. Each compiled test file ran by itself, one at a time, with npm test's node flags and a TAP reporter. That commit's files differ from this head's, c556485e, only in CONTRIBUTING.md, in comments of .github/workflows/ci.yml, test/ci-workflow-pins.test.ts and test/workspace-three-way.test.ts, and in test/explicit-prose-routing.test.ts's retryAtOnce. The repository keeps no lockfile, and package.json gives its direct dependencies as ^ ranges apart from @animalabs/mcpl-core's exact 0.4.0. By npm ls --depth=0 at 23:00 UTC in the run's own copy, whose node_modules dates from the run's install at 21:56:41 UTC and which main's side used through a link, they were @animalabs/chronicle 0.4.0, @animalabs/context-manager 0.23.1, @animalabs/mcpl-core 0.4.0, @animalabs/membrane 0.8.0, @babel/parser 7.29.11, @modelcontextprotocol/client and @modelcontextprotocol/server 2.4.0, @types/node 22.20.5, @types/ws 8.18.2, chokidar 4.0.3, discord.js 14.27.0, tsx 4.23.15, typescript 7.0.2 and ws 8.22.0.

file main this change tests passed, main / this change
mcp-server-ownership 90.2 s 21.9 s 40 of 40 / 40 of 40
mcp-modern-outcomes 66.9 s 37.1 s 59 of 59 / 59 of 59
mcpl-legacy-lifetime 49.8 s 19.2 s 19 of 19 / 19 of 19
mcpl-stderr-tail 38.6 s 33.4 s 23 of 23 / 23 of 23
stop-without-stream 43.1 s 23.5 s 48 of 48 / 48 of 48
host-quiesce 48.1 s 23.4 s 51 of 51 / 51 of 51
reply-server-routes 34.7 s 10.3 s 55 of 55 / 55 of 55
pack-installs-alone 116.0 s 44.2 s 27 of 27 / 27 of 27
workspace-three-way 27.9 s 22.4 s 387 of 387 / 386 of 386
mcpl-runtime-lifecycle 20.4 s 4.4 s 37 of 37 / 37 of 37
turn-continuation 25.6 s 15.8 s 52 of 52 / 52 of 52
event-gate-selfwake 9.5 s 0.2 s 9 of 9 / 9 of 9
explicit-prose-routing 11.3 s 4.7 s 120 of 120 / 120 of 120
ci-workflow-pins 0.2 s 0.3 s 5 of 5 / 5 of 5
  • The sum over these fourteen files is 582.4 s on main and 260.8 s with this change. workspace-three-way has one test fewer with this change because its fingerprints test is gone.
  • At this head's copies of the three test files that changed since, each from a clean checkout and run the same way: explicit-prose-routing passed 120 of 120 in 4.8 s and ci-workflow-pins 5 of 5 in 0.3 s, at a commit whose copies of both are this head's, and workspace-three-way 386 of 386 in 16.5 s at this head.
  • The load. The machine has 12 CPUs. The one-minute load average was 20 or more, up to 37, from 21:59:13 to 22:03:24 UTC, and under 20 at every other time during the table's runs. The runs that overlap that span are both runs of mcp-modern-outcomes, mcpl-legacy-lifetime and mcpl-stderr-tail, and main's run of stop-without-stream.
  • With this change only: diagnostics-signal passed 4 of 4 in 2.3 s, event-gate-judge 58 of 58 in 8.7 s, failed-create-releases 11 of 11 in 2.7 s, liveness-watchdog 3 of 3 in 4.0 s, mcp-modern-connection 10 of 10 in 3.5 s, mcp-recorded-text 45 of 45 in 2.6 s, mcp-server-bin 5 of 5 in 2.5 s, provider-acceleration-cooldown 10 of 10 in 7.9 s, and workspace-fingerprint 12 of 12 in 0.2 s.

A child that's never reaped. To check that the shortened stop graces still let their tests catch a child that's never reaped, we changed each side's compiled src/mcpl/transport.js so a child still alive after SIGKILL's wait counts as reaped, and ran the four files with a 30 s test timeout. On main and with this change the same tests failed or timed out, by name: 17 of 40 in mcp-server-ownership, 9 of 59 in mcp-modern-outcomes, 9 of 19 in mcpl-legacy-lifetime and 1 of 23 in mcpl-stderr-tail. In mcpl-legacy-lifetime, three of the nine fail on main at its 10 s give-up and time out with this change at the 30 s test timeout, since its give-up is 60 s.

Each change taken back, in a copy of that run's build:

  • mcpl-runtime-lifecycle's hot-connect fixture without its featureSets/update answer: the test passes, in 16.2 s, against 1.1 s with the answer.
  • armSelfWake's delay doubled: event-gate-selfwake fails 5 of its 9 tests.
  • the push handler answering accepted: false: host-quiesce's push test fails at "the flushed push is accepted".
  • scripts/pack-installs-alone.mjs ending a timed-out child with SIGTERM: the case whose title opens "a load, a CommonJS bin's parse or the bun probe that outlives its limit fails at the limit even when it handles SIGTERM" fails with "ended 60106.9 ms after the hang started".

workspace-fingerprint, in a separate run from a clean checkout of another commit with an earlier version of this change, whose src/ and test/workspace-fingerprint.test.ts are this head's, on Linux with Node 24.22.1: its 12 tests passed in each of 3 runs, and each of 15 single changes to the fingerprint's checks failed exactly one test.

Not verified

  • The whole suite at this head. We ran the files above, each alone. ci.yml runs Build & Test, with the whole suite, on pull_request.
  • Node 22.12.0. Our runs were at Node 24.22.1, and CI's one Build & Test leg is at 22.12.0.
  • macOS: no run here.
  • The new workflow on GitHub. This pull request's CI is the new jobs' first run.

  • Test and CI only, apart from the two test-only setters in src/, so there's no changelog fragment, and the pull request needs the no-changelog label.

Alfred-2451, William-2616 and Hilda-2580 made the commits, and Alfred-2451 ran the timings. Cordelia-2434 read the CI change and weighed what each part adds, and Amos-2081 read the per-child stop grace. Harriet-2914 read eight of William-2616's commits, the load fixes, and Alfred-2451 read the other three, the watchdog commits. Duncan-2410 read the fingerprint tests. Daniel-2612 wrote this description.

Hilda-2580 and others added 26 commits October 28, 2026 19:24
Five workspace tests waited out real time that what they check doesn't
need.

"no restored timestamp hides a change" slept 2.1 s twice and listed the
mount, so that the file's stamps were old by the time a hash recorded
its fingerprint. What that bought is an old mtime for utimes and cp -p
to restore, as they do from a backup. The test now sets the seeded
file's times 60 s back with utimes instead; only ctime can't be set, and
the kernel moves it to now on every change. With the sleeps and the
listings gone, the test fails and passes as before under each of these
mutants of the fingerprint check (3 to 5 runs of each, with and without
the sleeps):

  mutant                          this test         fingerprintVouches'
                                                    assertions
  ctime left out of the           passes            fail: "any field
  field comparison                                  differing"
  the racy window left out        passes            fail: "hashed inside
                                                    the racy window"
  both                            fails: "cp -p:    not run
                                  rehashed and
                                  adopted"
  ctime left out of the field     fails: "cp -p:    fail: "any field
  comparison and of the           rehashed and      differing"
  window's newest stamp           adopted"

The assertions vary only ctime among the fields, and sample the window
at 1,000 and 5,000 ms.

"a recheck of an unchanged file records nothing" slept 2.1 s so that
every hash after it came past the racy window of the stamps utimes set.
It now moves Date.now() past that window for the rest of the test, which
makes no change after that point, and asserts what the wait was for:
after the first recheck, the recorded fingerprint vouches for the file.
A recheck that re-recorded whenever a fresh fingerprint vouched still
fails it, and would pass if the clock were moved for the first sync
alone.

The two catch-up tests held each of chokidar's directory reads for a
second, so that a pass started beside the scan would finish before
chokidar watched anything. The reads now wait until the start (or the
re-attach) has returned, a timer turn has passed, and every pass started
by then has ended. A hold gives up after 15 s, so that a regression in
which start(), reattach() or a pass waited for the watcher's ready can't
hang its test, and a hold that gave up fails the test by name. A pass
started at start() still fails the first, a pass started at the
re-attach the second, a catch-up bound of 0 ms both, and a start() that
waits for the watcher's ready the first, on the hold that gave up. The
re-attach test now re-attaches only once the clock has moved past the
first ready, which the second ready's time is told apart from.

"a batch that comes while a late scan's successor still runs isn't held
either" set a 300 ms scan deadline and waited it out; its siblings use
50 ms, and so does it now. A successor scan not marked late still fails
it, at the third batch.
StdioTransport gives a stdio child STDIO_EXIT_GRACE_MS (2 s) to exit
after SIGTERM, and again after SIGKILL, before it says the child could
not be reaped. In test/mcp-server-ownership.test.ts, 17 tests use a
child whose kill() is a no-op, so each close of one waits out both
periods, four seconds; the startup test closes two, one per engine.
Those waits were most of the file's 90 s. Nothing ends such a child
while it is closed (a close never ends its stdin, and no test that
shortens a grace gives its child any of the fixtures' other ways to
exit), so the wait only delays the verdict, and the tests assert what
the failed reap leaves (the error's words, the server still registered,
a connect refused, the launch count, the retry completing), not how long
it took.

- src/mcpl/transport.ts: setStdioExitGraceForTests(child, ms) records a
  grace for one child in a WeakMap. Its doc says to set it only on a
  child that can exit by no means, since a short grace there races no
  exit. escalate(), the one attempt terminate() makes for a child, reads
  that child's grace once at its start, or STDIO_EXIT_GRACE_MS when
  there is none, and uses the value for both waits, its log line and its
  error. The setter isn't reachable from the package: package.json's
  exports name only ".", and src/index.ts doesn't re-export it.
- test/mcp-server-ownership.test.ts: unreapable(graceMs?) gives each
  child it makes unkillable that grace. 12 of its 13 call sites pass
  SHORT_GRACE_MS (500 ms), two of them in loops that make two tests
  each, so 14 of the 15 tests that use it, the startup test among them.
  The loop's two "lost on its own whose child can't be reaped" tests,
  which make their child's kill() a no-op by hand, give that child
  SHORT_GRACE_MS too. The stop() test's two SIGKILL error checks match
  "did not exit within 500ms of SIGKILL", where they took any number, so
  a setter that records nothing, or an escalate() that doesn't read the
  child's grace, fails them.

"a listing of a server whose disconnect failed to reap isn't published"
calls unreapable() with no grace and keeps 2 s: it releases its held
listing after polling for the close, and needs the failing disconnect
still in flight then.

Why per child: a grace set for the whole process also shortens the waits
of children that do exit, such as the ignore-sigterm child after SIGKILL
and ordinary children closed at a test's end, and those race the timer.
On Linux with Node 24.22.1, spawning the fixture beside the whole suite,
a child's exit arrived at most 473.5 ms after SIGKILL and 268 ms after
SIGTERM in 1,240 trials of each (the one-minute load reached 69). Every
child that can exit keeps 2 s here, as in production.

Runs of the file alone, before and after interleaved in one run: 94.1,
95.0 and 92.9 s before; 40.8, 44.4 and 40.1 s after; 40 of 40 passing in
each, at a one-minute load of 26-59. With terminate() taking a child
still alive after SIGKILL's wait as reaped, the same 17 tests fail as
with that break before the change; with a disconnect in flight no longer
refusing a connect of its id, the same 3.

The whole suite at this head: 8,793 tests, 8,731 passing, 0 failing, 62
skipped.

No changelog fragment: the change is test-only, with an internal setter
the package doesn't export (CONTRIBUTING.md, "What needs an entry"). It
touches src/, so the pull request needs the no-changelog label.
…nnect tests reconnect an ordinary child

- SHORT_GRACE_MS goes from 500 to 50. It is given only to children whose
  kill() the test made a no-op and that nothing else ends while they're
  closed, so it bounds no exit that can arrive: it only brings "could not
  be reaped" sooner. No test that takes it asserts, after an await, that
  the reap is still in flight: each awaits the reap's verdict, or an
  alert only the failed reap raises, before it asserts.
- "a connect during a disconnect is refused…" and "two simultaneous
  disconnects…" reconnect with an ordinary child, modern('m', '', log),
  in place of the ignore-SIGTERM one. Their reconnected child only has to
  be listed and routed; as the ignore-SIGTERM fixture it cost a further
  2 s at stop(). An ordinary child exits on SIGTERM within production's
  2 s, so nothing is shortened. The first child of each still ignores
  SIGTERM, as before.

Runs of the file alone (Linux, Node 24.22.1): 23.4, 24.9 and 23.9 s at a
one-minute load of 35-61, against 40.1-44.4 s with the previous commit
alone and 92.9-95.0 s before it. With terminate() taking a child still
alive after SIGKILL's wait as reaped, the same 17 tests fail; with a
disconnect in flight no longer refusing a connect of its id, the same 3.
… unkillable

The per-child seam the ownership tests take,
setStdioExitGraceForTests(child, ms), now in the three files that make a
stdio child's kill() a no-op and then wait out StdioTransport's 2 s
after SIGTERM and 2 s after SIGKILL to reach "could not be reaped", 4 s
a close. 17 tests take a short grace. Each such child is given 50 ms,
beside the no-op kill() that makes it unable to exit. Nothing else ends
it while it's closed, so the wait races no exit and only brings the
verdict sooner.

- test/mcp-modern-outcomes.test.ts: firstLaunch(true)'s child, the five
  children liveLine() reaches and makes unkillable by hand, the stuck
  transport of "a child that cannot be reaped fails close() explicitly",
  and the second-launch-on children of "a failed launch that cannot be
  reaped halts reconnecting". The three checks of the error's words that
  named 2000ms name the short grace, so a grace that isn't applied fails
  them. liveLine()'s child is typed ChildProcess, as the setter takes.
- test/mcpl-legacy-lifetime.test.ts: unreapableFrom(from, graceMs?) gives
  its stuck children the grace; eight of its nine callers pass it.
- test/mcpl-stderr-tail.test.ts: the one startup test whose launch can't
  be reaped.

Two tests keep 2 s, since each asserts, after an await, that the reap is
still in flight: "concurrent close() calls share one teardown and its
verdict" (mcp-modern-outcomes) and "a close that cannot reap fails, and
so do concurrent ones" (mcpl-legacy-lifetime) each check at 1 s that
neither close has settled. Every other shortened test asserts only after
the reap's verdict: a rejected close() or connect, an 'unreaped' event,
a permanent failure, holdsUnreapedChild, or lossReaped().

mcpl-stderr-tail's "a handshake that times out carries the last of the
server's stderr" keeps its 30 s on purpose: it pins the handshake-timeout
path itself, a wait for an answer that never comes, beside the
exited-server path its neighbour covers.
…turn past it

abandonActiveTurns() waits up to 10 s, polling every 250 ms, for the
turns it reached to end, then lists those still running as
unabandonable. Three tests hold a turn past that grace on purpose and
check that it's listed, so each waited out the full 10 s:
- stop-without-stream: "a quiesce's abandon of a turn whose hooks
  outlast the abandon's grace lists it…" (its hooks are held until after
  the quiesce returns) and "an abandon of the wait while it still holds
  the finished stream lists the turn if it outlasts the grace…" (its
  round's post is held until then);
- host-quiesce: "abandon reports turns it cannot cancel (token held, no
  stream)" (the token is held until the test's finally).

- src/framework.ts: the grace is ABANDON_GRACE_MS (10 s), as before.
  setAbandonGraceForTests(framework, ms), for tests only, records a
  grace for one framework in a WeakMap, which abandonActiveTurns() takes
  as its deadline in place of the constant. Its doc says to set it only
  where the test holds every turn the abandon reaches past the grace, so
  no turn can end within it. src/index.ts doesn't re-export it, and
  package.json's exports name only ".".
- The three tests set 50 ms before their quiesce. Two comments that
  named the ten-second grace go with it.

The loop re-tests its deadline after each 250 ms sleep, so with 50 ms
its first sleep is the whole wait, and each abandon reports about
0.25 s after it starts.
…Request, so the push it buffers is delivered

"MCPL: pushes buffer while quiesced; host/command resume reopens and
flushes" used a stdio fixture that advertised MCPL 0.4 and left
featureSets/update, the initial policy Request, unanswered, as a pre-0.5
server does. The host waits 15 s for it (sendFeatureSetsUpdateRequest's
default). Until then no grant is established, and the push the test
buffers while quiesced was refused once the resume flushed it: its
response was -32002, 'push-event rejected: capability "pushEvents"
initial policy not yet established'. The test took about 16 s, and two of
its checks couldn't fail for the reason they give: "no wake while
quiesced", since a refused push wakes no one, and "flushed", since a
refusal counted as the answer.

- The fixture advertises 0.5 with a "chat" feature set that uses
  pushEvents, as the push names, and answers the Request with
  { accepted: true }, as the suite's other MCPL fixtures do. It logs the
  push response's result beside its error.
- The test asserts that the flushed push is answered with no error and
  with accepted: true, since a push the host refuses can also be
  answered as a result with accepted: false.
…for fixed sleeps

settle() released the held turn, then three times ran runUntilIdle() and
slept 300 ms, 17 calls. Two tests in the explicit and hybrid describe
did the same inline. What each then checks is which server a reply was
published to, or that none was. In the default routing a turn awaits
its queued posts before it ends, but in explicit mode, and when a tool
ends the turn, its posts go out after it (chainSpeech, framework.ts).
Every post a stream queues while it is the agent's current stream is
chained behind the agent's entry in proseSpeechChains; a stream another
has replaced chains privately, and no test here replaces one.

- postsSettled(framework, agent) runs runUntilIdle(), awaits that
  entry, runs runUntilIdle() again, and returns once the entry hasn't
  moved, so a post queued by a turn the last post started is waited for
  too. settle() and the two inline loops call it.
- Four fixed sleeps after an awaited publish go: 500 ms after three
  tests' background channel_publish calls, and runUntilIdle() and
  300 ms after the code_execution test's script finished. Each call
  returns once the server answered channels/publish, or once the
  request timed out (handleToolPublish awaits sendChannelsPublish; the
  script awaits tools.channel_publish). The fixture logs a publish
  before it answers it and, in these tests, answers every publish at
  once, so what the sleep waited for had already happened.

The test reaches proseSpeechChains through a cast, as host-quiesce
reaches activeTurnTokens.
… up at 60 s by name

"a create() refused after it started the watchdog leaves none to kill the
process later" ended its child with SIGTERM after 20 s, as a safety, and
then checked the child's exit. The watchdog's worker ends its process with
SIGKILL (action 'exit') or SIGABRT ('abort'), never SIGTERM, so under load
a child still starting failed the test as if the release had left a
watchdog behind. With its child on one CPU shared with 16 busy loops, the
test at the parent commit failed 2 of 2 after 21.3 and 21.5 s: the safety
had ended the child before it printed anything.

What the test requires now:
- A refused create() that had started the liveness watchdog leaves no
  watchdog worker running: the child sees that worker exit, then ends on its
  own with code 0.
- A child that hasn't ended by LONGER_THAN_ANY_START_MS (60 s) fails the
  test by saying so, as "the watchdog worker started and never exited" when
  the child printed a start and the refusal and no exit, and as "the child
  hadn't finished" otherwise. A slow start can't read as the exit check's
  verdict, and a create() still running can't read as the release's.
- The child must have seen exactly one watchdog worker start, so the wait
  can't pass on nothing.
- The watchdog can't fire before the give-up: thresholdMs is
  NEVER_FIRES_IN_A_TEST_MS (2 x LONGER_THAN_ANY_START_MS), so a stalled main
  thread can't get a correct child killed.

The child wraps node:worker_threads' Worker (createRequire and
syncBuiltinESMExports) before create(), and prints a line when each worker
whose file names liveness-watchdog-worker starts and when it exits. If
create() doesn't refuse, the child exits at once, so the refusal check names
that. Otherwise it holds the workers referenced, waits on their exits, and
exits 0. The 200 ms wait and the 5 s busy loop, which gave a surviving
watchdog time to fire, are gone. The parent checks the give-up first, then
the exit (with what the child printed), the armed and refusal lines, and the
start count. A renamed worker file fails that count, and the count's message
names the file the child watches for.

The give-up ends the child with SIGKILL: it must end the child whatever the
child handles, and its own assertion comes first, so the signal never reads
as the watchdog's.

On Linux with Node 24.22.1:
- as is, 3 of 3 passed, the test taking 1.7-2.0 s; with its child on one
  CPU shared with 16 busy loops, 2 of 2 passed in 21.9 and 25.9 s.
- with the release no longer stopping the watchdog, and with stop() no
  longer terminating the worker: each fails at the give-up after 60.3 s,
  "the watchdog worker started and never exited".
- with create() held on a timer after the watchdog starts: fails at the
  give-up after 60.1 s, "the child hadn't finished".
- with the child's matcher naming another file, and without
  syncBuiltinESMExports(): each fails at the start count, 0.
- with create() no longer refusing: fails at the refusal check.
- this file and test/liveness-watchdog.test.ts together: 14 of 14; the
  whole suite passed.
…t 8 s as a SIGTERM

runWedgedChild killed its child with SIGTERM after 8 s, and the two tests
that use it then read that signal as the watchdog's: "expected SIGKILL,
got code/signal SIGTERM". The watchdog never sends SIGTERM, so under load a
child still starting at 8 s would have failed as if the watchdog had
misbehaved, the same way "a create() refused after it started the watchdog
leaves none to kill the process later" failed at 20 s.

What the tests require now:
- A wedged process whose watchdog's action is 'exit' is ended by SIGKILL,
  and one whose action is 'abort' by SIGABRT, with its report written, as
  before.
- A child the watchdog hasn't ended by LONGER_THAN_ANY_START_MS (60 s)
  fails the test by saying so: "the wedged child hadn't ended by 60000 ms,
  so the test ended it". That check comes before the signal check, so a
  slow start, or a watchdog that never fires, can't read as a wrong signal.

The child wedges its main thread for NEVER_FIRES_IN_A_TEST_MS (2 x
LONGER_THAN_ANY_START_MS) rather than 30 s, so a watchdog that never fires
leaves it wedged until the give-up, while a child the test somehow left
behind still ends by itself. The give-up ends the child with SIGKILL.
The two comments that described the old helper and its endless wedge go,
since neither held any longer.

On Linux with Node 24.22.1:
- as is, the file passed 3 of 3 in each of 3 runs, the two wedged tests
  taking 1.0-1.3 s. With the child on one CPU shared with 16 busy loops,
  2 of 2 passed, the wedged tests taking 7.5-9.2 s. At the parent commit,
  on that load, they passed 2 of 2 in 7.1-8.6 s, against the old 8 s
  safety.
- with the watchdog's worker no longer killing its process, and with the
  worker's file missing: both wedged tests fail at the give-up after
  60.0-60.1 s, by name; the healthy test passes.
….5 s after the worker's first

"watchdog does NOT kill a healthy (heartbeating) process" kept its child
alive for a fixed 1.5 s and passed if nothing killed it. Under load the
watchdog's worker could start late enough to make no check in that time,
so a main thread that stops beating passed too: with its child on one CPU
shared with 16 busy loops, a version whose main thread beats only once
passed 4 of 5 runs.

What the test requires now:
- The child stops waiting at the first check the real watchdog worker
  makes whose time, read just before the check ran, is at least 1.5 s
  after the worker's first check, and exits 0; nothing kills it before
  then. That check measures the heartbeat's age from the last beat it
  reads and kills when it is over 400 ms. So a child whose beats stopped
  for good more than 400 ms before it is killed before it can pass, and
  so is a worker whose view of the beats froze more than 400 ms before
  it, however late the worker starts and however bunched its checks.
  Beats that stop within the last 400 ms of the window, or a stall that
  ends between two checks, aren't guaranteed to be caught, as with the
  fixed 1.5 s before.
- The child also stops waiting if the worker exits first; the test then
  fails, saying how many checks the worker made. A child whose wrapper
  never saw the worker start, and a child that ends before recording what
  it saw, each fail by saying so. A child that hasn't ended by
  LONGER_THAN_ANY_START_MS (60 s) fails by the give-up's own message.

The child sees the real worker's checks with no change to src: its Worker
wrapper (createRequire and syncBuiltinESMExports, as in
failed-create-releases.test.ts) starts the watchdog's worker file under a
short eval shim. The shim wraps the worker's setTimeout so that each
callback reads the time before it runs and posts it once it has run, then
imports the real worker file, which schedules every check with
setTimeout. A check that kills the process posts nothing. The child
counts the window from the worker's first check, so a worker that starts
late, or reads the beat only once, still has the whole window to age it,
and writes what it saw to a file once, when it stops waiting.

On Linux with Node 24.22.1:
- without added load, as is, 3 of 3 passed, the test taking 2.2-2.4 s.
- with the child on one CPU shared with 16 busy loops, as is passed 5 of
  5 (11.4-16.5 s). The main thread's interval stamping no beat after the
  first, the beats stopping 600 ms after start(), and the worker reading
  only the first beat were each killed by SIGKILL in every run (5, 5 and 3
  runs), as a rule written before the runs required. Counted from start()
  instead, the window let the last of these pass 2 of 3 runs.
- without added load, the same three are each killed, and a worker that
  never schedules a check fails: "the watchdog's worker exited after 0
  checks". The whole file passed, 3 of 3.
…at 20 s

"a SIGUSR2 after every framework has stopped leaves the process running"
gave its child 20 s, then ended it with SIGTERM and failed with "the
child did not finish within 20 s". The child imports the whole framework
and creates and stops one before its signal, so on a loaded machine it
can still be starting at 20 s: the test failed so in two whole suites,
at one-minute loads of 101-126 sampled at their starts and ends, and
with its process on one CPU shared with 16 busy loops.

The give-up is now LONGER_THAN_ANY_START_MS (60 s), as in the isolate
tests, and fails by its own name: "the child hadn't finished by 60000
ms, so the test ended it". It ends the child with SIGKILL, so a handler
in the child can't hold it. The verdict is still the child's own exit.

On Linux with Node 24.22.1, at one-minute loads 74-107 sampled when the
runs began and ended, "under load" meaning the test's process on one
CPU shared with 16 busy loops:
- before this change, under load in an earlier run (one-minute loads
  124-176 sampled during it), the test failed 2 of 2 by its 20 s give-up.
- with it, the file passed 2 of 2 without added load (4.9-5.4 s) and 2
  of 2 under load (45.5-57.5 s).
- with the framework's handler listening for SIGUSR1 instead of SIGUSR2,
  the test fails: the child ends by SIGUSR2.
…st warms up first

Two tests in this file failed on loaded machines without anything being
wrong with the connection:
- "a lost child reconnects with backoff…" waited for its reconnect with
  the file's until(), which gave up after 5 s and said only "condition
  reached in time". The reconnect relaunches a node child and handshakes
  again, which took longer than that under load. Every until() in the
  file waits on a child process, so until() now gives up at
  LONGER_THAN_ANY_START_MS (60 s), naming what it waited for.
- "a deadline requests cancellation and reports no-response…" set the
  connection's requestTimeoutMs to 300 ms before its warm-up call, so
  the warm-up "echo" ran under the deadline meant for the slow call, and
  under load the warm-up itself was refused with the no-response error.
  The connection now starts with the default deadline, and the test sets
  300 ms on its configuration after the warm-up; callTool reads the
  deadline from that configuration at each call. The slow call's
  verdict, rejected at the deadline while the tool hadn't finished, is
  unchanged, but "hadn't finished" no longer compares the host's
  deadline with the tool's own 20 s sleep: the tool now runs in the
  fixture's release mode, released only by the test's own give-up at
  LONGER_THAN_ANY_START_MS, which the test clears once the call has
  rejected. A call that hadn't ended when the give-up released the tool
  fails by the give-up's name, "the call hadn't ended by 60000 ms, so
  the test released the tool", not as "Missing expected rejection", and
  so does a call that rejects only after the release. The fixture notes
  the cancellation when the call is already cancelled as its handler
  starts, as it is when the request and its cancellation are read
  together.

On Linux with Node 24.22.1, "under load" meaning the test's process on
one CPU shared with 16 busy loops:
- before this change, under load (one-minute loads 124-176 sampled), the
  reconnect test failed 2 of 2.
- with it, before the deadline test's give-up was named, the file passed
  2 of 2 without added load (8.6-9.0 s) and 2 of 2 under load
  (101.3-107.9 s), at one-minute loads 61-114 sampled.
- as committed, the deadline test passed 2 of 2 without added load (at
  one-minute loads 30-34); with the per-call deadline ignored, it fails
  after 60 s: "the call hadn't ended by 60000 ms, so the test released
  the tool".
- with no reconnect ever scheduled, the reconnect test fails after 60 s:
  "the connection reconnects, within 60000 ms" (run before the deadline
  test's release mode and the fixture's change, which it doesn't use).
The file's within() gave node children 10 s to connect or answer and
30 s to finish. "importing the package, or running its server module
under the command's name, starts nothing…" runs three children in a
row, two importing the whole package and one running a copy of the
server module, and on a loaded machine the second was still running at
30 s: the test failed with "the package
root imported under `node -e`: the run finished within 30000 ms".

within() now always gives up at LONGER_THAN_ANY_START_MS (60 s), failing
by the name it's given, so a slow start can't read as a verdict. The
verdicts are unchanged: exit codes, stderr, connections and answers.

On Linux with Node 24.22.1, at one-minute loads 74-107 sampled when the
runs began and ended, "under load" meaning the test's process on one
CPU shared with 16 busy loops:
- the file passed 2 of 2 without added load (7.3-9.2 s) and 2 of 2
  under load (52.8 s each).
- with the server module starting itself whenever its entry point is
  named mcp-server.js, the test fails, naming the bundled case.
…00 ms

"a judge that takes too long is timed out, its request aborted, and the
agent woken" slept 200 ms, then asserted the judge's request was
aborted. The rule's 100 ms debounce and the 50 ms judge timeout put the
abort at least 150 ms after evaluate(), leaving the sleep 50 ms, and a
loaded run found the request not yet aborted.

The test now waits, with the file's until(), for the request to be
aborted and the agent woken, giving up at LONGER_THAN_ANY_START_MS (60
s) rather than until's 5 s default. What it asserts is unchanged.

On Linux with Node 24.22.1, at one-minute loads 61-114 sampled when the
runs began and ended, "under load" meaning the test's process on one
CPU shared with 16 busy loops:
- the file passed 2 of 2 without added load (12.8-20.4 s) and 2 of 2
  under load (45.5-77.3 s).
- with the judge timeout no longer aborting the request, the test fails
  after 60 s: "timed out waiting for the judge to be timed out and the
  agent woken".
…ll time

"an error answer's data with many keys that redact alike is numbered in
time that grows with the keys, not with their square" timed redactedData
over 16,384 keys with performance.now() and asserted under 2 s. Wall
time measures the machine as well as the algorithm: a whole suite, at
one-minute loads of 122 at its start and 108 at its end, measured
2,781 ms.

The test now counts the numbering's probes. Each key that redacts to a
taken name is numbered by probing a Set of the names taken, so the test
counts calls to Set.prototype.has during redactedData, at 1,024 keys
and at 4,096, and asserts the larger makes fewer than 8 times the
probes: the loop as written makes 2n-1 (2,047 and 8,191, about 4 times),
and a search starting from 2 again for each key n(n+1)/2 (about 16
times). The count reaches the probes
through the global Set.prototype.has, restored after each call, with no
seam added to src. The test's name now says what it counts: "…is
numbered with name probes that grow with the keys, not with their
square". So it guards this numbering against the quadratic search it
once had, not every way redaction's time could grow: work done outside
Set.prototype.has isn't counted. A numbering rebuilt without a Set makes
fewer probes than keys, and the test fails saying so, to be pinned
again.

On Linux with Node 24.22.1, at one-minute loads 61-114 sampled when the
runs began and ended, "under load" meaning the test's process on one
CPU shared with 16 busy loops:
- the file passed 2 of 2 without added load (6.6-9.7 s) and 2 of 2
  under load (63.4-104.6 s).
- with each name's numbers searched from 2 again, the test fails:
  "524800 name probes for 1,024 keys and 8390656 for 4,096", sixteen
  times the probes for four times the keys.
…lock

"organization acceleration 429 defers one fresh compile, includes
arrivals, and avoids failure breaker" set the real cooldown to 1,000 ms
and had to push and settle a second message inside it before checking
that the message was held. On a loaded machine the cooldown ran out
first, the held request was inferred, and the test failed with "new
arrival is held, not separately inferred". It then slept 1,100 ms to
let the cooldown pass.

The cooldown records when it ends as Date.now() plus its length, and
its release re-arms while that time is still ahead. So the test now
mocks Date alone (node:test's mock.timers) after create(): while the
mocked clock stands still, the cooldown's timer can fire but can't end
it, however slow the machine. The clock moves 1 ms before the second
arrival, since a hold keeps two requests as one when they match,
timestamp included, and 999 ms after it, which ends the cooldown at its
1,000 ms; the test then waits for the deferred call with the file's
waitFor. setTimeout stays
real, since the framework's event loop yields with setTimeout and a
mocked one would stall runUntilIdle. The mock is reset before stop().
The file's provider-wait test already mocks Date the same way. With the
clock exact, the recovery receipt's cooldownMs is now asserted to be
1,000.

waitFor checked a 5 s give-up between drains, shorter than a turn on a
loaded machine; it now checks LONGER_THAN_ANY_START_MS (60 s), naming
what it waited for. What the tests assert is unchanged.

On Linux with Node 24.22.1, at one-minute loads 61-114 sampled when the
runs began and ended, "under load" meaning the test's process on one
CPU shared with 16 busy loops:
- the file passed 2 of 2 without added load (10.0-11.1 s) and 2 of 2
  under load (48.7-51.9 s).
- with the receipt's cooldownMs written as 0, the test fails at the
  receipt.
- with the cooldown's release doing nothing, the test fails after 60 s:
  "the deferred attempt once the cooldown had passed did not happen
  within 60000 ms"; with setTimeout mocked as well as Date, it times out
  before its first assertion (both run before the receipt's assertion
  was added).
…the kill by an event, not the clock

"npm pack or npm install that outlives its limit fails at the limit even
when it handles SIGTERM" sets the script's limit to 10 s for its install
case, and the script runs npm pack under that same limit before the
install the case hangs. The case handed pack to the real npm through a
second node, so on a loaded machine the real pack used up the 10 s: a
whole suite failed with "npm pack didn't finish within 10 s" in place of
the install's message.

In the install case the npm shim now answers pack itself, printing the
one record the script reads (the tarball's file name and its files), so
the real pack no longer runs under the install's limit; the shim's own
start still does, and the install never reads the tarball. The real
pack stays covered by the file's other cases.

Both cases asserted that they took under 40 s by the wall clock. That
was what showed the limit's kill ending an npm that handles SIGTERM:
the "didn't finish within N s" message comes from ETIMEDOUT, which
execFileSync also reports when a child that outlived its kill exits by
itself later. Each hanging npm's own timer now writes an "-outlived"
note, and the test asserts it is absent, so the kill is pinned by an
event rather than by the clock. The timer carries that verdict, so it
sits past the give-up, at NEVER_FIRES_IN_A_TEST_MS (120 s, main's was
60 s): a correct 5 or 10 s kill can't lose a race with it however slow
the machine.

On Linux with Node 24.22.1, "under load" meaning the test's process on
one CPU shared with 16 busy loops:
- before this change, under load, the test failed 3 of 3 with "npm pack
  didn't finish within 10 s" (one-minute loads 55-95 sampled).
- with the shim's answer, before the "-outlived" notes, it passed 2 of 2
  without added load and 3 of 3 under load, each time with the hanging
  install's note, so the script reached the install with the shim's
  record; the whole file passed, 27 of 27.
- as committed, it passed 2 of 2 without added load (16.1 s each, at
  one-minute loads 148-161 from other work). With the install's kill
  sent as SIGTERM, it fails after 121.6 s: "npm install, ignoring
  SIGTERM, outlived the limit"; with npm pack's kill sent as SIGTERM,
  after 130.6 s: "npm pack, ignoring SIGTERM, outlived the limit".
The file's until() gave up after 10 s and said only "in time: <what>".
The file's waits are for child processes and sockets, and for registry
state around a launch: a reconnect launch starting, a permanent
reconnect failure, a dial reaching its listener. On a loaded machine
three tests failed there without anything being wrong: "a reconnect
launch that cannot be reaped halts reconnecting…" ("in time: a
permanent reconnect failure") and the two tests that wait for "the
reconnect launch has started".

until() now gives up at LONGER_THAN_ANY_START_MS (60 s), naming what it
waited for and the bound. What the tests assert is unchanged.

On Linux with Node 24.22.1, without added load (one-minute loads 8-21
sampled when the runs began and ended):
- the file passed 2 of 2, 19 of 19 tests (48.2-49.0 s).
- with no reconnect ever scheduled, "a reconnect launch that cannot be
  reaped…" fails after 60 s: "a permanent reconnect failure, within
  60000 ms".
The file's 27 cases ran one after another, about 69 runs of
scripts/pack-installs-alone.mjs between them, each an npm pack, a linked
npm install and a child process per entry point. Alone it used about a
third of a core over its wall, waiting on its children, and in a whole
suite it ended last: started late in name order, it ran alone for its
last 75-119 s on a 12-core Linux machine.

The cases now run inside one describe with { concurrency: 4 }. Top-level
tests in a file run one at a time whatever --test-concurrency says (that
option spreads files), so a describe is what lets them overlap. Each
case already works in its own mkdtemp directory and gives the script its
own environment; the two shims made at the top (NO_BUN, BUN_BY_NODE) are
only read. The one case that changes process.env (PATH, in the
npmCommand case) changes and restores it without an await between, so no
other case can run meanwhile.

Measured alone on Linux, Node 24.22.1, at a one-minute load of 11-30:
the file as it was took 125.5 and 139.1 s; the same cases four at a time
took 53.5 and 38.6 s, with every case passing, about what four separate
processes of a quarter of the cases each took (50.7 and 38.5 s). One
file at concurrency 4 holds one of CI's test-file slots, where four
files would hold up to four.

Four at a time, a case's wall-clock bound also counts its siblings'
work, so the load-limit case's five checks now time each kill from the
hang's own start, the mtime of the note it writes once its SIGTERM
handler is in, rather than from check()'s start. That leaves out the
script's real npm pack and npm install, which run under their own 120 s
limit before the load and are what grows under load (as an npm pack did
past a 10 s limit in a loaded whole suite). The bound stays 40 s: a
script that only sends SIGTERM leaves each hang to its own 60 s timer,
so it fails the bound however loaded the machine is. A sibling can still
hold the thread with a synchronous call, packInto()'s real npm pack or
the tests' own npm pack at its 5 s limit (5.0-5.1 s): that delays when a
check reads its bound, by as long as the call takes, but not when its
hang was killed.

The cases are re-indented into the describe, except the lines inside the
bin load hook case's template literal, which is the spy preload's text;
git diff -w shows the node:fs and node:test imports, the describe's two
lines, and the five checks' bounds with their helper.
…he package installs and imports everywhere else

Build & Test ran the whole suite on up to five legs: ubuntu at Node
22.12.0, 24 and the newest Node, and macOS at 22.12.0 and the newest
Node on a push to main, or at 22.12.0 alone on a pull request (macOS at
24 was always left out). It now runs once, on ubuntu at 22.12.0, the
floor that engines.node declares, with the steps it had: the typecheck,
the build, npm test with AF_TEST_CONCURRENCY, the unforced files and the
check that no Node diagnostic report was written.

The other legs become the install-and-start job: macOS at 22.12.0, 24
and the newest Node, and ubuntu at 24 and the newest Node. Each installs
the repository with npm install (the repository keeps no lockfile),
builds, and runs scripts/pack-installs-alone.mjs, the check pack-check
and the publish workflow already run, rather than a second check inline.
It packs the package as npm publish packs it, installs the tarball alone
into an empty directory with its dependencies from the registry, as a
consumer's install resolves them, and loads the package's entry point.
For the two bins, agent-framework-mcp and agent-framework-recover, it
checks each #! line and that each one's static imports load; it runs
neither, since running agent-framework-mcp starts the MCP server. These
legs set up no Bun; where the runner has none, the check skips the bun
target's load and says so in its summary ("bun targets present, not
loaded (bun doesn't run here: ...)"). The job's 40-minute limit is
pack-check's, above the check's own limits (10 minutes each for npm pack
and npm install, a minute for each load), so the check names what hung.

pack-check (the packed release on its own, under Node 22.12.0 and 24)
and code-execution-bun (code_execution's files under Bun 1.4.0 and
1.4.3, on ubuntu, and on macOS on a push to main or when a pull request
touches what they run) are as they were.

What no longer runs:
- the suite on macOS. It ran there because "process/stdio behaviour
  differs (pipes are asynchronous on macOS), and ubuntu-only runs have
  missed that", as the Test step's comment said: c2fa58a made the macOS
  legs run the suite, by removing the Test step's ubuntu-only condition,
  when a refusal's stderr write was lost to process.exit on macOS's
  asynchronous pipes. code_execution's files still run on macOS under
  Bun, on a push to main and on a pull request that touches them.
- the suite at Node 24 and at the newest Node, on any platform. What
  differs by Node version, such as whether --test-timeout bounds a whole
  file (22.12) or each test (24), and stdio flushing around process
  exit, is now tested at 22.12.0 alone. pack-check still runs its check
  under 24 as well as 22.12.0, for the load hook's two branches.

macOS runners: a pull request takes Install & start's macOS leg at
22.12.0 alone, as Build & Test's was before, in place of the whole suite
there, which took 5 to 10 minutes in three of main's recent runs; one
that touches what code_execution's tests run also takes
code-execution-bun's two macOS legs, as before. A push to main takes
Install & start on macOS at all three Node versions. The changes job
decides both, as it decided Build & Test's macOS legs: its build-exclude
output became start-exclude.

Check contexts change with the jobs: Build & Test keeps its
(ubuntu-latest, node 22.12.0) context and loses the others, and Install
& start's come. As CONTRIBUTING says, a maintainer checks that the
repository's required status checks match. CONTRIBUTING's paragraph on
push-time CI says what runs where, and ci-workflow-pins.test.ts's doc no
longer speaks of a macOS runner running the suite.
…ial policy Request

"connectMcplServer hot-connects a network-only (WebSocket) MCPL server"
ran an in-process fixture that advertised MCPL 0.4 and answered only
initialize and tools/list. connectMcplServer awaits
settlePolicyExchange, so the unanswered featureSets/update waited out
its 15 s timeout: the test took 15.1 s on CI's ubuntu 22.12.0 leg (run
40751371209).

The fixture now advertises 0.5 and answers featureSets/update with
{ accepted: true }, as the suite's other MCPL fixtures do. What the test
checks, the connection, its url, the echo tool listed and gone after the
disconnect, doesn't depend on the grant.
…race

"a quiesce abandon whose cancel throws lists the turn as unabandonable,
takes its mark back, and resolves" holds its turn in its first tool
until after the quiesce returns, so the abandon waited out its 10 s
grace: 11.1 s of the describe's 12.8 s on CI's ubuntu 22.12.0 leg (run
40751371209). It sets setAbandonGraceForTests(framework, 50) before the
quiesce, as the three tests in stop-without-stream and host-quiesce do;
the turn it holds is what the setter's doc asks for.
…leeping 1.15 s

Eight tests armed a self-wake, which armSelfWake clamps to at least 1 s,
and slept 1150 or 1200 ms to see it fire or not: 9.3 s of the file on
CI's ubuntu 22.12.0 leg (run 40751371209), and a verdict that a late
timer could flip. They run an EventGate alone, with no framework, and
the self-wake is a plain setTimeout whose callback is synchronous
(event-gate.ts, armSelfWake). Each now enables t.mock.timers for
setTimeout only and ticks 1150 or 1200 ms: the timer fires, or doesn't,
inside the tick, and Date stays real, so the notice's timestamp is the
clock's. The unused sleep helper goes.
Seven tests make the first stream end in a transient error and check the
routing across the retry. Each waited the default first retry delay,
membrane 0.8.0's retryDelayMs of 1000 under DefaultErrorPolicy: about 1
s each, of the file's 11.5 s on CI's ubuntu 22.12.0 leg (run
40751371209). None uses the delay as a window: the ' !' and
late-delivery tests hold a post until they release it, and wait on the
retry's start or its finished response; the three across-a-retry cases
check what was routed once runUntilIdle() has returned.

They now pass AgentFramework.create an errorPolicy, the public option,
that retries once with no delay: "an error retry keeps a ' !' whose
envelope is delivered after the retry starts" and "a retried turn's text
waits for the replaced stream's late deliveries", each in both modes,
and the three "across an error retry" cases (continueTurn's retry path;
its capacity path keeps the default). createFramework takes the policy
as a third argument.

"hybrid: a capacity-held turn's later compile receipts the drafts its
first stream held" is unchanged: its 1 s is the provider acceleration
cooldown, which the test waits out by polling, not an error retry.
fingerprintVouches decides whether the workspace may trust a file's
recorded hash without reading the file again. At e85c57d, the whole
suite passed with any one of these taken out of it: size, mtime, inode
or device left out of the comparison; ctime left out of the window's
newest stamp; the 2,000 ms window moved to 1,000 ms or to 4,000 ms; or
its edge made inclusive. ctime left out of the comparison alone is
caught by the existing "any field differing" assertion. The code and
tests this describes are unchanged between e85c57d and the commit
this one is based on. Reading the rule since found three more terms
that, by reading, no existing test pins: mtime in the window's newest
stamp, mtime's comparison to the nanosecond, and the window's
rounding of nanoseconds down to whole milliseconds.

A real change to size, mtime, inode or device moves ctime too, so tests
through the file system can't tell those terms apart. The new file
calls fingerprintVouches directly and pins each term as a requirement
on what it answers, on a file system whose ctime it trusts:
- it vouches when none of size, mtime, ctime, inode and device has
  changed and the stamps are more than 2,000 ms older than the hash;
- it doesn't once any one of those five alone has changed, by as
  little as a nanosecond for a stamp;
- it doesn't for stamps exactly 2,000 ms before the hash, and does for
  stamps 2,000 ms and 1 ns before it;
- it doesn't for a ctime inside the window when mtime is older, or for
  an mtime inside the window when ctime is older.
Three-way's "a recorded fingerprint vouches only for an unchanged,
non-racy file on a ctime-keeping filesystem" held five assertions, and
workspace-fingerprint.test.ts now pins three of them: it vouches for an
unchanged fingerprint past the window, not for one inside it, and not
once a field differs. Its other two move into the new file as tests of
their own: it never vouches on a file system whose ctime the workspace
doesn't trust, and not when no fingerprint was recorded. The old test
goes, so a later change to the fingerprint meets one test of each
requirement, in the file a reader would look in.
@nissa-seru nissa-seru added the no-changelog Exempts this PR from the changelog check (internal-only change) label Oct 28, 2026
Alfred-2451 added 2 commits October 29, 2026 01:21
main 0f11975 brings anima-research#864, anima-research#876, anima-research#881 and the other merges since
62a126c. One file conflicted, test/workspace-three-way.test.ts:
- its observe.js import takes both sides: this branch's RACY_WINDOW_MS
  and anima-research#864's ROOT_ON_ANOTHER_DEVICE, ROOT_REPLACED,
  filesystemKeepsBirthTime and fsTypeKeepsBirthTime;
- anima-research#864's new test, "a filesystem keeps a birth time fixed only where it
  is listed, and only on Linux", goes in before "no restored timestamp
  hides a change", without the comment above that test, which this
  branch removed.
…s its SIGTERM and SIGKILL waits do

main's endOnPipeError (from anima-research#876) gives a child whose pipe failed
STDIO_EXIT_GRACE_MS to exit on its own before ending it, reading the
constant directly. This branch's escalate() reads the child's test grace
(setStdioExitGraceForTests) for the waits after SIGTERM and SIGKILL. So a
child given a test's grace still waited 2 s after a pipe error.

StdioTransport.exitGrace() is now the one lookup, the child's test grace
or STDIO_EXIT_GRACE_MS, and the pipe-failure wait and escalate() both
call it. Production's grace is unchanged: no child has a test grace
outside the tests.

test/mcpl-transport-close.test.ts: "a child given a test's exit grace
waits only that grace after a pipe failure, as after SIGTERM and
SIGKILL" gives a child that can exit by no means (its kill() a no-op)
a 50 ms grace, mocks setTimeout, and fails its stdin. It ticks the
clock: nothing is sent at 49 ms, SIGTERM at 50, SIGKILL 50 ms later,
and 'unreaped' names 50ms after 50 more. With the pipe-failure wait
reading STDIO_EXIT_GRACE_MS again, no SIGTERM comes at 50 ms and it
fails. "a failed write to a child that ignores SIGTERM ends it with
SIGKILL 2 s later" keeps the real 2 s, with no test grace.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Exempts this PR from the changelog check (internal-only change)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant