18 KiB
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 aeddbc4322 (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#heldyields 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 (#threadedis 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_refiscontext.target, andresolved_atis the closing event'sat.
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:
unknown-runorrun-ended, asthreadIdledoes.thread-not-allowedwhen the run is a thread run.not-holderwhen the run doesn't hold its role's claim.decision-not-heldwhendecisionisn't a currentlandHoldersentry 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)andlandRelease: (r) => broker.landRelease(r). - process.mjs: add both names to
LAUNCH_OPS(:5-19), and add twolaunchOplines 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 →Setof the decision ids written into that run'slanding. It's filled instart()and deleted in the run'sexitedhandler.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:
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;
landandlandingcome from one read (brief step 6).writeSessionFilegets...(held?.length ? { landing: held.map(({ decision, task_ref }) => ({ decision, task_ref })) } : {}), so a run with nothing held has nolandingkey.- 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):
landingis absent, or an array of{ decision, task_ref }with exactly those keys.decisionmatches the broker'sid(/^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/, at most 160 characters) andtask_refmatches/^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/. Anything else logsrunner: malformed landing entryand exitsEXIT.usagebefore 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
turnEnvand beforeloop:. For each entry:- The state read and the block, as a message turn (row 109).
SESSION_GONEexits as there. usage.beginwithturn: e.decision,runTurnwith${block}\n${landingRequest(e)}, thenusage.endwith the same turn, as a message turn bracketsrunTurn(sage-0808).- No
message.sendand nomessage.read. - A log line:
runner: landing turn ${n} for decision ${d} (${task_ref}): ok, orfailed: ${reason}. - On
stopping, break.
The turn counter
nis shared with message turns. - The state read and the block, as a message turn (row 109).
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:
landReleasewithout the unread check;landingOfnot consulted, which stops a run that already has the decision;landStartednot set, which starts on every tick;- the runner skipping validation.
Each goes in the packet with its killing test.
6. Docs
packages/cli/README.mdanddocs/TOOLS.md: the landing pass, theland-relaunchandrefusedlaunch-log records, and the session file'slanding.roles/merge-gate.md,roles/chief-of-staff.mdandroles/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)
-
Usage for a landing turn (REQ-LIMIT-4).
usage.beginandusage.endtaketurnas 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#limitCheckdoesn't run after it.My recommendation, in broker.mjs (owned):
#requestUsagealso acceptsturn= a decision id whens.threadis null and either:- for
begin,landHolderslists it fors.role; - for
end, abeginrecord 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. - for
-
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)
-
Usage: build it.
#requestUsageaccepts a held decision id as the turn:beginrefuses unlesslandHolderslists the decision for the run's instance (s.threadnull);endrefuses unless abeginrecord exists for this run and turn.
It stays in broker.mjs, with no usage.mjs change.
Tests, each with a mutant:
- the
beginrefusal; - the
endrefusal; - 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.
-
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
deliveriesrow.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.receivegives 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.beginwithout a matchingusage.endfor 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:
landReleaserefuses withlanding: truetwice 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.
landReleasefor it answerslanding: truetwice (300 ms apart), and the claim stays. Once the landing turn ends and the runner receives, the release goes through byland-relaunch. - launcher.test (:463): a
landing: trueanswer stops nothing and records noland-relaunch.