Skip to content

fix: unblock the fork trunk's portable serial 2 check - #64

Merged
sbracewell64 merged 4 commits into
mainfrom
fm/fork-trunk-serial2-fork-landing
Aug 9, 2026
Merged

fix: unblock the fork trunk's portable serial 2 check#64
sbracewell64 merged 4 commits into
mainfrom
fm/fork-trunk-serial2-fork-landing

Conversation

@sbracewell64

Copy link
Copy Markdown
Owner

What this fixes

The fork trunk's own Behavior portable serial 2 check is red at ed376cf, so every open fork PR inherits a red required check. Two independent defects were involved; the first one hid the second.

1. A blocked-worker wait bounded by a poll count instead of a duration

tests/fm-remote-secondmate-lifecycle-e2e.test.sh waits for a deliberately blocked worker to reach its barrier. The config-push inheritance barrier allowed 250 poll iterations of sleep 0.02. On the CI runner those iterations elapse in 5.3 seconds, while the transaction needs longer to traverse its SSH-boundary jobs - the sibling spawn barrier in the same CI job took about 16 seconds.

This is a dropped upstream fix rather than a code fault. Upstream raised exactly this bound to 1500 in upstream kunchenguid#1727 (30b18b9, 2026-08-04); the fork reconciliation ad53e97 (2026-08-05) landed 250 at that one site while keeping 1500 at its sibling.

Disconfirming evidence against a code-side cause: with an enlarged bound the transaction does reach its blocked write - measured at 297-341 iterations (19-26 seconds) across three loaded runs - and every serialization assertion after it passes.

Rather than restore the literal 1500, this converts the suite's blocked-worker waits to wall-clock bounds through a new shared helper, fm_test_wait_file in tests/lib.sh. A poll count is not a duration: it shrinks precisely when the work it waits for is slowest, so a restored count would leave the same defect one loaded runner away. The helper bounds its wait the way bin/fm-remote-job-lib.sh already bounds its own polls, and it distinguishes a producer that died from a bound that expired. The suite uses 90 seconds for a remote transaction and 30 for a local marker - hang tripwires with margin over the measured 26-second worst case, not expected durations, since a healthy wait ends the moment its marker appears.

2. A retirement that rebuilt the home it had just removed

Once the wait was fixed the suite reached its final case and failed there: a remote second-mate retirement completed, reported success, and left the retired home behind as a stray tree containing data/.parent-route/wake-ledger.tsv.

A remote second mate is retired by a host-local teardown whose DATA is a private directory inside the home being removed (bin/fm-remote-secondmate-control.sh). The terminal wake-ledger line is written after that removal, and bin/fm-wake-ledger.sh creates its ledger's directory - so the telemetry write rebuilt the tree the retirement had just deleted. Confirmed by probing the ledger writer directly against a non-existent destination, and isolated as fork-specific by running the upstream trunk on the same machine, where the same suite passes (bin/fm-wake-ledger.sh does not exist upstream).

The write now skips only a destination that lies inside a home this teardown just removed. That question is answered before the removal, while both paths still resolve, and carried as a verdict; both paths resolve through their nearest existing ancestor, so a destination that does not exist yet still compares correctly and two spellings of the same home still compare equal. The write keeps its position in the script, so a teardown that refuses after the removal still records no terminal line and a retry cannot append a second one.

An earlier version of this guard inferred the same answer afterwards from a vanished directory. That over-reached - any second-mate retirement whose ledger directory did not exist yet lost its terminal line, silently, because the skip bypassed the wake ledger terminal line not recorded warning too. Review caught it; the third test case below pins it.

Evidence

  • bin/fm-test-run.sh --lane portable-serial-2of4 at this head: 25 tests, 0 failed. The previously red script ran for real (gate_skip=false) and passed.
  • tests/fm-teardown.test.sh: 71 cases pass, including the three new retirement cases.
  • bin/fm-lint.sh clean, bin/fm-test-run.sh --check-coverage ok, bin/fm-doc-audience-check.sh ok.
  • Every new guarantee was witnessed failing under a matching defect before being accepted: a fixed poll count fails the wall-clock case; a wait that ignores a dead producer fails the exit case; a wait that refuses to poll fails the completion case; removing the teardown guard rebuilds the home; applying that guard too broadly drops a terminal line for a ledger outside the home; and restoring the vanished-directory inference drops the line for a ledger directory that does not exist yet.

