337 lines
18 KiB
Markdown
337 lines
18 KiB
Markdown
# 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`.
|