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'ssession.listrow (below). It names the state the human confirmed.confirm: the literaltrue. Anything else refusesconfirm-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": "..." }
successoris bound when the request is accepted, before any asynchronous work. It is a lowercase UUID,xxxxxxxx-xxxx-8xxx-Vxxx-xxxxxxxxxxxxwith V one of8,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, andrefusalnames why. The old session stays as it was.cancelled: a human cancelled the clear (session.cancel below, sage-2828).retiredis null if it was cancelled before retirement and true if after.startstays as it was.
startis null untilstateisretired. After that it isheld(waiting for capacity),started, orrefusedwithrefusalset.at: the time of the latest state change.
Repeats and conflicts:
- The same
opwith the same payload returns the current envelope and starts nothing new. - The same
opwith any other payload refusessession-op-conflict. - A new
opon a session with an open clear refusessession-clearing. - A
revisionthat isn't current refusessession-revision-stale. - Other refusals:
session-not-found,session-retired,processing-halted(D8: a halt refuses and changes nothing), and the owner refusalssession.createalready 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
activeagain.
- 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, refusessession-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,uncertainorretired. A cancelled clear leaves the sessionactiveif cancelled before retirement, andretiredif 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.
sessionsis append-only (schema.sql:370-372). The nearest per-session state is the latestthread_claimsrow (op and generation). Row 194's lifecycle is per instance, asaction.allowedevents keyed by a random request id (broker.mjs:622-666). - No cancelled state for an undelivered message.
deliveries.opis a CHECK list on an append-only table. - The runner calls
message.receiveonly when no harness child exists:runTurn's close handler SIGKILLs the turn's whole process group and clearschild(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, sounreadis 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 nextmessage.receiveorstate, so the turn's RESULT and read land first. - The runner's SIGTERM handler escalates to KILL after 5 s only when
childis set (runner.mjs:405-423). With no child it setsstopping, 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
closehandler, which runs endRun, removes the registry entry and releases the workspace lock (:600-641).
Clear, stage by stage
- Accept (
session.clear, the human's write). One transaction:- Check owner and grant (the
session.createpath), 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_PATTERNshape 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
requestedrow, so a repeat of the op returns the same id and name.
- 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
- Append journal stage
requested, with a payload digest for the repeat and conflict checks. - The answer is
clearing. From here:thread.sendandthread.opennaming the session refusesession-clearing. So does a thread-only send whose default is that session.threadsStatereports the session asclearing, sothreadPassnever 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.
- Check owner and grant (the
- 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
drainedwith 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
[].stateneeds no fence, because the runner asks it only for a message it received. A clear on a session with no holder appendsdrainedwith run null at accept.
- It appends
- Stop and proof (launcher). For a
drainedop 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
closehandler (endRun, registry remove, lock release), recorded withsessionClearExited {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
lockSessionWorkspacefor the session and the registry has no run for it. - If no
drainedarrives (a turn still running, or a stuck child), the session staysclearing. Capacity stays reserved, the successor stays unstarted, the status stays actionable, and no signal is sent. - Thirty seconds after the SIGTERM with no proof,
uncertainis 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.
- The exit proof is the run's own
- 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
sessionsrow with the bound id and name,predecessorset to the old id andopset to the clear op. - Add a
session_defaultsrow only if the old session was the default. - Add a
thread_opensrow 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.
- Check the exit proof (the run is in
- Start (D8).
threadPassstarts the successor from itsthread_opensrow through the samecheck()/start()as any session. The launcher reports the outcome withsessionClearStart {business, op, start, refusal}, which appendsstart-held,start-startedorstart-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
retiredit releases the fence:- If no
drainedwas recorded, the drain is still waiting. Nothing is signalled, the clear ends, and the run's next receive delivers again. - If
drainedwas 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
activewith 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.
- If no
- 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_cancelsrows and are never delivered.
- A run that still holds it keeps it, generation and all. Releasing the fence only stops its
receives answering
- After
retiredand beforestart-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_opensrow: 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:
drainedandcancelledare 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 afterdrainedtakes the "drained, no SIGTERM yet" path above. - After
start-startedit refusessession-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 itsmessage_cancelsrow goes in the same transaction, so it is never delivered and never pending. The send's answer gainscancelled: "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. threadsStatereportsretired, andthreadPassstarts neither aclearingnor aretiredsession.
- B2, a claim racing a no-holder clear. role.claim's thread path (broker.mjs:4827-4845)
refuses
session-clearingfor a session with an open op andsession-retiredfor a retired one. The runner gets refused and exits 21. Retire rechecks in its own transaction that noclaimis current for the session, unless it's the proven-ended run's. The no-run proof also takeslockSessionWorkspace, which a spawned run holds before it claims. - B3, op namespace.
sessionshasUNIQUE(thread, op)(schema.sql:263), and the successor takes the clear op. Both directions refusesession-op-conflictat accept, before any stop:session.clearrefuses an op already insessionsorsession_opsfor the thread.session.create(broker.mjs:5859-5872) refuses an op already insession_ops, instead of answering the successor row.
- B4 is in the schema step below (the
attemptkey). - N2, name cap.
name()caps names at 200 characters (broker.mjs:323). When<old>-Nwould be longer than that, session.clear refusessession-name-too-longat accept and writes nothing (sage-2828). Test: a 199-character name clears to a 201-character<old>-1and 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
failedwith the code. External effects: the old run's endRun record is the stop evidence the op joins (its run and generation are ondrained). No other effect is reconciled in 271. - N5, claim end. Retire ends the old claim only if that run's
claimis still the latestthread_claimsrow 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-1is inNAMESPACES(seat/src/sandbox.mjs:68), andsandboxArgvuses 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 onstage - the stages are
requested,drained,uncertain,exited,retired,failed,cancelled,start-held,start-startedandstart-refused attemptis 0 for every stage up toretired, and forfailedandcancelled. For the three start stages it is a report counter: 1 plus the number of start rows the op already has. EverysessionClearStartreport 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_attemptcolumn, 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 = 0for every non-start stage,cancelledincluded. session_ops_one_opentreats an op as open while its latest stage isrequested,drained,uncertainorexited.retired,failedandcancelledclose 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
revisionis the count of this session's rows
- append-only, with
message_cancels(seq, at, message UNIQUE, session, op), append-only.threadRows'spendinggainsAND NOT EXISTS (cancel), and each row gainsstate.- 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→3mbecomes one label up). - Migration file lists:
- comment-migration.test.mjs:82
- the
copyFileSynclists that copy3k-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
atcolumn, so both joinCOUNTEDin scripts/stack-release.sh:420. That is product scope for 271: a rollback with--restorecounts their rows written after the backup and refuses on them, as it does forsessionsandthread_opens. T46 (stack-release.test.mjs:913) then passes unchanged. Neither table goes inuntimed, 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 inpending.- 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 onaca16404c.
- 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_replacesession_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.
- tables 22 to 24:
- After the move, grep
packages/*/testsfor 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
copyFileSyncof3l-3m.sqlafter their3k-3l.sqlcopy, for theirengine-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'inpackages/*/tests/*afterwards has no hit.3k-3lstays in the migration copy lists and in sessions.test.mjs:435's "no 3k→3l step" assertion, both history. - Comment-only mentions (
3k-3l migrationin 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
drainedand 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
clearingwith no signal, past 30 s - a non-default session clears with the default unchanged
<old>-Ncollision: a user session already named<old>-1gives<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
uncertainafter 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
statekey inthreadsState. end-launch.test.mjs:193 pins a whole threadsState row, and a new key would be an old-test change. Instead:threadRowsdrops retired sessions (broker.mjs#threadRows), and a cancelled message isn'tpending.- 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 inclearPass(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_opensrow at retire. The successor's start comes fromsessionClears(stagesretired,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 apendingmessage, and threadPass starts it like any idle session (sage-2830's reading). - The run's end is a drain too.
endLaunchappendsdrained(run, generation) when the ending run's session has arequestedop. A run that exits before its next receive needs no signal. - Exit proof.
sessionClearExited {business, thread, op, run}refusesrun-not-endedunless 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:
sessionWorkspacecreates the session's workspace directory, so a session that never ran gets an empty one at its clear.
- Side effect:
- Restart.
recover()SIGKILLs an earlier host's runs and ends themhost-lost. A run that outlived the kill is a row 269 survivor and is ended too.clearStopwaits 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.clearStopsis 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. Anuncertainop it finds is never signalled.
- Halt at retire waits.
sessionClearRetirethrowsprocessing-haltedwhile processing is halted, and the op staysexiteduntil it lifts. Only an owner or grant refusal appendsfailed. - Held codes. A successor start refused
capacity-fullorlimit-reachedreportsheld. Every other refusal reportsrefused, and any non-Refusal throw reportslaunch-failed. Each code is logged and reported once per op, not once per tick. - Names stay bound.
#nameTakencounts any op'ssuccessor_name, a cancelled or failed one included. So a session.create of<old>-1after a cancelled clear refusessession-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
failedrefusessession-clear-complete, like cancel afterstart-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 nostart-*row is appended. - After a restart, a live survivor of the cleared run holds the clear at
drainedwith 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 refusesthread-not-ownedunless the thread's owner is the caller's#owner, and a tester's owner istester(:879-880). So a tester can't reach a human's or a user's thread. - Test: a tester's
session.clear,session.cancelandsession.operationon a thread a human owns each refusethread-not-ownedand write nothing (nosession_opsormessage_cancelsrow). - 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
cancelledonly when#routereturns a code (broker.mjs:5505,if (cancelled) return { id: mid, request, cancelled }). Every other send answers exactly what it did before.#routereturns 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
spawnSessionat launcher.mjs:574, insidestart()(:422), which runs every session, thread runs included. spawnSession (seat/src/session.mjs:300-302) builds the runner argv throughsandboxArgv, whose argv starts withNAMESPACES(sandbox.mjs:168), andNAMESPACESholds--unshare-pidand--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-drainedfor uncertain and proof; uncertain once; later proof resumes - B1: undelivered inputs cancelled at accept with receipts; a routed reply answers
cancelledsession-clearing, latersession-retired; the successor gets none - repeat, conflict, second op, stale revision, confirm, halt (writes nothing), B3 both ways
- Q4
<old>-Nwith a takendefault-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-ownedand write nothing; agents refusehuman-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, withfixtures/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
heldand logged once, started when the side session idles out - restart (sage-2830, Q2): a live survivor holds the clear at
drainedwith no signal; its exit lets the clear retire and start
- a running session: one
Open, still to write before the code round:
- A
failedclear (N4: a user's thread whose grant is revoked before retirement) and cancel afterfailedrefusingsession-clear-complete. - The launcher's
uncertainpath (a run that ignores SIGTERM paststopTimeoutMs). It needs an adapter that traps TERM, and a short stopTimeoutMs. - Mutants, at the run: the fence (
#sessionFencein role.claim, thread.send, thread.open), the drain at receive, the survivor wait in clearStop, CLEAR_HELD, and thecancelledfilter 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.sqlis the old 2713l-3m.sql, byte for byte.3l-3m.sqlis 206's.- store.mjs:
SCHEMA_VERSION = '3n', the 3m to 3n step after 206's,session_opsandmessage_cancelsstay last intimedTables, andSCHEMA_SHA256is 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 -cin schema.sql gives 25CREATE TABLEand 97CREATE TRIGGER; at0cbe2e9b3it 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.sqlequals refactor's schema.sql at0cbe2e9b3(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 readybecomes3n: comment-migration.test.mjs:112, thread-migration.test.mjs:164. My first build missed these two (3l readythen), 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 gains3m-3n.sql. - Engine copies: account- :90, gitea- :80 and grant-migration.test.mjs :83 copy
3m-3n.sqlafter 206's3l-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:
later3lin account- :21-22, gitea- :38-39 and grant-migration.test.mjs :24-25 gainssession_opsandmessage_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()andstart()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 atsessionClearExited, then only atsessionClearRetire. 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 nothread 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 loggedthread tick: broker exited before it repliedonce 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.