Two hypotheses raised at intake are disconfirmed rather than assumed away: the pinned treehouse version cannot matter here, because treehouse is installed only in the Herdr CI job and never in the portable serial shard, and this suite never invokes it; and the serial lane is no longer near its timeout, being four sharded jobs of about 9.5 minutes against a 15-minute cap.

Disclosures

Check state. The validation pipeline reported checks-passed, but no CI examined that head: zero check runs, and both upstream check suites sat at action_required awaiting maintainer approval. A head nothing examined is unverified rather than green, so the evidence above is local. This fork PR is the first head that the fork's own workflows will actually judge - including the serial-2 fix it carries.

Why this branch is separate. The pipeline run for this work pushed to fm/fork-trunk-serial2-base-red and opened kunchenguid#2009 against upstream. At push time it rebased onto upstream main, and because bin/fm-wake-ledger.sh does not exist upstream, the second-mate cleanup fix, its regression tests, and the review correction were silently dropped from the published head - verified: that head contains no wake-ledger lines in bin/fm-teardown.sh. That upstream PR and branch are deliberately left untouched; it stands as a partial contribution of the wall-clock change, which is the part that applies upstream. This branch is based on the fork trunk ed376cf, carries the complete fix, and bundles no upstream history.

Local test environment. Two scripts in serial shard 1 fail on the machine used for this work - tests/fm-tmux-agent-liveness.test.sh and tests/fm-turnend-guard.test.sh. They are unrelated to this change and reproduce identically at the untouched base ed376cf; that lane passed in CI on the same base.

Follow-ups, deliberately not in this change

  • The same poll-count wait pattern survives in tests/fm-remote-backlog-handoff.test.sh at lines 139, 234, and 309, on the same kind of remote transactions, now that tests/lib.sh owns a wall-clock helper.
  • The dated shard-balance evidence in docs/fm-test-portable-shards.md has drifted and needs refreshing from a green run's per-shard timing artifacts, which can only exist after this lands.

The remote secondmate lifecycle suite failed on the fork trunk itself, so every
open fork PR inherited a red required check. The config-push inheritance barrier
waited 250 poll iterations for its deliberately blocked write; on the CI runner
those iterations elapse in 5.3 seconds, while the transaction needs longer than
that to traverse its SSH-boundary jobs - the sibling spawn barrier in the same
job took about 16 seconds. Upstream raised exactly this bound to 1500 in kunchenguid#1727
on 2026-08-04; the fork reconciliation a day later landed 250 at that one site
while keeping 1500 at its sibling.

A poll count is not a duration. It shrinks precisely when the work it waits for
is slowest, so restoring a larger count would leave the same defect one loaded
runner away. tests/lib.sh now owns fm_test_wait_file, which bounds the wait by
wall clock the way bin/fm-remote-job-lib.sh already bounds its own polls, and
distinguishes a producer that died from a bound that expired. Every blocked
worker wait in the suite uses it: 90 seconds for a remote transaction, 30 for a
local marker, both hang tripwires with margin over a measured 26-second worst
case rather than expected durations - a healthy wait ends when its marker
appears and costs nothing extra.

Measured on the base: the transaction reaches its blocked write after 297-319
iterations (23-26 seconds) on a loaded runner, and every serialization assertion
after it passes, which is the disconfirming evidence against a code-side defect.

tests/fm-test-lib-wait.test.sh pins the helper's contract. Each of its three
guarantees was witnessed red under a matching defect: a fixed poll count fails
the wall-clock case, a wait that ignores a dead producer fails the exit case,
and a wait that refuses to poll fails the completion case.
…n teardown

