Files
stack/agents/darkwing/work/queue-106/r1/build.md
T
jason.woltjeandClaude Opus 5.5 7109e39e00 work: Darkwing row 106 round 1 packet (#1600)
Bus host lock follow-ups: release on a fork or send throw, retry a lock
that vanishes before the read, the have order test fixed to directory
order, the third-racer README sentence and the reviewer wrap. Gate passed
on 0069949ff; 9 mutant runs as expected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:50:20 -05:00

9.2 KiB

Row 106 (#1600) round 1: bus host lock follow-ups

Brief: docs/plans/2026-10-13_landing-follow-ups.md, section "Bus host lock: release on a late throw, a vanishing lock, the third racer" (last changed in aedc5db8f; blob 001cba498 at f00613106). BASE: 0069949ffc. That is row 104's landed commit. I built on f00613106 and moved the candidate onto BASE before the gate, as Sage asked. The only commit between the two is row 104's, which touches none of the 5 manifest paths, so the diff is byte-identical. The gate and the mutants ran on BASE. Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 5 files, and applying the patch to a fresh worktree at BASE reproduces every digest in it.

Per Sage, the third-racer sentence is in packages/cli/README.md, and packages/seat/README.md is untouched.

What changed

  • packages/cli/src/host.mjs, startHost (Sage N1). The broker's fork, its boot send and the wait for its reply now all run in one try. Its failure path kills the broker if the fork returned one, waits for it to end, and calls release(). Before, the fork and the send ran outside the try, so a throw from either left host.lock behind until a later start took it over as stale. The wait now starts after the send rather than before it. A send that throws therefore leaves no pending wait whose rejection nobody handles. The reply and the exit event come on a later tick, so the wait still misses neither. The notifier's fork and send are unchanged; the brief covers only the time before the broker's reply.
  • packages/cli/src/host.mjs, readState (Dewey a). An ENOENT from the read returns null, which claimHostLock already treats as "try again" within CLAIM_TRIES. Any other read or parse failure still refuses with bus host state is unreadable: <file>. A new found hook runs between the existence check and the read, for the tests only. claimHostLock takes found next to between and passes it on. readHostState and release() read through readState too: a host.json that goes between the check and the read now counts as no host, and release() already ignored a lock it couldn't read.
  • packages/harness/tests/git-tools.test.mjs, the have order test (Dewey b). It makes the two clone directories, reads their order with readdirSync, and gives the first-listed clone the older refs. It checks that the order holds once the clones exist, then expects the first-listed clone's 20 refs and then 12 of the other's. The old test took the order from the request, so it passed whatever clone order the code used.
  • packages/cli/README.md, step 3 of the host start (Dewey c). One new sentence: of three starts racing one stale lock, two can both claim it, and then the broker's writer.lock refuses the second broker.
  • roles/reviewer.md (Dewey d). The nothing-to-fetch line wraps after "has the commit;" like its neighbours. The words are the same.

Tests

File Base Candidate New
packages/cli/tests/host.test.mjs 28 31 a broker fork that throws leaves no host.lock and no host.json, a broker send that throws ... (one loop), a host.lock that goes between the check and the read is tried again; one that can't be parsed still refuses as unreadable
packages/harness/tests/git-tools.test.mjs 4 4 the have order test, rewritten

The fork and send tests use a test helper, throwOn. It hooks the send property that node installs on each new child, which spySends and failSends in the same file already do. Throwing from that install makes fork itself throw, after the child has started; throwing from the installed send makes the boot send throw. The helper kills every child it saw after the test, the one the throwing fork started included.

The ENOENT test writes a stale lock, and the found hook removes it the first time it runs. The claim then succeeds on the next try, and the hook ran once. The same test then writes {not json as the lock and expects the unreadable refusal, with the file left as it was.

Mutants

mutants.py and gate.sh are the scripts I ran, copied here; they point at my scratch worktree, so change WT/G to rerun them. BASE_TEST in mutants.py reads the test file from the worktree's HEAD, which must be BASE. mutants.py runs each mutant against the named tests only, through --test-name-pattern, and counts a pattern that matches no test as a fault. Each file is restored from its .orig, and the candidate's diff hashed 60246b4202f67281 (first 16 hex) before and after. Run 22:49:00 to 22:49:03Z on 2026-10-13.

Id Mutant Test Result
n01 the fork runs before the try fork throws killed
n02 the send runs between two trys send throws killed
n03 fork and send both before the try, as at BASE fork throws, send throws killed
n04 no ENOENT case in readState lock goes between check and read killed
n05 every read failure counts as no lock lock goes between check and read killed
n06 have takes the clones in reverse directory order have order killed
n07 have sorts the working refs by date across clones have order killed
x01 control: BASE's have test against n06 have order survived, as expected
x02 control: BASE's have test against n07 have order survived, as expected

Every run matched exactly 1 test, and every kill is an assertion failure. In n01 to n03, host.lock is still there after the throw. n04 refuses with bus host state is unreadable on the lock that went away. n05 is the check that an unparseable lock still refuses: with every read failure as null, the {not json claim loops to "kept changing" instead.

The controls are expected to survive. They correct part of the brief's finding b. The brief says the old test tells per-clone order from a cross-clone sort only when alpha lists first. On this ext4, alpha lists first, and the old test still passes both n06 and n07. It reads which clone came first from req.have[0] and builds its expectation from that, so any order of whole clones passes. The new test fixes the order before the request.

Gate

gate.sh, on BASE 0069949ff, 22:43:03 to 22:48:53Z on 2026-10-13, DOCKER_HOST unset, Docker 29.7.2.

Step Result Time (UTC)
pkg-bus 128 passed, 0 failed 22:43:03-22:43:06
pkg-business 67 passed, 0 failed 22:43:06-22:43:09
pkg-cli 168 passed, 0 failed 22:43:09-22:43:33
pkg-control-board 124 passed, 0 failed 22:43:33-22:43:34
pkg-conversation 182 passed, 0 failed 22:43:34-22:44:24
pkg-discord 178 passed, 0 failed 22:44:24-22:44:27
pkg-harness 73 passed, 0 failed 22:44:27-22:44:37
pkg-ledger 78 passed, 0 failed 22:44:37-22:44:58
pkg-mosaic 69 passed, 0 failed 22:44:58-22:45:00
pkg-queue 156 passed, 0 failed 22:45:00-22:45:34
pkg-runs 43 passed, 0 failed 22:45:34-22:45:35
pkg-seat 119 passed, 0 failed 22:45:35-22:45:40
pkg-tasks 92 passed, 0 failed 22:45:40-22:45:45
pkg-webui 108 passed, 0 failed 22:45:45-22:46:41
script-auth 15 passed, 0 failed 22:46:41-22:46:42
script-conductor 17 passed, 0 failed 22:46:42-22:46:47
script-config 24 passed, 0 failed 22:46:47-22:46:49
script-discord 66 passed, 0 failed 22:46:49-22:46:56
script-extension-package 18 passed, 0 failed 22:46:56-22:46:57
script-foundation 44 passed, 0 failed 22:46:57-22:47:38
script-queue 28 passed, 0 failed (node 156 passed) 22:47:38-22:48:12
script-release 14 passed, 0 failed 22:48:12-22:48:24
script-task 98 passed, 0 failed 22:48:24-22:48:53

The gate printed GATE_PASSED at 22:48:53Z. The Docker steps left a wt_default network, created at 22:48:12Z with 0 containers; I removed it.

Choices and limits

  • The wait for the boot reply now starts after the send. The other order would need a .catch on a wait that a throwing send abandons. Node emits the child's message and exit events from the event loop, and the wait attaches its listeners in the same synchronous run as the send, so nothing comes in between.
  • A fork that throws after node has started the child returns no handle, so startHost can't kill that child. The test kills it. I don't know of a real path where node's fork throws after the spawn; this is what the hook produces.
  • An asynchronous send failure, an error event on the broker child, still has no listener in startHost and would end the process before release(). That was so at BASE, and the brief names only throws. A bounded follow-up if wanted.
  • The README sentence goes in step 3, after the takeover it qualifies. The existing sentence after it is unchanged.

Going live (decision 109)

The host change is live only with a new copy, a re-pin and a bus restart, all under Mos's go. Until then the live bus, Console and CLI run host.mjs from the pinned copy at 465bbdfbb. The tests and the README change nothing that runs. The launcher reads a role's contract at each launch, from MOSAIC_ROLES_DIR or else the roles/ of the copy the bus runs from (packages/cli/src/config.mjs, rolesDir). So the reviewer wrap reaches a reviewer session only once that copy has it, and it changes the line breaks in the prompt only.