Files

38 KiB
Raw Permalink Blame History

Row 264 build notes (Gizmoduck)

T1 item 7: independent sessions, compaction counts and clear context. Gitea #1769, Vikunja 463 (design) and 466 (Claude compaction format, T3's dependency, linked and not built here). Brief: docs/plans/2026-10-21_t1-sessions.md, "T1 item 7: independent sessions, compaction counts and clear context" (blob ff0be0c79). Reviewer Duckworth, gate owner Sage. Branch gizmoduck/row264 from refactor da9555ca2. MVP due 10-27.

The brief pins its code citations at cca206cdc. git diff --stat cca206cdc da9555ca2 over every cited source file is empty, so F1-F11 still hold line for line. Only docs/prd/mosaic-stack.md moved.

Phase A: what the code has today

  • Bus. threads binds one instance per thread (schema.sql:240-250). thread_claims keys on thread alone (:251-261), and message_threads maps a message to a thread (:262-265). Schema is 3j (store.mjs:17). The thread tables first appear in migrations/3d-3e.sql.
  • Launcher. check() refuses a second run for the same instance and thread (launcher.mjs:233-240, registry.running(b.id, instance, thread)).
  • Seat. threadStore() makes one session/ and one claude-projects/ per thread (session.mjs:51-74). The registry's running key is business, instance and thread (:215-218).
  • Runner. stop() sends TERM to the child group, then KILL after 5 s (runner.mjs:402-414). It has no drain, no idle acknowledgement and no dequeue fence. D7 step 2 needs all three.

Consumer audit (D9), first pass

git grep -F over packages//src/ and adapters/* at da9555ca2:

