Files

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 #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:

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.