25 KiB
Row 156 build: the host starts the landing run (#1651)
Brief docs/plans/2026-10-18_landing-run-launch.md (blob aeddbc4322), design DESIGN.md in this
packet (approved sage-0808). Built on de74dd473 in a scratch worktree. Reviewer: Filbert.
Status at 2026-10-18T22:20Z: the code, tests and docs are written and rebased onto d88613bac.
Run 1 of the singles was red, with 3 failures in this row's own new tests, which are now fixed.
Run 2 was green on the tests, but mutant P02 survived. The fix is in process.mjs (Sage took it in
sage-0933), with a land.test process test that names the op. Run 3 is green: 22 singles
248/248, and P01, P02 and RU01 killed. The candidate is candidate-manifest.sha256 (afea1df3) and
build.patch (see "Runs").
Files (14)
| File | Change |
|---|---|
| packages/bus/src/broker.mjs | landHolders(business), landRelease(record), the #held generator shared with landHeld, and the decision-id turn in #requestUsage (sage-0808). |
| packages/bus/src/process.mjs, runtime.mjs | Launch ops landHolders and landRelease. In process.mjs, launchOp's last line is now an explicit credentialStatus branch and a launch-op-refused throw (run 2, mutant P02). |
| packages/cli/src/launcher.mjs | start() reads landHolders once for a non-thread instance and takes land_work and session.json landing from that one answer. landPass() runs after threadPass() in tick(). |
| packages/harness/src/runner.mjs | landingRequest, landingOk, and one turn per landing entry before the message loop, with a usage bracket on the decision id. |
| packages/bus/tests/land.test.mjs | landHolders (2 tests), landRelease (1), usage for a landing turn (1), and the bus process answering landHolders and landRelease from their own launchOp lines (1, after run 2). |
| packages/cli/tests/launcher.test.mjs | The :391 test now answers landHolders. Four landing-pass tests. |
| packages/harness/tests/runner.test.mjs | The template, landing turns, empty landing, malformed landing. |
| packages/cli/tests/thread-launch.test.mjs | Not in the brief's owned list. Its hand-built ops map gains landHolders and landRelease (two lines). Every non-thread start() now asks landHolders, so without them the test's non-thread launches refuse. |
| packages/cli/README.md, docs/TOOLS.md | "Landing runs" section, the bus start tick sentence, the Git socket sentence. |
| roles/merge-gate.md, cto.md, chief-of-staff.md | One sentence each: the host now starts the run, and row 149's steps are the fallback. workflow-roles.test.mjs:125-150 still matches all three. No other test reads them. |
Choices
- Usage for a landing turn (sage-0808).
usage.beginaccepts a decision id thatlandHolderslists for the run's instance.usage.endaccepts a turn that has a begin record for the same run. Both apply only to a non-thread run, and usage.mjs is unchanged. These are additions to the brief's test list, made per sage-0808. - Release with messages waiting (sage-0808).
landReleasewrites only the claim row, withby='land-relaunch'. Messages delivered and not read keep the run ({released:false, unread}, retried next tick). Messages still waiting get nodeliveriesrow and go to the next holder (land.test "still waiting", and launcher.test :463). - Launcher tests drive the pass by hand.
landingHostsetsthreadTickMsto an hour, so only the test's owntick()runs a pass. The host answerslandHoldersfromstate.holders.landReleaseanswers fromstate.release, or goes to the real broker when that is null. The broker's side of both is pinned in land.test. - The coder is the test holder. It is in
launch.instancesand shares sonnet (limit 1) with the reviewer. A running coder therefore makes the reviewer's landing start refusecapacity-full, which is what the refused-start test (:569) uses. - The refused-start record is
{event:"refused", by: <human>, instance, decision, code, why, limit?}. It is logged once per code per decision, and a start clears it. The release refusal is logged once per code per run.
Equivalent mutants (not run)
!s.threadin#requestUsage. A thread run's role is a thread instance, andlandHoldersleaves out thread instances' decisions, so begin can't match. End needs a begin with that turn, which therefore can't exist. The design's "a thread run refuses" case has no separate test for this reason.thread-not-allowedinlandRelease. A thread run's role is never listed, sodecision-not-heldrefuses it anyway.Correction (round 2, Filbert's F2 in 29033): not equivalent.thread ? null : landHoldersinstart(). A thread launch readslandHeldand never usesholding.holdingalso writes session.json'slanding(launcher.mjs:347), so without the guard alandHoldersentry naming the thread instance would reach the thread run's session file. thread-launch.test now asserts it doesn't, and LA14 is the mutant.
Gaps and follow-ups
- Runner usage turn id. No runner test can catch a wrong turn id. The runner fixture has no held
merge decision, so both usage calls refuse
not-recipienteither way. runner.test asserts that both calls happen around each landing turn, and land.test pins what the broker accepts. - "None on a thread run" (closed in round 2: thread-launch.test asserts it, LA14). Was: not asserted at the launcher level. It follows from the third
equivalent mutant above. A thread launch's
landingwould need a thread holder inlandHolders, and the broker never lists one (land.test :392). - Process wiring. process.test.mjs isn't owned, so the two op lines are covered through launcher.test
against the real host. P01 and P02 are listed below. (Correction, run 2: P02 survived, because a
missing line answered as
credentialStatus. Since the process.mjs fix in "Runs", a missing line refuseslaunch-op-refused, and a land.test test forks process.mjs and asserts each op's own answer, so P01 and P02 die there on a named assertion.) - Docs outside the owned list: packages/bus/README.md (launch ops list, :143) and packages/harness/README.md (:142-151, the turn order) don't mention landing turns. Follow-up.
- A superseded approval stays held and listed (sage-0928).
#held(broker.mjs:1024) yields a cross-role MERGE decision that is resolved with the approval choice, targets a task ref and is unspent. It doesn't applyreadLanding's superseded rule (packages/bus/src/landing.mjs:136-139: a later MERGE decision that supersedes it, or has the same task ref or work ref, refusessuperseded). If a second approval is raised for the same task, the first one is never spent, solandHeldcounts it as held andlandHolderslists it indefinitely. The cost is one wasted turn per stale decision per host start: the host starts the holder with thatlandingentry, and the turn'sland_workrefusessuperseded. Nothing lands wrongly. The land.test landHolders test pins this behavior:firstrefusessupersededand stays listed. Row 156 keeps the approved contract thatlandHolderslists exactly whatlandHeldcounts. The fix goes inlandHeld, or in how a later raise retires the earlier approval, andlandHeldandlandHoldersshould change together. Both options change base code outside 156's files. Sage files it on P19 after 156 lands.
Follow-up draft: the chief-of-staff's end step ends the host's landing run (sage-0907)
Sage ruled this a follow-up, not row 156. The step in roles/chief-of-staff.md is unchanged here apart from the brief's one sentence. Sage will fold this draft into docs/plans after 156 lands.
Finding. Row 149's step: if launch for the instance is refused instance-running and the run
belongs to that instance, end it with end_run (reason naming the decision), then launch once it has
exited. The cto messages the chief on every approval (cto.md). With the host running, landPass
(launcher.mjs:484-536) has already acted within one 2 s tick of the resolution:
- holder not running: it starts the holder with the
landingentry; - holder running from before the approval: it calls
landRelease(which waits for delivered, unread messages) and SIGTERMs the run, and the next tick starts the holder withlanding.
The chief acts later, after the cto's message and its own turn. Its launch then refuses
instance-running because of the host's landing run, and the step tells it to end that run.
role.revoke with a reason (broker.mjs:1512-1531) has no turn check, and END_PROTECTED is
['assistant']. The result is still one landing: if the landing turn's land_work ran, the
relaunch gets no landing entry and a second land_work refuses decision-consumed. If it didn't,
the decision stays held and the chief's relaunch gets the entry from landHolders. The cost is a
wasted run and possibly a revoke in the middle of the landing turn.
Proposed change (Sage's pick over dropping the end step). End the run only if it is a run of that instance that started before the approval. Dropping the step would leave the chief nothing to do if the host never starts the holder.
What the chief reads. Both stamps come from the broker's #now() as ISO strings, so they
compare as text. Both tools are given to every role (tools.mjs:227-229) and are scoped to the
business only.
- The approval time:
trailwith the decision id. Thedecision_eventsrow withopresolvedhasat(3b.sql:62-69). It is the same stamplandHoldersreports asresolved_at. - The run:
list_agents. The instance's row is its current claim, withholder_run(the run id) andat(the claim time) (3b.sql:36-45, broker.mjs:2048-2054). - The run's start:
trailwith that run id. Thesession.launchedevent has subject = run (broker.mjs:437) and is written at bind, before the runner claims, so itsatis the run's start. The claimatfrom step 2 is later than that and works as a bound when the trail is long.
Rule: end the run only when the session.launched at (or, failing that, the claim at) is
earlier than the resolved at. If list_agents has no claim row for the instance, the run is
still starting: don't end anything, and try launch again on a later turn.
What stays. A run that started before the approval is also the one landPass releases. If the
host is running, the chief and the host may both act on that same run. The run ends either way, but
the chief's revoke doesn't wait for unread messages the way landRelease does. The draft keeps this
because the fallback exists for a host that isn't starting holders, and the chief can't tell that
case from a host one tick behind. A test for the text: workflow-roles.test.mjs:125-150 matches the
three role files and would need its chief-of-staff pattern updated.
Overlap with row 155 and the base
- My diff applies cleanly on
0473574b0alone and on0473574b0plus 155 r3 (git apply --checkand--3way --check). With both applied, every changed source and test file parses. - Shared paths with 155: docs/TOOLS.md, broker.mjs, packages/cli/README.md, runner.test.mjs,
roles/chief-of-staff.md. The hunks are in different places. My only
#claimEndcall passesby, which was already on the base (row 125). - Base drift: de74dd473..d88613bac touches the candidate's paths in
7e7f8e208(row 162: docs/TOOLS.md, packages/cli/README.md) andb2f57ca70(row 155's landing). - Rebased onto
d88613bac(sage-0910), 2026-10-18T21:24Z. Thede74dd473diff (sha2563ae30c32…b908, 14 paths) applies cleanly withgit apply --3wayond88613bac. Every changed.mjspassesnode --check, and the mutant spec checks clean there ("33 mutants, 0 problems"). Build manifest on the new base: sha25639c9164e…c958, 14 paths. The singles and mutants run on this tree. - No row 164 freeze file is touched (human.mjs, human-cli.mjs, cli/src/transport.mjs, webui/src/bus.mjs), so channel-proof.test.mjs isn't in the singles. No test pins a sha256 of a changed path; the one 64-hex pin in a test that imports broker.mjs (thread-migration.test.mjs:46) pins migration DDL.
- Singles: 22 files. These five were added because they read the source of, import or fork a changed file: bus project-runtime (broker.mjs import list), limit-checks (runtime, broker and process source), broker, tasks, credential-status.
Mutants (33, mutants.mjs in this packet once run)
Broker (land.test): B01 threaded instance listed · B03 task_ref is the decision id · B04 unread released ·
B05 decision not checked · B06 any run of the holder's role releases · B08 an ended run releases ·
B09 release by the run · B10 begin accepts any turn · B11 end without a begin · B12 end accepts
another run's begin.
Ops: P01 no landRelease op and P02 no landHolders op (land.test and launcher.test since run 3) · RU01 no
runtime landHolders (launcher.test).
Launcher (launcher.test): LA01 no landPass · LA02 unlisted holder started · LA03 started again ·
LA04 start not noted · LA05 own landing released · LA06 unreleased run stopped · LA07 no SIGTERM ·
LA08 no land-relaunch record · LA09 release refusal every tick · LA10 no landing written · LA11 land
from a separate read · LA13 start refusal every tick.
Runner (runner.test): RN01 unchecked · RN02 extra key · RN03 no length limit · RN04 loose task ref ·
RN05 no landing turns · RN06 no state block · RN07a no landing begin · RN07b no landing end.
Runs
Run 1, red (slot sage-0927). Singles on wt-b against build manifest 39c9164e…c958,
21:45:03Z-21:46:11Z, busy check CLEAR at 21:45:03Z. 22 files, awk sum: tests 247, pass 244, fail 3.
All three failures are in this row's own new tests. The source didn't change for the fixes. The
mutants didn't run.
- land.test landHolders: the test landed
firstaftersecondwas raised for the same task.readLandingrefusessupersededfor that. The fix: assert thatfirstrefusessuperseded, landsecond, and assert thatfirststays listed (follow-up 5, sage-0928). - land.test landRelease: the human's message was taken to be the only one delivered. The resolve in
ready()also sends merge-gate a DECISION message (broker.mjs:1736-1747), so the receive gets 2. The test now asserts unread 2, then unread 1 once the human's message is read, and reads the DECISION message before the release. - runner.test "a malformed landing" (the 160-character decision at the end): the SIGTERM went
out right after the claim and killed the landing turn's adapter, so the stderr had no
okline. The test now sends a REQUEST and waits for its reply, which comes only after the landing turn, before the SIGTERM (no sleep).
After the fixes, the build manifest is 887c47e7e7bfa55529efc92e1d38073dab4756a3a3a2110d1b808b6835c515ac,
14 paths. Only land.test.mjs and runner.test.mjs differ from 39c9164e. Both pass node --check,
and the spec check reads "33 mutants, 0 problems".
Run 2 (slot sage-0928), logs in run2/. Manifest 887c47e7…15ac (run2/build-manifest.sha256).
Busy check CLEAR at 21:52:49Z, run 21:52:49Z-22:05:38Z, awk sums:
- the two touched files singly: tests 46, pass 46, fail 0;
- the 22 singles: tests 247, pass 247, fail 0;
- mutants: 3 controls ok, 32 killed, 1 survived (P02), 0 timeouts.
P02 removes the landHolders line from launchOp in process.mjs. The op is still in LAUNCH_OPS,
so it fell through to the last line, return runtime.credentialStatus(m.business, m.role). With
no role, that returns [], which reads as "no holders". The landing tests in launcher.test stub
landHolders at the host (:391, :431), so no test saw the difference, and gap 3's claim that P02
was covered through launcher.test was wrong. In production a missing line would mean landings
never start, with no error.
The fix is in process.mjs. The last line is now an explicit credentialStatus branch followed
by throw new BusError('launch-op-refused'), which is the code the catch at :86 already
uses. Every LAUNCH_OPS name has its own line, which was checked by a loop over the set, so no
working op changes. Only a missing line reaches the throw. New build manifest:
2a35cd383456a935405772cb1e2ade11cc05d1f60629cb5b0eedb0415a6eb82d; only process.mjs differs.
This changes behavior from base. On d88613bac, any LAUNCH_OPS name without its own line in
launchOp fell through to the last line and was answered as credentialStatus. Every name now
has an explicit line, and the fall-through refuses launch-op-refused (fail closed). No name in
the set reaches the throw today. An op that is not in LAUNCH_OPS is unchanged: it still gets
invalid-host-request at process.mjs:98, and the bus closes (:113-116).
After run 2: a named test for P01 and P02 (sage-0933). Sage asked for a test that names an op
outside LAUNCH_OPS and asserts launch-op-refused. That can't be written as stated, because such
an op never reaches launchOp (see the paragraph above). The throw is reachable only from a
LAUNCH_OPS name with no line, which is what P01 and P02 make. The substitute in land.test:
fork process.mjs, boot a demo business, and send the ops with ids. It asserts
landHolders → {ok:true, result:[], id:1}, landRelease for an unknown run →
{ok:false, error:'unknown-run', id:2}, credentialStatus → {ok:true, result:[], id:3}, and exit
0 on close. The messages read "landHolders, not launch-op-refused" and "landRelease, not
launch-op-refused". mutants.mjs now runs P01 and P02 against [land.test, launcher.test]. The
spec check reads "33 mutants, 0 problems". New build manifest:
afea1df3e042ccf41f5fa750b2841793605110bb45f55bc8018167d23c90fe1f, 14 paths. Only land.test.mjs
differs from 2a35cd38. Nothing ran before run 3.
Run 3 (slot sage-0935), logs in run3/. Manifest afea1df3…fe1f (run3/build-manifest.sha256),
checked at the start and the end. Busy check CLEAR at 22:14:13Z, run 22:14:13Z-22:18:52Z, awk sums:
- the 22 singles: tests 248, pass 248, fail 0, cancelled 0, skipped 0 (land.test 16/16; the one more than run 2 is the process test);
- P01 killed: land.test fails "landRelease, not launch-op-refused" (land.test:470), and one launcher.test test fails (:463);
- P02 killed: land.test fails "landHolders, not launch-op-refused" (land.test:469), plus 9 launcher.test failures;
- RU01 killed: 9 launcher.test failures;
- every control ok.
The other 30 mutants were killed in run 2 on 887c47e7 and were not rerun. Since then only two
files have changed. process.mjs has the explicit line and the throw; only a missing op line
reaches that code, and none of the 30 mutate process.mjs. land.test gained one test and kept its
others byte for byte, so the B mutants' killing assertions are still there.
Round 2 (Filbert's CHANGES in 29033, rulings sage-0942)
Built by 2026-10-18T22:42Z on 70c9131f0 (base.txt) in ~/dewey-scratch/r156/wt-c. The round 1
diff applied with git apply --3way. Only docs/TOOLS.md had drifted, and it merged cleanly. Nothing
has run yet; the run is run4.sh on Sage's slot.
-
F1, landRelease during a landing turn. broker.mjs keeps
#received, the runs that have askedmessage.receive.landReleaseanswers{released:false, landing:true}for any other run (broker.mjs:1016). The route and the rejected alternatives are in DESIGN.md, "Correction, round 2". README "Landing runs" and the TOOLSbus startsentence now hold as written. A run that never reaches its first receive (a hung landing turn, or a broker restart with a run that stops receiving) keeps the release closed. The decision then falls back to row 149's path: the chief ends the run and launches the holder (sage-0944). Tests:- land.test: the landRelease test asserts
landing: truetwice before the run receives. - runner.test: new test, "a run in a landing turn isn't released for a second held decision until
its landing turns end and it has read its messages". It uses a real runner and broker. The
adapter holds landing turn 1 open on a file while a second decision is raised and resolved.
Two
landReleasecalls 300 ms apart answerlanding: true, and the claim stays. Then the turn ends, the release goes through byland-relaunch, and the runner exits 22 (not-holder). - launcher.test :463: a
landing: trueanswer at a tick stops nothing and records noland-relaunch(the release count moves 4 → 5).
The tick mapping. Sage's wording names a test that ticks. No single test has a real launcher over a real broker holding a real merge decision. The acme fixture's roles have no merge-gate instance, and the launcher tests stub
landHolders. The coverage is split:- runner.test has the real runner and broker, and calls
landReleasethe way the launcher's tick does; - launcher.test shows that a tick given that answer does nothing. B13 is killed by land.test and runner.test, and LA06 by launcher.test.
- land.test: the landRelease test asserts
-
F2. thread-launch.test: "a thread run's session file has no landing, even when landHolders names its instance". The BUILD.md:56 equivalence claim is struck and corrected above.
-
F3. land.test: "a landing run started after the resolution passes checkLand for its held decision before any message.receive, and lands it". The broker is real, and the call is made directly as the landing run (merge-gate-land):
checkLandgives a ticket naming the decision, the commit, the verdict and the choice, andrecordLandrecords the merge withactor_runmerge-gate-land. AfterwardslandHoldersis empty and no message was delivered to the run. -
Notes (doc-only). The README skipped-turn sentence is in. The bus and harness README follow-ups (gap 4) stay follow-ups.
Mutants: 36 (mutants.mjs; spec check "36 mutants, 0 problems"). New:
- B13: drop the
#receivedcheck (land.test, runner.test); - B14: drop the add on receive (land.test, runner.test);
- LA14: a thread launch asks landHolders (thread-launch.test).
All 33 earlier mutants rerun, because land.test's release test changed.
Singles: 26 files. These four were added because round 2 touches the message.receive case:
- bus messages.test
- messaging.test
- agents.test
- tester.test
Build manifest: build-manifest-r2.sha256 (sha256 9fafa2b0…1785), 14 paths, the same paths as
round 1. Patch: build-r2.patch (sha256 2fd16539…d03c). Every changed .mjs passes
node --check. The test hash-pin grep finds only thread-migration.test.mjs, which pins migration DDL.
Correction, round 2 run 4 (red, 2026-10-18T22:48:24Z to 22:49:27Z, sage-0945 slot)
The busy check was CLEAR at 22:48:24Z, and wt-c matched 9fafa2b0. run4.sh stopped at step 1. The awk
sum over the 4 touched files:
- tests 84, pass 83, fail 1, cancelled 0, skipped 0.
- land.test: 17 tests, 1 fail. runner 32/0, launcher 24/0, thread-launch 11/0.
- No singles or mutants ran.
The F3 test's last assert was wrong, at land.test.mjs:473. "no message was delivered to the run"
counted every deliveries row for merge-gate-land and found 2. Those are the op='routed' rows
the run's role.claim writes when it routes the waiting messages to it as their holder. Nothing
was read. Every earlier assert in the test passed. The fix filters on op='delivered', the same
filter the release test uses at :445. The F3 line above should read "no message was read by the
run".
New manifest: build-manifest-r2.sha256, sha256 72a5c1b6…b700; only land.test changed,
cca44e64 to fb699ffb. New patch: build-r2.patch, a9fa9b75…8e48. The red run's logs and the
old manifest are in run4-red/. The rerun is the same run4.sh, from step 1, on its own slot.
Round 2 run 4, rerun (green, 2026-10-18T22:53:04Z to 23:08:35Z, sage-0947 slot)
Same run4.sh, from step 1, on wt-c against build-manifest-r2.sha256 (72a5c1b6…b700). The busy
check was CLEAR at 22:53:04Z. wt-c matched the manifest at the start and at the end. Awk sums over
the logs in run4/:
- touched (4 files, singly): tests 84, pass 84, fail 0, cancelled 0, skipped 0;
- singles (26 files, singly): tests 290, pass 290, fail 0, cancelled 0, skipped 0;
- mutant controls (4 files): tests 84, pass 84, fail 0;
- mutants: spec check "36 mutants, 0 problems"; killed 36, survived 0, timeout 0;
mutants.sh rc=0.
The F3 line above stands as corrected in run 4's correction: no message was read by the run. After the run, two fake-adapter shells from the runner tests (started 22:56Z and 23:07Z, cwd a deleted dir under my TMPDIR) were still alive; I killed them by PID.
Round 2 candidate: build-manifest-r2.sha256 (72a5c1b6…b700, 14 paths), patch build-r2.patch
(a9fa9b75…8e48), base 70c9131f0.
Round 2 verdict (Filbert, comment 29039, rev 1894)
Approve on 72a5c1b6. There are two notes, neither blocking, and both are follow-ups:
- cli/README.md doesn't describe the hung-landing-turn and broker-restart cases yet (doc-only).
- The broker's
#receivedset is never pruned, the same as#ended.