# Row 271 build notes (Gizmoduck) 264d, T1 item 7: clear context through row 194's lifecycle stop. Gitea #1776. Brief: docs/plans/2026-10-21_t1-sessions.md section 5 (D7, D8), with RULINGS Q4 (rebase onto 194's landing, no 194 code copied). Reviewer Duckworth, gate owner Sage. Built after row 270 (sage-2797). ## Contract for 264e (pinned for Huey, row 272) Proposed, pending Duckworth's review. Any change goes to Huey before it's pushed. ### session.clear Input, every key required, with no other key (`keys()` as `session.create` does): ```json { "thread": "", "session": "", "revision": 0, "confirm": true, "op": "" } ``` - `revision`: the integer from the session's `session.list` row (below). It names the state the human confirmed. - `confirm`: the literal `true`. Anything else refuses `confirm-required`. - `op`: the caller's operation key, an identifier, unique per thread. It is also the idempotency key. Answer, the operation envelope: ```json { "op": "...", "thread": "...", "session": "", "successor": "", "state": "clearing", "start": null, "refusal": null, "at": "..." } ``` - `successor` is bound when the request is accepted, before any asynchronous work. It is a lowercase UUID, `xxxxxxxx-xxxx-8xxx-Vxxx-xxxxxxxxxxxx` with V one of `8`, `9`, `a`, `b` (RFC 9562 version 8, variant 10). It's the first 32 hex digits of sha256(business, thread, old session, op) with those two nibbles set. It matches the UUID test every session selector uses (cli/src/thread.mjs:41), so 264a's selectors and deep links take it unchanged (Huey's question, gizmoduck-0274). - `state`: - `clearing`: accepted. Sends and launches to the old session are fenced, and the drain or stop is in progress. - `uncertain`: stop proof isn't available. Capacity stays reserved and nothing is forced. It resumes when proof arrives. - `retired`: the old session is retired and the successor exists, in one bus transaction. - `failed`: no retirement happened, and `refusal` names why. The old session stays as it was. - `cancelled`: a human cancelled the clear (session.cancel below, sage-2828). `retired` is null if it was cancelled before retirement and true if after. `start` stays as it was. - `start` is null until `state` is `retired`. After that it is `held` (waiting for capacity), `started`, or `refused` with `refusal` set. - `at`: the time of the latest state change. Repeats and conflicts: - The same `op` with the same payload returns the current envelope and starts nothing new. - The same `op` with any other payload refuses `session-op-conflict`. - A new `op` on a session with an open clear refuses `session-clearing`. - A `revision` that isn't current refuses `session-revision-stale`. - Other refusals: `session-not-found`, `session-retired`, `processing-halted` (D8: a halt refuses and changes nothing), and the owner refusals `session.create` already gives. ### session.cancel (sage-2828) Input, every key required, with no other key: ```json { "thread": "", "op": "", "confirm": true } ``` This is a human write with the same owner or write-grant check as session.clear, rechecked under the session fence. An agent caller refuses as session.clear does. `confirm` must be the literal `true`, or it refuses `confirm-required`. The answer is the operation envelope with `state` `cancelled`. - Before retirement, cancel stops the replacement: - The successor id is never created or started. - Messages already cancelled stay cancelled and are never replayed. - The old session is `active` again. - After retirement, cancel stops the startup only: - No further start attempts are made. - Both sessions stay as they are. Nothing un-retires. - Repeats and refusals: - A repeat with the same payload returns the original cancelled envelope. - The payload digest covers `{thread, op}` and the calling principal. A cancel from a different principal, or a payload that otherwise differs, refuses `session-op-conflict`. - An op that isn't a clear refuses `operation-not-found`. - A cancel of a clear whose successor has started refuses `session-clear-complete`. ### session.operation Input `{ "thread": "...", "op": "..." }`. Answer: the same envelope. Unknown op refuses `operation-not-found`. A CLI that waits 30 s and then reports pending uses this to poll. A retry queries the op here and never resends the clear. ### session.list additions Each row gains: - `state`: `active`, `clearing`, `uncertain` or `retired`. A cancelled clear leaves the session `active` if cancelled before retirement, and `retired` if after. - `revision`: an integer. 0 at creation, plus 1 for each lifecycle event on that session. - `successor`: the successor's id once retired, otherwise null. A successor row has `predecessor` set (264a's column). When the cleared session was the thread's default, `default` moves to the successor at retirement. Clearing any other session leaves the default where it is. On a base without 264d, `session.clear`, `session.cancel` and `session.operation` refuse `unknown-verb`, and the rows have no `state`, `revision` or `successor`. ## Design (draft 2: Sage's sage-2826 rulings and Mos's mos-1315 folded in) Measured on 2d0d407ba, which already holds row 194 (ab7377bcf). Nothing has been run. Draft 1 was 3105de889. Draft 2 changes where the drained acknowledgement comes from (stage 2): threadIdle alone can't show that no child is running. ### What exists and what doesn't - No per-session lifecycle state. `sessions` is append-only (schema.sql:370-372). The nearest per-session state is the latest `thread_claims` row (op and generation). Row 194's lifecycle is per instance, as `action.allowed` events keyed by a random request id (broker.mjs:622-666). - No cancelled state for an undelivered message. `deliveries.op` is a CHECK list on an append-only table. - The runner calls `message.receive` only when no harness child exists: - `runTurn`'s close handler SIGKILLs the turn's whole process group and clears `child` (harness/src/runner.mjs:363-366). - The loop asks receive only after the landing turns (:493-517) and after each message's RESULT and `message.read` (:524-580). - So a receive from the run is a completed turn boundary, made by the runner itself. - `threadIdle` (broker.mjs:865-877) is not enough. A landing turn reads no message, so `unread` is 0 while its child runs. That is why landRelease keeps a run until it has asked receive (`#received`, broker.mjs:363-365, :3815-3829). - There is precedent for acting at receive. `#limitStop` (broker.mjs:4147-4148, :4499-4520) holds a stop until the run's next `message.receive` or `state`, so the turn's RESULT and read land first. - The runner's SIGTERM handler escalates to KILL after 5 s only when `child` is set (runner.mjs:405-423). With no child it sets `stopping`, the loop ends, and it exits 0. - The launcher's idle stop (launcher.mjs:683-701) sends a SIGTERM after a start-time check. The exit proof is the run's `close` handler, which runs endRun, removes the registry entry and releases the workspace lock (:600-641). ### Clear, stage by stage 1. **Accept** (`session.clear`, the human's write). One transaction: - Check owner and grant (the `session.create` path), the revision, and that processing isn't halted. - Bind the successor before any async work: - The id is deterministic, from a sha256 of (business, thread, old session, op), shaped as a lowercase version 8 UUID (the contract above). 264a's ids are UUIDs, and every selector checks the UUID shape, so draft 2's `ID_PATTERN` shape is replaced. - The name is `-N`, with N the next free number in that thread. If a user already made a session called `-N`, the next free N is taken. - Both go on the `requested` row, so a repeat of the op returns the same id and name. - Append journal stage `requested`, with a payload digest for the repeat and conflict checks. - The answer is `clearing`. From here: - `thread.send` and `thread.open` naming the session refuse `session-clearing`. So does a thread-only send whose default is that session. - `threadsState` reports the session as `clearing`, so `threadPass` never starts it. - Undelivered messages to it are cancelled with a receipt each (`message_cancels`), in the same transaction. Old messages, results and run evidence don't change. 2. **Drain, acknowledged by the runner.** Once the session is clearing, the run's `message.receive` (the runner's call, at a turn boundary with no child) takes one transaction: - It appends `drained` with the run and its claim generation, once. - It delivers nothing and answers `[]`. - The claim stays held. That holds the dequeue fence, so no turn and no child can start before the stop, and the old capacity stays reserved. Later receives keep answering `[]`. `state` needs no fence, because the runner asks it only for a message it received. A clear on a session with no holder appends `drained` with run null at accept. 3. **Stop and proof** (launcher). For a `drained` op whose run and generation are still the holder's, the launcher sends the idle stop's start-time-checked SIGTERM, the same reviewed path. The runner has no child, so its handler never reaches KILL. - The exit proof is the run's own `close` handler (endRun, registry remove, lock release), recorded with `sessionClearExited {business, op, run}`. A signal, a revoked claim or a dead socket is not proof. - Under the host, the runner is pid 1 of its own pid namespace (`--unshare-pid --as-pid-1`), so its exit ends anything a turn left outside its process group. **This is a build check**, tested against bwrap, not assumed. - With no run, the proof is that the launcher takes `lockSessionWorkspace` for the session and the registry has no run for it. - If no `drained` arrives (a turn still running, or a stuck child), the session stays `clearing`. Capacity stays reserved, the successor stays unstarted, the status stays actionable, and no signal is sent. - Thirty seconds after the SIGTERM with no proof, `uncertain` is appended once. The launcher keeps waiting and sends no second signal. A launcher restart finds the run among its row 269 survivors and waits on its identity. 4. **Retire** (trusted op `sessionClearRetire {business, op}`), one transaction: - Check the exit proof (the run is in `#ended`), or that there was no run. - Append `retired`, and end the old claim. - Insert the successor `sessions` row with the bound id and name, `predecessor` set to the old id and `op` set to the clear op. - Add a `session_defaults` row only if the old session was the default. - Add a `thread_opens` row for the successor. The old workspace stays at its path with no new writer mount. The successor gets its own store, HOME and workspace from row 269's per-session layout, and a fresh checkout of the approved revision. Nothing is copied. 5. **Start** (D8). `threadPass` starts the successor from its `thread_opens` row through the same `check()`/`start()` as any session. The launcher reports the outcome with `sessionClearStart {business, op, start, refusal}`, which appends `start-held`, `start-started` or `start-refused`, each with the next report counter. A held start keeps its op and spends no work attempt. `failed`: a refusal at retire (for example, the owner lost the grant) appends `failed` with the code. Nothing is retired, and the old session goes back to `active` with its messages still cancelled. **Cancel** (sage-2828, re-ruling sage-2826 Q3, D7:263-264). `session.cancel` checks the owner or write grant under the fence and appends `cancelled`, attempt 0, in one transaction: - Before `retired` it releases the fence: - If no `drained` was recorded, the drain is still waiting. Nothing is signalled, the clear ends, and the run's next receive delivers again. - If `drained` was recorded with the claim still held and no SIGTERM sent, the launcher skips the stop because the op is no longer open. The run keeps its claim, and its next receive delivers again. - If the SIGTERM was already sent or the run has exited, the old session is `active` with no runner. The next send launches it through D8 like any idle session. - The successor row is never inserted, and the cancelled messages stay cancelled. - The launcher reads the op state right before its SIGTERM, in the same tick as its start-time check. A cancel after that point still finds a stopping runner, which takes the "active with no runner" path above. This is a residual: a cancel can't take back a SIGTERM already sent. - A pre-retire cancel doesn't touch the claim (duckworth-0171 C2): - A run that still holds it keeps it, generation and all. Releasing the fence only stops its receives answering `[]`. - A run already stopped ended its own claim through endRun, so there's nothing to change. - Messages already cancelled keep their `message_cancels` rows and are never delivered. - After `retired` and before `start-started`, no further start attempts are made. - The successor row and the default move stay. Nothing un-retires the old session. - threadPass doesn't start the successor from the clear's `thread_opens` row: a cancelled op suppresses that open. - **My reading, stated for Sage:** the successor is then an ordinary idle session. A later new send to it, including a thread-only send while it's the default, launches it through D8 like any idle session. That's new input, not the clear's startup. The other reading leaves the thread's default pointing at a session that can never start, because there is no verb to move a default (the session verbs are list and create). - The race with the drained receive: `drained` and `cancelled` are each one synchronous transaction on the open op. Whichever commits second sees the first. A receive after the cancel finds no open op and delivers normally. A cancel after `drained` takes the "drained, no SIGTERM yet" path above. - After `start-started` it refuses `session-clear-complete`. ### Early look d2 folds (duckworth-0170, 98dc69f0c) - **B1, messages routed by `#sessionFor` (broker.mjs:722-748).** A role reply chain or a spec notice can resolve to a clearing or retired session with no state check. Rule: **cancel with a receipt at arrival.** The message is inserted (old results stay as evidence, D7 step 4) and its `message_cancels` row goes in the same transaction, so it is never delivered and never pending. The send's answer gains `cancelled: "session-clearing"` or `"session-retired"`, an additive key. - Refusing was the other option. It would lose a teammate's RESULT outright: the runner only logs a failed reply (harness/src/runner.mjs:575-580). - Routing to the successor would replay old input, which D7 forbids. - A spec notice resolves through `#defaultSession`. During a clear of the default session that is still the old session, so the notice is cancelled. After retirement the default is the successor, and a new notice is new input that goes there. - `threadsState` reports `retired`, and `threadPass` starts neither a `clearing` nor a `retired` session. - **B2, a claim racing a no-holder clear.** role.claim's thread path (broker.mjs:4827-4845) refuses `session-clearing` for a session with an open op and `session-retired` for a retired one. The runner gets refused and exits 21. Retire rechecks in its own transaction that no `claim` is current for the session, unless it's the proven-ended run's. The no-run proof also takes `lockSessionWorkspace`, which a spawned run holds before it claims. - **B3, op namespace.** `sessions` has `UNIQUE(thread, op)` (schema.sql:263), and the successor takes the clear op. Both directions refuse `session-op-conflict` at accept, before any stop: - `session.clear` refuses an op already in `sessions` or `session_ops` for the thread. - `session.create` (broker.mjs:5859-5872) refuses an op already in `session_ops`, instead of answering the successor row. - **B4** is in the schema step below (the `attempt` key). - **N2, name cap.** `name()` caps names at 200 characters (broker.mjs:323). When `-N` would be longer than that, session.clear refuses `session-name-too-long` at accept and writes nothing (sage-2828). Test: a 199-character name clears to a 201-character `-1` and is refused, and a 198-character name clears. - **N3, batch.** Drain waits for the runner's whole receive batch. Messages already delivered are a turn in progress, and the runner runs each of them before its next receive (:543-590). Only undelivered messages are cancelled. This is a residual: a batch of k delivered messages delays the drain by k turns. - **N4, D7 step 1 rechecks.** Retire rechecks owner, write grant and halt in its transaction, and refuses to `failed` with the code. External effects: the old run's endRun record is the stop evidence the op joins (its run and generation are on `drained`). No other effect is reconciled in 271. - **N5, claim end.** Retire ends the old claim only if that run's `claim` is still the latest `thread_claims` row for the session. If endRun already ended it, nothing is written twice. Test: retire after endRun, with exactly one end row. - **N6, pid 1.** `--as-pid-1` is in `NAMESPACES` (seat/src/sandbox.mjs:68), and `sandboxArgv` uses it (:168) for the runner command that session.mjs:302 builds. At build I'll confirm that thread runs reach session.mjs:302. Teardown on exit is kernel behaviour, and the test stays. - **N7**: Duckworth checks the mos-1315 list line by line at the code round. - **N8**: row 206 is approved at 3l→3m, so if it lands first, 271 is 3m→3n (as mos-1315 item 4 says). Cancel tests (sage-2828, duckworth-0171 C2): - before retirement (no drained yet; and drained with the claim held) - a cancel racing the drained receive, each order - a cancel after `start-held`: no further report, and threadPass doesn't start the successor - held, held, then started on one op gives three start rows with reports 1-3 and one work attempt (C1) - after retirement, with no further start - an idempotent repeat - a changed payload refused - after completion: `session-clear-complete` - an agent caller refused Mutant: drop the fence release in cancel's pre-retire path. The kill is the pre-retire test's next send/receive. Tests gained: B1 a cancel at arrival (reply chain and spec notice) for clearing and retired; B2 a claim refused while clearing, and retire refusing a live claim; B3 both op directions; B4 a second `start-held` appended; N2; N5. duckworth-0171 N1: the `cancelled` key on message.send is additive. At build I'll grep the whole-answer `deepEqual` pins on message.send in `packages/*/tests/*`. A pin breaks only where a test's routed message lands on a clearing or retired session, which no test does before 271. Any hit goes into the mos-1315 list first. ### Schema step (label by landing order, mos-1315 item 4) - `session_ops(seq, at, business, thread, session, op, stage, attempt, successor, successor_name, run, generation, digest, refusal)`: - append-only, with `UNIQUE(thread, op, stage, attempt)` and a CHECK on `stage` - the stages are `requested`, `drained`, `uncertain`, `exited`, `retired`, `failed`, `cancelled`, `start-held`, `start-started` and `start-refused` - `attempt` is 0 for every stage up to `retired`, and for `failed` and `cancelled`. For the three start stages it is a report counter: 1 plus the number of start rows the op already has. Every `sessionClearStart` report bumps it, a held report included. This keys D7's retryable start (duckworth-0170 B4, duckworth-0171 C1). - The launcher's work attempt goes in its own `work_attempt` column, null outside the start stages. A held start spends none (D8, t1-sessions.md:274), so held, held, started share one work attempt and take reports 1, 2 and 3. - The stage CHECK pins `attempt = 0` for every non-start stage, `cancelled` included. - `session_ops_one_open` treats an op as open while its latest stage is `requested`, `drained`, `uncertain` or `exited`. `retired`, `failed` and `cancelled` close it, so a new clear on the same session can follow a cancelled or failed one. A cancelled op stays in the namespace (B3). - a trigger refuses a second open op per session - the envelope comes from the latest stage, and `revision` is the count of this session's rows - `message_cancels(seq, at, message UNIQUE, session, op)`, append-only. - `threadRows`'s `pending` gains `AND NOT EXISTS (cancel)`, and each row gains `state`. - Label: 3m if 271 lands before row 206. If 206 lands first (3l to 3m at Launchpad's 631a256ba), 271 rebases on it and is 3m to 3n. Sage sets the order. ### Pre-existing test changes (mos-1315) These are allowed: - the current schema label - the fabricated future step in comment-migration.test.mjs, moved up one label - the migration file lists, which gain the new step's file No assertion is removed or loosened, and no other expected value changes. This list was measured on 2d0d407ba with `git grep` for `'3l'`, `3k-3l` and `'3m'` in `packages/*/tests/*`. - Current label (`'3l'` becomes the new label): - account-migration.test.mjs:94, :144, :163, plus the rewrite source :156 - chief-of-staff.test.mjs:641, plus the rewrite source :617 - comment-migration.test.mjs:36 - gitea-migration.test.mjs:98, :124, :142, plus the rewrite source :135 - grant-migration.test.mjs:103, :128, :146, plus the rewrite source :139 - name-migration.test.mjs:68 - sessions.test.mjs:377, :450, plus the rewrite source :433 - spec-migration.test.mjs:66 - thread-migration.test.mjs:104, plus the rewrite source :52 - usage-migration.test.mjs:71 - A rewrite source is the `.replace("export const SCHEMA_VERSION = '3l';", ...)` string. It has to match store.mjs:17's new text, or the rewrite silently does nothing. - Fabricated future step: comment-migration.test.mjs:78-79, :86 (`3l`→`3m` becomes one label up). - Migration file lists: - comment-migration.test.mjs:82 - the `copyFileSync` lists that copy `3k-3l.sql`: account-migration.test.mjs:86, gitea-migration.test.mjs:76, grant-migration.test.mjs:79 - plus each one's sibling copy loop, read whole at the edit - sessions.test.mjs:435 asserts which step names the edited source holds. If its expected list needs the new step's file, that falls under the migration-file-list allowance. I'll read it whole at the edit. - **Restore lists (mos-1318 item 1).** Both new tables have an `at` column, so both join `COUNTED` in scripts/stack-release.sh:420. That is product scope for 271: a rollback with `--restore` counts their rows written after the backup and refuses on them, as it does for `sessions` and `thread_opens`. T46 (stack-release.test.mjs:913) then passes unchanged. Neither table goes in `untimed`, and nothing is removed from any list. - `session_ops`: COUNTED. Every write is a clear stage, and a restore that dropped one uncounted could leave a retired session's successor without its journal. - `message_cancels`: COUNTED. A dropped cancel would put a cancelled message back in `pending`. - T45's `LISTED` (stack-release.test.mjs:858) is a hand copy of COUNTED, so that a table dropped from the script fails T45. **Both names are added** (mos-1319). Mos counts this as the stricter change mos-1285 allows, because T45's loop then checks each new table on its own. The reviewer checks that LISTED matches COUNTED name for name: 15 names after 271, where there were 13. On this base COUNTED is at stack-release.sh:420. Mos measured it at :418 on aca16404c. - **Count pins (mos-1318 item 2).** schema.test.mjs:525-527 (table, trigger and view counts): - tables 22 to 24: `session_ops`, `message_cancels` - triggers 86 to 93: - `session_ops_no_update`, `session_ops_no_delete`, `session_ops_no_replace` - `session_ops_one_open` (refuses a second open op on a session) - `message_cancels_no_update`, `message_cancels_no_delete`, `message_cancels_no_replace` - views: 5, unchanged - The comment block above the pins gains a row 271 line, as rows 124-264a have. - If the build adds or drops an object, this list changes first and the reviewer checks it. - If row 206 (631a256ba) lands first, 271 re-adds its own objects on top of 206's counts. - Any other count pin found at build goes here before it's edited. - After the move, grep `packages/*/tests` for the old label. Only history fixtures and migration chains may still name it. The result is recorded here (mos-1315 item 3). - **TESTER_VERBS pin (mos-1325 item 1).** tester-writes.test.mjs:28-29 gains one comment line and the three names after 264a's `session.list`, `session.create`. The deepEqual stays exact. - **Engines that load the whole current store** need the new step's file. That's the migration-file-list allowance. account-, gitea- and grant-migration.test.mjs each gain a `copyFileSync` of `3l-3m.sql` after their `3k-3l.sql` copy, for their `engine-failing`, which keeps every step. The engines that truncate the step list (engine-3h, -3j, -3i, -3c, -3d, -3k) need nothing. - Applied on gizmoduck/row271 (edit script gizmo-271-edits/mos1315.py). The grep for `'3l'` in `packages/*/tests/*` afterwards has no hit. `3k-3l` stays in the migration copy lists and in sessions.test.mjs:435's "no 3k→3l step" assertion, both history. - Comment-only mentions (`3k-3l migration` in end-launch, message-filter, thread-claims, threads, session-launch, thread-cli, thread-launch, chat, session-compactions) are history and stay. ### Tests (T4, to write) - clear on a held session: - accept fences the session - the run's next receive appends `drained` and answers `[]` - SIGTERM, then exit proof, `retired`, the successor row and the default move - a landing-turn run (no receive yet) is never drained or signalled - a run whose turn is running (delivered, unread) stays `clearing` with no signal, past 30 s - a non-default session clears with the default unchanged - `-N` collision: a user session already named `-1` gives `-2` - repeat with the same payload gives the same envelope, id and name - conflict: a changed payload refuses `session-op-conflict` - a second op on a clearing session refuses `session-clearing` - stale revision, halted processing, retired session - undelivered messages are cancelled with receipts, not delivered to the successor - sends to a clearing session refuse - `uncertain` after 30 s with no proof, and no second signal - the successor start is held, started or refused, through check()/start() - the pid-namespace build check ## Build round 1 (code on gizmoduck/row271, not run: the slot was held) Where the code departs from the design above. Each one is for Duckworth at the code round. - **No `state` key in `threadsState`.** end-launch.test.mjs:193 pins a whole threadsState row, and a new key would be an old-test change. Instead: - `threadRows` drops retired sessions (broker.mjs `#threadRows`), and a cancelled message isn't `pending`. - A new trusted op, `sessionClears {business}` (broker.mjs:988), lists each open or starting clear: thread, instance, session, op, stage, run, generation, successor, refusal. The launcher reads it once per tick in `clearPass` (launcher.mjs:742), before its threads loop. Any session a listed clear owns (the old session while stopping, the successor while starting) is skipped by the normal start and idle code, so threadPass never starts or idles it. - **No `thread_opens` row at retire.** The successor's start comes from `sessionClears` (stages `retired`, `start-held`, `start-refused`), not from an open. A cancelled op isn't listed, so nothing starts its successor. A later new send gives the successor a `pending` message, and threadPass starts it like any idle session (sage-2830's reading). - **The run's end is a drain too.** `endLaunch` appends `drained` (run, generation) when the ending run's session has a `requested` op. A run that exits before its next receive needs no signal. - **Exit proof.** `sessionClearExited {business, thread, op, run}` refuses `run-not-ended` unless the run has ended. With run null, the launcher first takes and releases the session's workspace lock. A busy lock (a spawning run) throws and the clear waits a tick. - Side effect: `sessionWorkspace` creates the session's workspace directory, so a session that never ran gets an empty one at its clear. - **Restart.** `recover()` SIGKILLs an earlier host's runs and ends them `host-lost`. A run that outlived the kill is a row 269 survivor and is ended too. `clearStop` waits while a survivor with the clear's run is live (`liveRun`), and sends it nothing. When the old run is gone, the ended run is the proof. - `clearStops` is in memory, so a restarted launcher has no stop times. That's safe because recover() has already killed or listed every run from the earlier host, so none is in the new registry to signal. An `uncertain` op it finds is never signalled. - **Halt at retire waits.** `sessionClearRetire` throws `processing-halted` while processing is halted, and the op stays `exited` until it lifts. Only an owner or grant refusal appends `failed`. - **Held codes.** A successor start refused `capacity-full` or `limit-reached` reports `held`. Every other refusal reports `refused`, and any non-Refusal throw reports `launch-failed`. Each code is logged and reported once per op, not once per tick. - **Names stay bound.** `#nameTaken` counts any op's `successor_name`, a cancelled or failed one included. So a session.create of `-1` after a cancelled clear refuses `session-exists`, and the next clear takes `-2`. That's stricter than needed. Freeing a name would let a repeat of the cancelled op's envelope name a session that isn't its successor. - **Cancel after `failed`** refuses `session-clear-complete`, like cancel after `start-started`. - **message.send** answers `cancelled: "session-clearing"` or `"session-retired"` only when its route lands on such a session (B1). N1 grep of whole-answer pins is still to do. ### Tests added to T4 (sage-2830) - After a post-retire cancel, a thread-only send to the thread (the successor is the default) launches the successor through D8 as new input. The clear's latest row stays `cancelled`, and no `start-*` row is appended. - After a restart, a live survivor of the cleared run holds the clear at `drained` with no signal. Its exit lets the clear retire. ### Tester grant (mos-1325) The brief's section 3 says tester writes aren't granted by default and use fixtures only. 271 grants the tester `session.clear`, `session.cancel` and `session.operation` because the Q173 live check has to clear a tester thread through the tester socket. Without the grant, D7 can't be proved live by an agent tester. - The three verbs act only on the tester's own threads. Each resolves its thread with `#thread` (broker.mjs:871-877), which refuses `thread-not-owned` unless the thread's owner is the caller's `#owner`, and a tester's owner is `tester` (:879-880). So a tester can't reach a human's or a user's thread. - Test: a tester's `session.clear`, `session.cancel` and `session.operation` on a thread a human owns each refuse `thread-not-owned` and write nothing (no `session_ops` or `message_cancels` row). - The R3 user gate refuses all three for a user (user-principal.test.mjs:133 spreads TESTER_VERBS). Those refusals are this row's own checks (mos-1325 item 3). ### N1 and N6 at build - **N1.** message.send adds `cancelled` only when `#route` returns a code (broker.mjs:5505, `if (cancelled) return { id: mid, request, cancelled }`). Every other send answers exactly what it did before. `#route` returns a code only for a session with an open or retired clear, and before 271 no test can make one. So no whole-answer pin on message.send can change, and nothing joins the mos-1315 list. - **N6.** The launcher's only spawn is `spawnSession` at launcher.mjs:574, inside `start()` (:422), which runs every session, thread runs included. spawnSession (seat/src/session.mjs:300-302) builds the runner argv through `sandboxArgv`, whose argv starts with `NAMESPACES` (sandbox.mjs:168), and `NAMESPACES` holds `--unshare-pid` and `--as-pid-1` (:68). The bwrap test stays in T4. ### Tests written (round 1, not run: the slot was held) New files only. No old test changes beyond the mos-1315 list above. - `packages/bus/tests/session-clear.test.mjs` (15 tests), the bus half: - held default: accept fences send, open and claim; the receive drains once; no proof before the run's end; retire, successor row, default move; threadsState drops the retired session - unheld session: drained at accept, proof with run null; a non-default clear keeps the default - a run that ends before its next receive drains at its end, with its generation - delivered and unread: stays `clearing`, `not-drained` for uncertain and proof; uncertain once; later proof resumes - B1: undelivered inputs cancelled at accept with receipts; a routed reply answers `cancelled` `session-clearing`, later `session-retired`; the successor gets none - repeat, conflict, second op, stale revision, confirm, halt (writes nothing), B3 both ways - Q4 `-N` with a taken `default-1`, and a cancelled op's name staying bound - D8 start counters: held, refused, held, started, with attempt and work_attempt; complete after - halt at retirement waits and writes nothing - cancel before retirement (sage-2828); cancel after retirement and the thread-only send to the successor (sage-2830); cancel after started - B2 claim refusal and the claim after a cancel - mos-1325 tester: own thread clears; clear, cancel and operation on a human thread refuse `thread-not-owned` and write nothing; agents refuse `human-required` - append-only triggers, `session_ops_one_open`, CHECKs and UNIQUEs below the broker - `packages/cli/tests/session-clear.test.mjs` (3 tests, bwrap), the launcher half, with `fixtures/clear-adapter.mjs` (session-adapter's turn plus /proc pids and pid 1's argv): - a running session: one `session-clear-stop`, end, retired, successor launch in that order; the successor starts fresh in its own workspace; N6 pid namespace check on the first turn - held: capacity-full reported `held` and logged once, started when the side session idles out - restart (sage-2830, Q2): a live survivor holds the clear at `drained` with no signal; its exit lets the clear retire and start Open, still to write before the code round: - A `failed` clear (N4: a user's thread whose grant is revoked before retirement) and cancel after `failed` refusing `session-clear-complete`. - The launcher's `uncertain` path (a run that ignores SIGTERM past `stopTimeoutMs`). It needs an adapter that traps TERM, and a short stopTimeoutMs. - Mutants, at the run: the fence (`#sessionFence` in role.claim, thread.send, thread.open), the drain at receive, the survivor wait in clearStop, CLEAR_HELD, and the `cancelled` filter in `#threadRows`. ### N4 failed clear (round 1, not run) Test "T4 N4: a user's thread whose grant is gone by retirement..." in packages/bus/tests/session-clear.test.mjs. Finding for Duckworth: the `failed` path in sessionClearRetire (broker.mjs:1137, #ownerRefusal at broker.mjs:1191) has no verb route in 271. A user can't call session.clear (USER_VERBS, broker.mjs:138, gate at broker.mjs:4452 gives access-refused), and the human or tester gets thread-not-owned on a user's thread. Tester and human threads always pass #ownerRefusal while the business names them. So the test writes the op's requested and drained rows directly, as the accept would, then runs exited and retire with the grant removed. The path becomes reachable when a user verb for clear lands (264e or later). Question for review: keep the path as a guard, or drop it until then. ### Launcher uncertain path (round 1, not run) Test "T4 D7: a drained run that outlives the stop timeout..." in packages/cli/tests/session-clear.test.mjs. The test drives the ticks (tickMs one hour, stopTimeoutMs 1000) and SIGSTOPs the runner after the drain and before the tick that sends the TERM, so the TERM stays pending. It checks one stop, one uncertain, no force, then SIGCONT and retired plus started. Open: mutants (fence in claim/send/open, drain at receive, survivor wait, CLEAR_HELD, the cancelled filter in #threadRows, the uncertain once-only branch at launcher.mjs:788). ### Mutants (round 1, defined, not run) Each mutant is one edit on the 271 tree, and each kill is an assertion failure in a named test, not a timeout (sage-2707). Lines are on gizmoduck/row271-r2 at e567f949c, after the rebase onto 0cbe2e9b3 and the B1 fix, which put one comment line above broker.mjs:1194 (launcher.mjs didn't move). | # | File:line | Edit | Expected kill | | --- | --- | --- | --- | | M1 | broker.mjs:5158 | drop `#sessionFence(s.session)` in role.claim | bus T4 B2 (claim on a clearing session gives session-clearing) | | M2 | broker.mjs:6146 | drop the fence in thread.send | bus T4 D7 held default (send during the clear refuses session-clearing) | | M3 | broker.mjs:6134 | drop the fence in thread.open | bus T4 D7 held default (open refuses) | | M4 | broker.mjs:6287 | drop the fence in session.clear | bus T4 D7 repeat/conflict: a second op refuses session-clearing. The session_ops trigger (one open clear) is a second fence and would refuse with its own text, so the kill is the code check, not "refused at all" (mutant-masked-by-second-fence) | | M5 | broker.mjs:5599 | receive returns [] without writing drained | bus T4 D7 held default (stages after receive) | | M6 | broker.mjs:1319 | drop the drain at endLaunch | bus T4 D7 run ends before its next receive | | M7 | broker.mjs:6298 | always skip the drain at accept | bus T4 D7 unheld session | | M8 | launcher.mjs:798 | drop the survivor wait | cli T4 D7 Q2 survivor (stage stays drained, no stop) | | M9 | launcher.mjs:815 | CLEAR_HELD empty | cli T4 D8 held (start held, one log line) | | M10 | broker.mjs:1209 | drop the message_cancels filter in #threadRows | cli T4 D7 running session (cancelled input would launch D again) or bus B1; the run decides which, both are named | | M11 | launcher.mjs:788 | `stage === "drained"` becomes `true` | cli T4 D7 uncertain (one uncertain event); bus T4 D7 delivered-unread keeps its broker-side once | | M12 | broker.mjs:1137 | `#ownerRefusal` returns null | bus T4 N4 failed clear | | M13 | broker.mjs:1195 | drop the `'session.clear'` verb arg from `#ownerRefusal`'s `#granted` call (the r2 defect, duckworth-0201 B1) | bus T4 N4 live grant (refusal access-refused, state failed) | duckworth-0201 B1 (early look on 911b90903): `#ownerRefusal` called `#granted(s, project, instance)` against the signature `#granted(s, verb, project, instance)` at broker.mjs:919, so the project sat in the verb slot and the instance in the project slot, and a user's thread with a live grant failed retirement with access-refused. The N4 test revoked the grant first, so it could not tell the two apart. The call now passes `'session.clear'`, which is not in VIEWER_VERBS, so the recheck is at collaborator. The N4 setup is a shared helper, the revoked case is unchanged, and a second test keeps the grant and expects retired plus the successor. M13 restores the defect. Both N4 tests are new 271 tests, so mos-1292 doesn't apply. M10's kill is a guess until it runs; if neither test fails, I add a pending-count assertion and report it as a first-run change under mos-1272. ## Rebase onto row 206: schema label 3m to 3n (sage-2968) Row 206 landed 0cbe2e9b3 on refactor at 19:47Z with schema step 3l to 3m (thread_shares), and origin/gizmoduck/row271 at 74b5e3638 also set 3m (Mos found the collision in mos-1335). The branch is rebased on 0cbe2e9b3 as gizmoduck/row271-r2, a new name, so nothing is force-pushed. The rebase took `git rebase --onto 0cbe2e9b3 2d0d407ba`, since the 269 commits under 271 landed as 269 r3. Row 271 becomes step 3m to 3n: - `packages/bus/src/migrations/3m-3n.sql` is the old 271 `3l-3m.sql`, byte for byte. `3l-3m.sql` is 206's. - store.mjs: `SCHEMA_VERSION = '3n'`, the 3m to 3n step after 206's, `session_ops` and `message_cancels` stay last in `timedTables`, and `SCHEMA_SHA256` is the sha256 of the new schema.sql (a2ba3a46...). - schema.sql is refactor's with the 271 objects appended after 206's, unchanged. - schema.test.mjs:526-529: 25 tables and 96 triggers, 206's 23 and 89 plus 2 and 7. `grep -c` in schema.sql gives 25 `CREATE TABLE` and 97 `CREATE TRIGGER`; at 0cbe2e9b3 it gives 23 and 90 against the pinned 89, so the extra hit is in the base file and the step adds 7. - `packages/bus/tests/fixtures/schema-3m.sql` equals refactor's schema.sql at 0cbe2e9b3 (sha256 d5ca9bb9...). - stack-release.sh:420 COUNTED and stack-release.test.mjs:861 LISTED: both tables after 206's `thread_shares`, at the end of the list as before. New tests in packages/bus/tests/session-clear.test.mjs, written like 206's in thread-shares.test.mjs: - "T4 schema: a 3m store migrates to 3n": a 3m store from the fixture, with a row in each thread table and a share, migrates. Both tables are new and empty, every other row stays, the migrated sqlite_master equals a fresh store's, both tables refuse update, delete and replace, the one-open trigger refuses a second open clear, and a cancel naming no message refuses on its foreign key. - "T4 schema: the 3m engine refuses a 3n store": the 3m engine refuses with and without migrateSchema and leaves the store as it was, and it opens a fresh 3m store. ### Pre-existing test changes on the rebase The mos-1315 kinds, one label up: - Current label `'3m'` becomes `'3n'`: account-migration.test.mjs:98, :148, :167 and rewrite source :160; gitea-migration.test.mjs:102, :128, :146 and :139; grant-migration.test.mjs:107, :132, :150 and :143; chief-of-staff.test.mjs:641 and :617; comment-migration.test.mjs:36; name-migration :68; spec-migration :66; usage-migration :71; thread-migration :104 and :52; sessions.test.mjs:377, :450 and :433; thread-shares.test.mjs:406, :459 and the engine3l rewrite source :442. - The offline command's ready line, `bus schema 3m ready` becomes `3n`: comment-migration.test.mjs:112, thread-migration.test.mjs:164. My first build missed these two (`3l ready` then), so the first run would have shown them red. - The future step in comment-migration.test.mjs:78-79 moves to 3n to 3o, :86 expects `'3o'`, and the :82 file list gains `3m-3n.sql`. - Engine copies: account- :90, gitea- :80 and grant-migration.test.mjs :83 copy `3m-3n.sql` after 206's `3l-3m.sql`. Changes outside the mos-1315 list, the same kind 206 made for thread_shares. Mos approved all three under mos-1292 in mos-1336 (19:58Z). Conditions: neither name list grows past these two names without Mos, and a gate red in these files outside the listed lines goes to Mos: - `later3l` in account- :21-22, gitea- :38-39 and grant-migration.test.mjs :24-25 gains `session_ops` and `message_cancels`, with a comment line. These tests compare sqlite_master before and after older steps, and the 3m to 3n objects are new there just as thread_shares is. My first build missed this too. - `rows()` in gitea- :43 and grant-migration.test.mjs :45 leaves out both tables, as it leaves out thread_shares. - thread-shares.test.mjs:410-414: 206's 3l migration test now ends at 3n, so after it asserts thread_shares empty it asserts session_ops and message_cancels empty and drops them before the whole-table compare. Nothing it checked is removed. A grep for `'3m'` in these files afterwards has no hit. session-clear.test.mjs's 3m engine is new. This is a rebase, so the next run reruns every touched file whole. ## Run limits (mos-1275, sage-2991) The gate on e567f949c (sage-2991). Each node --test file and suite runs under `timeout -k 10 LIMIT`, and each scripts/test-*.sh under at least 3x its longest recorded run. The records are from agents/gizmoduck/work/queue-269/BUILD.md "Run limits", plus row 269's r3 gate. | Run | Longest recorded | LIMIT | | --- | --- | --- | | stack-release.test alone | 575 s (264a r12 survey) | 1800 s | | each other touched file alone | 15 s (thread-launch.test) | 300 s | | bus suite | 23 s | 900 s | | seat suite | none recorded | 600 s | | cli suite | 591 s (row 281 gate) | 1800 s | | each scripts/test-*.sh | 42 s (test-foundation) | 900 s | | each mutant run (one session-clear file whole) | none recorded | 300 s | A timeout with no failed test is a hang and goes to Sage. A timeout in the shell phase goes to Mos unplaced (mos-1275). A first red in a new 271 test is placed and reported after (mos-1272). A second red, a red in an old test outside the mos-1336 lines, or a fix that needs a product change outside the manifest or a looser assertion stops the run and goes to Mos and Sage (sage-2991). Mutants run only on a green base. ## Gate on e8af389e6 (sage-2991) The code on e8af389e6 is the same as on e567f949c. The only difference is the BUILD.md "Run limits" section. The run went from 20:31:19Z to 20:56:32Z (1513 s wall), on base 0cbe2e9b3 with origin/refactor at f2735bb6a, whose drift touches only docs/plans/QUEUE.md and queue.json. The logs are in /mnt/storage/scratch/gizmo-271-out/gate/. - The 18 touched test files, each run whole, all pass with 0 fail and 0 skip. Among them, bus session-clear 19/19, cli session-clear 4/4, stack-release.test 218/218, thread-shares 16/16 and seat compactions 20/20. Phase wall 596 s. - Suites: bus 873/873 (24 s), seat 192/192 (6 s), cli 873/873 (588 s). - scripts/test-*.sh: all 10 pass. auth 15, conductor 17, config 31, discord 81, extension-package 18, foundation 44, land-work 86, queue 156 node tests plus 28, release 14, task 91. The skips are test-queue's verify/render --check (this worktree isn't the canonical root) and test-task's live provider cases (row 244). test-task has no Docker skip. - The tree was clean after the tests and after each mutant restore. No node process was left with its cwd in the worktree. - First-run reds: none, so nothing was placed under mos-1272. Each mutant ran with bus and cli session-clear.test whole. Every kill is an AssertionError in the test this section named, never a timeout: | # | Killed by | | --- | --- | | M1 | bus T4 B2 and T4 D7 held default | | M2 | bus T4 D7 held default and T4 D7 delivered-unread | | M3 | bus T4 D7 held default | | M4 | bus T4 D7 repeat/conflict: `storage-refused` where `session-clearing` was expected, so the code check failed and the trigger was the only fence left. Also T4 D7 held default (`session-retired` expected, got null) | | M5 | bus T4 D7 held default and delivered-unread; cli T4 D7 uncertain, D7 D8 N6 running session, and D7 Q2 survivor | | M6 | bus T4 D7 run ends before its next receive | | M7 | bus T4 D7 unheld session (stages `['requested']` where `['requested','drained']` was expected), and 7 later bus tests, 5 of them on `not-drained` or `session-revision-stale` refusals that follow from the missing drain; cli T4 D8 capacity-full | | M8 | cli T4 D7 Q2 survivor | | M9 | cli T4 D8 capacity-full held | | M10 | bus T4 D7 B1 (cancelled inputs never reach the successor). The cli running-session test passes, so no pending-count assertion was added | | M11 | cli T4 D7 uncertain | | M12 | bus T4 N4 grant gone | | M13 | bus T4 N4 grant live | Open: the row's gate names tests T4 and T6 (queue row 271). This build maps T4. Of T6, these are covered: clear under a kill or restart (cli D7 Q2 survivor, D7 uncertain), grant revocation (bus N4), limits (cli D8 capacity-full held) and a halt at retirement (bus D7 N4 halt). These are not: a Q198 hold, a halt between reservation, spawn and bind, and a manager configured but unavailable. That goes to Sage before the in-review move. ## Round 3: T6 (sage-3001, duckworth-0209, duckworth-0210) Sage ruled the three T6 items left open above. Written with no runs; the r3 gate runs on a CLEAR. - Halt between reservation, spawn and bind: covered by citation. session-launch.test.mjs:372 (the halt fence, C2) halts once after check() and once inside its recheck, and neither attempt spawns. A successor goes through the same path: clearStart at launcher.mjs:824 calls `check()` and `start()` unchanged, so the fence that test pins is the successor's fence too. - Q198 hold: binds row 257 (Morgana). Row 271 has no Q198 code to test: 257 hasn't landed, and packages/*/src holds no pressure or Q198 code on this branch or on origin/refactor f2735bb6a. Carry for 257 (duckworth-0210): 257 either puts its hold code in CLEAR_HELD (launcher.mjs:815) or rules that a Q198 hold counts as a refusal, and either way it adds a clear-path test. - Manager configured but unavailable: a new cli test, "T4 T6: a bus unavailable at the exit proof and then at retirement" (packages/cli/tests/session-clear.test.mjs). The test's host op rejects with `broker-exited`, the code host.mjs:303 gives when the broker exits before it replies. It is down first at `sessionClearExited`, then only at `sessionClearRetire`. While it is down the test expects no retired or failed event, no launch but D's, nothing in the session registry (the successor takes no slot) and D still the thread's only and default session. The stage holds at drained, then at exited. The failure is logged once in all, and no `thread tick:` line appears. Once the bus answers, the clear retires and the successor starts, in the same event order as the running-session test. Reading of "capacity release": the launcher counts capacity from its registry of running sessions (`limitHit`, launcher.mjs:403). D's run really ends, so its slot is free on the host either way; what an unproven stop must not do is hand that slot to the successor. The test pins that. | # | Where | Mutant | Killed by | | --- | --- | --- | --- | | M14 | launcher.mjs:755-759 | drop clearPass's per-clear try/catch, so a failed op leaves the pass and tick() | cli T4 T6 bus unavailable: the first `lines()` assertion. The clear's line is never logged; the ticker's catch logs `thread tick:` instead | The harness the gate applies, M1-M14 with their exact anchors, is committed beside this file as mutants.py (duckworth-0212 C1). `check` counts each anchor; `apply ` edits the worktree. r3 gate, on a CLEAR (sage-3001): the touched files run whole, the cli suite runs whole, and M14. No src file changed in r3, so the full gate isn't due again. A first-run red in the new test is placed under mos-1272 and reported after; a fix that needs a product change goes to Sage and Mos before any edit. ## r3 gate on 9e18a9aaf (sage-3017) Run 21:16:37Z to 21:27:04Z (627 s wall), TMPDIR /mnt/storage/scratch/tmp/gJmI, limits as in "Run limits" above (300 s per file and per mutant run, 1800 s for the cli suite). origin/refactor was f2735bb6a. No src file differs from e567f949c, so the full gate wasn't due (sage-3001). | Run | Result | Wall | | --- | --- | --- | | cli session-clear.test whole | 5/5 pass | 12 s for both files | | bus session-clear.test whole (r3 touched no other test; the bus half of the clear) | 19/19 pass | | | cli suite whole | 874/874 pass, 0 skipped or todo | 585 s | | M14, bus session-clear.test | 19/19 pass (M14 is launcher-only) | 30 s for both | | M14, cli session-clear.test | 1 fail: the T6 test | | - First-run reds: none. The new T6 test passed on its first run, so nothing was placed under mos-1272. - M14 kill: an AssertionError at session-clear.test.mjs:365, the T6 test's first `lines()` assertion, as named above. The ticker's catch logged `thread tick: broker exited before it replied` once per tick and the clear's own line never appeared. - The tree was clean after the tests and after the M14 restore; launcher.mjs matched HEAD. No node process was left with its cwd in the worktree.