# Row 156 design: the host starts the landing run (#1651) Dewey, design only, per sage-0805: written read-only at HEAD 20b5030b5, with no candidate code and no runs. The brief is `docs/plans/2026-10-18_landing-run-launch.md`, blob aeddbc43224f (matching the row's pin). Line numbers below are at 20b5030b5 unless marked "r125". ## What this depends on in row 125 Row 125's candidate (manifest e688651b…, base 19fe85c3e) changes four of this row's files. The design assumes 125 has landed, and I'll build on the landed tree. Rechecked against 125's round 2 (manifest 71c55638…, same base): broker.mjs, process.mjs, runtime.mjs, launcher.mjs, usage.mjs and runner.test.mjs have the same blobs as in round 1, so every "r125" line below still holds. Round 2 changed only the limits writer and walker (launch-settings.mjs, limits.mjs), their tests and docs, none of which this row touches. | 125 change | Where | What 156 does with it | |---|---|---| | `check()` becomes async, with the `mayLaunch` limit step | launcher.mjs r125 :197, :221-232 | The landing pass calls `await check(instance)`. A landing start can refuse `limit-reached` or `limit-check-failed` and is logged once per code, like any other refusal. | | `launchOp` is awaited | process.mjs r125 :78 | `landHolders` and `landRelease` are sync, so nothing changes there. They sit beside `mayLaunch` in `LAUNCH_OPS`. | | `#claimEnd(s, h, op, decision, reason, by)` gains `by` | broker.mjs r125 :2290 | `landRelease` ends the claim with `by='land-relaunch'`, so the claim row says why the run lost the role. Without 125 the row would say the run released itself. | | `#limitCheck` after `usage.end`, and the stop at the next `state` or `message.receive` | broker.mjs r125 :1137, :1203-1240 | A landing turn reads `state` first, as a message turn does, so a pending limit stop applies before the landing turn. The landing turn is bracketed by `usage.begin`/`usage.end` with the decision id as `turn` (sage-0808, section 7), so `#limitCheck` runs after it. | | `endLaunch` returns `{ released, limit? }` | broker.mjs r125 :460-466, launcher.mjs r125 :355 | Nothing to do. A relaunched run's end shows `released: false`, because `landRelease` already ended the claim. | The brief says both rows change `runner.mjs`. 125's candidate changes only `runner.test.mjs` (the REQ-LIMIT-4 bracket test at :565), not `runner.mjs`. The overlap is the test file plus broker, process, runtime and launcher. ## 1. Broker: `landHolders` and `landRelease` ### `landHolders(business)` A trusted launcher read, not a socket verb. It returns `[{ instance, decision, task_ref, resolved_at }]`, in decision `seq` order, with one entry per decision. A role can hold more than one. I'll factor `landHeld`'s loop (broker.mjs:955-973) into a private `#held(business, role, thread)` that yields each usable decision with its raised context and closing event. Then: - `landHeld(record)` is true when `#held` yields anything. Its contract is unchanged, and thread runs keep using it. - `landHolders(business)` covers every MERGE decision in the business whose raising role isn't a thread instance (`#threaded` is false) and whose `#raisedThread(d)` is null. It applies `#held`'s tests: - the decision is cross-role; - the raised context's target is a task ref; - it was resolved with the approval option; - it isn't spent. `task_ref` is `context.target`, and `resolved_at` is the closing event's `at`. One test set covers both: a decision is listed exactly when `landHeld` with `thread: null` is true for its role. ### `landRelease({ business, run, decision })` This is the brief's "the row adds that check for this run", modelled on `threadIdle` (broker.mjs:351-363). It runs as one synchronous call, so a message sent after it waits for the next holder. It refuses, in order: 1. `unknown-run` or `run-ended`, as `threadIdle` does. 2. `thread-not-allowed` when the run is a thread run. 3. `not-holder` when the run doesn't hold its role's claim. 4. `decision-not-held` when `decision` isn't a current `landHolders` entry for the run's role. A tick working from an old snapshot can't stop a run for a decision that was already spent. Then it counts the messages delivered to this run and not read. That count is a turn in progress, the same subquery `#agentState` uses (broker.mjs:2215). If it isn't zero, the call returns `{ released: false, unread }`. Otherwise it ends the role claim with `op 'release'` and `by 'land-relaunch'` in a transaction, writes nothing else, and returns `{ released: true }`. It doesn't touch the decision. **Choice: pending messages don't block the release.** `threadIdle` also refuses while messages wait undelivered. Here I leave them waiting. After the release, the role has no holder. The new run claims the role and gets them through `#routeWaiting`. The approval DECISION message is often still undelivered when the tick runs. If it blocked, the raising run (with no `land_work`) would take a turn on it and might answer "I can't land". Releasing first gives that message to the landing run instead. The tradeoff: the landing run can get a turn for the landing entry and another for the DECISION message. The second turn's `land_work` refuses `decision-consumed`, and merge-gate's contract (row 149 round 4) reads that as already landed. ## 2. Runtime and process - runtime.mjs (beside `threadsState`, :167): `landHolders: (business) => broker.landHolders(business)` and `landRelease: (r) => broker.landRelease(r)`. - process.mjs: add both names to `LAUNCH_OPS` (:5-19), and add two `launchOp` lines under the row 121 lines (:36-37), with a `// Row 156 (#1651)` comment. ## 3. Launcher ### State There are two new in-memory maps beside `startedFor` and `refusedThreads`: - `landingOf`: run → `Set` of the decision ids written into that run's `landing`. It's filled in `start()` and deleted in the run's `exited` handler. - `landStarted`: decision → true once a start for it succeeded (brief step 3). - `refusedLand`: decision → the last refusal code, so each code is logged once. Nothing is persisted. After a restart, `recover()` kills the earlier host's runs, and the empty maps let the first tick start each holder (brief step 5). This avoids adding a field to `sessions.json`, which is `packages/seat/src/session.mjs`'s file and outside the row. ### `start()` for a non-thread instance This replaces the `landHeld` call at :262 for `thread === null`: ```js const held = thread ? null : (await host.op({ op: "landHolders", business: b.id })).filter((h) => h.instance === instance); const land = thread ? await host.op({ op: "landHeld", record: { business: b.id, role: instance, thread } }) : held.length > 0; ``` - `land` and `landing` come from one read (brief step 6). - `writeSessionFile` gets `...(held?.length ? { landing: held.map(({ decision, task_ref }) => ({ decision, task_ref })) } : {})`, so a run with nothing held has no `landing` key. - After `registry.add`, `landingOf.set(run, new Set(held?.map((h) => h.decision) ?? []))`. This runs for every non-thread launch: the tick, `role.launch` and pm. ### The landing pass, after the thread pass in `tick()` ``` holders = landHolders(b.id) // refused: log once, return for h of holders, in order: skip unless b.launch.instances includes h.instance (brief: unlisted starts nothing) s = registry.running(b.id, h.instance, null) if s: if stopping.has(s.run) or landingOf.get(s.run)?.has(h.decision): continue r = landRelease({ business, run: s.run, decision: h.decision }) // refused: log, continue if !r.released: continue // a turn in progress: a later tick stopping.add(s.run) record({ event: "land-relaunch", instance: h.instance, run: s.run, decision: h.decision }) SIGTERM s.runnerPid after the start-time check (as :386-390) continue // the next tick starts it if landStarted.has(h.decision): continue serialize: recheck closed and running; start({ instance, launchedBy: b.human, ...(await check(instance)) }) ok -> landStarted.set(decision); refusedLand.delete(decision) refused -> once per code: log + record({ event: "refused", by: b.human, instance, decision, code, why, limit? }) ``` A run counts as "started before the resolution" when its `landing` doesn't name the decision. That is exactly the case where it has no `land_work` for it. Comparing `startedAt` with `resolved_at` instead would miss a decision resolved between the `landHolders` read in `start()` and `startedAt`. Several holders for one instance: the first start writes every held decision into `landing`. The other decisions are then in `landingOf(run)`, so they don't start or stop anything. They are marked in `landStarted` when the run starts, because one start covered them. Thread instances never appear: `landHolders` excludes them, and `check()` would refuse `thread-required` anyway (brief step 4). The header comment gets a "Landing runs (row 156, #1651)" paragraph after the thread-session one. ## 4. Runner - **Session read** (before the claim, as the policy check): `landing` is absent, or an array of `{ decision, task_ref }` with exactly those keys. `decision` matches the broker's `id` (`/^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/`, at most 160 characters) and `task_ref` matches `/^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/`. Anything else logs `runner: malformed landing entry` and exits `EXIT.usage` before the claim, so no turn is taken. - **Exported `landingRequest({ decision, task_ref })`**: a fixed template with no message text in it. ``` Landing from the host, decision ${decision}, task ${task_ref}: Your merge decision ${decision} for ${task_ref} is approved and unused. Land it now with land_work, task_ref ${task_ref} and decision ${decision}. The reply only says the landing started; the gate's outcome comes later as a RESULT from the bus. If land_work refuses decision-consumed, the landing already happened. ``` - **Landing turns**, after the tool server and `turnEnv` and before `loop:`. For each entry: 1. The state read and the block, as a message turn (row 109). `SESSION_GONE` exits as there. 2. `usage.begin` with `turn: e.decision`, `runTurn` with `${block}\n${landingRequest(e)}`, then `usage.end` with the same turn, as a message turn brackets `runTurn` (sage-0808). 3. No `message.send` and no `message.read`. 4. A log line: `runner: landing turn ${n} for decision ${d} (${task_ref}): ok`, or `failed: ${reason}`. 5. On `stopping`, break. The turn counter `n` is shared with message turns. ## 5. Tests (brief list → file) | Brief test | File | Shape | |---|---|---| | `landHolders` lists only while `landHeld` is true: not open, declined, spent, gated, or another role's | land.test.mjs | Next to the `landHeld` test at :333, on the same fixture, asserting the two agree at each step. Also: a thread instance's decision isn't listed. | | `landRelease` refusals and the unread check | land.test.mjs | Unread blocks the release; after the read it releases; a later `message.receive` from the run refuses `not-holder`; the claim row has `by='land-relaunch'`. | | The tick starts a listed holder once with `land_work`; unlisted and same-decision get no start | launcher.test.mjs | A fake broker answers `landHolders`, as the :391 test answers `landHeld`. | | Started before the resolution: stop, `land-relaunch`, the next tick starts with `land_work`; started after: left alone | launcher.test.mjs | Two runs. | | After the landing spends the decision, no start | launcher.test.mjs | | | Restart with a held decision: the first tick starts the holder | launcher.test.mjs | | | The session file's `landing`, one turn before any message, lands, with the raising run's poll active; none without a decision; none on a thread run | launcher.test.mjs + runner.test.mjs | | | A non-empty `landing` always comes with the Landing line | launcher.test.mjs | Assert on the bundle's prompt and the session file from the same start. | | A malformed entry refuses the start | runner.test.mjs | Bad decision, bad task ref, an extra key, not an array: exit 2, no claim, no turn. | | Added (sage-0808): `usage.begin` with a decision id refuses unless held for the run's instance | land.test.mjs | Not held, another role's, a thread run: each refuses; held: accepted. | | Added (sage-0808): `usage.end` with a decision id refuses without a `begin` record | land.test.mjs | Accepted after the begin, even once the landing has spent the decision. | | Added (sage-0808): the usage record of a landing turn | land.test.mjs | Keyed `(run, decision, phase)`; `#limitCheck` sees its tokens. | | Added (sage-0808): messages waiting at the release stay undelivered | launcher.test.mjs | No `deliveries` row at the release; the new run takes the landing turn first, then `message.receive` delivers them to it. | The existing test at launcher.test.mjs:391 wraps `landHeld` for a pm (a non-thread instance). It changes to answer `landHolders`. The thread case keeps `landHeld`. Mutants: one per row above, plus: - `landRelease` without the unread check; - `landingOf` not consulted, which stops a run that already has the decision; - `landStarted` not set, which starts on every tick; - the runner skipping validation. Each goes in the packet with its killing test. ## 6. Docs - `packages/cli/README.md` and `docs/TOOLS.md`: the landing pass, the `land-relaunch` and `refused` launch-log records, and the session file's `landing`. - `roles/merge-gate.md`, `roles/chief-of-staff.md` and `roles/cto.md`: one sentence each saying the host now starts the landing run. Row 149's steps stay as the fallback. ## 7. Questions to Sage (answered) 1. **Usage for a landing turn (REQ-LIMIT-4).** `usage.begin` and `usage.end` take `turn` as a message id delivered to the run (broker.mjs:1181-1188), and a landing turn has none. With no bracket, the turn's tokens go unrecorded, and 125's `#limitCheck` doesn't run after it. My recommendation, in broker.mjs (owned): `#requestUsage` also accepts `turn` = a decision id when `s.thread` is null and either: - for `begin`, `landHolders` lists it for `s.role`; - for `end`, a `begin` record exists for this run and turn (the landing may have spent it by then). `usage.mjs` (125's file) needs no change: it keys records on `(run, turn, phase)`. The brief doesn't mention this, so I'd like a yes before it goes in. The alternative is to leave landing turns unbracketed and record that as a known gap. 2. **Pending messages at the stop.** I chose to release with undelivered messages waiting (section 1). Say if you want `threadIdle`'s stricter rule instead. ### Sage's answers (sage-0808, 2026-10-18T18:01Z) 1. **Usage: build it.** `#requestUsage` accepts a held decision id as the turn: - `begin` refuses unless `landHolders` lists the decision for the run's instance (`s.thread` null); - `end` refuses unless a `begin` record exists for this run and turn. It stays in broker.mjs, with no usage.mjs change. Tests, each with a mutant: - the `begin` refusal; - the `end` refusal; - the usage record of a landing turn. BUILD.md will name these as additions to the brief's test list, with sage-0808 as the reason. 2. **Release with messages waiting: yes.** The condition is that waiting messages stay undelivered: not dropped, and not marked delivered. The release writes only the claim row; it adds no `deliveries` row. Added test (launcher.test.mjs, with a mutant): a message waits when the raising run is released. The new run takes its landing turn first, then `message.receive` gives it the message, delivered to the new run. Nothing here touches the bus host process, the Console or the live tree. ### Correction, round 2 (Filbert's F1 in 29033, ruled in sage-0942) The approved design said `landRelease` releases "only between turns". Its check was only the unread count of delivered messages. A landing turn reads no message, so a run in its landing turns had nothing unread and was released. A SIGTERM in the middle of a landing turn could cut its `land_work` off. The design had this gap, not just the build. **Route: the runner tells the broker, implicitly.** The broker keeps an in-memory set of the runs that have asked `message.receive` (`#received`, added in the `message.receive` case for a non-human, non-tester session). The runner's first receive comes only after its last landing turn. `landRelease` answers `{released:false, landing:true}` for a run not in the set, after the decision-not-held check and before the unread count. The launcher treats it like any other `released:false`: nothing stops, and a later tick asks again. Why this route: - It needs no runner change and no new verb. runner.mjs runs its landing loop before the message loop, so its first receive comes after the last landing turn. The new runner.test test depends on that order. - On a broker restart the set is empty, so a rebound run counts as in its landing turns until its next receive. That errs toward not releasing, which is safe. A run that never receives again is never released for a decision; the holder keeps running, and a landing for it waits for the run to end. No landing is lost. Rejected: - **An explicit "landing turns done" verb.** The broker would have to keep that state, and an in-memory flag is lost on a restart. After a restart the run would have to send the verb again, which it can't know to do, or the flag would have to be persisted, which is a schema change outside the row. - **The usage bracket** (`usage.begin` without a matching `usage.end` for a decision id). Usage records exist only when usage is configured. A check that depends on optional config fails open where usage is off. Tests, each killed by a mutant (B13 drops the check, B14 drops the set's add): - land.test: `landRelease` refuses with `landing: true` twice before any receive, then follows the old release flow after the run receives. - runner.test: a real runner and broker. A landing turn's adapter waits on a file while a second decision is raised and resolved. `landRelease` for it answers `landing: true` twice (300 ms apart), and the claim stays. Once the landing turn ends and the runner receives, the release goes through by `land-relaunch`. - launcher.test (:463): a `landing: true` answer stops nothing and records no `land-relaunch`.