Compare commits

...
Author SHA1 Message Date
jason.woltje 74b5e36386 row 271 (264d, #1776): schema step 3l to 3m, session_ops and message_cancels with 7 triggers; successor id pinned as a lowercase version 8 UUID for Huey (gizmoduck-0274) 2026-10-22 12:06:46 -05:00
jason.woltje 82ff4ca105 gizmoduck: row 271 design: duckworth-0171 C1 (start stages keyed on a report counter, work attempt in its own column) and C2 (cancelled in the stage CHECK at attempt 0 and closing the op, claim and cancels untouched by a pre-retire cancel, post-retire successor reading stated, race and held tests); N1 grep at build 2026-10-22 12:03:37 -05:00
jason.woltje 78e14352f6 gizmoduck: row 271 design: session.cancel and the cancelled stage built in 271 (sage-2828 re-rules Q3 per D7:263-264), pre- and post-retire paths, cancel tests and the fence-release mutant; N2 refuses session-name-too-long at accept 2026-10-22 12:02:55 -05:00
jason.woltje 4cca8e785a gizmoduck: row 271 design: T45 LISTED gains session_ops and message_cancels to match COUNTED (mos-1319) 2026-10-22 12:01:41 -05:00
jason.woltje 549b5a0ebd gizmoduck: row 271 design: duckworth-0170 B1-B4 folded (cancel at arrival for routed messages, claim refused while clearing, op namespace both ways, attempt key on start stages) and N1-N8 answered; cancel recorded as a departure from D7:263-264 2026-10-22 12:01:13 -05:00
jason.woltje 8a9db2159a gizmoduck: row 271 design: both new tables join COUNTED in stack-release.sh, so T46 passes unchanged; schema.test count pins name the 2 tables and 7 triggers; T45 LISTED question to Mos (mos-1318) 2026-10-22 11:59:16 -05:00
jason.woltje 5ab7bcf4a2 gizmoduck: row 271 (264d) design draft 2: drained acknowledged by the run's own receive at a turn boundary (a landing turn has unread 0 with a child running), SIGTERM only after it; successor <old>-N bound at request with a deterministic id; no cancel (sage-2826); pre-existing test change list per mos-1315 2026-10-22 11:57:42 -05:00
jason.woltje 3105de8892 gizmoduck: row 271 (264d) design draft 1 in the packet: the threadIdle fence as the drain, the idle stop as the only signal, a session_ops journal and message_cancels in one schema step; Q1-Q4 for Sage and Mos 2026-10-22 11:53:59 -05:00
jason.woltje 2998b2a4e8 gizmoduck: row 270: the bus legacy test inserts its legacy thread by hand (sessions is append-only) and the test business has the assistant thread.create needs; not run 2026-10-22 11:53:32 -05:00
jason.woltje 2488b27f6a gizmoduck: row 270 (264c, #1775): completed Pi compactions counted into a host ledger beside the session store, keyed (entry id, timestamp) store-wide (sage-2806, sage-2814); launcher collects at run start and end under the workspace lock; session.list rows gain compactions through a runtime-injected reader; not run (slot held) 2026-10-22 11:48:19 -05:00
jason.woltje 12a277f9be gizmoduck: rows 270 and 271 packets: the 264c count and 264d clear contracts pinned for row 272 (Huey), and the Pi 0.85.1 compaction source contract 2026-10-22 11:38:44 -05:00
jason.woltje 2d0d407ba6 row 269 (264b, #1774): the M6 halt test halts after mayLaunch answers
A halt inside the op reaches the bus's own fence first (runtime.mjs:564-566,
processingWork at :352-356), so the launcher refused limit-check-failed
(launcher.mjs:389-391) before its recheck admission() at :397. First-run red of
a new test on 6d9ab3503 early signal, placed under mos-1272.
2026-10-22 10:41:40 -05:00
jason.woltje 6d9ab35039 row 269 (264b, #1774): BUILD.md names the sessioned-survivor residual in Q2 and records N1's mos-1292 allowance (duckworth-0159 N3, N4) 2026-10-22 10:15:32 -05:00
jason.woltje 2c16bc4209 row 269 (264b, #1774): BUILD.md Q2 notes that a survivor's released claim doesn't prove its run has exited, and the per-leftover 5 s wait (duckworth-0158 N1, N2) 2026-10-22 10:14:45 -05:00
jason.woltje 9221786228 row 269 (264b, #1774): BUILD.md answers sage-2750 on the recover() change; the survivor log names its pids
The section "recover() change, for a design read" covers three things, with
file:line against aca16404c. Every leftover is still bound and ended as
host-lost, and a survivor only refuses legacy-run-live while it lives. The
5 s wait applies only in the bwrap kill branch. No old test's expected value
changes. The fallback is to revert to the base recover() and leave the
refusal untested. The survivor log line now names the outer and runner pids
so an operator can clear it, and the new test's log assertion matches.
2026-10-22 10:14:11 -05:00
jason.woltje 3d29876dfb row 269 (264b, #1774): legacy-run-live gets its test (duckworth-0156)
recover() kept a survivor only when a bwrap outer pid outlived SIGKILL, so
the Q2 refusal (BUILD.md:177-179, t1-sessions.md:109) had no test. A leftover
whose runner still ran after its outer pid was gone was ended and dropped.
Now recover() waits on the outer pid and the runner, keeps any leftover where
either still runs, and logs that it still runs. check() reads only the
survivors that still run.

session-launch.test.mjs adds a test where a sessionless leftover's runner is
a sleep child and its outer pid is gone. It asserts the legacy-run-live
refusal with no launch and no session workspace, then one launch once the
child exits. BUILD.md adds mutants M10-M12 and replaces the "no test" note.
2026-10-22 10:11:49 -05:00
jason.woltje a81b3446eb row 269 (264b, #1774): refusedOnce in sessions.test.mjs pins actor_run, closing Duckworth N1 (DEFERRED.md:474-480)
Allowed by Mos in mos-1292 (copy mos-1294) on mos-1285's terms. The SELECT
reads actor_run, the expected row gets the run-name literal, and the three
callers pass `${name}-run`. Nothing else in the test changes. The old
expected row was:

  [['action.refused', { code: 'not-holder' }]]

DEFERRED.md gets a closing line on the entry naming row 269.
2026-10-22 10:09:37 -05:00
jason.woltje d595570a63 row 269 (264b, #1774): per-session store, workspace and single-writer lock
threadsState gives one row per session with `session` and `legacy`; threadIdle
idles a run against its bound session. Each session gets its own store
(threads/<b>/<t>/sessions/<s>/, or the thread's old store for a legacy-3l
session) and its own workspace (session-workspaces/<b>/<i>/<s>/), held by an
O_EXCL <ws>.lock from before the clones to the run's proven exit. A legacy
session's workspace binds in-sandbox at the old instance path. sessions.json
is v2 and reads v1. New tests: cli session-launch.test.mjs, seat
session-workspace.test.mjs. Not yet run: tests wait for Sage's CLEAR.

Two old assertions change as exact updates:
- mos-1285: end-launch.test.mjs threadsState deepEqual. The old row shape was
  { thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null,
  lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null }; the
  row keeps every key and value and adds session: 'th-1', legacy: true.
- mos-1289: thread-launch.test.mjs:182, old line
  `const store = join(f.dataRoot, "threads", "acme", A);`
  now reads A's default session id from threadsState's `session` key; :183-185
  unchanged; one added assert that threads/acme/<A>/session is absent.
2026-10-22 10:06:42 -05:00
jason.woltje e2e51bae1f row 269 (264b, #1774): BUILD.md lists thread-launch.test.mjs:182 as a second old assertion that changes. A thread.create thread's default session isn't legacy-3l, so its store takes D2's layout (t1-sessions.md:100, :294). Proposed as an exact path update, for Mos through Sage 2026-10-22 09:53:09 -05:00
jason.woltje f684317eb0 row 269 (264b, #1774): BUILD.md drops the planned bindLaunch session-running refusal. Two old tests bind a second unended run to one session and expect the claim refusal (threads.test.mjs:88-89, sessions.test.mjs:119-120), so the broker half of the fence is the existing per-session claim, thread-already-held (broker.mjs:4815) 2026-10-22 09:50:49 -05:00
jason.woltje 04676f57b9 row 269 (264b, #1774): design notes for Duckworth's early look (duckworth-0150). C1 adds the Console and R-guard sessions.json readers, the 264a refusals a v2 file causes, the /proc live check and the Console restart; C2 pins M6 to a generation change after check() with all eight start() admission calls named; C3 gives the realpath compare its own code and a symlinked workspaces root as M4's input; C4 lists the old threadsState assertions, end-launch.test:193 the only change, under mos-1285 2026-10-22 09:45:00 -05:00
jason.woltje 304bce2f13 row 269 (264b, #1774): design notes for sage-2717. Rollback names the four steps past the 264a exit-3 refusal and the re-pin plan section that carries them; new M8 binds a non-default session at the legacy path, old M8 becomes M9; Duckworth's legacy-bind check 2026-10-22 09:40:16 -05:00
jason.woltje 875b7eeebf row 269 (264b, #1774): design notes. D9 audit of every sessions.json reader at 84f53540d and of threadsState's consumers; sessions.json goes to v2 because recover() rebinds without session; pi 0.85.1 resumes by cwd, so the legacy session keeps its in-sandbox path; mutants M1-M8 and run limits (sage-2707) 2026-10-22 09:37:39 -05:00
jason.woltje aca16404cf queue: revs 3195-3216. Rows 278, 281 and 264a reviews, notes and done moves (sage, gizmoduck, megavolt, gander, webby, dewey, duckworth); row 264 round 1 request posted as jarvis (sage-2693, #1769) 2026-10-22 09:25:48 -05:00
jason.woltje f7e4ec3df5 row 264a (#1769): BUILD.md records the r13 gate on 2cc34aef8 (phases 1-3 green, M2 thrown at the trigger) and the r13p4 rerun at 84f53540d (M1-M4 killed on assertions, M2's four edits and kill rule), and the limits row gets the measured probe time; doc only (mos-1280 item 1, sage-2701) 2026-10-22 09:23:39 -05:00
jason.woltje b789188504 docs: row 264a landing sweep. BUILD-LOG gets Rocko's V502 rerun line and Sage's V502, rows 278/281 and row 264a landing lines; SESSIONS gets Dewey's, Rocko's and Sage's lines; DEFERRED moves the fire-keeps-grants gap to Done (row 281) and opens row 281 N1/N2 and row 264a N1 (actor_run, binds row 269) and N2 (esbuild skip) (sage-2695, #1769) 2026-10-22 09:22:05 -05:00
jason.woltje 84f53540dd row 264a r13 (#1769): BUILD.md writes M2's four edits before phase 4 reruns: the receive filter, delivery_same_session dropped from 3k-3l.sql and schema.sql, and store.mjs's SCHEMA_SHA256 set to the mutated schema; a kill counts only as a receive-test assertion; doc only (mos-1279, sage-2688, sage-2690, duckworth-0143, duckworth-0144) 2026-10-22 09:08:25 -05:00
jason.woltje 2cc34aef87 row 264a r13 (#1769): threads.test :341 takes the 3l trigger's text, 'only to its session's holder' (delivery_same_session, 3k-3l.sql:153); BUILD.md raises the stack-release limit to 1800 s from the survey's 575 s and records the r10 and r11 reds and the r12 survey with M1 in Runs; test only (mos-1278, sage-2682) 2026-10-22 08:36:56 -05:00
jason.woltje 93f4af12bc row 264a r12 (#1769): sessions.test T2's refused() keeps every table but events equal and pins one action.refused {code: not-holder} event per refused call in call order; the old every-table check could never pass since request()'s catch (broker.mjs:4242) writes the refusal through #refused; BUILD.md records Mos's reading of sage-2257; test only (mos-1277, sage-2676) 2026-10-22 08:21:41 -05:00
jason.woltje 75f049ed23 row 264a r11 (#1769): later3l in account-, gitea- and grant-migration names threads_id_not_session, the 3l trigger on threads (3k-3l.sql:104) the sqlite_master compares missed; test only (mos-1276 option A, sage-2672) 2026-10-22 08:14:15 -05:00
jason.woltje 54f65f9abd row 264a r10 (#1769): rebase on landed 281 (e479521d3), all 16 =; broker.mjs overlap read; cli limit 1800 s from 281's 591 s run (sage-2665, sage-2668, sage-2647) 2026-10-22 08:08:28 -05:00
jason.woltje 7451878b66 row 264a r9 (#1769): rebase on landed 278 (385a7194c), all 15 =; webui read check; run limits at 3x the longest recorded run (sage-2647, mos-1275) 2026-10-22 08:07:43 -05:00
jason.woltje 16889a46be row 264a r8 (#1769): build notes correction for the r7 red (account-migration, sqlite_sequence rows from the 3k-3l inserts), the whole-snapshot audit of every migrating test, the r7 run in Runs (sage-2621, duckworth-0129) 2026-10-22 08:07:43 -05:00
jason.woltje c3f43960eb row 264a r8 (#1769): the six migration tests that compare a whole store with sqlite_sequence after the 3k-3l step leave out the sessions and session_defaults sequence rows and assert them exactly, so any other new row still fails; usage-migration reads sqlite_sequence by name (sage-2621, duckworth-0129); unrun 2026-10-22 08:07:43 -05:00
jason.woltje 0431778465 row 264a r7 (#1769): build notes for the rebase onto landed 277 (980cec88c, sage-2608) and the per-session thread.claims; DEFERRED entry binds row 272 to make 274's user reply check session-scoped (sage-2591) 2026-10-22 08:07:43 -05:00
jason.woltje f3e9b6512c row 264a r7 (#1769): thread.claims reads each session's latest claim, so a thread with two held sessions lists both (sage-2591, duckworth-0124 C4); thread-claims.test threads get their default session and its T5 message names it (C5), T6 two held sessions; unrun 2026-10-22 08:07:43 -05:00
jason.woltje b7a05482bb row 264a r6 (#1769): build notes for the rebase onto landed 274 (0165be266, sage-2588): the four user-principal conflicts, users stay at the ruled seven verbs, and why 274's thread-scoped reply check needs no session clause yet 2026-10-22 08:07:43 -05:00
jason.woltje 4beb6433a4 row 264a r5 (#1769): build notes correction, rounds 1-4 never parsed three test files and no run claimed them; the round 5 gate runs every touched suite (sage-2581) 2026-10-22 08:07:43 -05:00
jason.woltje fbfaab3655 row 264a r5 (#1769): build notes for the rebase onto landed 236 (47a35bf43, sage-2579): the 194 conflicts, message-filter.test as the 19th test file, and the escaped quotes in three DEFAULT_SESSION blocks 2026-10-22 08:07:43 -05:00
jason.woltje 9ff668550f row 264a r5 (#1769): build notes name the op rename to legacy-3l and migration-3l by the step's target version (sage-2415) 2026-10-22 08:07:43 -05:00
jason.woltje 5baaaa447b row 264a r5 (#1769): build notes for the 3k-3l step on row 236 (8f51900b5, sage-2412): the renames, the merged pins and lists, gitea-migration reading both steps, and the in-memory dry run on 236's schema; README op value legacy-3l 2026-10-22 08:07:43 -05:00
jason.woltje c4a12de3d8 row 264a r3 (#1769): build notes say the generation fence is equivalent to the holder_run check it replaced, and M1 shows the refusal is tested (duckworth-0108, sage-2270) 2026-10-22 08:07:43 -05:00
jason.woltje 72fc5a330b row 264a r2 (#1769): generation fence for thread runs, a retired generation's approval, delivery and read refuse and write nothing (sage-2257); #route refuses an unknown session, README default-op note, Row 155 comment reworded, mutants M1-M3 in the build notes (duckworth-0107) 2026-10-22 08:07:43 -05:00
jason.woltje d6301dffce row 264a (#1769, T1 item 7): bus schema 3k to 3l, sessions below a thread
sessions and session_defaults are new and append-only. thread_claims, message_threads and
thread_opens are rebuilt in place with a session column, and thread_claims gains a generation
counted from claim rows. Each existing thread gets a default session with the thread's id; new
threads get a random one. Holder, per-run, release and delivery triggers key on the session,
and two id triggers keep session and thread ids apart (Duckworth checks 1-7, duckworth-0106).

The broker binds a thread run to a session (the default when the record names none), and
thread.open, thread.send and thread.messages take an optional session. New verbs session.list
and session.create (idempotent on op). Tests: sessions.test.mjs for T1 bus half, T2 and T7 with
a pinned 3k fixture; count, version and verb pins; hand-made test threads write the legacy
default session.

Built on row 236 (8f51900b5), which takes 3j-3k, so this step is 3k-3l by landing order
(row264 Q2, sage-2412). The op values are legacy-3l and migration-3l. gitea-migration.test reads
its store after both steps. Not run yet: every run waits for Sage's CLEAR.
2026-10-22 08:07:43 -05:00
jason.woltje ae525c4095 gizmoduck: row 264a bus design in the build notes: sessions and session_defaults, thread_claims, message_threads and thread_opens rebuilt with a session (legacy default id = thread id), generation, broker lookups by session, verbs, tests and pinned lists 2026-10-22 08:07:43 -05:00
jason.woltje b5630ca198 gizmoduck: row 264 build notes record Sage's Q1-Q7 rulings (sage-2235, 5f5b51dd6) 2026-10-22 08:07:43 -05:00
jason.woltje 26380e3f0b gizmoduck: row 264 phase A build notes, code at da9555ca2, consumer audit first pass, five proposed slices and Q1-Q7 to Sage (sage-2233, #1769, Vikunja 463) 2026-10-22 08:07:43 -05:00
47 changed files with 5468 additions and 280 deletions

No files matched your search

+8
View File
@@ -5303,3 +5303,11 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
### 2026-10-22 | Gander | row278 gate run3
Fresh gate on3e4c77ac6 under sage-2648: bus793/0, WebUI345/0/1 (esbuild version skip), all ten shell files425/0, touched files15/0, M1-M6 killed6/6. No timeout; sweep0 run-created leftovers. Runs1/2 void. Seven-path manifest8fa859a0 goes to Webby for final review. No landing or live change.
2026-10-22 11:29:09 UTC | Rocko | V502 carry/rerun on980cec88c: source407c8d7be and packet04a86cf33 match approved8e3c6a79b/9bf4683c6 patch-ids, no conflicts. Dewey0834 carry conditioned on rerun, satisfied under sage2616: stop-run13,processingHTTP6,bustasks9 allgreen (Node28/28),Python4/4,0skips/timeouts. Manifest5758d699,source5/5restored,cwdsweep[]. Doc rerun packet6af85df4b explicitly pushed to rocko/vikunja502-980c-20261022; no live action.
2026-10-22 11:30:17 UTC | Sage | Vikunja 502 (row 191 follow-up, no queue row) LANDED 6af85df4b, a fast-forward from 980cec88c. Before: the processing failure diagnostics and cleanup in stop-run, processing HTTP and bus tasks tests were Dewey-approved on the pre-194 base only (8e3c6a79b/9bf4683c6). After: Rocko's picks 407c8d7be/04a86cf33 carry the same patch-ids (37135ebe/0dbcda54), plus a doc-only rerun record 6af85df4b. The rerun on the new base was green: stop-run 13/13, processing HTTP 6/6, bus tasks 9/9, Python 4/4, 0 skips. Dewey carried the approve (dewey-0836). mosaic-task.mjs is unchanged, so N7 (runs suite) didn't trigger. Overlap with in-review rows 13 and 257 was clean. LIVE is still 86f95bb27.
2026-10-22 13:10 UTC | Sage | Rows 278 and 281 LANDED. Before: refactor at 6af85df4b (V502). Row 278 (#1783, Gander, Webby): the Console Agents view shows thread claims in their own section. It landed as 385a7194c at 12:39Z, ff, 9 commits, manifest 8fa859a0. Run 3 was green: bus 793, webui 345/0/1, ten shells 425/0 under the 3x limits (mos-1275), M1-M6 killed. Runs 1 and 2 were void, on fixture fixes 964b6b184 (placed by Sage) and 9696d1fcb (ruled by Mos, mos-1275). Webby approved (c30588, rev 3200). Done rev 3202, #1783 closed c30589. Row 281 (#1786, Megavolt, Dewey): a fire or a delete revokes the instance's host and Gitea grants. It landed as e479521d3 at 13:06Z, ff, 17 commits, manifest b73d4c81. Slot on 7979fb41e: bus 819, business 231, cli 837, dewey-bus 76 with the Docker gitea-grant test, shells green, M1-M22 killed. The M1 hang was fixed at its cause in b4a (mos-1273). Dewey approved (c30614, rev 3207). Done rev 3209, #1786 closed c30621. Skips on both: test-task's row 244 live-provider pair and test-queue's canonical-root check, plus webui's esbuild rebuild test on 278. After: refactor at e479521d3. DEFERRED moves the grant gap to Done and opens Dewey's N1/N2 text gaps. LIVE is still 86f95bb27.
2026-10-22 14:18:23 UTC | Sage | Row 264a LANDED (#1769, Gizmoduck, Duckworth): sessions per thread, schema 3k-3l. Before: refactor at e479521d3 (row 281). It landed as 84f53540d, ff, 21 commits, manifest 257408e9 (Duckworth approve rev 3214, comment 30640). Gate r13 on 2cc34aef8: 20 touched files, bus 827, cli 837, webui 345/1 named skip, ten shells under 3x limits, mutants M1-M4 killed on assertions. Corrections on the way: the kit stopped on first red, so four reds surfaced one at a time. r10 later3l missed a trigger (mos-1276). T2 had failed since r2 because refusals log action.refused (mos-1277, my sage-2257 wording). threads.test:341 kept the old trigger text (mos-1278). M2 was caught by the store trigger before any assertion, so it now drops the trigger too (mos-1279, four files with the SCHEMA_SHA256 pin). Deferred: actor_run pin binds row 269, esbuild skip to a later gate, session-scoped user reply binds row 272. No live change; LIVE is still 86f95bb27.
+548
View File
@@ -0,0 +1,548 @@
# 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.
+449
View File
@@ -0,0 +1,449 @@
# Row 269 build notes (Gizmoduck)
264b, T1 item 7: per-session native store, workspace and single-writer fence. Gitea #1774. Brief:
docs/plans/2026-10-21_t1-sessions.md (blob ff0be0c79), slices in agents/sage/work/queue-264/RULINGS.md
(5f5b51dd6). Reviewer Duckworth, gate owner Sage. Plan gizmoduck-0228, accepted with rulings in
sage-2707. Branch gizmoduck/row269 from refactor aca16404c. Row 253 lands first, and this branch is
rebased onto its tip, under a new name, before the gate.
Claim: rev 3217, op gizmoduck-0230, 2026-10-22 14:29:42Z. That was 21 s after Gander's slot opened
at 14:29:21Z (sage-2710). Reported in gizmoduck-0231 and not reverted, since a revert would be a
second queue write.
## D9 consumer audit, sessions.json (sage-2707 Q3)
Q3 allows `session` as an optional field with no version bump, if every sessions.json reader at
84f53540d selects by run and not by business, instance or thread. Every reader at 84f53540d:
| Reader | file:line at 84f53540d | Selects by |
| --- | --- | --- |
| `readSessions` (the only parser) | packages/seat/src/session.mjs:232-243 | nothing. Refuses any `sessionsVersion` but 1 (:241) and returns every entry with `live` |
| `stopSession` (`mosaic stop <run>`) | session.mjs:249-250 | run (`s.run === run`) |
| `launches list` | packages/cli/src/cli.mjs:526-530 | nothing. Prints every entry, and `--json` dumps them whole |
| host start, `leftover` | packages/cli/src/launcher.mjs:235 | business, then `recover()` works one entry (one run) at a time (:955-972) |
| runtime launch settings | packages/bus/src/runtime.mjs:149 -> packages/business/src/launch-settings.mjs:166 -> packages/business/src/launch.mjs:116-135 (`usageCounts`) | business. Counts entries per provider and model |
| Console settings (C1, duckworth-0150) | packages/webui/src/reads.mjs:234 -> the same launchSettings `read()` -> readSessions | business, through usageCounts. Any throw becomes `launch: {refused: 'not-configured'}` |
| stack-release R guard (C1) | scripts/stack-release.sh:2091-2096, its own JSON.parse, no version check | nothing. Takes `pid` and `runnerPid` of every entry in `sessions` and fails `sessions-live` if one is alive in /proc |
Not a reader: stack-release.sh:2199-2204 checks only that no sessions.json exists. packages/ledger
readSessions (ledger.mjs:146) is a different function that reads seat directories.
launch-settings `current()` (:129) doesn't call `sessions()`, so the limits the bus enforces don't
read the file; only the `limits` verb's `read()` (:166, runtime.mjs:292) does.
Tests that read the file: packages/cli/tests/launcher.test.mjs (`readSessions` at :164 through
:1097, each by run, by instance or whole) and packages/cli/tests/host.test.mjs:830 (file absent).
Not a file reader: `Registry` (session.mjs:192-230). Its list starts empty (:193), and the constructor
writes it over the file (:198). `running(business, instance, thread)` at :217 is the one key on the
thread, but it reads only the in-memory list this host added (launcher.mjs:529, removed at :553).
Its callers are launcher.mjs:289 (check) and the thread pass. This row changes that key (see Design).
Result: no file reader keys on the thread. Q3's condition holds as worded.
One more finding sends me to v2 anyway. `recover()` rebinds a leftover run with
`{business, role, run, harness, pid, startTime, thread}` and no `session` (launcher.mjs:964).
The broker compares that record's body with the recorded `session.launched` body, and the body
carries `session` whenever the launch record had one (broker.mjs:945). A mismatch refuses
`launch-record-mismatch` (broker.mjs:952). Each new thread run binds with its session (Design), so
after a rollback to 264a, a 264a host that finds a 264b entry logs "not ended, bindLaunch refused:
launch-record-mismatch" (launcher.mjs:968), and the run's claim stays held. The host has
already killed the process (:956-962), but it can't end the run. With v1 and an optional field,
that failure is quiet.
Decision: sessions.json goes to v2 (`SESSIONS_VERSION` 2). New code reads v1 and v2 and writes v2.
A v2 file keeps the `sessions` array and every v1 field of each entry, `pid` and `runnerPid`
included, so the R guard (stack-release.sh:2094-2095) reads it unchanged (C1).
A v1 entry has no `session`, and it is treated as the thread's legacy default session (D9). After a
rollback, a 264a host refuses to start with exit 3, "sessions file is malformed"
(session.mjs:241 via launcher.mjs:237), and the README's remedy applies: check which sessions still
run, then move the file aside. Tradeoff: a rollback while runs are live needs a hand step, but it
fails loudly at host start instead of leaving held claims. The rollback note names this refusal.
## D9 consumer audit, threadsState and threadIdle (sage-2707 audit addition)
These are trusted launcher APIs, not socket verbs (packages/bus/README.md:288, :294), and they're
reached only through the host op table (packages/bus/src/process.mjs:23-24, :54-55;
runtime.mjs:561-562). At aca16404c:
| Consumer | file:line | What it reads |
| --- | --- | --- |
| launcher thread pass | packages/cli/src/launcher.mjs:596, :611 | every row's `thread`, `holder`, `pending`, `unread`; threadIdle `{business, thread, run}` |
| bus end-launch.test | packages/bus/tests/end-launch.test.mjs:193, :198 | whole rows (deepEqual), and `not-holder` |
| bus thread-migration.test | packages/bus/tests/thread-migration.test.mjs:139 | `thread, holder, pending, unread` |
| bus threads.test | packages/bus/tests/threads.test.mjs:352, :375-376, :407, :416 | rows keyed by `thread` |
| cli launcher.test stub | packages/cli/tests/launcher.test.mjs:989 | wraps `host.op`, counts the op name |
| cli lifecycle.test stub | packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 | passes through, wraps `runtime.threadsState` |
| cli thread-launch.test stub | packages/cli/tests/thread-launch.test.mjs:90-91, :163, :264-265, :304, :348, :443 | `.find(x => x.thread === ...)` then `holder` |
Console and webui: no reader. `git grep threadsState` over packages/webui, packages/control-board and
scripts has no hit. The webui reads `thread.claims` (packages/webui/src/serve.mjs, cli.mjs,
public/bus.js, tests shell.test, thread-claims.test, thread-claims-browser.test). This row doesn't change
`thread.claims`. The webui's `session.launched` line reads only `harness` and `address`
(public/bus.js:151), so a `session` field in the body changes nothing it shows.
## D9 consumer audit, workspace and native store
| Consumer | file:line at aca16404c | Change in this row |
| --- | --- | --- |
| `workspaceDir` | packages/seat/src/session.mjs:49 | unchanged, still the non-thread path and the legacy path |
| launcher start, workspace | packages/cli/src/launcher.mjs:397-403 | a thread run uses its session workspace (Q1) |
| launcher instance delete (V5) | launcher.mjs:841-866 | also retires the instance's session workspaces |
| `threadStore` | session.mjs:59-74, launcher.mjs:388-393 | becomes the legacy branch of `sessionStore` |
| sandbox binds | packages/seat/src/sandbox.mjs:102-125 | the workspace gets a separate in-sandbox path |
| harness `transcript()` | packages/plugins/harness/pi/index.mjs:75, claude-code/index.mjs:130 | none: `{session, projects}` keeps its shape |
| in-sandbox readers of `session.workspace` | packages/harness/src/runner.mjs:332, :386, :475-476; bundle.mjs:258, :327; gate.mjs:152 | none: they get the in-sandbox path |
| task runner workspaces | scripts/mosaic-task.mjs:351 | none: `<dataRoot>/workspaces/<task>` is a legacy root the overlap check covers |
| git socket | packages/cli/src/git-socket.mjs:7 | none: it takes bundles and reads no workspace |
## Pi and Claude resume, read offline (sage-2707: no pi run while P19 383 holds)
Pinned `@earendil-works/pi-coding-agent` 0.85.1 (package.json:8), read in node_modules/.../dist:
- adapters/pi/adapter.sh:46-52 passes `--session-dir "$MOSAIC_SESSION_DIR"`, plus `-c` when that
directory isn't empty.
- With `-c` and an explicit session dir, `continueRecent` filters by cwd
(core/session-manager.js:1244-1250). It opens the newest `*.jsonl` whose header cwd resolves to
the process cwd (:397-410, `sessionCwdMatches` :393-395). With no match, it makes a new session in
the same directory (:1251), and nothing fails.
- Both adapters `cd "$MOSAIC_WORKSPACE"` (pi adapter.sh:34-36, claude adapter.sh:46-47), the runner
sets `MOSAIC_WORKSPACE` from `session.workspace` (runner.mjs:386), and the sandbox binds the
workspace at its host path (sandbox.mjs:112, `argsOf` :128-131). So a legacy transcript's header
cwd is `<dataRoot>/workspaces/<business>/<instance>`.
- Opening a file whose header cwd doesn't exist exits 1 in a headless run (main.js:536-548,
core/session-cwd.js:2-15). So `--session <file>` is no way around it either.
Result: a legacy default session run from `session-workspaces/.../<session>` as its cwd would
quietly start a new pi conversation, and its old history wouldn't resume. Claude Code keys its
projects directory on the cwd, and the adapter resumes by id (claude adapter.sh:53-59). I haven't
read the Claude binary, so whether `--resume` finds an id saved under another cwd is unverified. I
treat it as the same break.
## Design
### Legacy mapping (D9, M7)
A session is legacy when its row has `op='legacy-3l'`, which the migration gave every pre-3l thread
with `id` = the thread id (3k-3l.sql:50-51). The rule reads only an immutable row, never the disk.
- A legacy session keeps the thread's existing store, `threads/<b>/<t>/{session,claude-projects}`
(`threadStore`, unchanged).
- Its workspace is new (Q1), `session-workspaces/<b>/<instance>/<session>/`, but inside the sandbox
it's bound at the legacy path `workspaces/<b>/<instance>`. The cwd its transcripts recorded is
unchanged, so pi `-c` and Claude `--resume` find them. The host's old instance workspace isn't
mounted, so it stays as history (Q1, D9). Every in-sandbox reader gets the sandbox path (table
above), and host git (`ensureCheckouts`) gets the host path.
- Every other session uses D2's layout for both, and its sandbox path is its host path.
- A thread made by 264a's `thread.create` has a random default id (broker.mjs:5993), so this rule
doesn't call it legacy. A 264a launcher ran that thread from `threads/<b>/<t>/session`. A start
for a session whose thread has no legacy-3l session, while `threads/<b>/<t>/session` exists,
refuses `legacy-store-unmapped`, so it fails closed and doesn't start a fresh conversation. The
re-pin plan lists such threads. If 264a and 264b go live together, there are none.
### Broker
- `threadsState(business)`: one row per session of every thread, not just the default, ordered by
thread seq, then session seq. Each row adds `session` and `legacy` to the fields it has now.
`#threadRows` joins `sessions` in place of `session_defaults` (broker.mjs:868-896).
- `threadIdle(record)`: same keys. The row is the one for the session the run was bound to
(`#runs` entry, broker.mjs:927), so a run is idled against its own session.
- `bindLaunch`: no new refusal. It already takes `session` and puts it in the body (broker.mjs:945).
The broker half of the single-writer fence is the per-session claim that exists now:
a second claim on a held session refuses `thread-already-held` (broker.mjs:4815). A
`session-running` refusal at bind would break two old tests that bind a second unended run to one
session and then expect the claim refusal (threads.test.mjs:88-89, sessions.test.mjs:119-120).
The launcher's registry check and the workspace lock are the host halves.
### Seat
- `sessionStore(dataRoot, business, thread, session, { legacy })`: the legacy branch is
`threadStore`. Otherwise it's `threads/<b>/<t>/sessions/<s>/{session,claude-projects}`, with
`threadStore`'s checks on every segment and directory (ID_PATTERN, no `..`, owner, 0700, no
symlink), giving the same `{dir, session, projects}` shape.
- `sessionWorkspace(dataRoot, business, instance, session, { live })`: makes
`session-workspaces/<b>/<i>/<s>` 0700 with the same checks, then compares realpaths. It refuses
`session-workspace-overlap` when the path is inside, or contains, `<dataRoot>/workspaces` (legacy,
`.retired` and task workspaces) or any workspace in `live`. A segment that fails the lstat checks
refuses `session-workspace-unsafe` first (C3).
- Lock: `lockSessionWorkspace(path, owner)` makes `<path>.lock` with O_EXCL, next to the workspace
and outside every mount. It holds the host's pid and start time, and the run's outer pid and
start time once spawned. A lock whose host and run are both gone is stale and is taken over.
Anything else refuses `session-workspace-busy`. start() takes it before `ensureCheckouts` and
releases it only after the run's `close` (proven exit) or a failed start.
- `Registry`: entries gain `session` and `workspace`, and the key becomes `running(business,
instance, thread, session)`. `SESSIONS_VERSION` is 2, and `readSessions` reads 1 and 2.
### Launcher
- check(instance, { thread, session }): `instance-running` is per session. Q2 adds
`legacy-run-live`: a start for a thread instance refuses while any registry entry or unrecovered
leftover for that instance has no `session`.
A survivor is still bound and ended host-lost (launcher.mjs:1030-1040), so the broker sees its
claim free while it runs. A released claim doesn't prove the run has exited. Only this launcher
check holds the instance then, for sessionless survivors and thread starts only (duckworth-0158
N1). recover() waits up to 5 s per leftover for the outer pid and the runner (N2, see "recover()
change, for a design read").
Residual (duckworth-0159 N3): a survivor with a `session` counts nowhere, because :309 counts
only sessionless entries. The same session could start again while the old runner lives. The
lock doesn't catch it either: it records the outer pid as runPid (launcher.mjs:574), so with the
outer pid gone it reads stale and is taken over. The base has the same exposure. In practice
the runner dies with its pid namespace when the outer bwrap is killed.
- start(): the store and workspace come from the session. The lock covers checkouts and spawn.
`bindLaunch` gets `session`, and the entry records it.
- threadPass(): one pass per session row. `startedFor` and `refusedThreads` key on the session.
The `thread-idle`, `refused` and `end` records add `session`.
- recover(): passes the entry's `session` when present.
- remove(): moves `session-workspaces/<b>/<instance>` to `session-workspaces/<b>/.retired/<instance>-<transaction>`
after the legacy move, and records `workspace-retired` with that path.
- Admission (RULINGS Q5, D8): a named session starts only through threadPass, then check(), then
start(), so it passes through every `admission()` call start() makes now (launcher.mjs:382-524).
There is no second start path.
### Sandbox
`binds()` takes `workspaceDest` (default `workspace`), the in-sandbox path for the rw workspace
bind. The store binds keep their shape: only the selected session's two directories.
## Tests
T1 run half, with the fake harness adapter from thread-launch.test (no pi or Claude binary):
two sessions of one thread run at once, each with its own sentinel history, store and workspace;
stopping one and restarting it resumes only that one. The legacy session resumes from the old
store at the old in-sandbox cwd. A non-thread instance gets `thread-not-allowed`.
T2: a second start on a live session's workspace refuses `session-workspace-busy` before any
checkout; a hand-made live lock from another host refuses; a symlinked or nested session root
refuses `session-workspace-unsafe`; a workspace root symlinked into the session path refuses
`session-workspace-overlap`; a store segment symlink refuses `thread-store-refused`. Under a halt, a
named session's start refuses `processing-halted` and spawns nothing. A generation change after
check() refuses `processing-generation-changed` and spawns nothing (M6).
N1 (DEFERRED.md:474-480): `refusedOnce` in packages/bus/tests/sessions.test.mjs pins `actor_run`.
## Old assertions that change (C4, duckworth-0150, mos-1285)
Every pre-existing test that reads threadsState at aca16404c, checked against one row per session:
- packages/bus/tests/end-launch.test.mjs:193-195 deepEquals the whole row. This one changes. Mos
allowed it in mos-1285 as an exact update: still a whole deepEqual on threadsState('demo'), still
exactly one th-1 row, every old key and value the same, and two new keys with literal values,
`session: 'th-1'` (the fixture's legacy default, :14-15) and `legacy: true` (op `legacy-3l`). The
commit message names mos-1285 and the old row shape.
- packages/bus/tests/threads.test.mjs:352, :375-376: fromEntries keyed by thread. Neither test, and
neither fixture (`twoThreads`), makes a second session (no `session.create` in the file), so each
thread still has one row. Unchanged.
- packages/bus/tests/thread-migration.test.mjs:139: maps four fields of a one-session thread.
Unchanged.
- packages/cli/tests/thread-launch.test.mjs:163, :304, :348, :443: `find` by thread. One session
per thread in every fixture. Unchanged. The default session also sorts first, by session seq.
- packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 and thread-launch.test.mjs:90-91, :264-265
pass threadsState through. Unchanged.
- packages/cli/tests/thread-launch.test.mjs:182 (found in the code round, after mos-1285). The test
makes threads with `thread.create` (:171-172), so A's default session has a random id and op
`default` (broker.mjs:5992-6009), not `legacy-3l`. The brief puts every new store in D2's layout
(t1-sessions.md:100, :294), so A's store moves to `threads/acme/<A>/sessions/<A's session>`. The
proposed exact update changes only the `store` path on :182, taking the session id from
`threadsState` (the test already reads it at :162). Lines :183-185 stay as they are. One added
line asserts that `threads/acme/<A>/session` doesn't exist. This goes to Mos through Sage, and no
code commit edits the file until Mos rules.
- The same file's :375 (`../evil` refuses `thread-store-refused`) and :381, :384 (no `threads`, no
`workspaces`) stay unchanged: start() checks the store before it makes the session workspace.
Any other old assertion the code changes goes to Mos through Sage before the commit. After the
first run I survey every touched file whole for one.
## Mutants
Each kill counts only as an assertion failure in a test that runs that path, never a throw or
another fence (sage-2707, C3 duckworth-0128).
| Id | Change | Killed by |
| --- | --- | --- |
| M1 | `sessionWorkspace` drops the session segment: a thread's sessions share one checkout | T2 same-thread fence: `session-workspace-busy`, no second spawn |
| M2 | the session segment becomes the session name: two threads' `default` sessions share one checkout | T2 cross-thread fence, same assertion |
| M3 | the lock ignores a live holder | T2 hand-made lock: `session-workspace-busy` |
| M4 | the realpath overlap check removed | T2 nesting: `session-workspace-unsafe` |
| M5 | the launcher passes the thread's default store for every session | T1: the second session's sentinel history is its own |
| M6 | start() calls a no-op in place of every `admission(admissionGeneration)` (launcher.mjs:382, :396, :407, :420, :495, :499, :520, :524) for a non-legacy session | the generation test (below): `assert.rejects` with code `processing-generation-changed`, then `assert.equal` spawn count 0 |
| M7 | the legacy session maps to `sessions/<s>/` | the D9 test: the legacy run resumes the sentinel in `threads/<b>/<t>/session` |
| M8 | a non-default session binds its workspace at the legacy `workspaces/<b>/<instance>` in-sandbox path | T1: `assert.equal` that a named session's in-sandbox workspace is its `session-workspaces/<b>/<instance>/<s>` path, read from the fake adapter's recorded cwd (sage-2717) |
| M9 | `#threadRows` back to the default session only | T1: the named session's message starts its run |
| M10 | the `legacy-run-live` refusal removed from check() | the legacy-run-live test: `outcome` equals `[[S, "legacy-run-live"]]` |
| M11 | recover() keeps a survivor only when the outer pid outlived the kill (the runner ignored) | the legacy-run-live test, same assertion |
| M12 | check() reads every survivor, live or not | the legacy-run-live test: after the runner exits, `settle` gives one launch |
M6 and the halt fences (C2, duckworth-0150). A plain halt never reaches start(). tick() admits first
(:579) and runs no thread pass while halted (:583), and check() admits at :282 and :376. The kill
test lands the change between check() returning and the spawn:
- The fake host's `credentialStatus` op (start() calls it at :395, after check() returned and after
:382) records that it ran, then halts and resumes processing through `transition`. That leaves
`halted` false and a new generation (state.mjs:115-121).
- The tick and check fences see no halt, and start()'s second check() (:419) takes its own
generation, so it passes too. withProcessing at :406 has no parent context (the launcher never
opens one) and checks only `halted`, so it passes. The one fence left is start()'s own
`admission(admissionGeneration)`, which refuses `processing-generation-changed` at :396.
- The test asserts the hook ran (check passed), then the refusal code, then no spawn.
M6 removes all eight calls because removing only :382 leaves :396 to refuse. The T2 halt test stays
as written in Tests, but it doesn't kill M6 and isn't counted for it.
M4 and the segment checks (C3, duckworth-0150). The lstat check on each segment refuses a symlinked
session root before the realpath compare runs, so the compare gets its own code,
`session-workspace-overlap`, and the segment check keeps `session-workspace-unsafe`. M4's kill input
passes every segment check: `<dataRoot>/workspaces/<b>` is a symlink to
`session-workspaces/<b>/<i>/<s>`. Each segment of the session path is a real directory, and only the
realpath compare finds that the session path contains the legacy workspace root. The test asserts
`assert.rejects` with code `session-workspace-overlap` and no spawn. With M4 the start spawns, so
the rejection assertion fails. The segment test keeps asserting `session-workspace-unsafe` for a
symlinked session root, so it tells the two codes apart.
Duckworth's check for the early look (sage-2717): a non-default session never gets the legacy
`workspaces/<b>/<instance>` bind path. Only a session with op `legacy-3l` takes `workspaceDest`
set to the legacy path. M8 is that mutant.
### Code round corrections (local, not yet run)
Written before the first run, which waits for Sage's CLEAR.
- Tests live in two new files: packages/cli/tests/session-launch.test.mjs (T1, D9, T2) with a new
stub adapter, fixtures/session-adapter.mjs (thread-adapter.mjs's turn plus the run's cwd and its
entries; thread-adapter.mjs is unchanged), and packages/seat/tests/session-workspace.test.mjs
(sessionStore, sessionWorkspace, the lock, sessions.json v2). The new launch tests copy
thread-launch.test.mjs's host helpers rather than change them, so no other old test file changes.
- M1 and M2 kill in T1, not in a busy test. A test that expects `session-workspace-busy` still
passes when sessions share a path. T1 starts thread A's default and named sessions and thread B's
default at once and asserts no refusal, three live entries and three distinct cwds. With M1 (A's
two sessions) or M2 (the two `default` sessions) the second start refuses busy, and the
`deepEqual(refused, [])` assertion fails.
- Every wait a mutant could stall returns what it last saw (`settle`, `outcome`), so a kill is the
assertion after it, never a timeout. That covers M3, M4, M6 and M9, where the mutant's run starts
or never starts.
- The M6 test also carries the halt fence. start() admits right after credentialStatus, before it
makes the session workspace, so the first refusal leaves no workspace. On the retry the recheck's
mayLaunch op halts after the lock is taken. The recheck refuses `processing-halted`, and the test
asserts that the lock is gone (startLocked's throw releases it).
- The lock's release() runs once. A second call would unlink a lock another host has written since.
- `legacy-run-live` now has a test (Duckworth's early warning in duckworth-0156). Before, recover()
kept a survivor only when a bwrap pid outlived SIGKILL, which a test can't make. It also ended
a leftover whose runner still ran once the outer pid was gone, so the refusal never saw that run.
Now recover() waits up to 5 s on the outer pid and the runner. It keeps any leftover where
either still runs, and logs `run <run> (<instance>) from an earlier host still runs`. check()
reads only survivors that still run, so a start goes ahead once the run has exited. The test
writes a sessionless leftover whose runner is a sleep child and whose outer pid is gone. It
asserts the refusal, no launch, no session workspace, and then one launch after the child is
killed. M10-M12 cover it. The registry and pre-recover leftover terms stay. A launching host
can't reach them for a thread instance (check() refuses `session-required` first, and listen()
runs recover() first), but they cost nothing.
### recover() change, for a design read (sage-2750)
Sage asked for this because recover() predates the row. Lines are launcher.mjs at 3d29876df plus
the log-line edit after it, against aca16404c:947-976.
- What stays the same: every leftover is still bound and ended as host-lost (:1030-1040, base
:963-973). The survivor push (:1026-1029) comes before the bind, and the loop carries on past it,
so a kept leftover isn't left unended. Its bus run ends and its role is released, as in the base.
The kill is unchanged too: SIGKILL only to a live outer pid whose cmdline is bwrap (:1018-1021,
base :956-959).
- What changes:
1. The wait after the kill runs while the outer pid or the runner still runs (:1023). In the
base it waited on the outer pid only (base :961).
2. A leftover where either still runs after that goes on `survivors` and is logged (:1026-1029).
3. check() counts a survivor only while it still runs (:309, `liveRun` at :255).
- a. Nothing in this row kills a kept leftover's runner. The base didn't either: it ended the run
and let the runner go on, unseen. The run is still ended (:1038), so the runner holds no role.
Its capability died with the old broker (:1004-1008). The only thing a survivor does is refuse
`legacy-run-live` for sessions of its instance while it lives, and each refusal is a `refused`
launch record. For an operator, the log line names the run, the outer pid and the runner pid
(:1028). Killing that pid clears it. check() re-reads liveness on each pass (:309), so the next
tick starts the session with no host restart. If bindLaunch refuses (:1032-1036), the run stays
unended and logged, as in the base (base :965-969). The survivor entry doesn't change that.
- b. The 5 s wait only applies inside the kill branch (:1018-1024), so only to a leftover whose
outer bwrap was still alive. A leftover with nothing running doesn't wait, and nor does one
whose outer pid is gone: neither enters the branch. The wait ends as soon as both processes are
gone. The base already waited up to 5 s in the same branch. It can now last longer only if the
runner outlives its outer pid. With `--unshare-pid` and `--die-with-parent` the runner dies when
the namespace goes.
- c. Old tests that run recover() with a launching host and a leftover:
- launcher.test.mjs:885-943, the "host that died hard" pair. It asserts the outer pid and the
runner are gone right after setup (:917-918), the host-lost log (:919), one session.ended
(:920-922), one end record (:924), an empty sessions.json (:925), and then "already ended" for
a leftover whose processes are dead (:937-941). None of these expected values change.
Waiting on the runner makes :918 hold sooner, not later. The new log line can appear only if
a process outlives 5 s, which would already fail :917-918. The dead-process third host takes
no wait and logs no survivor.
- launcher.test.mjs:950-971 (`--no-launch`) returns before the loop (:1012-1015). It's
unchanged.
- stack-release.test.mjs:1928 writes `{pid: process.pid}` with no start times. `liveRun` is
false for it, and that test reads it through bus status, not recover().
- verbs.test.mjs:213 and the seat tests build a Registry with no launcher, so they aren't
affected.
No expected value in an old test changes, so nothing goes to Mos under mos-1272 for this. If a
run shows otherwise, I stop and send it before any change.
- Fallback, if Duckworth or Sage doesn't accept this: revert recover() and check() to the base
behaviour, keeping only the sessionless check over the registry, the pre-recover leftovers and
bwrap survivors. Drop the legacy-run-live test and M10-M12, and name the untestable path here.
- N1 (refusedOnce pins `actor_run`) changes a pre-existing test. Mos allowed it in mos-1292 (copy
mos-1294) on mos-1285's terms, and it's committed as a81b3446e, with a closing line on the
DEFERRED.md entry.
## Rollback
264b writes sessions.json v2. A 264a host given that file refuses to start, exit 3, "sessions file
is malformed" (session.mjs:241 via launcher.mjs:235-237). Session workspaces and per-session stores
stay on disk, and a 264a host never reads them.
What else refuses on a 264a stack while a v2 file is in place (C1, duckworth-0150). All of them go
through the 264a readSessions, which throws SeatError "sessions file is malformed" (session.mjs:241):
- `mosaic launches list` and `mosaic stop <run>`: exit 2 with that message (cli.mjs:526, :465).
- the bus `limits` verb: `settings.read()` throws (runtime.mjs:149, :292, launch-settings.mjs:166),
so the verb fails with that message. The limits the bus enforces still work, since `current()`
doesn't read the file. I haven't traced the code a bus caller sees for that throw.
- the Console settings view: launch shows `not-configured` (reads.mjs:234).
- the R guard keeps working (above).
All of these read the file on each call, so step 3 below clears them without a restart.
Console restart: the shape of what the Console reads doesn't change. usageCounts still counts
entries per provider and model, and reads.mjs keeps its fields. What changes is the file, and a
Console still on 264a code shows launch `not-configured` from the first v2 write until a human
restarts it on a 264b copy. The re-pin that carries this row needs its Console restart (Jason or
Mos), done right after the host's, as it does for a read-shape change. Step 3 covers the reverse
case after a rollback with no restart.
Seeing live sessions without `launches list` (C1): read the file directly the way the R guard does.
For each entry in `sessions`, a session is live if `/proc/<pid>/stat` or `/proc/<runnerPid>/stat`
exists and its state isn't `Z` (stack-release.sh:2080, :2095). For a stronger check, compare
`runnerStartTime` with field 22 of `/proc/<runnerPid>/stat`, which is what readSessions' `live`
does through startTimeOf.
How a rollback host gets past exit 3 (sage-2717), run on NEW before the rollback activate:
1. Stop the bus host on NEW. Its stop SIGKILLs what is left and ends those launches as `killed`
(packages/cli/README.md:470).
2. Read `sessions.json` with NEW's readSessions (`mosaic launches list`, cli.mjs:526-530). Go on only
if it shows no live entry: every listed runner is gone (pid and start time no longer match). If
NEW's CLI is already gone, use the /proc read above.
3. Move `sessions.json` aside under a dated name, never delete it (packages/cli/README.md:478).
4. Then roll back. The 264a host starts with no leftover file and nothing to recover.
A live entry at step 2 stops the rollback until that run ends or `mosaic stop <run>` ends it.
Which re-pin plan step carries it: no re-pin plan names 264b yet. The 84f53540d re-pin carries 264a
only. The first re-pin whose NEW includes this row puts steps 1-4 in its "Stop conditions and
rollback" section, before `rollback --plan`, the section the 10-24 plan uses
(agents/sage/work/window-plan/repin-1024-draft1.md:144-148). I'll name it to whoever drafts that
plan when the row lands.
## Run limits (sage-2707)
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. Records are from agents/gizmoduck/work/queue-264/BUILD.md "Run
limits" unless named.
| 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); the rest have none | 300 s |
| bus suite | 23 s | 900 s |
| cli suite | 591 s (row 281 gate) | 1800 s |
| webui suite | 57 s | 600 s |
| seat suite | none recorded | 600 s |
| harness suite | none recorded | 600 s |
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
| each mutant probe | 1 s wall (264a M1-M4) | 300 s |
+156
View File
@@ -0,0 +1,156 @@
# Row 270 build notes (Gizmoduck)
264c, T1 item 7: the compaction count, exact for Pi. Gitea #1775. Brief:
docs/plans/2026-10-21_t1-sessions.md section 4 (D6), with RULINGS Q6 (Pi exact, Claude `unknown` with
the Vikunja 466 reason). Reviewer Duckworth, gate owner Sage. Branch gizmoduck/row270 from
gizmoduck/row269 2d0d407ba (sage-2797, Q220). Row 269 lands first, and this branch rebases with it.
## Contract for 264e (pinned for Huey, row 272)
The surface reads the count from `session.list`. Each row gains one key, `compactions`:
```json
{ "id": "...", "at": "...", "name": "...", "predecessor": null, "default": true,
"compactions": { "count": 3, "known": true, "reason": null, "at": "2026-10-22T16:40:00.000Z" } }
```
- `known: true`: `count` is the exact number of completed compactions over the session's lifetime,
an integer of 0 or more. `reason` is null.
- `known: false`: `count` is a lower bound (an integer) or null when there is no bound. Show
`unknown` with the reason, never a bare 0. `reason` is one of:
- `harness-unsupported`: Claude Code, until Vikunja 466 pins its format.
- `not-collected`: no collection has run for this session yet.
- `store-unreadable`: a store file couldn't be read, or isn't a Pi session.
- `store-incomplete`: a stream file got shorter, was replaced, or has unreadable lines, so
entries may be missing.
- `at`: ISO time of the last collection, or null if none has run.
- A clear successor (264d) starts at `known: true, count: 0` once its first collection runs. The
retired session keeps its row and its count.
- On a base without 264c, the key is absent. The surface shows the count as unavailable, which is
what Huey's plan does (Sage Q1).
No new verb and no new input key. Access is `session.list`'s: the thread owner, or the tester.
## Pi source contract (pinned offline at 0.85.1)
`@earendil-works/pi-coding-agent` 0.85.1 (package.json pin), read in node_modules:
- One line per completed compaction: `{"type":"compaction","id":...,"parentId":...,"timestamp":...,
"summary":...,"firstKeptEntryId":...,"tokensBefore":...}` from `appendCompaction`
(dist/core/session-manager.js:818-833). The two callers write it after the summary exists
(dist/core/agent-session.js:1539, :1826). A start or an aborted compaction writes no entry.
- What doesn't count: a `message` entry with role `compactionSummary` (a rendered summary,
docs/session-format.md:154), any message inside a compaction's `retainedTail`, and
`branch_summary` entries.
- Identity: a compaction is its (entry `id`, entry `timestamp`) across the whole store, not per
stream. Pi's ids are unique only within a file (`generateId(this.byId)`, session-manager.js:21-29),
and `createBranchedSession` (:1093-1140) and `forkFrom` (:1264-1300) copy entries into a new
`*.jsonl` in the same directory with their id and timestamp unchanged. A (stream, id) key would
count a compaction made before a branch or fork once per copy. Reported to Sage in
gizmoduck-0262; accepted in sage-2814. The ledger line records the stream it was first seen in.
- Residual (sage-2814): two distinct compactions that share an id and a timestamp count once. That
needs an 8-hex collision between two files at the same millisecond. It is the one undercount
this key allows, and it never overcounts.
- Partial trailing line: wait. Only lines that end with a newline are read, as
packages/conversation/src/pi.mjs:35-38 does.
## Storage: A, a host ledger (sage-2806)
Sage ruled A over B. A keeps the count in `<store dir>/compactions.jsonl`, a file beside the
mounted `session/` and `claude-projects/`, in the store directory no bind mounts
(sandbox.mjs:122-127). B would have been a bus table. The tradeoff: A needs no schema step, so 271's
clear journal stays the only new schema step in the 264 rows, and the count lives with the native
store it is collected from. The cost is that the bus reads a file on each `session.list`, through a
reader runtime.mjs injects, because broker.mjs is a RUNNER_FILES file and can't import seat code.
### Ledger
`packages/seat/src/compactions.mjs`. One JSON object per line, `v: 1`:
- `{kind: "compaction", entry, completedAt, stream, at}`: once per (entry, completedAt) key.
- `{kind: "cursor", stream, bytes, tail, at}`: the stream was read through `bytes`. `tail` is the
sha256 of the up to 4096 bytes ending there. Written only when it moved.
- `{kind: "collected", at, known, reason, harness, version}`: one per collection.
Conditions (sage-2806):
1. Append-only, the launcher its only writer, a key never written twice, nothing rewrites or
truncates. The key is the one above, not (stream, id).
2. Mode 0600, opened `O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW`. **Lock used: the session's workspace
lock** (`lockSessionWorkspace`, taken in launcher.mjs `start()` and released in the run's exit
`finally` or on a start failure). Collection runs at run start, after `check()` gives the
harness and before the spawn, and at run end, before the lock is released. Each collection is
one `writeSync` loop on one O_APPEND descriptor. A collection error is logged and recorded as a
`compactions` launch-log event and never stops or fails the run.
3. `compactionCount(dir)`:
| Ledger | Answer |
| --- | --- |
| missing | `not-collected`, count null |
| unreadable, not a regular file, a symlink | `store-unreadable`, count null |
| any bad line, a torn last line included | `store-incomplete`, count a lower bound or null |
| any collection under a harness other than pi | `harness-unsupported`, lower bound or null |
| any collection that found a stream shrunk, changed under its cursor or gone, or a bad stream line | `store-incomplete`, for good |
| the last collection hit an unreadable stream or a non-session first line | `store-unreadable` until a clean collection |
| otherwise | `known: true`, the exact count |
Never count 0 when unknown. After a torn line, the next write starts on a fresh line, so the torn
line stays one bad line and the new lines stay whole.
4. `packages/seat/tests/compactions.test.mjs` "the ledger is under no sandbox mount": for a legacy
and a non-legacy store, no `binds()` source contains the ledger path.
5. This section.
### Wiring
- launcher.mjs: `compactions(store, model.harness, ...)` at start (after `newRun`) and in the exit
`finally` before `lock?.release()`. Thread sessions only.
- broker.mjs: a `compactions` constructor option, TypeError-checked like `access`. `session.list`
selects `op` to tell a legacy session (`legacy-3l`) and adds `compactions` to each row; `op` is
not returned. No reader gives `not-collected`; a reader that throws gives `store-unreadable`.
No import added.
- runtime.mjs: passes `({business, thread, session, legacy}) => compactionCount(storeDir(...))`.
- packages/bus/README.md: the `session.list` row.
Huey's 272 broker hunk (reply citation predicate, near broker.mjs:5167 on huey/row272) doesn't
touch `session.list`.
## Tests
New files only, so no pre-existing test changes:
- packages/seat/tests/compactions.test.mjs: zero, a failed start and non-compaction entries; one and
three; a compaction copied into a branched stream counts once (sage-2814); one copy in two
streams in one collection; the same id at another time counts twice; resume with cursors and
keys written once; ledger mode 0600; a partial trailing stream line waits; a missing, a directory
and a symlinked ledger; a torn and a bad ledger line; shrink, rewrite and remove sticky; a
symlinked stream and a non-header first line; Claude harness-unsupported and sticky; `storeDir`
against `sessionStore`; the mount test.
- packages/bus/tests/session-compactions.test.mjs: the key per row with the store identity
asked for, legacy true for `legacy-3l`, no reader and a throwing reader, the TypeError.
Run limits (mos-1275): each file whole with `timeout -k 10 120`, TMPDIR from
`mktemp -d /mnt/storage/scratch/tmp/gXXX`. Then the touched files' suites: seat session.test,
sandbox.test; cli thread-launch, session-launch; bus sessions, project-runtime, tester-writes.
## Mutants
Each must turn a new test red:
- M1 key per stream at write: `keyOf(f.entry, f.completedAt)` → `keyOf(f.stream + f.entry, ...)` in
`collectCompactions`. The reader still dedupes, so the count holds; the kill is the
branched-copy test's ledger lines (`[["c1","a.jsonl"],["c2","b.jsonl"]]`) and the two-stream
test's one line.
- M2 key without the timestamp: `keyOf(entry, null)` everywhere. Kill: same-id-another-time test.
- M3 count `compactionSummary` messages: count `e.message?.role === "compactionSummary"` too. Kill:
the zero test.
- M4 ignore the cursor tail: `digest(tail) === c.tail` → `true`. Kill: rewrite subtest.
- M5 torn line skipped: `bad = torn = true` → `torn = true`. Kill: torn-line test.
- M6 no newline after a torn line: drop the `ledger.torn ? "\n"` prefix. Kill: torn-line test's bad
lines.
- M7 incomplete not sticky: drop `if (ledger.incomplete)`. Kill: the "later clean collection"
assertion.
- M8 harness not sticky: drop `if (ledger.unsupported)`. Kill: Claude then Pi test.
- M9 0 when unknown: `count: count || null` → `count`. Kill: torn-line test's null.
- M10 removed stream ignored: drop the missing-cursor loop. Kill: remove subtest.
- M11 broker legacy flag: `op === 'legacy-3l'` → `false`. Kill: bus legacy test.
- M12 broker default 0: no reader → `{count: 0, known: true, ...}`. Kill: bus no-reader test.
+417
View File
@@ -0,0 +1,417 @@
# Row 271 build notes (Gizmoduck)
264d, T1 item 7: clear context through row 194's lifecycle stop. Gitea #1776. Brief:
docs/plans/2026-10-21_t1-sessions.md section 5 (D7, D8), with RULINGS Q4 (rebase onto 194's
landing, no 194 code copied). Reviewer Duckworth, gate owner Sage. Built after row 270 (sage-2797).
## Contract for 264e (pinned for Huey, row 272)
Proposed, pending Duckworth's review. Any change goes to Huey before it's pushed.
### session.clear
Input, every key required, with no other key (`keys()` as `session.create` does):
```json
{ "thread": "<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:
```json
{ "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:
```json
{ "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).
- 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
+6
View File
@@ -1519,3 +1519,9 @@ are never rewritten or removed; corrections are new entries.
2026-10-22 10:30:51 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 274 landing, row 281 d6 and 264a r5 plans | 274 landed 4c485c63b (ff, #1779 closed c30491, P19 414 c646); review request posted for Morgana (no token) c30488; 281 d6 approved by Dewey, code started; 264a r5 c4c75b6b4/5cb234285 run plan accepted; N7 DEFERRED (runs suite gates mosaic-task.mjs) committed
2026-10-22 10:59:30 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 256 #1761 creative delete landing | 256 landed f12441d4f (ff on 0165be266, 4 Honker commits); Webby APPROVE c30519 posted as jarvis for Webby (no token), rev 3185; request c30518; note 3186, done 3187; #1761 closed c30520; P19 416 c649; 277 rebases onto this landing next (sage-2594)
2026-10-22 11:21:00 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 277 #1782 owner thread claims landing | 277 landed 62f53b1e0 (ff on 46dedf925, 7 Morgana commits); Duckworth APPROVE c30539 posted as jarvis, rev 3192; request c30538; note 3193, done 3194; #1782 closed c30540; P19 506 c652; Honker 256 and Megavolt 236 BUILD.md notes picked; DEFERRED M8 resolve pin
2026-10-22T11:25Z | Dewey (Opus 5.5, 84fb346c) | Vikunja 502 rebase carry onto 980cec88c (Rocko 407c8d7be/04a86cf33) | APPROVE 9bf4683c6 carried: range-diff 1:= 2:=, patch-ids 37135ebe/0dbcda54; conditional on green touched-file and Python reruns on the new base (f5611c937 drift under stop-run); dewey-0834/0835
2026-10-22 11:29:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | Vikunja 502 on 980cec88c | clean picks407c8d7be/04a86cf33, patch-ids match; whole Node28/28,Python4/4,no skips/timeouts,sweepclear; rerun packet6af85df4b pushed explicitly to rocko/vikunja502-980c-20261022; Sage lands.
2026-10-22T13:06Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) manifest round 1 on megavolt/row281-e e479521d3 (manifest b73d4c81, 7 paths, on 385a7194c) | APPROVE c30614, rev 3207; N1 hosts.remove/open-decision hint and N2 fire-tool text non-blocking, DEFERRED via Sage
2026-10-22 11:32 UTC | Sage (Opus 5.5, 1ef1e4f8) | Vikunja 502 landing; 281 and 264a slot reds placed | V502 LANDED 6af85df4b (ff from 980cec88c, Dewey carry dewey-0836). Row 281 c174f37ac and 264a r7 a23afbf95 each went red on first-run test defects. 281 b3 is test-only (T6b ruling accepted by Dewey 0837). 264a r8 pins the 3l seq-0 rows. Megavolt reruns 281 on 6af85df4b.
2026-10-22 13:10 UTC | Sage (Opus 5.5, 1ef1e4f8) | Rows 278 and 281 landings; slot order 281 then 264a r10 then 253 | 278 LANDED 385a7194c (Webby c30588), 281 LANDED e479521d3 (Dewey c30614). DEFERRED: grant gap closed, N1/N2 open. 264a rebasing to r10 on e479521d3; 253 waits on 264a.
2026-10-22 14:22 UTC | Sage (Opus 5.5, 1ef1e4f8) | Row 264a landing; doc sweep | 264a LANDED 84f53540d (Duckworth c30640, #1769 closed c30646, P19 463 c669) after four slot reds ruled in mos-1276 to mos-1279. Doc sweep: V502, 278/281 and 264a BUILD-LOG lines, DEFERRED 281 N1/N2 and 264a N1/N2. 253 rebases onto the landing next.
+47 -11
View File
@@ -431,17 +431,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
comes near 64 runs. The follow-up row either raises the cap or splits
the done. Its test: a fire's done over the cap refuses, and the runs it
left alive still get the alive flag. (2026-10-22)
- **A fired or deleted instance keeps its grants.** (Dewey on the row 236
rebase look, dewey-0815; Mos mos-1271; Sage sage-2558.) Row 194's
`instance.fire` and row 256's delete revoke only the roles entry's
credential services through `revoke()` in `runtime.mjs`. The agent's
host grants (row 235) and its Gitea user and token (row 236) stay live
until a human runs `mosaic hosts revoke` or `mosaic grants revoke`. The
Console has no revoke route and doesn't say a grant is left. Row 281
(#1786) closes it: a fire or a delete revokes every host and Gitea grant
the instance holds, fail closed, with a test for each kind. It lands
before the .53 Case 1 install hires an instance with a grant.
(2026-10-22)
- **The runs suite doesn't gate a change to `scripts/mosaic-task.mjs`.**
(Dewey N7 on row 191, repeated on the Vikunja 502 review.)
@@ -460,6 +449,46 @@ at every gate. Started 2026-09-12 during the control board MVP.
changes `packages/cli/tests/tester.test.mjs`: add the resolve pin so M8
dies on an assertion. (2026-10-22)
- **Row 274's user reply check is scoped to the thread, not the session.**
(Row 264a r6, sage-2591.) In `packages/bus/src/broker.mjs`, the check that
a run answers a user only in reply to that user's message in its thread
joins `message_threads` by thread. That holds while a user's thread has
only its default session: a user can't call `session.create`, and
`#thread` refuses anyone but the owner. Binds row 272 (264e, #1777), or
any row that lets a user add sessions: make the check session-scoped and
test it with two sessions. (2026-10-22)
- **Row 281's refusal texts point the wrong way.** (Dewey N1 and N2 on
the row 281 manifest round, dewey-0842/0843 and dewey-0846; Sage
sage-2653.) N1: when a host decision is still open and the host is
removed, step G's fire or delete job refuses `host-unknown`, and the
inbox hint names a verb that can't help. The way out is to decline or
withdraw the decision (`packages/bus/src/broker.mjs`, the closed check
near :1684 and `decision.withdraw` near :5015). T14 now pins that path.
N2: the chief's fire tool text in `packages/harness/src/tools.mjs`
(near :186) doesn't say the fire's job can come back for an answer.
Neither changes behaviour. Binds the next row that changes the step G
hint text in `broker.mjs` or the fire tool text in `tools.mjs`: fix
both wording gaps and pin the hint in a test. (2026-10-22)
- **Row 264a's refusal check in sessions.test doesn't pin the actor.**
(Duckworth N1 on the row 264a r12 delta, duckworth-0138; Sage sage-2695.)
`refusedOnce` in `packages/bus/tests/sessions.test.mjs` checks that each
refused call adds one `action.refused` event with code `not-holder`, but
not that the event's `actor_run` is the refused run. Binds row 269 (264b,
#1774), or the next row that changes `sessions.test.mjs`: pin `actor_run`
in `refusedOnce`. (2026-10-22)
Closed by row 269 (264b, #1774): `refusedOnce` selects `actor_run` and
expects the refused run's name (mos-1292). (2026-10-22)
- **editor-provenance skips its rebuild check on esbuild 0.28.1.** (Duckworth
N2 on the row 264a manifest round, duckworth-0146.) The webui test
"rebuilding from the source gives the same bytes" needs esbuild 0.28.2,
the checkout has 0.28.1, and the gate logs a skip (webui.log:99). The file
is at base and was not in 264a's manifest. Binds the next gate that
touches the webui build: install the pinned esbuild in the gate checkout
so the check runs, or record why it can't. (2026-10-22)
## Queue
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
@@ -531,3 +560,10 @@ Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
`packages/cli/tests/gate-lane-auth.test.mjs` proves no gate step mounts
the host default. The real-model cases this moved off the gate are open
above.
- A fired or deleted instance kept its grants (2026-10-22, Dewey on the
row 236 rebase look, mos-1271): closed by row 281 (#1786), landing
e479521d3. A fire or a delete now runs a step G job that revokes every
host and Gitea grant the instance holds, fail closed, and tells the
human in the inbox when it can't. Megavolt built it; Dewey approved the
design (d6) and the manifest (c30614). The text gaps it left are open
above.
+4 -4
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -300,7 +300,7 @@ Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not e
| 261 | Email E05: Graph source module against a test-local registry (post-v1) | unassigned | #1766 | queued | 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E05 Graph source module against a test-local registry | — |
| 262 | Email E06: IMAP source module (post-v1) | unassigned | #1767 | queued | 261 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E06 IMAP source module | — |
| 263 | Email E07: address and rule evaluator (post-v1) | unassigned | #1768 | queued | 258 done, 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E07 address and rule evaluator | — |
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | in-progress | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat). |
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | done | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695). |
| 265 | Case 2 C2-09b rooms: room.open, room.send with @tags, room.read | rocko; reviewers dewey | #1770 | queued | 219 done | Dewey approves the manifest. The brief's tests run whole plus the bus suite and every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09b rooms: room.open, room.send with @tags, room.read | — |
| 266 | Case 2 C2-10c Console room view: merged timeline and @name completion | researcher; reviewers dewey | #1771 | queued | 225 done, 265 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every touched suite. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10c Console room view: merged timeline and @name completion | Haiku high subagent under decision 129. |
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | queued | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
@@ -314,10 +314,10 @@ Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not e
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | briefed | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | — |
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
| 277 | The owner sees a thread agent's claims: an owner or reader read lists held thread claims (10-27, mos-1262; no user thread live before it) | morgana; reviewers duckworth | #1782 | done | 205 done | Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the owner sees thread claims | LANDED 62f53b1e0 ff from 46dedf925 (7 Morgana commits, range patch-id 7f0533520bd2). Duckworth APPROVE manifest e281aa2c (10 paths), comment 30539. Gate 11:01-11:14Z: 7 touched files 0 fail, bus 793, cli 836, harness 151, business 231, seat 167, 10 scripts rc=0, M1-M9 killed; skips named, env only (test-queue root, test-task live x2). Overlap 13/257 clean. N3 per-session claims carry to 264a r7. |
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | briefed | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | — |
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | done | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes. |
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | done | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | LANDED ceadad993 (carry of cad927137 onto 343b6dd76: range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey APPROVE dewey-0809 c30409 + carry). Fix: stopRun loop is for(;;), deadline branch is the only time exit. Sage gate green: business 209/0/0, cli 794/0/0, discord processing+stop 9/0/0; Rocko stop-run 13/13, processing-control 62/62, M1 killed. Overlap sweep clean (13, 257). Next: 194 b16 rebases onto it (mos-1269). |
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | in-progress | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | — |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | done | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614. |
<!-- mosaic-queue:end -->
+698 -21
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 3194,
"revision": 3216,
"rows": [
{
"id": 1,
@@ -26839,7 +26839,7 @@
"closes": [
1769
],
"state": "in-progress",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -26854,16 +26854,70 @@
"reviewers": [
"duckworth"
],
"review": null,
"claim": {
"seat": "gizmoduck",
"op": "gizmoduck-264-claim-1"
"review": {
"rounds": [
{
"n": 1,
"op": "gizmoduck-264-review-r13b",
"by": "gizmoduck",
"at": "2026-10-22T14:11:07.284Z",
"issue": 1769,
"candidate": {
"kind": "manifest",
"digest": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e",
"text": "0f14a8e813a75abc8ab2a49d4574496e68a83ae3a37019c5bbfbbbc439fbe2ab agents/gizmoduck/work/queue-264/BUILD.md\nee456d3f7f5f36c499b1144f5c1a51bd3400662bd576c0a2c403a70cd49772c8 docs/plans/DEFERRED.md\n764b3d7865bfa3a0829f3c7d5553446fc61478cc8b46c0da5dd9463ea7ef1543 packages/bus/README.md\ncd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a packages/bus/schema.sql\na581d9e687ec2784751e293bd320885ba2ee75cbff38dd0f7f0816c3b6ccf1f1 packages/bus/src/broker.mjs\neb2822c73e527697399691ae520092277ddbd4d2a9a42a0731b3c81cc3569e58 packages/bus/src/migrations/3k-3l.sql\nca4a8eceef394dae290a5ad7bdb9f335ebbe53ba02aa3af71be7001ba2725a93 packages/bus/src/store.mjs\n4e2f46031bb2025bb7d674a7da5b663312ed827d346f354df0453d3595fa8781 packages/bus/tests/account-migration.test.mjs\necf7536dbb12765ef30897fbd0436f68d11233da7e79ff12d4cf80baf3b961fd packages/bus/tests/chief-of-staff.test.mjs\n7d8ddb1f603bda89488e521e27b68ada373efb19f23864da78613eb14baf6f6d packages/bus/tests/comment-migration.test.mjs\n45b3a93609179a2f856a9f2620acca2f0b07ab239d2d5957a792d6c6fadeb0af packages/bus/tests/end-launch.test.mjs\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/tests/fixtures/schema-3k.sql\n06c33d2101278f71b315327d7be1650d97f828b0dc2aafcc28c73a57d0fe0024 packages/bus/tests/gitea-migration.test.mjs\nb7a560894b9a6eb999b80dc9b2989ac4745b568f74d0b52eec3ab0459b46ad2b packages/bus/tests/grant-migration.test.mjs\n6e41496a5d55705f484e074e2264a84f5299e04211cd1a2f384f18c5b4f6f9c5 packages/bus/tests/message-filter.test.mjs\nb1921b88ba6ee0c28b4d9652ff529abba4b7e8b12423e99cf7996c3e207ef3f5 packages/bus/tests/name-migration.test.mjs\n52e7de4bdf04f7787f70079f78017fd034abd968370059f22d859ce5a58e27a8 packages/bus/tests/schema.test.mjs\nc09cdcca750d44ae1cebd3e8970708d39edd70682b99eeb77e19258d9fa8c068 packages/bus/tests/sessions.test.mjs\nc2f9002a8ba4474fa1bafc84d8ffd67756d450cf9a1346194a871cd0d541d524 packages/bus/tests/spec-migration.test.mjs\n4fd9c541735a8e654bee84ecab0172f9b1f21c668091fe12624ce812919327e4 packages/bus/tests/tester-writes.test.mjs\n0007f55235ef25e70bf89cfb9407e07c4780b54566163183d19b1f902fc0251e packages/bus/tests/thread-claims.test.mjs\n19e4200961fd81a23f2bf7089f81b2a57165a6a81c026ef879ab756587d705dc packages/bus/tests/thread-migration.test.mjs\n90d25f35389478cd3307b2383d93dc0a2519dd88eddc3c26bd37c39161083269 packages/bus/tests/threads.test.mjs\ncdd758deb9bcbb33eb1f4138673a11b9a5fd82fdd4fd88c14197d5240fe4b780 packages/bus/tests/usage-migration.test.mjs\n43f09ab4e7d3f8178a6cab6889ce2d85c83b8151a0f0dc26325d39bbe4236dc0 packages/cli/tests/stack-release.test.mjs\ncf6c1310f32d8495b5838620760646abcff7be0d38924511e14c53528d581802 packages/cli/tests/thread-cli.test.mjs\n4d4885bc624056b559e4c5cbdd7650c4fdeedc83695c8f1884a0bfa27b00a0cf packages/cli/tests/thread-launch.test.mjs\n3e794faab71337d13a531564183b5e0fd316c666bffbe834c8e5eedf9fad4a6a packages/webui/tests/chat.test.mjs\nd03dd152cde23c895bfbd07f1df8084925ae6638854d3a99a6aca82779ad4f12 scripts/stack-release.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "gizmoduck-264-review-r13b",
"by": "gizmoduck",
"at": "2026-10-22T14:11:07.284Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-264-review-r13b",
"by": "sage",
"at": "2026-10-22T14:12:46.925Z",
"state": "posted",
"comment": 30639,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30639,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "duckworth",
"op": "duckworth-0146-record",
"at": "2026-10-22T14:17:52.013Z",
"verdict": "approve",
"comment": 30640,
"candidate": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e"
}
]
}
]
},
"note": "Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat).",
"claim": null,
"note": "LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695).",
"blockedReason": null,
"createdAt": "2026-10-21T22:42:17.481Z",
"updatedAt": "2026-10-22T00:04:18.160Z",
"updatedBy": "gizmoduck"
"updatedAt": "2026-10-22T14:19:39.888Z",
"updatedBy": "sage"
},
{
"id": 265,
@@ -27642,7 +27696,7 @@
"closes": [
1783
],
"state": "briefed",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -27662,12 +27716,69 @@
"reviewers": [
"webby"
],
"review": null,
"review": {
"rounds": [
{
"n": 1,
"op": "gander-278-inreview-r1",
"by": "gander",
"at": "2026-10-22T12:34:26.185Z",
"issue": 1783,
"candidate": {
"kind": "manifest",
"digest": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3",
"text": "426007525d15ad0fa11ba02d6cc5d8436c7ff3fa38beb01e9a651c202169d6a5 packages/webui/README.md\n7ac85787f614aabce4b8eccef01fcdbf1018003e806c5de97d40f47c4041710a packages/webui/src/cli.mjs\nc0306a4db82c286826b157cceb036496666f58ce8073d14280e58c10377ed0d0 packages/webui/src/public/bus.js\naa25b50543b5bc632ac984e85b0d8b43b1c9af2d617bfc7c19351d07310f396d packages/webui/src/serve.mjs\nfd7ad903b682f8f6ceee2d75111b01aa97f50233cd875dd5553e3e83de33ce32 packages/webui/tests/shell.test.mjs\n3b2770db03a2fcce87512b600f386a51f822b251ccb992a46208ee484ee99f7e packages/webui/tests/thread-claims-browser.test.mjs\n48117fe02ef9b76ad12f15dfb0d25e77d187c088c0bff572f33e18418e566913 packages/webui/tests/thread-claims.test.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "gander-278-inreview-r1",
"by": "gander",
"at": "2026-10-22T12:34:26.185Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-278-review-r1b",
"by": "sage",
"at": "2026-10-22T12:37:23.447Z",
"state": "posted",
"comment": 30587,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30587,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "webby",
"op": "webby-278-verdict-r1",
"at": "2026-10-22T12:38:51.557Z",
"verdict": "approve",
"comment": 30588,
"candidate": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3"
}
]
}
]
},
"claim": null,
"note": null,
"note": "LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes.",
"blockedReason": null,
"createdAt": "2026-10-22T05:15:43.015Z",
"updatedAt": "2026-10-22T05:16:17.583Z",
"updatedAt": "2026-10-22T12:40:51.151Z",
"updatedBy": "sage"
},
{
@@ -27860,7 +27971,7 @@
"closes": [
1786
],
"state": "in-progress",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -27880,16 +27991,70 @@
"reviewers": [
"dewey"
],
"review": null,
"claim": {
"seat": "megavolt",
"op": "megavolt-281-claim"
"review": {
"rounds": [
{
"n": 1,
"op": "megavolt-281-inreview-r1",
"by": "megavolt",
"at": "2026-10-22T13:01:27.161Z",
"issue": 1786,
"candidate": {
"kind": "manifest",
"digest": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56",
"text": "7ce8139b6ffa3077d858f0cb059f6d2b8d5c09f6a8acf867611d84830be35868 agents/megavolt/work/queue-281/BUILD.md\n5fb103dccf550a0d0568771ca3ddec47196d7f5f24775501c5aecff7599343db packages/bus/src/broker.mjs\n6fe79a481bb35c6ab5bcda487bcc346fcb812e8ed96bd62f7198ebb09e6d292a packages/bus/tests/fire-revokes-grants.test.mjs\n5f9a8b73b6df8b83db990f6c9f15935e51dcc00ddf59d10eabe0f1dfd23d718c packages/bus/tests/gitea-grant.test.mjs\n88c5193c723f9362008995b46b697b8f455574b3fc2ddc7f31f549082f941efd packages/bus/tests/grant-executor.test.mjs\ndd3d99d48d28e2a001a013bafb486385c777056ea9d29e9c5ec9e38174aa02cd packages/bus/tests/lifecycle-revoke.test.mjs\n5ce79205c4ca6e5f6e77671968ce9d8b8ebfdbdde07b31676f2d05b284acb938 packages/cli/tests/lifecycle.test.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "megavolt-281-inreview-r1",
"by": "megavolt",
"at": "2026-10-22T13:01:27.161Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-281-review-r1b",
"by": "sage",
"at": "2026-10-22T13:02:59.024Z",
"state": "posted",
"comment": 30613,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30613,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "dewey",
"op": "dewey-281-verdict-r1",
"at": "2026-10-22T13:05:20.490Z",
"verdict": "approve",
"comment": 30614,
"candidate": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56"
}
]
}
]
},
"note": null,
"claim": null,
"note": "LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614.",
"blockedReason": null,
"createdAt": "2026-10-22T09:44:24.187Z",
"updatedAt": "2026-10-22T09:51:21.600Z",
"updatedBy": "megavolt"
"updatedAt": "2026-10-22T13:07:09.587Z",
"updatedBy": "sage"
}
],
"log": [
@@ -112108,6 +112273,518 @@
"receipt": "ok sage-277-done rev 3194 row 277 in-review→done round 1"
},
"viewSha": "47105a02fc98e4b65b8f9b39fbbcfea3a7a295516e992e95c26f6e5f2d052fdb"
},
{
"rev": 3195,
"op": "gander-278-start",
"verb": "move",
"args": {
"id": 278,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "gander",
"at": "2026-10-22T12:04:51.778Z",
"semantics": 2,
"result": {
"row": 278,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok gander-278-start rev 3195 row 278 briefed→in-progress"
},
"viewSha": "d3e0918fab9cd369618d3cce25053035c3593cc157ef02b3e67824577540a383"
},
{
"rev": 3196,
"op": "gander-278-inreview-r1",
"verb": "move",
"args": {
"id": 278,
"to": "in-review",
"reason": null,
"candidate": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-278/MANIFEST.sha256",
"evidence": null,
"issue": null
},
"by": "gander",
"at": "2026-10-22T12:34:26.185Z",
"semantics": 2,
"result": {
"row": 278,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1783,
"candidate": {
"kind": "manifest",
"digest": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3",
"text": "426007525d15ad0fa11ba02d6cc5d8436c7ff3fa38beb01e9a651c202169d6a5 packages/webui/README.md\n7ac85787f614aabce4b8eccef01fcdbf1018003e806c5de97d40f47c4041710a packages/webui/src/cli.mjs\nc0306a4db82c286826b157cceb036496666f58ce8073d14280e58c10377ed0d0 packages/webui/src/public/bus.js\naa25b50543b5bc632ac984e85b0d8b43b1c9af2d617bfc7c19351d07310f396d packages/webui/src/serve.mjs\nfd7ad903b682f8f6ceee2d75111b01aa97f50233cd875dd5553e3e83de33ce32 packages/webui/tests/shell.test.mjs\n3b2770db03a2fcce87512b600f386a51f822b251ccb992a46208ee484ee99f7e packages/webui/tests/thread-claims-browser.test.mjs\n48117fe02ef9b76ad12f15dfb0d25e77d187c088c0bff572f33e18418e566913 packages/webui/tests/thread-claims.test.mjs\n"
},
"receipt": "ok gander-278-inreview-r1 rev 3196 row 278 in-progress→in-review round 1 on #1783; request gander-278-inreview-r1 requesting"
},
"viewSha": "10e0b103a4099660e8e0f332214db3ffec46d7e31afce0be052efa11b29aca43"
},
{
"rev": 3197,
"op": "gander-278-inreview-r1.outcome",
"verb": "review-outcome",
"args": {
"id": 278,
"attempt": "gander-278-inreview-r1",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "gander",
"at": "2026-10-22T12:35:03.307Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"attempt": "gander-278-inreview-r1",
"state": "failed",
"receipt": "ok gander-278-inreview-r1.outcome rev 3197 row 278 round 1 request gander-278-inreview-r1 failed"
},
"viewSha": "807e3fdfac503cb45c13b8963b38c9131d36b5c9fdc6a071525ca0a115b2c39b"
},
{
"rev": 3198,
"op": "sage-278-review-r1b",
"verb": "review-request",
"args": {
"id": 278
},
"by": "sage",
"at": "2026-10-22T12:37:23.447Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"attempt": "sage-278-review-r1b",
"state": "requesting",
"receipt": "ok sage-278-review-r1b rev 3198 row 278 round 1 request sage-278-review-r1b requesting"
},
"viewSha": "6ab64156b87571dcc2e7d9fe0b7750eff8f576419ad147e6af460fd9ba9018b3"
},
{
"rev": 3199,
"op": "sage-278-review-r1b.outcome",
"verb": "review-outcome",
"args": {
"id": 278,
"attempt": "sage-278-review-r1b",
"outcome": "posted",
"status": 201,
"comment": 30587,
"detail": "created"
},
"by": "sage",
"at": "2026-10-22T12:38:00.443Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"attempt": "sage-278-review-r1b",
"state": "posted",
"receipt": "ok sage-278-review-r1b.outcome rev 3199 row 278 round 1 request sage-278-review-r1b posted comment 30587"
},
"viewSha": "459834d1aec5d3abd4e09095e189ef39c9696265d30c85a6ee69b1668bfe773d"
},
{
"rev": 3200,
"op": "webby-278-verdict-r1",
"verb": "review-record",
"args": {
"id": 278,
"verdict": "approve",
"comment": 30588,
"candidate": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3"
},
"by": "webby",
"at": "2026-10-22T12:38:51.557Z",
"semantics": 2,
"result": {
"row": 278,
"round": 1,
"verdict": "approve",
"receipt": "ok webby-278-verdict-r1 rev 3200 row 278 round 1 approve by webby (comment 30588)"
},
"viewSha": "7bc559a0a0ee4cd7c6fb80e79dfc96ab797460da2121ec82e784cecb57b72780"
},
{
"rev": 3201,
"op": "sage-278-landed-note",
"verb": "note",
"args": {
"id": 278,
"text": "LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes."
},
"by": "sage",
"at": "2026-10-22T12:40:15.700Z",
"semantics": 2,
"result": {
"row": 278,
"field": "note",
"receipt": "ok sage-278-landed-note rev 3201 row 278 note"
},
"viewSha": "0c929f230fb391defb269606b2e29c1b3d49bc53723762270d49c71eedd95afb"
},
{
"rev": 3202,
"op": "sage-278-done",
"verb": "move",
"args": {
"id": 278,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T12:40:51.151Z",
"semantics": 2,
"result": {
"row": 278,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-278-done rev 3202 row 278 in-review→done round 1"
},
"viewSha": "b9a7f299fbd07afe042bec4491791dd511487e47577e2cf816ef3b00506aca98"
},
{
"rev": 3203,
"op": "megavolt-281-inreview-r1",
"verb": "move",
"args": {
"id": 281,
"to": "in-review",
"reason": null,
"candidate": "/mnt/storage/scratch/megavolt-r281/manifest-r1.txt",
"evidence": null,
"issue": null
},
"by": "megavolt",
"at": "2026-10-22T13:01:27.161Z",
"semantics": 2,
"result": {
"row": 281,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1786,
"candidate": {
"kind": "manifest",
"digest": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56",
"text": "7ce8139b6ffa3077d858f0cb059f6d2b8d5c09f6a8acf867611d84830be35868 agents/megavolt/work/queue-281/BUILD.md\n5fb103dccf550a0d0568771ca3ddec47196d7f5f24775501c5aecff7599343db packages/bus/src/broker.mjs\n6fe79a481bb35c6ab5bcda487bcc346fcb812e8ed96bd62f7198ebb09e6d292a packages/bus/tests/fire-revokes-grants.test.mjs\n5f9a8b73b6df8b83db990f6c9f15935e51dcc00ddf59d10eabe0f1dfd23d718c packages/bus/tests/gitea-grant.test.mjs\n88c5193c723f9362008995b46b697b8f455574b3fc2ddc7f31f549082f941efd packages/bus/tests/grant-executor.test.mjs\ndd3d99d48d28e2a001a013bafb486385c777056ea9d29e9c5ec9e38174aa02cd packages/bus/tests/lifecycle-revoke.test.mjs\n5ce79205c4ca6e5f6e77671968ce9d8b8ebfdbdde07b31676f2d05b284acb938 packages/cli/tests/lifecycle.test.mjs\n"
},
"receipt": "ok megavolt-281-inreview-r1 rev 3203 row 281 in-progress→in-review round 1 on #1786; request megavolt-281-inreview-r1 requesting"
},
"viewSha": "64bd164fc938ff48e88bbe1f73b1b2092a44b947ef319f97956425d99324c86b"
},
{
"rev": 3204,
"op": "megavolt-281-inreview-r1.outcome",
"verb": "review-outcome",
"args": {
"id": 281,
"attempt": "megavolt-281-inreview-r1",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "megavolt",
"at": "2026-10-22T13:02:03.649Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"attempt": "megavolt-281-inreview-r1",
"state": "failed",
"receipt": "ok megavolt-281-inreview-r1.outcome rev 3204 row 281 round 1 request megavolt-281-inreview-r1 failed"
},
"viewSha": "dffd6ed44dbe0fce968c061f6be08378dde897ddaa251c9b8dd6d2c293fb2db4"
},
{
"rev": 3205,
"op": "sage-281-review-r1b",
"verb": "review-request",
"args": {
"id": 281
},
"by": "sage",
"at": "2026-10-22T13:02:59.024Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"attempt": "sage-281-review-r1b",
"state": "requesting",
"receipt": "ok sage-281-review-r1b rev 3205 row 281 round 1 request sage-281-review-r1b requesting"
},
"viewSha": "b862b16cf093400a938b3506bace6fae82155c9e651734447760c73f0c20ff8e"
},
{
"rev": 3206,
"op": "sage-281-review-r1b.outcome",
"verb": "review-outcome",
"args": {
"id": 281,
"attempt": "sage-281-review-r1b",
"outcome": "posted",
"status": 201,
"comment": 30613,
"detail": "created"
},
"by": "sage",
"at": "2026-10-22T13:03:38.863Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"attempt": "sage-281-review-r1b",
"state": "posted",
"receipt": "ok sage-281-review-r1b.outcome rev 3206 row 281 round 1 request sage-281-review-r1b posted comment 30613"
},
"viewSha": "d4b562903728382f89434caf9e4f41d7ddbaf8daa313f6c5d174bbbab3731ca7"
},
{
"rev": 3207,
"op": "dewey-281-verdict-r1",
"verb": "review-record",
"args": {
"id": 281,
"verdict": "approve",
"comment": 30614,
"candidate": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56"
},
"by": "dewey",
"at": "2026-10-22T13:05:20.490Z",
"semantics": 2,
"result": {
"row": 281,
"round": 1,
"verdict": "approve",
"receipt": "ok dewey-281-verdict-r1 rev 3207 row 281 round 1 approve by dewey (comment 30614)"
},
"viewSha": "ed7c23dfa5ee98a2502e4b6410b2527b6f357c775cb6b2afa7386f7feafb50fd"
},
{
"rev": 3208,
"op": "sage-281-landed-note",
"verb": "note",
"args": {
"id": 281,
"text": "LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614."
},
"by": "sage",
"at": "2026-10-22T13:06:32.779Z",
"semantics": 2,
"result": {
"row": 281,
"field": "note",
"receipt": "ok sage-281-landed-note rev 3208 row 281 note"
},
"viewSha": "e777e0997655c84a7f3d842126db35fcb0d3e5b02a57aa9358eebda281f49e76"
},
{
"rev": 3209,
"op": "sage-281-done",
"verb": "move",
"args": {
"id": 281,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T13:07:09.587Z",
"semantics": 2,
"result": {
"row": 281,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-281-done rev 3209 row 281 in-review→done round 1"
},
"viewSha": "9ac1eeb17b185f038531d58b8ba85ccca0cd9607902614d3b1bc2f5e8085c0c0"
},
{
"rev": 3210,
"op": "gizmoduck-264-review-r13b",
"verb": "move",
"args": {
"id": 264,
"to": "in-review",
"reason": null,
"candidate": "/mnt/storage/scratch/gizmo-264-out/manifest-264-r13b.txt",
"evidence": null,
"issue": null
},
"by": "gizmoduck",
"at": "2026-10-22T14:11:07.284Z",
"semantics": 2,
"result": {
"row": 264,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1769,
"candidate": {
"kind": "manifest",
"digest": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e",
"text": "0f14a8e813a75abc8ab2a49d4574496e68a83ae3a37019c5bbfbbbc439fbe2ab agents/gizmoduck/work/queue-264/BUILD.md\nee456d3f7f5f36c499b1144f5c1a51bd3400662bd576c0a2c403a70cd49772c8 docs/plans/DEFERRED.md\n764b3d7865bfa3a0829f3c7d5553446fc61478cc8b46c0da5dd9463ea7ef1543 packages/bus/README.md\ncd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a packages/bus/schema.sql\na581d9e687ec2784751e293bd320885ba2ee75cbff38dd0f7f0816c3b6ccf1f1 packages/bus/src/broker.mjs\neb2822c73e527697399691ae520092277ddbd4d2a9a42a0731b3c81cc3569e58 packages/bus/src/migrations/3k-3l.sql\nca4a8eceef394dae290a5ad7bdb9f335ebbe53ba02aa3af71be7001ba2725a93 packages/bus/src/store.mjs\n4e2f46031bb2025bb7d674a7da5b663312ed827d346f354df0453d3595fa8781 packages/bus/tests/account-migration.test.mjs\necf7536dbb12765ef30897fbd0436f68d11233da7e79ff12d4cf80baf3b961fd packages/bus/tests/chief-of-staff.test.mjs\n7d8ddb1f603bda89488e521e27b68ada373efb19f23864da78613eb14baf6f6d packages/bus/tests/comment-migration.test.mjs\n45b3a93609179a2f856a9f2620acca2f0b07ab239d2d5957a792d6c6fadeb0af packages/bus/tests/end-launch.test.mjs\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/tests/fixtures/schema-3k.sql\n06c33d2101278f71b315327d7be1650d97f828b0dc2aafcc28c73a57d0fe0024 packages/bus/tests/gitea-migration.test.mjs\nb7a560894b9a6eb999b80dc9b2989ac4745b568f74d0b52eec3ab0459b46ad2b packages/bus/tests/grant-migration.test.mjs\n6e41496a5d55705f484e074e2264a84f5299e04211cd1a2f384f18c5b4f6f9c5 packages/bus/tests/message-filter.test.mjs\nb1921b88ba6ee0c28b4d9652ff529abba4b7e8b12423e99cf7996c3e207ef3f5 packages/bus/tests/name-migration.test.mjs\n52e7de4bdf04f7787f70079f78017fd034abd968370059f22d859ce5a58e27a8 packages/bus/tests/schema.test.mjs\nc09cdcca750d44ae1cebd3e8970708d39edd70682b99eeb77e19258d9fa8c068 packages/bus/tests/sessions.test.mjs\nc2f9002a8ba4474fa1bafc84d8ffd67756d450cf9a1346194a871cd0d541d524 packages/bus/tests/spec-migration.test.mjs\n4fd9c541735a8e654bee84ecab0172f9b1f21c668091fe12624ce812919327e4 packages/bus/tests/tester-writes.test.mjs\n0007f55235ef25e70bf89cfb9407e07c4780b54566163183d19b1f902fc0251e packages/bus/tests/thread-claims.test.mjs\n19e4200961fd81a23f2bf7089f81b2a57165a6a81c026ef879ab756587d705dc packages/bus/tests/thread-migration.test.mjs\n90d25f35389478cd3307b2383d93dc0a2519dd88eddc3c26bd37c39161083269 packages/bus/tests/threads.test.mjs\ncdd758deb9bcbb33eb1f4138673a11b9a5fd82fdd4fd88c14197d5240fe4b780 packages/bus/tests/usage-migration.test.mjs\n43f09ab4e7d3f8178a6cab6889ce2d85c83b8151a0f0dc26325d39bbe4236dc0 packages/cli/tests/stack-release.test.mjs\ncf6c1310f32d8495b5838620760646abcff7be0d38924511e14c53528d581802 packages/cli/tests/thread-cli.test.mjs\n4d4885bc624056b559e4c5cbdd7650c4fdeedc83695c8f1884a0bfa27b00a0cf packages/cli/tests/thread-launch.test.mjs\n3e794faab71337d13a531564183b5e0fd316c666bffbe834c8e5eedf9fad4a6a packages/webui/tests/chat.test.mjs\nd03dd152cde23c895bfbd07f1df8084925ae6638854d3a99a6aca82779ad4f12 scripts/stack-release.sh\n"
},
"receipt": "ok gizmoduck-264-review-r13b rev 3210 row 264 in-progress→in-review round 1 on #1769; request gizmoduck-264-review-r13b requesting"
},
"viewSha": "7a3ea1661ded736fbe138c569e5ab120d96bd48c8348a394f2da89f669bb7fc9"
},
{
"rev": 3211,
"op": "gizmoduck-264-review-r13b.outcome",
"verb": "review-outcome",
"args": {
"id": 264,
"attempt": "gizmoduck-264-review-r13b",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "gizmoduck",
"at": "2026-10-22T14:11:43.637Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"attempt": "gizmoduck-264-review-r13b",
"state": "failed",
"receipt": "ok gizmoduck-264-review-r13b.outcome rev 3211 row 264 round 1 request gizmoduck-264-review-r13b failed"
},
"viewSha": "cadd960bd868b8c63ecb7a1128b9e2628434706849bdcac6f2b8a24c26bc6701"
},
{
"rev": 3212,
"op": "sage-264-review-r13b",
"verb": "review-request",
"args": {
"id": 264
},
"by": "sage",
"at": "2026-10-22T14:12:46.925Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"attempt": "sage-264-review-r13b",
"state": "requesting",
"receipt": "ok sage-264-review-r13b rev 3212 row 264 round 1 request sage-264-review-r13b requesting"
},
"viewSha": "fe49d268532fe1223929e2c660b3acc815daf109b5607f7909d30c9d53bf18fb"
},
{
"rev": 3213,
"op": "sage-264-review-r13b.outcome",
"verb": "review-outcome",
"args": {
"id": 264,
"attempt": "sage-264-review-r13b",
"outcome": "posted",
"status": 201,
"comment": 30639,
"detail": "created"
},
"by": "sage",
"at": "2026-10-22T14:13:24.411Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"attempt": "sage-264-review-r13b",
"state": "posted",
"receipt": "ok sage-264-review-r13b.outcome rev 3213 row 264 round 1 request sage-264-review-r13b posted comment 30639"
},
"viewSha": "a7fb599ce8e5c0320b4b9dbd9f812934b1098406169790370dc37bce946668c1"
},
{
"rev": 3214,
"op": "duckworth-0146-record",
"verb": "review-record",
"args": {
"id": 264,
"verdict": "approve",
"comment": 30640,
"candidate": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e"
},
"by": "duckworth",
"at": "2026-10-22T14:17:52.013Z",
"semantics": 2,
"result": {
"row": 264,
"round": 1,
"verdict": "approve",
"receipt": "ok duckworth-0146-record rev 3214 row 264 round 1 approve by duckworth (comment 30640)"
},
"viewSha": "765bffd5742a3e0d8fac6bc4971ddba7fea0a937ce63529fd1ff3a82baa66d35"
},
{
"rev": 3215,
"op": "sage-264-landed-note",
"verb": "note",
"args": {
"id": 264,
"text": "LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695)."
},
"by": "sage",
"at": "2026-10-22T14:19:03.485Z",
"semantics": 2,
"result": {
"row": 264,
"field": "note",
"receipt": "ok sage-264-landed-note rev 3215 row 264 note"
},
"viewSha": "830e39c04975c14ffe061b4a292ac05b088574a6932f47bd91ac43d982e92ef6"
},
{
"rev": 3216,
"op": "sage-264-done",
"verb": "move",
"args": {
"id": 264,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-22T14:19:39.888Z",
"semantics": 2,
"result": {
"row": 264,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-264-done rev 3216 row 264 in-review→done round 1"
},
"viewSha": "e81b02877f6780a2cc04aaa05922ddcfec132716d03de5613412d8c6f85a579a"
}
]
}
+48 -9
View File
@@ -241,9 +241,11 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
| `thread.open` | `{thread}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,after?,limit?}`; human sessions only. The thread's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the thread), at most `limit` (default 50, 1 to 200). Writes nothing. |
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). Writes nothing. |
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default,compactions}]`. `compactions` is `{count,known,reason,at}` from the host's ledger (row 270, #1775): `known` true gives the exact count of completed Pi compactions; otherwise `count` is a lower bound or null and `reason` is `harness-unsupported`, `not-collected`, `store-unreadable` or `store-incomplete`. Writes nothing. |
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
### Projects and threads (row 121, #1615)
@@ -311,6 +313,42 @@ the threads they made. A revoked run can't claim its thread again (`run-revoked`
thread run can still be stopped another way: the launcher stops it once the thread is idle, the
human stops it with `mosaic stop <run>`, and the run's end releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
### Sessions (row 264a, #1769)
Schema `3l` puts sessions below a thread. `sessions` and `session_defaults` are new and
append-only. `thread_claims`, `message_threads` and `thread_opens` each gain a `session`
column, and `thread_claims` gains `generation`, the claim's number in its session counting
from 1. A release or revoke carries its claim's number.
Every thread has a default session, the latest `session_defaults` row (D3). `thread.create`
writes one with a random id. The 3k→3l step gives each existing thread a default session whose
id is the thread's own, named `default` with op `legacy-3l`, and files every old claim, message
and open under it. A thread verb, or a `bindLaunch` record, that names no `session` works the
default, so a client from before 3l keeps working. A `session` from another thread refuses
`session-not-found`, and a non-thread role that names one refuses `thread-not-allowed`.
The thread rules above hold per session. One run holds a session at a time, and a run holds at
most one session in its business, while sibling sessions of a thread may each have a holder.
A thread run receives and replies in its own session, and the chief of staff's reply returns
to the session that sent the work. `threadsState` and `threadIdle` keep one row per thread,
computed on its default session; 264b's launcher starts the others.
A thread run's claim fences by generation (T2). A run acts only while its session's latest claim
row has the generation of the run's own latest claim. After a release or revoke, once another run
claims the session, the old run's `decision.withdraw`, `message.receive` and `message.read`
refuse `not-holder` and write nothing.
`thread.create` writes the default session with op `default`, so a `session.create` retry naming
`default` with op `default` answers with it. A migrated thread's default has op `legacy-3l`, and
the same retry there refuses `session-exists`.
The triggers check that a session has its thread's business, instance and owner, that its
predecessor and any default, claim, message or open name a session of the same thread, and that
a claim's generation is the one its session expects. `delivery_same_session` replaces
`delivery_same_thread`. `sessions_id_not_thread` and `threads_id_not_session` keep a session id
from naming another thread and a thread id from naming a session. A 3k engine refuses a 3l
store with `schema-mismatch`.
### Agent state (row 59, #1548)
For a human or reader session, `agents` derives each held role's state from
@@ -349,8 +387,9 @@ Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
`thread.claims` takes no arguments and lists the held thread claims in the
business, oldest thread first, as `{thread, instance, holder_run, harness,
at}`. A claim is held while its thread's latest `thread_claims` row is a
claim, so a released or revoked thread drops out. Human and reader sessions
at}`. A claim is held while its session's latest `thread_claims` row is a
claim (row 264a), so a released or revoked session drops out and a thread
with two held sessions lists twice. Human and reader sessions
only: a role session refuses `human-required` and the tester
`tester-refused`. The result carries the thread id but never its name, which
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
@@ -727,10 +766,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3k, SHA-256
`aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0`.
It has 20 tables, 72 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3k`, WAL,
`schema.sql` is v3l, SHA-256
`cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a`.
It has 22 tables, 87 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3l`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
+111 -11
View File
@@ -248,11 +248,34 @@ CREATE TABLE threads (
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
@@ -261,55 +284,95 @@ CREATE TABLE thread_claims (
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
@@ -415,3 +478,40 @@ CREATE TABLE gitea_grants (
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
CREATE TABLE session_ops (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
op TEXT NOT NULL,
stage TEXT NOT NULL CHECK (stage IN ('requested','drained','uncertain','exited','retired','failed','cancelled','start-held','start-started','start-refused')),
attempt INTEGER NOT NULL CHECK (CASE WHEN stage GLOB 'start-*' THEN attempt >= 1 ELSE attempt = 0 END),
work_attempt INTEGER CHECK (work_attempt IS NULL OR (stage GLOB 'start-*' AND work_attempt >= 1)),
successor TEXT NOT NULL,
successor_name TEXT NOT NULL,
run TEXT,
generation INTEGER CHECK (generation IS NULL OR generation >= 1),
digest TEXT,
refusal TEXT,
UNIQUE (thread, op, stage, attempt)
) STRICT;
CREATE TABLE message_cancels (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
message TEXT NOT NULL UNIQUE REFERENCES messages(id),
session TEXT NOT NULL REFERENCES sessions(id),
op TEXT NOT NULL
) STRICT;
CREATE INDEX session_ops_session ON session_ops (session, seq);
CREATE TRIGGER session_ops_no_update BEFORE UPDATE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
CREATE TRIGGER session_ops_no_delete BEFORE DELETE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
CREATE TRIGGER session_ops_no_replace BEFORE INSERT ON session_ops WHEN EXISTS (SELECT 1 FROM session_ops WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops
WHEN NEW.stage = 'requested' AND EXISTS (SELECT 1 FROM session_ops o WHERE o.session = NEW.session
AND o.seq = (SELECT max(seq) FROM session_ops WHERE thread = o.thread AND op = o.op)
AND o.stage IN ('requested','drained','uncertain','exited'))
BEGIN SELECT RAISE(ABORT, 'session has an open clear'); END;
CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
+196 -78
View File
@@ -125,7 +125,9 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
'instance.launch', 'instance.end', 'instance.request']);
'instance.launch', 'instance.end', 'instance.request',
// Row 264a (#1769): the sessions of the tester's own threads.
'session.list', 'session.create']);
// FED R3 (#414, federation design 3.5): the collaborator level is this exact list. Every other verb
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
@@ -382,6 +384,7 @@ export class Broker {
// FED R3: () => the checked access document (R1's loadAccess), read once per user request. Null,
// a throw or no document leaves every user with no grant (design 3.5, fail closed).
#access;
#compactions;
// r6 X3: interrupted host grants whose remote undo waits for reconcileHosts, each with its claim.
#hostUndo = [];
// r6 section 6 (N4): revokes a restart cut short, whose step 3 waits for reconcileHosts.
@@ -407,13 +410,14 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, compactions = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
this.#businesses = structuredClone(businesses);
this.#store = store;
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
@@ -433,6 +437,7 @@ export class Broker {
this.#giteaGrant = giteaGrant;
this.#giteaKeys = giteaKeys;
this.#access = access;
this.#compactions = compactions;
this.#project = project;
this.#library = library;
this.#usage = usage;
@@ -460,6 +465,8 @@ export class Broker {
'task_snapshots',
'projects',
'threads',
'sessions',
'session_defaults',
'thread_claims',
'thread_opens',
]) {
@@ -580,14 +587,26 @@ export class Broker {
#threaded(business, role) {
return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
}
#threadHolder(thread) {
return this.#store.get('SELECT * FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread);
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
#sessionHolder(session) {
return this.#store.get('SELECT * FROM thread_claims WHERE session=? ORDER BY seq DESC LIMIT 1', session);
}
// Row 155 (#1649): the held claims of every thread of a thread instance, oldest thread first, or
// D3: the thread's default session, the latest session_defaults row. thread.create and the 3k-3l
// migration write one for every thread.
#defaultSession(thread) {
return this.#store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1', thread)?.session ?? fail('session-not-found');
}
// The session a thread verb names, or the thread's default when it names none.
#threadSession(t, session) {
if (session === undefined) return this.#defaultSession(t.id);
if (!this.#store.get('SELECT 1 FROM sessions WHERE id=? AND thread=?', identifier(session), t.id)) fail('session-not-found');
return session;
}
// Row 155 (#1649): the held claims of every session of a thread instance, oldest thread first, or
// only the threads `owner` made. An end of the instance ends each of them.
#threadHolders(business, instance, owner = null) {
return this.#store.all(
"SELECT c.* FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND t.instance=? AND (? IS NULL OR t.owner=?) AND c.op='claim' ORDER BY t.seq",
"SELECT c.* FROM threads t JOIN sessions se ON se.thread=t.id JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id) WHERE t.business=? AND t.instance=? AND (? IS NULL OR t.owner=?) AND c.op='claim' ORDER BY t.seq, se.seq",
business,
instance,
owner,
@@ -685,23 +704,23 @@ export class Broker {
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
return null;
}
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
// The claim a run acts under: its session's for a thread run, its role's otherwise.
#own(s) {
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
return s.thread ? this.#sessionHolder(s.session) : this.#holder(s.business, s.role);
}
// Who a message to `to` goes to now: the thread's holder when the message is in a thread of
// `to`, the role's otherwise. Null while nobody holds it, and the message waits.
#recipient(business, to, thread) {
const h = thread && this.#threaded(business, to) ? this.#threadHolder(thread) : this.#holder(business, to);
// Who a message to `to` goes to now: the session's holder when the message is in a session of a
// thread of `to`, the role's otherwise. Null while nobody holds it, and the message waits.
#recipient(business, to, session) {
const h = session && this.#threaded(business, to) ? this.#sessionHolder(session) : this.#holder(business, to);
return h?.op === 'claim' ? h : null;
}
// The thread a new message belongs to (REQ-MSG-1). A thread run's message is in its thread.
// A message to a thread instance from anyone else is in the thread of the nearest message up
// its in_reply_to chain that has one, so the chief of staff's reply returns to the thread
// The session a new message belongs to (REQ-MSG-1). A thread run's message is in its session.
// A message to a thread instance from anyone else is in the session of the nearest message up
// its in_reply_to chain that has one, so the chief of staff's reply returns to the session
// that sent the work. Messages are append-only and in_reply_to names an earlier message, so
// the chain has an end; `seen` stops the walk there and needs no depth constant.
#threadFor(s, to, inReplyTo) {
if (s.thread) return s.thread;
#sessionFor(s, to, inReplyTo) {
if (s.thread) return s.session;
if (!this.#threaded(s.business, to)) return null;
// The user names the thread with thread.send, which checks the owner (REQ-USER-2).
if (s.human || s.user) fail('thread-required-use-thread-send');
@@ -709,18 +728,20 @@ export class Broker {
for (let at = inReplyTo ?? null; at !== null && !seen.has(at); ) {
seen.add(at);
const m = this.#store.get(
'SELECT m.in_reply_to, m.from_role, m.body, t.thread FROM messages m LEFT JOIN message_threads t ON t.message=m.id WHERE m.id=? AND m.business=?',
'SELECT m.in_reply_to, m.from_role, m.body, t.thread, t.session FROM messages m LEFT JOIN message_threads t ON t.message=m.id WHERE m.id=? AND m.business=?',
at,
s.business,
);
if (m?.thread) {
if (this.#store.get('SELECT instance FROM threads WHERE id=?', m.thread).instance !== to) fail('thread-instance-mismatch');
return this.#testerOwns(s, m.thread);
this.#testerOwns(s, m.thread);
return m.session;
}
// Row 143: a chain that reaches a spec notice belongs to the user's thread in that notice's project.
// Row 143: a chain that reaches a spec notice belongs to the user's thread in that notice's
// project, in its default session.
if (m?.from_role === NOTICE_ROLE && m.body.startsWith(SPEC_NOTICE)) {
const spec = this.#specThread(s, to, m.body);
if (spec) return this.#testerOwns(s, spec);
if (spec) return this.#defaultSession(this.#testerOwns(s, spec));
}
at = m?.in_reply_to ?? null;
}
@@ -734,6 +755,15 @@ export class Broker {
if (s.tester && this.#store.get('SELECT owner FROM threads WHERE id=?', thread).owner !== this.#owner(s)) fail('thread-not-owned');
return thread;
}
#compactionCount(t, session, legacy) {
const unknown = (reason) => ({ count: null, known: false, reason, at: null });
if (!this.#compactions) return unknown('not-collected');
try {
return this.#compactions({ business: t.business, thread: t.id, session, legacy });
} catch {
return unknown('store-unreadable');
}
}
#thread(s, id) {
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found');
@@ -794,9 +824,15 @@ export class Broker {
if (s.reader) fail('read-only');
if (s.human) fail('agent-required');
const h = this.#own(s);
if (h?.op !== 'claim' || h.holder_run !== s.run) fail('not-holder');
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run)) fail('not-holder');
return h;
}
// Row 264a (#1769, T2): the generation a thread run holds, its latest claim's in its session, or
// null if it never claimed. Once another run claims the session, the run's own generation is
// retired, and #agent refuses its approvals, deliveries and reads.
#runGeneration(s) {
return this.#store.get("SELECT max(generation) AS g FROM thread_claims WHERE session=? AND holder_run=? AND op='claim'", s.session, s.run).g;
}
#human(s) {
if (s.reader) fail('read-only');
if (!s.human || s.via !== 'cli' || s.outsideAgent !== true) fail('human-required');
@@ -813,7 +849,7 @@ export class Broker {
return cap;
}
// Row 121 (#1615): what the launcher needs to start, stop and recover thread sessions.
// Trusted launcher API, not a socket verb. Per thread: messages to the instance that no
// Trusted launcher API, not a socket verb. Per session of each thread (row 269, 264b): messages to the instance that no
// run has been delivered yet, messages delivered to the current holder and not yet read
// (a turn in progress), and the latest message, open and claim times. A message delivered
// to an earlier run and never read (it crashed mid-turn) isn't the holder's turn, so it
@@ -832,27 +868,32 @@ export class Broker {
const key = record.business + ':' + identifier(record.run);
const session = this.#runs.get(key) ?? fail('unknown-run');
if (this.#ended.has(key)) fail('run-ended');
// A run holds at most one thread, so this also refuses another thread and a non-thread run.
const [t] = this.#threadRows('t.business=? AND t.id=?', record.business, identifier(record.thread));
// A run holds at most one session, so this also refuses another thread and a non-thread run.
// Row 269 (264b): the row is the session the run was bound to, so a run is idled against its own.
const [t] = this.#threadRows('t.business=? AND t.id=? AND se.id=?', record.business, identifier(record.thread), session.session);
if (t?.holder !== record.run) fail('not-holder');
if (t.pending || t.unread) return { released: false, pending: t.pending, unread: t.unread };
this.#store.transaction(() => this.#claimEnd(session, this.#threadHolder(t.thread), 'release', null));
this.#store.transaction(() => this.#claimEnd(session, this.#own(session), 'release', null));
return { released: true };
}
// Row 269 (264b, #1774): one row per session of each thread, oldest thread first, then oldest
// session. `legacy` is a session the 3k-3l migration made (op legacy-3l), whose store and
// in-sandbox workspace path are the thread's old ones.
#threadRows(where, ...params) {
return this.#store
.all(
`SELECT t.id, t.instance,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
`SELECT t.id, t.instance, se.id AS session, se.op AS session_op,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) AS pending,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
AND c.op='claim' AND EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='delivered' AND d.holder_run=c.holder_run)
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')) AS unread,
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id) AS last_message,
(SELECT max(at) FROM thread_opens WHERE thread=t.id) AS last_open,
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id) AS last_message,
(SELECT max(at) FROM thread_opens WHERE session=se.id) AS last_open,
c.at AS claim_at, c.op AS claim_op, c.holder_run AS holder_run
FROM threads t LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
WHERE ${where} ORDER BY t.seq`,
FROM threads t JOIN sessions se ON se.thread=t.id
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id)
WHERE ${where} ORDER BY t.seq, se.seq`,
...params,
)
.map((r) => ({
@@ -864,13 +905,15 @@ export class Broker {
lastOpen: r.last_open,
lastClaim: r.claim_at,
holder: r.claim_op === 'claim' ? r.holder_run : null,
session: r.session,
legacy: r.session_op === 'legacy-3l',
}));
}
// Trusted launcher API, deliberately NOT a socket verb. S6 supplies immutable launch records.
bindLaunch(record) {
keys(
record,
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account', 'transcript'],
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
['business', 'role', 'run', 'harness'],
);
const b = this.#business(identifier(record.business));
@@ -893,10 +936,13 @@ export class Broker {
if (record.thread === undefined) fail('thread-required');
if (!this.#store.get('SELECT 1 FROM threads WHERE id=? AND business=? AND instance=?', identifier(record.thread), record.business, record.role))
fail('thread-not-found');
} else if (record.thread !== undefined) fail('thread-not-allowed');
} else if (record.thread !== undefined || record.session !== undefined) fail('thread-not-allowed');
// Row 264a (#1769): a thread run works one session of its thread, the thread's default when the
// record names none (D3).
const bound = record.thread === undefined ? null : this.#threadSession({ id: record.thread }, record.session);
const key = record.business + ':' + record.run;
if (this.#runs.has(key)) fail('duplicate-run');
const session = { ...record, address: record.address ?? null, thread: record.thread ?? null, human: false };
const session = { ...record, address: record.address ?? null, thread: record.thread ?? null, session: bound, human: false };
this.#secretCheck(record);
const old = this.#store.get(
"SELECT actor_role,body FROM events WHERE business=? AND kind='session.launched' AND subject=? ORDER BY seq LIMIT 1",
@@ -910,6 +956,8 @@ export class Broker {
pid: record.pid ?? null,
startTime: record.startTime ?? null,
...(record.thread === undefined ? {} : { thread: record.thread }),
// A record without a session keeps the body it had before row 264a.
...(record.session === undefined ? {} : { session: record.session }),
};
// A run an earlier broker bound is bound again unchanged (the host's recover()), even after its
// harness was switched off, so it can be ended.
@@ -1078,14 +1126,14 @@ export class Broker {
// close is.
#grantNotice(business, decision, body) {
const d = this.#store.get('SELECT raised_by_role,raised_by_run FROM decisions WHERE id=?', decision);
const thread = this.#threaded(business, d.raised_by_role)
const session = this.#threaded(business, d.raised_by_role)
? (this.#store.get(
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
"SELECT session FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
business,
d.raised_by_run,
)?.thread ?? null)
)?.session ?? null)
: null;
for (const [to, t] of [['human', null], [d.raised_by_role, thread]]) {
for (const [to, t] of [['human', null], [d.raised_by_role, session]]) {
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
@@ -3735,7 +3783,7 @@ export class Broker {
null,
body,
);
this.#route(s.business, mid, s.role, s.thread);
this.#route(s.business, mid, s.role, s.session);
}
// Trusted launcher API: whether the role, in the thread (null for a role run), holds a merge
// decision a landing could use: cross-role, raised there with a task ref as its target, resolved
@@ -4775,22 +4823,25 @@ export class Broker {
keys(a, []);
if (s.human) fail('agent-required');
// Row 121: a thread run claims its thread, so idle threads hold nothing (REQ-THREAD-2).
// Row 264a (#1769): the claim is the run's session's, with the session's next generation.
if (s.thread) {
if (this.#store.get("SELECT 1 FROM thread_claims WHERE thread=? AND holder_run=? AND op='revoke' LIMIT 1", s.thread, s.run))
if (this.#store.get("SELECT 1 FROM thread_claims WHERE session=? AND holder_run=? AND op='revoke' LIMIT 1", s.session, s.run))
fail('run-revoked');
if (this.#threadHolder(s.thread)?.op === 'claim') fail('thread-already-held');
if (this.#sessionHolder(s.session)?.op === 'claim') fail('thread-already-held');
this.#store.run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by) VALUES(?,?,?,?,?,?,?,?)',
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,address,by) VALUES(?,?,?,?,1+(SELECT count(*) FROM thread_claims WHERE session=? AND op='claim'),?,?,?,?,?)",
this.#now(),
s.business,
s.thread,
s.session,
s.session,
'claim',
s.run,
s.harness,
s.address,
by,
);
this.#routeThread(s.thread);
this.#routeSession(s.session);
return { role: s.role, run: s.run, thread: s.thread };
}
if (
@@ -4842,7 +4893,8 @@ export class Broker {
identifier(a.decision);
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
}
// Row 155 (#1649): a thread instance holds a claim per thread, so the end revokes each one.
// Row 155 (#1649): a thread instance holds a claim per held thread session (264a), so the end
// revokes each one.
if (this.#threaded(s.business, a.role)) {
const held = this.#threadHolders(s.business, a.role);
if (held.length === 0) fail('not-held');
@@ -5066,14 +5118,14 @@ export class Broker {
op === 'resolved'
? `resolved by ${by}: ${a.choice} (${JSON.parse(d.options).find((o) => o.key === a.choice).text})`
: `withdrawn by ${by}`;
// A close to a thread instance goes to the thread the raising run held. A decision
// A close to a thread instance goes to the session the raising run held. A decision
// raised before row 121 has no thread claim and stays unthreaded.
const thread = this.#threaded(s.business, d.raised_by_role)
const session = this.#threaded(s.business, d.raised_by_role)
? (this.#store.get(
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
"SELECT session FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
s.business,
d.raised_by_run,
)?.thread ?? null)
)?.session ?? null)
: null;
const mid = randomUUID();
this.#store.run(
@@ -5090,7 +5142,7 @@ export class Broker {
null,
`Decision ${d.id} on ${d.action} was ${outcome}.${a.note === undefined ? '' : `\nNote: ${a.note}`}`,
);
this.#route(s.business, mid, d.raised_by_role, thread);
this.#route(s.business, mid, d.raised_by_role, session);
}
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
}
@@ -5171,7 +5223,7 @@ export class Broker {
}
if (a.class === 'BLOCK') this.#checkBlock(s, to, a.in_reply_to);
if (a.decision) this.#decision(s, a.decision);
const thread = this.#threadFor(s, to, a.in_reply_to);
const thread = this.#sessionFor(s, to, a.in_reply_to);
// Row 170 (#1667): a tester's message to a role nobody holds is refused before it is
// stored, so a PM test never waits for the role's next run. A run launched but not yet
// claimed counts as no holder, as in #waiting. No lock: a holder that releases once the
@@ -5208,14 +5260,14 @@ export class Broker {
this.#received.add(s.business + ':' + s.run);
}
const role = s.human ? 'human' : s.role;
// A thread run receives its own thread's messages only (REQ-THREAD-1).
// A thread run receives its own session's messages only (REQ-THREAD-1, row 264a).
const rows = this.#store.all(
"SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))" +
(s.thread ? ' AND EXISTS(SELECT 1 FROM message_threads mt WHERE mt.message=m.id AND mt.thread=?)' : '') +
(s.thread ? ' AND EXISTS(SELECT 1 FROM message_threads mt WHERE mt.message=m.id AND mt.session=?)' : '') +
' ORDER BY seq LIMIT 100',
s.business,
role,
...(s.thread ? [s.thread] : []),
...(s.thread ? [s.session] : []),
);
for (const m of rows)
this.#delivery(
@@ -5449,13 +5501,14 @@ export class Broker {
}
// Row 277 (#1782, design D1): the thread claims held in the business, for human and reader
// sessions only. A thread run's role.claim writes thread_claims, which `agents` doesn't read,
// so this is where the owner sees it (PRD :1083-1084). A claim is held when its thread's latest
// thread_claims row is a claim, as #threadHolders reads it. No thread name: a user wrote that.
// so this is where the owner sees it (PRD :1083-1084). A claim is held when its session's latest
// thread_claims row is a claim, as #threadHolders reads it (row 264a, sage-2591), so a thread
// with two held sessions lists twice. No thread name: a user wrote that.
case 'thread.claims': {
if (!s.human && !s.reader) fail('human-required');
keys(a, [], []);
return this.#store.all(
"SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND c.op='claim' ORDER BY t.seq",
"SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t JOIN sessions se ON se.thread=t.id JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id) WHERE t.business=? AND c.op='claim' ORDER BY t.seq, se.seq",
s.business,
);
}
@@ -5703,6 +5756,8 @@ export class Broker {
this.#owner(s),
a.name,
);
// Row 264a (#1769, D3): every thread has a default session from the start.
this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
return { id: tid, instance: instances[0] };
}
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
@@ -5721,20 +5776,23 @@ export class Broker {
...(a.project === undefined ? [] : [a.project]),
).filter((t) => !s.user || this.#granted(s, t.project, t.instance));
}
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1).
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). Row 264a: a thread
// verb names one session of the thread, or works on its default (D3).
case 'thread.open': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread'], ['thread']);
keys(a, ['thread', 'session'], ['thread']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
this.#store.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', this.#now(), t.id, this.#owner(s));
const session = this.#threadSession(t, a.session);
this.#store.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', this.#now(), t.id, session, this.#owner(s));
return { opened: t.id };
}
// Row 177 (#1675): a tester's message is the tester's, from tester-cli, and writes no
// human.input, as on message.send. In its own thread the lists read it as the human.
case 'thread.send': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'body', 'class'], ['thread', 'body']);
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
// Row 120's lists hold here as on message.send: the instance must hear from the human.
this.#checkMessaging(s.business, 'human', t.instance);
string(a.body, 32768);
@@ -5754,32 +5812,64 @@ export class Broker {
null,
a.body,
);
this.#route(s.business, mid, t.instance, t.id);
this.#route(s.business, mid, t.instance, session);
// A user's message, like a tester's, is never the owner's authority evidence (design 3.5).
return { id: mid, request: s.tester || s.user ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
}
// The thread's history, oldest first. Reading writes nothing.
case 'thread.messages': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'after', 'limit'], ['thread']);
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
let after = 0;
if (a.after !== undefined) {
after = this.#store.get(
'SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND mt.thread=?',
'SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND mt.session=?',
identifier(a.after),
t.id,
session,
)?.seq;
if (after === undefined) fail('message-not-found');
}
return this.#store.all(
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
t.id,
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
session,
after,
);
}
// Row 264a (#1769, T1 item 7): a thread's sessions, oldest first, and which one is the default.
// Each user reaches only their own threads' sessions (REQ-USER-2); there are no shares (Q7).
// Row 270 (264c, D6): each row's compaction count, from the host's ledger reader (the bus
// host's seat compactions.mjs; this file is mounted in the sandbox and can't import it).
// Without a reader the count is unknown, never 0.
case 'session.list': {
if (!s.tester) this.#human(s);
keys(a, ['thread'], ['thread']);
const t = this.#thread(s, a.thread);
const fallback = this.#defaultSession(t.id);
return this.#store
.all('SELECT id,at,name,predecessor,op FROM sessions WHERE thread=? ORDER BY seq', t.id)
.map(({ op, ...r }) => ({ ...r, default: r.id === fallback, compactions: this.#compactionCount(t, r.id, op === 'legacy-3l') }));
}
// D8: a new session is metadata only. Its run starts when the launcher starts it (264b).
// The op key makes a retry find the first row: the same op and name answer it, another name
// under the same op refuses.
case 'session.create': {
if (!s.tester) this.#human(s);
keys(a, ['thread', 'name', 'op'], ['thread', 'name', 'op']);
if (!name(a.name)) fail('invalid-request');
identifier(a.op);
const t = this.#thread(s, a.thread);
const old = this.#store.get('SELECT id,name FROM sessions WHERE thread=? AND op=?', t.id, a.op);
if (old) {
if (old.name !== a.name) fail('session-op-conflict');
return { id: old.id, thread: t.id, name: old.name };
}
if (this.#store.get('SELECT 1 FROM sessions WHERE thread=? AND name=?', t.id, a.name)) fail('session-exists');
return { id: this.#newSession(s, t, a.name, a.op, false), thread: t.id, name: a.name };
}
case 'replies': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['id'], ['id']);
@@ -5840,10 +5930,12 @@ export class Broker {
#claimEnd(s, h, op, decision, reason = null, by = s.run ?? s.human) {
if (h.thread)
return this.#store.run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)',
'INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
h.thread,
h.session,
h.generation,
op,
h.holder_run,
h.harness,
@@ -5886,7 +5978,7 @@ export class Broker {
}
// The message a review verb sends, routed like message.send's.
#reviewMessage(s, mid, { to, cls, in_reply_to = null, decision = null, body }) {
const thread = this.#threadFor(s, to, in_reply_to);
const thread = this.#sessionFor(s, to, in_reply_to);
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
mid,
@@ -5904,9 +5996,35 @@ export class Broker {
this.#route(s.business, mid, to, thread);
}
// Row 121: a message names its thread (REQ-MSG-1) and goes to the holder that may have it.
#route(business, mid, to, thread) {
if (thread) this.#store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', mid, thread);
this.#delivery(mid, 'routed', this.#recipient(business, to, thread));
// Row 264a (#1769): it names one session of the thread, and the session's holder may have it.
#route(business, mid, to, session) {
if (session) {
const { thread } = this.#store.get('SELECT thread FROM sessions WHERE id=?', session) ?? fail('session-not-found');
this.#store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', mid, thread, session);
}
this.#delivery(mid, 'routed', this.#recipient(business, to, session));
}
// Row 264a (#1769): a session row and, for a new thread, its default. The 3k-3l migration made the
// legacy defaults, whose id is the thread id; a new session's id is random, and a trigger refuses
// one that names another thread.
#newSession(s, t, sessionName, op, makeDefault) {
const sid = randomUUID();
const at = this.#now();
this.#store.run(
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
sid,
at,
t.business,
t.id,
t.instance,
t.owner,
sessionName,
op,
null,
);
if (makeDefault)
this.#store.run('INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,?)', at, t.id, sid, this.#owner(s));
return sid;
}
// Row 146 (#1640): a message.send that nobody holds the target for waits for a claim. Null when
// the message was routed to a holder, or is the human's, a tester's, or in a thread to a thread
@@ -6033,12 +6151,12 @@ export class Broker {
);
for (const m of rows) this.#delivery(m.id, 'routed', h);
}
// A new thread holder gets the thread's waiting messages to the instance.
#routeThread(thread) {
const h = this.#threadHolder(thread);
// A new session holder gets the session's waiting messages to the instance.
#routeSession(session) {
const h = this.#sessionHolder(session);
const rows = this.#store.all(
"SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE mt.thread=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read'))",
thread,
"SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE mt.session=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read'))",
session,
);
for (const m of rows) this.#delivery(m.id, 'routed', h);
}
+168
View File
@@ -0,0 +1,168 @@
-- Row 264a (#1769, T1 item 7): sessions below a thread. Each existing thread gets one legacy default
-- session whose id is the thread id, and its claims, messages and opens name that session (D9's
-- mapping). thread_claims, message_threads and thread_opens are rebuilt with a session column, and
-- thread_claims gains generation, the claim's number in its session. Rows are copied with their seq
-- values and old columns as they were, and the sequences carry over, as 3h-3i did. The guards and the
-- delivery trigger that read the old tables are dropped first, so the renames rewrite nothing.
DROP TRIGGER thread_one_holder;
DROP TRIGGER thread_one_per_run;
DROP TRIGGER thread_release_by_holder;
DROP TRIGGER thread_release_same_run;
DROP TRIGGER thread_revoke_needs_decision;
DROP TRIGGER thread_claims_business;
DROP TRIGGER message_threads_match;
DROP TRIGGER delivery_same_thread;
DROP TRIGGER thread_claims_no_update;
DROP TRIGGER thread_claims_no_delete;
DROP TRIGGER thread_claims_no_replace;
DROP TRIGGER message_threads_no_update;
DROP TRIGGER message_threads_no_delete;
DROP TRIGGER message_threads_no_replace;
DROP TRIGGER thread_opens_no_update;
DROP TRIGGER thread_opens_no_delete;
DROP TRIGGER thread_opens_no_replace;
DROP INDEX thread_claims_thread;
DROP INDEX message_threads_thread;
ALTER TABLE thread_claims RENAME TO thread_claims_3k;
ALTER TABLE message_threads RENAME TO message_threads_3k;
ALTER TABLE thread_opens RENAME TO thread_opens_3k;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
INSERT INTO sessions(at,business,thread,id,instance,owner,name,op,predecessor)
SELECT at,business,id,id,instance,owner,'default','legacy-3l',NULL FROM threads ORDER BY seq;
INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads ORDER BY seq;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
INSERT INTO thread_claims(seq,at,business,thread,session,generation,op,holder_run,harness,address,by,reason,decision)
SELECT c.seq,c.at,c.business,c.thread,c.thread,
(SELECT count(*) FROM thread_claims_3k p WHERE p.thread = c.thread AND p.op = 'claim' AND p.seq <= c.seq),
c.op,c.holder_run,c.harness,c.address,c.by,c.reason,c.decision FROM thread_claims_3k c ORDER BY c.seq;
INSERT INTO message_threads(message,thread,session) SELECT message,thread,thread FROM message_threads_3k ORDER BY rowid;
INSERT INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,thread,by FROM thread_opens_3k ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name IN ('thread_claims','thread_opens');
INSERT INTO sqlite_sequence(name,seq) SELECT 'thread_claims', seq FROM sqlite_sequence WHERE name='thread_claims_3k';
INSERT INTO sqlite_sequence(name,seq) SELECT 'thread_opens', seq FROM sqlite_sequence WHERE name='thread_opens_3k';
DROP TABLE thread_claims_3k;
DROP TABLE message_threads_3k;
DROP TABLE thread_opens_3k;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
+40
View File
@@ -0,0 +1,40 @@
-- Row 271 (264d, #1776, T1 item 7 D7/D8): clear context. session_ops is the clear journal, one row
-- per stage, and message_cancels holds a receipt for each message a clear cancelled. Only new
-- tables, an index and their triggers.
CREATE TABLE session_ops (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
op TEXT NOT NULL,
stage TEXT NOT NULL CHECK (stage IN ('requested','drained','uncertain','exited','retired','failed','cancelled','start-held','start-started','start-refused')),
attempt INTEGER NOT NULL CHECK (CASE WHEN stage GLOB 'start-*' THEN attempt >= 1 ELSE attempt = 0 END),
work_attempt INTEGER CHECK (work_attempt IS NULL OR (stage GLOB 'start-*' AND work_attempt >= 1)),
successor TEXT NOT NULL,
successor_name TEXT NOT NULL,
run TEXT,
generation INTEGER CHECK (generation IS NULL OR generation >= 1),
digest TEXT,
refusal TEXT,
UNIQUE (thread, op, stage, attempt)
) STRICT;
CREATE TABLE message_cancels (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
message TEXT NOT NULL UNIQUE REFERENCES messages(id),
session TEXT NOT NULL REFERENCES sessions(id),
op TEXT NOT NULL
) STRICT;
CREATE INDEX session_ops_session ON session_ops (session, seq);
CREATE TRIGGER session_ops_no_update BEFORE UPDATE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
CREATE TRIGGER session_ops_no_delete BEFORE DELETE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
CREATE TRIGGER session_ops_no_replace BEFORE INSERT ON session_ops WHEN EXISTS (SELECT 1 FROM session_ops WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops
WHEN NEW.stage = 'requested' AND EXISTS (SELECT 1 FROM session_ops o WHERE o.session = NEW.session
AND o.seq = (SELECT max(seq) FROM session_ops WHERE thread = o.thread AND op = o.op)
AND o.stage IN ('requested','drained','uncertain','exited'))
BEGIN SELECT RAISE(ABORT, 'session has an open clear'); END;
CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
+6
View File
@@ -9,6 +9,7 @@ import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
@@ -261,6 +262,11 @@ export async function startBroker({
broker = new Broker({
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
access: access ? () => loadAccess(access.file, access.context) : null,
// Row 270 (264c): session.list's compaction count, read from the session store's ledger.
compactions: ({ business, thread, session, legacy }) => {
const dir = storeDir(dataRoot, business, thread, session, { legacy });
return dir ? compactionCount(dir) : { count: null, known: false, reason: 'store-unreadable', at: null };
},
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
+17 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0';
export const SCHEMA_VERSION = '3k';
export const SCHEMA_SHA256 = '129a76db0fe9973cdea5a87f274941b4111c86730fba8a0cbe16e61a534525d0';
export const SCHEMA_VERSION = '3m';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -66,6 +66,17 @@ const migrations = [{
from: '3j',
to: '3k',
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
}, {
// Row 264a (#1769): sessions below a thread. Each thread gets a default session whose id is the
// thread id, and thread_claims, message_threads and thread_opens are rebuilt to name it.
from: '3k',
to: '3l',
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
}, {
// Row 271 (#1776): clear context. The clear journal and the cancelled-message receipts. Only new tables.
from: '3l',
to: '3m',
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -77,10 +88,14 @@ const timedTables = [
'task_snapshots',
'projects',
'threads',
'sessions',
'session_defaults',
'thread_claims',
'thread_opens',
'instance_names',
'usage_records',
'session_ops',
'message_cancels',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
+20 -7
View File
@@ -15,6 +15,9 @@ const AT = '2026-10-18T00:00:00.000Z';
const SEQUENCE = 50;
const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url), 'utf8');
const here = (p) => new URL(p, import.meta.url);
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -48,6 +51,15 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). An after-migration compare
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
// new row still fails.
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'session_defaults', seq: 0 }, { name: 'sessions', seq: 0 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
@@ -71,6 +83,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -78,9 +91,9 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3l');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
assert.deepEqual({ ...before.rows.sqlite_sequence.find(r => r.name === 'usage_records') }, { name: 'usage_records', seq: SEQUENCE });
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
@@ -90,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
// Row 236 (#1742) adds 3j→3k's; its test is gitea-migration.test.mjs.
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1',
'gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
assert.deepEqual(master(path).filter(r => !later.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)).map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
const store = new Store(root);
try {
@@ -128,8 +141,8 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -140,14 +153,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3l';")
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3m';")
.replace('}];\nconst timedTables', `}, { from: '3l', to: '3m', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3k ready/);
assert.match(migrated.stdout, /bus schema 3l ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+9 -2
View File
@@ -8,6 +8,12 @@ import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { Client } from '../src/client.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
const businesses = {
demo: {
@@ -149,7 +155,8 @@ test('broker process: launch ops authorize role.launch, record refusals and end
const side = new Store(data);
side.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', '2026-10-15T00:00:00.000Z', 'demo', 'work', null, 'jason');
side.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'th-1', '2026-10-15T00:00:00.000Z', 'demo', 'p-1', 'coder', 'jason', 'one');
side.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', '2026-10-15T00:00:01.000Z', 'th-1', 'jason');
for (const sql of DEFAULT_SESSION) side.run(sql, 'th-1');
side.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', '2026-10-15T00:00:01.000Z', 'th-1', 'th-1', 'jason');
side.close();
const ready = await ask(child, {
op: 'boot',
@@ -184,7 +191,7 @@ test('broker process: launch ops authorize role.launch, record refusals and end
assert.deepEqual((await ask(child, { op: 'credentialStatus', business: 'demo', role: 'pm' })).result, []);
// The launcher's thread view, through the same child.
assert.deepEqual((await ask(child, { op: 'threadsState', business: 'demo' })).result, [
{ thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null, lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null },
{ thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null, lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null, session: 'th-1', legacy: true },
]);
// And its idle release (Dewey's F3, 28591): the op reaches the broker, which refuses a run
// that doesn't hold the thread.
+417
View File
@@ -0,0 +1,417 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+21 -8
View File
@@ -32,14 +32,26 @@ function source3j(t) {
db.close(); chmodSync(path, 0o600);
return { root, path };
}
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). The compare after the step
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
// new row still fails.
function migrated3l(now) {
const left = now.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
assert.deepEqual(left, [{ name: 'sessions', seq: 0 }, { name: 'session_defaults', seq: 0 }]);
return { ...now, sqlite_sequence: now.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
@@ -61,6 +73,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -82,10 +95,10 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(rows(path), before);
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
const store = new Store(root);
try {
@@ -108,10 +121,10 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3l');
});
test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k', async t => {
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
const { root, path } = source3j(t);
new Store(root, { migrateSchema: true }).close();
const after = rows(path), was = master(path);
@@ -119,14 +132,14 @@ test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k',
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+21 -8
View File
@@ -18,6 +18,9 @@ const here = (p) => new URL(p, import.meta.url);
const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
// Row 236 (#1742): 3j→3k's objects, after this step. Their test is gitea-migration.test.mjs.
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -37,11 +40,20 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). The compare after the step
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
// new row still fails.
function migrated3l(now) {
const left = now.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
assert.deepEqual(left, [{ name: 'sessions', seq: 0 }, { name: 'session_defaults', seq: 0 }]);
return { ...now, sqlite_sequence: now.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
@@ -64,6 +76,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -87,10 +100,10 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(rows(path), before);
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
const store = new Store(root);
try {
@@ -112,10 +125,10 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.equal(version(path), '3l');
});
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
const { root, path } = source3i(t);
new Store(root, { migrateSchema: true }).close();
const after = rows(path), was = master(path);
@@ -123,14 +136,14 @@ test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j',
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+8 -1
View File
@@ -15,6 +15,12 @@ import { fixture } from './spec-fixtures.mjs';
const AT = '2026-10-21T00:00:00.000Z';
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
// A thread owned by `owner`, on the business's assistant instance.
function threadOf(store, project, owner, name) {
@@ -23,6 +29,7 @@ function threadOf(store, project, owner, name) {
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
id, AT, 'demo', project, 'assistant', owner, name,
);
for (const sql of DEFAULT_SESSION) store.run(sql, id);
return id;
}
// A message with its thread row, its routed delivery and one event about it. The event names the
@@ -33,7 +40,7 @@ function put(store, { thread = null, from_role, from_run, to_role, in_reply_to =
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
id, AT, 'demo', from_role, from_run, to_role, 'INFO', in_reply_to, decision, null, body,
);
if (thread) store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', id, thread);
if (thread) store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', id, thread, thread);
store.run('INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)', id, AT, 'routed', null, null, null);
store.run(
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
+12 -2
View File
@@ -50,19 +50,29 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
// thread_claims and thread_opens stay in the compare: the step carries their seq.
const SEQ_3L = ['sessions', 'session_defaults'];
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'sessions', seq: 1 }, { name: 'session_defaults', seq: 1 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
}
test('3f copy migrates to 3g preserving every old column/row, with an empty instance_names and null to_name', t => {
const { root, path, copy } = source3f(t), before = snapshot(path), untouched = readFileSync(copy);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
} finally { store.close(); }
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(readFileSync(copy), untouched);
assert.deepEqual(snapshot(copy).rows, before.rows);
new Store(root).close();
+3 -2
View File
@@ -521,7 +521,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 124 adds usage_records and its three append-only triggers.
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
assert.equal(count('table') - 1, 20);
assert.equal(count('trigger'), 71);
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
assert.equal(count('table') - 1, 22);
assert.equal(count('trigger'), 86);
assert.equal(count('view'), 5);
});
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
// Row 270 (264c, #1775, T1 item 7 D6): session.list's `compactions` key, from the host's ledger
// reader (runtime.mjs gives the seat's compactionCount). The reader itself is
// packages/seat/tests/compactions.test.mjs's.
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
projects: { stack: { root: '/nonexistent' } },
},
};
function setup(t, options = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-compactions-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({ store, businesses, ...options });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args = {}) => b.request(human, { verb, args });
const project = call('project.create', { name: 'stack work', repo: 'stack' }).id;
const A = call('thread.create', { project, name: 'alpha' }).id;
return { store, call, project, A };
}
test('session.list rows carry the reader count for each session, with its store identity', (t) => {
const asked = [];
const f = setup(t, {
compactions: (q) => {
asked.push(q);
return { count: asked.length, known: true, reason: null, at: '2026-10-22T12:00:00.000Z' };
},
});
const S = f.call('session.create', { thread: f.A, name: 'second', op: 'op1' }).id;
const rows = f.call('session.list', { thread: f.A });
assert.deepEqual(rows.map((r) => [r.id, r.compactions]), [
[rows[0].id, { count: 1, known: true, reason: null, at: '2026-10-22T12:00:00.000Z' }],
[S, { count: 2, known: true, reason: null, at: '2026-10-22T12:00:00.000Z' }],
]);
assert.deepEqual(asked, rows.map((r) => ({ business: 'demo', thread: f.A, session: r.id, legacy: false })));
assert.ok(rows.every((r) => !('op' in r)));
});
// A legacy session (op legacy-3l, from the 3k-3l migration, its id the thread's) keeps the
// thread's store. The thread is made by hand with the session the migration gives it, as
// message-filter.test.mjs does; sessions is append-only.
test('a legacy session is asked for with legacy true', (t) => {
const asked = [];
const f = setup(t, { compactions: (q) => (asked.push(q), { count: 0, known: true, reason: null, at: null }) });
const L = randomUUID();
const at = '2026-10-21T00:00:00.000Z';
f.store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', L, at, 'demo', f.project, 'assistant', 'jason', 'legacy');
f.store.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?", L);
f.store.run("INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?", L);
f.call('session.list', { thread: L });
assert.deepEqual(asked, [{ business: 'demo', thread: L, session: L, legacy: true }]);
});
test('without a reader the count is unknown, never 0; a reader that throws is store-unreadable', (t) => {
const f = setup(t);
assert.deepEqual(f.call('session.list', { thread: f.A }).map((r) => r.compactions), [{ count: null, known: false, reason: 'not-collected', at: null }]);
const g = setup(t, {
compactions: () => {
throw new Error('EACCES');
},
});
assert.deepEqual(g.call('session.list', { thread: g.A }).map((r) => r.compactions), [{ count: null, known: false, reason: 'store-unreadable', at: null }]);
});
test('compactions must be a function', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-compactions-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
assert.throws(() => new Broker({ store, businesses, compactions: {} }), { name: 'TypeError', message: 'compactions must be a function' });
});
+456
View File
@@ -0,0 +1,456 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
// Row 264a (#1769, T1 item 7): sessions below a thread, in the bus. T1's bus half, T2 and T7 of
// docs/plans/2026-10-21_t1-sessions.md. The launcher, seat and Console halves are 264b-264e's.
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const roles = {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
};
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
projects: { stack: { root: '/nonexistent' } },
},
};
const AT = '2026-10-21T00:00:00.000Z';
const now = () => new Date().toISOString();
const here = (p) => new URL(p, import.meta.url);
const ddl3k = readFileSync(here('./fixtures/schema-3k.sql'), 'utf8');
function broker(t, store) {
const b = new Broker({ store, businesses });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const caps = { human };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
return caps[name];
};
const claim = (name) => call(name, 'role.claim');
const sessionOf = (message) => store.get('SELECT session FROM message_threads WHERE message=?', message)?.session ?? null;
const claims = (session) => store.all('SELECT op, holder_run, generation FROM thread_claims WHERE session=? ORDER BY seq', session).map((r) => ({ ...r }));
return { b, store, caps, call, launch, claim, sessionOf, claims };
}
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const ctx = broker(t, store);
const project = ctx.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
const A = ctx.call('human', 'thread.create', { project, name: 'alpha' }).id;
for (const r of ['chief', 'pm']) {
ctx.launch(r, r);
ctx.claim(r);
}
const [first] = ctx.call('human', 'session.list', { thread: A });
return { ...ctx, root, project, A, D: first.id };
}
test('T1 REQ-ROLE-2: two sessions of one thread keep their own history, delivery and replies', (t) => {
const { call, launch, claim, sessionOf, store, project, A, D } = setup(t);
// thread.create made one default session, with a random id that isn't the thread's.
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.name, r.predecessor, r.default]), [['default', null, true]]);
assert.notEqual(D, A);
assert.match(D, /^[a-f0-9-]{36}$/);
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.id, r.name, r.default]), [
[D, 'default', true],
[S, 'second', false],
]);
// A run with no session named works the default (D3). Each session has its own holder.
launch('a1', 'assistant', { thread: A });
launch('a2', 'assistant', { thread: A, session: S });
assert.deepEqual(claim('a1'), { role: 'assistant', run: 'a1-run', thread: A });
assert.deepEqual(claim('a2'), { role: 'assistant', run: 'a2-run', thread: A });
const toDefault = call('human', 'thread.send', { thread: A, body: 'to the default' });
const toSecond = call('human', 'thread.send', { thread: A, session: S, body: 'to the second' });
assert.equal(sessionOf(toDefault.id), D);
assert.equal(sessionOf(toSecond.id), S);
assert.deepEqual(call('a1', 'message.receive').map((m) => m.body), ['to the default']);
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['to the second']);
assert.deepEqual(call('a1', 'message.receive'), []);
// A reply stays in its run's session, and each history reads only its own.
const reply = call('a2', 'message.send', { to: 'human', body: 'second answers', in_reply_to: toSecond.id });
assert.equal(sessionOf(reply.id), S);
assert.deepEqual(call('human', 'thread.messages', { thread: A }).map((m) => m.body), ['to the default']);
assert.deepEqual(call('human', 'thread.messages', { thread: A, session: S }).map((m) => m.body), ['to the second', 'second answers']);
// The chief's reply to the second session's work comes back to that session.
const ask = call('a2', 'message.send', { to: 'chief', body: 'need help', class: 'REQUEST' });
const back = call('chief', 'message.send', { to: 'assistant', body: 'here', in_reply_to: ask.id });
assert.equal(sessionOf(back.id), S);
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['here']);
assert.deepEqual(call('a1', 'message.receive'), []);
// An open names its session too.
call('human', 'thread.open', { thread: A, session: S });
assert.equal(store.get('SELECT session FROM thread_opens ORDER BY seq DESC LIMIT 1').session, S);
// A non-thread role names no session, and a session of another thread is refused.
assert.throws(() => launch('p2', 'pm', { session: S }), { code: 'thread-not-allowed' });
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
assert.throws(() => launch('a3', 'assistant', { thread: B, session: S }), { code: 'session-not-found' });
assert.throws(() => call('human', 'thread.send', { thread: B, session: S, body: 'x' }), { code: 'session-not-found' });
assert.throws(() => call('human', 'thread.messages', { thread: B, session: 'no-such-session' }), { code: 'session-not-found' });
});
test('T2 REQ-ROLE-2: one holder per session, generations count its claims, and a sibling session\'s message never reaches the other holder', (t) => {
const { b, call, launch, claim, claims, store, project, A, D } = setup(t);
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
launch('a1', 'assistant', { thread: A });
claim('a1');
launch('a1b', 'assistant', { thread: A });
assert.throws(() => claim('a1b'), { code: 'thread-already-held' });
// A sibling session is free to claim while the default is held.
launch('a2', 'assistant', { thread: A, session: S });
claim('a2');
// Release and claim again: the second claim is generation 2, and its release carries it.
call('a2', 'role.release');
launch('a3', 'assistant', { thread: A, session: S });
claim('a3');
b.endLaunch({ business: 'demo', run: 'a3-run', reason: 'idle' });
assert.deepEqual(claims(S), [
{ op: 'claim', holder_run: 'a2-run', generation: 1 },
{ op: 'release', holder_run: 'a2-run', generation: 1 },
{ op: 'claim', holder_run: 'a3-run', generation: 2 },
{ op: 'release', holder_run: 'a3-run', generation: 2 },
]);
// The human's stop revokes each held session, with that claim's generation.
launch('a4', 'assistant', { thread: A, session: S });
claim('a4');
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), {
stopped: [
{ thread: A, run: 'a1-run' },
{ thread: A, run: 'a4-run' },
],
});
assert.deepEqual(claims(D).at(-1), { op: 'revoke', holder_run: 'a1-run', generation: 1 });
assert.deepEqual(claims(S).at(-1), { op: 'revoke', holder_run: 'a4-run', generation: 3 });
// The triggers hold these for a write that skips the broker.
const raw = (thread, session, generation, run) =>
store.run(
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo',?,?,?,'claim',?,'pi',?)",
now(),
thread,
session,
generation,
run,
run,
);
assert.throws(() => raw(A, S, 5, 'x-run'), /a claim row names its session's generation/);
assert.throws(() => raw(A, S, 3, 'x-run'), /a claim row names its session's generation/);
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
const [{ id: BD }] = call('human', 'session.list', { thread: B });
assert.throws(() => raw(A, BD, 1, 'x-run'), /a claim's session is in its thread/);
raw(A, S, 4, 'x-run');
assert.throws(() => raw(B, BD, 1, 'x-run'), /run already holds a session/);
// A threaded message goes only to its own session's holder.
launch('a5', 'assistant', { thread: A });
claim('a5');
const m = call('human', 'thread.send', { thread: A, session: S, body: 'for x' });
assert.throws(
() => store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'a5-run'),
/a threaded message goes only to its session's holder/,
);
store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'x-run');
});
test('T2 sage-2257: a released or revoked run\'s retired generation refuses its approval, delivery and read, and writes nothing', (t) => {
const { call, launch, claim, claims, store, A, D } = setup(t);
const tables = store.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").map((r) => r.name);
const counts = () => Object.fromEntries(tables.map((n) => [n, store.get(`SELECT count(*) AS n FROM "${n}"`).n]));
// The run raises a decision, takes one message and leaves a second waiting.
const prepare = (name) => {
launch(name, 'assistant', { thread: A });
claim(name);
const d = call(name, 'decision.raise', {
action: 'spend',
target: 'demo',
question: 'spend?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'no',
blocking: false,
});
call('human', 'thread.send', { thread: A, body: `for ${name}` });
const [taken] = call(name, 'message.receive');
call('human', 'thread.send', { thread: A, body: `waiting after ${name}` });
return { d, taken };
};
// Each refusal leaves its one action.refused event (request()'s catch, #refused); nothing else is
// written: no approval, delivery, read or claim state (mos-1277).
const lastSeq = () => store.get('SELECT max(seq) AS s FROM events').s;
const refusedOnce = (fn, label, run) => {
const from = lastSeq();
assert.throws(fn, { code: 'not-holder' }, label);
const added = store.all('SELECT kind, actor_run, body FROM events WHERE seq > ? ORDER BY seq', from);
assert.deepEqual(
added.map((e) => [e.kind, e.actor_run, JSON.parse(e.body)]),
[['action.refused', run, { code: 'not-holder' }]],
label,
);
};
const refused = (name, { d, taken }) => {
const { events, ...before } = counts();
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval', `${name}-run`);
refusedOnce(() => call(name, 'message.receive'), 'delivery', `${name}-run`);
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read', `${name}-run`);
const { events: after, ...rest } = counts();
assert.deepEqual(rest, before);
assert.equal(after, events + 3);
};
// Released: generation 1 is retired once a2 claims generation 2.
const one = prepare('a1');
call('a1', 'role.release');
const two = prepare('a2');
refused('a1', one);
// Revoked by the human's stop: generation 2 is retired once a3 claims generation 3.
call('human', 'assistant.stop', { instance: 'assistant' });
prepare('a3');
refused('a2', two);
assert.deepEqual(claims(D).map((r) => [r.op, r.holder_run, r.generation]), [
['claim', 'a1-run', 1],
['release', 'a1-run', 1],
['claim', 'a2-run', 2],
['revoke', 'a2-run', 2],
['claim', 'a3-run', 3],
]);
});
test('session.create is idempotent on its op, refuses a reused op or a taken name, and reaches only the owner\'s threads', (t) => {
const { call, store, project, A, D } = setup(t);
const first = call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' });
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' }), first);
assert.throws(() => call('human', 'session.create', { thread: A, name: 'third', op: 'op-1' }), { code: 'session-op-conflict' });
assert.throws(() => call('human', 'session.create', { thread: A, name: 'second', op: 'op-2' }), { code: 'session-exists' });
assert.throws(() => call('human', 'session.create', { thread: A, name: 'default', op: 'op-3' }), { code: 'session-exists' });
// thread.create's op answers a retry that names the default.
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'default', op: 'default' }), { id: D, thread: A, name: 'default' });
for (const args of [{ thread: A, name: 'x\ny', op: 'op-4' }, { thread: A, name: 'x' }, { thread: A, name: 'x', op: 'op-5', extra: 1 }])
assert.throws(() => call('human', 'session.create', args), { code: 'invalid-request' });
assert.equal(store.get('SELECT count(*) n FROM sessions WHERE thread=?', A).n, 2);
// Another owner's thread, made by hand with the session the broker would write.
const other = 'thread-of-someone-else';
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', other, now(), 'demo', project, 'assistant', 'someone-else', 'theirs');
store.run("INSERT INTO sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-theirs',?,'demo',?,'assistant','someone-else','default','default')", now(), other);
store.run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,'s-theirs','someone-else')", now(), other);
for (const [verb, args] of [
['session.list', { thread: other }],
['session.create', { thread: other, name: 'mine', op: 'op-6' }],
['thread.send', { thread: other, session: 's-theirs', body: 'hi' }],
])
assert.throws(() => call('human', verb, args), { code: 'thread-not-owned' }, verb);
assert.throws(() => call('human', 'session.list', { thread: 'nope' }), { code: 'thread-not-found' });
// Agents don't call the session verbs.
assert.throws(() => call('pm', 'session.list', { thread: A }), { code: 'human-required' });
});
test('the 3l triggers keep sessions in their thread and session ids apart from thread ids', (t) => {
// A bare database from schema.sql, so only the triggers stand between a write and the tables.
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-sql-'));
const db = new DatabaseSync(join(root, 'bus.sqlite'));
t.after(() => {
db.close();
rmSync(root, { recursive: true, force: true });
});
db.exec(readFileSync(here('../schema.sql'), 'utf8'));
const run = (sql, ...args) => db.prepare(sql).run(...args);
const session = (id, thread, extra = {}) =>
run(
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
id,
now(),
extra.business ?? 'demo',
thread,
extra.instance ?? 'assistant',
extra.owner ?? 'jason',
extra.name ?? id,
extra.op ?? id,
extra.predecessor ?? null,
);
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
for (const id of ['th-1', 'th-2'])
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,'demo','p-1','assistant','jason',?)", id, now(), id);
session('s-1', 'th-1');
// The legacy shape: a session whose id is its own thread's.
session('th-1', 'th-1', { name: 'default', op: 'legacy-3l' });
// A session id that names another thread, and a thread id that names a session, are refused.
assert.throws(() => session('th-2', 'th-1'), /a session id names another thread/);
assert.throws(
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('s-1',?,'demo','p-1','assistant','jason','x')", now()),
/a thread id names a session/,
);
// A session has its thread's business, instance and owner, and its predecessor is in its thread.
for (const extra of [{ business: 'other' }, { instance: 'pm' }, { owner: 'someone-else' }])
assert.throws(() => session('s-x', 'th-1', extra), /a session has its thread's business, instance and owner/, JSON.stringify(extra));
assert.throws(() => session('s-x', 'th-2', { predecessor: 's-1' }), /a session's predecessor is in its thread/);
session('s-2', 'th-1', { predecessor: 's-1' });
// The names and op keys are unique in a thread.
assert.throws(() => session('s-3', 'th-1', { name: 's-1' }), /UNIQUE/);
assert.throws(() => session('s-3', 'th-1', { op: 's-1' }), /UNIQUE/);
// A default is a session of its thread.
assert.throws(() => run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /a default session is in its thread/);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
// A message's and an open's session is in their thread.
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
assert.throws(() => run("INSERT INTO message_threads(message,thread,session) VALUES('m-1','th-2','s-1')"), /a threaded message's session is in its thread/);
assert.throws(() => run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /an open's session is in its thread/);
});
// T7: a 3k store from the pinned 3k schema (8f51900b5's, the parent of this change's schema.sql)
// and fixture rows. No live store is read or copied.
function source3k(t) {
const root = mkdtempSync(join(tmpdir(), 'sessions-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3k).digest('hex'), 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0');
db.exec(ddl3k);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3k');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const id of ['t1', 't2'])
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', 'jason', id);
// Duckworth's fixture: claim, release, claim, revoke on t1, with t2's claim between, at seqs
// with gaps so a renumbered copy would show.
const claimRow = (seq, thread, op, holder, extra = {}) =>
run(
'INSERT INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by,reason) VALUES(?,?,?,?,?,?,?,?,?)',
seq, AT, 'demo', thread, op, holder, 'pi', extra.by ?? holder, extra.reason ?? null,
);
claimRow(10, 't1', 'claim', 'a-run');
claimRow(11, 't2', 'claim', 'b-run');
claimRow(12, 't1', 'release', 'a-run');
claimRow(20, 't1', 'claim', 'c-run');
claimRow(40, 't1', 'revoke', 'c-run', { by: 'jason', reason: 'stuck' });
for (const [id, thread] of [['m-1', 't1'], ['m-2', 't2'], ['m-3', 't1']]) {
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', id, AT, 'demo', 'human', 'human-cli', 'assistant', 'INFO', id);
run('INSERT INTO message_threads VALUES(?,?)', id, thread);
}
run('INSERT INTO thread_opens(seq,at,thread,by) VALUES(?,?,?,?)', 7, AT, 't1', 'jason');
db.close(); chmodSync(path, 0o600);
return { root, path };
}
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
} finally { db.close(); }
};
const rows = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens', 'meta']
.map((name) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all().map((r) => ({ ...r }))]));
} finally { db.close(); }
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
};
test('T7: a 3k store migrates to 3l, each thread\'s default session has the thread\'s id, and old rows keep their seqs', (t) => {
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
const store = new Store(root);
t.after(() => store.close());
assert.deepEqual(store.all('SELECT id,at,business,thread,instance,owner,name,op,predecessor FROM sessions ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
{ id, at: AT, business: 'demo', thread: id, instance: 'assistant', owner: 'jason', name: 'default', op: 'legacy-3l', predecessor: null })));
assert.deepEqual(store.all('SELECT at,thread,session,by FROM session_defaults ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
{ at: AT, thread: id, session: id, by: 'migration-3l' })));
// Generation counts claim rows only: claim, release, claim, revoke on t1 is 1, 1, 2, 2.
assert.deepEqual(store.all('SELECT seq,thread,session,op,generation FROM thread_claims ORDER BY seq').map((r) => ({ ...r })), [
{ seq: 10, thread: 't1', session: 't1', op: 'claim', generation: 1 },
{ seq: 11, thread: 't2', session: 't2', op: 'claim', generation: 1 },
{ seq: 12, thread: 't1', session: 't1', op: 'release', generation: 1 },
{ seq: 20, thread: 't1', session: 't1', op: 'claim', generation: 2 },
{ seq: 40, thread: 't1', session: 't1', op: 'revoke', generation: 2 },
]);
assert.deepEqual(store.all('SELECT message,thread,session FROM message_threads ORDER BY rowid').map((r) => ({ ...r })), [
{ message: 'm-1', thread: 't1', session: 't1' },
{ message: 'm-2', thread: 't2', session: 't2' },
{ message: 'm-3', thread: 't1', session: 't1' },
]);
assert.deepEqual({ ...store.get('SELECT seq,thread,session,by FROM thread_opens') }, { seq: 7, thread: 't1', session: 't1', by: 'jason' });
for (const [name, seq] of [['thread_claims', 40], ['thread_opens', 7]])
assert.equal(store.get('SELECT seq FROM sqlite_sequence WHERE name=?', name).seq, seq, name);
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
// A legacy client names no session and gets the default: t2's holder releases at generation 1,
// and t1's next claim is its third.
const { call, launch, claim, claims, sessionOf } = broker(t, store);
for (const r of ['chief', 'pm']) {
launch(r, r);
claim(r);
}
assert.throws(() => call('human', 'session.create', { thread: 't1', name: 'default', op: 'op-new' }), { code: 'session-exists' });
store.run("INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo','t2','t2',1,'release','b-run','pi','b-run')", now());
assert.equal(store.get("SELECT seq FROM thread_claims WHERE thread='t2' ORDER BY seq DESC LIMIT 1").seq, 41);
launch('d', 'assistant', { thread: 't1' });
claim('d');
assert.deepEqual(claims('t1').at(-1), { op: 'claim', holder_run: 'd-run', generation: 3 });
const sent = call('human', 'thread.send', { thread: 't1', body: 'after the step' });
assert.equal(sessionOf(sent.id), 't1');
assert.deepEqual(call('d', 'message.receive').map((m) => m.body), ['m-1', 'm-3', 'after the step']);
assert.deepEqual(call('human', 'thread.messages', { thread: 't1' }).map((m) => m.body), ['m-1', 'm-3', 'after the step']);
});
// The 3k engine is store.mjs at this change with the 3k schema and version and no 3k→3l step.
async function engine3k(root) {
const moduleRoot = join(root, 'engine-3k');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3k);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3k(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = rows(path);
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
const { root: old } = source3k(t);
new Store3k(old).close();
});
+12 -2
View File
@@ -48,17 +48,27 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
// thread_claims and thread_opens stay in the compare: the step carries their seq.
const SEQ_3L = ['sessions', 'session_defaults'];
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'sessions', seq: 1 }, { name: 'session_defaults', seq: 1 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
}
test('3e copy migrates through 3f to 3g preserving every old column/row and adding null spec citations', t => {
const { root, path, copy } = source3e(t), before = snapshot(path), untouched = readFileSync(copy);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
} finally { store.close(); }
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(readFileSync(copy), untouched);
assert.deepEqual(snapshot(copy).rows, before.rows);
new Store(root).close();
@@ -23,6 +23,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
'instance.launch', 'instance.end', 'instance.request',
// Row 264a (#1769): the sessions of the tester's own threads.
'session.list', 'session.create',
]);
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
+24 -3
View File
@@ -13,6 +13,11 @@ import { Broker } from '../src/broker.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
const ROW = ['thread', 'instance', 'holder_run', 'harness', 'at'];
// Row 264a (#1769): a hand-made thread gets the default session the 3k-3l migration gives an old one.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-claims-'));
@@ -45,10 +50,12 @@ function setup(t) {
const thread = (name) => {
const id = randomUUID();
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', project, 'assistant', 'jason', name);
for (const sql of DEFAULT_SESSION) store.run(sql, id);
return id;
};
const claim = (H, run) => {
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H });
// A run on the thread's default session, or on `session` when one is named.
const claim = (H, run, session) => {
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H, ...(session ? { session } : {}) });
call(cap, 'role.claim');
return cap;
};
@@ -108,8 +115,22 @@ test('T5: the result carries neither the thread name a user wrote nor a message
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
m, AT, 'demo', 'assistant', 'assistant-run', 'human', 'INFO', null, null, null, 'alice secret message body',
);
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', m, H);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', m, H, H);
const text = JSON.stringify(call('human', 'thread.claims'));
assert.ok(text.includes(H), 'the thread row is there');
assert.doesNotMatch(text, /secret/);
});
// Row 264a (#1769, sage-2591, duckworth-0124 C4): a claim is held per session, so one thread with two
// held sessions lists both runs, and releasing one leaves the other. A per-thread latest row would show
// only the last claim.
test('T6: two held sessions of one thread list as two rows, each its session\'s latest claim', (t) => {
const { call, thread, claim } = setup(t);
const H = thread('plan');
const S = call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' }).id;
const first = claim(H, 'run-default');
claim(H, 'run-second', S);
assert.deepEqual(call('human', 'thread.claims').map((r) => [r.thread, r.holder_run]), [[H, 'run-default'], [H, 'run-second']]);
call(first, 'role.release');
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-second'], 'the default released, the second held');
});
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3k ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3l ready', ''].join('\n'));
});
+44 -18
View File
@@ -7,6 +7,12 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, THREAD_PAGE } from '../src/broker.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
// Row 121 (#1615): projects and threads in the bus. The assistant's messaging lists are the
// shipped roles/assistant.json's, so the chain below runs under the lists the stack runs.
@@ -81,32 +87,36 @@ test('REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the
const { b, store, call, launch, claim, A, B } = twoThreads(t);
launch('a2', 'assistant', { thread: A });
assert.throws(() => claim('a2'), { code: 'thread-already-held' });
// The trigger refuses a second claim that skips the broker.
// The trigger refuses a second claim that skips the broker. Row 264a: the claim is of the
// thread's default session, with the generation the trigger expects.
const sessionOf = (thread) => store.get('SELECT session FROM session_defaults WHERE thread=?', thread).session;
const second = () =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
now(),
'demo',
A,
sessionOf(A),
'a2-run',
'pi',
'a2-run',
);
assert.throws(second, /thread already held/);
// Second case: one run holds one thread. a-run already holds A.
assert.throws(second, /session already held/);
// Second case: one run holds one session. a-run already holds A's.
call('b', 'role.release');
assert.throws(
() =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
now(),
'demo',
B,
sessionOf(B),
'a-run',
'pi',
'a-run',
),
/run already holds a thread/,
/run already holds a session/,
);
// A released thread can be claimed again by a new run; the claim is the thread's, not the role's.
launch('b2', 'assistant', { thread: B });
@@ -128,12 +138,20 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
});
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));
const run = (sql, ...args) => db.prepare(sql).run(...args);
// Row 264a (#1769): a claim row names its session and the generation thread_claim_generation
// expects, so each refusal below comes from the trigger it names.
const generation = (op) =>
op === 'claim'
? 1 + db.prepare("SELECT count(*) n FROM thread_claims WHERE session='s-1' AND op='claim'").get().n
: (db.prepare("SELECT generation FROM thread_claims WHERE session='s-1' ORDER BY seq DESC LIMIT 1").get()?.generation ?? null);
const claim = (op, run_, extra = {}) =>
run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?)',
'INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
now(),
extra.business ?? 'demo',
extra.thread ?? 'th-1',
's-1',
generation(op),
op,
run_,
'pi',
@@ -143,9 +161,11 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
);
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-1',?,'demo','p-1','assistant','jason','one')", now());
run("INSERT INTO thread_opens(at,thread,by) VALUES(?,'th-1','jason')", now());
run("INSERT INTO sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-1',?,'demo','th-1','assistant','jason','default','default')", now());
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
run("INSERT INTO message_threads(message,thread) VALUES('m-1','th-1')");
run("INSERT INTO message_threads(message,thread,session) VALUES('m-1','th-1','s-1')");
// A thread is in its project's business.
assert.throws(
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-x',?,'other','p-1','assistant','jason','x')", now()),
@@ -154,20 +174,22 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
// A claim names the thread's business.
assert.throws(() => claim('claim', 'a-run', { business: 'other' }), /a thread claim names the thread's business/);
// Nothing releases or revokes a thread nobody holds.
assert.throws(() => claim('release', 'a-run'), /thread is not held/);
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /thread is not held/);
assert.throws(() => claim('release', 'a-run'), /session is not held/);
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /session is not held/);
claim('claim', 'a-run');
// Only the holder releases; a revoke needs a decision or a reason.
assert.throws(() => claim('release', 'b-run'), /only the holder releases; others revoke/);
assert.throws(() => claim('revoke', 'a-run'), /revoke needs a resolved decision or a reason/);
claim('revoke', 'a-run', { reason: 'stuck' });
// Every 3e table is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
// Every 3e table, and row 264a's two, is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
const rows = {
projects: ["id='p-1'", 'name', "INSERT OR REPLACE INTO projects(seq,id,at,business,name,created_by) SELECT seq,id,at,business,name,created_by FROM projects WHERE id='p-1'"],
threads: ["id='th-1'", 'name', "INSERT OR REPLACE INTO threads(seq,id,at,business,project,instance,owner,name) SELECT seq,id,at,business,project,instance,owner,name FROM threads WHERE id='th-1'"],
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by) SELECT seq,at,business,thread,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread) VALUES('m-1','th-1')"],
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,by) SELECT seq,at,thread,by FROM thread_opens WHERE seq=1'],
sessions: ["id='s-1'", 'name', "INSERT OR REPLACE INTO sessions(seq,id,at,business,thread,instance,owner,name,op) SELECT seq,id,at,business,thread,instance,owner,name,op FROM sessions WHERE id='s-1'"],
session_defaults: ['seq=1', 'by', 'INSERT OR REPLACE INTO session_defaults(seq,at,thread,session,by) SELECT seq,at,thread,session,by FROM session_defaults WHERE seq=1'],
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,session,generation,op,holder_run,harness,by) SELECT seq,at,business,thread,session,generation,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread,session) VALUES('m-1','th-1','s-1')"],
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,session,by FROM thread_opens WHERE seq=1'],
};
for (const [table, [where, col, replace]] of Object.entries(rows)) {
const only = new RegExp(`${table} is append-only`);
@@ -225,7 +247,7 @@ test('REQ-MSG-1: a message to or from the assistant names its thread, or is refu
// The store refuses a thread on a message the instance is not party to.
const pmOnly = call('pm', 'message.send', { to: 'coder', body: 'unrelated' });
assert.throws(
() => store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', pmOnly.id, A),
() => store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,(SELECT session FROM session_defaults WHERE thread=?))', pmOnly.id, A, A),
/to or from the thread's instance/,
);
void b;
@@ -277,8 +299,10 @@ test('REQ-MSG-1: a reply can\'t carry one thread instance\'s thread to another t
const helper = { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } };
const { store, call, launch, claim, count } = setup(t, demo({ roles: { ...roles, helper } }));
const project = call('human', 'project.create', { name: 'two instances' }).id;
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']])
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']]) {
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, now(), 'demo', project, instance, 'jason', id);
for (const sql of DEFAULT_SESSION) store.run(sql, id);
}
launch('chief', 'chief');
claim('chief');
launch('a', 'assistant', { thread: 'th-a' });
@@ -314,7 +338,7 @@ test('REQ-THREAD-1 REQ-USER-2: one thread\'s messages never reach another thread
'pi',
null,
),
/only to its thread's holder/,
/only to its session's holder/,
op,
);
// B's holder can't read A's message either.
@@ -437,6 +461,7 @@ test('Q169 REQ-USER-2: assistant.stop ends the human\'s own assistant thread run
'someone-else',
'theirs',
);
for (const sql of DEFAULT_SESSION) store.run(sql, other);
launch('o', 'assistant', { thread: other });
claim('o');
const last = (thread) => ({
@@ -486,6 +511,7 @@ test('REQ-USER-2: the human verbs refuse another owner\'s thread', (t) => {
'someone-else',
'theirs',
);
for (const sql of DEFAULT_SESSION) store.run(sql, other);
for (const [verb, args] of [
['thread.open', { thread: other }],
['thread.send', { thread: other, body: 'hi' }],
+16 -4
View File
@@ -46,17 +46,29 @@ function snapshot(path, columns) {
columns ??= Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
.map(({ name }) => [name, db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name)]));
const rows = Object.fromEntries(Object.entries(columns).map(([table, keys]) => [table,
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY rowid`).all()
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY ${table === 'sqlite_sequence' ? 'name' : 'rowid'}`).all()
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
return { columns, rows };
} finally { db.close(); }
}
test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old column, row and sequence; new usage table is empty', t => {
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
// thread_claims and thread_opens stay in the compare: the step carries their seq.
// The step writes the thread_claims and thread_opens rows again, after instance_names', so the
// snapshot reads sqlite_sequence by name, as account-migration.test.mjs does (row 126).
const SEQ_3L = ['sessions', 'session_defaults'];
function migrated3l(rows) {
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'session_defaults', seq: 1 }, { name: 'sessions', seq: 1 }]);
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
}
test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving every old column, row and sequence; new usage table is empty', t => {
const { root, path, copy } = source3g(t), before = snapshot(path), untouched = readFileSync(copy);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
@@ -64,7 +76,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
} finally { store.close(); }
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(readFileSync(copy), untouched);
assert.deepEqual(snapshot(copy).rows, before.rows);
new Store(root).close();
+136 -50
View File
@@ -75,6 +75,15 @@
// threadStore()), so the next run for the thread resumes the harness
// session (REQ-THREAD-2).
//
// Row 269 (264b, #1774): threadsState has one row per session of each
// thread, and everything above is per session. Each session has its own
// store (session.mjs sessionStore()) and its own workspace under
// session-workspaces/ (sessionWorkspace()), which a lock beside it keeps to
// one writer from before the clones until the run's proven exit. A legacy
// session (op legacy-3l) keeps the thread's store, and its new workspace
// shows inside the sandbox at the old instance path, the cwd its history
// recorded. The broker's half of the fence is its per-session claim.
//
// Landing runs (row 156, #1651). Every launch of a non-thread instance
// reads the broker's landHolders (launch op): the approved, unspent merge
// decisions each non-thread instance raised. The instance's entries go into
@@ -121,12 +130,14 @@
// processing-halted.
// - delete: instance-running against the registry, then lifecycleDelete
// (the writer's commit), then the rename of workspaces/<b>/I/ to
// workspaces/<b>/.retired/I-<transaction>/. A failed rename closes it
// workspace-rename-failed with the transaction; a later delete retries it.
// workspaces/<b>/.retired/I-<transaction>/, and of
// session-workspaces/<b>/I/ to session-workspaces/<b>/.retired/I-<transaction>/
// (row 269). A failed rename closes it workspace-rename-failed with the
// transaction; a later delete retries it.
import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
@@ -142,13 +153,16 @@ import {
runnerOf,
SESSION_CREDENTIALS,
sessionEnv,
threadStore,
sessionExit,
sessionStore,
sessionWorkspace,
lockSessionWorkspace,
spawnSession,
workspaceDir,
writeSessionFile,
} from "../../seat/src/session.mjs";
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
import { collectCompactions } from "../../seat/src/compactions.mjs";
import { CheckoutError, ensureCheckouts } from "../../seat/src/checkout.mjs";
import { createGitSocket } from "./git-socket.mjs";
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
@@ -237,6 +251,9 @@ export function createLauncher({
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
}
const registry = new Registry(dataRoot, { readOnly: !launches });
// Row 269: earlier-host runs still live after recover(). check() sees each while it lives.
const survivors = [];
const liveRun = (s) => startTimeOf(s.pid) === s.startTime || startTimeOf(s.runnerPid) === s.runnerStartTime;
const children = new Map();
let chain = Promise.resolve();
let closed = false;
@@ -267,6 +284,22 @@ export function createLauncher({
}
};
// Row 270 (264c, D6): count a thread session's completed compactions into its
// store's ledger, at run start (what earlier runs and a resume left) and at
// run end. The caller holds the session's workspace lock. A failure is
// logged and recorded, and never stops or fails the run: the count then
// reads unknown with its reason.
const compactions = (store, harness, { instance, thread, session, run }) => {
let c;
try {
c = collectCompactions(store, harness);
} catch (e) {
log(`run ${run}: compactions: ${e.message}`);
c = { count: null, known: false, reason: "store-unreadable" };
}
record({ event: "compactions", instance, thread, session, run, count: c.count, known: c.known, reason: c.reason });
};
function serialize(fn) {
const run = chain.then(fn);
chain = run.catch(() => {});
@@ -278,7 +311,7 @@ export function createLauncher({
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
// human's launch request, which may name any declared instance. The listing
// limits the chief, not the human. launch.by still takes the `pm` branch.
async function check(instance, { pm = false, thread = null, human = false } = {}) {
async function check(instance, { pm = false, thread = null, session = null, human = false } = {}) {
const admissionGeneration = admission().generation;
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
@@ -286,7 +319,12 @@ export function createLauncher({
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (registry.running(b.id, instance, thread)) throw new Refusal("instance-running");
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
if (registry.running(b.id, instance, thread, session)) throw new Refusal("instance-running");
// Row 269 (Q2): a run an earlier host started with no session works in the instance's old
// workspace and the thread's store, so no session of the instance starts while one lives.
if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session))
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
@@ -378,29 +416,59 @@ export function createLauncher({
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, human = false, admissionGeneration }) {
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, session = null, legacy = false, legacyThread = legacy, human = false, admissionGeneration }) {
admission(admissionGeneration);
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
// A thread's store is checked first, so a bad id or an unsafe directory
// launches nothing.
// A thread session's store is checked first, so a bad id or an unsafe
// directory launches nothing.
let store = null;
if (thread) {
try {
store = threadStore(dataRoot, b.id, thread);
store = sessionStore(dataRoot, b.id, thread, session, { legacy, legacyThread });
} catch (e) {
throw new Refusal("thread-store-refused", e.message);
throw new Refusal(e.code === "legacy-store-unmapped" ? e.code : "thread-store-refused", e.message);
}
}
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
admission(admissionGeneration);
// The clones come before the run, so one that can't be made leaves no
// run behind. The host's git treats each clone as hostile, and its
// checks hold only while no session writes the workspace: a workspace
// is one instance's, check() refused instance-running, and launches are
// serialized, so none does.
const workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
// checks hold only while no session writes the workspace. A non-thread
// workspace is one instance's, check() refused instance-running, and
// launches are serialized. A thread session's workspace is its own (row
// 269), and its lock refuses a second writer, whichever host asks.
let workspace;
let workspaceDest = null;
let lock = null;
if (thread) {
try {
const live = registry.sessions.filter((s) => s.business === b.id && s.workspace).map((s) => s.workspace);
workspace = sessionWorkspace(dataRoot, b.id, instance, session, { live });
lock = lockSessionWorkspace(workspace, { hostPid: process.pid, hostStartTime: startTimeOf(process.pid), business: b.id, instance, thread, session });
} catch (e) {
if (["session-workspace-unsafe", "session-workspace-overlap", "session-workspace-busy"].includes(e.code)) throw new Refusal(e.code, e.message);
throw e;
}
if (legacy) workspaceDest = workspaceDir(dataRoot, b.id, instance);
} else {
workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
}
try {
return await startLocked({ instance, launchedBy, resolved, model, account, thread, session, human, admissionGeneration, store, credentials, workspace, workspaceDest, lock });
} catch (e) {
// Every failure path in startLocked has proven the run's exit, or never spawned one.
lock?.release();
throw e;
}
}
// The rest of start(): clones, bundle, spawn, bind, register. The lock, when there is one, is
// released at the run's close.
async function startLocked({ instance, launchedBy, resolved, model, account, thread, session, human, admissionGeneration, store, credentials, workspace, workspaceDest, lock }) {
// What the session sees: a legacy session's workspace at the old instance path.
const inSandbox = workspaceDest ?? workspace;
let checkouts;
try {
checkouts = await withProcessing(dataRoot, () => {
@@ -416,11 +484,12 @@ export function createLauncher({
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
let harness, route, routeReason;
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,human}));
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
admission(admissionGeneration);
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
if (store) compactions(store, model.harness, { instance, thread, session, run });
const toolSocket = join(runDir, "tools.sock");
const turnMarker = join(runDir, "turn.done");
const version = harnessVersion !== undefined ? harnessVersion : call(model.harness, "version", () => harness.version({ env }));
@@ -440,7 +509,7 @@ export function createLauncher({
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
credentials,
session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
session: { run, launchedBy, workspace: inSandbox, toolSocket, turnMarker, checkouts: inSandbox === workspace ? checkouts : checkouts.map((c) => ({ ...c, path: join(inSandbox, relative(workspace, c.path)) })) },
harness,
route,
routeReason,
@@ -471,7 +540,7 @@ export function createLauncher({
brokerSocket: host.path,
launchSocket: launchSocketPath(dataRoot),
gitSocket: git,
workspace,
workspace: inSandbox,
sessionDir,
adapter,
bundle: files,
@@ -484,6 +553,7 @@ export function createLauncher({
const paths = {
workspace,
workspaceDest,
adapter,
harnessMounts,
brokerSocket: host.path,
@@ -519,11 +589,12 @@ export function createLauncher({
updateRuntime(dataRoot, managed.id, { runnerPid: runner.pid, runnerStartTime: runner.startTime });
admission(admissionGeneration);
if (!outerStart) throw new Error("the session process exited at once");
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}) });
bound = true;
admission(admissionGeneration);
entry = {
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
business: b.id, instance, ...(thread ? { thread, session, workspace } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
@@ -552,13 +623,16 @@ export function createLauncher({
const ended = await endRun(run, reason, signal ? null : code);
registry.remove(b.id, run);
// Row 125 (#1619, REQ-LIMIT-4): a run a limit check stopped names the limit, from the claim row.
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
record({ event: "end", instance, ...(thread ? { thread, session } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
log(`run ${run} (${instance}) ended: ${reason}`);
} catch (e) {
log(`run ${run}: ${e.message}`);
} finally {
try { unregisterRuntime(dataRoot, managed.id); }
catch { log(`run ${run}: processing receipt cleanup refused`); }
if (store) compactions(store, model.harness, { instance, thread, session, run });
try { lock?.release(); }
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
children.delete(run);
stopping.delete(run);
spawnedAt.delete(run);
@@ -566,7 +640,7 @@ export function createLauncher({
refusedRelease.delete(run);
}
});
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
record({ event: "launch", instance, ...(thread ? { thread, session } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
@@ -600,9 +674,12 @@ export function createLauncher({
}
const now = Date.now();
const ms = (at) => (at ? Date.parse(at) : 0);
// Row 269: a thread with a legacy session keeps its old store for that session only.
const legacyThreads = new Set(threads.filter((t) => t.legacy).map((t) => t.thread));
for (const t of threads) {
if (closed) return;
const s = registry.running(b.id, t.instance, t.thread);
const key = `${t.thread}/${t.session}`;
const s = registry.running(b.id, t.instance, t.thread, t.session);
if (s) {
const last = Math.max(ms(s.startedAt), ms(t.lastMessage), ms(t.lastOpen));
if (!stopping.has(s.run) && now - last >= b.launch.threadIdleSeconds * 1000) {
@@ -616,7 +693,7 @@ export function createLauncher({
// A turn in progress, or a message since the snapshot: the run keeps going.
if (!idle.released) continue;
stopping.add(s.run);
record({ event: "thread-idle", instance: t.instance, thread: t.thread, run: s.run });
record({ event: "thread-idle", instance: t.instance, thread: t.thread, session: t.session, run: s.run });
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
@@ -629,20 +706,21 @@ export function createLauncher({
// the work (it died before its claim, say) waits for the next one
// rather than start again every tick. A refused start isn't a start.
const signature = `${t.lastMessage}|${t.lastOpen}`;
if (startedFor.get(t.thread) === signature) continue;
if (startedFor.get(key) === signature) continue;
await serialize(async () => {
if (closed || registry.running(b.id, t.instance, t.thread)) return;
if (closed || registry.running(b.id, t.instance, t.thread, t.session)) return;
try {
await start({ instance: t.instance, launchedBy: b.human, thread: t.thread, ...(await check(t.instance, { thread: t.thread })) });
startedFor.set(t.thread, signature);
refusedThreads.delete(t.thread);
const where = { thread: t.thread, session: t.session };
await start({ instance: t.instance, launchedBy: b.human, ...where, legacy: t.legacy, legacyThread: legacyThreads.has(t.thread), ...(await check(t.instance, where)) });
startedFor.set(key, signature);
refusedThreads.delete(key);
} catch (e) {
const code = e instanceof Refusal ? e.code : "launch-failed";
// Logged once per code, not once per tick.
if (refusedThreads.get(t.thread) === code) return;
refusedThreads.set(t.thread, code);
log(`thread ${t.thread} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
if (refusedThreads.get(key) === code) return;
refusedThreads.set(key, code);
log(`thread ${t.thread} session ${t.session} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
}
});
}
@@ -849,20 +927,23 @@ export function createLauncher({
const transaction = out?.transaction ?? null;
if (transaction === null) return { refusal: "workspace-rename-failed" };
const bot = out.bot ? { bot: out.bot } : {};
const from = workspaceDir(dataRoot, b.id, r.instance);
if (!existsSync(from)) return { transaction, ...bot };
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
// The old instance workspace, then (row 269) the instance's session workspaces, each moved
// whole. A retry moves whichever is still in place.
for (const from of [workspaceDir(dataRoot, b.id, r.instance), join(dataRoot, "session-workspaces", b.id, r.instance)]) {
if (!existsSync(from)) continue;
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
return { transaction, ...bot };
}
@@ -958,10 +1039,15 @@ export function createLauncher({
process.kill(s.pid, "SIGKILL");
} catch {}
const end = Date.now() + 5000;
while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
while (liveRun(s) && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
}
// Row 269 (Q2): the session or its runner outlived the kill, or wasn't a session to kill.
if (liveRun(s)) {
survivors.push(s);
log(`run ${s.run} (${s.instance}) from an earlier host still runs (pid ${s.pid}, runner ${s.runnerPid}): no session of ${s.instance} starts until it exits`);
}
try {
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}) });
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}) });
} catch (e) {
// A host that died after ending the run but before updating sessions.json.
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
@@ -969,7 +1055,7 @@ export function createLauncher({
continue;
}
await endRun(s.run, "host-lost", null);
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
}
leftover = [];
+18
View File
@@ -0,0 +1,18 @@
// A stand-in harness turn for the session launch tests (row 269, #1774).
// thread-adapter.mjs's turn, plus the run's cwd and the names in it, so a
// test reads from the reply which workspace the session saw and at what
// in-sandbox path. It keeps a history in $MOSAIC_SESSION_DIR, the session's
// store.
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const request = process.env.MOSAIC_REQUEST ?? "";
const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? "";
const historyFile = join(process.env.MOSAIC_SESSION_DIR, "history");
const before = existsSync(historyFile) ? readFileSync(historyFile, "utf8").split("\n").filter(Boolean) : [];
const cwd = process.cwd();
const entries = readdirSync(cwd).sort();
appendFileSync(historyFile, `${body}\n`);
process.stdout.write(`THREAD ${JSON.stringify({ body, before, cwd, entries })}\n`);
writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
+414
View File
@@ -0,0 +1,414 @@
// Session runs (row 269, 264b, #1774): every session of a thread runs on its
// own, with its own store under threads/<b>/<t>/sessions/<s>/ and its own
// workspace under session-workspaces/<b>/<instance>/<s>/, and a lock beside
// the workspace keeps it to one writer. A legacy session (op legacy-3l) keeps
// the thread's old store, and its workspace shows inside the sandbox at the
// old instance path. Real runners in the bwrap sandbox; a stub adapter keeps
// a history in the store and answers with what it found, its cwd and what
// the cwd holds.
//
// The host is thread-launch.test.mjs's: startBroker on bootConfig's config,
// with the ops the broker child forwards, and the human capability from
// bindHuman directly.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { transition } from "../../processing-control/src/state.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { startTimeOf } from "../../seat/src/proc.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
// A thread made by hand gets the default session the 3k-3l migration gives a legacy thread, with
// the thread's id (as in thread-launch.test.mjs).
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const ADAPTER = fileURLToPath(new URL("./fixtures/session-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
// A mutant's kill is an assertion failure, not a timeout (sage-2707), so the waits a mutant could
// stall return what they last saw and the assertions after them decide.
async function settle(fn, ms = 20000) {
const end = Date.now() + ms;
let v;
while (Date.now() < end) {
v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
return v;
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// Each directory made 0700, as the store and workspace checks want.
function privateDir(...parts) {
for (let i = 1; i <= parts.length; i++) {
const d = join(...parts.slice(0, i));
if (!existsSync(d)) mkdirSync(d, { mode: 0o700 });
if (i > 1) chmodSync(d, 0o700);
}
return join(...parts);
}
// The assistant is the thread instance; sonnet is its model's limit.
function prepare(t, { sonnet = 3, idle = 1 } = {}) {
const root = tmp(t, "mosaic-session-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
doc.launch.threadIdleSeconds = idle;
return doc;
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
// A host and its launcher on the prepared root. `ops` replaces launch ops, given the runtime.
async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
...replace?.(runtime),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: tickMs,
log: (l) => logs.push(l),
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
return { host, runtime, launcher, call, logs, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
const sessionWs = (f, session) => join(f.dataRoot, "session-workspaces", "acme", "assistant", session);
// The refusals once there are n, or once anything launched.
const outcome = async (f, n = 1) => {
await settle(() => events(f, "refused").length >= n || events(f, "launch").length);
return events(f, "refused").map((e) => [e.session, e.code]);
};
const historyOf = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
// Send to one session of a thread; reply() waits for the assistant's RESULT reply in that session.
function send(call, thread, session, body) {
const sent = call("thread.send", { thread, ...(session ? { session } : {}), body });
const reply = async () => {
const r = await until(() => call("thread.messages", { thread, ...(session ? { session } : {}) }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.equal(r.from_role, "assistant");
return JSON.parse(r.body.match(/THREAD (\{.*\})/)[1]);
};
return { sent, reply };
}
// A thread made with thread.create, its default session and a second named one.
function twoSessions(call) {
const project = call("project.create", { name: "stack work", repo: "stack" }).id;
const A = call("thread.create", { project, name: "alpha" }).id;
const [{ id: D }] = call("session.list", { thread: A });
const S = call("session.create", { thread: A, name: "second", op: "op-second" }).id;
return { project, A, D, S };
}
test("REQ-THREAD-2 REQ-LAUNCH-1 two sessions of one thread, and another thread's default, run at once, each with its own store and workspace; a restart of one resumes only that one", { skip, timeout: 90000 }, async (t) => {
const prepared = prepare(t, { idle: 5 });
const { f } = prepared;
const host = await start(t, prepared);
const { project, A, D, S } = twoSessions(host.call);
const B = host.call("thread.create", { project, name: "beta" }).id;
const [{ id: BD }] = host.call("session.list", { thread: B });
const toD = send(host.call, A, D, "first for D");
const toS = send(host.call, A, S, "first for S");
const toB = send(host.call, B, null, "first for B");
// All three run together (M1, M2: a shared workspace would refuse the second start busy).
await settle(() => readSessions(f.dataRoot).length === 3 || events(f, "refused").length);
assert.deepEqual(events(f, "refused"), []);
assert.deepEqual(readSessions(f.dataRoot).map((s) => [s.thread, s.session]).sort(), [[A, D], [A, S], [B, BD]].sort());
const [sawD, sawS, sawB] = await Promise.all([toD.reply(), toS.reply(), toB.reply()]);
// Each session's history is its own (M5), and each runs in its own workspace at its own path (M8).
assert.deepEqual([sawD.body, sawD.before, sawD.cwd], ["first for D", [], sessionWs(f, D)]);
assert.deepEqual([sawS.body, sawS.before, sawS.cwd], ["first for S", [], sessionWs(f, S)]);
assert.deepEqual([sawB.body, sawB.before, sawB.cwd], ["first for B", [], sessionWs(f, BD)]);
const store = (thread, session) => join(f.dataRoot, "threads", "acme", thread, "sessions", session, "session", "history");
assert.deepEqual([historyOf(store(A, D)), historyOf(store(A, S)), historyOf(store(B, BD))], ["first for D\n", "first for S\n", "first for B\n"]);
for (const thread of [A, B]) assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
assert.equal(existsSync(join(f.dataRoot, "workspaces", "acme", "assistant")), false, "no thread session uses the old instance workspace");
for (const e of events(f, "launch")) assert.ok([D, S, BD].includes(e.session), JSON.stringify(e));
// All three stop when idle, and each lock goes with its run.
await until(() => events(f, "end").length === 3, 30000);
assert.deepEqual(readSessions(f.dataRoot), []);
for (const s of [D, S, BD]) assert.equal(existsSync(`${sessionWs(f, s)}.lock`), false, s);
// The named session's next message starts only that session (M9), which finds only its history.
const again = await send(host.call, A, S, "second for S").reply();
assert.deepEqual([again.before, again.cwd], [["first for S"], sessionWs(f, S)]);
assert.deepEqual(events(f, "launch").slice(3).map((e) => [e.thread, e.session]), [[A, S]]);
assert.equal(historyOf(store(A, D)), "first for D\n");
});
// D9 (M7): a thread from before 3l. Its legacy session resumes from the thread's old store, and
// its new workspace shows at the old instance path, which the transcripts recorded as the cwd.
// The old host workspace isn't mounted.
test("REQ-THREAD-2 REQ-LAUNCH-1 a legacy session resumes the thread's old store at the old in-sandbox cwd, in a workspace of its own", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const store = new Store(f.dataRoot);
const at = () => new Date().toISOString();
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", "th-legacy", at(), "acme", "p-hand", "assistant", "jason", "old");
for (const sql of DEFAULT_SESSION) store.run(sql, "th-legacy");
store.close();
const old = privateDir(f.dataRoot, "threads", "acme", "th-legacy");
writeFileSync(join(privateDir(old, "session"), "history"), "old sentinel\n");
privateDir(old, "claude-projects");
writeFileSync(join(privateDir(f.dataRoot, "workspaces", "acme", "assistant"), "old-file"), "the 264a workspace\n");
const host = await start(t, prepared);
const saw = await send(host.call, "th-legacy", null, "after 3l").reply();
assert.deepEqual(saw.before, ["old sentinel"]);
assert.equal(saw.cwd, join(f.dataRoot, "workspaces", "acme", "assistant"));
assert.equal(saw.entries.includes("old-file"), false, "the old host workspace isn't mounted");
assert.equal(readFileSync(join(old, "session", "history"), "utf8"), "old sentinel\nafter 3l\n");
assert.equal(existsSync(join(old, "sessions")), false);
assert.ok(existsSync(sessionWs(f, "th-legacy")));
assert.deepEqual(readdirSync(join(f.dataRoot, "workspaces", "acme", "assistant")), ["old-file"]);
assert.deepEqual(events(f, "launch").map((e) => [e.thread, e.session]), [["th-legacy", "th-legacy"]]);
});
// T2, M3: another host's live lock on the session's workspace refuses the start before any
// checkout. Once that host is gone the lock is stale, and the next start takes it over.
test("REQ-THREAD-2 REQ-LAUNCH-1 a live lock on a session workspace refuses session-workspace-busy before any checkout; a stale one is taken over", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, S } = twoSessions(host.call);
const other = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => other.once("exit", r));
t.after(() => other.kill("SIGKILL"));
const otherStart = await until(() => startTimeOf(other.pid));
privateDir(f.dataRoot, "session-workspaces", "acme", "assistant");
const lock = `${sessionWs(f, S)}.lock`;
const held = `${JSON.stringify({ hostPid: other.pid, hostStartTime: otherStart, business: "acme", instance: "assistant", thread: A, session: S })}\n`;
writeFileSync(lock, held, { mode: 0o600 });
const toS = send(host.call, A, S, "for S");
assert.deepEqual(await outcome(f), [[S, "session-workspace-busy"]]);
assert.equal(events(f, "refused")[0].thread, A);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(readdirSync(sessionWs(f, S)), [], "no checkout");
assert.equal(readFileSync(lock, "utf8"), held);
other.kill("SIGKILL");
await gone;
const saw = await toS.reply();
assert.deepEqual([saw.before, saw.cwd], [[], sessionWs(f, S)]);
await until(() => events(f, "end").length === 1);
assert.equal(existsSync(lock), false);
});
// Q2 (BUILD.md:177-179, t1-sessions.md:109): an earlier host's sessionless run of the instance is
// still live, so no session of it starts until that run has exited. A sleep child stands in for
// its runner; its outer pid is gone, so recover() has nothing to kill and keeps it as a survivor.
test("REQ-THREAD-2 REQ-LAUNCH-1 a session start refuses legacy-run-live while an earlier host's sessionless run lives, and starts once it has exited", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const runner = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => runner.once("exit", r));
t.after(() => runner.kill("SIGKILL"));
const runnerStartTime = await until(() => startTimeOf(runner.pid));
new Registry(f.dataRoot).add({ business: "acme", instance: "assistant", thread: "th-old", run: "run-left", harness: "pi", pid: 2 ** 22 + 7, startTime: "gone", runnerPid: runner.pid, runnerStartTime });
const host = await start(t, prepared);
assert.ok(host.logs.includes(`run run-left (assistant) from an earlier host still runs (pid ${2 ** 22 + 7}, runner ${runner.pid}): no session of assistant starts until it exits`), host.logs.join("\n"));
const { A, S } = twoSessions(host.call);
const toS = send(host.call, A, S, "for S");
assert.deepEqual(await outcome(f), [[S, "legacy-run-live"]]);
assert.equal(events(f, "refused")[0].thread, A);
assert.deepEqual(events(f, "launch"), [], "nothing spawned");
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(sessionWs(f, S)), false, "refused before the workspace");
runner.kill("SIGKILL");
await gone;
assert.equal(await settle(() => events(f, "launch").length), 1, "it starts once the run has exited");
const saw = await toS.reply();
assert.deepEqual([saw.before, saw.cwd], [[], sessionWs(f, S)]);
await until(() => events(f, "end").length === 1);
});
// T2, C3: a symlinked session root refuses session-workspace-unsafe, at the segment check.
test("REQ-THREAD-2 REQ-LAUNCH-1 a symlinked session workspace root refuses session-workspace-unsafe and launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const elsewhere = privateDir(prepared.root, "elsewhere");
symlinkSync(elsewhere, join(f.dataRoot, "session-workspaces"));
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "session-workspace-unsafe"]]);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(elsewhere), []);
});
// T2, M4: every segment of the session path is a real directory, but workspaces/<b> is a symlink
// to it, so only the realpath compare finds that the session workspace holds the legacy root.
test("REQ-THREAD-2 REQ-LAUNCH-1 a session workspace that holds the legacy workspace root refuses session-workspace-overlap and launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
const ws = privateDir(f.dataRoot, "session-workspaces", "acme", "assistant", D);
symlinkSync(ws, join(privateDir(f.dataRoot, "workspaces"), "acme"));
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "session-workspace-overlap"]]);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(ws), []);
});
// T2: a symlink among the store's segments refuses thread-store-refused before the workspace.
test("REQ-THREAD-2 REQ-LAUNCH-1 a symlinked session store refuses thread-store-refused and makes no workspace", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
const elsewhere = privateDir(prepared.root, "elsewhere");
symlinkSync(elsewhere, join(privateDir(f.dataRoot, "threads", "acme", A), "sessions"));
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "thread-store-refused"]]);
assert.match(events(f, "refused")[0].why, /is unsafe$/);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(elsewhere), []);
assert.equal(existsSync(join(f.dataRoot, "session-workspaces")), false);
});
// T2, M6 and the halt fence (C2). The credentialStatus op runs in start() after check() returned.
// Its first call halts and resumes, a new generation with no halt, so only start()'s own
// admission(admissionGeneration) refuses, before the session workspace is made. On the retry,
// the recheck's mayLaunch op (inside check(), after the lock is taken) halts once it has answered,
// and the recheck's admission() refuses processing-halted. Neither attempt spawns, and the second releases its lock.
test("REQ-THREAD-2 REQ-LAUNCH-1 a named session's start refuses processing-generation-changed, then processing-halted, after check() and before any spawn", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let credentials = 0;
let halts = 0;
const halt = () => transition(f.dataRoot, { business: "acme", human: f.doc.human, halted: true });
const host = await start(t, prepared, {
ops: (runtime) => ({
credentialStatus: (m) => {
if (m.role === "assistant" && ++credentials === 1) {
transition(f.dataRoot, { business: "acme", human: f.doc.human, halted: false, generation: halt().generation });
halts++;
}
return runtime.credentialStatus(m.business, m.role);
},
// The halt lands after the op answers: halted inside it, the bus's own fence
// (runtime.mjs processingWork) throws and the launcher refuses limit-check-failed.
mayLaunch: async (m) => {
const may = await runtime.mayLaunch(m);
if (m.instance === "assistant" && credentials === 2 && halts === 1) {
halt();
halts++;
}
return may;
},
}),
});
const { A, S } = twoSessions(host.call);
send(host.call, A, S, "for S");
const refused = await outcome(f, 2);
assert.deepEqual(refused, [[S, "processing-generation-changed"], [S, "processing-halted"]]);
assert.deepEqual([credentials, halts], [2, 2], "check() passed both times");
assert.equal(events(f, "launch").length, 0);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme")), false, "no run directory");
assert.ok(existsSync(sessionWs(f, S)), "the retry made the workspace");
assert.equal(existsSync(`${sessionWs(f, S)}.lock`), false, "and released its lock");
});
+1 -1
View File
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','thread_opens','instance_names','usage_records'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','instance_names','usage_records'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
+8
View File
@@ -17,6 +17,12 @@ import { run } from "../src/cli.mjs";
import { CliError } from "../src/errors.mjs";
import { busExit } from "../src/transport.mjs";
import { humanEnv, io } from "./helpers.mjs";
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), "utf8"));
const roles = {
@@ -185,6 +191,7 @@ test("REQ-CLI-1 a thread is its id, an id prefix of 8 characters or more, or <pr
const at = new Date().toISOString();
for (const n of ["1", "2"]) {
s.store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", `abcdef01-0000-4000-8000-00000000000${n}`, at, "demo", s.project, "assistant", "jason", `twin${n}`);
for (const sql of DEFAULT_SESSION) s.store.run(sql, `abcdef01-0000-4000-8000-00000000000${n}`);
}
const resolve = async (ref) => (await s.cli(["thread", "open", ref]));
const opened = (r) => r.out.trim();
@@ -322,6 +329,7 @@ test("REQ-USER-2 a thread of another user is refused for talk, open, read and fo
const theirs = randomUUID();
s.store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-theirs", at, "demo", "theirs", "stack", "someone-else");
s.store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", theirs, at, "demo", "p-theirs", "assistant", "someone-else", "private");
for (const sql of DEFAULT_SESSION) s.store.run(sql, theirs);
// A message waiting for the user: message.receive would write a delivery row for it.
s.call("chief", "message.send", { to: "human", body: "waiting", class: "INFO" });
const before = s.writes();
+10 -2
View File
@@ -27,6 +27,12 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
@@ -173,10 +179,11 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's s
assert.deepEqual(first.saw, { body: "first for A", before: [], transcripts: [] });
const [run1] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A).map((e) => e.run);
assert.ok(run1);
const store = join(f.dataRoot, "threads", "acme", A);
const store = join(f.dataRoot, "threads", "acme", A, "sessions", host.runtime.threadsState("acme").find((x) => x.thread === A).session);
assert.equal(readFileSync(join(store, "session", "history"), "utf8"), "first for A\n");
assert.deepEqual(readdirSync(join(store, "claude-projects")), ["turn-1.jsonl"]);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme", run1, "session")), false, "a thread run keeps no per-run session directory");
assert.equal(existsSync(join(f.dataRoot, "threads", "acme", A, "session")), false, "a thread.create session keeps no thread-level store");
await idleStop(f, host, A, run1);
// A second run of the same thread finds the first run's history.
@@ -356,7 +363,8 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thre
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
for (const [id, instance] of [["../evil", "assistant"], ["coder-thread", "coder"]]) {
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", id, at(), "acme", "p-hand", instance, "jason", id);
store.run("INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)", at(), id, "jason");
for (const sql of DEFAULT_SESSION) store.run(sql, id);
store.run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)", at(), id, id, "jason");
}
store.close();
+239
View File
@@ -0,0 +1,239 @@
// Row 270 (264c, #1775, T1 item 7 D6): the count of completed compactions over
// one thread session's lifetime, across its runs and native branches.
//
// The host collects it from the session's native store and appends what it
// found to a ledger, <store dir>/compactions.jsonl (0600, append-only). The
// store dir holds the mounted session/ and claude-projects/ but is not mounted
// itself (sandbox.mjs binds()), so the agent can't reach the ledger. The
// launcher is its only writer, and collects while it holds the session's
// workspace lock (session.mjs lockSessionWorkspace); each collection is one
// O_APPEND write. The count is rebuilt from the ledger, never from a page.
//
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
// written by SessionManager.appendCompaction after the summary exists. A start
// or an aborted compaction writes none. A `compactionSummary` message, a
// message inside a compaction's retainedTail and a branch_summary are not
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
// file of the same directory with their id and timestamp unchanged, so a
// compaction's key is (entry id, entry timestamp) over the whole store, and the
// stream it was first seen in is recorded beside it.
//
// Claude Code has no pinned compaction format yet (Vikunja 466): a session
// any Claude run collected for is unknown with harness-unsupported, never 0.
//
// Ledger lines, each one JSON object with v 1:
// { kind: "compaction", entry, completedAt, stream, at } once per key
// { kind: "cursor", stream, bytes, tail, at } read through `bytes`; `tail`
// is the sha256 of the up to 4096 bytes that end there
// { kind: "collected", at, known, reason, harness, version }
// A cursor lets the next collection read only what was appended. A stream
// that shrank, changed under its cursor or went away is store-incomplete from
// then on: entries may be lost, so the count stays a lower bound for good.
import { createHash } from "node:crypto";
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, writeSync } from "node:fs";
import { join } from "node:path";
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export const PI_VERSION = "0.85.1";
export const LEDGER = "compactions.jsonl";
const TAIL = 4096;
const CHUNK = 1 << 20;
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
const keyOf = (entry, completedAt) => JSON.stringify([entry, completedAt]);
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
// The store directory session.mjs sessionStore() makes, as a path only: the
// reader never creates it. Null for an id that isn't one.
export function storeDir(dataRoot, business, thread, session, { legacy = false } = {}) {
if (![business, thread, session].every(segment)) return null;
const dir = join(dataRoot, "threads", business, thread);
return legacy ? dir : join(dir, "sessions", session);
}
function readRange(fd, from, to) {
const out = Buffer.alloc(to - from);
let off = 0;
while (off < out.length) {
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
if (n === 0) break;
off += n;
}
return out.subarray(0, off);
}
// One collection over a Pi session directory. `cursors` are the ledger's,
// by stream. Returns the compactions found past each cursor, the new
// cursors, and the problems: store-unreadable (a file that can't be read or
// isn't a Pi session) and store-incomplete (a stream that shrank, changed or
// went away, or a line that isn't JSON). A missing directory is empty.
export function scanPi(dir, cursors = {}) {
const found = [], next = {}, problems = new Set();
let names;
try {
names = readdirSync(dir);
} catch (e) {
if (e.code !== "ENOENT") problems.add("store-unreadable");
names = [];
}
for (const stream of Object.keys(cursors)) if (!names.includes(stream)) problems.add("store-incomplete");
for (const name of names.filter((n) => n.endsWith(".jsonl")).sort()) {
const path = join(dir, name);
let fd;
try {
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
} catch {
problems.add("store-unreadable");
continue;
}
try {
const size = fstatSync(fd).size;
const c = cursors[name];
let from = 0;
if (c) {
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
else problems.add("store-incomplete");
}
const buf = readRange(fd, from, size);
// Complete lines only: a partial trailing line waits for its newline.
const end = buf.lastIndexOf(0x0a) + 1;
const lines = buf.subarray(0, end).toString("utf8").split("\n");
lines.pop();
let header = from > 0;
for (const line of lines) {
if (!line.trim()) continue;
let e;
try {
e = JSON.parse(line);
} catch {
e = null;
}
if (!e || typeof e !== "object" || Array.isArray(e)) {
problems.add("store-incomplete");
continue;
}
if (!header) {
header = true;
if (e.type !== "session") problems.add("store-unreadable");
continue;
}
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
}
}
const bytes = from + end;
next[name] = { bytes, tail: digest(readRange(fd, Math.max(0, bytes - TAIL), bytes)) };
} catch {
problems.add("store-unreadable");
} finally {
closeSync(fd);
}
}
return { found, cursors: next, problems: [...problems].sort() };
}
// The ledger as recorded. Throws on a ledger that can't be read. `bad` is set
// by any line that isn't a v 1 record, a torn last line included.
export function readLedger(dir) {
const events = new Map(), cursors = {};
let last = null, incomplete = false, unsupported = false, bad = false, torn = false, text;
const file = join(dir, LEDGER);
try {
if (!lstatSync(file).isFile()) throw Object.assign(new Error(`${file} isn't a regular file`), { code: "ledger-unsafe" });
text = readFileSync(file, { encoding: "utf8", flag: fsc.O_RDONLY | fsc.O_NOFOLLOW });
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
const lines = text.split("\n");
if (lines.pop() !== "") bad = torn = true;
for (const line of lines) {
let r;
try {
r = JSON.parse(line);
} catch {
r = null;
}
if (!r || typeof r !== "object" || r.v !== 1) {
bad = true;
continue;
}
if (r.kind === "compaction") {
const key = keyOf(r.entry, r.completedAt);
if (!events.has(key)) events.set(key, r);
} else if (r.kind === "cursor") cursors[r.stream] = { bytes: r.bytes, tail: r.tail };
else if (r.kind === "collected") {
last = r;
if (r.reason === "store-incomplete") incomplete = true;
if (r.harness !== "pi") unsupported = true;
} else bad = true;
}
return { events, cursors, last, incomplete, unsupported, bad, torn };
}
// What session.list shows: { count, known, reason, at }. Known only when the
// ledger is whole, every collection ran on Pi and none lost entries. Any other
// answer is unknown with a reason and a lower bound, or null for none: never 0.
export function compactionCount(dir) {
const unknown = (reason, count = null, at = null) => ({ count: count || null, known: false, reason, at });
let ledger;
try {
ledger = readLedger(dir);
} catch {
return unknown("store-unreadable");
}
if (!ledger) return unknown("not-collected");
const count = ledger.events.size;
const at = ledger.last?.at ?? null;
if (ledger.bad) return unknown("store-incomplete", count, at);
if (!ledger.last) return unknown("not-collected", count);
if (ledger.unsupported) return unknown("harness-unsupported", count, at);
if (ledger.incomplete) return unknown("store-incomplete", count, at);
if (!ledger.last.known) return unknown(ledger.last.reason, count, at);
return { count, known: true, reason: null, at };
}
// One collection for a session's store ({ dir, session } from sessionStore),
// at run start (resume) and run end, under the session's workspace lock.
// Appends the new compactions, the moved cursors and one collected line in a
// single O_APPEND write, then answers the count. A key already in the ledger
// is never written again, and nothing rewrites the file.
export function collectCompactions(store, harness, { now = () => new Date().toISOString() } = {}) {
const ledger = readLedger(store.dir) ?? { events: new Map(), cursors: {}, torn: false };
const at = now();
const out = [];
if (harness === "pi") {
const scan = scanPi(store.session, ledger.cursors);
for (const f of scan.found) {
const key = keyOf(f.entry, f.completedAt);
if (ledger.events.has(key)) continue;
ledger.events.set(key, f);
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
}
for (const [stream, c] of Object.entries(scan.cursors)) {
const old = ledger.cursors[stream];
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
}
// store-incomplete outranks store-unreadable: lost entries don't come back.
const reason = scan.problems.includes("store-incomplete") ? "store-incomplete" : scan.problems[0] ?? null;
out.push({ v: 1, kind: "collected", at, known: reason === null, reason, harness, version: PI_VERSION });
} else {
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: null });
}
const fd = openSync(join(store.dir, LEDGER), fsc.O_WRONLY | fsc.O_APPEND | fsc.O_CREAT | fsc.O_NOFOLLOW, 0o600);
try {
if (!fstatSync(fd).isFile()) throw Object.assign(new Error(`${join(store.dir, LEDGER)} isn't a regular file`), { code: "ledger-unsafe" });
// After a torn line, start on a fresh one: the torn line stays bad and
// keeps the count unknown, and the new lines stay whole.
const buf = Buffer.from((ledger.torn ? "\n" : "") + out.map((r) => JSON.stringify(r)).join("\n") + "\n");
let off = 0;
while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
} finally {
closeSync(fd);
}
return compactionCount(store.dir);
}
+7 -4
View File
@@ -97,9 +97,12 @@ function systemBinds(node = process.execPath) {
// the harness module's mounts (row 65: its mounts(), [{ src, dest, why }],
// read-only), the broker socket, the launch socket (null for a role that may
// not launch), the git socket (null for a role that neither pushes nor
// reviews), the thread's session store (session.mjs threadStore(), or null)
// and node. `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
// reviews), the thread's session store (session.mjs threadStore() or
// sessionStore(), or null) and node. workspaceDest (row 269) is where the
// workspace shows inside the sandbox when that isn't its host path: a legacy
// thread session's, so its harness finds the cwd its history recorded.
// `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, workspaceDest = null, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
const out = systemBinds(node);
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
@@ -109,7 +112,7 @@ export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSo
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review, and land_work" });
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
out.push(
{ kind: "rw", src: workspace, why: "the instance's workspace, where the harness works" },
{ kind: "rw", src: workspace, ...(workspaceDest && workspaceDest !== workspace ? { dest: workspaceDest } : {}), why: "the instance's workspace, where the harness works" },
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
{ kind: "ro", src: join(runDir, "bundle"), why: "the run's bundle stays as the host wrote it" },
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
+176 -17
View File
@@ -20,17 +20,22 @@
// every launch, refusal and end
// workspaces/<business>/<instance>/ the instance's workspace (0700), kept
// across launches
// threads/<business>/<thread>/ a thread's session store (0700), kept
// across the thread's runs (row 121):
// threads/<business>/<thread>/ a legacy thread session's store (0700),
// kept across its runs (row 121):
// session/ and claude-projects/
// threads/<business>/<thread>/sessions/<session>/
// any other session's store (row 269)
// session-workspaces/<business>/<instance>/<session>/
// a thread session's workspace (0700),
// with <session>.lock beside it
// bus-host/sessions.json the running sessions (0600), written
// only by the host; `mosaic stop` reads it
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { appendFileSync, closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { constants } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, sep } from "node:path";
import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs";
import { binds, prepareRun, REPO, sandboxArgv } from "./sandbox.mjs";
import { SeatError } from "./seat.mjs";
@@ -38,7 +43,10 @@ import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export { REPO };
export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
export const SESSIONS_VERSION = 1;
// Row 269 (264b): 2 adds `session` and `workspace` to a thread run's entry.
// readSessions still reads a version 1 file an earlier host left.
export const SESSIONS_VERSION = 2;
const SESSIONS_READ = [1, 2];
export const sessionsFile = (dataRoot) => join(dataRoot, "bus-host", "sessions.json");
export const launchSocketPath = (dataRoot) => join(dataRoot, "bus-host", "launch.sock");
@@ -47,6 +55,7 @@ export const gitSocketPath = (dataRoot) => join(dataRoot, "bus-host", "git.sock"
export const runsDir = (dataRoot, business) => join(dataRoot, "launches", business);
export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
export const sessionWorkspaceDir = (dataRoot, business, instance, session) => join(dataRoot, "session-workspaces", business, instance, session);
// Row 121 (#1615): a thread's session store, which outlives each of the
// thread's runs so the next run resumes the harness session (REQ-THREAD-2).
@@ -56,24 +65,174 @@ export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "wo
// is the run's sessionDir; claude-projects/ is bound at the run's
// home/.claude/projects (sandbox.mjs binds()). Throws SeatError.
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
export function threadStore(dataRoot, business, thread) {
for (const [what, x] of [["business", business], ["thread", thread]]) {
if (!segment(x)) throw new SeatError(`thread store: ${what} ${JSON.stringify(x)} isn't an id`, 2);
const seatError = (message, code) => Object.assign(new SeatError(message, 2), { code });
function segments(what, pairs, code) {
for (const [name, x] of pairs) {
if (!segment(x)) throw seatError(`${what}: ${name} ${JSON.stringify(x)} isn't an id`, code);
}
const dir = join(dataRoot, "threads", business, thread);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
for (const d of [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects]) {
}
// Makes each directory in order, then checks it is ours, a real directory and private.
function privateDirs(what, dirs, code) {
for (const d of dirs) {
try {
mkdirSync(d, { mode: 0o700 });
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const st = lstatSync(d);
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw new SeatError(`thread store: ${d} is unsafe`, 2);
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw seatError(`${what}: ${d} is unsafe`, code);
}
}
export function threadStore(dataRoot, business, thread) {
segments("thread store", [["business", business], ["thread", thread]], "thread-store-refused");
const dir = join(dataRoot, "threads", business, thread);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects], "thread-store-refused");
return out;
}
// Row 269 (264b, #1774): one session's store. A legacy session (op legacy-3l,
// whose id is its thread's) keeps the thread's store, so its harness resumes
// the history it has; any other session gets its own under sessions/, with
// the same checks on every segment and directory. Same shape as threadStore.
// legacyThread: the thread has a legacy session. When it has none, a
// threads/<b>/<t>/session left by a 264a run of the thread's default session
// refuses legacy-store-unmapped, so that history isn't silently left behind.
export function sessionStore(dataRoot, business, thread, session, { legacy = false, legacyThread = legacy } = {}) {
if (legacy) return threadStore(dataRoot, business, thread);
segments("thread store", [["business", business], ["thread", thread], ["session", session]], "thread-store-refused");
const threadDir = join(dataRoot, "threads", business, thread);
if (!legacyThread) {
let old = true;
try {
lstatSync(join(threadDir, "session"));
} catch (e) {
if (e.code !== "ENOENT") throw e;
old = false;
}
if (old) throw seatError(`thread store: ${join(threadDir, "session")} holds history no legacy session maps`, "legacy-store-unmapped");
}
const dir = join(threadDir, "sessions", session);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), threadDir, join(threadDir, "sessions"), dir, out.session, out.projects], "thread-store-refused");
return out;
}
const inside = (a, b) => a === b || a.startsWith(b + sep);
const realOrNull = (p) => {
try {
return realpathSync(p);
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
};
// Row 269: a thread session's workspace, session-workspaces/<b>/<i>/<s>. Each
// segment is an id and each directory ours, real and private
// (session-workspace-unsafe). Then the realpaths: it may not be inside, or
// contain, the legacy workspaces root, its business and instance directories
// or any entry of either (old instance, .retired and task workspaces), nor
// sit above or below a `live` workspace (session-workspace-overlap). The same
// path as a live one is left to the lock, which refuses session-workspace-busy.
export function sessionWorkspace(dataRoot, business, instance, session, { live = [] } = {}) {
segments("session workspace", [["business", business], ["instance", instance], ["session", session]], "session-workspace-unsafe");
const root = join(dataRoot, "session-workspaces");
const path = join(root, business, instance, session);
privateDirs("session workspace", [root, join(root, business), join(root, business, instance), path], "session-workspace-unsafe");
const real = realpathSync(path);
const legacy = join(dataRoot, "workspaces");
const named = [legacy, join(legacy, business), join(legacy, business, instance)];
const entries = (d) => {
try {
return readdirSync(d).map((x) => join(d, x));
} catch (e) {
if (e.code === "ENOENT" || e.code === "ENOTDIR") return [];
throw e;
}
};
for (const p of [...named, ...entries(legacy), ...entries(join(legacy, business))]) {
const other = realOrNull(p);
if (other && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the legacy workspace ${p}`, "session-workspace-overlap");
}
for (const p of live) {
const other = realOrNull(p);
if (other && other !== real && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the live workspace ${p}`, "session-workspace-overlap");
}
return path;
}
// Row 269: the single-writer lock on a session workspace, <workspace>.lock,
// beside it and outside every mount. It names the host (pid, start time) and,
// once spawned, the run's outer process. A lock whose host and run are both
// gone is stale and is taken over; any other refuses session-workspace-busy.
// Returns { file, update(fields), release() }.
const lockAlive = (pid, start) => Number.isSafeInteger(pid) && pid > 0 && start != null && startTimeOf(pid) === start;
export function lockSessionWorkspace(workspace, owner) {
const file = `${workspace}.lock`;
let held = { ...owner };
const write = (flag) => writeFileSync(file, `${JSON.stringify(held)}\n`, { mode: 0o600, flag });
const busy = (why) => seatError(`session workspace ${workspace} is busy: ${why}`, "session-workspace-busy");
for (let attempt = 0; attempt < 2; attempt++) {
try {
write("wx");
// One release per lock: a second unlink could take a lock another host has written since.
let released = false;
return {
file,
update(fields) {
if (released) return;
held = { ...held, ...fields };
write("w");
},
release() {
if (released) return;
released = true;
try {
unlinkSync(file);
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
},
};
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
let raw;
try {
raw = readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") continue;
throw e;
}
let other = null;
try {
other = JSON.parse(raw);
} catch {}
// An unreadable lock may be one being written, so it counts as held.
if (!other || typeof other !== "object") throw busy(`${file} is unreadable`);
if (lockAlive(other.hostPid, other.hostStartTime)) throw busy(`host pid ${other.hostPid} holds it`);
if (lockAlive(other.runPid, other.runStartTime)) throw busy(`run pid ${other.runPid} holds it`);
// Stale. Move it aside; if what moved isn't what was judged stale, another
// host took it over in between, so it goes back and this start refuses.
const aside = `${file}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
try {
renameSync(file, aside);
} catch (e) {
if (e.code === "ENOENT") continue;
throw e;
}
if (readFileSync(aside, "utf8") !== raw) {
try {
linkSync(aside, file);
} catch {}
unlinkSync(aside);
throw busy(`another host took ${file} over`);
}
unlinkSync(aside);
}
throw busy(`${file} could not be taken`);
}
// The session's environment is an allowlist: nothing else from the host's
// environment passes, so a founder credential variable (GITEA_TOKEN,
// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
@@ -212,10 +371,10 @@ export class Registry {
count(business, provider, model = undefined) {
return this.#sessions.filter((s) => s.business === business && s.provider === provider && (model === undefined || s.model === model)).length;
}
// A thread instance runs once per thread (row 121), so `thread` is part of
// the key; null is a session that holds no thread.
running(business, instance, thread = null) {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
// A thread instance runs once per thread session (row 121, row 269), so
// `thread` and `session` are part of the key; null is an entry without one.
running(business, instance, thread = null, session = null) {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread && (s.session ?? null) === session) ?? null;
}
#write() {
if (this.#readOnly) return;
@@ -238,7 +397,7 @@ export function readSessions(dataRoot) {
} catch {
throw new SeatError(`sessions file is unreadable: ${file}`, 2);
}
if (doc?.sessionsVersion !== SESSIONS_VERSION || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
if (!SESSIONS_READ.includes(doc?.sessionsVersion) || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
return doc.sessions.map((s) => ({ ...s, live: startTimeOf(s.runnerPid) === s.runnerStartTime }));
}
+230
View File
@@ -0,0 +1,230 @@
// Row 270 (264c, #1775, T1 item 7 D6, T3's Pi half): the compaction count
// from a session's native Pi store, through the host ledger
// (src/compactions.mjs). The streams are Pi 0.85.1's shapes, written by hand:
// a session header, then entries, one JSON object per line.
import { test } from "node:test";
import assert from "node:assert/strict";
import { appendFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, isAbsolute } from "node:path";
import { collectCompactions, compactionCount, LEDGER, readLedger, storeDir } from "../src/compactions.mjs";
import { binds } from "../src/sandbox.mjs";
import { sessionStore } from "../src/session.mjs";
function setup(t, { legacy = false } = {}) {
const root = mkdtempSync(join(tmpdir(), "seat-compactions-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const store = sessionStore(root, "acme", "th1", legacy ? "th1" : "s1", { legacy });
let tick = 0;
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
const collect = (harness = "pi") => collectCompactions(store, harness, { now });
const ledger = () => readFileSync(join(store.dir, LEDGER), "utf8");
const records = () => ledger().trim().split("\n").map((l) => JSON.parse(l));
return { root, store, collect, ledger, records };
}
const header = (id) => ({ type: "session", version: 3, id, timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" });
const message = (id, role = "user") => ({ type: "message", id, parentId: null, timestamp: "2026-10-22T11:00:01.000Z", message: { role, content: "x" } });
const compaction = (id, timestamp) => ({ type: "compaction", id, parentId: null, timestamp, summary: "s", firstKeptEntryId: "k", tokensBefore: 100 });
const line = (e) => `${JSON.stringify(e)}\n`;
const stream = (store, name, entries) => writeFileSync(join(store.session, name), entries.map(line).join(""));
const append = (store, name, ...entries) => appendFileSync(join(store.session, name), entries.map(line).join(""));
test("an empty store, a store with no compaction and a failed start count 0, known", (t) => {
const f = setup(t);
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:00.000Z" });
// A started and aborted compaction writes no entry; a compactionSummary message, a
// branch_summary and a message are not compactions.
stream(f.store, "a.jsonl", [header("a"), message("m1"), message("m2", "compactionSummary"), { type: "branch_summary", id: "b1", parentId: null, timestamp: "2026-10-22T11:00:02.000Z", summary: "b" }]);
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
});
test("one and three completed compactions", (t) => {
const f = setup(t);
stream(f.store, "a.jsonl", [header("a"), message("m1"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
assert.equal(f.collect().count, 1);
append(f.store, "a.jsonl", message("m2"), compaction("c2", "2026-10-22T11:02:00.000Z"), compaction("c3", "2026-10-22T11:03:00.000Z"));
assert.deepEqual(f.collect(), { count: 3, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
assert.equal(compactionCount(f.store.dir).count, 3);
});
// sage-2814: Pi's createBranchedSession and forkFrom copy entries into a new stream with
// their id and timestamp, so a copied compaction counts once.
test("a compaction copied into a branched stream counts once, under the stream it was first seen in", (t) => {
const f = setup(t);
const c1 = compaction("c1", "2026-10-22T11:01:00.000Z");
stream(f.store, "a.jsonl", [header("a"), message("m1"), c1]);
assert.equal(f.collect().count, 1);
stream(f.store, "b.jsonl", [header("b"), message("m1"), c1, message("m3")]);
assert.deepEqual(f.collect(), { count: 1, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
append(f.store, "b.jsonl", compaction("c2", "2026-10-22T11:05:00.000Z"));
assert.equal(f.collect().count, 2);
const lines = f.records().filter((r) => r.kind === "compaction");
assert.deepEqual(lines.map((r) => [r.entry, r.stream]), [["c1", "a.jsonl"], ["c2", "b.jsonl"]]);
});
test("one collection over two streams holding the same copy counts it once and writes it once", (t) => {
const f = setup(t);
const c1 = compaction("c1", "2026-10-22T11:01:00.000Z");
stream(f.store, "a.jsonl", [header("a"), c1]);
stream(f.store, "b.jsonl", [header("b"), c1]);
assert.equal(f.collect().count, 1);
assert.equal(f.records().filter((r) => r.kind === "compaction").length, 1);
});
// Pi's ids are unique only within a file: the same id at another time is another compaction.
test("the same entry id at another time in another stream is another compaction", (t) => {
const f = setup(t);
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
stream(f.store, "b.jsonl", [header("b"), compaction("c1", "2026-10-22T11:09:00.000Z")]);
assert.equal(f.collect().count, 2);
});
test("resume: each collection reads past its cursor and writes a key once", (t) => {
const f = setup(t);
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
f.collect();
f.collect();
append(f.store, "a.jsonl", compaction("c2", "2026-10-22T11:02:00.000Z"));
assert.equal(f.collect().count, 2);
const r = f.records();
assert.deepEqual(r.filter((x) => x.kind === "compaction").map((x) => x.entry), ["c1", "c2"]);
assert.deepEqual(r.filter((x) => x.kind === "cursor").map((x) => x.bytes), [statSync(join(f.store.session, "a.jsonl")).size - line(compaction("c2", "2026-10-22T11:02:00.000Z")).length, statSync(join(f.store.session, "a.jsonl")).size]);
assert.equal(r.filter((x) => x.kind === "collected").length, 3);
assert.equal(lstatSync(join(f.store.dir, LEDGER)).mode & 0o777, 0o600);
});
test("a partial trailing line waits for its newline", (t) => {
const f = setup(t);
stream(f.store, "a.jsonl", [header("a")]);
const whole = line(compaction("c1", "2026-10-22T11:01:00.000Z"));
appendFileSync(join(f.store.session, "a.jsonl"), whole.slice(0, 30));
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:00.000Z" });
appendFileSync(join(f.store.session, "a.jsonl"), whole.slice(30));
assert.deepEqual(f.collect(), { count: 1, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
});
test("a missing ledger is not-collected and an unreadable one store-unreadable, never 0", (t) => {
const f = setup(t);
assert.deepEqual(compactionCount(f.store.dir), { count: null, known: false, reason: "not-collected", at: null });
mkdirSync(join(f.store.dir, LEDGER));
assert.deepEqual(compactionCount(f.store.dir), { count: null, known: false, reason: "store-unreadable", at: null });
assert.throws(() => f.collect());
});
test("a symlinked ledger is store-unreadable and is not written through", (t) => {
const f = setup(t);
const target = join(f.root, "elsewhere.jsonl");
writeFileSync(target, "");
symlinkSync(target, join(f.store.dir, LEDGER));
assert.equal(compactionCount(f.store.dir).reason, "store-unreadable");
assert.throws(() => f.collect());
assert.equal(readFileSync(target, "utf8"), "");
});
// sage-2806 condition 3: a torn last line is a bad line, not a skipped one.
test("a torn last ledger line makes the count unknown, a lower bound, and stays so", (t) => {
const f = setup(t);
assert.equal(f.collect().count, 0);
appendFileSync(join(f.store.dir, LEDGER), '{"v":1,"kind":"compac');
assert.deepEqual(compactionCount(f.store.dir), { count: null, known: false, reason: "store-incomplete", at: "2026-10-22T12:00:00.000Z" });
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
assert.deepEqual(f.collect(), { count: 1, known: false, reason: "store-incomplete", at: "2026-10-22T12:00:01.000Z" });
// The next write started on a fresh line: every line after the torn one is whole.
const lines = f.ledger().split("\n");
assert.equal(lines.pop(), "");
const bad = lines.filter((l) => {
try {
JSON.parse(l);
return false;
} catch {
return true;
}
});
assert.deepEqual(bad, ['{"v":1,"kind":"compac']);
assert.equal(readLedger(f.store.dir).events.size, 1);
});
test("a bad ledger line in the middle makes the count unknown", (t) => {
const f = setup(t);
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
f.collect();
appendFileSync(join(f.store.dir, LEDGER), "not json\n");
append(f.store, "a.jsonl", compaction("c2", "2026-10-22T11:02:00.000Z"));
assert.deepEqual(f.collect(), { count: 2, known: false, reason: "store-incomplete", at: "2026-10-22T12:00:01.000Z" });
});
test("a stream that shrank, changed under its cursor or went away is store-incomplete for good", async (t) => {
for (const change of ["shrink", "rewrite", "remove"]) {
await t.test(change, (t) => {
const f = setup(t);
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
assert.equal(f.collect().known, true);
const path = join(f.store.session, "a.jsonl");
if (change === "shrink") stream(f.store, "a.jsonl", [header("a")]);
// Same length, other bytes: only the cursor's tail digest sees it.
if (change === "rewrite") stream(f.store, "a.jsonl", [header("a"), compaction("c9", "2026-10-22T11:01:00.000Z")]);
if (change === "remove") unlinkSync(path);
const c = f.collect();
assert.equal(c.known, false);
assert.equal(c.reason, "store-incomplete");
assert.equal(c.count, change === "rewrite" ? 2 : 1);
// A later clean collection doesn't make it known again.
assert.equal(f.collect().reason, "store-incomplete");
});
}
});
test("a stream that isn't a regular file or a Pi session is store-unreadable, and that passes", (t) => {
const f = setup(t);
const target = join(f.root, "outside.jsonl");
writeFileSync(target, line(header("x")) + line(compaction("c1", "2026-10-22T11:01:00.000Z")));
symlinkSync(target, join(f.store.session, "link.jsonl"));
assert.deepEqual(f.collect(), { count: null, known: false, reason: "store-unreadable", at: "2026-10-22T12:00:00.000Z" });
unlinkSync(join(f.store.session, "link.jsonl"));
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
});
test("a first line that isn't a session header is store-unreadable", (t) => {
const f = setup(t);
stream(f.store, "a.jsonl", [compaction("c1", "2026-10-22T11:01:00.000Z")]);
assert.equal(f.collect().reason, "store-unreadable");
});
// RULINGS Q6: Claude Code is unknown with the Vikunja 466 reason, never 0, and a session any
// Claude run collected for stays unknown.
test("a Claude run is harness-unsupported, and stays so after a Pi run", (t) => {
const f = setup(t);
assert.deepEqual(f.collect("claude"), { count: null, known: false, reason: "harness-unsupported", at: "2026-10-22T12:00:00.000Z" });
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
assert.deepEqual(f.collect("pi"), { count: 1, known: false, reason: "harness-unsupported", at: "2026-10-22T12:00:01.000Z" });
});
test("storeDir is the store sessionStore makes, without making it", (t) => {
const f = setup(t);
assert.equal(storeDir(f.root, "acme", "th1", "s1"), f.store.dir);
assert.equal(storeDir(f.root, "acme", "th1", "th1", { legacy: true }), join(f.root, "threads", "acme", "th1"));
assert.equal(storeDir(f.root, "acme", "th1", "s2"), join(f.store.dir, "..", "s2"));
assert.throws(() => lstatSync(join(f.store.dir, "..", "s2")), { code: "ENOENT" });
for (const bad of ["..", "a/b", "", "A", null]) assert.equal(storeDir(f.root, "acme", "th1", bad), null);
assert.equal(storeDir(f.root, "../x", "th1", "s1"), null);
});
// sage-2806 condition 4: the agent can't reach the ledger. It sits in the store dir, which no
// bind mounts; only the store's session/ and claude-projects/ are.
test("the ledger is under no sandbox mount", (t) => {
for (const legacy of [false, true]) {
const f = setup(t, { legacy });
f.collect();
const file = join(f.store.dir, LEDGER);
const runDir = join(f.root, "runs", "r1");
const list = binds({ runDir, workspace: join(f.root, "ws"), adapter: join(f.root, "a.sh"), brokerSocket: join(f.root, "bus.sock"), threadStore: f.store });
assert.ok(list.some((b) => b.src === f.store.session));
for (const b of list) {
if (!b.src || !isAbsolute(b.src)) continue;
const rel = relative(b.src, file);
assert.ok(rel.startsWith("..") || isAbsolute(rel), `${b.src} mounts ${file}`);
}
}
});
@@ -0,0 +1,126 @@
// Row 269 (264b, #1774): a thread session's store, its workspace and the
// single-writer lock beside the workspace (session.mjs sessionStore(),
// sessionWorkspace(), lockSessionWorkspace()), and sessions.json v2.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { startTimeOf } from "../src/proc.mjs";
import { SeatError } from "../src/seat.mjs";
import { lockSessionWorkspace, readSessions, Registry, sessionsFile, sessionStore, sessionWorkspace, threadStore } from "../src/session.mjs";
function scratch(t) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-session-ws-")));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
const mode = (p) => statSync(p).mode & 0o777;
const refuses = (code) => (e) => e instanceof SeatError && e.code === code;
test("REQ-THREAD-2 sessionStore: a legacy session keeps the thread's store, any other gets sessions/<s>/, each segment checked first", (t) => {
const root = scratch(t);
assert.deepEqual(sessionStore(root, "acme", "th-1", "th-1", { legacy: true }), threadStore(root, "acme", "th-1"));
const s = sessionStore(root, "acme", "th-1", "s-2", { legacyThread: true });
const dir = join(root, "threads", "acme", "th-1", "sessions", "s-2");
assert.deepEqual(s, { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") });
for (const d of [dirname(dir), dir, s.session, s.projects]) assert.equal(mode(d), 0o700, d);
for (const [thread, session] of [["..", "s"], ["th-1", ".."], ["th-1", "a/b"], ["th-1", null]])
assert.throws(() => sessionStore(root, "acme", thread, session, { legacyThread: true }), refuses("thread-store-refused"), `${thread}/${session}`);
assert.deepEqual(readdirSync(join(root, "threads", "acme", "th-1", "sessions")), ["s-2"], "a refused call makes nothing");
});
test("REQ-THREAD-2 sessionStore refuses legacy-store-unmapped for a thread whose old store no legacy session maps", (t) => {
const root = scratch(t);
threadStore(root, "acme", "th-1");
assert.throws(() => sessionStore(root, "acme", "th-1", "s-1"), (e) => refuses("legacy-store-unmapped")(e) && /holds history no legacy session maps$/.test(e.message));
assert.equal(existsSync(join(root, "threads", "acme", "th-1", "sessions")), false);
// A thread with a legacy session: its other sessions take D2's layout.
assert.ok(sessionStore(root, "acme", "th-1", "s-1", { legacyThread: true }).dir.endsWith(join("sessions", "s-1")));
const elsewhere = join(root, "elsewhere");
mkdirSync(elsewhere, { mode: 0o700 });
symlinkSync(elsewhere, join(root, "threads", "acme", "th-2"));
assert.throws(() => sessionStore(root, "acme", "th-2", "s-1"), (e) => refuses("thread-store-refused")(e) && /is unsafe$/.test(e.message));
assert.deepEqual(readdirSync(elsewhere), []);
});
test("REQ-THREAD-2 sessionWorkspace: private directories per session, unsafe segments and overlaps refused", (t) => {
const root = scratch(t);
const ws = sessionWorkspace(root, "acme", "assistant", "s-1");
assert.equal(ws, join(root, "session-workspaces", "acme", "assistant", "s-1"));
for (const d of [join(root, "session-workspaces"), dirname(dirname(ws)), dirname(ws), ws]) assert.equal(mode(d), 0o700, d);
assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1"), ws, "a second call reuses it");
assert.notEqual(sessionWorkspace(root, "acme", "assistant", "s-2"), ws);
for (const session of ["..", "a/b", ""]) assert.throws(() => sessionWorkspace(root, "acme", "assistant", session), refuses("session-workspace-unsafe"), session);
// A live workspace at the same path is the lock's to refuse; one above or below it overlaps.
assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1", { live: [ws] }), ws);
assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-3", { live: [dirname(ws)] }), refuses("session-workspace-overlap"));
// The legacy root symlinked into a session path: every segment is real, only the realpath finds it.
mkdirSync(join(root, "workspaces"), { mode: 0o700 });
symlinkSync(ws, join(root, "workspaces", "acme"));
assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-1"), refuses("session-workspace-overlap"));
// A symlinked root fails the segment check first.
const other = scratch(t);
const elsewhere = join(other, "elsewhere");
mkdirSync(elsewhere, { mode: 0o700 });
symlinkSync(elsewhere, join(other, "session-workspaces"));
assert.throws(() => sessionWorkspace(other, "acme", "assistant", "s-1"), refuses("session-workspace-unsafe"));
assert.deepEqual(readdirSync(elsewhere), []);
});
test("REQ-THREAD-2 lockSessionWorkspace: one writer; a live host or run holds it, a stale lock is taken over, an unreadable one holds", async (t) => {
const root = scratch(t);
const ws = sessionWorkspace(root, "acme", "assistant", "s-1");
const me = { hostPid: process.pid, hostStartTime: startTimeOf(process.pid) };
const lock = lockSessionWorkspace(ws, me);
assert.equal(lock.file, `${ws}.lock`);
assert.equal(mode(lock.file), 0o600);
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
lock.update({ run: "r-1", runPid: 1, runStartTime: "x" });
assert.deepEqual(JSON.parse(readFileSync(lock.file, "utf8")), { ...me, run: "r-1", runPid: 1, runStartTime: "x" });
lock.release();
assert.equal(existsSync(lock.file), false);
// A run that outlives its host still holds it.
const run = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => run.once("exit", r));
t.after(() => run.kill("SIGKILL"));
let runStart = null;
for (let i = 0; i < 100 && !runStart; i++) {
runStart = startTimeOf(run.pid);
if (!runStart) await new Promise((r) => setTimeout(r, 20));
}
const held = `${JSON.stringify({ hostPid: 2 ** 22 + 7, hostStartTime: "gone", runPid: run.pid, runStartTime: runStart })}\n`;
writeFileSync(`${ws}.lock`, held, { mode: 0o600 });
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
assert.equal(readFileSync(`${ws}.lock`, "utf8"), held);
run.kill("SIGKILL");
await gone;
// Now stale: taken over, and nothing is left beside it.
const taken = lockSessionWorkspace(ws, me);
assert.deepEqual(JSON.parse(readFileSync(taken.file, "utf8")), me);
assert.deepEqual(readdirSync(dirname(ws)).sort(), ["s-1", "s-1.lock"]);
// One release per lock: a second doesn't remove a lock written since.
taken.release();
writeFileSync(`${ws}.lock`, "{}\n", { mode: 0o600 });
taken.release();
assert.equal(readFileSync(`${ws}.lock`, "utf8"), "{}\n");
// An unreadable lock may be one being written, so it holds.
writeFileSync(`${ws}.lock`, "{", { mode: 0o600 });
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
});
test("REQ-THREAD-2 sessions.json v2: entries carry session and workspace, running() keys on the session, a v1 file still reads", (t) => {
const root = scratch(t);
const registry = new Registry(root);
const entry = { business: "acme", instance: "assistant", thread: "th-1", session: "s-1", workspace: "/w", run: "r-1" };
registry.add(entry);
assert.equal(JSON.parse(readFileSync(sessionsFile(root), "utf8")).sessionsVersion, 2);
assert.deepEqual(registry.running("acme", "assistant", "th-1", "s-1"), entry);
assert.equal(registry.running("acme", "assistant", "th-1", "s-2"), null);
assert.equal(registry.running("acme", "assistant", "th-1"), null, "an entry with a session isn't the thread's sessionless run");
writeFileSync(sessionsFile(root), `${JSON.stringify({ sessionsVersion: 1, sessions: [{ business: "acme", instance: "assistant", thread: "th-1", run: "r-0" }] })}\n`);
assert.deepEqual(readSessions(root).map((s) => [s.run, s.session ?? null]), [["r-0", null]]);
});
+9 -1
View File
@@ -5,6 +5,12 @@ import { createChat } from '../src/chat.mjs';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
const P = '10000000-0000-4000-8000-000000000001';
const T = '20000000-0000-4000-8000-000000000002';
@@ -220,15 +226,17 @@ test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated histor
const other = '40000000-0000-4000-8000-000000000004';
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
other, new Date().toISOString(), 'demo', project, 'assistant', 'someone-else', 'PRIVATE-NAME');
for (const sql of DEFAULT_SESSION) store.run(sql, other);
store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
M, new Date().toISOString(), 'demo', 'human', 'human-cli', 'assistant', 'INFO', 'PRIVATE-BODY');
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', M, other);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', M, other, other);
const foreignProject = '50000000-0000-4000-8000-000000000005';
const foreignThread = '60000000-0000-4000-8000-000000000006';
store.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)',
foreignProject, new Date().toISOString(), 'foreign', 'FOREIGN-PROJECT', null, 'jason');
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
foreignThread, new Date().toISOString(), 'foreign', foreignProject, 'assistant', 'jason', 'FOREIGN-THREAD');
for (const sql of DEFAULT_SESSION) store.run(sql, foreignThread);
assert.equal((await get('/api/chat/threads')).data.rows.length, 2);
const before = ['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n);
for (const id of [other, T, foreignThread]) for (const suffix of ['messages', 'open']) {
+1 -1
View File
@@ -417,7 +417,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads thread_opens instance_names usage_records"
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens instance_names usage_records"
STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");