Name Files and hits
thread_claims broker.mjs 12, store.mjs 1
message_threads broker.mjs 9
thread_opens broker.mjs 4, store.mjs 1
threadStore launcher.mjs 4, sandbox.mjs 7, session.mjs 3
running( launcher.mjs 5, session.mjs 1

Still to read: the broker holder, agents, end, usage, reply correlation and pending-approval paths named in D9, the two adapters (F5), both harness plugins, the conversation reader (F8) and the Console. Each gets a file:line row here before the first code commit.

Proposed slices

One seat can't build D1-D9 in six days and keep every slice reviewable. I propose five candidates under row 264, each with its own manifest and Duckworth round:

  1. 264a bus. Session tables, claim key (organization, thread, instance, session) with a generation, the D3 default session, the D9 bus mapping, session.list and session.create, and an optional session selector on the thread verbs. Tests: T1 bus half, T2 claims, T7 legacy clients and the rollback refusal.
  2. 264b seat and launcher. Per-session native store and workspace, the single-writer fence, the registry key with session, sandbox mounts of the selected store only, and pinned native ids in both adapters. Tests: T1 run half, T2 workspace and path mutants.
  3. 264c compaction count. Host collector, exact for Pi. Claude shows unknown with the 466 reason until that format is pinned. Tests: T3 Pi half.
  4. 264d clear. The D7 journal, the runner drain fence and idle acknowledgement, stop proof, then retirement and successor in one transaction. Tests: T4, T6.
  5. 264e surface. mosaic session CLI and the Console chooser, count and clear confirmation. Tests: T5 for the legacy single human, T7 Console half.

Phase A rulings (sage-2235)

Q1-Q7 went to Sage in gizmoduck-0154. Sage's answers are in agents/sage/work/queue-264/RULINGS.md at 5f5b51dd6. In short:

  • Q1. Five candidates in the order a, b, c, d, e, each with its own manifest, Duckworth round and landing. Row 264 carries 264a. Sage adds rows for 264b-264e. All five are MVP.
  • Q2. 264a builds as 3j-3k.sql. If 236 or 206 lands first, I rebase onto a new branch name, take the next label, and rerun the whole bus suite, the schema.test.mjs counts and the T46 lists.
  • Q3. PRD 1.13 has landed, and REQ-ROLE-2 (mosaic-stack.md:323-329) already carries the session text. Build to it.
  • Q4. Row 194's lifecycle path owns the stop. honker/row194-b5 dfabbbe8a is read-only reference. 264d rebases onto 194's landing. The runner's dequeue fence and idle acknowledgement are new code and a named review item. No 194 code gets copied.
  • Q5. One shared admission hook for every session start, using today's limits and revocation. Of 191 and 264b, whichever lands second wires 191's checks in. No setting turns it off, and a check that's configured but unavailable fails closed.
  • Q6. 264c lands with Pi exact and Claude unknown with the 466 reason. The Claude half of T3 stays open on row 264 and Vikunja 466.
  • Q7. Legacy single human only. Collaborator and share paths refuse. A4 and the full matrix go to whichever of 264 and 193 lands second.

264a design: the bus

Line numbers are at da9555ca2 unless they say otherwise.

Schema 3j-3k

  • sessions, new and immutable: id, at, business, thread, instance, owner, name, op (the creation operation key) and predecessor (null until 264d's clear). UNIQUE (thread, name) and (thread, op), so a repeated create with the same key finds the first row. Guard triggers check that the business, instance and owner match the thread's, and that a predecessor sits in the same thread.
  • session_defaults, new and append-only: thread, session, at, by. The latest row per thread is that thread's default (D3). A trigger checks that the session is in the thread.
  • thread_claims, message_threads and thread_opens are rebuilt, each with a session column, and thread_claims also gets generation. Rows are copied with their seq values and every old column as it was, and the sequence carries over, as 3h-3i.sql did for usage_records. That is a named review item: the copy adds two derived columns and changes nothing else.
  • Each existing thread gets one legacy default session with id equal to the thread id, the name default, op legacy-3k, and the thread's own at and owner. Every copied row names that session. This is D9's immutable mapping. 264b binds it to the existing native store at threads/<business>/<thread>/session/. A new thread's default session gets a random id.
  • generation is the claim's number within its session, counting from 1. A release or revoke carries its claim's number. On copy, it's the count of claim rows in the thread up to that row.
  • Triggers keyed on the thread now key on the session: one holder, release only while held, only the holder releases, and a generation check. These two keep their thread meaning: one held session per run per business, and the business check. New checks: the claim's, message's and open's session is in the named thread. delivery_same_thread becomes delivery_same_session, which sends a threaded message only to its own session's holder.
  • A 3j binary refuses a 3k store with schema-mismatch (store.mjs:181-186), which covers D9's rollback refusal. T7 tests it.

Broker

  • Run binding. bindLaunch takes an optional session. When it's missing, the run gets the thread's default session, which is the D3 path for today's launcher. The binding refuses a session that isn't in its thread. The run's s.session then drives role.claim, #own, message.receive and the reply routing.
  • Lookups. #threadHolder(thread) becomes #sessionHolder(session), which #recipient, #routeThread, #threadHolders, #claimEnd, threadIdle and run end all use. #threadFor returns a thread and its session: a thread run's own, or the session of the nearest threaded message up the reply chain. #specThread uses the opened thread's default session.
  • Launcher view. threadsState and threadIdle keep their row shape, one row per thread computed on its default session. Until 264b, the launcher starts only default sessions. Messages to another session wait, and nothing reaches the default session's run.
  • Verbs:
    • The thread verbs take an optional session, and leaving it out uses the default (D3). thread.create also makes the default session. thread.messages without a session reads the default session.
    • session.list {thread} and session.create {thread, name, op} (D8: metadata only). The build dropped session.get, session.open and session.send. thread.open, thread.send and thread.messages take an optional session, which does the same job with one owner check and one code path.
    • All of them are human or tester only, through #human/#thread, the same owner check as the thread verbs (Q7: single human, no shares). They go into TESTER_VERBS.
  • Other readers. #grantNotice, #raisedThread and the decision-close path (:736, :1500, :2941) read the holder run's claim row. The rebuilt thread_claims keeps that query working, and the session comes along from the same row.

Tests and lists

  • New file packages/bus/tests/sessions.test.mjs:
    • T1 bus half: two sessions in one thread, distinct histories, receive and reply isolation, and a non-thread role refused.
    • T2: one holder per session, generation fencing, sibling delivery refused by the trigger.
    • T7: legacy clients, the 3j-3k migration from a fixture (default session id = thread id, rows copied with seq), and the rollback refusal.
  • A new fixtures/schema-3j.sql.
  • Update the schema.test.mjs pins (:519-525), the '3j' version pins in the migration tests, COUNTED in scripts/stack-release.sh:404, and the T46 lists in packages/cli/tests/stack-release.test.mjs:860-934. Also timedTables (store.mjs:65-79) and the broker clock-seed list (:325-336).

Duckworth's design checks (duckworth-0106)

Duckworth had no objection to the in-place rebuild and asked for seven checks. Where each one is:

  1. The cross-table trigger. 3j-3k.sql drops delivery_same_thread before any rename and creates delivery_same_session after the new tables.
  2. Index names. The step drops thread_claims_thread and message_threads_thread before the renames, so the new tables can reuse the names.
  3. Per-table triggers. The step drops each guard and append-only trigger and creates it again with text that matches schema.sql. The digest check at store.mjs:231 enforces that, and the T7 sqlite_master comparison shows it.
  4. Generation counts claim rows only. T7's fixture is Duckworth's: claim, release, claim, revoke, giving 1, 1, 2, 2, with t2's claim between them.
  5. Sequences. Rows keep their seq values, sqlite_sequence carries over, and T7 checks that the next insert is 41 after 40.
  6. Id collisions. A legacy session id is its thread's id. sessions_id_not_thread refuses a session id that names another thread, and threads_id_not_session refuses a thread id that names a session. New ids come from randomUUID.
  7. thread_one_per_run. The inner max(seq) is per session.

Duckworth's early look (duckworth-0107) and Sage's ruling (sage-2257)

Duckworth read 312cf33a1 with git only and found nothing blocking. Round 2 (gizmoduck/row264-r2, rebased on dcd8d4dda with no shared paths) answers his notes:

  1. Retired-generation replay. Sage ruled it into 264a (sage-2257). #agent now states the thread run's fence in generations: the session's latest claim must have the generation of the run's own latest claim (#runGeneration). This refuses nothing new. The trigger thread_claim_generation gives each claim in a session its own number, so with h.op 'claim' the generations match exactly when h.holder_run is the run, which is the check it replaced (duckworth-0108). 312cf33a1 would most likely refuse the same calls. A new sessions.test case has a released run and a revoked run each try decision.withdraw, message.receive and message.read after another run claims the next generation. All refuse not-holder and every table's row count stays the same. The workspace and path halves of T2 stay in 264b.
  2. assistant.stop keeps returning {thread, run}. Adding session would change the pins in tester-threads.test and threads.test for a field no caller reads yet. The Console stop in 264e is the first reader, so the field goes there.
  3. #route refuses an unknown session with session-not-found instead of a TypeError.
  4. The README's session section says the default retry answers on new threads only, because a migrated default has op legacy-3k (legacy-3l from round 5).
  5. The Row 155 comment at the role.revoke end now says "held thread session".

Mutants

Each runs on a copy of the candidate tree with one edit, and the named test must fail.

  • M1 (sage-2257). In #agent, replace h.generation !== this.#runGeneration(s) with false. Killed by sessions.test "a released or revoked run's retired generation". M1 removes the thread run's fence entirely, so the kill shows the refusal is tested, not that generations add a check. A mutant that puts back h.holder_run !== s.run survives by equivalence. That isn't a coverage gap.
  • M2 (mos-1279, sage-2688, sage-2690). Four edits, and the restore sha check covers all four files:
    1. broker.mjs:5251, in message.receive: drop the mt.session=? filter (the session clause and its argument).
    2. 3k-3l.sql: drop the delivery_same_session trigger.
    3. schema.sql: drop the same trigger.
    4. store.mjs:16: set SCHEMA_SHA256 to the sha256 of the mutated schema.sql, with the replace sessions.test:432 uses. store.mjs:94 checks schema.sql against it at import (duckworth-0144). LEGACY_SHA256 stays. The chain check at :117 holds because both SQL files drop the trigger. Without edits 2-4, the trigger refuses #delivery's INSERT first. request() then returns storage-refused before any assertion runs. That happened at r13 (duckworth-0143). A kill counts only as an assertion in a test that exercises receive: sessions.test T1 :89, T7 :422, or another receive assertion the log names. A failure only at the trigger's own pin (:164-170) doesn't count, and neither does a thrown error. The killing test is expected to be T1, where a1 would take the second session's message.
  • M3. In 3k-3l.sql, count every claim row for generation instead of op='claim' rows only. Killed by sessions.test T7, generations 1, 1, 1, 2, 2.

Phase 4 rerun on r13 (mos-1279, sage-2688). The r13 gate's phases 1-3 carry. M1-M4 rerun whole on 2cc34aef8 plus this note, each under timeout -k 10 300 (Run limits). Any survivor, throw or non-receive kill goes to Mos unplaced.

Dry run

Before writing any node test, I ran 3j-3k.sql in an in-memory Python sqlite3 on a 3j fixture (gizmo-264-out/dry-3k.py, no repo code). The claims were kept with generations 1, 1, 1, 2, 2 in seq order for the mixed t1/t2 fixture. Seq 40 carried over and the next insert was 41. The migrated sqlite_master matched a fresh schema.sql, 119 rows each. Every guard refused its bad write.

Hand-made threads in tests

A thread that a test writes by hand has no default session, because only thread.create and the migration write one. The broker refuses such a thread with session-not-found, and the launcher view leaves it out. So every test that inserts a threads row now also writes the legacy-shape default session (DEFAULT_SESSION in threads, end-launch, thread-cli, thread-launch and chat tests). That mirrors the migration's rows rather than inventing a third shape.

Round 5: on row 236, the step is 3k-3l

Row 236 (8f51900b5, which round 5 is built on) adds migrations/3j-3k.sql with the gitea_users and gitea_grants tables, and lands first. By the landing-order label (Q2, sage-2412) 264a's step moves one version. The design above still says 3j-3k; read it as 3k-3l.

  • migrations/3k-3l.sql is round 4's 3j-3k.sql with three renames and nothing else: the copy tables are *_3k, and the op values are legacy-3l and migration-3l. The op values name the step's target version, as round 4's legacy-3k did on 3j-3k (sage-2415). No earlier migration set a precedent either way.
  • store.mjs runs 236's 3j to 3k step, then 3k to 3l. SCHEMA_VERSION is 3l, and SCHEMA_SHA256 is the merged schema.sql's, cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a. schema.sql merged with no conflict: 264a's edits are above 236's two tables at the end.
  • The T7 fixture is fixtures/schema-3k.sql, 236's schema.sql at 8f51900b5 (SHA-256 aba92408...). 236's fixtures/schema-3j.sql is the same blob round 4 added, so that path no longer differs.
  • Pins: schema.test 22 tables and 86 triggers; README 22 tables and 87 triggers. Every '3k' version pin is '3l', the comment-migration future step is 3l to 3m, and each copied engine also copies 3k-3l.sql.
  • account-migration and grant-migration filter both 236's objects and the 3k-3l objects. gitea-migration.test (236's) now reads its store after both steps: version 3l, rows skips sessions and session_defaults, master skips the 3k-3l objects, and its failing engine copies 3k-3l.sql. Its 3j engine already stripped every step after 3i-3j.
  • sessions.test's engine is a 3k engine: 236's schema and steps through 3j-3k, no 3k-3l.
  • The restore guard lists merged with no conflict: 236 adds gitea_users and gitea_grants to T46's untimed, and 264a adds sessions and session_defaults to COUNTED and LISTED.

Dry run (gizmo-264-out/dry-3l.py, Python sqlite3 in memory, no repo node): 3k-3l.sql on 236's schema kept every claim with generations 1, 1, 1, 2, 2, kept seq 40 with 41 next, and the migrated sqlite_master matched a fresh schema.sql, 121 rows each. Against round 4's run from 86f95bb27 (dry-3k-r4.py), the output differs only in the op values and the 119 rows that 236's two tables make 121.

Round 5 on landed 236 (47a35bf43)

Row 236 landed at bd5f11ded and refactor was 47a35bf43 (sage-2579). The prep commits moved from 8f51900b5 to 47a35bf43 by cherry-pick. schema.sql, the migrations, store.mjs and the fixtures are the same as the prep tip 4b369ddba, so the SHA-256 and the dry run above still hold.

  • Two conflicts, both with row 194. In broker.mjs TESTER_VERBS keeps 194's instance.launch, instance.end and instance.request and then 264a's session.list and session.create, and the private fields keep 194's block before 264a's claim comment. tester-writes.test pins the same list in the same order.
  • Row 205's message-filter.test (new since 8f51900b5) makes threads and message_threads rows by hand. Its threads now get the default session, as the other hand-made threads do, and its message_threads rows name that session. That makes 19 touched test files, not 18.
  • A defect from round 1: edit-264a-6.py wrote DEFAULT_SESSION with bare ' quotes inside single-quoted strings in threads.test, end-launch.test and chat.test. Those three files could not parse in any earlier round. The quotes are now escaped. The two cli tests used double quotes and were fine. Runs below records no run, so no reported result rests on those files.
  • Every broker insert into thread_claims, thread_opens and message_threads names a session, 194's new callers included. 194's lifecycle-verbs.test reads thread_claims by thread only.
  • Pins on this base: schema.test 22 tables and 86 triggers, README 22 tables and 87 triggers, COUNTED and LISTED carry sessions and session_defaults, T46's untimed list carries 236's gitea_users and gitea_grants.

Correction (sage-2581)

Rounds 1 to 4 never parsed bus threads.test, bus end-launch.test or webui chat.test. Each had the bare quotes in DEFAULT_SESSION described above, from edit-264a-6.py in round 1. No run claimed those files: this packet's Runs section was empty through round 4 and the r4 gate was voided before it started (sage-2412). Round 5 escapes the quotes.

The round 5 gate is wider than the plan above (sage-2581, mos-1200: a rebase carries no result). Each of the 19 touched test files runs whole on its own, then the bus, cli and webui suites whole, then every scripts/test-*.sh, then the mutants, with every skip named.

Round 6 on landed 274 (0165be266)

Row 274 (FED R3) landed at 4c485c63b and refactor was 0165be266 (sage-2588). The round 5 commits moved from 47a35bf43 by cherry-pick. broker.mjs is the only path both rows change.

  • Four conflicts in broker.mjs, all from 274's user principal. USER_VERBS and USER_LEVEL follow TESTER_VERBS, which ends with the session verbs as before. In thread.open, thread.send and thread.messages each verb keeps 274's !s.tester && !s.user check and #grantedThread, and adds 264a's optional session key, #threadSession and the session on thread_opens and #route. thread.send keeps 274's return, where a user's message writes no human.input.
  • Users stay at the seven ruled verbs (sage-2576): session.list and session.create are not in USER_VERBS. user-principal.test:133 already checks every verb outside the list refuses a user, and that set now includes the two session verbs.
  • 274's B6 check (a run answers a user only in reply to that user's message in its thread) joins message_threads by thread, not by session. No code change: a user can't call session.create, and #thread refuses anyone but the owner, so a user's thread has only its default session and the two scopes are the same rows. A slice that lets a user add sessions (264e) must make it session-scoped and test it.
  • 274's other writes and tests make threads through the broker, so each gets its default session. Its tests read thread_opens and message_threads by thread, and those columns stay.
  • schema.sql, the migrations, store.mjs and the fixtures are unchanged from round 5, and so are the touched test files: 19.

