Files

51 KiB

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

{ "thread": "<thread id>", "session": "<session id>", "revision": 0, "confirm": true, "op": "<op key>" }
  • 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:

{ "op": "...", "thread": "...", "session": "<old id>", "successor": "<new id>",
  "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:

{ "thread": "<thread id>", "op": "<the clear's 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 <old name>-N, with N the next free number in that thread. If a user already made a session called <old>-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 <old>-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 <old>-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
  • <old>-N collision: a user session already named <old>-1 gives <old>-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 <old>-1 after a cancelled clear refuses session-exists, and the next clear takes <old>-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 <old>-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 <id> 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.