With the wait bound fixed, the same suite reached its final case and failed
there: a remote secondmate retirement completed, reported success, and left the
retired home behind as a stray tree containing data/.parent-route/wake-ledger.tsv.

A remote secondmate is retired by a host-local teardown whose DATA is a private
directory INSIDE the home being removed (bin/fm-remote-secondmate-control.sh).
The terminal wake-ledger line is written after that removal, and
bin/fm-wake-ledger.sh creates its ledger's directory, so the telemetry write
rebuilt the tree the retirement had just deleted. The line was unreachable
evidence there in any case: it lived only inside the deleted home, and the
parent home never reaches its own ledger write for a remote retirement.

The write now skips a destination inside a home this teardown just removed, by
path and by the vanished directory, so neither spelling of the path resurrects
it. Its position is unchanged, so a teardown that refuses after the removal
still records no terminal line.

Both directions are covered and were witnessed red: without the guard the
retirement rebuilds the home, and with the guard applied too broadly a
retirement whose ledger lives outside that home loses its terminal line.
The first guard asked whether the ledger belonged to the removed home partly by
testing whether its directory had vanished. That over-reached: any secondmate
retirement whose ledger directory did not exist yet took the skip path, so its
terminal line was dropped - and silently, because the skip bypassed the
"wake ledger terminal line not recorded" warning as well. Only the ledger that
lives inside the removed home should ever be skipped.

The question is now answered before the removal, while both paths still resolve,
and the answer is carried as a verdict. Both paths resolve through their nearest
existing ancestor, so a destination that does not exist yet still compares
correctly and two spellings of the same home still compare equal. Every other
destination keeps its previous behavior, including creating a directory that is
not there yet and warning when the write fails.

The new case pins exactly the regression: a retirement whose ledger lives
outside the removed home in a directory that does not exist yet must still
create it and record the line. It was witnessed red against the previous guard
while the other two cases stayed green.
…mp root

This suite's EXIT trap signalled the remote job worker and removed the temp root
in the same breath. Signalling is not stopping: the worker can still be writing
job state under that root while the removal walks it, and `rm -rf` then fails
with "Directory not empty". Because that removal is the trap's last command, its
status becomes the script's, so a run whose every assertion passed still reports
failure - which is exactly how it presented, printing ALL TESTS PASSED and then
exiting 1.

The race is pre-existing on the trunk rather than new: the CI run for base
ed376cf logged the identical "Directory not empty" failure twice for the sibling
remote fixture, where it happened to be harmless because that suite's trap does
not end on the removal. What changed is only exposure - this script moved
between serial shards, and so onto a different runner.

The trap now waits for the worker to actually die before removing, the same
bounded wait tests/fm-remote-secondmate-lifecycle-e2e.test.sh already uses.

Witnessed red through a control that models the real condition, a worker that
keeps writing and does not die the instant it is signalled: removing immediately
after the signal failed 5 of 5 times with the identical message, and waiting for
the worker to die first failed 0 of 5.
@sbracewell64
sbracewell64 merged commit c1141a5 into main Aug 9, 2026
13 of 14 checks passed
sbracewell64 added a commit that referenced this pull request Aug 9, 2026
* fix(tests): bound the blocked-worker waits by wall clock, not poll count

The remote secondmate lifecycle suite failed on the fork trunk itself, so every
open fork PR inherited a red required check. The config-push inheritance barrier
waited 250 poll iterations for its deliberately blocked write; on the CI runner
those iterations elapse in 5.3 seconds, while the transaction needs longer than
that to traverse its SSH-boundary jobs - the sibling spawn barrier in the same
job took about 16 seconds. Upstream raised exactly this bound to 1500 in kunchenguid#1727
on 2026-08-04; the fork reconciliation a day later landed 250 at that one site
while keeping 1500 at its sibling.