Round 7 on landed 277 (980cec88c)

Row 277 (thread.claims, #1782) landed at 62f53b1e0, after row 256 (f12441d4f), and refactor was 980cec88c (sage-2608). The round 6 commits moved from 0165be266 by cherry-pick with no conflict, and the range-diff is = for all eleven.

  • 256 changes packages/bus/src/runtime.mjs, packages/business/src/instance-settings.mjs and packages/webui/src/public/agents.js. None is a manifest path and none of their changed lines names a thread, a session, message_threads or thread_claims. Three touched tests load these modules (bus chief-of-staff and tester-writes, cli thread-launch), so the whole-file runs cover 256's code.
  • 277's thread.claims took the latest thread_claims row per thread. With sessions that is the last claim of any session, so a second held session would hide the first (duckworth-0123 N3, sage-2591). It now joins sessions and takes each session's latest row, as #threadHolders does, ordered by thread then session. The result keeps 277's five keys: two rows of one thread differ by holder_run, and mosaic agents prints each as its own thread line. Adding the session id to the row is left to the slice that shows sessions to the owner.
  • thread-claims.test (277's) makes its threads by hand, so each now gets the default session the migration gives an old thread (C5, :47), and its T5 message names that session (:111). T6 holds two sessions of one thread, sees both rows, releases one and sees the other (C4). M4 swaps the query back to 277's per-thread latest row and must fail T6.
  • 274's thread-scoped user reply check binds row 272 (264e) in docs/plans/DEFERRED.md, written here (sage-2608): a slice that lets a user add sessions makes it session-scoped.
  • 277's other tests: message-filter.test's claim pin reads thread.claims for a thread made with its default session, and the cli format and tester tests use stub rows or no threads.
  • Touched test files: 20 (thread-claims.test joins). Mutants M1-M4.

Correction: the r7 red (sage-2621, duckworth-0129)

The r7 gate on Sage's CLEAR (sage-2619) stopped on its first file. account-migration.test failed 2 of 3: "3h copy migrates to 3i" at :87 and "a 3h→3i step that fails partway" at :136. Each compares a whole snapshot of the store after the steps to 3l, sqlite_sequence included, with the snapshot before them, and found two more rows: session_defaults and sessions, both at seq 0.

The cause is mine. 3k-3l.sql:50-52 fills sessions and session_defaults with INSERT ... SELECT, and SQLite writes a sqlite_sequence row for an AUTOINCREMENT table on that insert even when it copies no row. My round 1 schema commit (67fded6af then, 31fcecf7b in r8) changed the file's version pin, master filter and engine copy, but no round ran the file before r7 (Correction above: no run was claimed). The same gap held in every migration test that dumps sqlite_sequence.

What I read in r8, before any run: every touched test (and every bus, cli and webui test that calls migrateSchema) that compares a whole snapshot or whole table across a step to 3l. git grep migrateSchema and git grep sqlite_sequence over packages/*/tests/* and scripts/* gave the list. A Python sqlite3 dry run of the real steps on each fixture's shape (gizmo-264-out/dry-seq-r8.py, no repo node) gave the rows after the step.

  • account-migration :87 and :136: red as run. Fixed. :132 compares before the 3l step (the failing 3h→3i engine rolls back) and is unchanged.
  • gitea-migration :90 (3j to 3l) and grant-migration :95 (3i to 3l): rows() leaves out the new tables by name but keeps sqlite_sequence. Same two rows at seq 0. Fixed. gitea :113 and grant :117 compare after a rolled-back step and are unchanged.
  • usage-migration :67 (3g to 3l), name-migration :65 (3f to 3l), spec-migration :61 (3e to 3l): whole snapshots with sqlite_sequence. Their fixtures have one thread, so the two rows are at seq 1. Fixed. In usage-migration the step also moves the thread_claims and thread_opens rows after instance_names' (3k-3l.sql:84-86 deletes and writes them again), so its snapshot now reads sqlite_sequence by name, as account-migration's has since row 126 (ca15d73e9).
  • Read and not affected: thread-migration :110 and :144 compare messages and deliveries only; sessions.test T7 and the D9 refusal compare named 3l tables, 3l against 3l, and :177 lists table names; each engine-refusal test (account :156, gitea and grant's third test) compares 3l with 3l; comment-migration and chief-of-staff count rows and compare no snapshot; stack-release.test T46 lists table names from schema.sql, and its --migrate tests run stub copies at 3g to 3h.

The fix, in all six files: the compare after the step leaves out the two new rows and asserts them exactly, names and seq, so an extra row of any other name still fails. account, gitea and grant leave out the rows SESSIONS_3L names (sage-2621); their stores have no thread rows, so nothing else in that list has a sequence row. usage, name and spec leave out only sessions and session_defaults, because their thread_claims and thread_opens rows must stay in the compare: the step carries those seqs. Nothing else in the files changes.

Round 9 on landed 278 (385a7194c)

Row 278 (thread claims in the Console) landed, and refactor moved from 6af85df4b to 385a7194c, fast-forward (sage-2657). r8's 15 commits moved by rebase with no conflict, and the range-diff is = for all fifteen.

  • 278 changes packages/webui (src/cli.mjs, src/serve.mjs, src/public/bus.js, README, and the tests shell, thread-claims and thread-claims-browser), BUILD-LOG.md and its own packet. None is a manifest path.
  • Webui reads by 264a's tests: stack-release.test reads packages/webui/src/cli.mjs at :718 (a --tester-port match, still present) and its copy tests at :2475-2573 replace or append the whole file, so 278's added line doesn't reach them. No 264a test pins a hash of a webui file; the 64-hex pins in the migration tests and sessions.test are fixture DDL.
  • 278's thread.claims reader in bus.js renders one table row per result row and checks only that thread, holder_run, harness and at are strings, so 264a's per-session rows (a thread with two held sessions lists twice, five keys kept) show as two rows. 278's three webui tests use fake bus objects and never reach the broker's query.

Round 10 on landed 281 (e479521d3)

Row 281 (a fire or delete revokes the instance's grants) landed, and refactor moved from 385a7194c to e479521d3, fast-forward (sage-2668). r9's 16 commits moved by rebase with no conflict, and the range-diff gives all 16 =. The r9 run never started (sage-2665), so no result carries over.

  • 281's paths over 385a7194c: packages/bus/src/broker.mjs, five tests (bus fire-revokes-grants, gitea-grant, grant-executor, lifecycle-revoke; cli lifecycle) and its own packet. The one shared path is broker.mjs, and the hunks don't touch: 264a's at :128, :455-:1081 and :3276-:5537, 281's at :396-:400 and :1273-:3831 (base lines).
  • 264a removes #routeThread, #threadFor and #threadHolder. No line 281 adds calls them, and every private name the merged broker calls is defined. No line 281 adds names thread_claims, thread_opens, sessions or session_defaults; its section 3a "claim" is the host-key claim.
  • 281's tests name no schema version, migration, sqlite_sequence, thread_claims or 64-hex pin. cli lifecycle.test's sessions are launcher.registry.sessions, which 264a doesn't change.
  • No 264a test reads a 281 path as text or pins its hash. The touched tests that import broker.mjs run against the merged file in this round's gate, which is the test of the pair.
  • 281's gate (its BUILD.md, "Whole packages") recorded the cli suite at 591 s, longer than the 535 s the table used. 3x is 1773 s, so the cli limit goes from 1650 s to 1800 s. Its other times (bus 23 s, each scripts/test-*.sh at most 36 s) are under the table's, and those limits stand.

Correction: the r11 red, T2 (mos-1277, sage-2676)

The r11 gate (75f049ed2, ffc49615, CLEAR sage-2674) went red in its first phase at sessions.test T2 "a released or revoked run's retired generation", :204: the events table had 10 rows before the three refused calls and 13 after. Every other table's count was equal. request()'s catch (broker.mjs:4242) sends each refusal through #refused (:4081-:4096), which writes one action.refused event with body {code}. r2's base dcd8d4dda has the same catch, so the assertion that every table's row count stays the same (round 2, item 1 above) could never pass. Sage reproduced it at 72fc5a330, ed6773f72 and 75f049ed2, and the three rows are action.refused {"code":"not-holder"}. Mos ruled it a test defect, not a product fault.

Mos's reading of sage-2257: "writes nothing" means a retired generation's approval, delivery and read write no approval, delivery, read or claim state. The refusal trail stays. Commit 19 (test only) makes refused() say that, for both calls (released and revoked):

  • every table's row count stays equal except events;
  • each refused call adds exactly one event, kind action.refused with body {"code":"not-holder"}, checked after each call in call order (withdraw, receive, read), so events grows by three.

Before the next gate, one survey pass runs the touched files the r10 and r11 runs never reached (sage-2676 step 3), with M1 against the fixed T2.

Run limits (sage-2634, sage-2637, sage-2647, mos-1275)

Every run in run-264 goes through timeout -k 10 LIMIT, so a hang stops the run instead of holding the slot. Each LIMIT is at least 3x the longest recorded run of that file, suite or script. The records are my own gate logs (ℹ duration_ms for node runs, the lap lines for scripts), the times in committed BUILD.md files, and row 278's committed receipts: SHELL-LIMITS.json (row 196's longest per script) and run3/whole/results.json in agents/gander/work/queue-278. A timeout with no failed test is a hang, not a pass or a red, and goes to Sage first (sage-2637). A timeout in the shell phase goes to Mos unplaced (mos-1275).

Run Longest recorded Where LIMIT
stack-release.test alone 575 s the r12 survey, 2026-10-22 (row 234 r3 singles with mutants: 505 s; plain r3: 482 s); 3 x 575 = 1725 (mos-1275 item 3) 1800 s
each other touched file alone 15 s (thread-launch.test); end-launch, gitea-migration, message-filter, sessions, thread-claims, thread-cli and chat have none rows 234, 235, 210 logs 300 s
bus suite 23 s row 235 run 1b; row 278 run 3: 22.5 s 900 s
cli suite 591 s row 281's gate, 2026-10-22 (row 235 run 1: 535 s; Megavolt row 199: 472 s) 1800 s
webui suite 57 s row 278 run 3 (row 66 r2: about 60 s) 600 s
each scripts/test-*.sh 42 s (test-foundation); task 36.8, queue 23.4, land-work 12.6, release 12.2, discord 8.8, conductor 8.1, config 3.2, extension-package 3.0, auth 1.9 the longest of row 268's gate (2026-10-22 04:40Z), row 196's receipts and row 278 run 3 900 s
mutant probes M1-M4 (sessions.test, thread-claims.test) 1 s wall each (longest ℹ duration_ms 512, M3) the r13p4 rerun, 2026-10-22 14:08:43Z to 14:08:45Z 300 s

Runs

  • r7 a23afbf95 on 980cec88c, Sage's CLEAR sage-2619, 2026-10-22 11:29:47Z. HEAD and the manifest digest eb7c1ab3 printed and matched. RED on the first file, account-migration.test 1 pass 2 fail (Correction above); the run stopped there as ruled, with no retry. The sweep found 0 node processes in the worktree. Slot returned.
  • r10 54f65f9ab on e479521d3, Sage's CLEAR sage-2669, 2026-10-22 13:11Z. RED on the first file, account-migration.test :106: later3l missed the 3l trigger threads_id_not_session (3k-3l.sql:104). Mos ruled option A (mos-1276): commit 18 names it in account-, gitea- and grant-migration. On r11 all 8 *-migration files ran whole, 25/25, 0 skipped (2026-10-22 13:14:25Z).
  • r11 75f049ed2 on e479521d3, manifest ffc49615, Sage's CLEAR sage-2674, 2026-10-22 13:16:37Z. Phase 1 was green for account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5, end-launch 7/7, gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1 and schema 1/1. Then RED at sessions.test T2 :204 (Correction: the r11 red, above), and the run stopped. 0 node processes left. Mos ruled a test defect (mos-1277): commit 19.
  • r12 93f4af12b on e479521d3, manifest 16603087, survey pass (sage-2676 step 3), Sage's CLEAR sage-2678, 2026-10-22 13:24:09Z to 13:34:07Z, 598 s. Each file whole under timeout -k 10, with no stop on red and no edits, all 0 skipped and 0 todo: sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3, threads 16/17 RED, usage-migration 1/1, thread-cli 11/11, thread-launch 11/11, chat 23/23, stack-release 218/218 in 575 s. M1 against the fixed T2: rc=1, killed on an assertion, file restored, sessions 6/7 with T2 sage-2257 the only failure ("Missing expected exception: approval" at :204). Log gizmo-264-out/r12-survey/probes/M1.log. Mos accepted M1 killed by T2 alone at r12 (mos-1278 item 4). The threads.test red: :330 expected /only to its thread's holder/ (:341, from row 121, 2c16d6b93) and got the 3l trigger's "only to its session's holder". Mos ruled option A (mos-1278): delivery_same_thread in the base (schema.sql:300-306 at e479521d3) and delivery_same_session (3k-3l.sql:147-153) share the WHEN, the ops and the holder check, and differ only in taking the latest claim per session and in the text, so :330 keeps its strength. Commit 20 changes :341 to /only to its session's holder/ and nothing else in that file.
  • r13 gate, 2cc34aef8 on e479521d3, manifest 65e04d27, Sage's CLEAR sage-2684. Start 2026-10-22 13:39:53Z, busy count 0, HEAD and digest printed and matched, blobs checked with sha256sum -c. Every count below has 0 fail, 0 cancelled and 0 todo.
    • Phase 1, the 20 touched files whole, one at a time, all 0 skipped, wall 600 s: bus account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5, end-launch 7/7, gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1, schema 1/1, sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3, threads 17/17, usage-migration 1/1; cli stack-release 218/218, thread-cli 11/11, thread-launch 11/11; webui chat 23/23.
    • Phase 2, suites whole: bus 827/827 (23 s), cli 837/837 (593 s), webui 345 pass of 346 with 1 skipped (55 s). The skip is the esbuild 0.28.1 editor rebuild (Duckworth named it in duckworth-0146; DEFERRED row 264a N2).
    • Phase 3, the 10 scripts/test-*.sh, all rc=0: auth 15, conductor 17, config 31, discord 81, extension-package 18, foundation 44 (36 s), land-work 86, queue 156 node tests and 28 suite checks, release 14, task 91. The worktree was clean after.
    • Phase 4: M1 killed by T2 at sessions.test:204 (6/7). M2 then failed with no assertion: T1 (:89) and T7 (:422) threw storage-refused from #refused (broker.mjs:4082) through request()'s catch (:4242), because the delivery_same_session trigger refused #delivery's INSERT. The run stopped there at 14:02:47Z, total wall 1374 s, and M3 and M4 did not run. 0 node processes left. Mos ruled option B (mos-1279): phases 1-3 carry and phase 4 reruns with M2's four edits.
    • Logs: gizmo-264-out/r13-run.out, with the per-file, suite and probe logs under gizmo-264-out/r13/single, r13/suite and r13/probes.
  • r13p4, phase 4 rerun under mos-1279 at HEAD 84f53540d (2cc34aef8 plus commit 21, the M2 note in this file), Sage's CLEAR sage-2688 with the fourth edit confirmed in sage-2690. Start 2026-10-22 14:08:43Z, busy count 0. git diff --name-only 2cc34aef8..HEAD was this file only, and the non-BUILD.md blobs matched manifest 65e04d27. Kit gizmo-264-out/probes-264-r13p4.py (--check ok), each probe under timeout -k 10 300, each file restored and its sha checked. End 14:08:45Z, failures 0, 0 node processes left.
    • M1, the #agent generation fence set to false: killed, sessions 6/7, T2 sage-2257 at sessions.test:204 (assert.throws(fn, { code: 'not-holder' }, label)).
    • M2, four edits: the mt.session=? clause and its argument dropped from message.receive (broker.mjs:5251), delivery_same_session dropped from 3k-3l.sql and from schema.sql, and store.mjs:16 SCHEMA_SHA256 set to the sha256 of the mutated schema.sql. Killed, sessions 4/7, by T1 at sessions.test:89 (a1's receive got the second session's message). T7 also failed on its receive assertion at :422. T2 failed at :168, the trigger's own pin, which doesn't count as a kill under mos-1279. The kill rule: only an assertion in a test that exercises receive counts, never a thrown error.
    • M3, generation counted over every claim row in 3k-3l.sql: killed, sessions 6/7, T7 at sessions.test:390 (the thread_claims generations).
    • M4, thread.claims back to the latest row per thread: killed, thread-claims 5/6, T6 at thread-claims.test:133 (two held sessions list as two rows).
    • Logs: gizmo-264-out/r13p4-run.out and gizmo-264-out/r13p4/probes/M1.log to M4.log.