Files

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.begin accepts a decision id that landHolders lists for the run's instance. usage.end accepts 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). landRelease writes only the claim row, with by='land-relaunch'. Messages delivered and not read keep the run ({released:false, unread}, retried next tick). Messages still waiting get no deliveries row and go to the next holder (land.test "still waiting", and launcher.test :463).
  • Launcher tests drive the pass by hand. landingHost sets threadTickMs to an hour, so only the test's own tick() runs a pass. The host answers landHolders from state.holders. landRelease answers from state.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.instances and shares sonnet (limit 1) with the reviewer. A running coder therefore makes the reviewer's landing start refuse capacity-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.thread in #requestUsage. A thread run's role is a thread instance, and landHolders leaves 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-allowed in landRelease. A thread run's role is never listed, so decision-not-held refuses it anyway.
  • thread ? null : landHolders in start(). A thread launch reads landHeld and never uses holding. Correction (round 2, Filbert's F2 in 29033): not equivalent. holding also writes session.json's landing (launcher.mjs:347), so without the guard a landHolders entry 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

  1. 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-recipient either way. runner.test asserts that both calls happen around each landing turn, and land.test pins what the broker accepts.
  2. "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 landing would need a thread holder in landHolders, and the broker never lists one (land.test :392).
  3. 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 refuses launch-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.)
  4. 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.
  5. 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 apply readLanding'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, refuses superseded). If a second approval is raised for the same task, the first one is never spent, so landHeld counts it as held and landHolders lists it indefinitely. The cost is one wasted turn per stale decision per host start: the host starts the holder with that landing entry, and the turn's land_work refuses superseded. Nothing lands wrongly. The land.test landHolders test pins this behavior: first refuses superseded and stays listed. Row 156 keeps the approved contract that landHolders lists exactly what landHeld counts. The fix goes in landHeld, or in how a later raise retires the earlier approval, and landHeld and landHolders should 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 landing entry;
  • 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 with landing.

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.

  1. The approval time: trail with the decision id. The decision_events row with op resolved has at (3b.sql:62-69). It is the same stamp landHolders reports as resolved_at.
  2. The run: list_agents. The instance's row is its current claim, with holder_run (the run id) and at (the claim time) (3b.sql:36-45, broker.mjs:2048-2054).
  3. The run's start: trail with that run id. The session.launched event has subject = run (broker.mjs:437) and is written at bind, before the runner claims, so its at is the run's start. The claim at from 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 0473574b0 alone and on 0473574b0 plus 155 r3 (git apply --check and --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 #claimEnd call passes by, 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) and b2f57ca70 (row 155's landing).
  • Rebased onto d88613bac (sage-0910), 2026-10-18T21:24Z. The de74dd473 diff (sha256 3ae30c32…b908, 14 paths) applies cleanly with git apply --3way on d88613bac. Every changed .mjs passes node --check, and the mutant spec checks clean there ("33 mutants, 0 problems"). Build manifest on the new base: sha256 39c9164e…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 first after second was raised for the same task. readLanding refuses superseded for that. The fix: assert that first refuses superseded, land second, and assert that first stays 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 ok line. 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 asked message.receive. landRelease answers {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 TOOLS bus start sentence 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: true twice 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 landRelease calls 300 ms apart answer landing: true, and the claim stays. Then the turn ends, the release goes through by land-relaunch, and the runner exits 22 (not-holder).
    • launcher.test :463: a landing: true answer at a tick stops nothing and records no land-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 landRelease the 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.
  • 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): checkLand gives a ticket naming the decision, the commit, the verdict and the choice, and recordLand records the merge with actor_run merge-gate-land. Afterwards landHolders is 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 #received check (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 #received set is never pruned, the same as #ended.