A poll count is not a duration. It shrinks precisely when the work it waits for
is slowest, so restoring a larger count would leave the same defect one loaded
runner away. tests/lib.sh now owns fm_test_wait_file, which bounds the wait by
wall clock the way bin/fm-remote-job-lib.sh already bounds its own polls, and
distinguishes a producer that died from a bound that expired. Every blocked
worker wait in the suite uses it: 90 seconds for a remote transaction, 30 for a
local marker, both hang tripwires with margin over a measured 26-second worst
case rather than expected durations - a healthy wait ends when its marker
appears and costs nothing extra.

Measured on the base: the transaction reaches its blocked write after 297-319
iterations (23-26 seconds) on a loaded runner, and every serialization assertion
after it passes, which is the disconfirming evidence against a code-side defect.

tests/fm-test-lib-wait.test.sh pins the helper's contract. Each of its three
guarantees was witnessed red under a matching defect: a fixed poll count fails
the wall-clock case, a wait that ignores a dead producer fails the exit case,
and a wait that refuses to poll fails the completion case.

* fix(bin): stop a retired secondmate home from being rebuilt by its own teardown

With the wait bound fixed, the same suite reached its final case and failed
there: a remote secondmate retirement completed, reported success, and left the
retired home behind as a stray tree containing data/.parent-route/wake-ledger.tsv.

A remote secondmate is retired by a host-local teardown whose DATA is a private
directory INSIDE the home being removed (bin/fm-remote-secondmate-control.sh).
The terminal wake-ledger line is written after that removal, and
bin/fm-wake-ledger.sh creates its ledger's directory, so the telemetry write
rebuilt the tree the retirement had just deleted. The line was unreachable
evidence there in any case: it lived only inside the deleted home, and the
parent home never reaches its own ledger write for a remote retirement.

The write now skips a destination inside a home this teardown just removed, by
path and by the vanished directory, so neither spelling of the path resurrects
it. Its position is unchanged, so a teardown that refuses after the removal
still records no terminal line.

Both directions are covered and were witnessed red: without the guard the
retirement rebuilds the home, and with the guard applied too broadly a
retirement whose ledger lives outside that home loses its terminal line.

* fix(bin): decide ledger containment before the home is removed

The first guard asked whether the ledger belonged to the removed home partly by
testing whether its directory had vanished. That over-reached: any secondmate
retirement whose ledger directory did not exist yet took the skip path, so its
terminal line was dropped - and silently, because the skip bypassed the
"wake ledger terminal line not recorded" warning as well. Only the ledger that
lives inside the removed home should ever be skipped.

The question is now answered before the removal, while both paths still resolve,
and the answer is carried as a verdict. Both paths resolve through their nearest
existing ancestor, so a destination that does not exist yet still compares
correctly and two spellings of the same home still compare equal. Every other
destination keeps its previous behavior, including creating a directory that is
not there yet and warning when the write fails.

The new case pins exactly the regression: a retirement whose ledger lives
outside the removed home in a directory that does not exist yet must still
create it and record the line. It was witnessed red against the previous guard
while the other two cases stayed green.

* fix(tests): let the handoff suite's worker die before removing its temp root

This suite's EXIT trap signalled the remote job worker and removed the temp root
in the same breath. Signalling is not stopping: the worker can still be writing
job state under that root while the removal walks it, and `rm -rf` then fails
with "Directory not empty". Because that removal is the trap's last command, its
status becomes the script's, so a run whose every assertion passed still reports
failure - which is exactly how it presented, printing ALL TESTS PASSED and then
exiting 1.

The race is pre-existing on the trunk rather than new: the CI run for base
ed376cf logged the identical "Directory not empty" failure twice for the sibling
remote fixture, where it happened to be harmless because that suite's trap does
not end on the removal. What changed is only exposure - this script moved
between serial shards, and so onto a different runner.

The trap now waits for the worker to actually die before removing, the same
bounded wait tests/fm-remote-secondmate-lifecycle-e2e.test.sh already uses.

Witnessed red through a control that models the real condition, a worker that
keeps writing and does not die the instant it is signalled: removing immediately
after the signal failed 5 of 5 times with the identical message, and waiting for
the worker to die first failed 0 of 5.
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