Compare commits

...
Author SHA1 Message Date
jason.woltje 23c3d62183 queue: revs 3130-3133 (Sage): row 277 #1782 owner thread claims added and briefed; row 278 #1783 Console thread claims added and briefed 2026-10-22 00:17:54 -05:00
jason.woltje 70e289ba73 sage: #1782 design D1 ruling (sage-2487) and the Console thread-claims follow-up brief 2026-10-22 00:14:35 -05:00
jason.woltje ab50de9fe3 queue: revs 3114-3129: rows 191, 205, 268 landed and done (Sage 3116/3117, 3119/3120, 3123/3124, 3126-3129); Rocko 191 in-review 3114/3115; Dewey 191 verdict 3118; Morgana 205 in-review 3121/3122; Duckworth 205 verdict 3125 2026-10-22 00:12:00 -05:00
jason.woltje b3665f5ab1 sage: docs for landings 191, 205, 268; #1782 owner thread-claims brief (mos-1262); re-pin 86f95bb27 plan; phase 2 window draft 3; seat SESSIONS and BUILD-LOG lines 2026-10-22 00:10:08 -05:00
jason.woltje c27207f6e6 row 268 (C2-01c, #1773, Vikunja 464): playgrounds at three levels; classify checks prefixes first
checkMapping takes kind absent, hybrid or draft. A draft has a name and one
or more prefixes and no books, and comes back with books []. A prefix inside
or holding another entry's book refuses library-playground-other-universe,
which replaces library-playground-outside-book. A prefix equal to a book or
holding its own refuses library-mapping-invalid, as does a draft source.
classify returns a playground file's entry and the book holding its prefix,
or null at universe level and in a draft.
2026-10-22 00:04:42 -05:00
jason.woltje ce6a96ad61 morgana: row 205 r2 (sage-2430): put() events carry their decision so the decision trail reaches them; pin checks the thread claim via threadsState, owner read is a follow-up row 2026-10-21 23:49:34 -05:00
jason.woltje 59f708ca21 morgana: row 205a build (sage-2288): trail and replies hide other users' thread messages from owner, reader and tester; hidden answers as unknown; #readable owner-only; design d2 rulings 2026-10-21 23:49:34 -05:00
jason.woltje f751505ada morgana: row 205 design d1 (sage-2280): FED R3 unfiled, so split 205a message filter now and 205b viewer verbs after R3; mutant 3 to 193b2; hidden trail subject returns [] 2026-10-21 23:49:34 -05:00
rocko 0fa7bf421f fix: place processing controls within the Console navigation box 2026-10-21 22:42:13 -05:00
rocko 93d211c295 fix: allocate Console viewport space to processing controls 2026-10-21 22:42:13 -05:00
rocko 08c896e3c5 docs: record processing gate failures and timer fixture correction 2026-10-21 22:42:12 -05:00
rocko 03ed2bccce test: attribute processing timer to its owning script 2026-10-21 22:42:12 -05:00
rocko ad6020754d fix: stop processing controls on Console sign-out and preserve layout 2026-10-21 22:42:12 -05:00
rocko 5c8f983dde test: initialize processing control in Discord CLI fixtures 2026-10-21 22:42:12 -05:00
rocko f1267e49af fix: account for processing controls in image inputs and launch fixtures 2026-10-21 22:42:12 -05:00
rocko d2be97c5fc test: use private tester socket for processing controls 2026-10-21 22:42:12 -05:00
rocko c13c68c813 test: strengthen processing cancellation and refusal guards
Bound the halt-abort expectation, check Discord refusal before malformed journal reads, and isolate HTTP origin and valid oversized-body guards. These test changes are committed for the row 191 gate; execution awaits the coordinated slot.
2026-10-21 22:42:12 -05:00
rocko 4bdc7e0e86 processing: inspectable per-run stop seam for lifecycle fire
Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
2026-10-21 22:42:12 -05:00
jason.woltje 86f95bb27f queue: revs 3109-3113 (darkwing: row 273 in-review round 1 and review request; filbert: row 273 APPROVE recorded (c30308); sage: row 273 landing note and done after 0bbf0f6d1 (#1778)) 2026-10-21 22:41:05 -05:00
jason.woltje 5365adaef6 docs: row 273 TOOLS.md activate usage and F39 rules (Filbert N1), BUILD-LOG for rows 202 and 273
TOOLS.md: activate takes --no-sso and --send; three lines state the F39 rules.
BUILD-LOG: the row 202 landing line held since 02:01Z and the row 273 landing line.
2026-10-21 22:37:16 -05:00
jason.woltje 0bbf0f6d18 row 273: T53 test 6 pins the usage line with --no-sso
The usage line gained --no-sso in the proof and sso bracket. T53 test 6
still matched the old bracket and failed the whole run on 978143bf4.
2026-10-21 22:15:36 -05:00
jason.woltje 95f4bd8fdc row 273 notes: name the changed T57 item 3 assertion for review (sage-2321) 2026-10-21 22:15:36 -05:00
jason.woltje d8034c51bc stack-release: a rollback that drops --sso drops --send; the --no-sso walk refuses an ok line with no activation (row 273, sage-2315)
B1: row 200's rollback to a Console without --sso kept the pointer's --send,
which F39 b forbids once --send can sit beside --sso. send_for_target drops it
whenever the run drops the pointer's --sso, and the plan and steps say so.
N1: an ok line with no activation refuses sso-off-flags instead of being
skipped.
2026-10-21 22:15:36 -05:00
jason.woltje b28baf7ee8 stack-release tests: T58 adds sage-2305 cases a and b (row 273)
(a) --sso on, then two re-pins that carry it, then --no-sso gives back the
arrival's ExecStart byte for byte. (b) every set in the log holds --sso, so
--no-sso refuses sso-off-flags before any unit stops.
2026-10-21 22:15:36 -05:00
jason.woltje 3673463182 stack-release: --no-sso drops --send in one place, send_for_target (row 273) 2026-10-21 22:15:36 -05:00
jason.woltje 7991662dc0 stack-release: activate --send and --no-sso (row 273, #1778)
activate --send turns send on only when the run's Console flags hold
--sso, given or carried (F39 b), and puts it after --sso and before the
tester flags. activate --no-sso turns --sso off with the Console flags
from the set of the activation that turned it on, drops --send (F39 a)
and keeps the pointer's tester. Both are usage errors under --units
external. T58 covers the brief's T1-T4; the external test covers T5.
Design and the set-walk choice: agents/darkwing/work/queue-273/BUILD.md.
2026-10-21 22:15:36 -05:00
jason.woltje 378a76b854 queue: revs 3103-3108 (morgana: rows 205 and 274 claims; darkwing: row 273 claim; sage: row 276 F5 follow-up added and briefed (#1781, duckworth-0111 C4), row 202 done after ddfb29af7 (#1700 closed)) 2026-10-21 21:17:02 -05:00
jason.woltje ddfb29af7c row 202: FED R5, mosaic access list, grant and revoke (#1700)
Candidate quackerjack/fed-r5-r5 a74cd9f87 on c3d88840c, manifest of 6
paths (CANDIDATE.sha256). Duckworth APPROVE round 5, rev 3026 (#1700
c30184). C1 gate on Sage's slot by Quackerjack on the merge of a74cd9f87
onto a849d89b7 (3b86180b9), finished 01:56:24Z: cli suite 771/771, skip 0
(run 2; run 1 red on a too-long gate TMPDIR, harness only), every
scripts/test-*.sh rc 0 with Docker up, mutants M1-M13 killed by their
named tests. Landed onto 9c9732983 with the candidate's blobs and modes
for the 6 paths; c3d88840c..9c9732983 touches none of them.
2026-10-21 21:00:28 -05:00
jason.woltje 9c97329833 docs: brief for the row 253 F5 follow-up, supervised legacy AS_USER calls (duckworth-0111 C4) 2026-10-21 20:58:00 -05:00
jason.woltje a849d89b73 docs: phase 2 plan, row 273 cases go in stack-release.test.mjs (mos-1247; test-release.sh never calls stack-release.sh) 2026-10-21 20:23:38 -05:00
jason.woltje 0042f713dc queue: revs 3092-3102 (megavolt: row 236 claim; sage: row 273 activate --send/--no-sso added and briefed (#1778, mos-1246), row 274 FED R3 added and briefed (#1779), row 275 193b1b added and briefed (#1780), row 205 re-pinned to 193b1a with its piece, row 206 re-pinned and after 275) 2026-10-21 20:06:55 -05:00
jason.woltje f0d0b31e9b docs: briefs for the activate --send/--no-sso row, FED R3 and the 193b1 split; phase 2 plan F39 fixes
- docs/plans/2026-10-22_activate-send-nosso.md: the row Mos approved in mos-1246. Darkwing builds,
  Filbert reviews. --no-sso drops --send, --send refuses without --sso (F39), test-release.sh cases.
- docs/plans/2026-10-22_federation-r3.md: FED R3, the broker user principal, on 193b1a's candidate.
  Owner Morgana, reviewer Dewey. Its parents R1 and row 196 have landed, and the filing was owed.
- human-users-build.md: 193b1 split into 193b1a (message filter, chained on 196) and 193b1b (viewer
  verbs and board refusals, after FED R3 and R4). Mutant 3 moves to 193b2 (sage-2288, morgana-0063).
- sso-phase2-window.md draft 2: the tool gap paragraph names the approved row, and rollback step 1
  drops "--send follows the pointer", since --no-sso drops --send (mos-1246 items 2 and 3).
2026-10-21 19:42:01 -05:00
jason.woltje 92be0db025 docs: Q175 phase 2 window plan, draft 2 after mos-1245 (#385, Vikunja 412)
R1 same-copy rollback, 7247d80c7 named, placeholder stop, step 10 aimed at a
tester-owned thread, agent config writes. Names the tool gap: activate has no
--send and no --no-sso, and --expect-from is external-units only.
2026-10-21 19:33:57 -05:00
jason.woltje 02da6a2ae4 queue: revs 3088-3091 (sage: rows 205 and 236 brief re-pins to HEAD and briefed) 2026-10-21 19:30:48 -05:00
jason.woltje 2dd9c80c1c docs: Q175 phase 2 window plan, draft 1 for Mos (#385, Vikunja 412) 2026-10-21 19:21:29 -05:00
jason.woltje 137ddd0079 docs: BUILD-LOG entry for the row 254 landing and a Sage SESSIONS line (#1759) 2026-10-21 19:18:12 -05:00
jason.woltje e3c0446f55 queue: revs 3073-3087 (gizmoduck: row 264 claim, row 268 r2 back to in-progress and in-review on ecd9b35eb, request refused without a token; sage: 268 r2 review request as jarvis, rows 269-272 for 264b-264e added and briefed with issues #1774-#1777; webby: row 268 r2 APPROVE carried on ecd9b35eb) 2026-10-21 19:17:00 -05:00
jason.woltje dcd8d4ddaa queue: revs 3071-3072 (sage: row 254 landing note and done after 331597709 and eb9672c91, #1759 closed) 2026-10-21 18:58:46 -05:00
jason.woltje eb9672c910 row 254: README request-timeout sentence and a DEFERRED entry after the landing (#1759)
Doc-only commit after the row 254 landing. The library README now says
that a sync outlasting the server's 120-second hold gets request-timeout
and still finishes (Dewey N1, #1759 comment 30173; server.mjs sets the
120 s socket timeout and the broker promise runs on). DEFERRED records
Dewey N2: library-mapping-invalid, library-remote-invalid,
library-state-invalid and library-git still map to 502 until the Console
library routes row decides them. No test reads either file's content.
2026-10-21 18:55:26 -05:00
jason.woltje 3315977095 row 254: library.sync 130 s human client timeout, six library-* STATUS entries, channel-proof re-pin, README (#1759)
Candidate megavolt/row254 b31300d79 on c3d88840c, manifest be63cfbe
(7 paths). Dewey APPROVE r1, rev 3006 (#1759 c30173). Gate on Sage's
slot by Megavolt, 23:37:27-23:40:52Z on b31300d79: library 77/77, bus
628/628, webui 305 pass with 1 skip (editor rebuild needs esbuild
0.28.2), every scripts/test-*.sh rc 0; the human-cli timeout and
library-fetch-failed STATUS mutants killed by their behaviour tests.
Landed onto 41b0cf755 with the candidate's blobs and modes for the 7
paths; c3d88840c..41b0cf755 touches none of them. Merged-tree rerun of
library, bus, webui and cli on Sage's slot before the push.
2026-10-21 18:44:22 -05:00
jason.woltje 41b0cf7551 queue: revs 3060-3070 (gizmoduck: row 268 claim and in-review, review request refused without a token; sage: 268 review request as jarvis, notes on 216 and 220 pointing at RULINGS.md da9555ca2, row 264 to gizmoduck with reviewer duckworth and briefed; webby: row 268 r1 APPROVE on 9d9ee12c3) 2026-10-21 18:35:57 -05:00
jason.woltje 5f5b51dd65 sage: rulings for row 264 (T1 item 7 sessions) on Gizmoduck's phase A questions Q1-Q7
Five slices 264a-264e, all MVP; schema label by landing order across rows 236, 264a and 206; PRD 1.13
already landed; row 194 is the stop owner for 264d; one admission hook for row 191; Pi count exact with
Claude unknown until Vikunja 466; legacy single human until row 193 (gizmoduck-0154, #1769, Vikunja 463).
2026-10-21 18:34:12 -05:00
130 changed files with 10005 additions and 243 deletions
+60
View File
@@ -5219,3 +5219,63 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-21T22:28:56Z | Huey | T1 sessions design r2, scrooge-0006, mos-1235/1236, Vikunja 463 | Before: r1 347278044 lacked the exact per-session checkout/single-writer invariant and safe runner drain ordering, and admitted other role instances. After: F1 exact workspace path, fenced checkout setup, legacy shared-workspace quiescence, clear retains old dirty/untracked/unpushed checkout and creates fresh approved checkout, T2 mutant; F2 dequeue fence and generation-bound turn-boundary idle proof before runner signal, busy drain stays clearing, T4 cases; F3 THREAD_DEFINITION only per Mos scope ruling, no role eligibility change; N1 private titles/history vs owner-visible count/activity projection; N2 Claude offline adapter/managed-store-reader dependency flagged as MVP risk. Section 8 records Mos’s Q1–Q3 readings, no questions to Jason. Verification: 27 pinned references resolve, summary within 15 lines, source/delta consistency review, whitespace and installed queue guard checked. No code, queue/PRD edits, builds/suites/live changes/provider calls/credential reads.
2026-10-21T22:30:20Z | Huey | T1 sessions design r3, scrooge-0007 R1, Vikunja 463 | Before: r2 e19e30df2 nested new session checkouts under the retained legacy instance workspace, allowing project-name collision or historical parent inspection to reach live siblings. After: exact path is dataRoot/session-workspaces/business/instance/session, outside the legacy root; canonicalized ancestor/descendant overlap refuses; T2 nesting mutant covers historical mount/inspection and a legacy project named sessions. All other r2 content retained as approved, revision label updated. Verification: old nested path absent, manual R1-only delta, whitespace and installed queue guard checked; no code/builds/suites/live changes/provider calls/credential reads.
2026-10-20 18:51:10 UTC | Row 191 #1689 design phase | Before: per-business launch revoke and per-binding Discord STOP leave task containers, active sessions and background work without one switch. Proposed per-data-root durable halt with human-only resume, generation-fenced starts, bounded concurrent termination, write-once stop receipts and existing event mirror; no schema migration. DESIGN.md inventories starter/continuation paths and container control-mount restriction. Sent to Dewey with Sage copied; implementation waits independent design approval. No tests, commits or live actions.
2026-10-20 18:57:51 UTC | Row 191 design revision | Dewey r1 changes: interim Console --send absence, reserved processing events and missing-state semantics required correction. DESIGN-r2.md now names CLI interim path, unavailable UI controls, trusted initialization and fail-closed missing leaf, container delete/replace mount tests, reservations and id-bound reconciliation. Small notes addressed including diagnostic seats and aliases. Recommended smaller Docker scope to Sage; design and scope approval pending, no code or test runs.
2026-10-20T19:50:56Z | Rocko | row 191 #1689 implementation stage: from independent brakes to shared root state, generation-fenced launcher and tracked stops, Discord/task guards and control HTTP/UI. Evidence is agents/rocko/work/queue-191/logs/ and PROGRESS.md. Fixture single files only so far; heavy verification and candidate not complete. Scope additions sage-1367 (one read-only CODE file) and sage-1392 (processing-only client socket timeout); no schema, proof baseline, live file/unit, commit or push change.
2026-10-20 22:58:45 UTC | Rocko | row 191 #1689 rebase inspection candidate | Rebuilt uncommitted kill-switch tree from da230ca48 onto landed row 190 base 00d4cc1f6 under new branch rocko/row191-r2-00d4. Hand merges retained instance flags and SSO/sign-out changes; fixture tests exposed and fixed SSO signer compatibility and two unknown-runtime cleanup defects. Final touched singles 249/249 across 26 files; 15 overlapping Client reader files 102/102. Manifest a4f780c2 has 78 paths; packet 185 files verified with scan clear. Mutants and owner gate remain pending; no review-ready or activation claim, no commit/push/live action.
2026-10-21 23:39:21 UTC | Rocko | row 191 #1689 inspection snapshot | Sage-2242 authorized a local inspection commit for Honker's row 194 S6. Created d52b4db1c on new rocko/row191-inspection-20261021, parent 00d4cc1f6, preserving the reviewed-design build and candidate a4f780c2 (78 paths). Static manifest/index/source scan and diff check verified; no tests run, no push credential or push attempt. Existing single-file evidence is historical, 249/249 and overlapping Client readers 102/102; suites/mutants/gate remain pending. Current code has verified runtime signalling and instance halt, but no dedicated per-run stop API yet; Honker and Sage notified of that seam directly. Snapshot is not review-ready or approved for live use.
2026-10-22 00:04:05 UTC | Rocko | row 191 #1689 per-run stop inspection | Implemented agreed Honker-0079/0081 stopRun contract with verified per-signal identity, fixed 5s+2s, durable first-owner join and immutable result plus later-exit observations. Focused CLEAR sage-2250: final 9 files 62/62, no skipped, after correcting host guard placement and peer-discovery fixture timeout isolation; initial failures and failed teardown logs retained, only own failed group stopped. Slot returned with cwd sweep clear. Sage published f7bf7dfec; sage-2265 clean rebase creates 7aa18b0f6 on dcd8d4dda under new branch, explicit Rocko identity, all 79 manifest product blobs identical so 62/62 evidence carried. Direct S6 handoff sent to Honker and Sage. Wider suites, mutants, review and activation remain pending.
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing (#1759), library.sync human timeout | Before: the human transport gave library.sync the 5-second default, so a sync longer than that reported outcome-unknown, and six library-* codes reached the Console as 502. After: 331597709 lands Megavolt's candidate (7 paths, tree d49e4626f, manifest digest be63cfbe recomputed and matched). human-cli.mjs waits 130 s for library.sync, past the server's 120 s hold, and 5 s for other verbs. webui bus.mjs maps library-sync-busy, library-diverged and library-accept-stale to 409, library-accept-invalid to 400, and library-credential-missing and library-fetch-failed to 503. channel-proof re-pinned. Merged-tree gate on the landing commit: library 77/0, bus 628/0, webui 305/0 (1 skip), cli 749/0 (23:44:33-23:55:11Z). eb9672c91 adds the README request-timeout sentence (Dewey N1) and a DEFERRED entry for the four library codes still at 502 (Dewey N2, binds the Console library routes row). Overlap sweep before push: 268 rebased to r2 ecd9b35eb (README only, approval carried webby-0096), 194 rebased to b7. Done rev 3072, #1759 closed.
2026-10-22 00:24:34 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 full gate on c78f48946 | stopped on second integration failure per sage-2275; singles 269/269, processing-control 61/61, bus 646/646; CLI interrupted with 26 observed failures; no further edits; cwd sweep empty and slot returned to Sage.
2026-10-22 00:48:20 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eb3abba12 full gate | stopped at Discord 190/192; 32 singles 304/304, processing-control 61/61, bus 646/646, cli 761/761; two uninitialized Discord fixtures reported to Sage; no later run; cwd sweep empty, slot clear.
2026-10-22 00:58:27 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2294 fixture fix | e6800380d on eb3abba12, two test files/four additions, assertions preserved; 338-entry static all-package sweep recorded; no tests; sent Sage for publication and Honker for dependent gate.
2026-10-22 01:30:04 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 e6800380d resume | binding15/15, recover5/5, Discord192/192; stopped WebUI314 passed/4 failed/1 esbuild skip; processing polling violates sign-out/manual redirect and bar changes layout; no fixes/retries, cwd sweep empty, slot returned.
2026-10-22 01:38:08 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2317 Console integration fix | 8e1f55c1d on e6800380d; shared sign-out/manual redirect/interval and desktop frame fixes, assertions retained, six browser cases, 73 mutant sites preflight; syntax only, no tests; sent Sage/Honker.
2026-10-22 02:06:38 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 rebase sage-2329 | eafcbd3e7 on ddfb29af7, new branch, dispatcher retains processing and access; CLI/readme auto-merges checked; 87-path fd0f2740 manifest and 73-site preflight; Sage2333 published, awaiting CLEAR; no tests.
2026-10-22 02:20:51 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eafcbd3e7 full gate | stopped singles313/6: six new browser cases count app freshness timer as processing timer; diagnosis sent Sage, no fix/retry; no suites/shells/mutants; cwd sweep empty, slot clear.
2026-10-22 02:23:13 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2345 timer fixture | 4db8b5778 on eafcbd3e7, one line attributes timer to processing.js; all assertions retained, no product change or tests; sent Sage and Honker; awaiting publication/CLEAR.
2026-10-22 02:25:29 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2349 record commit | 0f8235277 on 4db8b5778 adds packet BUILD.md cause record; product manifest remains ffb46fbd/87 paths; no amend, product change or tests; Sage/Honker notified.
2026-10-22 02:48:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 0f8235277 full gate second red | singles330/330, processing61/61, bus646/646, cli783/783, discord192/192; WebUI323/1/1 host esbuild skip; shell geometry1062vs1000; stopped/no retry, no later suites/shells/mutants; cwd sweep empty, slot returned, Sage escalates Mos.
2026-10-22 02:57:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 mos-1248 desktop layout restart | FF 5cd32f4c1 on 0f8235277: desktop grid allocates processing bar, existing shell assertions kept, visibility/no-scroll assertions added; 74 static mutant sites; candidate 2f93ef19; sent Webby/Sage for static review; no runs, awaiting review and CLEAR.
2026-10-22 03:11:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 static nav layout, mos-1249/1250 | FF da4334806 on 5cd32f4c1; controls first in nav, non-sticky per sage-2386/webby-0108; candidate-only placement assertion changed, landed shell assertions preserved; 77 mutants statically preflighted; manifest 245dceb1; Webby/Sage handoff sent, no runs, awaiting static look/publish/debug CLEAR.
2026-10-22 03:15:11 UTC | Rocko (d99326cc) | row 191 sage-2394 debug | da4334806 shell5/5 and processing-browser9/9, awk14/0/0; geometry heights reported with read-only distinction; cwd sweep[] and slot returned; full window awaits separate CLEAR.
2026-10-22 02:01:30 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 202 #1700 FED R5 landing | ddfb29af7 on 9c9732983: 6 manifest paths from a74cd9f87 (digests match CANDIDATE.sha256, patch-id 9d89077b equal); Duckworth APPROVE r5 rev 3026; C1 gate by Quackerjack on merge 3b86180b9: cli 771/771 skip 0 (run 2, run 1 red on gate TMPDIR length), 10 test-*.sh rc 0, M1-M13 killed; done rev 3108; landing note refused for length before the done move, so the record is the commit body and #1700 c30265; 191 and 194 rebased onto it (one scripts/mosaic case line)
2026-10-22 03:35:55 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 273 #1778 activate --send/--no-sso landing | 0bbf0f6d1 landed on 378a76b85, a rebase of cac7d0f67 (0042f713d) with all 6 range-diff pairs = and equal patch-ids; 4-path manifest e1031023 unchanged; Filbert APPROVE rev 3111 (c30308) carried merge-scoped under mos-1160, carry comment c30319 names 0bbf0f6d1. Gate on 0bbf0f6d1 by Darkwing: stack-release.test.mjs 218/218 skip 0, stack-release-external.test.mjs 25/25 skip 0, test-release.sh rc 0 (selftest 14/0, no skips), T58 controls c0 11/11 pass and c1 11/11 fail, M1-M7 killed. Rerun because row 202 changed scripts/mosaic, which stack-release.test.mjs copies. Red 1 was 978143bf4 (T53 test 6 usage pin). This doc commit adds the TOOLS.md usage line and F39 rules (Filbert N1).
2026-10-22 04:02:12 UTC | Rocko (d99326cc) | row191 full window0fa7bf421 | touched35files336pass, packages2403pass/1esbuildskip, shells425selftests+156queue node pass; SR01–07killed, SR08timeout,69unrun; stopped/no retry;88blobs restored,cwd sweep[],owned network removed,slot returned Sage; await Mos ruling.
2026-10-22 04:14:56 UTC | Rocko (d99326cc) | row191 control/resume sage-2427 | control12/12;SR08separate timeoutkill;49resumed assertionkills thenKS43survived;total56assertionkills+1timeout,1survivor,19unrun;88blobs restored,cwd sweep[],slot returned,Sage/Mos informed;no retry/edit.
2026-10-22 04:21:30 UTC | Rocko (d99326cc) | row191 KS43+19 retarget run | KS43assertionkill thenKS44invalid mutation syntax;57assertionkills+1timeout,1invalid,18unrun;stopped,no edit/retry;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified.
2026-10-22 04:30:06 UTC | Rocko (d99326cc) | row191 KS44+18 resume | 10assertionkills thenKS54timeout-without-control despite dependency assertion;total67assertionkills+1timeoutkill,1unqualifiedtimeout,8unrun;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified,no retry/edit.
2026-10-22 04:41:46 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | row191 #1689 builder complete/in-review | published0fa7bf421,manifest0bcbf337;35singles336pass,9packages2403pass/1knownskip,10shells425selftests+156queue nodepass;75assertionkills+2controlledtimeoutkills=77;88hashrestore,cwd sweep[],slotreturned;in-review rev3114,expected no-token failure3115(exit1);Sage/Dewey handoff,no live action.
2026-10-22 05:03:18 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | Vikunja502 processing test/runner hardening | brief+isolatedrocko-v502;expected wordingred→green,Node27/27,Python4/4,5/5assertionkills(includingSR08andKS54nohang),0skips/timeouts;sweep[],candidate5hashrestored,slotreturned05:01:29Z;candidate968d6ee3,runnerc5bdc764;packetready,no commit/push/liveaction.
2026-10-22 05:07:34 UTC | Rocko (d99326cc) | Vikunja502 committed/review handoff sage-2482 | newbranchrocko/vikunja502-ce6a-20261022,source8e3c6a79b+packet9bf4683c6 on ce6a96ad6;5paths no base drift,range-diff=,patch-id37135ebe unchanged;manifest968d6ee3,packet93889519 verifies39/39;focused runs carry;Sage publish/Dewey review handoff sent,no push/liveaction.
2026-10-22 05:10:00 UTC | Sage (1ef1e4f8) | serial landing 205 then 268 | 205: 3 commits, 4 paths, digest 858463b1, every scripts/test-*.sh green on ce6a96ad6, Duckworth N1-N4 carried (N1/N3 to 205b or R3, N2 to the 193b2 brief, N4 #1782 before any user thread goes live). 268 r4: c27207f6e on 205, 9 paths, digest caa0fa4e, patch-id 2942acc8 equal to r3. Overlap checks clean before both pushes. No live action; LIVE stays 86f95bb27.
+1
View File
@@ -22,6 +22,7 @@ RUN npm ci --ignore-scripts
# runtime adapters.
COPY contracts /opt/mosaic/contracts
COPY src /opt/mosaic/src
COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/
COPY adapters /opt/mosaic/adapters
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
+113
View File
@@ -0,0 +1,113 @@
# Row 273 (#1778): activate --send and --no-sso
Darkwing, 2026-10-22. Brief: `docs/plans/2026-10-22_activate-send-nosso.md` (blob in f0d0b31e9), approved by
Mos in mos-1246 (F39). Base: origin/refactor 0042f713d. Reviewer: Filbert. Gate owner: Sage.
## What changed
Only `scripts/stack-release.sh` and its two node test files.
- `activate --send` puts `--send` into the run's Console flags after `--sso` and before the tester
flags, where webui-service.sh renders it. It refuses `send-needs-sso` unless those flags hold `--sso`,
given or carried. The plan line is `send: on; --send was given`, or `send: carried` when the pointer
already holds it. `check_target` lets it name the pointer's copy only when the pointer holds no `--send`,
so a second `--send` on the same copy refuses `already-active`.
- `activate --no-sso` refuses `sso-not-on` when the pointer holds no `--sso`. Otherwise it takes the
Console flags from a rollback set (below) and keeps the pointer's tester flags. `send_for_target`
drops `--send`, whether it came from the set or the pointer. It is the only place that does, so one
mutant there (M3) shows the drop is tested. The plan prints `sso: off; Console flags from <set>/<file>; restores <flags>`
and a send line, `send: dropped; --no-sso drops --send (F39)` or `send: off; --no-sso was given and the
pointer holds no --send`. The steps log gets `drop --send; --no-sso was given` and
`turn off --sso; the Console flags come from <set>/<file>`.
- Usage errors: `--send` with `--no-send`; `--no-sso` with `--sso`, `--send`, a proof pair or
`--no-proof`; either flag on any verb but activate; either flag under `--units external`.
`--no-sso --no-send` is allowed, since the brief doesn't forbid it and both drop the same flag.
- The set read that `sso_base` did inline for a rollback to a Console without `--sso` moved into
`set_flags`, which both paths call. Its three messages are unchanged, and T57 still pins them.
- `from_log` has a new mode, `sets`: the activation of each ok activate and rollback line, newest first,
stopping at the adopt line, which took no rollback set.
## The consequential choice: which set --no-sso reads
The brief says the flags come from "the rollback set of the activation that turned `--sso` on", and that
"a set whose Console already ran with `--sso`" refuses. Each set holds the Console unit from before its
run. So the activation that turned `--sso` on is the newest one whose set holds a Console without
`--sso`: every activation after it ran with `--sso`, and so did every set it left.
`no_sso_base` walks the log's sets newest first and stops at the first set whose Console has no `--sso`.
It refuses `sso-off-flags` at the first set it can't read (missing, failing its SHA256SUMS, or with no
argv the script can parse), since it can't tell whether that set was the one. If every set ran with
`--sso`, it refuses with `the Console in <set>/<file> already ran with --sso, and the log names no
earlier activation`. A log with no set at all refuses with `the log names no activation with a rollback
set`.
The tradeoff: a later same-copy re-pin that carried `--sso` (a `--no-send`, or a re-pin onto a newer
copy) doesn't break `--no-sso`, which the phase 2 window needs if a re-pin lands between step 5 and a
rollback. The cost is that "a set that ran with `--sso` refuses" now applies only when no older set
without `--sso` exists. A tampered set on the walk still refuses, even one newer than the set the walk
would have used. Filbert, please rule on this reading.
## Filbert's early look: B1 and N1 (sage-2315)
Filbert accepted the walk. Sage ruled both findings into this row.
- B1: row 200's rollback to a copy whose Console has no `--sso` took the flags from the set and kept
the pointer's `--send`. From an `--sso --send` pointer, which this row's `--send` can now make, that
gave a Console with `--send` and no `--sso`, which F39 b forbids. `send_for_target` now drops `--send`
whenever the run drops the pointer's `--sso`, so it is still the one place that drops it. The plan
line reads `send: dropped; the run drops --sso, and --send needs it (F39)`, and the steps log gets
`drop --send; the run drops --sso`. Row 200's sso line now names only the tester as following the
pointer.
- Changed existing assertion, for the review to approve by name: T57 item 3 (sage-1389), "the
plain case", ends with `assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)])`. It
used to expect `'--send'` at the end. The case seeds a legacy `--send` pointer, turns `--sso` on
(which carries `--send`), then rolls back to B, whose Console has no `--sso`. A carried `--send`
that ends without `--sso` breaks F39 b whatever its source, so the old pin asserted the wrong
outcome (Sage accepted this in sage-2321). Row 200's two sso plan strings also lose "--send and"
(`ROLLBACK_PLAN` and the round 3 named-instance case), since `--send` no longer follows the pointer
on that path.
- N1: the walk skipped an ok line with no activation. Skipping it could land on an older set that
isn't the right one, so it now refuses `sso-off-flags` with `an ok line in the log names no
activation`. The walk reads the log through `mapfile`. That gives no entry for an empty log, so a
log with no set still gets its own message.
## test-release.sh
The window plan says test-release.sh "gets cases for both". The brief lists it only as a file to run
whole. test-release.sh is release.sh's Docker selftest and doesn't call stack-release.sh, so the cases
live in the node tests and test-release.sh runs unchanged.
## Tests (T58 in stack-release.test.mjs)
- T1: `--sso --send` from B onto C1 with a tester port: plan, pointer, unit and steps. `--send` beside a
carried `--sso` re-pins C1 once; a second `--send` refuses `already-active`; C carries it.
- T2: `--send` without `--sso` refuses on another copy and on the pointer's copy, also when the pointer
carries `--send` alone; `--plan` lists the refusal.
- T2 (sage-2315 B1): `--sso --send` onto C1, then a rollback to B, whose Console has no `--sso`. The
pointer and the unit end with neither flag, and the plan and steps name the drop.
- T2 and T4 usage: every forbidden pairing and verb, checked against the full usage text.
- T3: from an `--sso --send` pointer, `--no-sso` on the same copy gives back the pre-`--sso` ExecStart
line byte for byte, without `--send`. A second case walks past a `--no-send` re-pin that ran with
`--sso` and keeps the pointer's tester port (7334), which differs from the set's (7333). A third
(sage-2305 a) turns `--sso` on, then re-pins twice with `--send` and `--no-send`, so the two newest
sets both hold `--sso`. `--no-sso` then gives back the arrival's ExecStart line byte for byte.
- T4 (sage-2305 b): two sets, both edited or written to hold `--sso` with sums intact, refuse
`sso-off-flags` through `refused()`.
- T4: `sso-not-on`; an ok line with no activation; a set that fails its sums, ran with `--sso`, has no
argv or is missing; a log with no set. Each runs through `refused()`, so nothing stops and nothing else changes.
- T5: stack-release-external.test.mjs adds `--send` and `--no-sso` to the row 232 usage list.
- T55 test 5 and the T57 usage test match the new usage line.
## Mutants
| # | Change | Expected killer |
|---|---|---|
| M1 | `set_flags` skips the SHA256SUMS comparison when `--no-sso` is given | T4 tampered set |
| M2 | `no_sso_base` skips the `sso-not-on` refusal | T4 `sso-not-on` |
| M3 | the `--no-sso` branch of `send_for_target` keeps `--send` | T3 |
| M4 | `--send` skips the `send-needs-sso` refusal | T2 |
| M5 | parse_opts drops the `--send`/`--no-send` rule | T2 and T4 usage |
| M6 | a rollback to a Console without `--sso` keeps `--send` (B1) | T2 (sage-2315 B1) |
| M7 | the walk skips an ok line with no activation (N1) | T4 no-activation line |
Runs and results go in the round's notes once Sage clears the slot.
+80
View File
@@ -0,0 +1,80 @@
# Row 268 build notes (Gizmoduck)
Case 2 C2-01c, playground levels. Gitea #1773, Vikunja 464. Brief: docs/plans/2026-10-20_case2-rows.md,
"C2-01c playground levels: universe-level prefixes and draft universes" (blob a6ac627c8). The ruling is
agents/gosalyn/work/case2/DESIGN.md "Ruling after r11: playgrounds at three levels" (:49-88 at
db1e7732a), and it wins where the two differ. Reviewer Webby, gate owner Sage. Branch gizmoduck/row268
from refactor db1e7732a. Line numbers are on this branch unless a commit is named.
## mapping.mjs
- `kind` (:71). An entry with a `kind` key must say `"hybrid"` or `"draft"`. Before, a plain universe
with `kind` refused as an unknown key, and `kind: "draft"` did the same.
- A draft (:73-86) refuses `books` with its own message before the key check, so the cause reads
plainly. It takes `kind`, `name` and `playground`, needs one prefix or more, and comes back as
`{ kind: 'draft', name, books: [], playground }`, frozen with the rest.
- The prefix level check moved out of the per-entry loop to :127-140, after every entry is read,
because it needs every entry's books. It runs after the book-holds-book, prefix-holds-prefix and
listed-twice checks, so an overlap still reports as before. For each prefix and each book folder:
- equal to any book folder: `library-mapping-invalid` (brief, What ships, "or equal to a book folder");
- inside or holding another entry's book: `library-playground-other-universe`, and the message says
which, with the book and its entry;
- holding its own entry's book: `library-mapping-invalid`.
A prefix inside its own book is book level, and one that touches no book is universe level. A draft
has no books, so its prefixes are either outside every book or refused as another entry's.
- `sources` (:117-120) now refuses any entry with a `kind`, so a draft can't be a source either. The
message changed from "not a non-hybrid universe" to "not a plain universe". mapping.test.mjs:61 on
db1e7732a pinned the old words, and that line moves with it.
- `library-playground-outside-book` is gone from packages and docs/case2.
## scope.mjs
`classify` (:12-24) now runs two passes. The first finds a prefix in any entry, and the file is
`playground` with that entry's id and `book` set to the entry's book folder that holds the prefix, or
`null`. The second finds a book folder, and the file is `canon`. `place` (:26-32) works out `type` and
`folder` from the first folder under the prefix or the book, as before. The validator keeps prefixes
from overlapping, so the first pass finds at most one.
## Readers on this base
- reader.mjs:182 and :194 spread `classify`'s answer into each file record, so a universe-level or
draft file carries `book: null`. Nothing in the reader reads `book`.
- reader.mjs:206-209. R5 fires for a canon file linking into any playground. R6 fires when the target's
universe differs and isn't a source. A draft can't be a source, so a link into one gives R5 and R6.
A link into the file's own universe-level prefix gives R5 only. No code change was needed. The new
test covers both.
- survey.mjs:66 and mirror.mjs:246 read only `scope`. mirror.mjs:251 calls `readLibrary` and checks
only `path`.
- No other package calls this `classify` or reads `universes` (git grep over packages/*/src).
- C2-05a (row 216) and C2-06 (row 217) have no candidate branch on origin at the time of writing. I
check again before in-review.
## Tests
- mapping.test.mjs: the old outside-book test splits into the other-universe refusals (inside another
entry's book, holding one, a draft prefix inside a book), the mapping-invalid ones (equal to a book,
own or another's, and holding its own), universe level in a plain universe and in a hybrid with
`books: []`, and the draft shape (frozen with `books: []`, with `books`, with none or empty
prefixes, an extra key, and five bad `kind` values). The overlap test gains a cross-entry hold and a
cross-entry duplicate. The hybrid test gains a draft source. The caller-supplied `fail` test now
uses `Second World/Playground/` and sees the new code, since `Elsewhere/` is valid now.
- scope.test.mjs, new: one case per level (book, universe in a plain universe, universe in a hybrid,
draft), `.canvas` under each new prefix staying unmapped, prefixes first inside a book that holds a
prefix, and the reader case for R5 and R6, with `book: null` on the read records.
- business.test.mjs: `Elsewhere/Playground/` now loads, and `Second World/Playground/` refuses with
`library-playground-other-universe` through `validateBusinessDocument`.
## Docs
- packages/library/README.md: the universe keys, a line on the three levels, the refusal table and
the Scope section.
- docs/case2/SETUP-SHEET.md "Your playground": one line per level and the draft entry.
- docs/case2/SURVEY-RUN.md:114: the new code and message.
## Hash pins
`git grep -E '[0-9a-f]{64}'` over packages/*/tests finds no pin on any changed file.
## Runs
None yet. Every run waits for Sage's CLEAR.
+74
View File
@@ -0,0 +1,74 @@
# Row 254 build: library sync follow-up (C2-03a deferrals)
Row 254, #1759, reviewer Dewey, gate owner Sage. Brief: docs/plans/2026-10-21_library-sync-followup.md
(0de97f429), for the deferrals Dewey named in the row 222 approval (#1723 comment 30138). Built on
origin/refactor c3d88840c, which carries row 222 (0b33f5e9d) and row 196 (7247d80c7). Launch: sage-2163.
## What it does
1. **Client timeout.** `packages/bus/src/human-cli.mjs` gives `library.sync` a 130 s client timeout. Every
other verb keeps Client's 5 s default (`timeout: undefined` takes the default). The server holds a library
verb's socket for 120 s (`packages/bus/src/server.mjs`, the `socket.setTimeout(120_000)` branch), and the
fetch alone may take 100 s (GIT_LIMITS). So the client waits past the server and gets the server's own
answer, including its `request-timeout`, instead of giving up first.
- The Console has no library route, so there is no Console timeout to change (brief item 1, "when one
exists"). When a route is added, note that webui `humanCall` kills its child at 30 s by default. The
route would need `timeoutMs` above 130 s for library.sync.
- `tester-cli.mjs` is unchanged: library.sync is human-only, so the tester gets `human-required`.
2. **STATUS codes (215 N3).** `packages/webui/src/bus.mjs` maps six codes:
| Code | Status | Why |
|---|---|---|
| library-sync-busy | 409 | a sync is running; read status again |
| library-diverged | 409 | held for a human accept |
| library-accept-stale | 409 | the tip moved; read status again |
| library-accept-invalid | 400 | a malformed accept |
| library-credential-missing | 503 | the remote can't be reached, like no bus to ask |
| library-fetch-failed | 503 | the same |
Neither 503 is 502, the default for an unknown code, so dropping any one entry changes its status and the
test catches it.
3. **README (222 N4).** `packages/library/README.md` Sync section: the transport timeouts, that a client
timeout leaves the sync running, that a second call gets `library-sync-busy`, and that `library.status`
shows the result. It also says the Console has no library route yet.
- Correction to row 222's BUILD.md, "What it does": "`webui/src/cli.mjs`: the Console gets the call" was
not true. `cli.mjs` builds `librarySync` and passes it to `startServer`, and `serve.mjs` never reads it
(checked on c3d88840c: no library reference in serve.mjs).
## Channel proof re-pin
`packages/bus/tests/channel-proof.test.mjs` pins both changed files by hash. The test change is two baseline
values and a header comment, nothing else:
| File | Old | New |
|---|---|---|
| packages/bus/src/human-cli.mjs | 269c3a66008c0d57… | 9c9af66b98ea0a490c9a880fc81c3c62414a8ee0b5ba6478608771b8c4f87932 |
| packages/webui/src/bus.mjs | 934ab2f9b6bde72a… | e69d17d4385a4d190d15373ed1a26e270a5c6906eec9e61fcc2bb6b05849cabb |
human.mjs and transport.mjs are unchanged, and so are the entry-path test and the plug-in import ban. No
other test pins either file: `git grep` for both old hashes finds only queue.json history and old packets,
and the one 64-hex string in webui service.test.mjs is a made-up UID.
## Dewey's other 222 notes
None is a few lines in a file this row changes, so all three stay deferred:
- N1: a close waits on every entry left in a pass (runtime.mjs).
- N2: a held tip still gets its tree read, and a refused read hides the divergence (sync.mjs).
- N3: held states log every tick (runtime.mjs).
## Tests
- `packages/webui/tests/library-status.test.mjs` (new): each of the six codes maps to its status, and an
unknown code is 502.
- `packages/bus/tests/library-sync.test.mjs`, a new test. A stand-in bus socket answers every request after 6 s,
and the real human-cli.mjs runs two requests in parallel. library.sync gets the answer, and library.status
gets `outcome-unknown` at 5 s. It takes about 6 s.
- `packages/bus/tests/channel-proof.test.mjs`: the new baselines.
Planned mutants on Sage's slot:
1. Drop the human-cli timeout line: the library-sync test fails (sync gets outcome-unknown) and the
channel-proof hash fails.
2. Drop one STATUS entry (`library-fetch-failed`): library-status test fails, and so does the channel-proof hash.
Both files are hash-pinned, so channel-proof kills any edit to them. Each mutant is also expected to be killed
by its behaviour test, and the run lists every failing test so that can be checked.
+82
View File
@@ -0,0 +1,82 @@
# Row 205a (193b1a, #1703): the trail and replies message filter
Design: DESIGN.md d2 in this directory, with rulings in section 7 (sage-2288). Base: refactor 02da6a2ae. No schema
change. Nothing has run yet: every test run waits for Sage's CLEAR.
## Changes
`packages/bus/src/broker.mjs`:
- `#hiddenMessage(s, id)`: the message's thread (`message_threads` joined to `threads`) is owned by a `user:`
other than `#owner(s)`, and `#readable(s, t)` is false. A message with no thread, or in the business human's
or the tester's thread, is never hidden. A reader session owns nothing, so every user thread is hidden from it.
The tester reads through its reader view, and `#owner` still gives `tester`.
- `#readable(s, t)` returns false. Rule 1 (a share in force) can't hold before 193b2's schema 3j, so nobody
reads another's thread. 193b2 adds the share rule and rules 2-4 here.
- `#trail`: a hidden subject returns `[]` before anything is read, exactly as an unknown id does (ruling 3).
After the rows are collected, it drops hidden `messages` rows, `deliveries` rows of a hidden message, and
`events` rows whose subject is a hidden message. Decisions, decision events, task snapshots, run events and
claims stay.
- `replies`: a hidden id fails `message-not-found`, as an unknown id does. Hidden replies are left out in SQL
(`NOT EXISTS` a thread whose owner starts with `user:` and isn't the caller), so the `REPLIES` limit counts
only visible rows.
- The SQL clause doesn't call `#readable`. That is exact while `#readable` is false. 193b2 must add the share
exception to the clause and to `#readable` together.
`packages/bus/tests/message-filter.test.mjs` (new). It writes a `user:alice` thread and its messages
straight to the store, as R3's `thread.send` and the run's `to: 'user'` reply will write them, because nothing
makes a user session before R3.
- `trail` on a hidden id equals `trail` on an unknown id, byte for byte, for the owner, a reader and the tester.
- `replies` on a hidden id equals `replies` on an unknown id (code and message), for all three.
- `trail` on a user-thread run's decision shows the decision and the owner's message, and drops the user
thread message, its delivery and its event.
- `replies` with `REPLIES` hidden replies ahead of a visible one returns the visible one.
- Messages in the business human's and the tester's threads stay visible on both verbs (row 177 control).
- The governance pin, one assertion per item in the user sentence: inbox entry, claim, task, project file and
the closed decision in `decisions {list: 'recent'}` show to the owner. `trail` and `replies` hide the user
message, and the decision's trail carries no user body.
## Mutants (each must fail its named test in message-filter.test.mjs)
| Mutant | Edit | Named test |
|---|---|---|
| M1 | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user messages |
| M11 | the trail drop loop iterates nothing | trail on a decision keeps the decision and drops the user thread messages, their deliveries and their events |
| M12 | the replies `NOT EXISTS` clause removed | replies leaves out other users thread messages and still fills a page |
| M13 | trail fails `message-not-found` for a hidden subject | trail on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester |
| M14 | replies returns `[]` for a hidden id | replies on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester |
## Gate
The kit is /mnt/storage/scratch/morgana-r205 (`slot205.sh`, `mutate.py`, `busy.sh`), run as
`bash busy.sh && CAND=<sha> bash slot205.sh` on Sage's CLEAR. It runs these files whole:
- message-filter;
- the bus files that call trail or replies: replies, tester, tester-threads, tester-writes, broker, decisions,
decision-close, end-launch and task-read.
Then it runs the bus suite, then M1 and M11-M14, each on its named test and restored after each. It uses umask
022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g205XXXX`. It refuses unless HEAD is the candidate
and the tree is clean, and it stops at the first unexpected failure (sage-2371).
Sage's gate adds every `scripts/test-*.sh`. The webui tests that read trail or replies run there.
Static check: `node --check` on broker.mjs and the new test, run from `/`. Both parse. All five mutant anchors
match exactly once.
## r2 (after the 742cba805 slot, failure 1 of the window, sage-2428)
slot205 on 742cba805 stopped red in message-filter, 4 pass and 2 fail. Both fixes are in the test only.
broker.mjs is unchanged (sage-2430).
- "trail on a decision ..." (:124, its event shows). `#trail` reaches events on a decision trail by
`subject` = the decision or `body.$.decision` = the decision. `put()` wrote every event body as `{}`, so the
shown message's event was out of reach. That also made the "no hidden event" check (:127) vacuous: the hidden
event was never collected, so dropping it proved nothing. Fix: `put()` writes `{decision}` as the event body
when the message has a decision. Both events are now collected; the filter keeps one and drops the other.
- Governance pin, claims item (:175). A thread run's `role.claim` writes `thread_claims` only and returns
(broker.mjs:2682). `agents` (:3309) reads `role_claims` only. No owner verb shows a thread claim. The gap
predates 205. Ruling sage-2430, option (a): the pin checks through `threadsState` (the launcher view) that
the claim is recorded with holder `assistant-run` and isn't filtered. `agents` is unchanged in 205.
Follow-up: Sage adds a row for an owner-visible thread-claim read. Like the filter, it lands before any
user thread goes live, since PRD :1084 and the FED R6 sentence promise that the owner sees claims.
M1 still fails the pin: with `#readable` true, `trail` on the user message returns rows instead of `[]`.
+173
View File
@@ -0,0 +1,173 @@
# Row 205 (193b1, #1703): viewer verbs, board refusals and the message filter
Owner Morgana, reviewer Duckworth, gate owner Sage (sage-2280). Brief: `docs/plans/2026-10-20_human-users-build.md`,
"Brief: 193b1, viewer verbs, board refusals and the message filter" (blob a73e1bba0, rev 3088). Design:
row 193 DESIGN.md r5 (e2d161e2e, Duckworth approved), sections 4.3, 5.4, 5.5, 5.7, 8 and 9. Base: refactor
02da6a2ae. Draft 2: Sage's rulings (sage-2288) are in section 7. Sections 1-6 are d1 as sent (morgana-0063).
## 1. The dependency gap
The brief chains 193b1 on FED R3, FED R4 and row 196's verifier. Only row 196 exists (7247d80c7). On 02da6a2ae:
- R3 and R4 aren't filed. The FED brief (`docs/plans/2026-10-20_federation-r1-r2-r5.md`:12-13) files R3 "after
R1 and row 196" and R4 after R3, R5 and 193a. No `*/fed-r3` branch is on origin.
- Row 196's `verifyPrincipal` returns the `user` kind, and server.mjs accepts the `user` envelope, but
runtime.mjs wires only `authenticateHuman` and `authenticateTester`. The broker has no `bindUser`, no
`s.user`, no per-request grant read and no collaborator verb list. FED 3.6 and row R3 own all of these.
- So nothing makes a user session today, and no thread is owned by `user:<id>` except through a direct
store write.
193b1 has two halves that depend on R3 in different ways.
| Item | Needs a user session (R3) | Testable on 02da6a2ae |
|---|---|---|
| Viewer verb list (4.3) | yes: it extends R3's collaborator list | no |
| Per-grant level checks via `covers(..., {level})` | yes: R3's grant read | no |
| Board reads refused to users (5.5) | yes | no |
| `trail` and `replies` filter (5.7) | no: it filters owner, reader and tester sessions | yes, with seeded `user:<id>` threads |
| `#readable` owner-only | no | yes |
| Governance pin test (owner side) | no: the owner reads decisions, inbox, agents, tasks and `project.file` | yes, with a seeded user-thread run |
| Mutants 6 (viewer `thread.send`), 10 (user allowed `trail`) | yes | no |
| Mutants 11, 12 (trail, replies filter removed) | no | yes |
| Mutants 1, 3 (`#readable` rules 1 and 3 dropped) | see 4 | see 4 |
## 2. Recommendation: split 205 into 205a now and 205b after R3
- **205a, the message filter.** `trail` and `replies` hide messages in other users' threads from the owner,
reader sessions and the tester, with `#readable` owner-only. Plus the owner-side governance pin test and
mutants 11 and 12. No R3 dependency, no schema change.
- **205b, viewer verbs and board refusals.** Built on R3's candidate, as Q177 chains a row on its parent's
candidate. The viewer list, the level checks, users refused on board reads, the user-session half of the
pin test, mutants 6 and 10.
Why split. The filter is the item the boundary orders first: "No user thread goes live before 193b1's
message filter has landed" (mos-1021 B2, FED R8). Landing it before R3 means R3 and every later row can't
create a user thread that the owner can read on `trail`. Waiting for R3 holds the filter behind a row that
isn't filed. The other option, building all of 193b1 against R3's named interface as 193a did with R1,
doesn't work here: 193a only called R1's exported `covers`, while 193b1 would have to write `bindUser`, the
grant read and the collaborator list itself, which is R3's scope and owner.
Tradeoff: two rows and two reviews in place of one, and 205b rebases onto R3. The split is a brief change,
so it's Sage's to rule.
## 3. 205a design
### 3.1 The hidden-message check
One broker method, `#hiddenMessage(s, id)`:
```
SELECT t.owner FROM message_threads mt JOIN threads t ON t.id = mt.thread
WHERE mt.message = ? AND t.business = ?
```
A message is hidden when that owner starts with `user:`, isn't `#owner(s)`, and `#readable(s, t)` is false.
A message with no thread row, or in a thread owned by the business human or `tester`, is never hidden, so
row 177's behaviour is unchanged. A reader session has no `#owner`, so every user thread's messages are
hidden from it.
`#readable(s, t)` in 205a returns false. Before 193b2's schema 3j there is no `thread_shares` table, so rule 1
(a share in force) can't hold, and the function is false for anyone but the thread's owner (5.7: "b1 hides
every message in another user's thread"). It is written as its own method so 193b2 adds the share rule and
rules 2-4 in one place.
### 3.2 `trail`
- If `subject` is a hidden message id, return `[]`. Design 5.7 says `message-not-found`, "as an unknown id
does", but `trail` on an unknown id returns `[]` today (it has no not-found check). A different code for a
hidden id would confirm the id exists, which is what 5.7 wants to prevent, so I follow its reason over its
code. Question 3.
- After `#trail` collects its rows, drop: `messages` rows that are hidden, `deliveries` rows whose `message`
is hidden, and `events` rows whose `subject` is a hidden message id. This covers each way `#trail` reaches
a message: by id, by `decision`, and through `task.created` → `human.input` → `body.message`.
- Decision rows, decision events, task snapshots, run events and role claims stay. 5.7 puts them on the
governance side.
### 3.3 `replies`
A hidden `id` fails `message-not-found`, and hidden messages are dropped from the result. The `LIMIT`
applies before the filter today; the filter moves into the SQL (`AND NOT EXISTS` a thread owned by another
`user:`) so a page of hidden replies doesn't return short. For 205a that SQL form is exact because
`#readable` is false; 193b2 adds the share exception to the same clause.
### 3.4 Tests (packages/bus/tests/message-filter.test.mjs, new)
Fixtures seed a project, a thread owned by `user:alice` with messages in `message_threads`, a delivery and an
event on one of them, and a decision raised by that thread's run that cites the message. Sessions: the
business human, a reader session and the tester.
- `trail` on a hidden message id: `[]` for all three sessions, the same as an unknown id.
- `trail` on the decision: the decision row and its events show; the user's message, its delivery and its
events don't.
- `trail` on a human-owned thread's message: unchanged (row 177 control).
- `replies` on a hidden id: `message-not-found`. `replies` on a visible id with hidden and visible replies:
only the visible ones, and a full page when hidden replies come first.
- Governance pin, owner side: for the user-thread run, `decisions {list: 'recent'}`, `inbox`, `agents`,
`tasks` and `project.file` each show its item, and `trail` and `replies` hide the user's message body.
Each item in the user sentence is one named assertion.
### 3.5 Mutants
| Mutant | Edit | Named test |
|---|---|---|
| 11 | the `trail` filter removed | trail on the decision hides the user's message, delivery and events |
| 12 | the `replies` filter removed | replies hides other users' thread messages |
| 11b | the hidden-subject early return removed | trail on a hidden message id returns [] |
| 1 | `#readable` returns true | governance pin: trail and replies hide the user body |
## 4. Mutants 1 and 3
Design section 8 lists mutants 1 (rule 1 dropped) and 3 (rule 3 dropped) for b1. With no shares table,
rule 1 is always false, so `#readable` is false whatever rules 2-4 say. Dropping rule 3 changes nothing a
test can see until 193b2 adds shares, and rules 2 and 3 need R3's grant read. So:
- Mutant 1 in 205a is "`#readable` returns true", killed by the pin test above.
- Mutant 3 moves to 193b2, where rule 1 can hold and rule 3 decides.
This is a second brief change for Sage to rule on.
## 5. Gate (Sage's slot, on CLEAR only)
The brief's gate: Duckworth approves the manifest; the bus suite and every `scripts/test-*.sh` green on
Sage's gate; the mutants killed. The new test file whole, the touched existing files whole, then the bus
suite, then each mutant on its named test with a restore check. umask 022, TMPDIR from
`mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`. No test touches the live data root, config, units, slot
files, any live access.json or `~/.mosaic`.
## 6. Questions for Sage
1. Split 205 into 205a (filter, now) and 205b (viewer verbs and board refusals, after R3's candidate)?
2. Mutant 1 as "`#readable` returns true" in 205a, and mutant 3 moved to 193b2?
3. A hidden `trail` subject returns `[]`, the same as an unknown id, in place of 5.7's `message-not-found`
(3.2)? `replies` already fails `message-not-found` on an unknown id, so there the code stays.
## 7. Rulings (sage-2288, re morgana-0063)
1. **Split approved.** 205a is built now: the 5.7 `trail` and `replies` filter over owner, reader and tester
sessions, `#readable` owner-only, the owner-side governance pin test, and mutants 11 and 12. 205b comes
after R3's candidate and R4: viewer verbs, `covers` level checks, board reads refused to users, the
user-side pin, and mutants 6 and 10. 205a's base is origin/refactor 02da6a2ae or later. R3 builds on
205a's candidate and extends the filter to user sessions, so the filter isn't written twice.
2. **Mutant 1** in 205a is "`#readable` returns true", killed by the pin test. **Mutant 3** moves to 193b2
(row 206). Sage changes the brief to match.
3. **Hidden equals unknown.** A hidden `trail` subject returns `[]`, exactly as an unknown id does. `replies`
keeps `message-not-found` for both. The point of 5.7 is that a caller can't tell hidden from unknown.
So 205a adds:
- one test per verb comparing the hidden response with the unknown-id response byte for byte
(`JSON.stringify` of the result, or of the error's code and message), for each of the three sessions;
- a mutant M13 that makes the hidden path distinguishable: `trail` fails `message-not-found` for a
hidden subject. The `trail` equality test must kill it. M14 does the same for `replies`: a hidden id
returns `[]` instead of failing. The `replies` equality test must kill it.
### 7.1 205a mutant table (replaces 3.5)
| Mutant | Edit | Named test |
|---|---|---|
| M1 (design 1) | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user's messages |
| M11 (design 11) | the `trail` filter of hidden rows removed | trail on the decision hides the user's message, its delivery and its events |
| M12 (design 12) | the `replies` filter removed | replies drops other users' thread messages and still fills a page |
| M13 | `trail` fails `message-not-found` for a hidden subject | trail on a hidden id answers exactly as on an unknown id |
| M14 | `replies` returns `[]` for a hidden id | replies on a hidden id answers exactly as on an unknown id |
Queue: Sage re-pins 205 to the split brief and adds 205b and the R3 row at the next gap. I claim after
that. No run before Sage's CLEAR.
+127
View File
@@ -0,0 +1,127 @@
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
Review record: round 2 approved as duckworth-0041 (commit 2a1d17da7) on 48a3fb96e; viewer carry c204bef73 after it.
## What was built
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
atomic write, the lock and the receipt.
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
- `scripts/mosaic`: sends `access` to the CLI.
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
and the limits on peers and subjects.
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
## The lock file (not in PLAN.md; added at Sage's request)
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
read, the check, the temp write and the rename. It is removed in a `finally`.
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
is written.
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
refusal names the path and the pid. A human checks that no write is running and removes the file. This
is the crash case: the holder died mid-write, and a human clears it.
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
holder that died in that gap leaves an empty lock; a human removes it.
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
race below is gone, not accepted.
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
it, and the second rename silently replaces the first. The revision check did not catch it, because both
had read the old revision before either wrote.
Known limits, stated plainly:
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
no reclaim. The refusal says which pid and which path.
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
3. The lock is per access file. It does not cover the bus's own reads.
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
The bytes are never mixed.
Tests for the lock (all in `access.test.mjs`):
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
file, the lock and the receipt are unchanged.
- "the lock is mode 0600 while held and removed after a refused write".
Tests for the write path and the refusals (added in this round):
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
Exit 3, the file path in the message, nothing written.
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
throws if called, and the file is 0664. The refusal must come first.
## The human proof, and the bus behavior behind test 10
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
one function, `proveHuman` in `access.mjs`, for R4 to swap.
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
receipt are unchanged.
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
## Context
`accessContextFor(system, env)`:
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
row 192's candidate).
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
`instances`. A business file that fails to load fails the command.
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
reads `console` through `checkConsole`.
## Mutant list (one per refusal; run on Sage's go, before review)
| # | Mutant | Expected kill |
|---|---|---|
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
| M10 | the lock is not taken | "a live holder's lock refuses" |
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
## Not done in this row
- `--peers` and the federation key: row 203.
- `access.reload`: row R4.
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
@@ -0,0 +1,814 @@
# Row 191 stopRun inspection round
Design: ../DESIGN-r2.md, approved by Dewey in 29473; scope sage-2244;
per-run contract ../STOP-RUN-CONTRACT.md, honker-0079/0081. Not review-ready:
full package/shell/Docker gate and acceptance/guard mutants remain pending.
Current commit 7aa18b0f6e1a09188fb602853ebb02815b642ac9, new branch
rocko/row191-stoprun-dcd8-20261022, parent dcd8d4dda. Worktree
/mnt/storage/scratch/rocko-r191-r3. Earlier inspection f7bf7dfec is preserved.
All 79 manifest paths have identical blobs at f7bf7dfec and 7aa18b0f6;
rebase-product-diff.stat is empty, rebase-product-check.txt names the check.
The manifest SHA-256 stays
9971ac5fe56d0d36eb914ecaf2c282e14ab3cd29860fd27b268ec0baadaf0e52.
Rocko author and committer identity were selected explicitly. No attribution
lines, force-push or branch deletion. Sage publishes through jarvis.
## Changes Dewey must review: two focused-run fixes
1. packages/bus/src/broker.mjs, requestHost: the new processingWork guard was
initially inserted into the inner refusal logger's try block. That let the
original request reach validation, then prevented its refusal being logged
while halted, returning storage-unavailable. Move it to the main admission
try, before keys/credentials/authority/effects. The refusal logger retains
its existing unconditional audit behavior. The new host-admission test now
expects processing-halted and proves neither executor method was called.
Initial evidence: ../logs/stoprun-focused-packages_bus_tests_processing.test.mjs.log.
2. packages/bus/tests/processing.test.mjs, real socket await test: row 196's
asynchronous kernel-peer subprocess now precedes dispatch. The old 5 ms
server deadline could fire during that subprocess, before the processing
await branch extends it. This test already stubs authenticateHuman; give it
a deterministic peerReader too, with read and close, and assert exactly two
proof reads for halt/resume. Keep the 5 ms ordinary timeout and 30 ms halt
delay. Production peer discovery, proof/transport and timeout behavior are
unchanged. The test still fails if the async branch timeout is removed.
The first fixture retry omitted close(); its teardown failed and left its
own server running. Only that verified test group 2831504 was killed. Both
unsuccessful logs remain. Corrected evidence:
../logs/stoprun-focused-bus-processing-fixed-2.log (14/14).
These are diagnosed code/fixture mistakes, not declared flakes. No failed log
was removed and no existing security assertion was weakened.
## Focused evidence, sage-2250
Ran singly on 41b0cf755 because none of row 254's seven paths overlaps ours.
Sage-2265 carries this evidence to dcd8d4dda after the empty 79-path diff.
Scripted busy check exited 0 before the first file. Every test runner used its
own process group, 180 s timeout, scratch TMPDIR
/mnt/storage/scratch/rocko-r191-tmp and DOCKER_HOST=unix:///nonexistent.sock.
There was no Docker, browser, package suite, shell suite or mutant run.
| File | Final passed | Failed | Skipped |
|---|---:|---:|---:|
| processing-control/tests/stop-run.test.mjs | 12 | 0 | 0 |
| processing-control/tests/runtimes.test.mjs | 10 | 0 | 0 |
| processing-control/tests/coordinator.test.mjs | 3 | 0 | 0 |
| processing-control/tests/containers.test.mjs | 6 | 0 | 0 |
| processing-control/tests/effects.test.mjs | 5 | 0 | 0 |
| processing-control/tests/task.test.mjs | 7 | 0 | 0 |
| bus/tests/processing.test.mjs | 14 | 0 | 0 |
| bus/tests/processing-runtime.test.mjs | 2 | 0 | 0 |
| bus/tests/project-runtime.test.mjs | 3 | 0 | 0 |
| Script sum | 62 | 0 | 0 |
All final runners exited 0. The batch stopped at the initial bus failure,
then that file was diagnosed/rerun before the final two files. Counts are from
focused-run-table-final.json and include only each final successful file log.
Earlier bus failures are reported above, not silently included as green.
Approximate run span 23:59Z–00:00:41Z; start second was not recorded.
Cwd-scoped node/chromium sweep was empty at 00:00:41Z; slot returned to Sage.
## stopRun implementation
The export resolves host-owned run identity even without a bound bus claim.
It checks boot/start/group identity immediately before each signal, preserves
verified member anchors, waits 5 s before KILL and observes for 2 s. Signals
are acknowledgements only, never invented. Root/business/run cache joins the
same Promise. Atomic hard-link publication plus durable exclusive claim
coordinates other host processes. First owner/reason/deadline/result persists;
completed retries never re-escalate or overwrite that result. Halt links its
generation to the first fire receipt without relabelling the runtime's stop
cause. A later verified exit is separate immutable observation evidence.
The seam writes no bus events. Honker maps killed/alive to lifecycle events.
No global unit, live config, binding, token, slot file or live store touched.
No schema migration. First activation still requires stopping old processing
before trusted initialization and aligning the copy/image/read-only mount in
Mos's window. Existing result.json evidence remains write-once.
## Integration and outstanding work
The 12 latest-base conflict resolutions are listed in STATUS.md. New landed
host execution, publish and library sync paths are now wrapped in processing
scopes; host credential cancellation composes with its existing revocation
signal. This changes only files already owned by 191. Those wider integrations
still need the full owning-file/package gate and mutant evidence.
Fifteen proposed per-run mutants preflight exact once in stoprun-mutants.json;
none has been applied or run. The broader kill-switch acceptance/guard table
and executable mutant runner are still being completed. No kill count or
review-ready claim is made. Ask Sage for the later CLEAR when the packet is
complete, as sage-2259/2262 requires.
### Gate preparation after the focused slot
The published product implementation is unchanged. Three uncommitted test-file
strengthenings are saved in post-inspection-tests.patch: effects.test.mjs adds a
4-second bounded abort expectation for the watcher mutant; Discord's early
start fence is also checked before a malformed journal can be read; WebUI's
body limit now uses oversized valid JSON, and a direct-handler test isolates
its own origin/site guards from serve's outer fence. These edits are unrun and
are not included in the published 9971ac5f manifest. They must pass before a
new candidate is cut. Thus 62/62 is prior focused evidence, not a claim for
these changed test bytes.
The complete proposed table has 70 exact-once sites in all-mutants.json,
including expected killing test names. Static preflight is clean; no mutant
has been applied. run-mutants.py validates preflight, uses new process groups,
tracks only its own verified descendant identities, kills those groups on
timeout, checks named TAP failures, and restores all source bytes in finally.
A timeout or wrong-test failure is not counted as a valid kill. The table and
runner are prepared evidence, not a kill result. The requested nine-package,
ten-shell, seventy-mutant gate is in gate-plan.json; Sage CLEAR is pending.
## Published test-strengthening commit and full gate (sage-2269/2271)
81889b58d57889b12a584aa225e1ac26e90e5700 is one fast-forward commit on
7aa18b0f6, same branch rocko/row191-stoprun-dcd8-20261022. It commits the
three test changes saved in post-inspection-tests.patch; that patch remains
historical evidence, not an uncommitted candidate. Product implementation is
unchanged. Earlier manifest 9971ac5f belongs to 7aa18b0f6; candidate.txt now
names the manifest for 81889b58d. Sage published this tip before granting CLEAR.
The full gate checks HEAD equality and clean product bytes before its first
file. Changed test files run singly, then nine packages, all ten shell suites,
then seventy mutants. Each step gates on its exit status and retains its full
log. run-gate.sh uses pipefail and an EXIT trap for the label-verified Compose
network rocko-r191-gate_default, project rocko-r191-gate. TMPDIR is
/mnt/storage/scratch/tmp/rocko-r191-gate. Docker is available only for test-task
and test-release; other steps use unix:///nonexistent.sock. Live provider calls
are disabled. No daemon, socket, volume or live configuration changes.
Gate start: 2026-10-22T00:18:00Z. Results remain pending until gate-results.json
and mutant-results.json provide actual exit statuses and counts.
### First full-gate stop and fixture correction (sage-2274)
The 81889b58d run stopped at its first failure, 00:18:00Z–00:19:21Z.
27 files completed: 268 passed, 1 failed, 0 skipped. Packages, shells and
mutants did not start. Full logs/results are in gate-attempt-81889/.
The tester refusal test's fixture requested tester:true over TCP, which row
196 now refuses before route handling. This is a stale fixture, not a flake.
The correction uses the existing startTesterSocket helper, a private fixture
root, socketPath requests, TESTER_ORIGIN and no human identity header. Every
assertion remains; the request now reaches the intended processing refusal
rather than failing listener setup or the outer identity fence. A grep of all
changed test files found no other tester:true TCP setup. The cwd node/chromium
sweep returned []; no Docker test workload ran. Commit c78f48946 is a single
fast-forward test-only fix on 81889b58d, authorized by sage-2274. The complete
gate restarts from its first single file only after Sage publishes this tip.
Sage-2275 published c78f48946 and granted full restart. Second attempt begins
2026-10-22T00:21:03Z; any further failure stops before additional edits.
Candidate manifest b49fd493dc75ef2484ffc255213dc8eeaaaa348dee416c8d26d1c2bab8e20067.
### Second attempt stopped; no further changes (sage-2275)
Run 00:21:03Z–00:23:50Z on published c78f48946: all 27 changed singles
269/269, processing-control 61/61, bus 646/646. The CLI package emitted 26
failed subtests before its verified own runner group 3119310 was stopped.
Its result is interrupted, not a complete package count. Six gate-lane image
checks refuse because scripts/testing/lane-image.mjs pins the old four COPY
lines and 191 adds processing-control to Containerfile. Twenty launch-account,
launch-limit-checks, launch-limits and launch-routes cases hit
processing-uninitialized on fixture roots with no explicit control init.
No edits, retries, later packages, shells, Docker test workloads or mutants
followed this second failure. Parent runner exited; cwd node/chromium sweep
returned [] at 00:23:50Z. Slot returned to Sage at 00:24:05Z with the exact
failures and a request for scope on image-manifest and fixture initialization.
This is an integration failure, not a declared flake or a passing gate.
## Image-input manifest and launcher fixture scope (sage-2278, sage-2282)
Commit eb3abba12 is one fast-forward commit on c78f48946. This is an image-input
manifest change: scripts/testing/lane-image.mjs now pins the exact Containerfile
COPY of packages/processing-control/src/state.mjs and entrypoint.mjs and hashes
both named source paths, without a wildcard. Its only two changed lines add
those inputs. Without them, a lane could reuse a stale image after a reader or
entrypoint change. The image test fixtures copy both files and each has its
own receipt-invalidating test. Mutant KS56 drops state.mjs from the hash list;
its named killer is the state.mjs receipt-invalidating test. Static preflight
is 71 sites / 0 errors. No new run result is claimed before CLEAR.
The launch-account, launch-limit-checks, launch-limits and launch-routes fixtures
now explicitly call initializeControl(f.dataRoot) before constructing their
launchers. Existing assertion lines are byte-identical. A new launch-limit-checks
test leaves initialization off and asserts processing-uninitialized, no
mayLaunch/authorizeLaunch, and no registered session. A live root gets control
initialized by the bus broker. The re-pin order is bus start before any seat
launch; a launcher started before that refuses. This supplements the existing
window precondition to stop old processing before the first trusted init.
Sage-2282 supersedes the test-only image ruling: lane-image.mjs is a product
blob, so mos-1200 carries no earlier results. The complete plan restarts from
the first single file, all nine packages, all ten shells and all 71 mutants
only after publication and Sage CLEAR. Earlier attempts remain in their
separate gate-attempt directories; neither is a passing full gate.
Sage-2283 published eb3abba12. Current 85-path candidate manifest is
01a2b33651414c9b0cb094d2108f9e39c48a41b79974dcc16bdec374f8371091.
The gate prints HEAD and this digest, verifies every manifest blob before its
first test, and carries no old results. Awaiting CLEAR after Gander 253.
## Full restart on eb3abba12 stopped at Discord (sage-2286)
Verified HEAD and all 85 manifest blobs before tests. Run span:
2026-10-22T00:34:50Z–00:47:33Z. No old result carried.
| Step | Passed | Failed | Skipped | Exit |
|---|---:|---:|---:|---:|
| 32 changed test files singly | 304 | 0 | 0 | 0 |
| processing-control | 61 | 0 | 0 | 0 |
| bus | 646 | 0 | 0 | 0 |
| cli | 761 | 0 | 0 | 0 |
| discord | 190 | 2 | 0 | 1 |
Singles sum independently confirmed by awk over all 32 TAP logs. Discord's
binding.test.mjs:201 STOP check and recover.test.mjs:145 ready recovery check
both get processing-uninitialized before the expected behavior. Those older
fixture roots do not explicitly initialize control. The gate stopped at this
step; no subsequent package, shell suite, Docker test workload or mutant ran.
Docker availability was checked read-only (29.7.2); no daemon/volume changes.
The EXIT trap completed. Cwd node/chromium sweep returned [] at 00:47:49Z,
source tree remains clean, and the slot was handed back at 00:47:58Z.
No test or product edits or retries followed this failure; Sage has the table
and the fixture-initialization recommendation. This is not a green full gate.
## Discord fixture init and all-package static sweep (sage-2294)
Commit e6800380dc0765ce3f1477044e2ce4f773ebb43c is test-only and fast-forwards eb3abba12.
Only binding.test.mjs and recover.test.mjs change: one initializeControl import
and one initializeControl(dep.dataRoot) call in each affected CLI fixture.
Every original assertion line is retained. Product blobs are unchanged.
FIXTURE-SWEEP.md and fixture-sweep.json enumerate 338 source call-site hits,
with the two fixes and the already-initialized chains, plus the screens for
rootless optional contexts and read-only/usage CLI calls. The generator reads
repository source only and invokes no fixture. Deliberate missing/deleted/
corrupt-state refusal tests remain negative; no shared helper masks them.
Current manifest be36af8f0855f57336e6a33283aa0a8e94297f338b1f8180936cbccf1119c60a, 87 paths. Sage-2294 carries only unchanged
processing-control 61/61, bus 646/646, CLI 761/761 and unchanged singles from
gate-attempt-eb3/. The resume must run both touched files wholly, whole Discord,
then the never-run WebUI/tasks/seat/project/library packages, every shell suite
and all 71 mutants. No result is claimed for the new test bytes before CLEAR.
The live initialization order remains bus start before any seat or Discord
start; no fixture change initializes or repairs a live root.
## Resume on e6800380d stopped at WebUI (sage-2311)
Start 2026-10-22T01:27:40Z; failed-step exit 01:28:45Z. Startup verified HEAD,
manifest be36af8f and all 87 blobs, and read-only Docker availability (29.7.2).
| New step | Passed | Failed | Skipped | Exit |
|---|---:|---:|---:|---:|
| binding.test.mjs whole | 15 | 0 | 0 | 0 |
| recover.test.mjs whole | 5 | 0 | 0 | 0 |
| Discord whole | 192 | 0 | 0 | 0 |
| WebUI whole | 314 | 4 | 1 | 1 |
Carried unchanged under sage-2294: 32 singles 304/304, processing-control 61/61,
bus 646/646 and CLI 761/761, from gate-attempt-eb3/. These are explicitly carried
results, not fresh runs on e6800380d.
Four WebUI failures are retained in gate-logs/package-webui.log:
- board-held.test.mjs:91: processing's config/state GETs omit redirect:manual.
- board-held.test.mjs:194 and send-browser.test.mjs:58: processing's independent
hash/focus/interval refresh continues to read after the shell signs out.
- shell.test.mjs:104: the added control bar makes the short desktop page 1095px
high at a 1000px viewport. Its new height is not accounted for by the existing
frame/sidebar/footer layout.
Static diagnosis: processing.js ignores the existing body.signed-out state and
mosaic-signed-out event that app.js and bus.js already share. Its GETs also lack
manual redirect handling. processing.css adds an external bar while shell.css's
frame minimum and sidebar heights still subtract only bar-h and foot-h. These
are product integration defects; no assertions were weakened and no fix was
made after the failed step. Sage received the table and recommendations.
One TAP skip: the editor rebuild requires exactly esbuild 0.28.2; installed
shared node_modules has 0.28.1. Full skip line is in tap-skips.json. No shell
suite ran, so there are no ^skip shell lines (skip-lines.txt remains empty).
No tasks/seat/project/library suite, shell Docker workload or mutant ran after
WebUI failed. Parent runner exited, EXIT cleanup completed, cwd node/chromium
sweep returned [] at 01:29:00Z, source tree is clean, and slot returned to Sage.
No retries or source edits were made. This is not a passing gate.
## Console shared sign-out and bounded desktop layout (sage-2317)
Commit 8e1f55c1db6a096e227b5114cd13a7142493b112, fast-forward on e6800380d, changes only the
processing assets and their owning two tests. Current 87-path manifest:
d3b014e6a0e9e547a29ae780eb27b1c0e57cc7b0e73dc49954abbff623929f41.
All three processing fetch sites use redirect:manual. responseBody dispatches
the existing mosaic-signed-out event for an opaque redirect, or its signIn
variant for exactly 401/sign-in required. No independent sign-out state is
added: refresh/action guards read body.signed-out. The existing event cancels
the interval, invalidates pending reads, clears stale state and disables
writes. In-flight answers cannot restore state after sign-out. Initial work
waits for DOMContentLoaded because this deferred asset precedes app.js in the
HTML and needs its shared sign-out listener registered first. app.js/bus.js
and their proof baselines remain untouched.
The CSS keeps the control bar in normal document flow. On desktop, the frame
flexes into the remaining viewport rather than imposing another full-height
minimum below the bar. Fixed navigation retains the shell's command/footer
bounds and existing responsive column widths. Mobile retains natural flow.
No short-page, sidebar or footer assertion is changed; rendering remains to
be verified on the next authorized gate, not claimed from static checks.
The VM fixture models the existing body class, document events and DOM ready;
its original assertion lines are unchanged. Six new browser cases cover config,
state and POST opaque redirects and sign-in-required replies, manual redirect
requests, interval cancellation and no subsequent fetch from forced refreshes
or clicks. KS57 removes manual redirect from the config GET; KS58 removes the
refresh signed-out guard. Both name the config-redirect browser case as their
killer. Static preflight: 73 exact-once sites / 0 errors. Syntax checks pass;
no owning test or mutant has run on this commit.
A product blob changes, so sage-2317/mos-1200 carries nothing. The next CLEAR
runs all changed files singly, all nine package suites, all ten shell suites
with Docker available and all 73 mutants. The editor rebuild's esbuild0.28.1
vs pinned0.28.2 skip remains host drift and is reported separately. Earlier
failed attempts remain immutable in gate-attempt-* directories.
## Rebase onto row 202 (sage-2329)
New branch rocko/row191-stoprun-ddfb-20261022, tip eafcbd3e7d6ef6e259fb59d87a7b0857b42337aa,
base ddfb29af7. The prior published branch is preserved; no force-push, delete
or old-name reuse. Six Rocko commits rebased, with one manual conflict in
scripts/mosaic's human CLI case: prepend halt|resume|processing and retain
row 202's appended access. packages/cli/src/cli.mjs auto-merges retain the
access import/branch and all three processing commands. CLI README retains
both the Access and Instance processing switch sections.
The full old/new tip diff has 15 paths (base plans/queue/logs, row 202's new
access files and its three shared CLI paths), 2327 insertions/32 deletions.
Within row 191's owned paths only CLI README, cli.mjs and scripts/mosaic differ,
with the row 202 additions. rebase-ddfb-log.txt, rebase-ddfb-tip-diff.stat,
rebase-ddfb-owned-diff.stat and rebase-ddfb-range-diff.txt retain exact evidence.
Manifest fd0f2740b1cccadaaad8c613640fdddb0497cdab33ec2c0fd11debf3cae6c4ee, 87 candidate paths. The package command lists were
regenerated from this base; CLI includes access.test.mjs. All 73 mutant sites
must preflight again. No tests ran, no result carries, and the complete gate
waits for publication and Sage CLEAR after Megavolt's debug run.
Sage-2333 published eafcbd3e7. Their per-file patch-id comparison matches
the prior six-commit range except the exactly ruled scripts/mosaic dispatch
line; binary/secrets/attribution checks pass. Gate remains unrun pending CLEAR.
## eafcbd3e7 full gate stopped on timer fixture attribution (sage-2339)
Run 2026-10-22T02:18:04Z–02:19:25Z. HEAD, 87 blobs and fd0f2740 manifest
verified. Short mktemp TMPDIR /mnt/storage/scratch/tmp/gxbC; named Compose
project rocko-r191-gate. Nothing carried. 32 changed files completed:
313 passed, 6 failed, 0 skipped. All six new processing-browser sign-out
cases stop at their common precondition, processingTimers length2 vs1.
The original two browser cases pass. Their recorder attributes every 5000ms
interval to processing, but app.js:307 already arms its own freshness timer.
The intended processing interval is at processing.js:87. These new cases have
not yet exercised sign-out or killed KS57/KS58, so no behavior proof is claimed.
The gate stopped at this first red step. No package, shell, Docker workload
or mutant ran; the esbuild rebuild was not reached. Runner exited, EXIT cleanup
completed and cwd node/chromium sweep returned [] at 02:19:34Z. No source edit
or retry followed the failure. Sage received the exact failure and a proposed
test-only caller-attribution filter with the assertions unchanged. This is a
fixture error, not a passing gate or a declared flake.
## Timer recorder attribution correction (sage-2345)
Commit 4db8b57785895bc62f5f015468a69515da7fc77f, fast-forward on eafcbd3e7, is a one-line
test-only correction in processing-browser.test.mjs. Cause: app.js:307's
freshness interval is also 5000ms, and the recorder incorrectly counted it as
a processing interval. The recorder now requires the processing.js caller
stack. Every assertion remains byte-identical, including exactly one timer
before sign-out and zero after it. A second 5000ms interval from processing.js
would still be counted and fail the exactly-one assertion. No product byte
changes, and no test or mutant ran for this correction.
Manifest ffb46fbdb875f97ff2eb7a7bed8bf6d5c4df53b8a63c738ffcde5fc5f6b7f6ad, 87 paths. Full restart after publication and CLEAR,
with nothing carried. Sage-2345 counts the timer-fixture red as the first in
this 191 window; any second red stops and is escalated to Mos. Previous logs
are retained in gate-attempt-eaf/.
## Full gate on 0f8235277: second red (sage-2351)
Candidate: 0f8235277e1fc21588edb6f2931d66c8e154a400 on ddfb29af7.
Manifest: ffb46fbdb875f97ff2eb7a7bed8bf6d5c4df53b8a63c738ffcde5fc5f6b7f6ad.
No prior result carried. Start 2026-10-22T02:33:41Z; failed-step exit
2026-10-22T02:46:17Z. Fresh TMPDIR /mnt/storage/scratch/tmp/giNF.
Compose project rocko-r191-gate, trapped ownership-checked network cleanup.
| Step | Passed | Failed | Skipped | Exit |
|---|---:|---:|---:|---:|
| 34 changed test files singly | 330 | 0 | 0 | 0 |
| processing-control | 61 | 0 | 0 | 0 |
| bus | 646 | 0 | 0 | 0 |
| cli (including access) | 783 | 0 | 0 | 0 |
| discord | 192 | 0 | 0 | 0 |
| webui | 323 | 1 | 1 | 1 |
| tasks, seat, project, library | not run | — | — | — |
| all ten scripts/test-*.sh | not run | — | — | — |
| all 73 mutants | not run | — | — | — |
Singles sum independently confirmed with awk over all 34 TAP logs.
The sole failure is shell.test.mjs:380 in the full shell-browser test:
scrollHeight 1062 vs innerHeight 1000. Full evidence is
gate-logs/package-webui.log (not ok 271, line 1626).
All prior manual-redirect/sign-out failures and eight processing-browser cases
pass, but the desktop short-page geometry is still wrong. No passing layout
or full-gate claim is made.
Known host-drift TAP skip: editor rebuild requires exactly esbuild 0.28.2,
while shared node_modules has 0.28.1. Full line is in tap-skips.json. This skip
is not counted as a red. No shell suite ran, so skip-lines.txt has no ^skip
shell line. No Docker test workload or mutant started.
Stopped at the first failed step without retry or product edits, per sage-2351.
This is the second red in the 191 window and is with Sage for Mos escalation
under mos-0823. Parent runner exited; EXIT cleanup completed. Cwd-scoped node
and chromium sweep returned [] at 02:47:14Z, product worktree is clean, and the
slot was returned. No in-review move was made.
### mos-1248 / sage-2363: desktop viewport allocation (unrun static candidate)
The second red on 0f8235277 was shell.test.mjs:380 (1062 versus 1000).
The shell gives .frame a viewport-based minimum at packages/webui/src/public/shell.css:135;
processing.css:12-14 replaced that minimum but used a column flex body with only
min-height:100vh, so the new bar was not allocated a bounded share of the viewport.
The desktop body now uses explicit grid rows for the command bar, controls, optional
banner, frame and footer. The frame row is minmax(min-content,1fr): it takes the
remaining short-page space while long content may grow the document. No clipping,
overflow suppression, fixed body height, test viewport change or assertion removal.
The existing shell.test.mjs:380 equality stays byte-identical. Added assertions
check actual processing-bar visibility, positive height, viewport placement and
no document scroll at 1440x1000; the existing 1440x420 long-page checks remain.
KS59 reverts processing.css to 0f8235277's bytes and must fail that same shell
browser test. Total: 74 planned mutants. No test or mutant has run on this fix.
Webby must statically review this delta before Sage opens the full no-carry gate.
### Correction after Webby B1 (webby-0104, 2026-10-22 03:00Z)
The cause stated in the mos-1248/sage-2363 note above is withdrawn.
processing.css loads after shell.css and already overrides the frame minimum.
With the tree fixed and inspector hidden, the remaining floor is the Board's
main content. The grid min-content row preserves that floor, so 5cd32f4c1
is not an accepted layout fix. Its static review is CHANGES at Webby's
REVIEW-layout.md, commit e2e7c60e5. No run established a new layout measurement.
Next step requires a granted fixture probe for main/control heights and an
owner layout ruling; no further product change or gate is authorized yet.
Existing shell assertions remain unchanged.
### Static nav placement: mos-1249/mos-1250, sage-2383/2385/2386, webby-0106/0108
Fast-forward on 5cd32f4c1; the rejected grid is removed. The unchanged labeled
region is now the first child of nav.tree, before its Console h2. The banner
stays at its original body location with every id, role, label and shipped
string unchanged. No processing.js change and no runtime reparenting.
At >=760 the tree is the same fixed box, with the same width breakpoints and
ordinary overflow:auto. The controls are non-shrinking and non-sticky; they
scroll with overflowing nav links (sage-2386 withdraws persistent visibility).
Below760 the region takes a full flex line before Console/Projects. Its mobile
location changes from before .frame to first in the static nav; the banner
therefore precedes it in a halted mobile view. Banner behavior itself is unchanged.
Specificity (0,3,0) resets .btn margin/order and .small margin/basis, leaving
the shell rules unchanged. No content is shortened and no overflow is hidden.
Correct height arithmetic (Webby B1): page=max(innerHeight,86+C), where C is
main content height. Controls consume space within the existing nav box, not
another body row. The old failing layout was max(innerHeight,86+barHeight+C).
The halted banner remains a body row; Sage tracks that separately.
Candidate-test design change at processing-ui.test.mjs:67 under mos-1250.
Old assertion:
```js
assert.ok(html.indexOf('id="processing-controls"') < html.indexOf('<div class="frame">'));
```
New assertion text:
```js
const controlsAt = html.indexOf('id="processing-controls"');
const mainAt = html.indexOf('<main id="main"'), inspectorAt = html.indexOf('<aside class="inspector"');
assert.ok(controlsAt >= 0 && controlsAt < mainAt, 'controls are outside and before #main');
assert.ok(!(inspectorAt < controlsAt && controlsAt < html.indexOf('</aside>', inspectorAt)),
'controls are outside aside.inspector');
```
Reason: the bar moves into the frame's first column under mos-1249 while
remaining outside route-specific #main and aside.inspector. This is a change
to our unlanded candidate assertion, not a landed assertion.
All existing shell assertions stay unchanged. Added bar-inside-tree assertion
and diagnostic height output. New processing browser coverage checks 400px
Stop order=0, visibility, focus and position above Projects; also on-load
visibility/focus at1440x1000,1440x420,800x420. Debug logs report controls/tree
heights at those desktop sizes and main height; Sage judges controls over half
the 334px nav at1440x420 before the window. No run yet.
Mutant delta: KS59 now reverts the complete layout (HTML and CSS together);
KS60 hides the controls; KS61 moves them into #main; KS62 drops the (0,3,0)
button reset. Total77. Every new site has its named killing test.
### sage-2394 debug on published da4334806 (2026-10-22)
03:14:37Z–03:14:53Z: shell.test.mjs whole 5/5, processing-browser.test.mjs
whole 9/9, 0 failures/skips. awk sum14/0/0. Original shell short-page and
long-page assertions pass unchanged. Logs/results and empty cwd sweep are in
debug-da4334806/. Slot clear sent Sage/Webby03:15:11Z after sweep.
Controls/tree/main heights: read-only shell1440x1000 212.5/914/914; writable
processing fixture1440x1000 127/914/914;1440x420 127/334/907.734375;800x420
127/334/1131.65625. Stop visible/order0/focusable/aboveProjects at400x1000.
Writable127 is below half the334 nav. The read-only212.5 height at1000 was
flagged for Sage's usability call; that state was not measured at420, so no
claim is made that all states use less than half the short nav.
No mutants or full gate run under this debug grant. Await separate CLEAR.
### R6 accepted: sage-2399 / webby-0111 (2026-10-22 03:16Z)
Sage accepts the short-nav usability tradeoff. Writable controls occupy38%
of the334px nav at1440x420. The read-only recovery note makes that about64%,
inferred from the measured212.5px height at the same width. The region is
non-sticky and scrolls away; Projects was already below the fold at that size
before191. Webby confirmed the debug TAP and has no window blocker. Sage
tracks the read-only note length with the halt-banner follow-up; no change
to this row.
Do not rebase or run now. Row273 lands first. On Sage's landing message,
rebase the candidate including the two latest layout commits onto the new
refactor under a new branch name (mos-1086), then provide range-diff,
patch-ids and the refreshed88-product-path manifest. The full no-carry
window requires a separate Sage CLEAR on that published tip.
### Rebase after row273: sage-2402 (2026-10-22 03:42Z)
New branch rocko/row191-stoprun-86f9-20261022, tip
0fa7bf421fc153a226b5f06ed484e089e48e23ba, on86f95bb27. All10 commits
rebase cleanly and range-diff pairs each with '='; no conflict resolution.
All88 per-file patch-ids match the old candidate. Whole product diff stable
patch-id before/after:0846823aefe165363601a6c68134ad46cc701c73.
87 product blobs are identical; only docs/TOOLS.md changes because the new
base carries row273's --send/--no-sso usage and three explanatory F39 lines.
191's TOOLS hunks stay unchanged. New88-path manifestSHA256:
0bcbf337433f6d46d809ee1dcaf8a5a8503160403d273cf73d6dac64adef3c02.
Range-diff, per-path patch-ids and TOOLS comparison are rebase-86f9-* files.
Static77-site exact-once preflight has0errors. No tests run, nothing carried
into the required full window; await Sage publication and separate CLEAR.
### Full no-carry window on0fa7bf421: stopped at SR08 (sage-2407/2418)
03:44:56Z–04:01:29Z. All35touched files passed336/0/0; nine packages
passed2403/0 with1known editor/esbuild skip. Every shell exited0:425selftests
plus156queue node tests passed; the canonical-root queue check and two
explicit live-provider exclusions are saved in skip-lines.txt. See
GATE-RESULTS.md for per-suite counts and mutant table.
SR01–SR07 killed. SR08 timed out at90seconds without named assertion failure
(TAP header only); runner restored all88candidate blobs and stopped. The69
later mutants did not run. SR08 is not counted as killed. Cwd sweep[] and
owned network cleanup completed; slot clear sent Sage04:02:12Z.
Saved mutation, timeout policy lines64–89 and shell/skip table sent Sage in
response to sage-2418. No source edit or retry; no in-review move. Await
Mos ruling and explicit Sage CLEAR before any control/replay/resume.
### SR08 timeout policy: mos-1259 / sage-2423 (2026-10-22 04:07Z)
Cause confirmed by Mos: runtimes.mjs:333 loops on now() <= deadline, while
stop-run.test.mjs:106-108 gives the second host a fixed fake clock with real
sleep. Removing the join branch enters the stop loop without advancing that
clock. Original SR08 log contains TAP header only; original timeout record
stays immutable. Mos permits separate classification as killed-by-timeout
only after the unmutated same whole test file passes the same90second
harness in this resumed run. **Control result: not run; awaiting CLEAR.**
No assertion kill is claimed for SR08.
Runner review requested from Dewey, Sage copied. SHA256 of run-mutants.py:
fe0d360f86d86ab2df967da5338479c593c27c88d06eb38a1389d99a08489369. Diff:runner-mos1259.diff; baseline:
run-mutants.before-mos1259.py. Shared execution harness uses90seconds, own
process groups, verified descendant cleanup and restored source bytes. A
control must pass every test with exit0, no not-ok/skip/cancel/fail. Its record
binds invocation id, candidate HEAD/manifest and test hash; all88candidate
blobs verified before and after control, and before each mutation. The
approved historical SR08 classification and69-site SR09+resume use separate
files so the original seven kills and timeout evidence remain intact.
Assertion kills and timeout kills have separate counters. No control for the
same file in this invocation means a timeout stops; survivors and invalid
failures stop too. Every timeout gets a cause/control record before review.
No product/test change, no control/mutant execution. Wait for independent
runner approval and Sage CLEAR. Green suites carry only on unchanged0fa7bf421
and manifest0bcbf337. Test clock fix is deferred to Vikunja502 as Mos ruled.
### Independent runner approval: dewey-0789 (2026-10-22 04:11:20Z)
Dewey APPROVES the frozen fe0d360f86d86ab2df967da5338479c593c27c88d06eb38a1389d99a08489369
runner, static only. Use only the proposed --control-file stop-run.test.mjs
--adjudicate-timeout SR08 --resume-from SR09 invocation. A positional-id or
full run would overwrite the original evidence and is not this resume.
Report the complete tally as7 prior assertion kills (old status killed),
plus SR08 separately adjudicated by timeout after control passes, plus69
resumed sites. The summary file counts only this invocation. No control or
resume result exists yet; Sage CLEAR remains required.
Classification change: the reviewed runner recognizes indented not-ok lines
with ^\s* rather than only top-level lines. Controls therefore reject nested
failures too, and mutant assertion kills still require the named pattern in
a failing test name. SR01–SR07 evidence was classified by the original runner.
Historical SR08 has no HEAD field. Its binding to0fa7bf421 relies on the saved
commit.txt/log timestamps and the new candidate HEAD+88blob check, not a
retroactively invented HEAD field in the old result. Preserve this provenance
in the adjudication report. Any newly detected timeout needs a concrete cause
before review; the generic placeholder is not accepted review evidence.
### Control and resume: sage-2427, stopped on KS43
Control/resume began04:13:44Z; last saved mutant result2026-10-22T04:14:08Z; process
absence confirmed04:14:56Z and slot returned to Sage. Whole unmutated
stop-run.test.mjs control passed12/12, exit0, no not-ok, no skips/cancels,
in0.459seconds. The reviewed runner adjudicated original SR08 separately
as killed-by-timeout under mos-1259. The immutable original SR08 row/log
stay unchanged; adjudication binds the control, exact cause, current HEAD
and manifest. Historical HEAD provenance remains as Dewey N4 described.
Resume stopped at KS43, not retried. It removes the inner processingHTTP
origin check and its selected outer-route test passed1/1(exit0). Saved
evidence:resume-mutant-logs/KS43.tap and resume-mutant-results.json.
Tally:7prior assertion kills +49resumed assertion kills =56assertion kills;
1historical timeout kill;1survivor(KS43);19unrun. No new timeouts or placeholder
causes. All88candidate blobs restored and resume cwd sweep[]; no product or
test change. No in-review move. Sage and Mos notified directly.
Static diagnosis only: KS43 selects processing.test.mjs:52's outer-server
fence test. The unchanged direct-handler test at:95 already isolates this
origin guard from serve's outer refusal. Its pattern was not selected. Sent
recommendation to Sage/Mos to correct only the mutant's named killer after
their ruling, then run KS43+19remaining. No metadata edit or rerun authorized
yet; no claim the direct-handler test kills it until the run proves it.
### KS43 metadata retarget: mos-1261 (2026-10-22 04:16:41Z)
Original pattern `processing write fences refuse` selected processing.test:52.
KS43 survived with1passing test and no writer call, preserved in
resume-mutant-results.json and resume-mutant-logs/KS43.tap. The serve-level
test cannot isolate the handler guard: outer fences and later refusal can
mask its removal. No product defect or new kill is claimed from static read.
New pattern: `processing handler independently refuses wrong origin and cross-site before its writer`,
the existing direct processingHTTP test at:95-107 with valid user/site/content
headers, asserted403 and no writer call. Only KS43's pattern changed in the
77-site table; product/test/mutation bytes unchanged. Generator mirrors it.
TableSHA256:5dbc8308c995e1974d6da63b3daa967896d822154eab38a8bb96154be667edf5. Diff:mutant-table-mos1261.diff,
baseline:all-mutants.before-mos1261.json. Dewey must independently review
this metadata diff before the next run. Static audit of all19unrun sites:
mutant-pattern-audit-mos1261.md; no other serve-level/direct-handler mismatch
with an existing direct test warrants retargeting. KS44 body-bound case has
no direct-handler counterpart; its oversized whitespace+{} case is valid
JSON without the bound, so it retains the existing pattern.
Carry56assertion kills+1timeout kill and green suites only on unchanged
0fa7bf421. Next approved scope is KS43+19sites after review and Sage CLEAR.
No run authorized by this metadata change.
### Retarget review APPROVE and C1 archive: dewey-0791 (2026-10-22 04:19:55Z)
Dewey approves table5dbc8308 and the19-site audit, conditional on preserving
the first resume evidence before any next run. Archive:mos-1261-archive/,
full per-file inventory:mos-1261-archive/sha256-manifest.txt.
- `resume-mutant-results.json` SHA256 `f5725ef26cdb488441285081fadaa9ce4a3e7d390ab2a1c6d04acfd27f105fc6`
- `resume-mutant-logs/KS43.tap` SHA256 `c778ee33edb667f795ab560a328fd5c1c04ef6acf99d7b79c364f6302cf29864`
- `unmutated-control.json` SHA256 `ee9ddcd69d24276165abe1185f159a2737157fcae8fd37f731768e9324b765e5`
- `SR08-timeout-adjudication.json` SHA256 `214a04696d3329ef67bf10578daf4d251599816fd246bc31abecae1a6230e1e0`
The50-row result, all first-resume logs and control/adjudication records
were copied byte-for-byte. Original paths remain untouched too. A separate
retarget-mos1261/ directory holds byte-identical approved runnerfe0d360f,
reviewed table5dbc8308 and identical candidate/commit files. The proposed
--resume-from KS43 invocation there selects20sites and writes its own logs
and results without overwriting either original or archive. No control flag
is needed for these tests; any timeout without a same-file control stops.
Final tally will combine7old assertionkills +49first-resume assertionkills +
SR08separate timeout adjudication +20retarget results. Still no run; awaiting
Sage CLEAR. Product/test bytes remain unchanged.
### KS43 retarget run: sage-2436, stopped on KS44 invalid mutation
Run began04:21:06Z; KS43 is killed by the named direct-handler assertion.
KS44 then fails import with SyntaxError Unexpected token catch at
processing-http.mjs:40. The failing TAP name is the test file, not the
selected test, so runner records invalid-failure and stops without a kill.
Evidence remains in retarget-mos1261/console.log,resume-mutant-results.json
and resume-mutant-logs/KS43.tap,KS44.tap. No original evidence overwritten.
Cause: our KS44 mutation uses `// no body bound` to replace an inline
`if (size > 1024) throw Error();` in a one-line for-await body. The comment
swallows the remaining chunks.push and closing brace. This is malformed
mutation metadata, not an observed product failure. No metadata correction
or retry yet. Proposed to Sage/Mos: replace only the new text with `void 0;`,
then independent review and a new CLEAR for KS44+18 on unchanged candidate.
Current tally57assertionkills (7+49+1),1SR08timeoutkill,1invalid(KS44),18unrun.
Cwd sweep[] and all88candidate hashes restored04:21:30Z; slot returned and
Sage/Mos notified. No in-review move. Wait for ruling before any edit or run.
### KS44 syntax metadata correction: mos-1264 (2026-10-22 04:22:16Z)
Only KS44.new changes from `// no body bound` to `void 0;`. Old site,
test and pattern stay unchanged. The invalid import result remains in
retarget-mos1261/resume-mutant-results.json and resume-mutant-logs/KS44.tap.
TableSHA256:85d9a7b13ad510e840f258d892afbfce393cefbc4006e1f808605b83adc84570. Diff:mutant-table-mos1264.diff;
baseline:all-mutants.before-mos1264.json. Generator mirrors the one field.
Authorized syntax-class preflight applied KS44–KS62 individually in scratch,
ran node --check on each JavaScript file, and restored before advancing.
All15JS checks passed; no other replacement fix was needed. KS59–KS62
change only HTML/CSS and are explicitly asset-only/not-applicable for the
Node parser; Mos/Sage informed, no false JS syntax pass claimed. All19
mutations restored and88candidate hashes match. Per-site logs/results and
restoration record:syntax-mos1264/. No behavioral test or assertion-mutant
run occurred. Dewey must review table diff and syntax results before next
Sage CLEAR for KS44+18. Carry57assertionkills+1timeoutkill only on unchanged
0fa7bf421. No product or test change.
### dewey-0795 approval: syntaxfixed-mos1264 staged (unrun)
Dewey APPROVES table85d9a7b1 and syntax preflight, static only. Conditions
met:syntaxfixed-mos1264/ holds byte copies of runnerfe0d360f, table85d9a7b1,
commit.txt,candidate.txt,candidate-manifest.sha256. Full SHA256 and byte-cmp
list is syntaxfixed-mos1264/byte-copy-check.txt. Invocation after Sage CLEAR:
python3 syntaxfixed-mos1264/run-mutants.py --resume-from KS44 (19sites).
All previous invalid/survivor evidence stays untouched in its original dirs.
No behavioral run occurred during preparation.
### Final19 resume stopped at KS54: sage-2444 (2026-10-22)
Run began04:27:45Z; KS44–KS53 are10named assertion kills. KS54 then timed out
at90.073seconds. Its log contains the expected dependency-list assertion:
"the runtime refuses an invalid adapter and closes a valid one" at
bus/tests/tasks.test.mjs:263. The mutation drops processing from runtime.mjs
tasks factory deps. The test opens runtime then asserts before close at:265;
a failing assertion skips the close and leaves Node alive. No same-file
control ran in this invocation, so this is timeout-without-control, not a
kill. No assertion or timeout kill is claimed for KS54.
Raw resume result's generic cause sentence says a control passed, which is
incorrect: its control:null and timeout-without-control status establish the
opposite. The raw evidence remains unchanged; the concrete cause and
correction are in syntaxfixed-mos1264/KS54-timeout-report.json. This explicit
report replaces the placeholder as explanation without altering the log.
Current tally67assertionkills(7+49+1+10),1adjudicatedSR08timeoutkill,
1unqualifiedKS54timeout,8unrun. All88candidate blobs restored; cwd sweep[]
and slot returned04:30:06Z to Sage, Mos directly copied as instructed. No
retry, product/test edit or in-review move. Sage's disclosed syntaxcheck
caused no busy refusal or foreign-node report. Await owner ruling/CLEAR.
### Final controls/mutants GREEN: mos-1267, sage-2448, dewey-0798
On unchanged0fa7bf421, whole-file tasks control9/9 passed in0.815s; KS54
rerun killed-by-timeout with the exact dependency not-ok at tasks.test:263.
KS55–KS62 are8assertionkills. Final run04:35:32Z–04:37:17Z, cwd sweep[] and
88hashrestore04:37:48Z; slot returned to Sage and direct handoff to Dewey/Mos.
Final77-site tally:75assertionkills(7+49+1+10+8),2separate timeoutkills
(SR08adjudication,KS54control+rerun). final-mutant-results.json consolidates
latest accepted evidence by id; no prior record or log overwritten.
GATE-RESULTS.md is the final per-suite/mutant table; prior narrative saved
as GATE-RESULTS-before-final.md.
KS54 raw placeholder result saved as resume-mutant-results.raw.json before
filling concrete cause in its review result. Same-invocation control id
1792643732624962173; prior syntaxfixed eleven-row result file remains exact
0ede036954ff77c21f29e1a744b21d1d9b18eb75736f78ecd1cf4ae559358911.
Dewey independently read logs/control/restoration and confirmed tally in
dewey-0798, explicitly not yet a manifest verdict. N6test cleanup stays
deferred under mos-1267/Vikunja502; no candidate byte changes permitted.
### Claimant review handoff
Queue move rocko-191-review-r1 entered in-review at revision3114, round1
on#1689, manifest0bcbf337. Revision3115 records the expected missing-token
request failure; command exit1. Receipt:in-review-receipt.txt. Sage posts
the request as jarvis; Dewey owns the manifest verdict. Candidate source
remains published0fa7bf421 with88product paths, base86f95bb27. No live
action, push, source change or further gate authorized by this handoff.
+83
View File
@@ -0,0 +1,83 @@
# Row 264 (T1 item 7 sessions, #1769): Sage's rulings on the phase A questions
Sage, 2026-10-21T23:34Z. These answer Q1-Q7 in `agents/gizmoduck/work/queue-264/BUILD.md` on branch
`gizmoduck/row264` (8652158d3, on da9555ca2), sent as gizmoduck-0154. The brief is
`docs/plans/2026-10-21_t1-sessions.md` at blob ff0be0c79 (design r3, Mos's rulings mos-1235 in section 8).
None of these rulings changes what a requirement allows. They set order, labels and integration points the
brief leaves open.
## Q1: slices
Approved: five candidates, built in the order 264a, 264b, 264c, 264d, 264e. Each gets its own manifest,
its own Duckworth round and its own landing. Row 264 carries 264a. I add one row each for 264b-264e,
with a Gitea issue, in the next slot gap. Their `after` lists:
- 264b after 264a.
- 264c after 264b. It reads the per-session store, so it can't land before the store exists.
- 264d after 264b and row 194 (see Q4).
- 264e after 264a. Its clear and count parts switch on as 264c and 264d land. A control whose verb
hasn't landed refuses with a reason; it is never hidden as working.
All five are MVP. TARGET.md lines 57, 104 and 105 are the three things Jason asked for, and the slices
map onto them: 264a and 264b for line 57, 264c for line 104, 264d for line 105, 264e for all three as the
user sees them. None of them is cut by this ruling. Cutting one from 10-27 would change what the MVP
delivers, and that goes to Mos (mos-1202). I report the schedule risk on the standup, not as a question.
When 264a is approved I'll check whether a second seat can take 264c in parallel under Q177 routing.
## Q2: schema label
The label goes by landing order, not by row. Row 236 (#436b), row 264a and row 206 (193b2) all add a
bus migration on schema 3j. The first of the three to land takes 3j-3k. The next one rebases and takes
the next label, and so on. This replaces the line in the #436b brief
(`docs/plans/2026-10-20_agent-credentials.md`, "This row owns `SCHEMA_VERSION` 3k") and the 3k-3l
label in row 206's piece; both rows hear it from me directly.
Build 264a as `3j-3k.sql` now. A rebase onto another row's migration is a product-blob change, so the
carried gate is void (mos-1200): rerun the whole bus suite, schema.test.mjs with its step-count pins, and
the T46 restore-guard table lists. Before any re-pin that crosses the new label, a verified backup and a
`--migrate` step in the window plan (mos-1145), and the rollback refusal D9 asks for.
## Q3: PRD
Nothing to wait for. PRD 1.13 has landed on refactor with both replacements:
`docs/prd/mosaic-stack.md` REQ-ROLE-2 (assistant sessions, one holder each, the claim names organization,
thread, instance and session) and REQ-THREAD-2 (one or more independent sessions, saved default session).
264a builds to that text. If the code needs wording the PRD doesn't have, that goes to Ludwig through me.
## Q4: stop proof
Row 194's lifecycle path is the stop owner. The brief names it (F12 and D7 step 2, "the reviewed
lifecycle path"). Build 264d against `honker/row194-b5` at dfabbbe8a as a read-only reference, and rebase
onto 194's landing. 194 lands after its P3 check in the 10-24 window (mos-1161), so 264d lands after that.
Don't copy 194 code into 264d's manifest.
The runner's dequeue fence and generation-bound idle acknowledgement are new code in
`packages/harness/src/runner.mjs`, and they belong to 264d. Approved as a named review item. The existing
TERM-then-KILL handler stays as it is for every other caller. D7 forbids calling it while a child runs.
## Q5: kill checks
Approved, as D8 already says. Use today's launcher limit and revocation path. Add one shared admission
hook that every session start goes through. Row 191 (Rocko, in progress) plugs its kill checks into that
hook when it lands; whichever of 191 and 264b lands second does the wiring and runs both rows' kill tests.
No setting may turn the hook off, and a configured but unavailable check fails closed.
## Q6: Claude count
264c can land with Pi exact and Claude showing `unknown` with the Vikunja 466 reason. T3 passes for Pi
in 264c. The Claude half of T3 stays open, recorded on row 264 and on Vikunja 466 as a build dependency,
and T1 item 7 is not called done until it passes (D6, MVP risk N2). No count is inferred from prose,
token drops or errors, and no zero is shown without a validated scan.
## Q7: RBAC
Approved, as D4 says. Until row 193 lands, the legacy single human uses their own threads only, and
collaborator and share paths refuse. T5 in this row covers that case, forged identity and cross-instance
IDs. The A4 `session.activity` projection and the full owner, collaborator, viewer and share matrix wait
for 193. The row that lands second wires them and runs the full T5.
## Process
- Claim the row in the first slot gap. Tests and queue writes run only on my CLEAR.
- One branch per round. A rebase goes on a new branch name (mos-1086).
- Design notes stay in `agents/gizmoduck/work/queue-264/BUILD.md`.
@@ -0,0 +1,50 @@
// Sage, 2026-10-22: row 191 (kill switch) landed, comment on P19 414 (Q174 landing comment). Sage's own key (bot user 13); the sync key reads back. Comments only: no task edits.
// Checks each task's title before posting. Prints no secret.
// Usage: node comment-r191-landed.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 414, title: /^MVP platform: the Given list for Cases 1 and 2/, body: [
"Row 191 (kill switch, stopRun) landed on refactor as 0fa7bf421 on 10-22 (recorded 04:49Z), a fast-forward on 86f95bb27. An owner can halt an instance: agent verbs that start or continue work refuse with a fixed code, reads and the human's own verbs (including the clear) still work, and running runs are stopped. Gitea #1689 is closed (comment 30360). Queue row 191 is done (rev 3120).",
"Rocko built it, and Dewey approved the manifest (88 paths, digest 0bcbf337, comment 30359, rev 3118). Gate on 0fa7bf421: 35 touched files whole, every package suite, the ten scripts/test-*.sh, and 77/77 mutants checked. Sage ran the runs suite 43/0/0 for Dewey's N7. Follow-ups are on Vikunja 502 (real clock, exact HTTP refusals, runtime close in finally, runner cause text).",
"It isn't live yet. Live is still 86f95bb27. The switch reaches dragon-lin at the next re-pin, through the window plan and Mos.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,61 @@
// Sage, 2026-10-22: files the 10-27 owner thread-claim row (mos-1262) as a P19 task. No relation, labels, assignees,
// due dates or bucket moves. Sage's own key (bot user 13); the sync key reads back. Refuses if the title exists.
// Prints no secret.
// Usage: node file-thread-claims.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON ROW ISSUE
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT, ROW, ISSUE] = process.argv.slice(2);
if (!/^[0-9]+$/.test(ROW ?? "") || !/^[0-9]+$/.test(ISSUE ?? "")) throw new Error("ROW and ISSUE must be numbers");
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
const TASKS = [
{
title: `The owner sees a thread agent's claims: agents lists held thread claims (row ${ROW}, #${ISSUE}, 10-27)`,
description: ps(SIG, [
"Filed by Mos's ruling mos-1262 as a 10-27 row, not post-v1. Row 205's message filter found it (morgana-0072, Sage ruling sage-2430).",
"A thread run's role.claim writes thread_claims only (broker.mjs:2682), and the owner's agents verb reads role_claims only (:3309). So the owner can't see a thread agent's claim, which PRD :1083-1084 and the FED R6 user sentence promise. No user thread goes live before this row lands, the same bind as the 193b1a filter. Q176 Case 1 needs it.",
`Queue row ${ROW}, after row 205, owner Morgana, reviewer Duckworth, gate Sage. Brief: docs/plans/2026-10-22_owner-thread-claims.md. If it can't make 10-27, that's a scope question for Jason, and it goes to Mos first.`,
]),
},
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
async function existing() {
const out = [];
for (let page = 1; page < 20; page++) {
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
out.push(...list(r.json));
if (list(r.json).length < 50) return out;
}
throw new Error("more than 19 pages");
}
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
const have = await existing();
for (const T of TASKS) if (have.some((t) => t.title === T.title)) throw new Error("title exists; refusing");
writeFileSync(OUT, "", { flag: "wx" });
rec.created = [];
for (const T of TASKS) {
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
const c = { status: r.status, id: r.json?.id ?? null };
rec.created.push(c);
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) { rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`); continue; }
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
Object.assign(c, { readBack: back.status, done: back.json?.done });
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push(`read-back differs for ${r.json.id}`);
}
save();
console.log(JSON.stringify(rec));
@@ -0,0 +1,69 @@
// Sage, 2026-10-22: re-pin window 7247d80c7 to 86f95bb27 done (mos-1253, mos-1263). Comments on P19 386 and 435 (Mos posted 412 himself, mos-1266),
// text from Darkwing (darkwing-0160). No task is closed. Sage's own key (bot user 13); the sync key reads back.
// Checks each task's title before anything is posted. Prints no secret.
// Usage: node post-w12-86f95bb27.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13). Text from Darkwing, the window operator.";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const WIN = "agents/darkwing/work/window-repin-86f95bb27/";
const POSTS = [
{ id: 386, title: /^Q173 test system/, close: false, body: [
"Re-pin 7247d80c7 to 86f95bb27 done on dragon-lin, 2026-10-22, with Darkwing operating. Activation 20261022T040256Z-86f95bb27f6c, pointer at 04:03:00Z UTC, schema 3j with no migration. P1 to P6, B0, W0 to W5 and W9 to W11 passed. Nothing was rolled back, and there's no counted failure (mos-1260). W5 backup SHA256SUMS 45b18429.",
"S1 to S2 (03:55:33Z to 04:24:42Z UTC): schema 3j at both, events 1798 to 1817 with no grant.* kind, and hosts and host_grants at 0 rows.",
"W12 (w12c.sh, mos-1263) W12_PASS at 04:26:43Z. Backup w12-20261022T042555Z first (SHA256SUMS 210ce775). The chief relayed the pm read and launched pm r2cf8536da2c3, which answered PRD 1.14 with 85 ids. Row 66 on chief rd0b8fdb4d62d: turn 1 drew 4 banned words and the [unslop: note, turn 2 got the notice, and counts went from 1/0/0 to checked 6, flagged 1, notices 1, broken null.",
`STATE: ${WIN}STATE.md`,
] },
{ id: 435, title: /^Console inbox: Approve\/Deny/, close: false, body: [
"Re-pin to 86f95bb27 (activation 20261022T040256Z-86f95bb27f6c). Row 202's access verbs are live, and nobody created a grant. The bus journal since W5 has no grant.* event, and hosts and host_grants hold 0 rows.",
`STATE: ${WIN}STATE.md`,
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], closes: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
const before = {};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
if (P.close && t.json.done) throw new Error(`task ${P.id} is already done; nothing posted`);
before[P.id] = { title: t.json.title, description: t.json.description };
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) { rec.problems.push(`comment ${P.id} read-back: ${back.status}`); save(); continue; }
if (P.close) {
const full = await api("GET", `/api/v1/tasks/${P.id}`, undefined, key);
if (full.status !== 200 || full.json?.id !== P.id) { rec.problems.push(`v1 get ${P.id}: ${full.status}`); save(); continue; }
const u = await api("POST", `/api/v1/tasks/${P.id}`, { ...full.json, done: true }, key);
const b2 = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
const x = { task: P.id, status: u.status, readBack: b2.status, done: b2.json?.done, titleSame: b2.json?.title === before[P.id].title, descriptionSame: b2.json?.description === before[P.id].description, inverse: `mark undone: POST /api/v1/tasks/${P.id} done false (sage key)` };
rec.closes.push(x);
if (u.status >= 300 || b2.status !== 200 || b2.json?.done !== true || !x.titleSame || !x.descriptionSame) rec.problems.push(`close ${P.id}: update or read-back differs`);
}
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,118 @@
# Re-pin 7247d80c7 to 86f95bb27 (rows 251, 244, 254, 202, 273, PRD 1.11-1.14), no migration
Sage, 2026-10-22, for mos-1253 under DECISIONS Q173 and mos-1246. The step layout is
`repin-1024-draft1.md`, with the copy changed and `--migrate` dropped. Where a step is unchanged I name it
and say only what differs. Mos checks this file's sha256 before P0, and any change after that means a new
check (mos-0849/0850). This file stays uncommitted until row 191's window ends, because refactor doesn't
move while a rebased candidate's gate runs.
## Who, and what nobody does
- Darkwing operates if free. Otherwise I operate. Mos verifies each step's output before the next one,
runs P3, and gives the W5 go. Row 191's full window keeps first claim on the slot (mos-1225). Steps that
start no test and no stub run alongside it: P1, W0, P4, P5, P6, B0, W1-W4. P2 waits for a gap or CLEAR,
because it starts node stubs that a busy check counts.
- Jason has no step. Nothing is pruned. Nobody acts as Jason: no `--by jason`, no human shell or tmux
pane, every bus read and write carries `--tester`, and `bus.tester` stays as it is. If it isn't `true`,
stop at P1.
- No pi (#383). No Save in the human Console, no `mosaic name`.
- Outside reach doesn't change. No `--send`, no `--sso`, no proof pair. Both go on together in the phase 2
window, which still waits for Jason's start (Vikunja 412). The tunnel forwards 7332 only.
- dragon-lin only. .53 and .54 take nothing (mos-1198, mos-1167).
## Names
- OLD, live: `7247d80c7af7ffa70fc61557406bf491cdf3bfc7`, schema 3j, pointer `pm: true`, activation
`20261021T215926Z-7247d80c7af7`. Console flags at W5 last time: `--port 7332 --public-host
mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-socket`. P1
re-reads them from `status`. I don't copy them from this file.
- NEW: `86f95bb27f6cb237bf9fa9ba53320bace6c4de0b`, 59 commits past OLD (`git rev-list --count`). The last
code landing in it is 0bbf0f6d1 (row 273). The two commits after that are docs and queue files.
- `WIN=agents/darkwing/work/window-repin-86f95bb27/` if Darkwing operates, else
`agents/sage/work/window-repin-86f95bb27/`. The scripts adapt last window's, in
`agents/sage/work/window-repin-7247d80c7/`, with NEW and OLD changed and every `--migrate` removed.
## What NEW carries over OLD
Non-test paths changed from OLD to NEW (`git diff --name-only`, tests and READMEs left out):
| Row | Commit | Paths | Live effect to check |
|---|---|---|---|
| 251, 244 | d37210288, e69260a3c | scripts/test-release.sh, test-task.sh, scripts/testing/* | Gate tooling. Not in the live path. |
| PRD 1.11-1.14 | ea0b2a16d..db1e7732a | packages/tasks/requirements.json | W9: the pm's requirements read gives 1.14 with 85 ids. |
| 254 | 331597709 | packages/bus/src/human-cli.mjs (library.sync client timeout 130 s), packages/webui/src/bus.mjs (six `library-*` STATUS codes) | No library entry is live, so neither path runs. |
| 202 | ddfb29af7 | packages/cli/src/access.mjs, cli.mjs, scripts/mosaic | `mosaic access list/grant/revoke`. Nobody runs them in this window (Q188, mos-1141). |
| 273 | 7991662dc..0bbf0f6d1 | scripts/stack-release.sh | `--send` and `--no-sso`. With neither given, the plan keeps the pointer's Console flags. W3 checks that. |
Nothing else in packages/bus/src or packages/webui/src changed (`git diff --quiet` with those two files left
out). The four service scripts and scripts/stack-copy.sh are unchanged from OLD (`git diff --quiet 7247d80c7
86f95bb27 --` on them). package-lock.json is unchanged.
## Schema and backups
- `SCHEMA_VERSION` is 3j at both commits (`packages/bus/src/store.mjs:17`). No migration step and no
schema.sql change in the range. W3's plan must show no migration line. If it asks for `--migrate`, stop.
- B0 is the backup taken first. It's an online copy of the live store through sqlite's backup API, read only
on the source, as in last window's W12. It is laid out the way `rehearse --backup PATH` reads it
(`stack-release.sh:2232-2235`): `$DATA/backups/b0-<STAMP>/bus/bus.sqlite`, `SHA256SUMS` written from
inside `bus/`, `SCHEMA` reading 3j, and `CHECK` holding the integrity_check result, the event count and
maxSeq. The operator sends Mos the path and the SHA256SUMS sha256 before P3.
- W5 takes the stopped-store backup every activate takes, plus the rollback set. That one is the rollback
point.
- The grant look-alike count from draft 1 is dropped. It guarded the 3i-3j migration, and there is none.
- Rollback is a pointer flip. OLD and NEW share 3j, so `rollback --plan` then the real run, with no
`--restore`. Any restore goes to Mos and ends the window (mos-1226, mos-1253 item 5).
## Before the window (nothing stopped)
| # | Who | Step |
|---|-----|------|
| P0 | Mos | The sha check of NEW and of this file. |
| P1 | operator | `status`: pointer, activation, 3j, Console flags, unit flags match. Business file sha256 and `business-check.mjs` LOAD_OK for OLD now and for NEW after W0. `ls -ld $DATA/console` is 0700 and `tester.sock` is 0600 (row 196 made them at the last W5). `bus.tester` is `true`. |
| W0 | operator | `scripts/stack-copy.sh copy $NEW`. The copy has `tools/unslop-hook` and `packages/library/bin/askpass` with NEW's mode. |
| P2 | operator, in a gap | The two stub runs from last window's p2.sh against NEW's copy, then render both units from REPO's service scripts and diff them against the live units. The only difference allowed is the copy path. |
| B0 | operator | The backup above. Path and SHA256SUMS sha256 to Mos. |
| P3 | Mos | `stack-release.sh rehearse $NEW mosaic-stack --backup <B0 path> --port <scratch>`, no `--migrate`. It must show no migration, the interpreter preflight passing, one receipt line, no `library:` line, and one pm reply. Mos also reads healthz, `/api/config` and the two tester-http reads on NEW's Console with the pointer's flags, as in mos-1107. |
| P4 | operator | The tunnel read. |
| P5 | operator | The four service scripts held at NEW, and empty porcelain on them. |
| P6 | operator | The 7332 matrix (a)-(g) on OLD as `$WIN/matrix-old.txt`, plus the two tester-http reads, for W10. |
## The window
| # | Who | Step |
|---|-----|------|
| W0b | operator | The business file copy. |
| W1 | operator | Idle check, start stamp from `date -u`, S1 with `$SNAP --held`. Open pm tasks: the pm finishes or parks them, or the operator records which piece crosses the window. |
| W2 | operator | `status`: pointer on OLD, `pm: true`, 3j, unit flags match. |
| W3 | operator | `stack-release.sh activate $NEW mosaic-stack --pm --tester-socket --plan`. It must show OLD to NEW, no migration, the backup line, and a Console line with OLD's flags exactly: `--tester-socket`, no `--send`, no `--sso`, no proof pair, no drop line. Any other difference stops here. |
| W4 | operator | The unit reads, as last window. W3 and W4 go to Mos for the W5 go. |
| W5 | operator, on Mos's go | The same command without `--plan`. Exit 0, `result ok`, every gate check passing, `pm_launched` included. Record the activation id, backup path, STAMP and the backup SHA256SUMS sha256. Then `status`. |
| W9 | operator, after Mos verifies W5 | Last window's W9 list: no `Sign-in:` line in the webui journal since the W5 stamp (with `UTC`), `/proc/<Console pid>/cwd` names NEW's copy, `tester.sock` 0600 in a 0700 `console/`, one new `peercred-starts.jsonl` line, no `library:` line in the bus journal. The pm's requirements read gives 1.14 with 85 ids. If the pm isn't relaunched by the restart (mos-1230), the read moves to W12, as last time. |
| W10 | operator | The matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt` apart from named lines. The two tester-http reads match P6's. No listener on 7333 (`ss -ltn`). P4 again. A Console POST on the main port with no signed-in session is refused (the mos-1195 baseline). |
| W11 | operator | STATE report in `$WIN/STATE.md`, posted on P19 386, 412 and 435, and on the Gitea issues for 202 (#1700), 254 (#1759) and 273 (#1778). |
| W12 | operator, after W11 | Row 66's live check as last time: backup first, one Claude Code managed session with a known tell, the RESULT note, the next-turn notice and the `unslop.jsonl` counts. Pi isn't run (#383). |
After W12 I write draft 3 of the phase 2 plan naming 86f95bb27. That window still waits for Jason.
## Steps skipped, and why
Mos allowed skipping a step that only repeats a test row 273's or row 202's gate already ran on the same
code. The blob ids at the gated commit and at NEW are equal for every one of those files (`git rev-parse`):
scripts/stack-release.sh `7c7676c71` at 0bbf0f6d1 and NEW, and access.mjs `a0a7c9364`, cli.mjs `b14b8fd79`,
scripts/mosaic `91c79ab4b` and human-cli.mjs `27b67fbf7` at ddfb29af7 and NEW.
- No separate run of the `--send`/`--no-sso` walk or the rollback flag carry. Darkwing's gate on 0bbf0f6d1
ran stack-release.test.mjs 218/218 (T53, T57, T58 c0/c1) and test-release.sh rc 0. W3's live plan line is
the only check left.
- No live `mosaic access` run. Row 202's gate ran access.test.mjs on ddfb29af7. A live grant would also
break Q188.
- The grant look-alike count, for the reason under Schema. It's a scope drop, not a gate repeat.
Nothing else is skipped. P2 and P3 aren't gate repeats: they check NEW's copy as built on dragon-lin.
## Stop conditions and rollback
As in draft 1. The first failure stops the step and goes to Mos. A second failure, or any restore, ends
the window (mos-0823, mos-1253 item 5). Before W5 there's nothing to undo. After W5, rollback is
`rollback --plan` then the real run, which flips the pointer to OLD with the same Console flags. Keep every
copy. Prune nothing.
+2
View File
@@ -38,6 +38,8 @@ services:
volumes:
# Configured runtime state root (from config.json dataRoot).
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}:/var/lib/mosaic
# The whole control directory is protected against unlink and rename-over replacement.
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}/control:/var/lib/mosaic/control:ro
# Runtime credential only: pi auth file mounted READ-ONLY.
# Never copied into the image.
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
+39
View File
@@ -1455,3 +1455,42 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T22:30:20Z | Huey (gpt-6.1-sol) | T1 item 7 r3, scrooge-0007 R1, Vikunja 463 | isolated session-workspaces root and nesting mutant recorded; R1-only confirmation requested, other r2 findings closed; doc-only checks
2026-10-21 | Ludwig | PRD 1.11-1.14 drafts (Vikunja 459, 462, 463, 464) | approved by Della, handed to Sage
2026-10-21T22:51:07Z | Sage (Opus 5.5) | PRD 1.13 and 1.14 landing with Gosalyn's C2-01c brief and Case 2 design (51fdfb7e2), rows 264-268 filed (#1769-#1773), Case 2 re-pin to a6ac627c8, Vikunja 463 closed | pushed 6ba8a582e..db1e7732a; requirements tests 3/3 and 3/3 and verbs 31/31 on 5f73b388c and db1e7732a
2026-10-21 23:39:21 UTC | Rocko (d99326cc) | row 191 inspection handoff, sage-2242 | authorized local snapshot d52b4db1c on new rocko/row191-inspection-20261021; 78 blobs match a4f780c2; no push credential, no push or tests; Sage/Honker messaged directly; per-run API seam and mutants/gate pending.
2026-10-22 00:04:05 UTC | Rocko (d99326cc) | row 191 stopRun focused stage and S6 handoff | 9 files final 62/62; two bus failures diagnosed/fixed and logged, own failed fixture group cleaned; no runners remain, slot returned. Export in f7bf7dfec, rebased on new branch to 7aa18b0f6 (dcd8d4dda), 79 product blobs identical. Honker/Sage handed the sha directly; broader gate/mutants pending, no live action.
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing, rows 269-272 (264b-264e, #1774-#1777), 268 r2 request, row 191 publish and gate slot | 331597709 and eb9672c91 landed, queue e3c0446f5 pushed; rocko/row191-stoprun-dcd8-20261022 7aa18b0f6 and 81889b58d published as jarvis; 191 full gate on Rocko's slot from 00:17Z
2026-10-22 00:24:34 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 full gate on c78f48946 | stopped on second integration failure per sage-2275; singles 269/269, processing-control 61/61, bus 646/646; CLI interrupted with 26 observed failures; no further edits; cwd sweep empty and slot returned to Sage.
2026-10-22 00:48:20 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eb3abba12 full gate | stopped at Discord 190/192; 32 singles 304/304, processing-control 61/61, bus 646/646, cli 761/761; two uninitialized Discord fixtures reported to Sage; no later run; cwd sweep empty, slot clear.
2026-10-22 00:58:27 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2294 fixture fix | e6800380d on eb3abba12, two test files/four additions, assertions preserved; 338-entry static all-package sweep recorded; no tests; sent Sage for publication and Honker for dependent gate.
2026-10-22T01:04:44Z | Dewey (Opus 5.5, 84fb346c) | FED R3 early look, e7d2dc3e6 (morgana-0065) | static only, no queue record; mutant rulings: M3x stays plus reply-chain under M3x, #threadFor user clause equivalent, 193b1a filter mutant n/a; N1 role key user to fix in R3 (:359 and instance-settings:90), Sage rules on widening; dewey-0777 to Morgana, dewey-0778 to Sage
2026-10-22T01:08:21Z | Dewey (Opus 5.5, 84fb346c) | FED R3 r2 62de33760 delta (morgana-0066) | static, no findings; N1 fixed at broker :361 and instance-settings :91 with tests; Sage rules instance-settings scope; dewey-0779 Morgana, dewey-0780 Sage
2026-10-22 01:30:04 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 e6800380d resume | binding15/15, recover5/5, Discord192/192; stopped WebUI314 passed/4 failed/1 esbuild skip; processing polling violates sign-out/manual redirect and bar changes layout; no fixes/retries, cwd sweep empty, slot returned.
2026-10-22 01:38:08 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2317 Console integration fix | 8e1f55c1d on e6800380d; shared sign-out/manual redirect/interval and desktop frame fixes, assertions retained, six browser cases, 73 mutant sites preflight; syntax only, no tests; sent Sage/Honker.
2026-10-22 02:06:38 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 rebase sage-2329 | eafcbd3e7 on ddfb29af7, new branch, dispatcher retains processing and access; CLI/readme auto-merges checked; 87-path fd0f2740 manifest and 73-site preflight; Sage2333 published, awaiting CLEAR; no tests.
2026-10-22 02:20:51 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eafcbd3e7 full gate | stopped singles313/6: six new browser cases count app freshness timer as processing timer; diagnosis sent Sage, no fix/retry; no suites/shells/mutants; cwd sweep empty, slot clear.
2026-10-22 02:23:13 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2345 timer fixture | 4db8b5778 on eafcbd3e7, one line attributes timer to processing.js; all assertions retained, no product change or tests; sent Sage and Honker; awaiting publication/CLEAR.
2026-10-22 02:25:29 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2349 record commit | 0f8235277 on 4db8b5778 adds packet BUILD.md cause record; product manifest remains ffb46fbd/87 paths; no amend, product change or tests; Sage/Honker notified.
2026-10-22 02:48:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 0f8235277 full gate second red | singles330/330, processing61/61, bus646/646, cli783/783, discord192/192; WebUI323/1/1 host esbuild skip; shell geometry1062vs1000; stopped/no retry, no later suites/shells/mutants; cwd sweep empty, slot returned, Sage escalates Mos.
2026-10-22 02:57:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 mos-1248 desktop layout restart | FF 5cd32f4c1 on 0f8235277: desktop grid allocates processing bar, existing shell assertions kept, visibility/no-scroll assertions added; 74 static mutant sites; candidate 2f93ef19; sent Webby/Sage for static review; no runs, awaiting review and CLEAR.
2026-10-22T02:58:24Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) early static look at 8bf442be9 (megavolt-0112) | B1 step 7 revoke undone by step 8 write, B2 sweep fire retry skips D1 id check, N1-N4; dewey-0781 Megavolt, dewey-0782 Sage; no queue verdict
2026-10-22 03:01:02 UTC | Rocko (d99326cc) | row 191 Webby static review | B1 accepted: 5cd32f4c1 preserves the content floor; earlier root-cause claim withdrawn in BUILD.md; no runs; requesting fixture measurement slot and owner layout ruling.
2026-10-22T03:08:58Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r2 77dd43c9e delta look (megavolt-0115) | B1 and B2 closed by reading; N5 (settle unknown/401 branches unguarded) not blocking; N1-N4 DEFERRED agreed with README lines; dewey-0783 Megavolt, dewey-0784 Sage; no queue verdict
2026-10-22T03:10:55Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r3 7965efd0f delta look (megavolt-0119) | N5 closed by reading; r3 is the run candidate; dewey-0785 Megavolt, dewey-0786 Sage; no queue verdict
2026-10-22 03:11:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 static nav layout, mos-1249/1250 | FF da4334806 on 5cd32f4c1; controls first in nav, non-sticky per sage-2386/webby-0108; candidate-only placement assertion changed, landed shell assertions preserved; 77 mutants statically preflighted; manifest 245dceb1; Webby/Sage handoff sent, no runs, awaiting static look/publish/debug CLEAR.
2026-10-22T03:11:36Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r4 1c0048911 static look (sage-2390) | README N3/N4 sentences match the rulings; N5 test split per branch, same asserts; r4 is the run candidate; dewey-0787 Sage, dewey-0788 Megavolt
2026-10-22 03:15:11 UTC | Rocko (d99326cc) | row 191 sage-2394 debug | da4334806 shell5/5 and processing-browser9/9, awk14/0/0; geometry heights reported with read-only distinction; cwd sweep[] and slot returned; full window awaits separate CLEAR.
2026-10-22T03:36:37Z | Filbert (Opus 5.5, sage-2398) | row 273 (#1778) static review, darkwing/row273 978143bf4 to cac7d0f67, carried to 0bbf0f6d1 | APPROVE c30308, queue rev 3111 (filbert-273-record-r1), digest e1031023 rebuilt and matched; B1/N1 fixed, T57 item 3 change approved, N1 TOOLS.md by Sage at landing. Posted before sage-2396 hold; my drift check missed row 202 scripts/mosaic (test-copied). Merge-scoped carry c30319 on 0bbf0f6d1: range-diff 6/6 =, patch-ids equal; rerun green per darkwing-0147 (not mine). Line left uncommitted: HEAD 378a76b85 behind origin 0bbf0f6d1.
2026-10-22T03:42:29Z | Darkwing (claude-opus-5-5) | row 273 (#1778) activate --send/--no-sso, sage-2301/mos-1246 F39 | LANDED 0bbf0f6d1 (docs 5365adaef, done rev 3113, #1778 closed c30324); 978143bf4 run red (stale T53 usage pin, fixed cac7d0f67), cac7d0f67 green on old base, rebased darkwing/row273-b2 (range-diff all =, digest e1031023) green 218/218, 25/25, 14/0, M1-M7 killed; in-review rev 3109/3110 c30307; TOOLS.md F39 lines match darkwing-0137
2026-10-22 04:02:12 UTC | Rocko (d99326cc) | row191 full window0fa7bf421 | touched35files336pass, packages2403pass/1esbuildskip, shells425selftests+156queue node pass; SR01–07killed, SR08timeout,69unrun; stopped/no retry;88blobs restored,cwd sweep[],owned network removed,slot returned Sage; await Mos ruling.
2026-10-22T04:11:48Z | Dewey (Opus 5.5, 84fb346c) | row 191 runner review (sage-2424, mos-1259 item 3): run-mutants.py fe0d360f static | APPROVE to Rocko dewey-0789 and Sage dewey-0790; N1-N5 non-blocking (placeholder timeout cause, 7+1+69 tally, indented not-ok match, SR08 time binding, resume-only invocation)
2026-10-22 04:14:56 UTC | Rocko (d99326cc) | row191 control/resume sage-2427 | control12/12;SR08separate timeoutkill;49resumed assertionkills thenKS43survived;total56assertionkills+1timeout,1survivor,19unrun;88blobs restored,cwd sweep[],slot returned,Sage/Mos informed;no retry/edit.
2026-10-22T04:20:27Z | Dewey (Opus 5.5, 84fb346c) | row 191 mos-1261 mutant table review: all-mutants.json 5dbc8308 KS43 pattern retarget + 19-site audit, static | APPROVE to Rocko dewey-0791 and Sage dewey-0792; C1 archive resume-mutant-results.json (f5725ef2) and KS43.tap (c778ee33) before a --resume-from KS43 run
2026-10-22 04:21:30 UTC | Rocko (d99326cc) | row191 KS43+19 retarget run | KS43assertionkill thenKS44invalid mutation syntax;57assertionkills+1timeout,1invalid,18unrun;stopped,no edit/retry;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified.
2026-10-22T04:25:19Z | Dewey (Opus 5.5, 84fb346c) | row 191 mos-1264 KS44 table fix (85d9a7b1, // -> void 0) + 19-site syntax preflight, static; also C1 archive check (dewey-0793/0794) | APPROVE to Rocko dewey-0795 and Sage dewey-0796; run condition: new subdir with byte copies, --resume-from KS44 (19 sites); tally 57 kills + SR08
2026-10-22 04:30:06 UTC | Rocko (d99326cc) | row191 KS44+18 resume | 10assertionkills thenKS54timeout-without-control despite dependency assertion;total67assertionkills+1timeoutkill,1unqualifiedtimeout,8unrun;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified,no retry/edit.
2026-10-22T04:38:59Z | Dewey (Opus 5.5, 84fb346c) | row 191 final mutant evidence check (control-ks54-mos1267, syntaxfixed-mos1264), static | tally 75 assertion + 2 timeout kills = 77 confirmed (dewey-0798 Rocko, dewey-0799 Sage); N6 tasks.test.mjs:262-266 runtime not closed on failure, Sage rules
2026-10-22 04:41:46 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | row191 #1689 builder complete/in-review | published0fa7bf421,manifest0bcbf337;35singles336pass,9packages2403pass/1knownskip,10shells425selftests+156queue nodepass;75assertionkills+2controlledtimeoutkills=77;88hashrestore,cwd sweep[],slotreturned;in-review rev3114,expected no-token failure3115(exit1);Sage/Dewey handoff,no live action.
2026-10-22T04:46:51Z | Dewey (Opus 5.5, 84fb346c) | row 191 manifest review round 1 (#1689, candidate 0bcbf337, source 0fa7bf421 on 86f95bb27) | APPROVE comment 30359, queue rev 3118; manifest, gate logs (2739/0/1 named), 77 mutants and 712-file packet checked; N6 carried to Vikunja 502; N7/N8 non-blocking; dewey-0800 Sage, dewey-0801 Rocko
2026-10-22T04:52:40Z | Dewey (Opus 5.5, 84fb346c) | row 236 static look at rebase 08840f3c1 on 0fa7bf421 (#1742) | clean, not a verdict: commit 1 only the three 191 union lines, 2-6 =, commit 7 grants.status read with a test; manifest 32 paths digest 1e40433b matches; dewey-0802 Megavolt, dewey-0803 Sage
2026-10-22 05:03:18 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | Vikunja502 processing test/runner hardening | brief+isolatedrocko-v502;expected wordingred→green,Node27/27,Python4/4,5/5assertionkills(includingSR08andKS54nohang),0skips/timeouts;sweep[],candidate5hashrestored,slotreturned05:01:29Z;candidate968d6ee3,runnerc5bdc764;packetready,no commit/push/liveaction.
2026-10-22 05:07:34 UTC | Rocko (d99326cc) | Vikunja502 committed/review handoff sage-2482 | newbranchrocko/vikunja502-ce6a-20261022,source8e3c6a79b+packet9bf4683c6 on ce6a96ad6;5paths no base drift,range-diff=,patch-id37135ebe unchanged;manifest968d6ee3,packet93889519 verifies39/39;focused runs carry;Sage publish/Dewey review handoff sent,no push/liveaction.
2026-10-22 05:10:00 UTC | Sage (Opus 5.5, 1ef1e4f8) | landings 191, 205, 268 (MVP 10-27) | 191 LANDED 0fa7bf421 (P19 414 c595); 205 LANDED ce6a96ad6 (gate GATE_PASSED 04:56:43Z, Duckworth APPROVE rev 3125, done rev 3127, #1703 closed c30379); 268 r4 LANDED c27207f6e as a carry on 205 (rerun 14/83/209, Webby 0112/0113, done rev 3129, #1773 closed c30384); doc commit adds the #1782 brief, re-pin plan, phase 2 draft 3
+18 -1
View File
@@ -276,11 +276,25 @@ scripts/mosaic follow --thread <thread> [--tester]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
scripts/mosaic halt | resume [--business <id>]
scripts/mosaic processing status [--business <id>] [--json]
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
```
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
recovery. `mosaic processing status --json` reports settled, pending or unknown
stops; `mosaic resume` checks the current generation and refuses pending stops.
Only the existing human proof can write it, independently of channel settings.
A missing/invalid state is never off: use halt, then resume; an unsafe control
parent needs operator repair. Unknown task/remote outcomes are not retried.
Native diagnostic seats remain available. First activation must stop old
processing before trusted initialization and align the image/read-only control
mount. This is a separate approved window, never a build step.
The human commands read and resolve through the broker's human transport
(`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside
an agent run, except the forms that take `--tester` (rows 110, 175 and 184), which
@@ -466,7 +480,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-socket | --tester-port N (legacy copies)]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof | --sso | --no-sso] [--send | --no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -476,6 +490,9 @@ scripts/stack-release.sh gate ID BUSINESS --units external --since EPOCH [--time
common options: --copies DIR, --bin DIR, --instance NAME, --units user|external
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
--send needs --sso in the run's Console flags, given or carried. --no-sso needs --sso in the
pointer's, and takes the Console flags from the rollback set of the activation that turned it on.
--no-sso drops the pointer's --send, and so does a rollback to a Console without --sso.
```
This is an operator plan, not an action agents run. Both reviews and the
+9 -3
View File
@@ -22,9 +22,15 @@ edits it by hand, and the agents can't change it. He will ask you to confirm it
## Your playground
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
names and tell Jason. Until you have one, every note in a book folder is canon.
A folder named in the `playground` list of that entry is your playground. A playground folder can sit
at one of three levels:
- Book level: inside one book folder, for ideas about that book, for example `<book>/playground/`.
- Universe level: outside every book folder, for ideas about a universe that aren't tied to one book.
- A new universe: a draft universe entry with a name and its own playground folders and no books, for a
world you are only starting. When it is real, Jason turns it into a plain universe with books and you
move the notes in Obsidian.
You choose the names and tell Jason. Until you have one, every note in a book folder is canon.
What the playground means for the agents:
- They search canon unless they ask for the playground on purpose.
+1 -1
View File
@@ -111,7 +111,7 @@ repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
| 3 | `library survey: library-playground-other-universe: <file> universes.<u>.playground "<prefix>" lies inside book folder "<book>" of universes.<v>`, or `holds book folder` | a playground prefix inside, or holding, another universe's book folder | Give it to Jason for the draft |
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
+26 -7
View File
@@ -12,7 +12,7 @@ Single test files run freely, and suites and mutant batches need Sage's slot.
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
No user thread goes live before 193b1a's message filter has landed (mos-1021 B2, FED R8).
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
@@ -34,20 +34,39 @@ gate, and the mutants are killed.
## Brief: 193b1, viewer verbs, board refusals and the message filter
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
Split on 2026-10-22 (sage-2288, after morgana-0063) into 193b1a and 193b1b below. The text that stood here
chained the whole row on FED R3 and R4, which weren't filed. Only the viewer half needs them.
## Brief: 193b1a, the message filter
Chained on row 196's verifier (done). The bus gets the section 5.7 `trail` and `replies` filter over owner,
reader and tester sessions, with `#readable` owner-only. No schema change. A hidden `trail` subject returns
`[]`, exactly as an unknown id does, and `replies` returns `message-not-found` for both. One test per verb
compares the hidden response with the unknown-id response byte for byte. The owner-side governance pin test
names each item in the user sentence above. Bus tests and mutants 11 and 12 from section 8, plus mutant 1
as "`#readable` returns true" (the pin test kills it) and a mutant that makes a hidden subject
distinguishable from an unknown id. FED R3 builds on this row's candidate and extends the filter to user
sessions.
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193b1b, viewer verbs and board refusals
Chained on 193b1a, FED R3 and FED R4. The bus gets the viewer verb list, per-grant `covers` level checks,
board reads refused to users, and the user-side governance pin test. Bus tests and mutants 6 and 10 from
section 8.
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193b2, thread shares (schema 3l)
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
Chained on 193b1a and 193b1b. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
section 5.5 share filters. Schema 3k to 3l (Sage, 2026-10-20: machine grant row a owns 3i to 3j and
the #436b build owns 3j to 3k, so this row builds on #436b's landing). The table is append-only, and row 181's restore guard and
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8,
plus mutant 3 (rule 3 dropped), moved here from 193b1 because only shares can kill it (sage-2288).
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
@@ -0,0 +1,54 @@
# Row: stack-release.sh activate --send and --no-sso, for the Q175 phase 2 window
Sage, 2026-10-22, for Mos's go mos-1246 (re sage-2285). Gitea #385, Vikunja #412. The window plan is
`docs/plans/2026-10-22_sso-phase2-window.md` (draft 2, 92be0db02).
## Brief: activate --send and --no-sso
Owner Darkwing. Reviewer Filbert (not Sage or Darkwing, mos-1246 item 1). Gate owner Sage. Base: origin/refactor
at this brief's commit. It lands and is re-pinned before the phase 2 window. That re-pin is Q173 work on Mos's
go, run by Jason or Mos. If the target copy's schema differs from 3j and needs `--migrate`, it waits for Jason.
Why: at 02da6a2ae, `scripts/stack-release.sh activate` has `--no-send` but no `--send`, and nothing turns
`--sso` off. Without `--sso`, `sso_base` carries it from the pointer, and `check_target` refuses the
pointer's copy as `already-active`. So the window can't turn send on with `--sso` (mos-1195), and its
same-copy rollback (mos-1245 R1) can't run.
Send and forward auth (mos-1246 F39): send went off on 10-20 (mos-0972) because, under forward auth, any
same-uid process can send as Jason through 127.0.0.1:7332. So send goes only with `--sso`.
What to build, in `scripts/stack-release.sh` only:
1. `activate --send` puts `--send` into RUN_CONSOLE where `webui-service.sh` renders it. It refuses unless
the run's Console flags hold `--sso`, given or carried. With `--no-send` it is a usage error. The plan's
send line reads `send: on; --send was given`, or `send: carried` when the pointer already holds it.
`check_target` lets `--send` name the pointer's copy when the pointer holds no `--send`.
2. `activate --no-sso` is allowed only when the pointer's Console flags hold `--sso`. Otherwise it refuses
with a named reason. It takes the Console flags from the rollback set of the activation that turned
`--sso` on (that set's `show-$CONSOLE_UNIT.txt`, checked against its SHA256SUMS), the way `sso_base`
already does for a rollback to a copy without `--sso`. It drops `--send`, and its plan line says so
(F39 a). The tester follows the pointer. A set it can't read, or a set whose Console already ran with
`--sso`, refuses before anything stops. `check_target` lets `--no-sso` name the pointer's copy. With
`--sso`, `--send`, a proof pair or `--no-proof`, it is a usage error. The plan prints
`sso: off; Console flags from <set>/<file>` and lists every flag it restores.
3. Both flags are refused under `--units external`, like the other unit-render flags (row 232).
4. The usage text and the header comment name both flags. No other behavior changes.
Tests, whole files on Sage's slot: `packages/cli/tests/stack-release.test.mjs`,
`packages/cli/tests/stack-release-external.test.mjs` for item 3, and `scripts/test-release.sh`.
- T1: `--sso --send` on a pointer without send. The plan shows `send: on`, and the unit and the pointer
gain `--send`.
- T2: `--send` without `--sso`, given or carried, refuses (F39 b). `--send --no-send` is a usage error.
`--send` on the same copy when the pointer already has send refuses as `already-active`.
- T3: from an `--sso --send` pointer, `--no-sso` on the same copy returns the unit and the pointer to the
pre-`--sso` flags byte for byte, with no `--send` (F39 a and c). The tester stays as the pointer had it.
- T4: `--no-sso` refusals, each before any unit stops: a pointer without `--sso`; a tampered set
(SHA256SUMS mismatch); a set whose Console ran with `--sso`; `--no-sso` combined with `--sso`, `--send`
or a proof flag.
- T5: `--units external` refuses both flags.
Mutants, each killed: drop the SHA256SUMS check in the `--no-sso` path; let `--no-sso` run with no `--sso`
in the pointer; let `--no-sso` keep `--send`; let `--send` run without `--sso`; let `--send` and
`--no-send` combine.
Gate: Filbert approves the manifest. The two touched test files run whole plus test-release.sh, and the
mutants are killed. No live activation in this row. The window uses it.
@@ -0,0 +1,29 @@
# Brief: row 253 F5 follow-up, supervised legacy AS_USER calls in the case scripts
Sage, 2026-10-22. Source: row 253's DESIGN-C4 addendum (Gander 968db0bcd, :111-124) and Duckworth's
addendum ruling (duckworth-0111, RULING-c4-addendum.md at 45288e584, F5 and C4).
## Brief: supervised legacy AS_USER calls
Owner Gander. Reviewer Duckworth. Gate owner Sage. Starts after row 253 lands.
Why: when a terminal is present, sudo runs the command in a new session (use_pty), and it can't relay
SIGKILL. Row 253 puts its new config query and peer probe under an inline supervisor that scans the
session it owns. It leaves the older calls in `case1-accept.sh` as they are. These are the `timeout 330 ...
talk` path (case1-accept.sh.txt:305 in Gander's frozen snapshot) and the other AS_USER diagnostics (:326,
:335, :355, :396). Each of these wraps sudo in a GNU timeout outside the uid drop. That has the same gap:
when it fires, it kills timeout's process group, and the command keeps running in its separate session.
What to build:
1. Every remaining AS_USER call in the case scripts runs under row 253's supervisor text. That's the same
bytes, pinned by row 253's C5 test, with its own inner and outer bounds and reasons. The talk path
keeps its 300 s conversation and its stdout and stderr diagnostics.
2. Or, for any call that can't take the supervisor, the header says it isn't supervised and why, and the
DEFERRED entry stays open for that call by name.
Tests: extend case-sudo-fixture.mjs cases in both fake modes (exec and session hop) to each changed call.
Each one needs a no-leftover assertion and its exact reason. Mutants: drop the supervisor from the talk path,
and drop the outer bound from one diagnostic. Each must be killed.
Gate: Duckworth approves the manifest, the touched test files run whole, every scripts/test-*.sh runs,
and the mutants are killed.
+39
View File
@@ -0,0 +1,39 @@
# Federation build row R3, the broker user principal (#414)
Sage, 2026-10-22. The design is Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at b93f098f0 on
`gizmoduck/federation-design` (r4.11, sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6).
Sections 3.5, 3.6 and 7 set R3's scope and test gate. Vikunja P19 #414. R3's parents, R1 (row 201) and row 196,
have landed. I owed this filing when they did and missed it (morgana-0063). Row 205 (193b1a) and 193b1b wait
on it.
## Brief: FED R3, the broker user principal
Owner Morgana, after 193b1a's candidate goes to review. Reviewer Dewey (section 7: Filbert or Dewey). Gate
owner Sage. Base: 193b1a's candidate (row 205), so R3 extends that row's `trail` and `replies` filter to user
sessions instead of re-cutting it. If 193b1a changes in review, R3 rebases onto a new branch name.
What to build, in `packages/bus/src/` (broker.mjs and a new `user-cli.mjs`) plus `packages/harness/src/runner.mjs`
for the header line:
1. The user proof: a path-bound `packages/bus/src/user-cli.mjs` with its own nonce variable
(`MOSAIC_BUS_USER_NONCE`), checked with the `user` kind of row 196's `verifyPrincipal` (section 3.6 checks
1-3). R3 consumes that kind. It doesn't re-implement the parent walk.
2. `bindUser({ business, user })` and the broker changes 1-7 in section 3.6, including the reply path (B6),
`#userThread`, `user-thread-human`, `#waiting` returning null for `to: 'user'`, and the runner header line
for `from_role` `user`, word for word.
3. The collaborator verb list from section 3.5, exact, with its filters. Every other verb fails
`access-refused` before its own checks. The grant is read per request through R1's loader and `covers`
with `level: 'collaborator'`.
4. The section 3.6 item 5 audit: every `s.human` and `s.tester` branch in broker.mjs, listed in the packet as
user refused, user treated as human, or not reachable for a user.
5. No revocation timer, `access.reload` or launcher change. Those are R4. No Console change. That is R6.
Tests, in `packages/bus/tests/` and the runner's tests: the R3 test gate in section 7, item by item. Mutants:
the five in section 7's R3 row, plus one that drops the 193b1a filter for user sessions (a user's `trail` of
a hidden subject differs from an unknown id's). The agent-parent, non-Console-parent and kind-confusion
mutants are row 196's and stay there.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
use scratch roots. Single test files run freely, and suites need Sage's slot.
Gate: Dewey approves the manifest. The bus and harness suites and every `scripts/test-*.sh` are green on
Sage's gate, and the mutants are killed.
@@ -0,0 +1,92 @@
# The owner sees a thread agent's claims (10-27 row)
Sage, 2026-10-22, for mos-1262. Row 205's message filter found the gap (morgana-0072, ruling sage-2430).
## Why
PRD :1083-1084 promises that what a thread's agent does with the owner's authority stays visible to the
owner: its decisions, inbox entries, claims, tasks and project files. FED R6 shows users the same list word
for word. Today a thread run's `role.claim` writes `thread_claims` only and returns at broker.mjs:2682. The
`agents` verb (:3309) reads `role_claims` only, and no other owner verb shows thread claims. So the owner
can't see a thread agent's claim.
Mos ruled this a 10-27 row, not post-v1 (mos-1262). No user thread goes live before it lands, the same
bind as the 193b1a filter. Q176 Case 1 ("Shared and private agent sessions available per user; RBAC")
needs it. If it can't make 10-27, that's a scope question for Jason, and it goes to Mos first.
## Brief: the owner sees thread claims
Chained on row 205 (193b1a). Start from refactor after 205 lands.
What to build:
1. A human or reader session's `agents` result also lists every thread claim currently held in the
business: thread id, holder run, harness and claim time. A claim counts as held when its thread's
latest `thread_claims` op is `claim`. Role claim rows keep their current shape and order. Thread
claim rows are added after them, marked so a reader can tell the two apart.
2. A role session's `agents` result is unchanged.
3. The rows carry no message content and no thread name a user wrote. The 205 filter still hides user
messages. This row adds claims only.
4. The CLI's `agents` output (packages/cli format.mjs reads `c.role` and `holder_run`) shows the thread
rows without breaking the role rows.
5. Row 205's governance pin (message-filter.test.mjs, the "claims" item) moves from `threadsState` to the
owner's `agents` call. Its comment naming this row goes away.
If the builder finds a cleaner shape, for example a separate owner verb, it goes in BUILD.md to Duckworth
before code. A widening beyond owner and reader sessions goes to Mos (mos-1202).
Tests:
- Owner `agents` shows a held thread claim. After revoke or release, it doesn't.
- A role session's `agents` result is byte-equal to before for the same store.
- No message body or user-written thread text appears in the output.
- The CLI renders a mixed list.
Mutants, each killed by a named test:
- M1. Thread rows dropped from the owner result.
- M2. The held check reads the first op instead of the latest, so a released claim shows.
- M3. Thread rows added to a role session's result.
Gate: 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.
## Ruling on design r1 (sage-2487)
Duckworth approved Morgana's design r1 (3e9939b7b) as D1: a separate human and reader verb `thread.claims`,
with `agents` unchanged, under conditions C1-C3 (duckworth-0118, agents/duckworth/work/row1782/DESIGN-r1.md
at 13fb9abc5). The brief allowed that shape. Brief item 5's pin moves to `thread.claims`, as C3 records.
Row 277 builds it.
The Console doesn't show thread claims under D1, and an owner who uses only the Console won't run
`mosaic agents`. So PRD :1083-1084 isn't met until the Console shows them too. That's the follow-up brief
below, with the same bind as row 277.
## Brief: the Console shows thread claims
Chained on row 277. Start from refactor after 277 lands.
What to build:
1. The Console's Agents view gets a "Thread claims" section for a human or reader session. It reads
`thread.claims` and lists each held claim: thread id, holder run, harness and claim time.
2. The existing role rows (webui public/bus.js :447, :462, :474) keep their shape, counts and order.
3. No message body and no thread name a user wrote appears in the section.
4. An empty result shows an empty-state line, not an error. A refusal from `thread.claims` shows the
refusal and leaves the role rows intact.
Tests:
- The section lists a held claim and drops it after release.
- Role rows and their counts are byte-equal to before for the same store.
- No message body or user-written thread text in the rendered section.
- A refused `thread.claims` call leaves the role rows rendered.
Mutants, each killed by a named test:
- M1. The section is not rendered.
- M2. Thread claims are counted into the role totals.
Gate: 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. No user thread goes live before this
row lands.
+111
View File
@@ -0,0 +1,111 @@
# Plan: Q175 phase 2 window, the live Console switches to --sso
Sage, 2026-10-22. Draft 3 for Mos, after mos-1266. It names 86f95bb27, live since 04:03:00Z on 10-22. Sources: `agents/sage/work/q175-sso/DESIGN.md` sections 5
and 10 (phase 2 order, rollback, Jason-gated steps), Mos's C1 condition (mos-0977, b416d2ede), Jason's phase 2
answers (Q209, mos-1216, mos-1217), mos-1195, mos-1197 and mos-1245. Rows 192 (phase 1), 200 (`activate --sso`),
its N1 follow-up and 273 (`activate --send` and `--no-sso`) have landed. Vikunja #412, Gitea #385.
## The tool gap is closed
Draft 2 found two missing `scripts/stack-release.sh` flags: `activate --send` and `activate --no-sso`. Row 273
added both (mos-1246, F39). It landed as 0bbf0f6d1 (Gitea #1778), and the 86f95bb27 re-pin put it live
(activation 20261022T040256Z-86f95bb27f6c). `--send` refuses unless the run's Console flags hold `--sso`,
given or carried. `--no-sso` takes the Console flags from the rollback set of the activation that turned
`--sso` on, and it drops `--send`. Neither flag ran in that re-pin, so step 5 and rollback 1 are their first
live use. Darkwing's gate on 0bbf0f6d1 ran stack-release.test.mjs 218/218, including T53, T57 and T58.
## When
Only when Jason starts the window (mos-1197). Nothing below runs before his start. The re-pin condition is met:
W12 passed on 86f95bb27 at 04:26:43Z on 10-22, and Mos closed that window in mos-1266. This plan names
86f95bb27, which carries phase 1 and row 273, so its loader accepts a config with or without `console`. If another re-pin lands before
Jason starts, the plan names the copy live at that point, and step 3 runs against it.
## Who
- Sage or Darkwing runs the agent steps under Q173: backup first, STATE report after.
- Mos or Jason starts and restarts the Console (decision 102).
- Jason signs in, sends and signs out.
- Config writes 1 and 2 (steps 4 and 8) are an agent's, `console` key only, each with a backup first and
a validate after. Mos's message to Jason at the window start names them. If Jason says he'll write them
himself, steps 4 and 8 become his and nothing else changes (mos-1245 (a)).
## Values
- Provider: the existing Authentik `mosaic-stack` provider (A5). Issuer
`https://auth.diversecanvas.com/application/o/mosaic-stack/`. Redirect
`https://mosaic.woltje.com/auth/callback`. The Logout URI field holds `/auth/signed-out`.
- clientId `fvchAepp9ZGoVq8l7YKej3Gbh5dyf8vNv9ORpDaU` (mos-1217, not a secret).
- clientSecret `{ "slot": "console-sso-authentik" }`. The slot file is
`~/.config/mosaic-dev/secrets/mosaic-stack/console-sso-authentik`, set to 0600 by Mos at 21:25:06Z on 10-21
(A6).
- subjects: Jason's sub only, name `jason.woltje` (mos-1197). The loader refuses an empty list
(`sso-config.mjs:62`), and the Console reads the config only at start. So the first write carries one
placeholder subject. The real sub replaces it after his first sign-in, followed by a restart (A8).
## Steps
1. Backups: the bus store, the release pointer, the Console unit and config.json, each copied 0600 into
a new dated directory, with sha256 recorded. The pointer backup records the pre-window Console flags
the rollback restores.
2. Slot shape check (A6), without printing or copying the value: `stat` shows mode 0600 and the owner;
`wc -l` is 1; `tr -cd '\r' <slot | wc -c` is 0; the last byte is a newline (`tail -c1 | od -An -c`).
3. Straggler check (C1): list every running process, unit and timer that reads config.json, with the
copy it runs from (`/proc/<pid>/exe` and cwd links, `systemctl --user list-units`, `list-timers`). Each one
must run from 86f95bb27, or be shown never to re-read config. Step 4 waits until the list is clean.
4. Config write 1 (agent, backup from step 1): add `console.sso` with the values above and one subject
`{ "sub": "pending-first-sign-in", "name": "jason.woltje" }`. `scripts/mosaic-config.mjs validate`
reads it back before anything starts.
5. First `scripts/stack-release.sh activate 86f95bb27 mosaic-stack --sso --send --plan`. Its from line must
name 86f95bb27 (`--expect-from` is for `--units external` only, so the live user units refuse it as a
usage error). Then the same command without `--plan`, the same way earlier re-pins handled the
Console. The tester stays the private socket from row 196 (`--tester-socket`). The `--plan` output
names every flag it drops (`--public-host`, `--auth-header`, `--allow-user`) and every flag it carries.
The run stops if `--sso` or `--send` is missing or a forward-auth flag remains. Mos or Jason starts the
Console. No agent starts it.
6. The start line shows one provider, the issuer and `subjects 1`. No secret value, no sub.
7. Jason's first sign-in (A8). It refuses `subject-unknown`. An agent reads `sub=` from that one log line,
checks that `iss=` is the issuer above, and records that the refused sub is not the placeholder. The sub
goes into the window record, not into any message.
8. Config write 2 (agent, new backup first): replace the placeholder with Jason's sub, so the list holds
that one subject only. Validate. Mos or Jason restarts the Console. The start line shows `subjects 1`.
9. Jason signs in, sends one message and signs out.
10. Unsigned write refused (mos-1195), within Q173 (mos-1245 (c)): an agent acts as an anonymous client,
not as Jason. It POSTs one Console write to `127.0.0.1:7332` with no cookie and no auth header, and
the body names a tester-owned thread, so a wrong 2xx reaches no live seat. It expects 401
`sign-in required` with the bus event count unchanged, and it writes nothing else. No agent sends
`X-authentik-username` to the live Console (design section 8).
11. Forged header refused: on a scratch Console from the same copy (`serve --sso --port <scratch>`,
`MOSAIC_CONFIG` at a scratch config with a scratch dataRoot and a dummy slot, issuer an unreachable
loopback host, no `--send` and no tester), a request with `X-authentik-username` and no cookie
gets 401. Mos or Jason may run the same check on live instead (B6).
12. Sign-out landing (A5): after step 9, did Authentik land on `/auth/signed-out`, or did it refuse
`post_logout_redirect_uri`? `sso.mjs` deletes the session before redirecting, so a miss changes only the
landing page. On a miss, Mos turns it into one small ask to Jason, and the window still counts as passed.
13. STATE report: pointer, copy, schema, Console flags from the unit, the start line, results of steps 7-12,
backup paths and digests. Gitea #385 gets the STATE comment. The `--sso` flag carries forward on later
re-pins (mos-1197).
## Stops
- Any refusal in steps 2-6 or 8: stop, keep the backups, and roll back as below. Never edit around it.
- While the placeholder is the only subject, any sign-in that creates a session is a stop and a
rollback (mos-1245 (b)).
- Step 10: any result other than 401 with the event count unchanged is a stop, and it goes to Mos.
- Two failures in the window: stop and report to Mos (mos-0823).
## Rollback
1. Same-copy re-activate (mos-1245 R1): `scripts/stack-release.sh activate 86f95bb27 mosaic-stack --no-sso
--plan`, then the same without `--plan`. R1 asked for `--expect-from 7247d80c7`, but `--expect-from` is
for `--units external` only and the live units are user units, so the plan's from line must name
86f95bb27 instead. The `--plan` output must show the pre-window Console flags from the step 1 pointer backup: `--port 7332 --public-host
mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-socket`, exactly.
`--no-sso` drops `--send` (mos-1246 F39), so there's no `--send`. A plan that differs is a stop. Mos or Jason restarts the Console.
2. Restore config.json from the step 1 backup, which removes `console`. 86f95bb27 loads config with or
without the key, so the order of 1 and 2 doesn't matter on this copy.
3. STATE after the rollback reads config.json, the unit's Console flags and the pointer.
4. Last resort only, and it goes to Mos first: `scripts/stack-release.sh rollback` goes to the previous
distinct copy, 7247d80c7. Both copies are at schema 3j and 7247d80c7 carries phase 1, so it's a pointer
flip with no `--restore`. Its `--plan` must show the pre-window Console flags above. Any restore goes to
Mos and ends the window (mos-1226).
+8
View File
@@ -346,6 +346,14 @@ at every gate. Started 2026-09-12 during the control board MVP.
do nothing yet. The row that adds the Console library routes decides
whether the tester listener gets `library.sync`. Duckworth's view is
that it shouldn't, because sync is a human write. (2026-10-21)
- **Console library routes: four library codes still map to 502.**
(Dewey N2 on the row 254 manifest, #1759 comment 30173.) Row 254
gave six library codes a STATUS entry in `packages/webui/src/bus.mjs`.
`library-mapping-invalid`, `library-remote-invalid`,
`library-state-invalid` and `library-git` still fall through
`busStatus` to 502. The row that adds the Console library routes
decides their statuses and gives its `humanCall` for `library.sync`
a timeout over 130 seconds. (2026-10-21)
- **T27 sweep test leaves two `sleep 60` children running.**
(Darkwing row 251 slot, darkwing-0113 and darkwing-0117; Dewey on
r2, comment 30168.) The sweep test spawns two detached shells,
+21 -11
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 3059 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3133 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -227,7 +227,7 @@ Generated from `docs/plans/queue.json` revision 3059 by `queue render`. Do not e
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | done | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | Landed 00d4cc1f6 on refactor (base 99a406ed3). Patch-id 5ec610a7 matches git diff 176a6bf4b..df65a3d4c on the 25 manifest paths. Manifest cc0ea9e8, Filbert approve r3 29713. Gate on d0bbc5c1c: packages 2664/0, scripts 429/0. Mutants 20/20. Unpushed. |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | done | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | Landed 0fa7bf421 fast-forward on 86f95bb27 (10 commits, 89 paths; manifest 0bcbf337 = 88 paths without BUILD.md). Dewey manifest APPROVE c30359 rev 3118. Gate: 77 mutants killed (75 assert + SR08, KS54 timeout with controls), suites green on Sage's window; N7 closed by runs suite 43/0/0 on 0fa7bf421. N6, clock fix, :52 assertion, generic cause go to Vikunja 502 (mos-1267/1268). Rebase next: 205, 236, 194, 253, 274, 268. |
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | sage-1610 (honker-0049): a human add takes tracker {bot, botId}, required for a vikunja definition, refused otherwise, checked by checkBot and bot uniqueness; CLI and API only. Delete accepts a tracker bot when all credentials are store refs, and its result names the bot for removal by hand in Vikunja. No Vikunja account is created (Q188 untouched). Mutants: drop the requirement, refuse the bot on delete, skip checkBot on add. |
@@ -238,11 +238,11 @@ Generated from `docs/plans/queue.json` revision 3059 by `queue render`. Do not e
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | done | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | Landed afad4993f on refactor (squash of gizmoduck ae3c36f24..44e36fc4c, manifest 225597146daf...0c520). patch-id ba604ad5e matches the reviewed diff on all 3 paths. Gate on 44e36fc4c in the slot (gizmoduck-0060): business 173/0, 10 scripts 422/0, mutants 44/44. Duckworth approve 29581. |
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 2, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | done | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | done | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | Landed ce6a96ad6 fast-forward on 0fa7bf421 (3 commits, 4 paths, manifest 858463b1). Duckworth APPROVE r1 c30375 (signed :124 test diff, mos-1262). Gate: Morgana slot205 10 files, bus 652/0, M1 M11-M14 killed, 0 skips; Sage test-*.sh 10/10, 3 by-design skips. N1/N3 to 205b or R3, N2 to 193b2 brief, N4 = #1782 before any user thread. Rebase next: 268 r4, 194, 236, 253, 274. |
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
@@ -252,11 +252,11 @@ Generated from `docs/plans/queue.json` revision 3059 by `queue render`. Do not e
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | done | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | LANDED e54e05463 on 6096b051b (sage-2094). Patch-id 2f68188b6c9d equals a36523713..dde3445ab; 118 paths, blobs and modes equal; tree 5b1613245 equals merge-tree. No path overlap with rows 66/243; no hash pins either way. Gate: Filbert APPROVE rev 2924; herb-0095 grant sage-2071 2634 pass 0 fail, 1 webui skip (editor rebuild needs esbuild 0.28.2, env, not a 215 path), accepted. |
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | Rulings on phase A: agents/sage/work/queue-216/RULINGS.md (da9555ca2). r2 rebases on row 268's landing and adds the four PRD 1.14 playground tests. |
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | 268 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | Rulings on phase A: agents/sage/work/queue-220/RULINGS.md (da9555ca2). Scope adds packages/business/src/library-seats.mjs (imported by path, no index export), its test, and control-board serve.mjs. r2 building as a Haiku subagent. |
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | done | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
@@ -272,7 +272,7 @@ Generated from `docs/plans/queue.json` revision 3059 by `queue render`. Do not e
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | queued | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | in-progress | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
@@ -290,7 +290,7 @@ Generated from `docs/plans/queue.json` revision 3059 by `queue render`. Do not e
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | done | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | LANDED d37210288 on b79ad5f2c: candidate 27f01ef63 manifest f2bdd684, patch-id 440d2442c976 equal, blob diff empty, tree matches merge-tree. Dewey r2 approve rev 2996 (c30168). Gate: bus suite whole in a clean worktree, 627/627 at 21:30:33Z on Sage's slot. Overlap check vs 194/198/244/253/254 branches: none. DEFERRED sleep-orphan entry follows as a doc commit. |
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | gander; reviewers webby | — | briefed | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | in-progress | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | in-review, round 1, request posted | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759. |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | briefed | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | — |
| 257 | Q198 S1: pressure parser and pure rules evaluator (post-v1) | morgana; reviewers filbert | #1762 | in-review, round 2, request posted | — | Reviewer approves the manifest; touched test files whole plus the package suite green on Sage's slot; both brief mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: Q198 S1 pressure parser and pure rules evaluator | — |
@@ -300,11 +300,21 @@ Generated from `docs/plans/queue.json` revision 3059 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 | sage | #1769 | queued | — | 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 | 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). |
| 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. |
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | briefed | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | MVP first claim (mos-1240). Lands before C2-05a (216) and C2-06 (217) read classify; if either has a candidate first, the change goes into it too (gosalyn-0134). Code library-playground-other-universe accepted (sage). |
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | done | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | Landed c27207f6e fast-forward on ce6a96ad6 (1 commit, 9 paths, digest caa0fa4e). r4 is a carry of r3 aca53c4f0: range-diff =, patch-id 2942acc8 equal, blobs equal. Webby APPROVE round 2 on ecd9b35eb stands; carries webby-0112 (r3) and webby-0113 (r4, notes 34e1ab2bf). Gate: Gizmoduck r4 rerun brief 14/14, library 83/83, business 209/209, 0 skips; gate grep empty. 194 rebases next (business.test.mjs). |
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | briefed | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | gizmoduck; reviewers duckworth | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | done | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | landed 0bbf0f6d1 03:35:55Z, FF of 378a76b85; rebase of cac7d0f67, 6 range-diff pairs =, patch-ids equal, manifest e1031023 unchanged. Gate by Darkwing on 0bbf0f6d1: stack-release.test 218/218, external 25/25, test-release.sh rc 0, T58 c0 pass c1 fail, M1-M7 killed. Filbert APPROVE rev 3111 c30308, carry c30319. Docs 5365adaef (TOOLS.md N1 + BUILD-LOG). #1778. |
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | in-progress | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | — |
| 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 | briefed | 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 | — |
| 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 | — |
<!-- mosaic-queue:end -->
+2718 -52
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -935,3 +935,25 @@ narrow cross-instance reach, not reach within one instance.
The Console provenance option trusts verified code and process metadata, not an independent
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
## Durable instance processing control
`processing.status` is an authorized read. `processing.halt` ({}) and
`processing.resume` ({generation}) require the existing human proof before
validation or effects. They are management verbs independent of channels and
never tester or role grants. The strict state under the resolved dataRoot's
control/ directory is authoritative; reserved processing.halt/resume/stop
actions mirror its durable transitions and write-once stop receipts with
verified human and generation. Generic event writers cannot forge them. No
schema change is needed. Invalid state refuses work and leaves control reads
and human recovery reachable. Halt then resume repairs an invalid leaf; an
unsafe parent is not automatically repaired.
The bus remains a read/control service while new agent work, tracker polling,
notifications and launches pause. Narrow exit cleanup remains possible. Every
managed starter carries a generation and publishes identity before releasing
work. Stops inspect root/boot/start identity or generated container id/labels,
TERM concurrently, then KILL after five seconds and observe for two more.
Unknown targets remain visible and prevent resume. Existing run evidence is
not synthesized or rewritten. First activation stops old processing before
trusted initialization and aligns the copy and image in the human's window.
+143 -7
View File
@@ -1,5 +1,6 @@
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { isAbsolute } from 'node:path';
import { isAbsolute, dirname } from 'node:path';
import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
@@ -296,11 +297,19 @@ export class Broker {
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
#stops = new Map();
#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 }) {
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 }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
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.
const dataRoot = dirname(store.directory);
try { initializeControl(dataRoot); } catch (e) {
if (!e.code?.startsWith('processing-')) throw e;
// Keep the control socket and the human's conservative halt repair available.
}
this.#processing = processing ?? { read: () => processingStatus(dataRoot) };
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
@@ -368,6 +377,83 @@ export class Broker {
}
}
}
#processingState() {
try {
const state = this.#processing.read();
if (!object(state) || (state.invalid === true
? ![true, null].includes(state.halted) || typeof state.code !== 'string' || !state.code.startsWith('processing-')
: typeof state.halted !== 'boolean' || state.version !== 1 || typeof state.generation !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(state.generation)))
fail('processing-state-invalid');
return state;
}
catch (e) {
if (e.code?.startsWith('processing-')) return { halted: true, invalid: true, code: e.code };
fail('processing-state-invalid');
}
}
#processingWork() {
const state = this.#processingState();
if (state.invalid) fail(state.code);
if (state.halted) fail('processing-halted');
return state;
}
#processingGate(s, verb) {
if (s.human || s.reader || s.tester) return;
if (!['inbox', 'agents', 'tasks', 'trail', 'decisions', 'replies', 'messages', 'state', 'usage',
'requirements', 'spec.read', 'project.list', 'project.files', 'project.file', 'library.file', 'library.status',
'thread.list', 'thread.messages', 'names', 'instances', 'routes', 'hosts.status',
'processing.status', 'role.release', 'usage.end'].includes(verb)) this.#processingWork();
}
reconcileProcessing() { this.#processingMirror(this.#processingState()); }
recordProcessingStop(stop) {
if (typeof this.#processing.stopReceipts !== 'function') fail('not-configured');
const state = this.#processingState();
if (!state.halted || state.invalid || stop?.generation !== state.generation) fail('reserved-event');
const stored = this.#processing.stopReceipts(state.generation).find(r => r.id === stop.id);
if (!stored || JSON.stringify(stored) !== JSON.stringify(stop)) fail('reserved-event');
this.#store.transaction(() => {
const body = { action: 'processing.stop', human: state.human, scope: 'instance', ...stored };
const old = this.#store.get('SELECT body FROM events WHERE id=?', stored.id);
if (old) {
if (old.body !== JSON.stringify(body)) fail('processing-audit-conflict');
return;
}
this.#event({ business: state.business, role: null, run: null }, 'action.allowed', body, stored.runtime, stored.id);
});
}
#processingMirror(state) {
if (!state?.operation || state.invalid) return;
const action = state.halted ? 'processing.halt' : 'processing.resume';
this.#store.transaction(() => {
const old = this.#store.get('SELECT business,kind,body FROM events WHERE id=?', state.operation);
if (old) {
const body = JSON.parse(old.body);
if (old.business !== state.business || old.kind !== 'action.allowed' || body.action !== action ||
body.operation !== state.operation || body.human !== state.human) fail('processing-audit-conflict');
return;
}
this.#event({ business: state.business, role: null, run: null }, 'action.allowed', {
action, operation: state.operation, generation: state.generation, human: state.human,
at: state.at, recovered: state.recovered, scope: 'instance',
}, state.generation, state.operation);
});
}
async #requestProcessing(s, verb, args) {
try {
this.#human(s);
keys(args, verb === 'processing.halt' ? [] : ['generation'], verb === 'processing.halt' ? [] : ['generation']);
const method = verb === 'processing.halt' ? 'halt' : 'resume';
if (typeof this.#processing[method] !== 'function') fail('not-configured');
this.#processingMirror(this.#processingState());
const result = await this.#processing[method]({ ...args, business: s.business, human: s.human });
this.#processingMirror(result);
this.#secretCheck(result);
return result;
} catch (e) {
throw this.#refused(s, e.code?.startsWith('processing-') ? new BusError(e.code) : e, { verb, args }, 'outcome-unknown');
}
}
#now() {
this.#clock = Math.max(Date.now(), this.#clock + 1);
return new Date(this.#clock).toISOString();
@@ -463,6 +549,22 @@ export class Broker {
#owner(s) {
return s.tester ? 'tester' : s.human;
}
// Row 205 (193b1a, #1703, row 193 design 5.7): a message in a thread a user other than the caller
// owns is hidden from trail and replies unless #readable holds. The business human's and the
// tester's threads are never hidden, so row 177 is unchanged. A reader owns nothing.
#hiddenMessage(s, id) {
const t = this.#store.get(
'SELECT t.* FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=? AND t.business=?',
id,
s.business,
);
return Boolean(t) && t.owner.startsWith('user:') && t.owner !== this.#owner(s) && !this.#readable(s, t);
}
// Row 193 design 5.4. Rule 1 needs a share in force, and there is no thread_shares table before
// 193b2 (schema 3j), so nobody reads another's thread yet. 193b2 adds the share rule and rules 2-4.
#readable(s, t) {
return false;
}
#agent(s) {
if (s.tester) fail('tester-refused');
if (s.reader) fail('read-only');
@@ -581,6 +683,7 @@ export class Broker {
};
// 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.
if (!old) this.#processingWork();
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
if (old) {
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
@@ -654,7 +757,7 @@ export class Broker {
const s = this.#session(cap);
return { business: s.business, role: s.role, run: s.run, human: s.human || null };
}
#event(s, kind, body, subject = null) {
#event(s, kind, body, subject = null, eid = randomUUID()) {
this.#secretCheck({ kind, body, subject });
if (
(kind.startsWith('action.') || kind.startsWith('review.')) &&
@@ -662,7 +765,6 @@ export class Broker {
subject !== (body.task_ref ?? body.target)
)
fail('task-subject-required');
const eid = randomUUID();
this.#store.run(
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
eid,
@@ -1599,6 +1701,7 @@ export class Broker {
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
#checkAuthority(s, action, context = {}) {
this.#processingWork();
this.#agent(s);
return this.#authorityFor(s, action, context);
}
@@ -1716,6 +1819,7 @@ export class Broker {
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
checkPush(cap, task_ref) {
this.#processingWork();
const s = this.#session(cap);
return this.#store.transaction(() => {
this.#agent(s);
@@ -1752,6 +1856,7 @@ export class Broker {
// project the landing uses. A refusal is a code; nothing is recorded unless it refuses.
// The ticket names the landing to recordLand and landRefused, which may run after the run ended.
checkLand(cap, args) {
this.#processingWork();
const s = this.#session(cap);
return this.#store.transaction(() => {
keys(args, ['task_ref', 'decision'], ['task_ref', 'decision']);
@@ -2029,6 +2134,7 @@ export class Broker {
// Row 93: what the git socket's fetch for a reviewer may send, from the review.requested event the
// reviewer cites. The caller must hold review.verdict and be the request's reviewer. Trusted host only.
reviewFetch(cap, { task_ref, request } = {}) {
this.#processingWork();
const s = this.#session(cap);
return this.#store.transaction(() => {
this.#agent(s);
@@ -2056,7 +2162,7 @@ export class Broker {
return this.#store.transaction(() => {
this.#agent(s);
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
if (kind === 'action.allowed' && ['git.push.working', MERGE].includes(body?.action)) fail('reserved-event');
if (kind === 'action.allowed' && ['git.push.working', MERGE, 'processing.halt', 'processing.resume', 'processing.stop'].includes(body?.action)) fail('reserved-event');
if (kind === 'action.allowed' && grantName(body)) fail('reserved-event');
return this.#event(s, kind, body, subject);
});
@@ -2179,6 +2285,13 @@ export class Broker {
const args = request.args ?? {};
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
if (request.verb === 'processing.halt' || request.verb === 'processing.resume')
return this.#requestProcessing(s, request.verb, structuredClone(args));
if (request.verb === 'processing.status') {
keys(args, []);
return this.#processingState();
}
this.#processingGate(s, request.verb);
// Row 213 (C2-08, Case 2 design 5.1 M6): a creative role has no free-text decision channel. It
// runs before the spec-cited branch below, so a cited raise refuses too.
if (request.verb === 'decision.raise' && !s.human && !s.tester &&
@@ -2479,6 +2592,7 @@ export class Broker {
async requestTask(cap, request, handler) {
const s = this.#session(cap);
try {
this.#processingWork();
keys(request, ['verb', 'args'], ['verb']);
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
const args = request.args ?? {};
@@ -2516,6 +2630,7 @@ export class Broker {
let args;
try {
keys(request, ['verb', 'args'], ['verb']);
this.#processingWork();
if (!verb) fail('unknown-verb');
args = request.args ?? {};
const field = verb === 'web.search' ? 'query' : 'url';
@@ -2544,7 +2659,8 @@ export class Broker {
let out;
try {
onAdmit();
out = await service.handle(verb, args);
out = await (typeof this.#processing.work === 'function'
? this.#processing.work(() => service.handle(verb, args)) : service.handle(verb, args));
} catch {
complete({ outcome: 'failed' });
throw refuse(new BusError('web-failed'));
@@ -2571,6 +2687,7 @@ export class Broker {
};
let a, entry, timeout, bound;
try {
this.#processingWork();
keys(request, ['verb', 'args'], ['verb']);
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
a = request.args ?? {};
@@ -3562,10 +3679,15 @@ export class Broker {
identifier(a.id);
if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
fail('message-not-found');
// Row 205 (design 5.7): a hidden id fails as an unknown one does. Hidden replies are left out
// in the query, so a page of them doesn't come back short; #readable is false until 193b2,
// which adds the share exception to this clause too.
if (this.#hiddenMessage(s, a.id)) fail('message-not-found');
return this.#store.all(
`SELECT * FROM messages WHERE business=? AND in_reply_to=? ORDER BY seq LIMIT ${REPLIES}`,
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ?) ORDER BY seq LIMIT ${REPLIES}`,
s.business,
a.id,
this.#owner(s) || null,
);
}
default:
@@ -3814,6 +3936,8 @@ export class Broker {
for (const m of rows) this.#delivery(m.id, 'routed', h);
}
#trail(s, subject) {
// Row 205 (design 5.7, sage-2288): a hidden subject answers exactly as an unknown id does.
if (this.#hiddenMessage(s, subject)) return [];
const rows = new Map();
const add = (table, list) => {
for (const row of list) {
@@ -3895,6 +4019,18 @@ export class Broker {
this.#store.all('SELECT * FROM role_claims WHERE business=? AND holder_run=?', s.business, run),
);
}
// Row 205 (design 5.7): drop hidden messages, their deliveries and the events about them.
// Decision rows and their events stay: the run's governance is the owner's to see.
const hidden = new Set(
[...rows.values()].filter((r) => r.table === 'messages' && this.#hiddenMessage(s, r.id)).map((r) => r.id),
);
for (const [key, r] of rows)
if (
(r.table === 'messages' && hidden.has(r.id)) ||
(r.table === 'deliveries' && hidden.has(r.message)) ||
(r.table === 'events' && hidden.has(r.subject))
)
rows.delete(key);
return [...rows.values()].sort(
(a, b) => a.at.localeCompare(b.at) || a.table.localeCompare(b.table) || a.seq - b.seq,
);
+1 -1
View File
@@ -27,7 +27,7 @@ export class Client {
socket.destroy();
error ? reject(error) : resolve(value);
};
socket.setTimeout(this.timeout, () => end(new BusError('outcome-unknown')));
socket.setTimeout(['processing.halt', 'processing.resume'].includes(verb) ? Math.max(this.timeout, 20000) : this.timeout, () => end(new BusError('outcome-unknown')));
socket.on('connect', () => socket.write(request));
socket.on('error', () => end(new BusError('outcome-unknown')));
socket.on('end', () => end(new BusError('outcome-unknown')));
+3
View File
@@ -24,6 +24,9 @@ try {
const me = readProcess(process.pid);
const client = new Client({
path: process.argv[2],
// Row 254: library.sync fetches for up to 100 s and the server holds its socket for 120 s,
// so wait past that for the server's own answer. Every other verb keeps the 5 s default.
timeout: request.verb === 'library.sync' ? 130_000 : undefined,
human: {
business: request.business,
pid: me.pid,
+51 -11
View File
@@ -23,6 +23,9 @@ import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
import { createUsage } from './usage.mjs';
import { createLimits } from './limits.mjs';
import { createProcessing } from '../../processing-control/src/coordinator.mjs';
import { discordProcessingHooks } from '../../discord/src/processing.mjs';
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
@@ -91,7 +94,7 @@ export async function startBroker({
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let store, credentials, server, adapter, syncTimer = null, syncPass = null;
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null;
try {
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -138,11 +141,20 @@ export async function startBroker({
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
let broker;
processing = createProcessing({ dataRoot, ...discordProcessingHooks(dataRoot), containerStop: createContainerStopper({ dataRoot }),
onTransition: () => broker.reconcileProcessing(),
onStop: stop => broker.recordProcessingStop(stop),
log: line => process.stderr.write(`${line}\n`),
});
// C2-04: mirrors are host state, never session checkouts. An unsafe
// existing mirror refuses startup before a socket can become ready.
for (const business of Object.values(businesses))
for (const [project, entry] of Object.entries(business.projects ?? {}))
if (entry.kind === 'library') await libraryModule.ensureMirror({ business, project, dataRoot });
if (entry.kind === 'library') {
try { await processing.work(() => libraryModule.ensureMirror({ business, project, dataRoot })); }
catch (e) { if (!e.code?.startsWith('processing-')) throw e; }
}
// C2-03a: the slot is <configDir>/secrets/<business>/library-gitea (design 10 step 4). Sync only
// checks that it is there; the askpass helper reads it.
const slotDir = librarySync?.configDir ?? organization?.dir ?? null;
@@ -150,10 +162,11 @@ export async function startBroker({
slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null,
...(librarySync ? { command: librarySync.command } : {}),
});
const broker = new Broker({
broker = new Broker({
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,
processing,
usage,
limits,
store,
@@ -161,12 +174,12 @@ export async function startBroker({
library: {
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
sync: (context, { accept }) => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept }),
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
LibraryError: libraryModule.LibraryError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
writeRecord: context => processing.work(() => projectModule.writeRecord({ ...context, dataRoot })),
listFiles: context => projectModule.listProjectFiles({ ...context, dataRoot }),
readFile: context => projectModule.readProjectFile({ ...context, dataRoot }),
writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }),
@@ -205,6 +218,13 @@ export async function startBroker({
}
},
});
broker.reconcileProcessing();
processing.start();
const processingWork = () => {
const state = processing.read();
if (state.invalid) throw new BusError(state.code);
if (state.halted) throw new BusError('processing-halted');
};
// Process identities must be supplied for every managed run when using the human transport.
const launchRecords = [];
function bindLaunch(r) {
@@ -222,7 +242,7 @@ export async function startBroker({
const bound = launches.map(bindLaunch);
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
if (tasks) {
adapter = await tasks({ broker, credentials, businesses });
adapter = await tasks({ broker, credentials, businesses, processing });
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
throw new BusError('invalid-adapter');
}
@@ -241,9 +261,18 @@ export async function startBroker({
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
};
const path = join(store.directory, 'broker.sock');
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
// signal with processing cancellation without changing its proof or credential entry points.
const host = runRoot ? hostExecutor({ root: runRoot, credentials: {
busy: (...args) => credentials.busy(...args),
useFor: (session, name, fn, options) => credentials.useFor(session, name, (token, meta) =>
fn(token, { ...meta, signal: meta.signal
? AbortSignal.any([meta.signal, processing.signal()]) : processing.signal() }), options),
} }) : null;
server = await serve({
web: (business) => web.get(business) ?? null,
host: runRoot ? hostExecutor({ credentials, root: runRoot }) : null,
host: host ? { busy: (...args) => host.busy(...args),
run: (...args) => processing.work(() => host.run(...args)) } : null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
@@ -297,11 +326,11 @@ export async function startBroker({
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
const entry = { state: 'running' };
remotes.set(ticket, entry);
withGitea(ticket, (token) =>
processing.work(() => withGitea(ticket, (token) =>
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
throw e instanceof WorkingError ? new BusError(e.code) : e;
}),
).then(
)).then(
(r) => Object.assign(entry, { state: 'pushed', ...r }),
(e) => {
// Not a refusal: the error's name and code only, never a message that could carry git's output.
@@ -374,7 +403,13 @@ export async function startBroker({
}
}
};
const tick = () => { if (!syncPass) syncPass = syncAll().finally(() => { syncPass = null; }); };
const tick = () => {
const state = processing.read();
if (!syncPass && !state.halted && !state.invalid)
syncPass = processing.work(syncAll).catch(e => {
if (!e.code?.startsWith('processing-')) syncLog({ event: 'library-sync-refused', code: 'library-sync-failed' });
}).finally(() => { syncPass = null; });
};
if (timed.length) {
tick();
syncTimer = setInterval(tick, librarySync?.intervalMs ?? libraryModule.SYNC_INTERVAL_MS);
@@ -390,7 +425,10 @@ export async function startBroker({
threadsState: (business) => broker.threadsState(business),
threadIdle: (r) => broker.threadIdle(r),
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
mayLaunch: ({ business, instance, provider, model, account = null }) => limits.mayLaunch(business, { instance, provider, model, account }),
mayLaunch: async ({ business, instance, provider, model, account = null }) => {
processingWork();
return limits.mayLaunch(business, { instance, provider, model, account });
},
identity: (cap) => broker.identity(cap),
authorizeLaunch,
checkPush,
@@ -417,6 +455,7 @@ export async function startBroker({
await stopSync();
await server.close();
await adapter?.close?.();
await processing?.close();
store.close();
credentials.close();
},
@@ -424,6 +463,7 @@ export async function startBroker({
} catch (e) {
await server?.close();
await adapter?.close?.();
await processing?.close();
store?.close();
credentials?.close();
throw e;
+3 -2
View File
@@ -134,8 +134,9 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
});
return;
}
// Row138: only project Git effects need an await; preserve every other sync path below.
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
// Async effects retain the synchronous path for every other verb.
if (r.verb === 'processing.halt' || r.verb === 'processing.resume' ||
r.verb === 'usage.begin' || r.verb === 'usage.end' ||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) || LIBRARY_VERBS.includes(r.verb) ||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
HOSTS_VERBS.includes(r.verb) ||
+4 -1
View File
@@ -11,6 +11,7 @@
// every call and labels every reply untrusted (broker.mjs, requestWeb).
import { SEARCH_MAX_QUERY_CHARS, WEB_DEFAULTS, WEB_REFUSAL, WebRefusal, webFetch } from "../../discord/src/web.mjs";
import { currentProcessing } from '../../processing-control/src/effects.mjs';
export const WEB_SEARCH_NOT_CONFIGURED = "search is not configured for this business";
// Under the runner's own 30 s wait for a tool call, so a slow source is a refusal, not a lost reply.
@@ -28,6 +29,7 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
// so a stalled backend is cut off, not just ignored: web.mjs destroys its request on abort, and a
// search-source module gets the signal as the `signal` of its last argument.
const within = (start) => {
const processing = currentProcessing(); processing?.guard();
const controller = new AbortController();
let timer;
const late = new Promise((_, reject) => {
@@ -36,7 +38,8 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
reject(new WebRefusal(WEB_REFUSAL.TIMEOUT));
}, deadlineMs);
});
return Promise.race([start(controller.signal), late]).finally(() => clearTimeout(timer));
const signal = processing ? AbortSignal.any([processing.signal, controller.signal]) : controller.signal;
return Promise.race([start(signal), late]).finally(() => clearTimeout(timer));
};
async function run(verb, args) {
if (verb === "web.fetch") {
+4 -2
View File
@@ -2,6 +2,8 @@
// Row 196 intentionally re-pins human.mjs for the distinct user nonce and shared human
// walk, and webui bus.mjs for initial-spawn nonce plus peer refusal status. Entry paths
// and the ban on channel plug-ins importing a proof caller are retained.
// Row 254 (#1759) re-pins human-cli.mjs for the 130 s library.sync client timeout and webui
// bus.mjs for six library-* STATUS entries; no proof or transport change.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -12,14 +14,14 @@ import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073",
"packages/bus/src/human-cli.mjs": "9c9af66b98ea0a490c9a880fc81c3c62414a8ee0b5ba6478608771b8c4f87932",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
// transport change. Row 155 (#1649) added eight for the assistant routes.
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f",
"packages/webui/src/bus.mjs": "e69d17d4385a4d190d15373ed1a26e270a5c6906eec9e61fcc2bb6b05849cabb",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+36
View File
@@ -4,6 +4,9 @@ import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer } from 'node:net';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, LIBRARY_VERBS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
@@ -107,3 +110,36 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
assert.equal((await library.readSyncState(mirror, { branch: 'main' })).lastError.code, 'library-fetch-failed');
});
// Row 254 (#1759): the human transport waits past the server's 120 s library hold for library.sync
// only. A stand-in bus answers each request after 6 s, past the 5 s default every other verb keeps.
test('row 254 human-cli: library.sync outlasts the 5 s client default, and library.status does not', { timeout: 20000 }, async t => {
const root = mkdtempSync(join(tmpdir(), 'bus-library-cli-'));
const socket = join(root, 'bus.sock'), timers = new Set();
const server = createServer(conn => {
let input = '';
conn.on('error', () => {});
conn.on('data', b => {
input += b;
if (!input.includes('\n')) return;
const { verb } = JSON.parse(input);
timers.add(setTimeout(() => { if (!conn.destroyed) conn.end(JSON.stringify({ ok: true, result: { verb } }) + '\n'); }, 6000));
});
});
await new Promise(r => server.listen(socket, r));
t.after(() => { for (const timer of timers) clearTimeout(timer); server.close(); rmSync(root, { recursive: true, force: true }); });
const cli = fileURLToPath(new URL('../src/human-cli.mjs', import.meta.url));
const run = verb => new Promise(resolve => {
const child = spawn(process.execPath, [cli, socket], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '', err = '';
child.stdout.on('data', b => { out += b; });
child.stderr.on('data', b => { err += b; });
child.on('close', status => resolve({ status, out, err }));
child.stdin.end(JSON.stringify({ business: 'acme', verb, args: { project: 'books' } }) + '\n');
});
const [sync, status] = await Promise.all([run('library.sync'), run('library.status')]);
assert.equal(sync.status, 0, sync.err);
assert.deepEqual(JSON.parse(sync.out), { verb: 'library.sync' });
assert.equal(status.status, 2);
assert.equal(status.err.trim(), 'outcome-unknown');
});
+193
View File
@@ -0,0 +1,193 @@
// Row 205 (193b1a, #1703, row 193 design 5.7, sage-2288): a user's messages are private from the
// business owner, reader sessions and the tester on trail and replies; the run's governance is not.
// Nothing makes a user session before FED R3, so a user's thread and its messages are written to the
// store as R3's thread.send and the run's reply will write them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { Broker, REPLIES } from '../src/broker.mjs';
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' }];
// A thread owned by `owner`, on the business's assistant instance.
function threadOf(store, project, owner, name) {
const id = randomUUID();
store.run(
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
id, AT, 'demo', project, 'assistant', owner, name,
);
return id;
}
// A message with its thread row, its routed delivery and one event about it. The event names the
// message's decision in its body, which is how #trail reaches it from the decision.
function put(store, { thread = null, from_role, from_run, to_role, in_reply_to = null, decision = null, body }) {
const id = randomUUID();
store.run(
'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);
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(?,?,?,?,?,?,?,?)',
randomUUID(), AT, 'demo', 'action.allowed', from_role, from_run, id, JSON.stringify(decision ? { decision } : {}),
);
return id;
}
// From alice to the instance, as R3's thread.send writes it.
const fromAlice = (store, thread, body, extra = {}) =>
put(store, { thread, from_role: 'user', from_run: 'user:alice', to_role: 'assistant', body, ...extra });
// The run's reply to alice, as R3's `to: 'user'` path writes it.
const toAlice = (store, thread, body, extra = {}) =>
put(store, { thread, from_role: 'assistant', from_run: 'assistant-run', to_role: 'user', body, ...extra });
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-message-filter-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: { chief: { definition: 'chief-of-staff', ...within(['message.send']) }, assistant: { definition: 'assistant', ...within(['message.send']) } },
},
},
});
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const project = call('human', 'project.create', { name: 'shared work' }).id;
const U = threadOf(store, project, 'user:alice', 'alice');
return { b, store, caps, call, project, U };
}
const sessions = ['human', 'reader', 'tester'];
// What a caller sees of a call: its result, or its refusal's code and message.
const answer = (fn) => {
try {
return JSON.stringify({ result: fn() });
} catch (e) {
return JSON.stringify({ code: e.code, message: e.message });
}
};
test('trail on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester', (t) => {
const { store, call, U } = setup(t);
const A = fromAlice(store, U, 'alice: my salary is private');
const unknown = randomUUID();
for (const who of sessions) {
const hidden = answer(() => call(who, 'trail', { subject: A }));
assert.equal(hidden, answer(() => call(who, 'trail', { subject: unknown })), who);
assert.equal(hidden, JSON.stringify({ result: [] }), who);
}
});
test('replies on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester', (t) => {
const { store, call, U } = setup(t);
const A = fromAlice(store, U, 'alice: hello');
toAlice(store, U, 'hello alice', { in_reply_to: A });
const unknown = randomUUID();
for (const who of sessions) {
const hidden = answer(() => call(who, 'replies', { id: A }));
assert.equal(hidden, answer(() => call(who, 'replies', { id: unknown })), who);
assert.equal(JSON.parse(hidden).code, 'message-not-found', who);
}
});
test('trail on a decision keeps the decision and drops the user thread messages, their deliveries and their events', (t) => {
const { b, store, call, U } = setup(t);
const run = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: U });
b.request(run, { verb: 'role.claim' });
const d = b.request(run, {
verb: 'decision.raise',
args: { action: 'deploy', question: 'Ship it?', options, recommendation: 'yes', blocking: false },
}).id;
const hidden = toAlice(store, U, 'alice, I asked the owner about shipping', { decision: d });
const shown = put(store, { from_role: 'assistant', from_run: 'assistant-run', to_role: 'human', body: 'Ship it, owner?', decision: d });
for (const who of sessions) {
const rows = call(who, 'trail', { subject: d });
assert.ok(rows.some((r) => r.table === 'decisions' && r.id === d), `${who}: the decision row shows`);
assert.ok(rows.some((r) => r.table === 'messages' && r.id === shown), `${who}: the owner's message shows`);
assert.ok(rows.some((r) => r.table === 'deliveries' && r.message === shown), `${who}: its delivery shows`);
assert.ok(rows.some((r) => r.table === 'events' && r.subject === shown), `${who}: its event shows`);
assert.equal(rows.filter((r) => r.table === 'messages' && r.id === hidden).length, 0, `${who}: no hidden message`);
assert.equal(rows.filter((r) => r.table === 'deliveries' && r.message === hidden).length, 0, `${who}: no hidden delivery`);
assert.equal(rows.filter((r) => r.table === 'events' && r.subject === hidden).length, 0, `${who}: no hidden event`);
assert.equal(JSON.stringify(rows).includes('alice, I asked'), false, `${who}: the body appears nowhere`);
}
});
test('replies leaves out other users thread messages and still fills a page', (t) => {
const { store, call, U } = setup(t);
const P = put(store, { from_role: 'human', from_run: 'human-cli', to_role: 'chief', body: 'Status, everyone?' });
for (let i = 0; i < REPLIES; i++) toAlice(store, U, `hidden ${i}`, { in_reply_to: P });
const shown = put(store, { from_role: 'chief', from_run: 'chief-run', to_role: 'human', body: 'All green.', in_reply_to: P });
for (const who of sessions) assert.deepEqual(call(who, 'replies', { id: P }).map((m) => m.id), [shown], who);
});
test('messages in the business human and tester threads are not hidden (row 177 unchanged)', (t) => {
const { store, call, project } = setup(t);
const H = threadOf(store, project, 'jason', 'mine');
const T = threadOf(store, project, 'tester', 'probe');
const h = put(store, { thread: H, from_role: 'human', from_run: 'human-cli', to_role: 'assistant', body: 'from jason' });
const x = put(store, { thread: T, from_role: 'tester', from_run: 'tester-cli', to_role: 'assistant', body: 'from tester' });
const hr = put(store, { thread: H, from_role: 'assistant', from_run: 'r', to_role: 'human', body: 'to jason', in_reply_to: h });
const xr = put(store, { thread: T, from_role: 'assistant', from_run: 'r', to_role: 'tester', body: 'to tester', in_reply_to: x });
for (const who of sessions) {
for (const id of [h, x]) assert.ok(call(who, 'trail', { subject: id }).some((r) => r.table === 'messages' && r.id === id), `${who} ${id}`);
assert.deepEqual(call(who, 'replies', { id: h }).map((m) => m.id), [hr], who);
assert.deepEqual(call(who, 'replies', { id: x }).map((m) => m.id), [xr], who);
}
});
// Row 193's user sentence, one assertion per item: "The business owner sees your thread's agent's
// decisions, inbox entries, claims, tasks and project files, but not your messages."
test('governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user messages', async (t) => {
const f = await fixture(t, { roles: { assistant: { definition: 'assistant', ...within(['message.send', 'record.write']) } } });
const project = (await f.call(f.human, 'project.create', { name: 'alice work' })).id;
const U = threadOf(f.store, project, 'user:alice', 'alice');
const run = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: U });
await f.call(run, 'role.claim');
const A = fromAlice(f.store, U, 'alice: please write up my plan');
toAlice(f.store, U, 'alice, done', { in_reply_to: A });
await f.call(run, 'record.write', { project: 'stack', path: 'docs/alice-plan.md', record: '# Plan\n' });
const d = (await f.call(run, 'decision.raise', { action: 'deploy', question: 'Ship the plan?', options, recommendation: 'yes', blocking: false })).id;
f.store.run(
'INSERT INTO task_snapshots(at,business,task_ref,updated,digest,fields,source,via,read_at,role,run) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
AT, 'demo', 'vikunja:3/47', AT, 'a'.repeat(64), JSON.stringify({ bucket: 1, done: false }), 'self', null, null, 'assistant', 'assistant-run',
);
const inbox = await f.call(f.human, 'inbox');
assert.ok(inbox.some((x) => x.id === d), 'inbox entries: the run raise is in the owner inbox');
// A thread run's claim is in thread_claims, and `agents` reads role_claims only, so no owner verb
// shows it yet (sage-2430). This is the launcher view: the claim is recorded and the filter leaves it
// alone. The follow-up row Sage adds for an owner-visible thread-claim read moves this to an owner verb.
const claim = f.broker.threadsState('demo').find((x) => x.thread === U);
assert.equal(claim?.holder, 'assistant-run', 'claims: the run claim is recorded');
const tasks = await f.call(f.human, 'tasks');
assert.ok(tasks.some((x) => x.task_ref === 'vikunja:3/47' && x.run === 'assistant-run'), 'tasks: the run task write shows');
const file = await f.call(f.human, 'project.file', { project: 'stack', path: 'docs/alice-plan.md' });
assert.equal(file.content, '# Plan\n', 'project files: the run record shows');
await f.call(f.human, 'decision.resolve', { id: d, choice: 'yes' });
const recent = await f.call(f.human, 'decisions', { list: 'recent' });
assert.ok(recent.some((x) => x.id === d), 'decisions: the closed raise shows in recent');
assert.deepEqual(await f.call(f.human, 'trail', { subject: A }), [], 'messages: trail hides the user message');
await assert.rejects(Promise.resolve().then(() => f.call(f.human, 'replies', { id: A })), { code: 'message-not-found' }, 'messages: replies hides it');
const trail = await f.call(f.human, 'trail', { subject: d });
assert.ok(trail.some((r) => r.table === 'decisions' && r.id === d), 'decisions: the trail keeps the decision');
assert.equal(JSON.stringify(trail).includes('alice'), false, 'messages: no user body on the decision trail');
});
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { startBroker } from '../src/runtime.mjs';
import { Client } from '../src/client.mjs';
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
import { readControl } from '../../processing-control/src/state.mjs';
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
async function broker(root) {
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
return { r, client: new Client({ path: r.path, cap, timeout: 15000 }) };
}
test('real runtime injects the coordinator, stops its workload and mirrors stable stop events across restart', async t => {
const root = mkdtempSync(join(tmpdir(), 'processing-runtime-bus-'));
let live;
const p = spawn(process.execPath, ['-e', "console.log('ready');setInterval(()=>{},1000)"], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
const exit = once(p, 'exit');
t.after(async () => {
await live?.close();
if (p.exitCode === null && p.signalCode === null) { try { process.kill(-p.pid, 'SIGKILL'); } catch {} await exit; }
rmSync(root, { recursive: true, force: true });
});
await once(p.stdout, 'data');
let current = await broker(root); live = current.r;
const receipt = registerRuntime(root, { kind: 'session', business: 'demo', run: 'runtime-fixture' });
updateRuntime(root, receipt.id, { ...processIdentity(p.pid), phase: 'running', group: true });
const halted = await current.client.call('processing.halt');
assert.equal(halted.halted, true);
assert.equal(halted.quiescent, true);
await exit;
assert.equal(processIdentity(p.pid), null);
await assert.rejects(live.mayLaunch({ business: 'demo', instance: 'coder', provider: 'fixture', model: 'fixture' }), { code: 'processing-halted' });
await live.close(); live = null;
current = await broker(root); live = current.r;
assert.equal((await current.client.call('processing.status')).halted, true);
const events = [halted.generation, receipt.id].flatMap(subject =>
live.broker.request(live.readers[0].cap, { verb: 'trail', args: { subject } }));
assert.equal(events.filter(e => e.body?.action === 'processing.halt').length, 1);
assert.equal(events.filter(e => e.body?.action === 'processing.stop').length, 1);
assert.equal((await current.client.call('processing.resume', { generation: halted.generation })).halted, false);
});
test('invalid state still starts the control bus and human halt then resume repairs it', async t => {
const root = mkdtempSync(join(tmpdir(), 'processing-repair-bus-'));
let live;
t.after(async () => { await live?.close(); rmSync(root, { recursive: true, force: true }); });
let current = await broker(root); live = current.r;
await live.close(); live = null;
writeFileSync(join(root, 'control', 'processing.json'), '{');
current = await broker(root); live = current.r;
assert.equal((await current.client.call('processing.status')).code, 'processing-state-invalid');
await assert.rejects(current.client.call('processing.resume', { generation: 'bad' }), { code: 'processing-state-invalid' });
const halted = await current.client.call('processing.halt');
assert.equal(halted.recovered, true);
assert.equal((await current.client.call('processing.resume', { generation: halted.generation })).halted, false);
assert.equal(readControl(root).halted, false);
});
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
test('processing halt and resume extend only the socket timeout while ordinary status retains the explicit timeout', async t => {
const root = mkdtempSync(join(tmpdir(), 'processing-client-')), path = join(root, 'fixture.sock');
const seen = [], sockets = new Set();
const server = createServer(socket => {
sockets.add(socket); let buffer = '', timer;
socket.on('error', () => {});
socket.on('close', () => { sockets.delete(socket); clearTimeout(timer); });
socket.on('data', data => {
buffer += data;
if (!buffer.includes('\n')) return;
seen.push(JSON.parse(buffer));
timer = setTimeout(() => socket.end(JSON.stringify({ ok: true, result: { real: 'completion' } }) + '\n'), 80);
});
});
await new Promise(resolve => server.listen(path, resolve));
t.after(async () => { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)); rmSync(root, { recursive: true, force: true }); });
const client = new Client({ path, cap: 'a'.repeat(64), timeout: 15 });
for (const verb of ['processing.halt', 'processing.resume']) {
assert.deepEqual(await client.call(verb, {}), { real: 'completion' });
assert.equal(client.timeout, 15, 'the constructor setting stays unchanged');
}
await assert.rejects(client.call('processing.status'), { code: 'outcome-unknown' });
assert.deepEqual(seen, ['processing.halt', 'processing.resume', 'processing.status'].map(verb => ({ cap: 'a'.repeat(64), verb, args: {} })));
});
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initializeControl, transition } from '../../processing-control/src/state.mjs';
import { withProcessing } from '../../processing-control/src/effects.mjs';
import { webService } from '../src/web.mjs';
test('a root halt aborts the active search-source signal before the normal web deadline', async t => {
const root = mkdtempSync(join(tmpdir(), 'processing-web-')); initializeControl(root);
t.after(() => rmSync(root, { recursive: true, force: true }));
let ready, observed;
const started = new Promise(resolve => { ready = resolve; });
const service = webService({ deadlineMs: 3000, search: { search: (_query, { signal }) => new Promise((_resolve, reject) => {
observed = signal; signal.addEventListener('abort', () => reject(signal.reason), { once: true }); ready();
}) } });
const call = withProcessing(root, () => service.handle('web.search', { query: 'fixture' }));
await started; const began = Date.now(); transition(root, { business: 'demo', human: 'jason', halted: true });
await call.catch(() => {});
assert.equal(observed.aborted, true); assert.equal(observed.reason.name, 'ProcessingError');
assert.ok(Date.now() - began < 1500);
});
+143
View File
@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
const state = await import('../../processing-control/src/state.mjs').catch(e => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
});
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
} } };
function fixture(t, extra = {}) {
assert.equal(typeof state.initializeControl, 'function');
const root = mkdtempSync(join(tmpdir(), 'processing-bus-'));
state.initializeControl(root);
const store = new Store(root);
const processing = {
read: () => state.readControl(root),
async halt(actor) { return state.transition(root, { ...actor, halted: true }); },
async resume(actor) { return state.transition(root, { ...actor, halted: false }); },
};
const b = new Broker({ store, businesses, processing, ...extra });
const cap = b.bindLaunch({ business: 'demo', role: 'coder', run: 'run', harness: 'pi' });
b.request(cap, { verb: 'role.claim' });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = b.bindReader({ business: 'demo' });
const tester = b.bindTester({ business: 'demo' });
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
return { root, b, store, cap, human, reader, tester, processing,
call: (who, verb, args = {}) => b.request(who, { verb, args }) };
}
// Dropping #human before either effect would allow a role/tester to clear the switch.
for (const verb of ['processing.halt', 'processing.resume']) test(`${verb} refuses agent reader and tester before any transition`, async t => {
const f = fixture(t), before = state.readControl(f.root);
for (const cap of [f.cap, f.reader, f.tester]) {
await assert.rejects(async () => f.call(cap, verb, { generation: before.generation }), e => ['human-required', 'read-only', 'tester-refused'].includes(e.code));
}
assert.deepEqual(state.readControl(f.root), before);
});
test('human halt and clear each have one event with verified actor; launch.restore cannot clear', async t => {
const f = fixture(t);
const halted = await f.call(f.human, 'processing.halt');
assert.equal(halted.halted, true);
await f.call(f.human, 'processing.halt');
f.call(f.human, 'launch.restore');
assert.equal(f.call(f.reader, 'processing.status').halted, true);
const resumed = await f.call(f.human, 'processing.resume', { generation: halted.generation });
assert.equal(resumed.halted, false);
const events = f.store.all("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') IN ('processing.halt','processing.resume')").map(r => JSON.parse(r.body));
assert.deepEqual(events.map(e => [e.action, e.human]), [['processing.halt', 'jason'], ['processing.resume', 'jason']]);
assert.equal(new Set(events.map(e => e.operation)).size, 2);
});
test('all ordinary work verbs refuse before dispatch while pure reads remain available', async t => {
const f = fixture(t); await f.call(f.human, 'processing.halt');
for (const verb of ['role.claim', 'role.revoke', 'decision.raise', 'decision.resolve', 'message.send', 'message.receive', 'message.read', 'staff.request', 'review.request', 'review.verdict', 'event.emit', 'organization.set', 'record.write', 'usage.begin', 'unknown.future.work']) {
await assert.rejects(async () => f.call(f.cap, verb), { code: 'processing-halted' }, verb);
}
assert.equal(f.call(f.cap, 'state').launch.state, 'allowed');
assert.ok(Array.isArray(f.call(f.reader, 'agents')));
assert.equal(f.call(f.reader, 'processing.status').halted, true);
// A human can still queue intent and manage settings under their existing permissions.
assert.doesNotThrow(() => f.call(f.human, 'message.send', { to: 'coder', body: 'wait for resume' }));
assert.doesNotThrow(() => f.call(f.cap, 'role.release'));
});
test('task web authorization and bind paths refuse before external work', async t => {
const f = fixture(t); await f.call(f.human, 'processing.halt');
let effects = 0;
await assert.rejects(f.b.requestTask(f.cap, { verb: 'task.get', args: {} }, () => { effects++; }), { code: 'processing-halted' });
await assert.rejects(f.b.requestWeb(f.cap, { verb: 'web.fetch', args: { url: 'https://example.com' } }, { handle() { effects++; } }), { code: 'processing-halted' });
assert.throws(() => f.b.authorize(f.cap, 'role.launch', { target: 'coder' }), { code: 'processing-halted' });
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'coder', run: 'later', harness: 'pi' }), { code: 'processing-halted' });
assert.equal(effects, 0);
});
for (const action of ['processing.halt', 'processing.resume', 'processing.stop']) test(`recordEvent cannot forge ${action}`, t => {
const f = fixture(t);
assert.throws(() => f.b.recordEvent(f.cap, { kind: 'action.allowed', body: { action, human: 'jason' } }), { code: 'reserved-event' });
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed'").n, 0);
});
for (const own of [false, true]) test(`recordTask ${own ? 'self' : 'poll'} cannot forge a processing action through its kinds filter`, t => {
const f = fixture(t);
assert.throws(() => f.b.recordTask({ business: 'demo', ...(own ? { cap: f.cap } : {}), events: [
{ kind: 'action.allowed', body: { action: 'processing.halt', human: 'jason' } },
] }), { code: 'reserved-event' });
});
test('spec read remains a read but cannot release new write work after an await', async t => {
let finish;
const ready = new Promise(resolve => { finish = resolve; });
const f = fixture(t, { project: { async writeRecord() { await ready; return { path: 'docs/a.md', commit: 'a'.repeat(40) }; } } });
const writing = f.call(f.cap, 'record.write', { project: 'stack', path: 'docs/a.md', record: '# A' });
await f.call(f.human, 'processing.halt'); finish();
// An already committed external effect must remain outcome-unknown, not a fake rollback.
await assert.rejects(writing, { code: 'outcome-unknown' });
});
test('the real socket awaits halt and resume beyond the ordinary idle deadline', async t => {
const f = fixture(t), path = join(f.root, 'control.sock');
const halt = f.processing.halt;
f.processing.halt = async args => { await new Promise(r => setTimeout(r, 30)); return halt(args); };
let peers = 0;
const server = await serve({ broker: f.b, path, timeout: 5,
// Row 196 added asynchronous kernel-peer discovery before the dispatch timeout.
// This test isolates the post-auth await; peer proof is exercised in its owning files.
peerReader: { close: async () => {}, read: async () => { peers++; return { pid: process.pid, uid: process.getuid(), gid: process.getgid() }; } },
authenticateHuman: () => f.b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) });
try {
const client = new Client({ path, human: { fixture: true }, timeout: 1000 });
const halted = await client.call('processing.halt');
assert.equal(halted.halted, true);
assert.equal((await client.call('processing.resume', { generation: halted.generation })).halted, false);
assert.equal((await new Client({ path, cap: f.reader }).call('processing.status')).halted, false);
assert.equal(peers, 2);
} finally { await server.close(); }
});
test('a malformed processing reader never silently admits agent work', t => {
const f = fixture(t);
for (const answer of [null, {}, { halted: false }, { version: 1, generation: 'bad', halted: false }, { halted: 'false' }]) {
f.processing.read = () => answer;
assert.throws(() => f.call(f.cap, 'message.send', { to: 'human', body: 'no' }), { code: 'processing-state-invalid' });
}
});
test('halt refuses the newly landed host executor path before credentials or effects', async t => {
const f = fixture(t); await f.call(f.human, 'processing.halt');
let called = false;
await assert.rejects(f.b.requestHost(f.cap, { verb: 'host.run', args: {} }, {
busy: () => { called = true; return false; }, run: async () => { called = true; },
}), e => e.code === 'processing-halted');
assert.equal(called, false);
});
+3 -1
View File
@@ -15,7 +15,9 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
"import { isAbsolute } from 'node:path';",
"import { isAbsolute, dirname } from 'node:path';",
// Row 191 / sage-1367: the one additional read-only CODE source, with no project/git imports.
"import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';",
// Row 65: the plug-in name pattern a harness takes. kinds.mjs is in RUNNER_FILES and imports nothing.
"import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';",
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
+5 -2
View File
@@ -249,16 +249,19 @@ test('the runtime refuses an invalid adapter and closes a valid one', async (t)
// Each refusal closed what it had opened, the adapter included; the writer lock is free.
assert.equal(closed, 4);
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
let given = null;
let given = null, processing = null;
const runtime = await startBroker({
dataRoot: root,
businesses,
tasks: async (deps) => {
given = Object.keys(deps).sort();
processing = deps.processing;
return { handle: async () => ({ ok: 1 }), timeout: 1000, close: async () => closed++ };
},
});
assert.deepEqual(given, ['broker', 'businesses', 'credentials']);
// Row 191: runtime injects cancellation alongside the existing factory dependencies.
assert.deepEqual(given, ['broker', 'businesses', 'credentials', 'processing']);
assert.equal(typeof processing.work, 'function');
await runtime.close();
assert.equal(closed, 5);
await assert.rejects(
+5 -1
View File
@@ -21,8 +21,12 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
assert.ok(Object.isFrozen(loaded.projects.books.universes.harbor.books));
const only = structuredClone(doc); delete only.projects.stack;
assert.deepEqual(Object.keys(validateBusinessDocument(only, file, { rolesDir: REPO_ROLES }).projects), ['books']);
// C2-01c: a prefix outside every book is a universe-level playground; one inside another
// universe's book refuses.
doc.projects.books.universes.harbor.playground = ['Elsewhere/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-outside-book/);
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
});
+40
View File
@@ -1061,6 +1061,35 @@ A status line shows `revoking:` with the tags of a revoke still in progress.
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
command. A usage error exits 4 and a bus refusal 2.
## Access (FED R5, #414)
```
mosaic access list --business ID [--json]
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
mosaic access revoke --business ID --user ID [--grant GID] --revision N
```
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
write runs the file's checks on the new document first, bumps the revision by one, and renames
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
`<dataRoot>/state/access-log.jsonl`.
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
writable by group or other. If it is not, or a business file fails to load, the command refuses
with exit 3, names the file and the key path, and writes nothing.
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
once. Removing a provider from `console.sso` takes effect only after a Console restart.
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
@@ -1078,3 +1107,14 @@ The helper supplies the fixed logical origin, reports status/text/body as JSON,
and never retries an uncertain write. It cannot navigate a browser or upgrade the
tester to an owner. `mosaic ... --tester` continues using broker.sock directly.
See docs/TOOLS.md for explicit activation conversion and rollback behavior.
## Instance processing switch
`mosaic halt [--business ID]` and `mosaic resume [--business ID]` use the human
management path even when the CLI channel is disabled. The business chooses
proof context; the affected scope is every managed workload in the resolved
data root. Read `mosaic processing status --json` after an unknown or partial
result. Resume refuses unconfirmed stops and a stale generation. A restarted
`bus start --pm` keeps its control bus up while halted and skips the chief.
The switch does not clear other launch/Discord brakes or retry tasks. Native
diagnostic launch stays available. See packages/processing-control/README.md.
+288
View File
@@ -0,0 +1,288 @@
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
// The human edits <configDir>/access.json here. Each write is checked against the
// instance's context before it lands, bumps the revision by one, and appends one
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
// through humanTransport: a bus that does not see a human shell refuses it, so
// no agent and no tester can write. The context (providers, peers, businesses,
// ssoSubjects) comes in as a function; the caller decides where it comes from.
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { refuseInsideAgent } from "./transport.mjs";
export const ACCESS_USAGE = [
"mosaic access list --business ID [--json]",
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
].join("\n");
// The help text. A grant's `instances` is the real limit: the granted instance
// runs with the owner's role, so a collaborator drives it with that authority.
// Removing a user acts at once. Removing a provider from console.sso waits for a
// Console restart, so take the users out of access.json first. Peer homes are
// empty until the federation key lands (row 203), so a grant naming a peer
// home refuses until then. A grant naming a subject needs console.sso.
export const ACCESS_HELP = `${ACCESS_USAGE}
A grant gives a user exactly what the granted instances can do. The instances
list is the real limit: a collaborator's message drives a granted instance with
the owner's role.
To cut a user off, revoke them here first. It acts at once. Removing a provider
from console.sso takes effect only after a Console restart, so do that second.
Peer homes are not read yet (row 203 adds the federation key), so a grant that
names a peer home refuses. A grant that names a sign-in subject needs console.sso
in the system config; without it every such grant refuses.
The command runs as the instance's uid: access.json must belong to it and must
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
// The instance's context for checkAccess, read from the system config and the business
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
// has an id and a subjects list). peers is empty until row 203. A business file that
// fails to load fails the whole command.
export function accessContextFor(system, env) {
const providers = system.console?.sso?.providers ?? [];
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
const dir = join(configDir(env), "businesses");
const businesses = {};
if (existsSync(dir)) {
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
const id = file.slice(0, -".json".length);
const doc = loadBusiness(id, { dir: configDir(env), rolesDir: rolesDir(env) });
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
}
}
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
}
// Every verb needs --business: the human proof runs through that business's bus.
// --tester is refused outright on every verb, before any bus call.
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--json" || a === "--tester") out.flags.add(a);
else if (a.startsWith("--")) {
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
out.values[a] = args[++i];
} else out.positional.push(a);
}
return out;
}
const list = (value) => (value === "*" ? ["*"] : value.split(",").filter(Boolean));
function readCurrent(file, context) {
if (!existsSync(file)) return { document: { accessVersion: 1, revision: null, users: [], grants: [] }, revision: 0 };
const document = loadAccess(file, context);
return { document, revision: document.revision };
}
// The file calls the write path makes. A test passes a stand-in through deps.fs.
const realFs = { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync };
// Atomic write: a temp file in the same directory, 0600, fsynced, then renamed over
// the old file. A reader sees the old bytes or the new bytes, never a mix. A failure
// before or at the rename removes the temp file and leaves the old bytes in place.
function writeAtomic(file, text, fs = realFs) {
const tmp = join(dirname(file), `.access-${randomBytes(8).toString("hex")}.tmp`);
try {
const fd = fs.openSync(tmp, "wx", 0o600);
try {
fs.writeSync(fd, text);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, file);
} catch (error) {
try {
fs.unlinkSync(tmp);
} catch {
// Already gone: the temp file was never created.
}
throw error;
}
}
function receipt(dataRoot, line) {
const dir = join(dataRoot, "state");
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, "access-log.jsonl"), `${JSON.stringify(line)}\n`, { mode: 0o600 });
}
// Two writers never interleave: the read, the check and the rename happen under an
// exclusive lock file, `<access.json>.lock`, mode 0600. The file holds the holder's
// pid and a newline. A lock whose holder is alive refuses. A lock that is empty,
// unreadable, or whose pid is gone also refuses, and nothing reclaims it: a human
// removes it once no write is running. Fail closed, with no reclaim race.
const BUSY = "refused: the access file is being written by another command; try again";
function lockPid(lock) {
let text;
try {
text = readFileSync(lock, "utf8");
} catch {
return null;
}
const pid = Number(text.trim());
return Number.isSafeInteger(pid) && pid >= 1 ? pid : null;
}
function pidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== "ESRCH";
}
}
function lockRefusal(lock) {
const pid = lockPid(lock);
if (pid !== null && pidAlive(pid)) return BUSY;
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
}
function takeLock(lock, fs) {
try {
return fs.openSync(lock, "wx", 0o600);
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
throw new CliError(lockRefusal(lock), 3);
}
export function withLock(file, fn, fs = realFs) {
const lock = `${file}.lock`;
const fd = takeLock(lock, fs);
try {
fs.writeSync(fd, `${process.pid}\n`);
} finally {
fs.closeSync(fd);
}
try {
return fn();
} finally {
fs.unlinkSync(lock);
}
}
// The one human-proof call: project.list, which the bus gates with #human
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
// project names. Row R4 swaps this call for access.reload.
async function proveHuman(transport, business) {
return transport(business)("project.list", {});
}
// Resolve a --projects item: "*" and UUIDs pass through, anything else is a project
// name looked up in project.list at grant time (the id is what gets written).
function resolveProjects(items, rows) {
return items.map((item) => {
if (item === "*" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item)) return item;
const hits = rows.filter((r) => r.name === item);
if (hits.length !== 1) throw new CliError(`refused: project-not-found: no project named ${item} in this business (or the name is ambiguous)`, 2);
return hits[0].id;
});
}
// deps: { system: { dataRoot }, transport(business) -> call(verb, args), contextFor() -> context, configDir?, now?, fs? }
export async function accessCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
return await accessVerb(args, io, deps);
} catch (error) {
// A file or business doc the checks refuse to load (group-writable, a wrong uid, a
// hand edit) is a refusal with exit 3 and the file and key path, not a crash.
if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 3);
throw error;
}
}
async function accessVerb(args, io, deps) {
const parsed = parse(args);
const sub = parsed.positional[0];
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
const business = parsed.values["--business"];
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
const rows = await proveHuman(deps.transport, business);
const file = deps.file ?? accessFilePath(deps.configDir ?? configDir(io.env));
// Built after the human proof, so a refused caller never reads the business files.
const context = deps.contextFor();
if (sub === "list") {
const { document } = readCurrent(file, context);
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(document, null, 2)}\n` : formatList(document));
return;
}
const expected = parsed.values["--revision"];
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
const user = parsed.values["--user"];
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
const result = withLock(file, () => {
const { document, revision } = readCurrent(file, context);
if (Number(expected) !== revision) {
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
}
let next;
let touched;
if (sub === "grant") {
const level = parsed.values["--level"];
if (!parsed.values["--projects"] || !parsed.values["--instances"] || level === undefined) {
throw new CliError(`grant needs --projects, --instances and --level\n${ACCESS_USAGE}`, 4);
}
if (!LEVELS.includes(level)) throw new CliError(`--level takes ${LEVELS.join(", ")}`, 2);
const id = `g-${randomBytes(4).toString("hex")}`;
const grant = {
id,
user,
business,
projects: resolveProjects(list(parsed.values["--projects"]), rows),
instances: list(parsed.values["--instances"]),
level,
};
if (parsed.values["--expires"] !== undefined) grant.expires = parsed.values["--expires"];
touched = [id];
next = { ...document, revision: revision + 1, grants: [...document.grants, grant] };
} else {
const only = parsed.values["--grant"];
const drop = (g) => g.user === user && g.business === business && (only === undefined || g.id === only);
touched = document.grants.filter(drop).map((g) => g.id);
if (touched.length === 0) throw new CliError(`refused: no grant for ${user} at ${business} to revoke`, 2);
next = { ...document, revision: revision + 1, grants: document.grants.filter((g) => !drop(g)) };
}
// Every check runs on the new document before a byte is written.
const checked = checkAccess(next, context);
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
return { checked, revision, touched };
}, deps.fs);
receipt(deps.system.dataRoot, {
at: (deps.now ?? (() => new Date()))().toISOString(),
verb: sub,
business,
user,
actor: "human",
revisionBefore: result.revision,
revisionAfter: result.checked.revision,
grants: result.touched,
});
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
}
function formatList(doc) {
const lines = [`access file revision ${doc.revision ?? "none"}`];
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
for (const g of doc.grants) {
lines.push(`grant ${g.id} user ${g.user} business ${g.business} projects ${g.projects.join(",")} instances ${g.instances.join(",")} level ${g.level}${g.expires ? ` expires ${g.expires}` : ""}`);
}
return `${lines.join("\n")}\n`;
}
+51 -2
View File
@@ -20,6 +20,7 @@ import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
@@ -58,6 +59,9 @@ export const USAGE = `usage:
${THREAD_TALK_USAGE}
mosaic name <instance> <name> [--business <id>] [--tester]
mosaic names [--business <id>] [--json] [--tester]
mosaic halt [--business <id>]
mosaic resume [--business <id>]
mosaic processing status [--business <id>] [--json]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
@@ -457,6 +461,36 @@ async function stop(rest, io, deps) {
io.stdout.write(r.stopped ? `stopped ${r.business}/${r.instance} run ${r.run}\n` : `${r.reason}\n`);
}
async function processingCommand(verb, rest, io, deps) {
const parsed = parse(rest, { flags: ["--json"], values: ["--business"] });
if (verb === 'processing' ? parsed.positional.length !== 1 || parsed.positional[0] !== 'status' : parsed.positional.length !== 0)
throw usage('use mosaic halt, mosaic resume or mosaic processing status');
refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const call = callFor(parsed, io, { ...deps, system });
try {
let result;
if (verb === 'resume') {
const current = await call('processing.status');
if (current?.invalid) throw new CliError(`processing state is invalid; use mosaic halt, then mosaic resume (${current.code})`, 3);
result = await call('processing.resume', { generation: current?.generation });
} else result = await call(verb === 'halt' ? 'processing.halt' : 'processing.status', {});
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
if (verb === 'halt' && result.quiescent !== true)
throw new CliError(`halt gate is on for instance ${system.dataRoot}; ${result.remaining ?? 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
if (result.invalid) io.stdout.write('state is invalid; use mosaic halt, then mosaic resume\n');
} catch (e) {
if (e instanceof CliError) throw e;
const code = typeof e.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
if (code === 'outcome-unknown') throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
throw new CliError(`processing refused: ${code}`, 3);
}
}
async function launches(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "off" || sub === "on") {
@@ -511,8 +545,13 @@ async function bus(rest, io, deps) {
await launcher.listen();
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
if (parsed.flags.has("--pm")) {
const pm = await launcher.launchPm();
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
try {
const pm = await launcher.launchPm();
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
} catch (e) {
if (e.code !== 'processing-halted') throw e;
io.stdout.write('processing halted: chief launch skipped; control bus remains available\n');
}
}
} catch (e) {
await host.close(1);
@@ -577,6 +616,15 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
if (verb === "access") {
const system = deps.system ?? loadSystem({ env: io.env });
return accessCommand(rest, io, {
system,
transport: (b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }),
contextFor: () => deps.accessContext ?? accessContextFor(system, io.env),
now: deps.now,
});
}
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
@@ -590,6 +638,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "follow") return talk(rest, io, deps, true);
if (verb === "name" || verb === "names") return names(verb, rest, io, deps);
if (verb === "stop") return stop(rest, io, deps);
if (["halt", "resume", "processing"].includes(verb)) return processingCommand(verb, rest, io, deps);
if (verb === "launches") return launches(rest, io, deps);
if (verb === "bus") return bus(rest, io, deps);
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
+17 -11
View File
@@ -85,6 +85,7 @@ import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
import { withProcessing } from '../../processing-control/src/effects.mjs';
const HEADER_MAX = 4096;
const HEADER_TIMEOUT_MS = 10000;
@@ -372,7 +373,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
const p = project(facts.project);
const where = { dataRoot, business: b.id, project: facts.project, env };
const refused = async (e, notify) => {
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError || e.name === 'ProcessingError' ? e.code : "git-failed";
if (code === "git-failed" || code === "rollback-failed") log(`git socket: ${id}: ${code}: ${e.message}`);
// The host's own faults stay in its log; the role gets the code.
const detail = notify && code !== "git-failed" && e.message !== code ? e.message.slice(0, 1000) : null;
@@ -392,20 +393,19 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
}
const { commit, base, reviewed, patch_id } = prepared;
socket.end(`${JSON.stringify({ ok: true, result: { landing: "started", commit, base, reviewed, patch_id } })}\n`);
slot.done = (async () => {
slot.done = withProcessing(dataRoot, async scope => {
try {
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: slot.abort.signal });
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
scope.guard();
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
} catch (e) {
await refused(e, true);
} finally {
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
await gate.dropNetwork(`${id}_default`, gateEnv(env));
landing = null;
}
})();
}).catch(e => refused(e, true));
}
async function doPublish(socket, cap, req) {
@@ -479,11 +479,17 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (typeof req.task_ref === "string") subject = req.task_ref;
if (!who.role || who.business !== b.id) throw new Refusal("not-a-role");
file = join(dir, `${randomBytes(8).toString("hex")}.bundle`);
if (req.op === "push") await doPush(socket, cap, req, rest, file);
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
else if (req.op === "land") await doLand(socket, cap, req);
else if (req.op === "publish") await doPublish(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
await withProcessing(dataRoot, async scope => {
const abort = () => socket.destroy();
scope.signal.addEventListener('abort', abort, { once: true });
try {
if (req.op === "push") await doPush(socket, cap, req, rest, file);
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
else if (req.op === "land") await doLand(socket, cap, req);
else if (req.op === "publish") await doPublish(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
} finally { scope.signal.removeEventListener('abort', abort); }
});
} catch (e) {
if (e instanceof Recorded) return fail(e.code);
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
+52 -7
View File
@@ -127,6 +127,9 @@ import { createGitSocket } from "./git-socket.mjs";
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, runtime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
import { withProcessing } from '../../processing-control/src/effects.mjs';
const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
@@ -179,6 +182,13 @@ export function createLauncher({
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
const dataRoot = system.dataRoot;
function admission(generation = null) {
try { return assertProcessing(dataRoot, generation); }
catch (e) {
if (e instanceof ProcessingError) throw new Refusal(e.code);
throw e;
}
}
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
const skillsDir = join(dataRoot, "skills-enabled");
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
@@ -231,6 +241,7 @@ export function createLauncher({
// launch.by itself. A thread instance launches only for a thread, and
// nothing else launches for one.
async function check(instance, { pm = false, thread = null } = {}) {
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`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
@@ -324,11 +335,13 @@ export function createLauncher({
if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; }
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
}
return { resolved, model, account, harness, route, routeReason };
admission(admissionGeneration);
return { resolved, model, account, harness, route, routeReason, admissionGeneration };
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, 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
@@ -342,6 +355,7 @@ export function createLauncher({
}
}
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
@@ -351,7 +365,10 @@ export function createLauncher({
mkdirSync(workspace, { recursive: true, mode: 0o700 });
let checkouts;
try {
checkouts = await ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
checkouts = await withProcessing(dataRoot, () => {
admission(admissionGeneration);
return ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
});
} catch (e) {
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
throw e;
@@ -362,6 +379,7 @@ export function createLauncher({
}
let harness, route, routeReason;
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread}));
admission(admissionGeneration);
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
@@ -436,7 +454,16 @@ export function createLauncher({
threadStore: store,
readOnly,
};
const child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
admission(admissionGeneration);
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
let child;
try {
admission(admissionGeneration);
child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
} catch (e) {
unregisterRuntime(dataRoot, managed.id);
throw e;
}
let bound = false;
let entry = null;
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve(sessionExit(code, signal))));
@@ -445,10 +472,16 @@ export function createLauncher({
try {
if (child.pid === undefined) throw new Error("the session process did not start");
const outerStart = startTimeOf(child.pid);
const identity = processIdentity(child.pid);
if (!identity) throw new Error("the session process exited at once");
updateRuntime(dataRoot, managed.id, { ...identity, group: true, phase: 'running' });
const runner = await runnerOf(child);
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 } : {}) });
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,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
@@ -462,20 +495,29 @@ export function createLauncher({
child.kill("SIGKILL");
await exited;
if (bound) await endRun(run, "failed", null);
unregisterRuntime(dataRoot, managed.id);
if (e instanceof Refusal) throw e;
throw new Refusal("launch-failed", e.message);
}
children.set(run, exited);
exited.then(async ({ code, signal }) => {
const reason = endReason(code, signal);
let receipt;
try { receipt = runtime(dataRoot, managed.id); }
catch { log(`run ${run}: processing receipt unavailable at exit`); }
const state = processingStatus(dataRoot);
const haltGeneration = receipt?.haltGeneration ?? (state.halted && !state.invalid ? state.generation : null);
const reason = haltGeneration ? 'kill-switch' : endReason(code, signal);
try {
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, ...(ended?.limit ? { limit: ended.limit } : {}) });
record({ event: "end", instance, ...(thread ? { thread } : {}), 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`); }
children.delete(run);
stopping.delete(run);
landingOf.delete(run);
@@ -490,6 +532,7 @@ export function createLauncher({
// landing pass. A tick that is still running when the next is due skips it.
async function tick() {
if (ticking || closed) return;
try { admission(); } catch { return; }
ticking = true;
try {
await threadPass();
@@ -679,7 +722,9 @@ export function createLauncher({
} catch (e) {
if (!(e instanceof Refusal)) throw e;
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
const error = new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
error.code = e.code;
throw error;
}
});
}
+8 -2
View File
@@ -7,6 +7,7 @@
import { Client } from "../../bus/src/client.mjs";
import { openDirect } from "../../discord/src/notify.mjs";
import { createNotifier, runLoop } from "./notifier.mjs";
import { withProcessing, currentProcessing } from '../../processing-control/src/effects.mjs';
const log = (line) => process.stderr.write(`mosaic-notify: ${line}\n`);
let loop = null;
@@ -37,8 +38,13 @@ if (!process.send) {
clearTimeout(timer);
try {
const client = new Client({ path: m.path, cap: m.cap });
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}), log });
const notifier = createNotifier({ business: m.business, dataRoot: m.dataRoot, inbox: () => client.call("inbox"), direct, log });
const processing = { work: fn => withProcessing(m.dataRoot, fn), guard: () => currentProcessing()?.guard() };
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}),
fetch: (url, init) => {
const scope = currentProcessing(); scope?.guard();
return fetch(url, { ...init, ...(scope ? { signal: scope.signal } : {}) });
}, log });
const notifier = createNotifier({ business: m.business, dataRoot: m.dataRoot, inbox: () => client.call("inbox"), direct, processing, log });
loop = runLoop(notifier, { pollMs: m.pollMs ?? undefined, log });
process.send({ ok: true });
} catch (e) {
+7 -2
View File
@@ -251,7 +251,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
// inbox(): the broker's inbox through the reader capability.
// direct.send({content, nonce}) resolves {messageId} or throws a RestOutcome.
export function createNotifier({ business, dataRoot, inbox, direct, now = () => new Date(), zone = ZONE, log = () => {} }) {
export function createNotifier({ business, dataRoot, inbox, direct, now = () => new Date(), zone = ZONE, processing = null, log = () => {} }) {
zoned(now(), zone);
const journal = openJournal(journalPath(dataRoot, business), { log, now });
const backoff = new Map();
@@ -269,6 +269,7 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
}
async function attempt(key, record, message) {
processing?.guard();
const t = now().getTime();
try {
const { messageId } = await direct.send(message);
@@ -328,7 +329,11 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
return done;
}
return Object.freeze({ tick, journal: journalPath(dataRoot, business) });
const poll = () => processing ? processing.work(tick).catch(e => {
if (e.name !== 'ProcessingError') throw e;
return { dms: 0, digest: false, failed: 0, halted: true };
}) : tick();
return Object.freeze({ tick: poll, journal: journalPath(dataRoot, business) });
}
// Runs tick() every pollMs until stop(). Ticks never overlap.
+314
View File
@@ -0,0 +1,314 @@
// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
// The one test that runs the real human-cli.mjs does it on a scratch bus from
// agent ancestry, so the bus must refuse, and nothing is written.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as nodeFs from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { startBroker } from "../../bus/src/runtime.mjs";
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
import { CliError } from "../src/errors.mjs";
import { socketPath } from "../src/config.mjs";
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
const CONTEXT = {
providers: ["authentik"],
peers: [],
businesses: { acme: { human: "jason", instances: ["coder"] } },
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
};
const DOC = {
accessVersion: 1,
revision: 1,
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: [],
};
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
// that sees no human shell when FAKE_MODE is "refuse".
const BODY = `
import { readFileSync, writeFileSync } from "node:fs";
const input = JSON.parse(readFileSync(0, "utf8"));
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
`;
function setup(t, { mode = "ok", doc = DOC } = {}) {
const root = tmp(t, "mosaic-access-");
const config = join(root, "config");
mkdirSync(config, { recursive: true });
const dataRoot = join(root, "data");
const log = join(root, "fake.log");
const cli = fakeCli(root, BODY);
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
const file = join(config, "access.json");
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
const deps = {
system: { dataRoot },
configDir: config,
contextFor: () => CONTEXT,
now: () => new Date("2026-10-20T20:00:00Z"),
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
};
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
}
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
async function run(args, deps, env = humanEnv()) {
const i = io(env);
await accessCommand(args, i, deps);
return i.out.text;
}
async function refusal(promise, pattern) {
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
return promise.catch((e) => e);
}
test("list prints the revision and the users", async (t) => {
const s = setup(t);
const out = await run(["list", "--business", "acme"], s.deps);
assert.match(out, /access file revision 1/);
assert.match(out, /user alice home local/);
});
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
const s = setup(t);
const out = await run([...GRANT, "--revision", "1"], s.deps);
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.equal(doc.grants.length, 1);
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "grant");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.equal(line.actor, "human");
});
test("a viewer grant writes the viewer level, one revision up", async (t) => {
const s = setup(t);
await run([...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "viewer", "--revision", "1"], s.deps);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.equal(doc.grants[0].level, "viewer");
});
test("a second write appends a second receipt and leaves the first intact", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
assert.equal(lines[0].revisionAfter, 2);
});
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
});
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, "");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
assert.equal(existsSync(`${s.file}.lock`), true);
});
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
const s = setup(t);
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
});
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
assert.equal(existsSync(`${s.file}.lock`), false);
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
assert.equal(err.exitCode, 3);
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
});
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
const s = setup(t);
let mode;
withLock(s.file, () => {
mode = statSync(`${s.file}.lock`).mode & 0o777;
});
assert.equal(mode, 0o600);
assert.equal(existsSync(`${s.file}.lock`), false);
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("a grant naming an unknown instance is refused before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
await refusal(run(bad, s.deps), /refused: /);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("a project name resolves to its id at grant time", async (t) => {
const s = setup(t);
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await run(args, s.deps);
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
});
test("an unknown project name refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await refusal(run(args, s.deps), /project-not-found/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const doc = s.read();
assert.equal(doc.revision, 3);
assert.deepEqual(doc.grants, []);
});
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("an agent caller is refused before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
assert.equal(err.exitCode, 3);
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
const args = [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
});
test("a bus that refuses the human proof blocks the write", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
assert.equal(err.exitCode, 3);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
const root = tmp(t, "mosaic-access-ctx-");
const f = fixture(root, "acme");
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(ctx.providers, ["authentik"]);
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
assert.deepEqual(ctx.peers, []);
assert.equal(ctx.businesses.acme.human, "jason");
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(bare.ssoSubjects, []);
assert.deepEqual(bare.providers, []);
});
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
const root = tmp(t, "mosaic-access-real-");
const f = fixture(root, "acme");
const config = join(root, "config");
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
});
t.after(() => bus.close());
writeJson(join(config, "access.json"), DOC);
const file = join(config, "access.json");
const before = readFileSync(file, "utf8");
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
const deps = {
system: { dataRoot: f.dataRoot },
configDir: config,
contextFor: () => CONTEXT,
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
};
// The bus closes the socket on a failed human proof, so the client reports
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
// outside R5 (BUILD.md). The test pins it, and nothing is written.
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
assert.equal(err.code, "outcome-unknown");
assert.equal(err.exitCode, 1);
assert.equal(readFileSync(file, "utf8"), before);
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
});
@@ -3,10 +3,18 @@ import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync,readFileSync,statSync
import { spawnSync } from 'node:child_process';
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
const processingInputs=['packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'];
function copyProcessingInputs(root){
mkdirSync(join(root,'packages/processing-control/src'),{recursive:true});
for(const file of processingInputs)cpSync(new URL('../../../'+file,import.meta.url),join(root,file));
}
test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{
assert.equal(typeof image.imageFingerprint,'function','image fingerprint required');
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
copyProcessingInputs(root);
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
@@ -25,6 +33,7 @@ test('exclusive image preparation records actual image ID and build wall time an
assert.equal(typeof image.prepareImage,'function','prepareImage is required');
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
copyProcessingInputs(root);
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
@@ -40,6 +49,7 @@ test('cleanup refuses a foreign Docker label and removes only an exact owned pro
function imageFixture(t){
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
copyProcessingInputs(root);
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
return {root,receipt};
@@ -134,3 +144,12 @@ test('guard owns every Compose file argument and refuses caller configuration ov
for(const args of [['compose','-f','foreign.yaml','run','mosaic-agent'],['compose','--project-directory','/tmp','run','mosaic-agent']])
assert.throws(()=>docker.guardDocker(args,run),/unowned command/);
});
// Row 191 image-input manifest: either copied reader must invalidate the pinned image.
for(const file of processingInputs)test(`changing ${file} invalidates the image receipt`,t=>{
const {root,receipt}=imageFixture(t);
const inspect=()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'});
assert.equal(image.verifyImage(receipt,root,inspect).imageId,receipt.imageId);
writeFileSync(join(root,file),readFileSync(join(root,file),'utf8')+'\n// changed fixture input\n');
assert.throws(()=>image.verifyImage(receipt,root,inspect),/image inputs differ/);
});
+19
View File
@@ -1027,3 +1027,22 @@ test("bus-service.sh refuses a default claude without the execute bit, or in a P
assert.ok(r.stderr.includes(join(spaced, "claude")), r.stderr);
assert.equal(r.stdout, "");
});
// Row 191: a restarted halted instance must keep its recovery bus reachable.
test('bus start --pm while halted keeps the control host up without launching the chief', { timeout: 30000 }, async t => {
const { initializeControl, transition, readControl } = await import('../../processing-control/src/state.mjs');
const f = fixture(tmp(t)); notifyConfig(f.dataRoot, 'acme', null);
initializeControl(f.dataRoot);
transition(f.dataRoot, { business: 'acme', human: 'jason', halted: true });
const child = spawn(process.execPath, [CLI, 'bus', 'start', 'acme', '--pm'], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
const exited = once(child, 'exit'); let output = '';
child.stdout.on('data', b => { output += b; }); child.stderr.on('data', b => { output += b; });
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exited; } });
await until(() => /processing halted: chief launch skipped/.test(output) || child.exitCode !== null);
assert.match(output, /processing halted: chief launch skipped/);
assert.equal(child.exitCode, null, output);
const status = spawnSync(process.execPath, [CLI, 'bus', 'status', '--json'], { env: f.env, encoding: 'utf8', timeout: 5000 });
assert.equal(status.status, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).host.live, true);
assert.equal(readControl(f.dataRoot).halted, true);
});
@@ -6,6 +6,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createLauncher} from '../src/launcher.mjs';
import {initializeControl} from '../../processing-control/src/state.mjs';
import {fixture,tmp} from './helpers.mjs';
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
@@ -19,6 +20,7 @@ function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_
if(account)doc.launch.account=account;
return doc;
}),ops=[];
initializeControl(f.dataRoot);
for(const k of SESSION_CREDENTIALS.flat())delete f.env[k];
for(const k of set)f.env[k]=VALUE;
const host={business:'acme',beforeClose:()=>{},op:async x=>{
@@ -6,14 +6,16 @@ import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {createLauncher} from '../src/launcher.mjs';
import {initializeControl} from '../../processing-control/src/state.mjs';
import {fixture,tmp} from './helpers.mjs';
import {launchLogFile} from '../../seat/src/session.mjs';
const HIT={path:['tokens','instances','coder'],limit:100,count:120,unknownTurns:1};
// answer is what the mayLaunch op returns, or an Error it throws.
function setup(t,answer){
function setup(t,answer,{initialize=true}={}){
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),ops=[];
if(initialize)initializeControl(f.dataRoot);
const host={business:'acme',beforeClose:()=>{},op:async x=>{
ops.push(x);
if(x.op==='identity')return {business:'acme',role:'pm'};
@@ -78,3 +80,11 @@ test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launche
assert.doesNotMatch(src,/modules\(\s*["'](?!harness["'])/);
assert.doesNotMatch(src,/\bimport\s*\(/);
});
// Row 191: only trusted bus startup initializes control; an early seat launch refuses.
test('a launcher before trusted control initialization refuses processing-uninitialized',async t=>{
const {launcher,ops}=setup(t,{allow:true},{initialize:false});
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'processing-uninitialized'});
assert.equal(ops.some(x=>x.op==='mayLaunch'||x.op==='authorizeLaunch'),false);
assert.deepEqual(launcher.registry.sessions,[]);
});
@@ -4,11 +4,13 @@ import assert from 'node:assert/strict';
import {join} from 'node:path';
import {readFileSync,writeFileSync} from 'node:fs';
import {createLauncher} from '../src/launcher.mjs';
import {initializeControl} from '../../processing-control/src/state.mjs';
import {fixture,tmp} from './helpers.mjs';
import {systemFor,writeJson} from '../../business/tests/helpers.mjs';
import {launchLogFile} from '../../seat/src/session.mjs';
test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[];
initializeControl(f.dataRoot);
const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='mayLaunch')return {allow:true};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}};
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
const session={business:'acme',instance:'reviewer',run:'fixture-existing',harness:'pi',provider:'zai',model:'claude-sonnet-5-5'};launcher.registry.add(session);
@@ -7,6 +7,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createLauncher} from '../src/launcher.mjs';
import {initializeControl} from '../../processing-control/src/state.mjs';
import {fakeHarnesses,fixture,tmp} from './helpers.mjs';
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
@@ -25,6 +26,7 @@ const ON=['pi','claude-code','down','sparse','metered','broken','throws','absent
// vars are the coder's whole agent vars; edit changes the rest of the file.
async function setup(t,vars,{on=ON,edit=doc=>doc,env:more={}}={}){
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars=vars;doc.plugins={...doc.plugins,harness:on};return edit(doc);}),ops=[];
initializeControl(f.dataRoot);
Object.assign(f.env,more);
const host={business:'acme',beforeClose:()=>{},op:async x=>{
ops.push(x);
+46
View File
@@ -1075,3 +1075,49 @@ test("a runner that ignores SIGTERM is killed when the host closes", { skip, tim
assert.equal(ends.length, 1);
assert.equal(ends[0].signal, "SIGKILL");
});
// Row 191: root-scoped processing is stronger than per-business launch.restore.
test('kill switch refuses chief and hire before launch work and a clear permits a later request', { skip, timeout: 30000 }, async t => {
const { readControl, transition } = await import('../../processing-control/src/state.mjs');
const s = await setup(t);
const halted = transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: true });
await assert.rejects(s.launcher.launchPm(), /PM launch refused: processing-halted/);
assert.deepEqual(await s.launcher.fromSession(s.cto.cap, 'coder'), { ok: false, error: 'processing-halted' });
assert.deepEqual(readSessions(s.f.dataRoot), []);
transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: false, generation: halted.generation });
const run = await s.launcher.launchPm();
assert.equal(readControl(s.f.dataRoot).halted, false);
assert.equal(readSessions(s.f.dataRoot)[0].run, run.run);
});
test('kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle', { skip, timeout: 30000 }, async t => {
const { transition } = await import('../../processing-control/src/state.mjs');
const s = await setup(t);
const run = await s.launcher.launchPm();
const entry = readSessions(s.f.dataRoot).find(r => r.run === run.run);
const session = readFileSync(join(entry.runDir, 'session.json'));
const halted = transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: true });
await until(() => !readSessions(s.f.dataRoot).some(r => r.run === run.run), 12000);
assert.deepEqual(readFileSync(join(entry.runDir, 'session.json')), session);
const end = log(s.f).find(r => r.event === 'end' && r.run === run.run);
assert.equal(end.reason, 'kill-switch');
assert.equal(end.haltGeneration, halted.generation);
assert.equal(startTimeOf(entry.runnerPid), null);
});
test('a launch waiting across halt and resume cannot use its stale admission', { skip, timeout: 30000 }, async t => {
const { transition } = await import('../../processing-control/src/state.mjs');
let root, first = true;
const prepared = prepare(t); root = prepared.f.dataRoot;
const s = await setup(t, doc => doc, prepared, { wrap: host => ({ ...host, async op(m) {
const answer = await host.op(m);
if (m.op === 'mayLaunch' && first) {
first = false;
const state = transition(root, { business: 'acme', human: 'jason', halted: true });
transition(root, { business: 'acme', human: 'jason', halted: false, generation: state.generation });
}
return answer;
} }) });
await assert.rejects(s.launcher.launchPm(), /processing-generation-changed/);
assert.deepEqual(readSessions(root), []);
});
+18
View File
@@ -451,3 +451,21 @@ test("runLoop never overlaps ticks and stops after the one in flight", async ()
assert.ok(after >= 2);
assert.equal(count, after);
});
// Row 191: the child remains alive for recovery but sends no processing while halted.
test('processing halt prevents notifier reads and sends and fences the wait before delivery', async t => {
const { initializeControl, transition } = await import('../../processing-control/src/state.mjs');
const { createProcessing } = await import('../../processing-control/src/coordinator.mjs');
const dataRoot = tmp(t); initializeControl(dataRoot);
const bus = broker(t);
bus.raise('git.push.protected', { target: 'refactor', blocking: true, task_ref: 'vikunja:1/7' });
const processing = createProcessing({ dataRoot }); t.after(() => processing.close());
const actor = { business: 'demo', human: 'jason' }; let reads = 0, sends = 0, haltInRead = false;
const notifier = createNotifier({ business: 'demo', dataRoot, processing, now: () => new Date(AT),
inbox: async () => { reads++; if (haltInRead) transition(dataRoot, { ...actor, halted: true }); return bus.read('inbox'); },
direct: { send: async () => { sends++; return { messageId: 'fixture' }; } } });
const halted = transition(dataRoot, { ...actor, halted: true });
await notifier.tick(); assert.equal(reads, 0); assert.equal(sends, 0);
transition(dataRoot, { ...actor, halted: false, generation: halted.generation }); haltInRead = true;
await notifier.tick(); assert.equal(reads, 1); assert.equal(sends, 0);
});
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { createProcessing } from '../../processing-control/src/coordinator.mjs';
import { main } from '../src/cli.mjs';
import { io, BUSINESSES } from './helpers.mjs';
function setup(t) {
const dataRoot = mkdtempSync(join(tmpdir(), 'processing-cli-')), calls = [];
const processing = createProcessing({ dataRoot });
const store = new Store(dataRoot);
const b = new Broker({ store, businesses: BUSINESSES, processing });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const deps = { system: { dataRoot }, transport: business => async (verb, args = {}) => {
assert.equal(business, 'demo'); calls.push(verb); return b.request(human, { verb, args });
} };
t.after(async () => { await processing.close(); store.close(); rmSync(dataRoot, { recursive: true, force: true }); });
return { dataRoot, deps, calls };
}
test('human halt/status/resume uses management transport without loading any channel', async t => {
const f = setup(t), x = io();
await main(['halt', '--business', 'demo'], x, f.deps);
assert.match(x.out.text, /processing halted/);
assert.ok(x.out.text.includes(f.dataRoot), 'the affected instance root is named');
const status = io();
await main(['processing', 'status', '--business', 'demo', '--json'], status, f.deps);
assert.equal(JSON.parse(status.out.text).halted, true);
const resumed = io(); await main(['resume', '--business', 'demo'], resumed, f.deps);
assert.match(resumed.out.text, /processing resumed/);
assert.deepEqual(f.calls, ['processing.halt', 'processing.status', 'processing.status', 'processing.resume']);
});
for (const verb of ['halt', 'resume']) test(`${verb} refuses inside an agent before transport`, async t => {
const f = setup(t), x = io({ MOSAIC_RUN_ID: 'managed-run' });
await assert.rejects(main([verb, '--business', 'demo'], x, f.deps), e => e.exitCode === 3);
assert.deepEqual(f.calls, []);
});
test('unknown and partially stopped outcomes are never reported as a successful halt', async t => {
const f = setup(t);
const unknown = io();
await assert.rejects(main(['halt', '--business', 'demo'], unknown, {
...f.deps, transport: () => async () => { throw Object.assign(new Error('outcome-unknown'), { code: 'outcome-unknown' }); },
}), e => e.exitCode === 1 && /status/.test(e.message));
assert.doesNotMatch(unknown.out.text, /processing halted/);
const partial = io();
await assert.rejects(main(['halt', '--business', 'demo'], partial, {
...f.deps, transport: () => async () => ({ version: 1, generation: '11111111-1111-4111-8111-111111111111', operation: '11111111-1111-4111-8111-111111111111', business: 'demo', human: 'jason', at: '2026-10-20T00:00:00.000Z', recovered: false, halted: true, quiescent: false, remaining: 1, stopped: 0 }),
}), e => e.exitCode === 1 && /1.*unconfirmed/.test(e.message));
assert.doesNotMatch(partial.out.text, /all.*stopped/);
});
+1 -1
View File
@@ -49,7 +49,7 @@ test('a copy carries every file the harness modules read from it',async t=>{
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
@@ -66,13 +66,13 @@ async function closed(e,x,a){
const storeSha=(e)=>sha(e.store);
const backupStore=(e,a)=>join(e.data,'backups','stack',a,'bus','bus.sqlite');
test('row 232: confirm, revert, gate, --to and --expect-from need --units external; the user-only flags are usage under it',async(t)=>{
test('row 232: confirm, revert, gate, --to and --expect-from need --units external; the user-only flags are usage under it, and so are row 273\'s --send and --no-sso',async(t)=>{
const {e,x}=await xseed(t);
const logs=logLines(e).length;
for(const a of [['confirm',C1,'acme'],['revert',C1,'acme'],['gate',B,'acme','--since','1'],['rollback','acme','--to',B],['activate',C1,'acme','--plan','--expect-from',B]]){
const r=await run(e,...a);assert.equal(r.code,4,a.join(' '));assert.match(r.err,/^usage: stack-release.sh adopt BUSINESS\n/);
}
for(const a of [['activate',C1,'acme','--pm'],['activate',C1,'acme','--tester-port','7444'],['activate',C1,'acme','--no-proof'],['activate',C1,'acme','--no-send'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--proof-header','X','--proof-file','/p'],['rollback','acme'],['gate',B,'acme'],['activate',C1,'acme','--expect-from',B],['confirm',C1,'acme','--rollback',B],['confirm',C1,'acme','--cause','A5']]){
for(const a of [['activate',C1,'acme','--pm'],['activate',C1,'acme','--tester-port','7444'],['activate',C1,'acme','--no-proof'],['activate',C1,'acme','--no-send'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--no-sso'],['activate',C1,'acme','--proof-header','X','--proof-file','/p'],['rollback','acme'],['gate',B,'acme'],['activate',C1,'acme','--expect-from',B],['confirm',C1,'acme','--rollback',B],['confirm',C1,'acme','--cause','A5']]){
const r=await run(x,...a);assert.equal(r.code,4,a.join(' '));
}
assert.equal(logLines(e).length,logs);
+199 -7
View File
@@ -1258,7 +1258,7 @@ test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{
['activate',C1,'acme','--plan','--proof-file',f]]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\]/);
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
@@ -1417,7 +1417,7 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\| --no-proof \| --sso\] \[--no-send\]/);
assert.match(usageText,/\| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
@@ -1465,7 +1465,7 @@ const DROPS='--public-host console.example --auth-header x-user --allow-user jas
const SHOW=`show-${CON}.txt`;
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --send and --tester-port as the pointer has them`;
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --tester-port as the pointer has it`;
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
@@ -1526,7 +1526,7 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
['rollback','acme','--sso'],['pm-on','acme','--sso'],['status','acme','--sso'],['adopt','acme','--sso']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\] \[--no-send\]/);
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
@@ -1600,14 +1600,14 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.ok(stepText(e).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${SHOW}\n`));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// The plain case: --send and the preview set and pair come back as they were.
// The plain case: the preview set and pair come back as they were. --send lost its --sso, so it goes (row 273, F39 b).
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
setKey(g,undefined);
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f),'--send']);
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)]);
for(const x of [e,g])await noLeak(x);
});
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
@@ -1679,7 +1679,7 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
setKey(a,undefined);
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --send and --tester-port as the pointer has them`]);
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]);
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
assert.ok(stepText(a).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${show}\n`));
@@ -1695,6 +1695,198 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
});
});
// Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and
// activate --no-sso turns --sso off with the Console flags from the set of the activation that turned
// it on, dropping --send. The tester follows the pointer.
const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)";
const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off";
const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`;
const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`;
const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('T58 row 273: activate --send and --no-sso',async(t)=>{
await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
const steps=stepText(e);
assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// The same copy with send on again is already active.
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
await noLeak(e);
});
await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
const out=await planOnly(e,0,'activate',C1,'acme','--send');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']);
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
const want=['--port',e.port,'--business','acme','--sso','--send'];
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
// Another copy carries both.
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T2: --send without --sso, given or carried, refuses before any stop, and --plan lists it',async(t)=>{
const e=await mk(t);await seedPreview(e);
await refused(e,['activate',C1,'acme','--send'],'send-needs-sso',SEND_MSG);
await refused(e,['activate',B,'acme','--send'],'send-needs-sso',SEND_MSG);
const out=await planOnly(e,3,'activate',C1,'acme','--send');
assert.deepEqual(sendLines(out),['send: off']);
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses send-needs-sso: ${SEND_MSG}`]);
// A pointer that already holds --send without --sso still refuses a --send.
const g=await mk(t);await seedPreview(g,undefined,['--send']);
await refused(g,['activate',C1,'acme','--send'],'send-needs-sso',SEND_MSG);
await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`);
for(const x of [e,g])await noLeak(x);
});
await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{
const e=await mk(t),base=await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
const set=arrivalSet(e,C1);
setKey(e,undefined);
const out=await planOnly(e,0,'rollback','acme');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]);
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${base.join(' ')}`);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,base);
assert.doesNotMatch(conExec(e),/ --sso( |$)| --send( |$)/);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${base.join(' ')}`),conExec(e));
assert.ok(stepText(e).includes('Z drop --send; the run drops --sso\n'),stepText(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['activate',C1,'acme','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'],
['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'],
['activate',C1,'acme','--no-sso',...pair(f)],['activate',C1,'acme','--no-sso','--no-proof'],
['rollback','acme','--send'],['rollback','acme','--no-sso'],['pm-on','acme','--send'],['pm-on','acme','--no-sso'],['status','acme','--no-sso'],['adopt','acme','--send']]){
r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
assert.match(usageText,/\n--send needs --sso in the run's Console flags, given or carried\. --no-sso needs --sso in the\n/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const pre=conExec(e),want=noSend(base);
assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.equal(conExec(e),pre.replace(' --send --tester-port 7333',' --tester-port 7333'));
const steps=stepText(e);
assert.ok(steps.includes('Z drop --send; --no-sso was given\n'),steps);
assert.ok(steps.includes(`Z turn off --sso; the Console flags come from ${set}/${SHOW}\n`),steps);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// --sso is off now, so a second --no-sso has nothing to turn off.
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
await noLeak(e);
});
await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
// The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ.
let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']);
const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334'];
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]);
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.doesNotMatch(stepText(e),/drop --send/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
// Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it.
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2);
for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /);
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]);
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,base);assert.equal(conExec(e),pre);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const first=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
// The set of the activation that turned --sso on now claims its Console ran with it too, sums intact.
setEdit(first,SHOW,' --public-host ',' --sso --public-host ');
const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why);
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]);
await noLeak(e);
});
await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]);
// One set, from the activation that turned --sso on.
await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8');
// An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above.
const logText=readFileSync(e.log,'utf8');
appendFileSync(e.log,JSON.stringify({at:'2026-10-22T00:00:00Z',op:'activate',from:C1,to:C1,backup:null,activation:null,result:'ok'})+'\n');
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG('an ok line in the log names no activation'));
writeFileSync(e.log,logText);
setEdit(set,SHOW,' --allow-user jason',' --allow-user mallory',false);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${show} does not match the set's SHA256SUMS`));
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${OFF_MSG(`${show} does not match the set's SHA256SUMS`)}`]);
setEdit(set,SHOW,' --allow-user mallory',' --sso --allow-user jason');
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`the Console in ${show} already ran with --sso, and the log names no earlier activation`));
setEdit(set,SHOW,' serve ',' ');
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${show} holds no Console argv this script can read`));
rmSync(show);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${set} has no ${SHOW} and SHA256SUMS`));
writeFileSync(show,text);setEdit(set,SHOW,'\n','\n');
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
assert.doesNotMatch(conExec(e),/ --sso( |$)| --send( |$)/);
// A pointer with --sso and only the adopt line: no activation took a set.
const g=await mk(t);await seedPreview(g,undefined,['--sso']);
await refused(g,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG('the log names no activation with a rollback set'));
for(const x of [e,g])await noLeak(x);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
+42 -8
View File
@@ -50,6 +50,8 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSyn
import { join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { DiscordError } from "./errors.mjs";
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
import { createRest } from "./rest.mjs";
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
@@ -58,7 +60,7 @@ import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
import { createSetsparkApi } from "./setspark.mjs";
import { assembleContext } from "./context.mjs";
import { createConnector } from "./connector.mjs";
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
const USAGE = [
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
@@ -171,6 +173,9 @@ async function check(opts) {
}
async function run(opts) {
const root = loadDataRoot(opts.config);
const generation = assertProcessing(root).generation;
const processing = () => assertProcessing(root, generation);
const { bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
const token = readToken(binding);
ensureJournal(journalDir);
@@ -180,8 +185,18 @@ async function run(opts) {
}
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
writePid(journalDir, process.pid);
const cleanupPid = () => clearPid(journalDir, process.pid);
let managed = null;
const cleanupPid = () => {
clearPid(journalDir, process.pid);
if (!managed) return;
if (processingStatus(root).halted) updateRuntime(root, managed.id, { phase: 'ended' });
else unregisterRuntime(root, managed.id);
};
try {
processing();
managed = registerRuntime(root, { kind: 'discord', business: 'instance', run: binding.name });
updateRuntime(root, managed.id, { ...processIdentity(process.pid), phase: 'running', group: false });
processing();
await runClaimed();
} catch (err) {
cleanupPid();
@@ -201,6 +216,7 @@ async function run(opts) {
const rest = createRest({ token, log: warn });
const gw = await rest.getGatewayBot();
processing();
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
gateway.on("log", (m) => warn(`gateway: ${m}`));
gateway.on("ready", (r) => warn(`gateway: READY as ${r.user && r.user.id}, session ${r.sessionId}`));
@@ -208,6 +224,7 @@ async function run(opts) {
gateway.on("closed", (c) => warn(`gateway: closed ${c.code} ${c.reason} reconnect=${c.willReconnect}`));
const engine = createEngine({
processingRoot: root, binding: binding.name,
command: pi,
args: buildPiArgs({ ...binding.engine, sessionDir, appendSystemPromptFile: promptFile, continueSession, tools: toolRoots }),
cwd: opts.repo,
@@ -223,15 +240,17 @@ async function run(opts) {
// key as the model's verbs; without a setspark key it has none, and an
// approval request from the model is refused.
const api = binding.tools && binding.tools.setspark ? createSetsparkApi(binding.tools.setspark) : null;
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, log: warn });
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, processing, log: warn });
let shuttingDown = false;
let exitCode = 0;
const shutdown = (code) => {
let processingWatch = null;
const shutdown = (code, immediate = false) => {
if (shuttingDown) return;
shuttingDown = true;
clearInterval(processingWatch);
exitCode = code;
warn("stopping: finishing in-flight turns");
connector.stop().catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
cleanupPid();
process.exit(exitCode);
});
@@ -240,8 +259,20 @@ async function run(opts) {
warn(`gateway fatal close ${f.code}: ${f.reason}`);
shutdown(2);
});
process.on("SIGTERM", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0, processingStatus(root).halted === true));
process.on("SIGINT", () => shutdown(0));
processingWatch = setInterval(() => {
try { processing(); }
catch {
const state = processingStatus(root);
if (state.halted && !state.invalid) {
try { requestProcessingStop(journalDir, state.generation); }
catch { warn('processing STOP could not be recorded'); }
}
shutdown(BRAKE_EXIT, true);
}
}, 100);
processingWatch.unref();
// Reload on SIGHUP: re-read the file, refuse anything that is not a
// reloadable difference, verify new channels over REST, then swap.
@@ -250,11 +281,13 @@ async function run(opts) {
const reloadNow = async () => {
const at = new Date().toISOString();
try {
processing();
const next = loadBinding(bindingFile);
if (next.name !== binding.name) throw new DiscordError(`reload: name ${JSON.stringify(next.name)} does not match ${binding.name}`);
const diff = reloadDiff(connector.binding, next);
for (const id of diff.channels.added) {
const ch = await rest.getChannel(id);
processing();
if (ch.guild_id !== next.guildId) throw new DiscordError(`reload: channel ${id} is in guild ${ch.guild_id}, not ${next.guildId}`);
}
connector.reload(next);
@@ -330,6 +363,7 @@ function reloadCommand(opts) {
function recoverCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
assertProcessing(dataRoot);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
@@ -356,7 +390,7 @@ async function main() {
main().then((code) => {
if (code !== null) process.exit(code);
}).catch((err) => {
const code = err instanceof DiscordError ? err.exitCode : 1;
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
warn(err.message);
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
process.exit(code);
+34 -4
View File
@@ -66,6 +66,7 @@ export function createConnector({
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
typingIntervalMs = 8000,
readReceipt = READ_RECEIPT,
processing = () => {},
log = () => {},
} = {}) {
for (const [k, v] of Object.entries({ binding: initialBinding, journalDir, rest, gateway, engine })) {
@@ -73,6 +74,22 @@ export function createConnector({
}
ensureJournal(journalDir);
let binding = initialBinding;
const guarded = object => new Proxy(object, {
get(target, key) {
const value = Reflect.get(target, key);
if (typeof value !== 'function') return value;
return (...args) => { processing(); return value.apply(target, args); };
},
});
rest = guarded(rest);
if (api) api = guarded(api);
function processingRefusal() {
try { processing(); return null; }
catch (e) {
if (e.code?.startsWith('processing-')) return e.code;
throw e;
}
}
const state = {
inbox: new Set(), channels: new Map(), inFlight: 0, typingTimer: null, typingChannel: null,
@@ -140,6 +157,7 @@ export function createConnector({
function typingTick() {
state.typingTimer = null;
if (processingRefusal()) return;
if (state.inFlight === 0 || state.typingChannel === null) return;
rest.typing(state.typingChannel);
state.typingTimer = setTimeoutImpl(typingTick, typingIntervalMs);
@@ -287,6 +305,8 @@ export function createConnector({
// is answered at once with a private line; an approval is deferred, the
// service is asked, and the request message is then edited in place.
async function handleInteraction(interaction) {
const refusal = processingRefusal();
if (refusal) return { accepted: false, reason: refusal };
if (!interaction || typeof interaction !== "object" || typeof interaction.id !== "string") return { accepted: false, reason: "not-an-interaction" };
if (interaction.type !== INTERACTION_TYPE.MESSAGE_COMPONENT) return { accepted: false, reason: "not-a-component" };
if (state.interactions.has(interaction.id)) return { accepted: false, reason: "duplicate" };
@@ -383,6 +403,7 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
record.engine.turns = result.turns ?? null;
if (binding.tools) record.tools = result.tools || [];
if (result.model) record.engine.model = result.model;
processing();
if (result.text.length === 0) throw new DiscordError("engine returned no text", 1, { code: "engine-empty" });
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: result.text, noncePrefix: message.id });
const opened = (result.tools || []).filter((t) => t && t.name === "open_approval_request" && t.ok && t.request);
@@ -392,10 +413,12 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
}
} catch (err) {
status = "failed";
error = { code: (err.details && err.details.code) || "error", message: err.message };
error = { code: err.code || (err.details && err.details.code) || "error", message: err.message };
log(`turn ${message.id} failed: ${error.code}: ${error.message}`);
// Q19: one fixed line, never model output, on failure.
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: FIXED_LINES.failed, noncePrefix: `${message.id}-f` });
if (!processingRefusal()) {
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: FIXED_LINES.failed, noncePrefix: `${message.id}-f` });
}
} finally {
state.inFlight -= 1;
typingStop();
@@ -436,6 +459,8 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
}
async function admit(message) {
const refusal = processingRefusal();
if (refusal) return drop(message, refusal);
// A thread the connector has not seen: one REST lookup, only when the
// cheap checks (guild, listed author) would let the message through.
const listed = binding.channels.some((c) => c.id === message.channel_id);
@@ -447,6 +472,8 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
log(`channel lookup ${message.channel_id} failed: ${err.message}`);
}
}
const afterLookup = processingRefusal();
if (afterLookup) return drop(message, afterLookup);
const auth = authorize(binding, message, channelInfo);
if (!auth.ok) return drop(message, auth.reason);
if (typeof message.content !== "string") return drop(message, "no-content");
@@ -567,10 +594,12 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
},
async start() {
processing();
if (state.started) throw new DiscordError("connector already started", 1);
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, 1);
const reconciled = await reconcile();
const approvals = await reconcileApprovals();
processing();
if (approvals.length > 0) log(`approvals: ${approvals.map((a) => `${a.kind} ${a.requestId} ${a.status}`).join("; ")}`);
state.inbox = readInboxIds(journalDir);
state.started = true;
@@ -582,13 +611,14 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
},
// Finish in-flight turns (each has its own timeout), then close.
async stop() {
async stop({ immediate = false } = {}) {
state.stopping = true;
gateway.close(1000, "stop");
if (immediate) await engine.stop();
await Promise.allSettled([...state.turnPromises]);
await state.deliveryChain;
typingStop();
await engine.stop();
if (!immediate) await engine.stop();
},
};
}
+55 -2
View File
@@ -35,6 +35,8 @@ import { spawn as nodeSpawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { DiscordError } from "./errors.mjs";
import { enabledToolNames } from "./tools.mjs";
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
export const PI_FIXED_ARGS = Object.freeze([
"--mode", "rpc", "--no-extensions", "--no-context-files", "--no-skills",
@@ -81,6 +83,7 @@ export function createEngine({
log = () => {},
onExit = () => {},
abortGraceMs = ABORT_GRACE_MS,
processingRoot = null, binding = null,
} = {}) {
if (typeof command !== "string" || command.length === 0) throw new DiscordError("engine: command required", 1);
if (!Array.isArray(args)) throw new DiscordError("engine: args required", 1);
@@ -90,6 +93,18 @@ export function createEngine({
// wedged: set when the engine gave up on pi and is stopping it. Nothing
// is sent to that child again.
const state = { child: null, buffer: "", pending: [], held: [], responses: new Map(), nextId: 1, busy: false, exited: null, wedged: false };
let managed = null;
const processing = () => {
if (processingRoot) return assertProcessing(processingRoot, managed?.generation ?? null);
};
const releaseRuntime = () => {
if (!managed) return;
try {
if (!processingStatus(processingRoot).halted && runtimeGroupState(runtime(processingRoot, managed.id)) === false)
unregisterRuntime(processingRoot, managed.id);
}
catch { log('engine: processing receipt cleanup refused'); }
};
// A turn that fails on the client side (timeout, protocol error) stays in
// the pending queue, marked done, until pi's own turn_end for it arrives.
@@ -282,6 +297,7 @@ export function createEngine({
}
function write(command) {
if (command.type === 'prompt') processing();
if (!state.child || state.exited !== null || state.wedged) throw new DiscordError("engine is not running", 1, { code: "engine-down" });
state.child.stdin.write(JSON.stringify(command) + "\n");
}
@@ -301,7 +317,25 @@ export function createEngine({
function stopChild({ graceMs = 5000 } = {}) {
const child = state.child;
if (!child || state.exited !== null) return Promise.resolve(state.exited);
if (!child || (state.exited !== null && !managed)) return Promise.resolve(state.exited);
if (managed) {
let record;
try { record = runtime(processingRoot, managed.id); } catch { return Promise.resolve(state.exited); }
const signal = createRuntimeSignaller(record, members => updateRuntime(processingRoot, managed.id, { members }));
signal('SIGTERM');
const began = Date.now();
return new Promise(resolve => {
let killed = false;
const poll = setInterval(() => {
let live;
try { live = runtimeGroupState(runtime(processingRoot, managed.id)); } catch { live = false; }
if (live === false || Date.now() - began > graceMs + 2000) {
clearInterval(poll); releaseRuntime(); resolve(state.exited); return;
}
if (!killed && Date.now() - began >= graceMs) { signal('SIGKILL'); killed = true; }
}, 20);
});
}
return new Promise((resolve) => {
const timer = setTimeoutImpl(() => {
try {
@@ -326,7 +360,23 @@ export function createEngine({
return {
start() {
if (state.child) throw new DiscordError("engine already started", 1);
const child = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
processing();
if (processingRoot) managed = registerRuntime(processingRoot, { kind: 'discord', business: 'instance', run: binding });
let child;
try {
processing();
child = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], ...(processingRoot ? { detached: true } : {}) });
if (managed) {
const identity = processIdentity(child.pid);
if (!identity) throw new DiscordError('engine process identity unavailable', 1);
updateRuntime(processingRoot, managed.id, { ...identity, group: true, phase: 'running' });
processing();
}
} catch (e) {
if (child?.pid) child.kill('SIGKILL');
releaseRuntime();
throw e;
}
state.child = child;
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
@@ -342,12 +392,14 @@ export function createEngine({
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
child.on("error", (err) => {
releaseRuntime();
log(`engine spawn error: ${err.message}`);
state.exited = { code: null, signal: null, error: err.message };
failAll("engine-down", `engine failed: ${err.message}`);
onExit(state.exited);
});
child.on("exit", (code, signal) => {
releaseRuntime();
state.exited = { code, signal };
failAll("engine-down", `engine exited (code ${code}, signal ${signal})`);
onExit(state.exited);
@@ -358,6 +410,7 @@ export function createEngine({
// Resolves {text, message, tools, turns, usage, model, provider}. Rejects
// with DiscordError carrying details.code for the turn record.
prompt(text, { timeoutMs = 180000 } = {}) {
try { processing(); } catch (e) { return Promise.reject(e); }
if (typeof text !== "string" || text.length === 0) throw new DiscordError("prompt text required", 1);
const turn = { resolve: null, reject: null, timer: null, grace: null, done: false, accepted: false, tools: new Map(), turns: 0, last: null };
const done = new Promise((resolve, reject) => {
+50 -2
View File
@@ -21,7 +21,7 @@
// Directories are 0700, files 0600. Lines are appended, never rewritten.
import {
appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
unlinkSync, writeFileSync, writeSync,
} from "node:fs";
import { join } from "node:path";
@@ -176,16 +176,64 @@ export function stopRequested(dir) {
}
export function requestStop(dir, reason = "stop") {
return stopLock(dir, () => appendStop(dir, reason));
}
function stopLock(dir, action) {
const lock = join(dir, '.stop.lock');
try { mkdirSync(lock, { mode: 0o700 }); }
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
try { return action(); } finally { rmdirSync(lock); }
}
function appendStop(dir, reason) {
const path = stopPath(dir);
const fd = openSync(path, "a", 0o600);
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
fsyncSync(fd);
} finally {
closeSync(fd);
}
return path;
}
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function processingStops(dir) {
let fd;
try {
fd = openSync(stopPath(dir), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o600 || st.size > 65536) return null;
const buffer = Buffer.alloc(65537), size = readSync(fd, buffer, 0, buffer.length, 0);
if (size > 65536) return null;
const bytes = buffer.subarray(0, size);
return new TextDecoder('utf8', { fatal: true }).decode(bytes).split('\n').filter(Boolean).map(JSON.parse);
} catch (e) { return e.code === 'ENOENT' ? [] : null; }
finally { if (fd !== undefined) closeSync(fd); }
}
export function requestProcessingStop(dir, generation) {
if (!PROCESSING_GENERATION.test(generation)) throw new DiscordError('invalid processing generation', 1);
return stopLock(dir, () => {
const rows = processingStops(dir);
if (rows === null) throw new DiscordError('STOP cannot be verified', 1);
const reason = `processing:${generation}`;
if (!rows.some(r => r?.reason === reason)) appendStop(dir, reason);
return stopPath(dir);
});
}
export function clearProcessingStop(dir, generation) {
if (!PROCESSING_GENERATION.test(generation)) return false;
return stopLock(dir, () => {
const rows = processingStops(dir);
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
new Date(r.at).toISOString() !== r.at)) return false;
unlinkSync(stopPath(dir));
return true;
});
}
export function clearStop(dir) {
const path = stopPath(dir);
if (existsSync(path)) unlinkSync(path);
+30
View File
@@ -0,0 +1,30 @@
// Host-only adapters for the root coordinator. Workers cannot select these paths.
import { lstatSync } from 'node:fs';
import { dirname } from 'node:path';
import { bindingDataDir } from './binding.mjs';
import { requestProcessingStop, clearProcessingStop } from './journal.mjs';
import { listRuntimes, unregisterRuntime } from '../../processing-control/src/runtimes.mjs';
function journal(root, record) {
const dir = bindingDataDir(root, record.run);
for (const path of [dirname(dir), dir]) {
const st = lstatSync(path);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077))
throw new Error('processing-discord-path-unsafe');
}
return dir;
}
export function discordProcessingHooks(dataRoot) {
return {
beforeStop(record, generation) {
if (record.kind === 'discord') requestProcessingStop(journal(dataRoot, record), generation);
},
onResume(generation) {
for (const r of listRuntimes(dataRoot)) {
if (r.kind === 'discord' && r.haltGeneration === generation) {
clearProcessingStop(journal(dataRoot, r), generation);
unregisterRuntime(dataRoot, r.id);
}
}
},
};
}
+2
View File
@@ -7,6 +7,7 @@ import { validateBinding, loadBinding, readToken, checkPrivateFile, resolveConte
import { homedir } from "node:os";
import { DiscordError } from "../src/errors.mjs";
import { makeRoot, makeRepo, makeDeployment, rawBinding } from "./helpers.mjs";
import { initializeControl } from '../../processing-control/src/state.mjs';
const cli = join(import.meta.dirname, "..", "src", "cli.mjs");
@@ -202,6 +203,7 @@ test("cli: run refuses when STOP is present, before any network use", () => {
const root = makeRoot();
const repo = makeRepo(root);
const dep = makeDeployment(root);
initializeControl(dep.dataRoot);
const r0 = runCli(["stop", "test-seat", "--config", dep.config]);
assert.equal(r0.status, 0, r0.stderr);
assert.match(r0.stdout, /STOP written/);
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, symlinkSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { makeRoot } from './helpers.mjs';
import { ensureJournal, requestStop, stopRequested, stopPath } from '../src/journal.mjs';
const api = await import('../src/journal.mjs');
function fixture() { const dir = join(makeRoot(), 'journal'); ensureJournal(dir); return dir; }
test('resume clears only its own generation-tagged Discord STOP and keeps manual or newer brakes', () => {
assert.equal(typeof api.requestProcessingStop, 'function');
const dir = fixture(), generation = randomUUID();
api.requestProcessingStop(dir, generation); const first = readFileSync(stopPath(dir));
api.requestProcessingStop(dir, generation); assert.deepEqual(readFileSync(stopPath(dir)), first);
assert.equal(api.clearProcessingStop(dir, randomUUID()), false);
assert.equal(api.clearProcessingStop(dir, generation), true); assert.equal(stopRequested(dir), false);
api.requestProcessingStop(dir, generation); requestStop(dir, 'cli stop');
const mixed = readFileSync(stopPath(dir));
assert.equal(api.clearProcessingStop(dir, generation), false); assert.deepEqual(readFileSync(stopPath(dir)), mixed);
});
test('unreadable, malformed and symlink STOP never get removed or followed by processing recovery', () => {
assert.equal(typeof api.requestProcessingStop, 'function');
const dir = fixture(), generation = randomUUID();
writeFileSync(stopPath(dir), 'unreadable operator brake\n', { mode: 0o600 });
assert.equal(api.clearProcessingStop(dir, generation), false);
unlinkSync(stopPath(dir)); const outside = join(dir, 'outside');
writeFileSync(outside, 'untouched', { mode: 0o600 }); symlinkSync(outside, stopPath(dir));
assert.equal(api.clearProcessingStop(dir, generation), false);
assert.throws(() => api.requestProcessingStop(dir, generation));
assert.equal(readFileSync(outside, 'utf8'), 'untouched');
});
test('coordinator halt writes the Discord brake and resume clears only its own stopped runtime', async () => {
const { initializeControl } = await import('../../processing-control/src/state.mjs');
const { registerRuntime, updateRuntime } = await import('../../processing-control/src/runtimes.mjs');
const { createProcessing } = await import('../../processing-control/src/coordinator.mjs');
const { discordProcessingHooks } = await import('../src/processing.mjs');
const root = makeRoot(); initializeControl(root);
const dir = join(root, 'discord', 'test-seat'); ensureJournal(dir);
const processing = createProcessing({ dataRoot: root, ...discordProcessingHooks(root) });
const first = registerRuntime(root, { kind: 'discord', business: 'instance', run: 'test-seat' });
updateRuntime(root, first.id, { phase: 'ended' });
const halted = await processing.halt({ business: 'demo', human: 'jason' });
assert.equal(halted.quiescent, true); assert.equal(stopRequested(dir), true);
await processing.resume({ business: 'demo', human: 'jason', generation: halted.generation });
assert.equal(stopRequested(dir), false);
const second = registerRuntime(root, { kind: 'discord', business: 'instance', run: 'test-seat' });
updateRuntime(root, second.id, { phase: 'ended' });
const again = await processing.halt({ business: 'demo', human: 'jason' });
requestStop(dir, 'cli stop'); const bytes = readFileSync(stopPath(dir));
await processing.resume({ business: 'demo', human: 'jason', generation: again.generation });
assert.deepEqual(readFileSync(stopPath(dir)), bytes);
await processing.close();
});
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createConnector } from '../src/connector.mjs';
import { initializeControl, assertProcessing, transition } from '../../processing-control/src/state.mjs';
import { makeRoot, makeRepo, makeDeployment, binding, message, fakeRest, fakeGateway, fakeEngine } from './helpers.mjs';
import { appendOutbox, ensureJournal, readOutbox, listTurns } from '../src/journal.mjs';
const actor = { business: 'demo', human: 'jason', halted: true };
function fixture() {
const root = makeRoot(); initializeControl(root);
const journalDir = join(root, 'journal'), rest = fakeRest(), gateway = fakeGateway(), engine = fakeEngine({ replies: [{ text: 'reply' }] });
const processing = () => assertProcessing(root);
const options = { binding: binding(), journalDir, rest, gateway, engine, processing };
return { root, journalDir, rest, gateway, engine, options };
}
test('halted connector refuses before outbox reconciliation, engine or gateway start', async () => {
const f = fixture(); ensureJournal(f.journalDir);
appendOutbox(f.journalDir, { nonce: 'pending', channelId: '100000000000000200', status: 'intent', content: 'do not replay', at: new Date().toISOString() });
transition(f.root, actor);
await assert.rejects(createConnector(f.options).start(), e => e.code === 'processing-halted');
assert.equal(f.rest.calls.length, 0); assert.equal(f.engine.started, false);
const corrupt = fixture(); ensureJournal(corrupt.journalDir);
const connector = createConnector(corrupt.options);
transition(corrupt.root, actor);
writeFileSync(join(corrupt.journalDir, 'outbox.jsonl'), '{broken\n', { mode: 0o600 });
await assert.rejects(connector.start(), e => e.code === 'processing-halted');
assert.equal(corrupt.rest.calls.length, 0); assert.equal(corrupt.engine.started, false);
});
test('halt arriving during startup reconciliation prevents engine and gateway start', async () => {
const f = fixture(); ensureJournal(f.journalDir);
appendOutbox(f.journalDir, { nonce: 'pending', channelId: '100000000000000200', status: 'intent', content: 'first', at: new Date().toISOString() });
const create = f.rest.createMessage;
f.rest.createMessage = async (...args) => { const result = await create(...args); transition(f.root, actor); return result; };
await assert.rejects(createConnector(f.options).start(), e => e.code === 'processing-halted');
assert.equal(f.engine.started, false);
});
test('halted connector refuses a new inbound turn before claiming inbox or invoking the engine', async () => {
const f = fixture(), connector = createConnector(f.options); await connector.start();
transition(f.root, actor);
const answer = await connector.handleMessage(message());
assert.equal(answer.accepted, false); assert.equal(answer.reason, 'processing-halted');
assert.equal(connector.inboxSize, 0); assert.equal(f.engine.prompts.length, 0);
await connector.stop();
});
test('halt during a model turn never sends its answer or a fallback failure message', async () => {
const f = fixture();
f.engine.prompt = async () => { transition(f.root, actor); return { text: 'must not send', usage: { input: 1, output: 1 } }; };
const connector = createConnector(f.options); await connector.start();
const turn = await connector.handleMessage(message()); await turn.turn;
assert.equal(f.rest.calls.length, 0);
assert.equal(listTurns(f.journalDir)[0].error.code, 'processing-halted');
await connector.stop();
});
test('Discord run and recover refuse a halted root before reading a token or reaching network', () => {
const root = makeRoot(), repo = makeRepo(root), d = makeDeployment(root);
mkdirSync(d.dataRoot, { recursive: true, mode: 0o700 }); initializeControl(d.dataRoot);
transition(d.dataRoot, actor); unlinkSync(d.tokenFile);
for (const command of ['run', 'recover']) {
const r = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, command, d.raw.name, '--config', d.config, '--repo', repo], { encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /processing-halted/);
}
});
test('Discord engine publishes a verified group and a root halt kills it independently of the connector', async t => {
const { createEngine } = await import('../src/engine-pi.mjs');
const { listRuntimes, sweepRuntimes, processIdentity } = await import('../../processing-control/src/runtimes.mjs');
const f = fixture();
const engine = createEngine({ command: process.execPath,
args: [new URL('./fake-pi.mjs', import.meta.url).pathname], cwd: f.root,
env: { ...process.env }, processingRoot: f.root, binding: 'test-seat' });
t.after(() => engine.stop({ graceMs: 50 }));
const child = engine.start();
const tracked = listRuntimes(f.root);
assert.equal(tracked.length, 1); assert.equal(tracked[0].pid, child.pid);
assert.equal(tracked[0].kind, 'discord'); assert.equal(tracked[0].group, true);
const halted = transition(f.root, actor);
await assert.rejects(engine.prompt('not admitted'), e => e.code === 'processing-halted');
const result = await sweepRuntimes(f.root, halted.generation, { graceMs: 80, killWaitMs: 1000 });
assert.equal(result.quiescent, true); assert.equal(processIdentity(child.pid), null);
});
+2
View File
@@ -13,6 +13,7 @@ import {
} from "../src/journal.mjs";
import { DiscordError } from "../src/errors.mjs";
import { makeRoot, makeRepo, makeDeployment } from "./helpers.mjs";
import { initializeControl } from '../../processing-control/src/state.mjs';
const cli = fileURLToPath(new URL("../src/cli.mjs", import.meta.url));
const worker = fileURLToPath(new URL("../fixtures/legacy-owner-worker.mjs", import.meta.url));
@@ -146,6 +147,7 @@ test("cli: recover exits 0 when ready, 3 behind a brake or a held binding, and r
const root = makeRoot();
const repo = makeRepo(root);
const dep = makeDeployment(root);
initializeControl(dep.dataRoot);
const run = (args) => spawnSync(process.execPath, [cli, ...args, "--config", dep.config, "--repo", repo], { encoding: "utf8" });
let r = run(["recover", "test-seat"]);
assert.equal(r.status, 0, r.stderr);
+22 -7
View File
@@ -34,22 +34,30 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
`manuscript` lists folder names and shares none with `types`.
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
non-hybrid `sources`, `constraints` (each one `{ "exclude": ... }` or
`{ "pin": { "entity", "field", "value" } }`) and `notes`.
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as
`{ kind: "draft", name, books: [], playground }`. `kind` is absent, `"hybrid"` or `"draft"`.
- A playground prefix ends in `/` and has one of three levels (C2-01c). Book level lies inside one of
its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
listed twice, across every entry.
Refusals:
| Code | When |
|---|---|
| `library-playground-outside-book` | a playground prefix is not strictly inside a book of its own universe |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, a book folder in two universes, a book folder that holds another, a playground prefix that holds another, an unknown hybrid source |
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground prefix that holds another or is listed twice, a prefix equal to a book folder or holding its own entry's book, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
## Scope
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
mapped. A path under a playground prefix is `playground`. Any other path under a book folder is
`canon`. Everything else is `unmapped`. The type is the first folder after the book, or after the
playground prefix: a `types` name, `manuscript`, or `null`.
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
in a draft. Any other path under a book folder is `canon`. Everything else is `unmapped`. The type is
the first folder after the playground prefix, or after the book: a `types` name, `manuscript`, or
`null`.
## Reader
@@ -165,6 +173,13 @@ lets go, so an object must outlive the next move of `served`. The bus host runs
with a `user`, once at start and then every 60 seconds; the slot is
`<configDir>/secrets/<business>/library-gitea`. `library.sync` is the human's bus verb for the same.
The human transport (`packages/bus/src/human-cli.mjs`) waits 130 seconds for `library.sync`, past the
server's 120-second hold, and 5 seconds for every other verb. A client that gives up before the answer
gets `outcome-unknown`, but the sync keeps running: a second call meanwhile gets `library-sync-busy`,
and `library.status` shows the result once it ends. A sync that outlasts the 120-second hold gets
`request-timeout` from the server and still finishes the same way. The Console has no library route yet: `cli.mjs`
passes `librarySync` to `startServer`, and `serve.mjs` doesn't read it.
## Tests
```sh
+30 -14
View File
@@ -1,5 +1,6 @@
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
// books, type folders, manuscript folders and playground prefixes. A pure validator. Business file
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. Business file
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
// problem. Agents can't write this entry (M9, C2-04).
import { raise } from './problems.mjs';
@@ -10,6 +11,7 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
// C2-03a (sage-2021): the bot account sync answers git's Username prompt with. Row 199's NAME.
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
const CONSTRAINT_KEYS = ['exclude', 'pin'];
const KINDS = ['hybrid', 'draft'];
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
// and dot names are not.
@@ -61,29 +63,29 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
object(entry.universes, 'universes');
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
const universes = {}, bookOwner = new Map(), prefixes = [];
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
for (const [id, universe] of Object.entries(entry.universes)) {
const name = `universes.${id}`;
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
object(universe, name);
const hybrid = universe.kind === 'hybrid';
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes'] : ['name', 'books', 'playground'], name);
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
bad(`${name}.name must be a short display name`);
const books = list(universe.books, `${name}.books`, folder, 'a folder path');
if (!hybrid && !books.length) bad(`${name}.books must name at least one book folder`);
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
if (!hybrid && !draft && !books.length) bad(`${name}.books must name at least one book folder`);
for (const book of books) {
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
bookOwner.set(book, id);
}
const playground = list(universe.playground ?? [], `${name}.playground`,
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
for (const prefix of playground) {
// Every playground file must have a universe and a book (1.1, B1).
if (!books.some(book => holds(book, prefix) && prefix !== `${book}/`))
fail('library-playground-outside-book', `${where} ${name}.playground ${JSON.stringify(prefix)} is not inside one of its books`);
prefixes.push(prefix);
}
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
const out = { name: universe.name, books, playground };
if (hybrid) {
out.kind = 'hybrid';
@@ -114,14 +116,28 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
}
for (const [id, universe] of Object.entries(universes)) {
for (const source of universe.sources ?? []) {
if (!Object.hasOwn(universes, source) || universes[source].kind === 'hybrid')
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a non-hybrid universe`);
if (!Object.hasOwn(universes, source) || Object.hasOwn(universes[source], 'kind'))
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a plain universe`);
}
}
const books = [...bookOwner.keys()];
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
// into playground.
for (const [prefix, id] of prefixOwner) {
for (const [book, owner] of bookOwner) {
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
const inside = holds(book, prefix), holding = holds(prefix, book);
if ((inside || holding) && owner !== id)
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
}
}
return deepFreeze({
kind: 'library', remote: entry.remote, branch: entry.branch,
+21 -10
View File
@@ -3,19 +3,30 @@
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
// `type` is the mapped type of the first folder under the book (canon) or under the playground
// prefix (playground), 'manuscript' for a manuscript folder, else null. `folder` is that folder name.
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
// prefixes from overlapping, so at most one matches.
export function classify(mapping, path) {
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
for (const [universe, entry] of Object.entries(mapping.universes)) {
const book = entry.books.find(b => inside(b, path));
if (!book) continue;
const entries = Object.entries(mapping.universes);
for (const [universe, entry] of entries) {
const prefix = entry.playground.find(p => inside(p, path));
const rest = path.slice((prefix ?? `${book}/`).length).split('/');
const folder = rest.length > 1 ? rest[0] : null;
const type = folder === null ? null : Object.hasOwn(mapping.types, folder) ? mapping.types[folder]
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
return { scope: prefix ? 'playground' : 'canon', universe, book, type, folder };
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
}
for (const [universe, entry] of entries) {
const book = entry.books.find(b => inside(b, path));
if (book) return place(mapping, path, `${book}/`, { scope: 'canon', universe, book });
}
return { scope: 'unmapped' };
}
function place(mapping, path, under, where) {
const rest = path.slice(under.length).split('/');
const folder = rest.length > 1 ? rest[0] : null;
const type = folder === null ? null : Object.hasOwn(mapping.types, folder) ? mapping.types[folder]
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
return { ...where, type, folder };
}
+52 -8
View File
@@ -13,16 +13,57 @@ test('the fixture mapping checks and comes back frozen', () => {
assert.throws(() => { m.types.Extra = 'place'; });
});
test('a playground prefix outside its universe books refuses library-playground-outside-book', () => {
refuses(e => { e.universes.harbor.playground = ['Second World/Playground/']; return e; }, 'library-playground-outside-book');
refuses(e => { e.universes.harbor.playground = ['Elsewhere/']; return e; }, 'library-playground-outside-book');
// The whole book as its playground would leave no canon: not inside the book.
refuses(e => { e.universes.harbor.playground = ['Book One/']; return e; }, 'library-playground-outside-book');
const draft = (playground = ['Drafts/New World/']) => ({ kind: 'draft', name: 'New World', playground });
test('C2-01c: a prefix inside or holding another entry\'s book refuses library-playground-other-universe', () => {
refuses(e => { e.universes.harbor.playground = ['Second World/Playground/']; return e; },
'library-playground-other-universe', /universes.harbor.playground "Second World\/Playground\/" lies inside book folder "Second World" of universes.second/);
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.playground = ['Worlds/']; return e; },
'library-playground-other-universe', /"Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
// A draft has no books, so a draft prefix inside a book is always inside another entry's.
refuses(e => { e.universes.idea = draft(['Book One/New World/']); return e; },
'library-playground-other-universe', /universes.idea.playground "Book One\/New World\/" lies inside book folder "Book One" of universes.harbor/);
});
test('C2-01c: a prefix equal to a book folder, or holding its own, refuses library-mapping-invalid', () => {
// The whole book as its playground would leave no canon.
refuses(e => { e.universes.harbor.playground = ['Book One/']; return e; }, 'library-mapping-invalid', /is the whole book folder "Book One"/);
refuses(e => { e.universes.harbor.playground = ['Second World/']; return e; }, 'library-mapping-invalid', /is the whole book folder "Second World"/);
refuses(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = ['Shelf/']; return e; },
'library-mapping-invalid', /"Shelf\/" holds its own book folder "Shelf\/Book One"/);
});
test('C2-01c: universe-level prefixes lie outside every book, in a plain universe and in a hybrid with no books', () => {
const m = mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Harbor Ideas/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], playground: ['Crossover Ideas/'] };
return e;
});
assert.deepEqual(m.universes.harbor.playground, ['Book One/Playground/', 'Harbor Ideas/']);
assert.deepEqual([m.universes.cross.books, m.universes.cross.playground], [[], ['Crossover Ideas/']]);
});
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
const m = mapping(e => { e.universes.idea = draft(); return e; });
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
refuses(e => { e.universes.idea = draft([]); return e; }, 'library-mapping-invalid', /universes.idea.playground must name at least one prefix/);
refuses(e => { e.universes.idea = { kind: 'draft', name: 'New World' }; return e; }, 'library-mapping-invalid', /at least one prefix/);
refuses(e => { e.universes.idea = { ...draft(), notes: 'x' }; return e; }, 'library-mapping-invalid', /unknown key "notes"/);
for (const kind of ['library', 'Draft', 'plain', null, 1])
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
});
test('no playground prefix holds another, and no book folder holds another', () => {
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
'library-mapping-invalid', /holds playground/);
// The rule covers every level and every entry (C2-01c).
refuses(e => { e.universes.harbor.playground = ['Ideas/']; e.universes.idea = draft(['Ideas/New World/']); return e; },
'library-mapping-invalid', /playground "Ideas\/" holds playground "Ideas\/New World\/"/);
refuses(e => { e.universes.harbor.playground = ['Ideas/']; e.universes.idea = draft(['Ideas/']); return e; },
'library-mapping-invalid', /listed twice/);
refuses(e => { e.universes.second.books = ['Second World', 'Book One/Part Two']; return e; }, 'library-mapping-invalid', /holds book folder/);
refuses(e => { e.universes.second.books = ['Book One']; return e; }, 'library-mapping-invalid', /in two universes/);
});
@@ -58,7 +99,10 @@ test('a hybrid universe is a shape the query code can take (1.4), with sources t
notes: 'Set after book one.' }; return e; };
const m = checkMapping(hybrid(mappingEntry()));
assert.deepEqual(m.universes.cross.sources, ['harbor', 'second']);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor', 'nowhere']; return e; }, 'library-mapping-invalid', /non-hybrid universe/);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor', 'nowhere']; return e; }, 'library-mapping-invalid', /not a plain universe/);
// A draft has no canon to draw on (C2-01c).
refuses(e => { hybrid(e); e.universes.idea = draft(); e.universes.cross.sources = ['harbor', 'idea']; return e; },
'library-mapping-invalid', /sources names "idea", which is not a plain universe/);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor']; return e; }, 'library-mapping-invalid', /two or more/);
refuses(e => { hybrid(e); e.universes.cross.constraints = [{ exclude: 'x', pin: {} }]; return e; }, 'library-mapping-invalid', /exactly one/);
});
@@ -67,9 +111,9 @@ test('a caller-supplied fail receives the code, as business file loading will pa
const seen = [];
const fail = (code, message) => { seen.push(code); throw new Error(message); };
const entry = mappingEntry();
entry.universes.harbor.playground = ['Elsewhere/'];
entry.universes.harbor.playground = ['Second World/Playground/'];
assert.throws(() => checkMapping(entry, 'businesses/acme.json projects.books', { fail }), /businesses\/acme.json projects.books/);
assert.deepEqual(seen, ['library-playground-outside-book']);
assert.deepEqual(seen, ['library-playground-other-universe']);
});
test('scope comes from the path alone (design 1.2)', () => {
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classify, readLibrary } from '../src/index.mjs';
import { BASE, mapping, repo } from './helpers.mjs';
// Playgrounds at three levels (ruling after r11, C2-01c). The fixture's harbor keeps its book-level
// prefix and gains a universe-level one. A hybrid with no books has a universe-level prefix, and a
// draft has its own.
const levels = () => mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Harbor Ideas/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], playground: ['Crossover Ideas/'] };
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'] };
return e;
});
test('C2-01c: each level gives its universe, and its book or null', () => {
const m = levels();
assert.deepEqual(classify(m, 'Book One/Playground/Characters/New.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
assert.deepEqual(classify(m, 'Harbor Ideas/Places/Island.md'),
{ scope: 'playground', universe: 'harbor', book: null, type: 'place', folder: 'Places' });
assert.deepEqual(classify(m, 'Harbor Ideas/Loose.md'), { scope: 'playground', universe: 'harbor', book: null, type: null, folder: null });
assert.deepEqual(classify(m, 'Crossover Ideas/Chapters/Meeting.md'),
{ scope: 'playground', universe: 'cross', book: null, type: 'manuscript', folder: 'Chapters' });
assert.deepEqual(classify(m, 'Drafts/New World/Characters/Ana.md'),
{ scope: 'playground', universe: 'idea', book: null, type: 'character', folder: 'Characters' });
for (const path of ['Harbor Ideas/Board.canvas', 'Crossover Ideas/Sketch.canvas', 'Drafts/New World/Map.canvas',
'Drafts/Other.md', 'Drafts/New World.md', 'Harbor Ideasx/a.md'])
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
});
test('C2-01c: prefixes come first: in a book that holds a prefix, a file outside it is canon and one under it playground', () => {
const m = levels();
assert.deepEqual(classify(m, 'Book One/Places/Dock.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'place', folder: 'Places' });
assert.deepEqual(classify(m, 'Book One/Playground.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.deepEqual(classify(m, 'Second World/Chapters/01.md'), { scope: 'canon', universe: 'second', book: 'Second World', type: 'manuscript', folder: 'Chapters' });
});
test('C2-01c: a canon file linking into a draft gives R6, and into its own universe-level prefix gives R5', async t => {
const r = repo(t, { ...BASE, 'Harbor Ideas/Island.md': 'An island.\n', 'Drafts/New World/Ana.md': 'Ana.\n',
'Book One/Places/Dock.md': 'See [[Island]].\n', 'Book One/Places/Pier.md': 'Like [[Ana]].\n' });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: levels() });
const codes = path => result.problems.filter(p => p.path === path).map(p => p.code).sort();
const file = path => result.files.find(f => f.path === path);
assert.deepEqual(codes('Book One/Places/Dock.md'), ['R5']);
// Any playground gives R5. A draft is never a source, so it gives R6 as well.
assert.deepEqual(codes('Book One/Places/Pier.md'), ['R5', 'R6']);
assert.deepEqual(file('Book One/Places/Pier.md').links.map(l => l.path), ['Drafts/New World/Ana.md']);
for (const [path, universe] of [['Harbor Ideas/Island.md', 'harbor'], ['Drafts/New World/Ana.md', 'idea']]) {
assert.deepEqual([file(path).scope, file(path).universe, file(path).book], ['playground', universe, null], path);
assert.deepEqual(codes(path), [], path);
}
});
+42
View File
@@ -0,0 +1,42 @@
# Instance processing control
The durable switch is `<dataRoot>/control/processing.json`, using the root resolved
by the existing system configuration loader. All businesses in that root share
one switch; separate roots remain independent. No package selects an instance by
business name or systemd unit name.
A human runs `mosaic halt [--business ID]`, `mosaic processing status --json` and
`mosaic resume [--business ID]`. The business chooses the human proof context.
Halt publishes state before waiting for stops. Resume checks the displayed
generation and refuses while a stop is unresolved. Neither agents nor tester
sessions can change the switch. CLI channel settings cannot disable recovery.
The bus remains available for reads and human management. Managed processing,
new sessions, task containers, tracker activity and outbound notification stop.
Native diagnostic/development seats remain available. A remote request already
accepted cannot be undone: its completion or unknown outcome remains evidence.
No task is automatically retried on resume.
The trusted host explicitly initializes the first off document. Once control/
exists, a missing, malformed, unsafe or oversized state refuses work. Recovery
is halt, then resume; unsafe parent ownership/path boundaries require operator
repair. Never delete the file to clear a halt. State publication uses exclusive
temporary files, fsync and atomic rename. Run evidence remains write-once.
Runtime receipts bind generated ids to the canonical root, boot id and process
start ticks or inspected container id and labels. Stops begin concurrently,
TERM first, then KILL after five seconds, with two seconds for observation.
Unknown remains unknown and prevents resume. Stop receipts are write-once and
mirrored into reserved bus actions. An interrupted Docker CLI is not evidence
that its container stopped; a container that cannot be inspected stays unknown.
Task containers mount the whole control directory read-only, below the existing
root mount. The image entrypoint uses the shared strict reader and admission
generation. A row-191 upgrade must stop old processing before the first trusted
initialization and align the image, bus and copy in a separately approved window.
Nothing in a code build performs that activation.
The Console status and persistent halted banner work read-only. The controls
require the existing authenticated human write path and `--send`; during Q175's
interim the UI explains that the instance CLI is the working path. No write
exception or alternate authentication scheme is introduced.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@mosaic/processing-control",
"private": true,
"type": "module",
"scripts": { "test": "node --test tests/*.test.mjs" }
}
@@ -0,0 +1,23 @@
#!/usr/bin/env node
// The shell launchers already load/release/bootstrap. Resolve the selected config again here.
import { randomUUID } from 'node:crypto';
import { loadSystem } from '../../cli/src/config.mjs';
import { runContainer } from './containers.mjs';
import { unregisterRuntime } from './runtimes.mjs';
import { initializeControl, assertProcessing } from './state.mjs';
try {
const system = loadSystem();
initializeControl(system.dataRoot);
assertProcessing(system.dataRoot);
const args = process.argv.slice(2), tty = args[0] === '--tty';
if (args.length === 1 && args[0] === '--check') process.exit(0);
if (tty) args.shift();
const result = runContainer(system.dataRoot, { kind: 'container', business: 'instance', run: randomUUID() },
{ tty, args, env: process.env, stdio: 'inherit' });
if (!result.haltGeneration && !result.proc.error && result.proc.status === 0) unregisterRuntime(system.dataRoot, result.managed.id);
if (result.proc.error) throw result.proc.error;
process.exit(result.proc.status ?? 1);
} catch (e) {
process.stderr.write(`processing: ${e.code?.startsWith('processing-') ? e.code : 'container-launch-failed'}\n`);
process.exit(3);
}
@@ -0,0 +1,86 @@
import { randomUUID } from 'node:crypto';
import { spawnSync, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { initializeControl, assertProcessing } from './state.mjs';
import { registerRuntime, updateRuntime, runtime, rootTag } from './runtimes.mjs';
const execute = promisify(execFile);
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
export function prepareContainer(dataRoot, record) {
initializeControl(dataRoot);
const state = assertProcessing(dataRoot);
const containerName = `mosaic-${rootTag(dataRoot).slice(0, 12)}-${randomUUID()}`;
const managed = registerRuntime(dataRoot, { ...record, containerName });
assertProcessing(dataRoot, state.generation);
return managed;
}
export function containerArgs(record, { tty = false, args = [] } = {}) {
return ['compose', 'run', '--rm', ...(!tty ? ['-T'] : []), '--name', record.containerName,
'--label', `mosaic.root=${record.root}`, '--label', `mosaic.runtime=${record.id}`,
'-e', `MOSAIC_PROCESSING_GENERATION=${record.generation}`, 'mosaic-agent', ...args];
}
export function runContainer(dataRoot, record, { tty = false, args = [], ...options } = {}) {
const managed = prepareContainer(dataRoot, record);
assertProcessing(dataRoot, managed.generation);
// Keep this synchronous. The independent broker sweeps the prepublished name while this waits.
const proc = spawnSync('docker', containerArgs(managed, { tty, args }), options);
const current = runtime(dataRoot, managed.id);
// An interrupted Docker client is not proof its container exited. Keep that receipt for inspection.
if (!proc.error && proc.status === 0) updateRuntime(dataRoot, managed.id, { phase: 'ended' });
return { proc, managed, haltGeneration: current.haltGeneration ?? null };
}
export function createContainerStopper({ dataRoot = null, docker = async args => {
const r = await execute('docker', args, { encoding: 'utf8', timeout: 2000, maxBuffer: 131072 });
return r.stdout;
} } = {}) {
return async (record, { graceMs = 5000, killWaitMs = 2000 } = {}) => {
const signals = [];
let id = record.containerId ?? null, seen = false;
const start = Date.now(), termEnd = start + graceMs, end = termEnd + killWaitMs;
async function inspect(target) {
const doc = JSON.parse(await docker(['inspect', '--format', '{{json .}}', target]));
if (!doc || !/^[a-f0-9]{64}$/.test(doc.Id) || doc.Name !== '/' + record.containerName ||
doc.Config?.Labels?.['mosaic.root'] !== record.root || doc.Config?.Labels?.['mosaic.runtime'] !== record.id ||
typeof doc.State?.Running !== 'boolean' || (id && doc.Id !== id)) {
const error = new Error('container identity mismatch'); error.code = 'identity-mismatch'; throw error;
}
if (!id) {
id = doc.Id;
if (dataRoot) updateRuntime(dataRoot, record.id, { containerId: id });
}
seen = true;
return doc;
}
while (Date.now() <= end) {
try {
const doc = await inspect(id ?? record.containerName);
if (!doc.State.Running) return { outcome: signals.includes('SIGKILL') ? 'killed' : signals.length ? 'terminated' : 'already-exited', signals };
const next = !signals.length ? 'TERM' : Date.now() >= termEnd && !signals.includes('SIGKILL') ? 'KILL' : null;
if (next) {
// Revalidate immediately before each signal, targeting the immutable inspected id.
const checked = await inspect(id);
if (!checked.State.Running) continue;
await docker(['kill', '--signal', next, id]);
signals.push('SIG' + next);
}
} catch (e) {
// A missing name can still be a queued create. Only a container observed before a signal
// and then removed by --rm is known to have finished. Never signal on an inspect failure.
if (e.code === 'identity-mismatch') return { outcome: 'unknown', signals };
if (id && seen && signals.length) {
try {
const listed = await docker(['ps', '--all', '--no-trunc', '--filter', `id=${id}`, '--format', '{{.ID}}']);
if (listed.trim() === '') return { outcome: signals.includes('SIGKILL') ? 'killed' : 'terminated', signals };
} catch { /* a failed Docker request proves nothing about the workload */ }
}
if (dataRoot) {
try {
if (runtime(dataRoot, record.id).phase === 'ended')
return { outcome: signals.includes('SIGKILL') ? 'killed' : signals.length ? 'terminated' : 'already-exited', signals };
} catch { return { outcome: 'unknown', signals }; }
}
}
await pause(Math.min(20, Math.max(1, end - Date.now())));
}
return { outcome: 'unknown', signals };
};
}
@@ -0,0 +1,74 @@
import { initializeControl, processingStatus, transition, ProcessingError } from './state.mjs';
import { readStops, sweepRuntimes } from './runtimes.mjs';
import { withProcessing, currentProcessing } from './effects.mjs';
// The control bus stays reachable. Only its processing, supervised workloads and background effects stop.
export function createProcessing({ dataRoot, graceMs = 5000, killWaitMs = 2000, containerStop = null,
onTransition = () => {}, onStop = () => {}, beforeStop = () => {}, onResume = () => {}, log = () => {} }) {
try { initializeControl(dataRoot); } catch (e) {
if (!e.code?.startsWith('processing-')) throw e;
}
let active = null, timer = null, closed = false;
let last = { quiescent: true, remaining: 0, stopped: 0 };
let settledGeneration = null;
const read = () => {
const state = processingStatus(dataRoot);
const counts = state.invalid || (state.halted && state.generation !== settledGeneration)
? { quiescent: false, remaining: null, stopped: 0 } : last;
return { ...state, ...counts, stopping: active !== null };
};
function reconcile() {
if (active) return active;
const state = processingStatus(dataRoot);
if (state.invalid) return Promise.resolve({ quiescent: false, remaining: null, stopped: 0 });
active = Promise.resolve().then(async () => {
await onTransition(state);
if (state.halted) {
const result = await sweepRuntimes(dataRoot, state.generation, { graceMs, killWaitMs, containerStop, beforeStop });
for (const stop of readStops(dataRoot, state.generation)) await onStop(stop);
const { results, ...summary } = result;
last = summary;
settledGeneration = state.generation;
} else last = { quiescent: true, remaining: 0, stopped: 0 };
return last;
}).finally(() => { active = null; });
return active;
}
return {
read,
work: fn => withProcessing(dataRoot, fn),
guard: () => currentProcessing()?.guard(),
signal: () => currentProcessing()?.signal,
stopReceipts: generation => readStops(dataRoot, generation),
// Publication is synchronous: no timer/model/HTTP wait can delay the admission fence.
halt(actor) {
const published = transition(dataRoot, { ...actor, halted: true });
return reconcile().then(summary => ({ ...published, ...summary }));
},
async resume(actor) {
if (active) throw new ProcessingError('processing-stop-pending');
const state = processingStatus(dataRoot);
if (state.invalid) throw new ProcessingError(state.code);
if (state.generation !== actor.generation) throw new ProcessingError('processing-generation-changed');
const summary = await reconcile();
if (!summary.quiescent) throw new ProcessingError('processing-stop-pending');
await onResume(state.generation);
const published = transition(dataRoot, { ...actor, halted: false });
await onTransition(published);
last = { quiescent: true, remaining: 0, stopped: 0 };
return { ...published, ...last };
},
reconcile,
start() {
if (timer || closed) return;
const tick = () => {
if (!active && processingStatus(dataRoot).halted) reconcile().catch(() => log('processing-reconcile-failed'));
};
tick(); timer = setInterval(tick, 250); timer.unref();
},
async close() {
closed = true; clearInterval(timer); timer = null;
await active;
},
};
}
@@ -0,0 +1,61 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { assertProcessing } from './state.mjs';
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, createRuntimeSignaller, runtimeGroupState, runtime } from './runtimes.mjs';
const contexts = new AsyncLocalStorage();
export const currentProcessing = () => contexts.getStore();
// Trusted compensation/temporary-file cleanup only; never an agent work entry point.
export const withoutProcessing = fn => contexts.run(undefined, fn);
export async function withProcessing(dataRoot, fn) {
const parent = currentProcessing();
const generation = assertProcessing(dataRoot, parent?.dataRoot === dataRoot ? parent.generation : null).generation;
const pending = registerRuntime(dataRoot, { kind: 'effect', business: 'instance', run: randomUUID() });
const controller = new AbortController();
const children = [];
const guard = () => assertProcessing(dataRoot, generation);
const poll = setInterval(() => {
try { guard(); } catch (e) { controller.abort(e); }
}, 25);
poll.unref();
const scope = {
dataRoot, generation, signal: controller.signal, guard,
track(child) {
const identity = processIdentity(child.pid);
if (!identity) return;
children.push(new Promise(resolve => child.once('close', resolve)));
let record;
try { record = registerRuntime(dataRoot, { kind: 'effect', business: 'instance', run: randomUUID() }); }
catch (e) {
createRuntimeSignaller({ ...identity, kind: 'effect', group: true })('SIGKILL');
throw e;
}
const running = updateRuntime(dataRoot, record.id, { ...identity, group: true, phase: 'running' });
const signal = createRuntimeSignaller(running, members => updateRuntime(dataRoot, record.id, { members }));
let kill = null;
const cancel = () => {
signal('SIGTERM');
kill = setTimeout(() => signal('SIGKILL'), 5000);
kill.unref();
};
controller.signal.addEventListener('abort', cancel, { once: true });
child.once('close', () => {
controller.signal.removeEventListener('abort', cancel); clearTimeout(kill);
try {
if (runtimeGroupState(runtime(dataRoot, record.id)) === false) unregisterRuntime(dataRoot, record.id);
} catch { /* keep unverifiable receipts for reconciliation */ }
});
try { guard(); } catch (e) { controller.abort(e); }
if (controller.signal.aborted && kill === null) cancel();
},
};
try {
guard();
const result = await contexts.run(scope, () => fn(scope));
guard();
return result;
} finally {
clearInterval(poll);
await Promise.all(children);
unregisterRuntime(dataRoot, pending.id);
}
}
@@ -0,0 +1,7 @@
// Image-baked final admission, using the same strict state reader as the host.
import { assertProcessing } from './state.mjs';
try { assertProcessing(process.argv[2], process.env.MOSAIC_PROCESSING_GENERATION ?? null); }
catch (e) {
process.stderr.write(`run-agent: ${e.code?.startsWith('processing-') ? e.code : 'processing-state-invalid'}\n`);
process.exit(3);
}
@@ -0,0 +1,405 @@
// Only host-side starters publish these identities. A receipt is never a request from a worker.
import { randomUUID, createHash } from 'node:crypto';
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, linkSync,
readdirSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { controlDirectory, readControl, assertProcessing, ProcessingError } from './state.mjs';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:@/-]{0,159}$/;
const fail = () => { throw new ProcessingError('processing-runtime-invalid'); };
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
export const rootTag = root => createHash('sha256').update(dirname(controlDirectory(root))).digest('hex');
export function processIdentity(pid) {
try {
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
const fields = raw.slice(raw.lastIndexOf(')') + 2).split(' ');
if (fields[0] === 'Z') return null;
return { pid, startTime: fields[19], boot: readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() };
} catch { return null; }
}
function alive(identity) {
if (!identity || !Number.isSafeInteger(identity.pid) || identity.pid < 2 ||
typeof identity.startTime !== 'string' || !UUID.test(identity.boot)) return null;
const current = processIdentity(identity.pid);
if (current) return current.boot === identity.boot && current.startTime === identity.startTime;
try {
const stat = readFileSync(`/proc/${identity.pid}/stat`, 'utf8');
if (stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0] === 'Z') return false;
process.kill(identity.pid, 0); return null;
}
catch (e) {
if (e.code === 'ESRCH') return false;
if (e.code === 'ENOENT') {
try { process.kill(identity.pid, 0); } catch (gone) { if (gone.code === 'ESRCH') return false; }
}
return null;
}
}
function directory(root, ...segments) {
// Checking the state also validates the service-owned control parent. A halted state is fine here.
readControl(root);
let dir = controlDirectory(root);
for (const name of segments) {
if (!['runtimes', 'stops', 'run-stops'].includes(name) && !UUID.test(name)) fail();
const parent = dir;
dir = join(dir, name);
let made = false;
try { mkdirSync(dir, { mode: 0o700 }); made = true; }
catch (e) { if (e.code !== 'EEXIST') fail(); }
const st = lstatSync(dir);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o700) fail();
if (made) {
const fd = openSync(parent, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { fsyncSync(fd); } finally { closeSync(fd); }
}
}
return dir;
}
function read(file) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o600 || st.size > 16384) fail();
const buffer = Buffer.alloc(16385), n = readSync(fd, buffer, 0, buffer.length, 0);
if (n > 16384) fail();
return JSON.parse(new TextDecoder('utf8', { fatal: true }).decode(buffer.subarray(0, n)));
} catch { fail(); }
finally { if (fd !== undefined) closeSync(fd); }
}
function write(file, doc, once = false) {
let fd, dirfd;
const temp = join(dirname(file), `.receipt-${randomUUID()}`);
try {
fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeFileSync(fd, JSON.stringify(doc) + '\n'); fsyncSync(fd); closeSync(fd); fd = undefined;
if (once) linkSync(temp, file);
else renameSync(temp, file);
dirfd = openSync(dirname(file), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
fsyncSync(dirfd);
return doc;
} catch (e) {
if (once && e.code === 'EEXIST') return read(file);
fail();
} finally {
if (fd !== undefined) closeSync(fd);
if (dirfd !== undefined) closeSync(dirfd);
try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') fail(); }
}
}
const fields = ['version', 'id', 'root', 'kind', 'business', 'run', 'generation', 'phase', 'supervisor',
'pid', 'startTime', 'boot', 'group', 'runnerPid', 'runnerStartTime', 'containerName', 'containerId', 'haltGeneration', 'members'];
function validate(r) {
if (!r || typeof r !== 'object' || Array.isArray(r) || Object.keys(r).some(k => !fields.includes(k)) ||
r.version !== 1 || !UUID.test(r.id) || !/^[0-9a-f]{64}$/.test(r.root) ||
!['session', 'task', 'container', 'discord', 'effect'].includes(r.kind) ||
!UUID.test(r.generation) || !['starting', 'running', 'ended'].includes(r.phase) ||
typeof r.business !== 'string' || !ID.test(r.business) || typeof r.run !== 'string' || !ID.test(r.run) ||
!r.supervisor || !Number.isSafeInteger(r.supervisor.pid) || !UUID.test(r.supervisor.boot)) fail();
if (r.pid !== undefined && (!Number.isSafeInteger(r.pid) || r.pid < 2 ||
typeof r.startTime !== 'string' || !/^\d+$/.test(r.startTime) || !UUID.test(r.boot))) fail();
if (r.group !== undefined && typeof r.group !== 'boolean') fail();
if (r.runnerPid !== undefined && (!Number.isSafeInteger(r.runnerPid) || r.runnerPid < 2 ||
typeof r.runnerStartTime !== 'string' || !/^\d+$/.test(r.runnerStartTime))) fail();
if (r.haltGeneration !== undefined && !UUID.test(r.haltGeneration)) fail();
if (r.members !== undefined && (!r.group || !Array.isArray(r.members) || r.members.length > 64 ||
r.members.some(m => !m || Object.keys(m).sort().join(',') !== 'boot,pid,startTime' ||
!Number.isSafeInteger(m.pid) || m.pid < 2 || typeof m.startTime !== 'string' ||
!/^\d+$/.test(m.startTime) || m.boot !== r.boot))) fail();
if (r.containerName !== undefined && !/^mosaic-[a-f0-9]{12}-[a-f0-9-]{36}$/.test(r.containerName)) fail();
if (r.containerId !== undefined && !/^[a-f0-9]{64}$/.test(r.containerId)) fail();
return r;
}
export function registerRuntime(root, record) {
const admission = assertProcessing(root);
const r = validate({ ...record, version: 1, id: randomUUID(), root: rootTag(root),
generation: admission.generation, phase: 'starting', supervisor: processIdentity(process.pid) });
const file = join(directory(root, 'runtimes'), r.id + '.json');
write(file, r, true);
try { assertProcessing(root, admission.generation); }
catch (e) { unlinkSync(file); throw e; }
return r;
}
export function runtime(root, id) {
if (!UUID.test(id)) fail();
const r = validate(read(join(directory(root, 'runtimes'), id + '.json')));
if (r.id !== id || r.root !== rootTag(root)) fail();
return r;
}
export function updateRuntime(root, id, update) {
if (Object.keys(update).some(k => !['pid', 'startTime', 'boot', 'group', 'runnerPid', 'runnerStartTime',
'containerId', 'phase', 'haltGeneration', 'members'].includes(k))) fail();
const r = validate({ ...runtime(root, id), ...update });
return write(join(directory(root, 'runtimes'), id + '.json'), r);
}
export function unregisterRuntime(root, id) {
runtime(root, id);
unlinkSync(join(directory(root, 'runtimes'), id + '.json'));
}
export function listRuntimes(root) {
return readdirSync(directory(root, 'runtimes')).filter(n => !n.startsWith('.receipt-')).map(name => {
if (!name.endsWith('.json') || !UUID.test(name.slice(0, -5))) fail();
return runtime(root, name.slice(0, -5));
});
}
export function readStops(root, generation) {
if (!UUID.test(generation)) fail();
return readdirSync(directory(root, 'stops', generation)).filter(name => !name.startsWith('.receipt-')).map(name => read(join(directory(root, 'stops', generation), name)))
.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
}
function stopRecord(root, generation, r, outcome, signals, problem = null, receipt = null) {
const doc = { version: 1, id: randomUUID(), generation, runtime: r.id, kind: r.kind,
business: r.business, run: r.run, ...(receipt ? { stop: receipt.id, reason: receipt.reason, owner: receipt.owner } : {}), at: new Date().toISOString(), outcome, signals, ...(problem ? { problem } : {}),
identity: r.pid ? { pid: r.pid, startTime: r.startTime, boot: r.boot } : null };
return write(join(directory(root, 'stops', generation), `${r.id}.${outcome}.json`), doc, true);
}
function groupOf(pid) {
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
return Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[2]);
} catch { return null; }
}
function groupMembers(pgid) {
return readdirSync('/proc').filter(n => /^\d+$/.test(n)).map(Number)
.filter(pid => groupOf(pid) === pgid).map(processIdentity).filter(Boolean);
}
function signal(r, sig, members) {
if (sig === 'SIGTERM' && r.kind === 'session' && r.runnerPid !== undefined) {
const runner = { pid: r.runnerPid, startTime: r.runnerStartTime, boot: r.boot };
if (alive(r) !== true || alive(runner) !== true || groupOf(runner.pid) !== r.pid) return false;
try { process.kill(runner.pid, sig); return true; } catch { return false; }
}
// A surviving, start-time-checked member anchors the original group even after its leader exits.
// Never signal a recycled group merely because its numeric id matches.
const verified = r.group
? members.some(m => alive(m) === true && groupOf(m.pid) === r.pid)
: alive(r) === true;
if (!verified) return false;
try { process.kill(r.group ? -r.pid : r.pid, sig); return true; } catch { return false; }
}
export function createRuntimeSignaller(record, onMembers = () => {}) {
let members = record.group && alive(record) === true && groupOf(record.pid) === record.pid
? groupMembers(record.pid) : record.members ?? [];
return sig => {
if (record.group && members.some(m => alive(m) === true && groupOf(m.pid) === record.pid)) {
const found = groupMembers(record.pid);
members = [...members, ...found.filter(m => !members.some(old => old.pid === m.pid && old.startTime === m.startTime))];
}
try { if (record.group) onMembers(members.slice(0, 64)); } catch { return false; }
return signal(record, sig, members);
};
}
export function runtimeGroupState(record) {
if (!record.group) return alive(record);
if (alive(record) === true && groupOf(record.pid) === record.pid) return true;
if ((record.members ?? []).some(m => alive(m) === true && groupOf(m.pid) === record.pid)) return true;
// An unknown survivor may be ours or a recycled group. Neither permits a signal or quiescence.
return groupMembers(record.pid).length ? null : false;
}
// One first-owner stop operation per canonical root/business/run. Never async-wrap the
// exported function: callers joining an in-flight stop must receive the identical Promise.
const stopping = new Map();
const STOP_GRACE_MS = 5000, STOP_OBSERVE_MS = 2000;
function stopKey(root, business, run) {
const hex = createHash('sha256').update(JSON.stringify([rootTag(root), business, run])).digest('hex');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20,32)}`;
}
function stopPaths(root, key) {
const dir = directory(root, 'run-stops');
return { claim: join(dir, key + '.claim.json'), result: join(dir, key + '.result.json') };
}
function optionalRead(file) {
try { lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
return read(file);
}
function stopResult(doc, expected) {
const fields = ['version','id','root','business','run','reason','owner','at','runtime','kind','outcome','signals','identity'];
if (!doc || typeof doc !== 'object' || Array.isArray(doc) ||
Object.keys(doc).length !== fields.length || Object.keys(doc).some(k => !fields.includes(k)) ||
doc.version !== 1 || doc.business !== expected.business || doc.run !== expected.run ||
doc.root !== expected.root || !UUID.test(doc.id) || !['fire','kill-switch'].includes(doc.reason) ||
typeof doc.at !== 'string' || !Number.isFinite(Date.parse(doc.at)) || new Date(doc.at).toISOString() !== doc.at ||
(doc.runtime !== null && !UUID.test(doc.runtime)) ||
(doc.kind !== null && !['session','task','container','discord','effect'].includes(doc.kind)) ||
!['stopped','killed','alive','identity-changed','unknown'].includes(doc.outcome) ||
!Array.isArray(doc.signals) || doc.signals.length > 2 ||
doc.signals.some(s => !['SIGTERM','SIGKILL'].includes(s)) || new Set(doc.signals).size !== doc.signals.length ||
(doc.signals.length === 2 && doc.signals.join(',') !== 'SIGTERM,SIGKILL') ||
!doc.owner || typeof doc.owner !== 'object' || Array.isArray(doc.owner)) fail();
if (doc.reason === 'fire' ? Object.keys(doc.owner).join(',') !== 'decision' || typeof doc.owner.decision !== 'string' || !ID.test(doc.owner.decision) :
Object.keys(doc.owner).join(',') !== 'generation' || !UUID.test(doc.owner.generation)) fail();
if (doc.identity !== null && (!doc.identity || Object.keys(doc.identity).sort().join(',') !== 'boot,pid,startTime' ||
!Number.isSafeInteger(doc.identity.pid) || doc.identity.pid < 2 ||
typeof doc.identity.startTime !== 'string' || !/^\d+$/.test(doc.identity.startTime) || !UUID.test(doc.identity.boot))) fail();
return { run: doc.run, outcome: doc.outcome, pid: doc.identity?.pid ?? null,
signals: doc.signals, receipt: doc };
}
export function stopRun(args, options = {}) {
try {
if (!args || Object.keys(args).sort().join(',') !== 'business,owner,reason,root,run' ||
typeof args.root !== 'string' || typeof args.business !== 'string' || !ID.test(args.business) ||
typeof args.run !== 'string' || !ID.test(args.run) ||
!args.owner || typeof args.owner !== 'object' || Array.isArray(args.owner)) fail();
if (args.reason === 'fire') {
if (Object.keys(args.owner).join(',') !== 'decision' || typeof args.owner.decision !== 'string' || !ID.test(args.owner.decision)) fail();
} else if (args.reason === 'kill-switch') {
if (Object.keys(args.owner).join(',') !== 'generation' || !UUID.test(args.owner.generation)) fail();
const state = readControl(args.root);
if (!state.halted || state.generation !== args.owner.generation) throw new ProcessingError('processing-generation-changed');
} else fail();
const root = rootTag(args.root), key = stopKey(args.root, args.business, args.run);
const local = root + ':' + key;
if (stopping.has(local)) return stopping.get(local);
const pending = Promise.resolve().then(() => ownStop({ ...args, rootHash: root }, key, options)).catch(e => {
if (!e.code?.startsWith('processing-')) throw e;
return { run: args.run, outcome: 'unknown', pid: null, signals: [], receipt: null };
});
stopping.set(local, pending);
pending.then(() => stopping.delete(local), () => stopping.delete(local));
return pending;
} catch (e) { return Promise.reject(e); }
}
async function ownStop(args, key, options) {
const { root, rootHash, business, run, reason, owner } = args;
const files = stopPaths(root, key), expected = { root: rootHash, business, run };
const old = optionalRead(files.result);
if (old) return stopResult(old, expected);
const now = options.now ?? Date.now, sleep = options.sleep ?? wait;
const grace = options.graceMs ?? STOP_GRACE_MS, observe = options.killWaitMs ?? STOP_OBSERVE_MS;
const started = now();
const claim = { version: 1, id: randomUUID(), ...expected, reason, owner,
supervisor: processIdentity(process.pid), at: new Date().toISOString(), deadline: Date.now() + grace + observe };
const winner = write(files.claim, claim, true);
if (winner.root !== rootHash || winner.business !== business || winner.run !== run || !UUID.test(winner.id) ||
!Number.isFinite(winner.deadline) || winner.deadline > Date.now() + STOP_GRACE_MS + STOP_OBSERVE_MS + 1000) fail();
if (winner.id !== claim.id) {
// Another host owns delivery. No lease stealing or fresh deadlines on owner loss.
while (Date.now() <= winner.deadline) {
const result = optionalRead(files.result);
if (result) return stopResult(result, expected);
if (alive(winner.supervisor) !== true) break;
await sleep(20);
}
return { run, outcome: 'unknown', pid: null, signals: [], receipt: null };
}
let record = null, signals = [], initialIdentity = null;
const finish = outcome => {
const doc = { version: 1, id: claim.id, ...expected, reason, owner, at: new Date().toISOString(),
runtime: record?.id ?? null, kind: record?.kind ?? null, outcome, signals: [...signals],
identity: initialIdentity };
return stopResult(write(files.result, doc, true), expected);
};
const matches = listRuntimes(root).filter(r => r.business === business && r.run === run);
if (matches.length !== 1) return finish('unknown');
record = matches[0];
if (reason === 'kill-switch') record = updateRuntime(root, record.id, { haltGeneration: owner.generation });
if (record.pid) initialIdentity = { pid: record.pid, startTime: record.startTime, boot: record.boot };
if (record.containerName) {
let containerStop = options.containerStop;
if (!containerStop) {
const { createContainerStopper } = await import('./containers.mjs');
containerStop = createContainerStopper({ dataRoot: root });
}
const result = await containerStop(record, { graceMs: grace, killWaitMs: observe });
signals = result.signals;
return finish(result.outcome === 'killed' ? 'killed' :
['terminated','already-exited'].includes(result.outcome) ? 'stopped' : 'unknown');
}
const identity = options.identity ?? processIdentity;
let members = record.group && alive(record) === true && groupOf(record.pid) === record.pid
? groupMembers(record.pid) : record.members ?? [];
const status = r => {
if (!r.pid) return r.phase === 'ended' ? false : null;
const current = identity(r.pid);
if (current && (current.pid !== r.pid || current.startTime !== r.startTime || current.boot !== r.boot))
return 'identity-changed';
if (initialIdentity && (initialIdentity.pid !== r.pid || initialIdentity.startTime !== r.startTime || initialIdentity.boot !== r.boot))
return 'identity-changed';
if (options.state) {
const observed = options.state(r);
return observed === true && !current && !r.group ? null : observed;
}
return runtimeGroupState({ ...r, members });
};
const deliver = (r, sig) => {
// Re-check immediately before the actual signal, including fixture delivery.
if (status(r) !== true) return false;
if (r.group) updateRuntime(root, r.id, { members: members.slice(0,64) });
return options.signal ? options.signal(r, sig) : signal(r, sig, members);
};
const termEnd = started + grace, deadline = termEnd + observe;
let termTried = false, killTried = false;
while (now() <= deadline) {
try { record = runtime(root, record.id); }
catch {
const present = readdirSync(directory(root, 'runtimes')).includes(record.id + '.json');
return finish(present ? 'unknown' : signals.includes('SIGKILL') ? 'killed' : 'stopped');
}
if (!initialIdentity && record.pid) initialIdentity = { pid: record.pid, startTime: record.startTime, boot: record.boot };
if (record.group && members.some(m => alive(m) === true && groupOf(m.pid) === record.pid)) {
const found = groupMembers(record.pid);
members = [...members, ...found.filter(m => !members.some(old => old.pid === m.pid && old.startTime === m.startTime))];
}
let live = status(record);
if (live === 'identity-changed') return finish('identity-changed');
if ((record.phase === 'ended' && !record.group) || live === false)
return finish(signals.includes('SIGKILL') ? 'killed' : 'stopped');
if (record.pid && live === true && !termTried) {
termTried = true;
if (deliver(record, 'SIGTERM')) signals.push('SIGTERM');
}
if (record.pid && live === true && now() >= termEnd && !killTried) {
killTried = true;
if (deliver(record, 'SIGKILL')) signals.push('SIGKILL');
}
if (now() >= deadline) {
live = status(record);
return finish(live === 'identity-changed' ? 'identity-changed' : live === true ? 'alive' :
live === false ? signals.includes('SIGKILL') ? 'killed' : 'stopped' : 'unknown');
}
await sleep(Math.min(20, Math.max(1, deadline - now())));
}
return finish('unknown');
}
// A later exit is new evidence, never a replacement for a fire caller's first result.
function laterExit(root, first, receipt, generation) {
try {
const current = runtime(root, first.id);
if (current.pid) {
const identity = processIdentity(current.pid);
if (identity && (identity.startTime !== current.startTime || identity.boot !== current.boot)) return false;
if (runtimeGroupState(current) !== false) return false;
} else if (current.phase !== 'ended') return false;
const key = stopKey(root, first.business, first.run);
write(join(directory(root, 'run-stops'), key + '.' + generation + '.observation.json'), {
version: 1, id: randomUUID(), stop: receipt.id, generation, runtime: first.id,
root: rootTag(root), business: first.business, run: first.run, outcome: 'exited', at: new Date().toISOString(),
}, true);
return true;
} catch { return false; }
}
export async function sweepRuntimes(root, generation, { graceMs = 5000, killWaitMs = 2000, containerStop = null,
beforeStop = () => {}, stopOptions = {} } = {}) {
const state = readControl(root);
if (!state.halted || state.generation !== generation) throw new ProcessingError('processing-generation-changed');
const prior = readStops(root, generation);
const results = await Promise.all(listRuntimes(root).map(async first => {
const complete = prior.find(s => s.runtime === first.id && s.outcome !== 'unknown');
if (complete) return complete;
let problem = null;
try { await beforeStop(first, generation); } catch { problem = 'brake-unconfirmed'; }
const result = await stopRun({ root, business: first.business, run: first.run,
reason: 'kill-switch', owner: { generation } }, { graceMs, killWaitMs, containerStop, ...stopOptions });
let outcome = result.outcome === 'stopped' ? 'terminated' : result.outcome === 'killed' ? 'killed' : 'unknown';
if (outcome === 'unknown' && result.receipt && !problem && laterExit(root, first, result.receipt, generation))
outcome = 'terminated';
if (problem) outcome = 'unknown';
return stopRecord(root, generation, first, outcome, result.signals, problem, result.receipt);
}));
const remaining = results.filter(r => r.outcome === 'unknown').length;
return { quiescent: remaining === 0, remaining, stopped: results.length - remaining, results };
}
+129
View File
@@ -0,0 +1,129 @@
// Host-owned, per-data-root admission state. No model, git or credential imports.
import { randomUUID } from 'node:crypto';
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync,
renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
export class ProcessingError extends Error {
constructor(code) { super(code); this.name = 'ProcessingError'; this.code = code; }
}
const fail = code => { throw new ProcessingError(code); };
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:@/-]{0,159}$/;
const identity = value => typeof value === 'string' && ID.test(value);
const FIELDS = ['version', 'generation', 'operation', 'halted', 'at', 'business', 'human', 'recovered'];
const MAX_BYTES = 8192;
export function controlDirectory(dataRoot) {
try {
const root = realpathSync(dataRoot), st = lstatSync(root);
if (!st.isDirectory() || st.uid !== process.getuid()) fail('processing-control-unsafe');
return join(root, 'control');
} catch { fail('processing-control-unsafe'); }
}
function parent(dataRoot) {
const dir = controlDirectory(dataRoot);
let st;
try { st = lstatSync(dir); }
catch (e) {
if (e.code === 'ENOENT') fail('processing-uninitialized');
fail('processing-control-unsafe');
}
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o700)
fail('processing-control-unsafe');
return dir;
}
function valid(doc) {
if (!doc || typeof doc !== 'object' || Array.isArray(doc) ||
Object.keys(doc).length !== FIELDS.length || Object.keys(doc).some(k => !FIELDS.includes(k)) ||
doc.version !== 1 || !UUID.test(doc.generation) || typeof doc.halted !== 'boolean' ||
typeof doc.recovered !== 'boolean' || typeof doc.at !== 'string' ||
!Number.isFinite(Date.parse(doc.at)) || new Date(doc.at).toISOString() !== doc.at)
fail('processing-state-invalid');
if (doc.operation === null) {
if (doc.halted || doc.business !== null || doc.human !== null || doc.recovered) fail('processing-state-invalid');
} else if (!UUID.test(doc.operation) || !identity(doc.business) || !identity(doc.human)) fail('processing-state-invalid');
return Object.freeze(doc);
}
export function readControl(dataRoot) {
const file = join(parent(dataRoot), 'processing.json');
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7777) !== 0o600 || st.size > MAX_BYTES)
fail('processing-state-invalid');
const buf = Buffer.alloc(MAX_BYTES + 1);
let size = 0, n;
while ((n = readSync(fd, buf, size, buf.length - size, null)) > 0) {
size += n;
if (size > MAX_BYTES) fail('processing-state-invalid');
}
return valid(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buf.subarray(0, size))));
} catch { fail('processing-state-invalid'); }
finally { if (fd !== undefined) closeSync(fd); }
}
function publish(dataRoot, doc) {
const dir = parent(dataRoot), file = join(dir, 'processing.json'), temp = join(dir, `.state-${randomUUID()}`);
let fd, dirfd;
try {
fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeFileSync(fd, JSON.stringify(valid(doc)) + '\n');
fsyncSync(fd); closeSync(fd); fd = undefined;
renameSync(temp, file);
dirfd = openSync(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
fsyncSync(dirfd);
} catch { fail('processing-state-invalid'); }
finally {
if (fd !== undefined) closeSync(fd);
if (dirfd !== undefined) closeSync(dirfd);
try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') fail('processing-state-invalid'); }
}
}
// First-use host bootstrap only. A present directory with no file is not a new installation.
export function initializeControl(dataRoot) {
const dir = controlDirectory(dataRoot);
try { mkdirSync(dir, { mode: 0o700 }); }
catch (e) {
if (e.code === 'EEXIST') return readControl(dataRoot);
fail('processing-control-unsafe');
}
const rootfd = openSync(realpathSync(dataRoot), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { fsyncSync(rootfd); } finally { closeSync(rootfd); }
publish(dataRoot, { version: 1, generation: randomUUID(), operation: null, halted: false,
at: new Date().toISOString(), business: null, human: null, recovered: false });
return readControl(dataRoot);
}
export function assertProcessing(dataRoot, generation = null) {
const state = readControl(dataRoot);
if (state.halted) fail('processing-halted');
if (generation !== null && state.generation !== generation) fail('processing-generation-changed');
return state;
}
// Called only by the coordinator after the broker has proved a human.
export function transition(dataRoot, { halted, business, human, generation = null }) {
if (typeof halted !== 'boolean' || !identity(business) || !identity(human)) fail('invalid-request');
parent(dataRoot);
let current, recovered = false;
try { current = readControl(dataRoot); }
catch (e) {
if (!halted || e.code !== 'processing-state-invalid') throw e;
recovered = true;
}
if (!halted && generation !== current.generation) fail('processing-generation-changed');
if (current?.halted === halted) return { ...current, changed: false };
const operation = randomUUID();
const state = { version: 1, generation: operation, operation, halted,
at: new Date().toISOString(), business, human, recovered };
publish(dataRoot, state);
return { ...state, changed: true };
}
export function processingStatus(dataRoot) {
try { return readControl(dataRoot); }
catch (e) {
if (!(e instanceof ProcessingError)) throw e;
return { halted: e.code === 'processing-uninitialized' ? null : true, invalid: true, code: e.code };
}
}
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initializeControl, transition } from '../src/state.mjs';
const api = await import('../src/containers.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'processing-container-')); initializeControl(root); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
const actor = { business: 'demo', human: 'jason', halted: true };
test('container admission publishes its generated name and root/runtime labels before dispatch', t => {
assert.equal(typeof api.prepareContainer, 'function');
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'run-1' });
const args = api.containerArgs(r, { args: ['request'] });
assert.match(r.containerName, /^mosaic-[a-f0-9]{12}-[a-f0-9-]{36}$/);
assert.ok(args.includes(r.containerName)); assert.ok(args.includes(`mosaic.root=${r.root}`));
assert.ok(args.includes(`mosaic.runtime=${r.id}`)); assert.ok(args.includes(`MOSAIC_PROCESSING_GENERATION=${r.generation}`));
assert.deepEqual(args.slice(-2), ['mosaic-agent', 'request']);
transition(root, actor); assert.throws(() => api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'new' }), e => e.code === 'processing-halted');
});
function inspected(r, running = true, id = 'a'.repeat(64)) {
return { Id: id, Name: '/' + r.containerName, Config: { Labels: { 'mosaic.root': r.root, 'mosaic.runtime': r.id } }, State: { Running: running } };
}
test('container sweep sends TERM then KILL only after verifying exact id and both labels', async t => {
assert.equal(typeof api.createContainerStopper, 'function');
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
const calls = []; let running = true;
const stop = api.createContainerStopper({ docker: async args => {
calls.push(args);
if (args[0] === 'inspect') return JSON.stringify(inspected(r, running));
if (args.includes('KILL')) running = false;
return '';
} });
const result = await stop(r, { graceMs: 30, killWaitMs: 100 });
assert.equal(result.outcome, 'killed'); assert.deepEqual(result.signals, ['SIGTERM', 'SIGKILL']);
assert.deepEqual(calls.filter(c => c[0] === 'kill'), [['kill', '--signal', 'TERM', 'a'.repeat(64)], ['kill', '--signal', 'KILL', 'a'.repeat(64)]]);
});
test('foreign labels and a replaced container id stay unknown and are never signalled', async t => {
assert.equal(typeof api.createContainerStopper, 'function');
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
const calls = [];
const stop = api.createContainerStopper({ docker: async args => {
calls.push(args); const doc = inspected(r); doc.Config.Labels['mosaic.root'] = 'b'.repeat(64); return JSON.stringify(doc);
} });
assert.equal((await stop(r, { graceMs: 1, killWaitMs: 1 })).outcome, 'unknown');
assert.equal(calls.filter(c => c[0] === 'kill').length, 0);
let reads = 0; calls.length = 0;
const replacement = api.createContainerStopper({ docker: async args => {
calls.push(args); if (args[0] !== 'inspect') return '';
return JSON.stringify(inspected(r, true, (++reads > 1 ? 'b' : 'a').repeat(64)));
} });
assert.equal((await replacement(r, { graceMs: 1, killWaitMs: 1 })).outcome, 'unknown');
assert.equal(calls.filter(c => c[0] === 'kill').length, 0);
});
test('an unverified missing container remains unknown rather than claiming a pending start ended', async t => {
assert.equal(typeof api.createContainerStopper, 'function');
const root = fixture(t), r = api.prepareContainer(root, { kind: 'container', business: 'instance', run: 'one' });
const stop = api.createContainerStopper({ docker: async () => { throw new Error('no container'); } });
assert.equal((await stop(r, { graceMs: 5, killWaitMs: 5 })).outcome, 'unknown');
});
test('a removed --rm container is settled only by its trusted owner completion receipt', async t => {
const { updateRuntime } = await import('../src/runtimes.mjs');
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
let removed = false;
const stop = api.createContainerStopper({ dataRoot: root, docker: async args => {
if (args[0] === 'kill') { removed = true; updateRuntime(root, r.id, { phase: 'ended' }); return ''; }
if (removed) throw Error('removed by --rm');
return JSON.stringify(inspected(r));
} });
const result = await stop(r, { graceMs: 30, killWaitMs: 100 });
assert.equal(result.outcome, 'terminated'); assert.deepEqual(result.signals, ['SIGTERM']);
});
test('after signalling a verified container, successful empty Docker listing proves --rm removed that id', async t => {
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'one' });
let removed = false;
const stop = api.createContainerStopper({ docker: async args => {
if (args[0] === 'kill') { removed = true; return ''; }
if (args[0] === 'ps') return '';
if (removed) throw Error('removed');
return JSON.stringify(inspected(r));
} });
const result = await stop(r, { graceMs: 20, killWaitMs: 100 });
assert.equal(result.outcome, 'terminated'); assert.deepEqual(result.signals, ['SIGTERM']);
});
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readControl, assertProcessing } from '../src/state.mjs';
import { registerRuntime, updateRuntime, processIdentity, unregisterRuntime } from '../src/runtimes.mjs';
const { createProcessing } = await import('../src/coordinator.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
const actor = { business: 'demo', human: 'jason' };
function fixture(t) {
assert.equal(typeof createProcessing, 'function');
const root = mkdtempSync(join(tmpdir(), 'processing-coordinator-'));
const c = createProcessing({ dataRoot: root, graceMs: 80, killWaitMs: 1000 });
t.after(async () => { await c.close(); rmSync(root, { recursive: true, force: true }); });
return { root, c };
}
test('halt publishes before awaiting stops, resume refuses pending, and only clear permits new work', async t => {
const { root, c } = fixture(t);
const p = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)"], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
const exit = once(p, 'exit');
t.after(async () => { if (p.signalCode === null && p.exitCode === null) { try { process.kill(-p.pid, 'SIGKILL'); } catch {} await exit; } });
await once(p.stdout, 'data');
const r = registerRuntime(root, { business: 'demo', run: 'standin', kind: 'session' });
updateRuntime(root, r.id, { ...processIdentity(p.pid), group: true, phase: 'running' });
const halted = c.halt(actor);
assert.equal(readControl(root).halted, true, 'durable gate is set before any stop await');
await assert.rejects(c.resume({ ...actor, generation: readControl(root).generation }), { code: 'processing-stop-pending' });
const result = await halted;
assert.equal(result.quiescent, true);
assert.deepEqual(await exit, [null, 'SIGKILL']);
assert.throws(() => assertProcessing(root), { code: 'processing-halted' });
const resumed = await c.resume({ ...actor, generation: result.generation });
assert.equal(resumed.halted, false);
assert.doesNotThrow(() => assertProcessing(root));
});
test('a new coordinator replays a persisted halt and an unresolved start keeps clear refused', async t => {
const { root, c } = fixture(t);
const pending = registerRuntime(root, { business: 'demo', run: 'starting', kind: 'session' });
const result = await c.halt(actor);
assert.equal(result.quiescent, false);
await c.close();
const next = createProcessing({ dataRoot: root, graceMs: 10, killWaitMs: 10 });
try {
assert.equal(next.read().halted, true);
await assert.rejects(next.resume({ ...actor, generation: result.generation }), { code: 'processing-stop-pending' });
unregisterRuntime(root, pending.id);
const resumed = await next.resume({ ...actor, generation: result.generation });
assert.equal(resumed.halted, false);
} finally { await next.close(); }
});
test('a new halt generation never inherits a previous quiescent status before its sweep', async t => {
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { transition } = await import('../src/state.mjs');
const root = mkdtempSync(join(tmpdir(), 'processing-counts-'));
const processing = createProcessing({ dataRoot: root });
t.after(async () => { await processing.close(); rmSync(root, { recursive: true, force: true }); });
transition(root, { business: 'demo', human: 'jason', halted: true });
assert.equal(processing.read().quiescent, false);
await processing.reconcile(); assert.equal(processing.read().quiescent, true);
});
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initializeControl, transition } from '../src/state.mjs';
const api = await import('../src/effects.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'processing-effect-')); initializeControl(root); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
const actor = { business: 'demo', human: 'jason', halted: true };
test('halt aborts an active local request and prevents a subsequent effect after halt/resume', { timeout: 4000 }, async t => {
assert.equal(typeof api.withProcessing, 'function');
const root = fixture(t); let started;
const ready = new Promise(resolve => { started = resolve; });
const work = api.withProcessing(root, async scope => {
await new Promise(resolve => { scope.signal.addEventListener('abort', resolve, { once: true }); started(); });
scope.guard();
});
await ready; const halted = transition(root, actor);
transition(root, { ...actor, halted: false, generation: halted.generation });
await assert.rejects(work, e => e.code === 'processing-generation-changed');
});
test('processing scopes isolate roots and cancel only the registered child group', async t => {
assert.equal(typeof api.withProcessing, 'function');
const a = fixture(t), b = fixture(t);
let admitted;
const ready = new Promise(resolve => { admitted = resolve; });
const task = api.withProcessing(a, async scope => {
const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>process.exit(0));console.log('ready');setInterval(()=>{},1000)"], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
const exit = once(child, 'exit'); t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
scope.track(child); await once(child.stdout, 'data'); admitted();
const outcome = await exit; assert.deepEqual(outcome, [0, null]); scope.guard();
});
await ready; transition(b, actor);
await api.withProcessing(a, async scope => scope.guard());
transition(a, actor);
await assert.rejects(task, e => e.code === 'processing-halted');
});
test('halted scopes never invoke their body and unscoped human work has no processing context', async t => {
assert.equal(typeof api.withProcessing, 'function');
const root = fixture(t); transition(root, actor); let called = false;
await assert.rejects(api.withProcessing(root, async () => { called = true; }), e => e.code === 'processing-halted');
assert.equal(called, false); assert.equal(api.currentProcessing(), undefined);
});
test('host git is stopped promptly by the active scope rather than waiting for its long deadline', async t => {
const { mkdirSync, writeFileSync } = await import('node:fs');
const { runGit, gitEnv } = await import('../../seat/src/checkout.mjs');
const root = fixture(t), bin = join(root, 'bin'); mkdirSync(bin);
writeFileSync(join(bin, 'git'), `#!${process.execPath}\nprocess.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000);\n`, { mode: 0o755 });
const begin = Date.now();
const task = api.withProcessing(root, async () => runGit(['status'], { ...gitEnv(), PATH: bin + ':' + process.env.PATH }, { timeoutMs: 10000, graceMs: 100 }));
await new Promise(resolve => setTimeout(resolve, 100)); transition(root, actor);
await assert.rejects(task, e => e.code === 'processing-halted');
assert.ok(Date.now() - begin < 2000, 'the halt cancels the actual git process');
});
test('a child leader exiting does not erase an unverified surviving group receipt', async t => {
const { listRuntimes } = await import('../src/runtimes.mjs');
const root = fixture(t);
let child;
await api.withProcessing(root, async scope => {
const script = `const {spawn}=require('node:child_process');spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});console.log('ready');setTimeout(()=>process.exit(0),80);`;
child = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
const closed = once(child, 'close');
scope.track(child); await once(child.stdout, 'data'); await closed;
});
const records = listRuntimes(root);
assert.equal(records.length, 1, 'unknown group remains discoverable to the independent halt sweep');
assert.equal(records[0].pid, child.pid);
assert.equal(records[0].group, true);
});
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initializeControl, transition } from '../src/state.mjs';
import { sandboxProblem } from '../../seat/src/sandbox.mjs';
const skip = sandboxProblem() ?? false;
test('the actual nested read-only control mount refuses unlink, replacement and parent removal', { skip }, t => {
const root = mkdtempSync(join(tmpdir(), 'processing-mount-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
initializeControl(root); transition(root, { business: 'demo', human: 'jason', halted: true });
const file = join(root, 'control', 'processing.json'), bytes = readFileSync(file), inode = statSync(file).ino;
writeFileSync(join(root, 'replacement'), 'off replacement');
const r = spawnSync('bwrap', ['--unshare-user', '--unshare-pid', '--die-with-parent',
'--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', '--symlink', 'usr/lib', '/lib',
'--symlink', 'usr/lib64', '/lib64', '--proc', '/proc', '--dev', '/dev',
'--bind', root, '/fixture', '--ro-bind', join(root, 'control'), '/fixture/control',
'/bin/sh', '-c', 'rm /fixture/control/processing.json && exit 10; echo unlink-refused; mv /fixture/replacement /fixture/control/processing.json && exit 11; echo replace-refused; rm -rf /fixture/control && exit 12; echo parent-refused'],
{ encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /unlink-refused/); assert.match(r.stdout, /replace-refused/); assert.match(r.stdout, /parent-refused/);
assert.deepEqual(readFileSync(file), bytes); assert.equal(statSync(file).ino, inode);
});
test('the image entrypoint refuses halted and stale generations using the shared reader', t => {
const root = mkdtempSync(join(tmpdir(), 'processing-entry-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const initial = initializeControl(root), entry = new URL('../src/entrypoint.mjs', import.meta.url).pathname;
const run = generation => spawnSync(process.execPath, [entry, root], { env: { ...process.env, MOSAIC_PROCESSING_GENERATION: generation }, encoding: 'utf8' });
assert.equal(run(initial.generation).status, 0);
const halt = transition(root, { business: 'demo', human: 'jason', halted: true });
assert.equal(run(initial.generation).status, 3);
transition(root, { business: 'demo', human: 'jason', halted: false, generation: halt.generation });
const stale = run(initial.generation); assert.equal(stale.status, 3); assert.match(stale.stderr, /processing-generation-changed/);
});
@@ -0,0 +1,183 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { initializeControl, transition } from '../src/state.mjs';
const api = await import('../src/runtimes.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
const actor = { business: 'demo', human: 'jason', halted: true };
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'processing-runtime-'));
initializeControl(root); t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
async function child(t, stubborn = false) {
const p = spawn(process.execPath, ['-e', `process.on('SIGTERM',()=>{${stubborn ? '' : 'process.exit(0)'}});console.log('ready');setInterval(()=>{},1000)`], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
const ended = once(p, 'exit');
t.after(async () => { if (p.exitCode === null && p.signalCode === null) { try { process.kill(-p.pid, 'SIGKILL'); } catch {} await ended; } });
await once(p.stdout, 'data');
return { p, ended };
}
function receipt(root, p) {
const r = api.registerRuntime(root, { kind: 'session', business: 'demo', run: 'run' });
return api.updateRuntime(root, r.id, { ...api.processIdentity(p.pid), phase: 'running', group: true });
}
// Without TERM, a cooperative child would survive until unnecessary escalation.
test('halt terminates a tracked stand-in and leaves honest write-once stop evidence', async t => {
assert.equal(typeof api.registerRuntime, 'function');
const root = fixture(t), { p, ended } = await child(t);
const r = receipt(root, p);
const evidence = join(root, 'evidence.json'); writeFileSync(evidence, 'immutable existing result');
const state = transition(root, actor);
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 150, killWaitMs: 1000 });
assert.equal(result.quiescent, true);
assert.equal(result.remaining, 0);
assert.deepEqual(await ended, [0, null]);
const stops = api.readStops(root, state.generation);
assert.equal(stops.length, 1);
assert.equal(stops[0].runtime, r.id);
assert.equal(stops[0].outcome, 'terminated');
assert.deepEqual(stops[0].signals, ['SIGTERM']);
assert.equal(readFileSync(evidence, 'utf8'), 'immutable existing result');
const bytes = JSON.stringify(stops);
await api.sweepRuntimes(root, state.generation, { graceMs: 10, killWaitMs: 1000 });
assert.equal(JSON.stringify(api.readStops(root, state.generation)), bytes);
});
// Dropping the KILL escalation leaves this actual child alive and fails quiescence.
test('a TERM-resistant workload is killed after the bounded wait', async t => {
assert.equal(typeof api.registerRuntime, 'function');
const root = fixture(t), { p, ended } = await child(t, true);
receipt(root, p);
const state = transition(root, actor), begin = Date.now();
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 80, killWaitMs: 1000 });
assert.equal(result.quiescent, true);
assert.deepEqual(await ended, [null, 'SIGKILL']);
assert.ok(Date.now() - begin >= 80);
assert.deepEqual(api.readStops(root, state.generation)[0].signals, ['SIGTERM', 'SIGKILL']);
});
// An unchecked stale start identity would terminate a different process.
test('wrong start ticks or boot identity cannot signal a live replacement', async t => {
assert.equal(typeof api.registerRuntime, 'function');
const root = fixture(t), { p } = await child(t);
const r = receipt(root, p);
api.updateRuntime(root, r.id, { startTime: '0' });
const state = transition(root, actor);
await api.sweepRuntimes(root, state.generation, { graceMs: 30, killWaitMs: 30 });
await delay(30);
assert.equal(p.exitCode, null); assert.equal(p.signalCode, null);
assert.equal(api.readStops(root, state.generation)[0].outcome, 'unknown');
});
test('a pending start cannot be reported quiescent and a second root is never signalled', async t => {
assert.equal(typeof api.registerRuntime, 'function');
const a = fixture(t), b = fixture(t), { p } = await child(t);
api.registerRuntime(a, { kind: 'session', business: 'demo', run: 'pending' });
receipt(b, p);
const state = transition(a, actor);
const result = await api.sweepRuntimes(a, state.generation, { graceMs: 30, killWaitMs: 30 });
assert.equal(result.quiescent, false);
assert.equal(result.remaining, 1);
assert.equal(p.exitCode, null); assert.equal(p.signalCode, null);
});
// A group leader can exit on TERM while its child ignores it. Quiescence is the whole group.
test('escalation still kills the tracked group after its leader exits', async t => {
const root = fixture(t);
const script = `const {spawn}=require('node:child_process');
const c=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});
c.stdout.once('data',()=>console.log(c.pid));process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000);`;
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
const [line] = await once(p.stdout, 'data');
const descendant = Number(String(line).trim());
const r = receipt(root, p), state = transition(root, actor);
const outcome = await api.sweepRuntimes(root, state.generation, { graceMs: 80, killWaitMs: 1000 });
assert.equal(outcome.quiescent, true);
assert.equal(api.processIdentity(descendant), null, 'stubborn descendant must not survive the group leader');
assert.equal(api.readStops(root, state.generation).find(s => s.runtime === r.id).outcome, 'killed');
});
// Session TERM goes to the runner, leaving the outer namespace for forced escalation.
test('session TERM targets its verified runner before forcing the outer namespace', async t => {
const root = fixture(t);
const script = `const {spawn}=require('node:child_process');
process.on('SIGTERM',()=>require('node:fs').writeFileSync(${JSON.stringify(join(root, 'outer-term'))},'unexpected'));
const c=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>process.exit(0));console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});
c.stdout.once('data',()=>console.log(c.pid));c.once('exit',()=>process.exit(0));setInterval(()=>{},1000);`;
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
const [line] = await once(p.stdout, 'data'), runner = api.processIdentity(Number(String(line).trim()));
const r = receipt(root, p);
api.updateRuntime(root, r.id, { runnerPid: runner.pid, runnerStartTime: runner.startTime });
const state = transition(root, actor);
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 300, killWaitMs: 1000 });
assert.equal(result.quiescent, true);
assert.deepEqual(api.readStops(root, state.generation)[0].signals, ['SIGTERM']);
assert.equal(api.runtime(root, r.id).haltGeneration, state.generation);
assert.equal(existsSync(join(root, 'outer-term')), false);
});
// Publishing a receipt is not durable if its newly-created containing directory can vanish.
test('new runtime and stop directories sync their containing directories', t => {
const root = fixture(t);
const script = `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';
const real=fs.fsyncSync,seen=[];fs.fsyncSync=fd=>{seen.push(fs.readlinkSync('/proc/self/fd/'+fd));return real(fd)};
syncBuiltinESMExports();const api=await import(process.argv[1]);
const {transition}=await import(process.argv[2]);
const r=api.registerRuntime(process.argv[3],{kind:'task',business:'demo',run:'gone'});
api.updateRuntime(process.argv[3],r.id,{phase:'ended'});
const s=transition(process.argv[3],{business:'demo',human:'jason',halted:true});
await api.sweepRuntimes(process.argv[3],s.generation);
console.log(JSON.stringify({seen,generation:s.generation}));`;
const child = spawnSync(process.execPath, ['--input-type=module', '-e', script,
new URL('../src/runtimes.mjs', import.meta.url).href, new URL('../src/state.mjs', import.meta.url).href, root], { encoding: 'utf8' });
assert.equal(child.status, 0, child.stderr);
const { seen, generation } = JSON.parse(child.stdout);
assert.ok(seen.filter(p => p === join(root, 'control')).length >= 3, 'runtime mkdir, halt rename and stops mkdir sync control');
assert.ok(seen.includes(join(root, 'control', 'stops')), 'generation directory creation syncs stops');
assert.ok(seen.includes(join(root, 'control', 'stops', generation)), 'stop receipt syncs generation directory');
});
test('a vanished group leader with unverified surviving members stays unknown, never quiescent', async t => {
const root = fixture(t);
const script = `const {spawn}=require('node:child_process');const c=spawn(process.execPath,['-e',"console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});c.stdout.once('data',()=>console.log(c.pid));process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000)`;
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
const [line] = await once(p.stdout, 'data'); const descendant = Number(String(line).trim());
receipt(root, p); const exited = once(p, 'exit'); p.kill('SIGTERM'); await exited;
const state = transition(root, actor);
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 20, killWaitMs: 30 });
assert.equal(result.quiescent, false); assert.equal(result.remaining, 1);
assert.notEqual(api.processIdentity(descendant), null, 'never signal a group with no verified surviving anchor');
});
test('persisted member identity anchors a group after the leader exited before the sweep', async t => {
const root = fixture(t);
const script = `const {spawn}=require('node:child_process');const c=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']});c.stdout.once('data',()=>console.log(c.pid));process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000)`;
const p = spawn(process.execPath, ['-e', script], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(-p.pid, 'SIGKILL'); } catch {} });
const [line] = await once(p.stdout, 'data'), descendant = Number(String(line).trim()), r = receipt(root, p);
api.updateRuntime(root, r.id, { members: [api.processIdentity(descendant)] });
const exited = once(p, 'exit'); p.kill('SIGTERM'); await exited;
const state = transition(root, actor);
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 30, killWaitMs: 1000 });
assert.equal(result.quiescent, true); assert.equal(api.processIdentity(descendant), null);
assert.deepEqual(api.readStops(root, state.generation)[0].signals, ['SIGTERM', 'SIGKILL']);
});
test('an unconfirmed secondary brake does not leave a verified workload running or claim success', async t => {
const root = fixture(t), { p, ended } = await child(t);
receipt(root, p); const state = transition(root, actor);
const result = await api.sweepRuntimes(root, state.generation, { graceMs: 60, killWaitMs: 1000,
beforeStop: () => { throw Error('fixture journal unavailable'); } });
await ended;
assert.equal(api.processIdentity(p.pid), null);
assert.equal(result.quiescent, false); assert.equal(result.remaining, 1);
const stops = api.readStops(root, state.generation); assert.equal(stops[0].outcome, 'unknown');
assert.equal(stops[0].problem, 'brake-unconfirmed');
});
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
const api = await import('../src/state.mjs').catch(e => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
});
const root = t => {
const dir = mkdtempSync(join(tmpdir(), 'processing-state-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
};
const actor = { business: 'demo', human: 'jason' };
const stateFile = dir => join(dir, 'control', 'processing.json');
const refusal = code => e => e.code === code;
// Removing durable publication must let a new reader launch after halt.
test('halt persists across readers and resume needs the current generation', t => {
assert.equal(typeof api.initializeControl, 'function');
const dir = root(t);
const initial = api.initializeControl(dir);
assert.equal(initial.halted, false);
const halted = api.transition(dir, { ...actor, halted: true });
assert.equal(halted.halted, true);
assert.notEqual(halted.generation, initial.generation);
assert.equal(api.readControl(dir).generation, halted.generation);
assert.throws(() => api.assertProcessing(dir), refusal('processing-halted'));
assert.throws(() => api.transition(dir, { ...actor, halted: false, generation: initial.generation }), refusal('processing-generation-changed'));
const resumed = api.transition(dir, { ...actor, halted: false, generation: halted.generation });
assert.equal(resumed.halted, false);
assert.doesNotThrow(() => api.assertProcessing(dir));
// A start that waited across halt+resume must not use its old admission.
assert.throws(() => api.assertProcessing(dir, initial.generation), refusal('processing-generation-changed'));
assert.equal(lstatSync(stateFile(dir)).mode & 0o777, 0o600);
assert.equal(lstatSync(join(dir, 'control')).mode & 0o777, 0o700);
});
// Reinitializing a missing file as off would bypass a previously published halt.
test('first initialization is explicit; missing state after control exists fails closed and halt repairs it', t => {
assert.equal(typeof api.initializeControl, 'function');
const dir = root(t);
assert.throws(() => api.readControl(dir), refusal('processing-uninitialized'));
const initial = api.initializeControl(dir);
assert.equal(api.initializeControl(dir).generation, initial.generation);
unlinkSync(stateFile(dir));
assert.throws(() => api.initializeControl(dir), refusal('processing-state-invalid'));
assert.throws(() => api.assertProcessing(dir), refusal('processing-state-invalid'));
assert.throws(() => api.transition(dir, { ...actor, halted: false, generation: initial.generation }), refusal('processing-state-invalid'));
const halted = api.transition(dir, { ...actor, halted: true });
assert.equal(halted.recovered, true);
assert.equal(halted.halted, true);
assert.equal(api.transition(dir, { ...actor, halted: false, generation: halted.generation }).halted, false);
});
// A read error, permissive mode or unbounded payload must never look like off.
for (const [name, edit] of [
['invalid JSON', file => writeFileSync(file, '{')],
['unknown keys', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), extra: true }))],
['bad timestamp', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), at: 'yesterday' }))],
['nonboolean state', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), halted: 0 }))],
['bad generation', file => writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file)), generation: 'bad' }))],
['loose mode', file => chmodSync(file, 0o644)],
['oversized file', file => writeFileSync(file, ' '.repeat(8193))],
]) test(`refuses ${name} instead of enabling work`, t => {
assert.equal(typeof api.initializeControl, 'function');
const dir = root(t); api.initializeControl(dir); edit(stateFile(dir));
assert.throws(() => api.assertProcessing(dir), refusal('processing-state-invalid'));
});
test('a symlink leaf never follows its target; human halt replaces only the leaf', t => {
assert.equal(typeof api.initializeControl, 'function');
const dir = root(t); api.initializeControl(dir);
const outside = join(dir, 'outside'), target = readFileSync(stateFile(dir));
writeFileSync(outside, target, { mode: 0o600 });
unlinkSync(stateFile(dir)); symlinkSync(outside, stateFile(dir));
assert.throws(() => api.readControl(dir), refusal('processing-state-invalid'));
api.transition(dir, { ...actor, halted: true });
assert.deepEqual(readFileSync(outside), target);
assert.equal(lstatSync(stateFile(dir)).isSymbolicLink(), false);
});
test('unsafe parent and interrupted initialization never get repaired as off', t => {
assert.equal(typeof api.initializeControl, 'function');
const dir = root(t); mkdirSync(join(dir, 'control'), { mode: 0o700 });
assert.throws(() => api.initializeControl(dir), refusal('processing-state-invalid'));
chmodSync(join(dir, 'control'), 0o777);
assert.throws(() => api.transition(dir, { ...actor, halted: true }), refusal('processing-control-unsafe'));
});
test('repeated halt is a no-op and a different root keeps running', t => {
assert.equal(typeof api.initializeControl, 'function');
const a = root(t), b = root(t); api.initializeControl(a); api.initializeControl(b);
const first = api.transition(a, { ...actor, halted: true });
const before = readFileSync(stateFile(a));
assert.equal(api.transition(a, { ...actor, halted: true }).generation, first.generation);
assert.deepEqual(readFileSync(stateFile(a)), before);
assert.doesNotThrow(() => api.assertProcessing(b));
});
// Coercing undefined/numbers through a regex would publish unverifiable actor metadata.
test('transition rejects nonstring identity before publishing state', t => {
const dir = root(t); api.initializeControl(dir);
const before = readFileSync(stateFile(dir));
for (const bad of [undefined, null, 123, ['jason']]) {
assert.throws(() => api.transition(dir, { ...actor, human: bad, halted: true }), refusal('invalid-request'));
assert.deepEqual(readFileSync(stateFile(dir)), before);
}
});
// A lost control directory after power failure could permit a later off bootstrap.
test('bootstrap syncs the containing root as well as the state and control directory', t => {
const dir = root(t);
const script = `import fs from 'node:fs'; import {syncBuiltinESMExports} from 'node:module';
const real=fs.fsyncSync, seen=[];
fs.fsyncSync=fd=>{seen.push(fs.readlinkSync('/proc/self/fd/'+fd));return real(fd)};
syncBuiltinESMExports();
const {initializeControl}=await import(process.argv[1]);initializeControl(process.argv[2]);
console.log(JSON.stringify(seen));`;
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script,
new URL('../src/state.mjs', import.meta.url).href, dir], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
const seen = JSON.parse(result.stdout);
assert.ok(seen.includes(dir), 'control mkdir must be durable in the root');
assert.ok(seen.includes(join(dir, 'control')), 'state rename must be durable');
assert.ok(seen.some(p => p.startsWith(join(dir, 'control', '.state-'))), 'state bytes must be durable');
});
// Each fstat/read guard is exercised independently, rather than hidden by a later parse refusal.
test('state ownership and bounded read guards refuse before trusting a document', t => {
const dir = root(t); api.initializeControl(dir); const file = stateFile(dir);
const script = `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';
const stat=fs.fstatSync,read=fs.readSync;let reads=0;
const file=process.argv[2],which=process.argv[3],valid=fs.readFileSync(file);
if(which==='oversize')fs.writeFileSync(file,Buffer.concat([valid,Buffer.alloc(8193,32)]));
fs.fstatSync=fd=>{const s=stat(fd);if(fs.readlinkSync('/proc/self/fd/'+fd)===file){
if(which==='owner')s.uid++;
if(which==='grow')fs.writeFileSync(file,Buffer.concat([valid,Buffer.alloc(8193-valid.length,32)]));
}return s};
fs.readSync=(...args)=>{if(fs.readlinkSync('/proc/self/fd/'+args[0])===file)reads++;return read(...args)};syncBuiltinESMExports();
const {readControl}=await import(process.argv[1]);let code=null;
try{readControl(process.argv[4])}catch(e){code=e.code}console.log(JSON.stringify({code,reads}));`;
for (const which of ['owner', 'oversize', 'grow']) {
const valid = readFileSync(file);
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script,
new URL('../src/state.mjs', import.meta.url).href, file, which, dir], { encoding: 'utf8' });
writeFileSync(file, valid);
assert.equal(result.status, 0, result.stderr);
const answer = JSON.parse(result.stdout); assert.equal(answer.code, 'processing-state-invalid', which);
if (which !== 'grow') assert.equal(answer.reads, 0, `${which}: refuse before reading bytes`);
else assert.ok(answer.reads > 0);
}
});
test('canonical root aliases address one switch and never create independent control state', t => {
const a = root(t), b = root(t), alias = join(b, 'alias');
api.initializeControl(a); symlinkSync(a, alias, 'dir');
assert.equal(api.initializeControl(alias).generation, api.readControl(a).generation);
const halted = api.transition(alias, { ...actor, halted: true });
assert.equal(api.readControl(a).generation, halted.generation);
assert.equal(api.readControl(a).halted, true);
});
@@ -0,0 +1,183 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { initializeControl, readControl, transition } from '../src/state.mjs';
import { registerRuntime, updateRuntime, runtime, stopRun, sweepRuntimes, readStops } from '../src/runtimes.mjs';
const boot = '12345678-1234-4234-8234-123456789abc';
const decision = 'fire-decision';
function setup(t, run = 'unbound-run') {
const root = mkdtempSync(join(tmpdir(), 'stop-run-')); initializeControl(root);
t.after(() => rmSync(root, { recursive: true, force: true }));
const r = registerRuntime(root, { kind: 'session', business: 'demo', run });
updateRuntime(root, r.id, { pid: 2000000001, startTime: '42', boot, phase: 'running', group: false });
let time = 0, live = true, changed = false;
const signals = [], sleeps = [];
const options = {
now: () => time,
sleep: async ms => { sleeps.push(ms); time += ms; },
identity: pid => live ? { pid, startTime: changed ? '43' : '42', boot } : null,
state: () => live,
signal: (_r, sig) => { signals.push(sig); if (sig === 'SIGKILL') live = false; return true; },
};
return { root, r, signals, sleeps, options,
setLive: value => { live = value; }, change: () => { changed = true; },
args: { root, business: 'demo', run, reason: 'fire', owner: { decision } } };
}
test('unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy', async t => {
const f = setup(t), before = readControl(f.root);
const result = await stopRun(f.args, f.options);
assert.equal(result.outcome, 'killed'); assert.equal(result.pid, 2000000001);
assert.deepEqual(result.signals, ['SIGTERM', 'SIGKILL']);
assert.ok(f.sleeps.reduce((a,b)=>a+b,0) >= 5000);
assert.equal(result.receipt.reason, 'fire'); assert.deepEqual(result.receipt.owner, { decision });
assert.deepEqual(readControl(f.root), before, 'fire never halts the instance');
});
test('same run joins the identical promise and completed repeats preserve owner and send no signals', async t => {
const f = setup(t);
const first = stopRun(f.args, f.options), second = stopRun(f.args, f.options);
assert.equal(second, first);
const result = await first;
const later = await stopRun({ ...f.args, owner: { decision: 'other' } }, f.options);
assert.deepEqual(later, result); assert.deepEqual(f.signals, ['SIGTERM', 'SIGKILL']);
});
test('identity changed before TERM or between TERM and KILL refuses the changed signal', async t => {
for (const phase of ['before', 'after-term']) {
const f = setup(t, phase);
if (phase === 'before') f.change();
f.options.signal = (_r, sig) => { f.signals.push(sig); f.change(); return true; };
const result = await stopRun(f.args, f.options);
assert.equal(result.outcome, 'identity-changed');
assert.deepEqual(f.signals, phase === 'before' ? [] : ['SIGTERM']);
assert.deepEqual(result.signals, f.signals);
}
});
test('failed signal delivery observes alive, records no invented signals and never restarts escalation', async t => {
const f = setup(t); f.options.signal = () => false;
const result = await stopRun(f.args, f.options);
assert.equal(result.outcome, 'alive'); assert.deepEqual(result.signals, []);
assert.equal(f.sleeps.reduce((a,b)=>a+b,0), 7000);
assert.deepEqual(await stopRun(f.args, f.options), result);
});
test('missing run and another business never borrow a pid or signal', async t => {
const f = setup(t);
for (const args of [{ ...f.args, run: 'missing' }, { ...f.args, business: 'other' }]) {
const result = await stopRun(args, f.options);
assert.equal(result.outcome, 'unknown'); assert.equal(result.pid, null); assert.deepEqual(result.signals, []);
}
assert.deepEqual(f.signals, []);
});
test('halt joins an in-progress fire without another TERM or deadline and links its first receipt', async t => {
const f = setup(t); let entered, release;
const ready = new Promise(resolve => { entered = resolve; });
const gate = new Promise(resolve => { release = resolve; });
let blocked = false;
const sleep = f.options.sleep;
f.options.sleep = async ms => { if (!blocked) { blocked = true; entered(); await gate; } await sleep(ms); };
const first = stopRun(f.args, f.options); await ready;
const halted = transition(f.root, { business: 'demo', human: 'jason', halted: true });
const sweep = sweepRuntimes(f.root, halted.generation, { stopOptions: f.options });
release(); const result = await first, summary = await sweep;
assert.equal(summary.quiescent, true); assert.deepEqual(f.signals, ['SIGTERM', 'SIGKILL']);
const [link] = readStops(f.root, halted.generation);
assert.equal(link.stop, result.receipt.id); assert.equal(link.reason, 'fire');
assert.equal(runtime(f.root, f.r.id).haltGeneration, undefined, 'halt does not relabel a fire-owned stop');
assert.deepEqual(link.owner, { decision });
});
test('a separate host joins durable first ownership without duplicate signals', async t => {
const { spawn } = await import('node:child_process');
const { once } = await import('node:events');
const f = setup(t), source = new URL('../src/runtimes.mjs', import.meta.url).href;
const script = `import {stopRun} from ${JSON.stringify(source)};
let time=0,live=true,waiting=true;
const args=JSON.parse(process.argv[1]);
const result=await stopRun(args,{
now:()=>time, identity:pid=>live?{pid,startTime:'42',boot:${JSON.stringify(boot)}}:null,
state:()=>live,signal:(_r,sig)=>{process.send({signal:sig});if(sig==='SIGKILL')live=false;return true},
sleep:async ms=>{if(waiting){waiting=false;await new Promise(r=>process.once('message',r))}time+=ms}
});process.send({result});process.disconnect();`;
const child = spawn(process.execPath, ['--input-type=module', '-e', script, JSON.stringify(f.args)],
{ detached: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
t.after(() => { if (child.exitCode === null) { try { process.kill(-child.pid, 'SIGKILL'); } catch {} } });
const messages = []; child.on('message', value => messages.push(value));
await once(child, 'message');
const joined = stopRun({ ...f.args, owner: { decision: 'second-host' } },
{ ...f.options, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)) });
setTimeout(() => child.send('continue'), 30);
const result = await joined;
const exit = child.exitCode === null ? await once(child, 'exit') : [child.exitCode];
assert.equal(exit[0], 0);
assert.deepEqual(f.signals, [], 'the second host sends no signal');
assert.deepEqual(messages.filter(m => m.signal).map(m => m.signal), ['SIGTERM', 'SIGKILL']);
assert.equal(result.receipt.id, messages.find(m => m.result).result.receipt.id);
assert.deepEqual(result.receipt.owner, { decision });
});
test('corrupt and symlinked final receipts refuse as unknown rather than signalling again', async t => {
const { readdirSync, writeFileSync, symlinkSync, unlinkSync } = await import('node:fs');
const f = setup(t); const complete = await stopRun(f.args, f.options);
const dir = join(f.root, 'control', 'run-stops');
const name = readdirSync(dir).find(n => n.endsWith('.result.json'));
writeFileSync(join(dir, name), '{broken', { mode: 0o600 });
assert.equal((await stopRun(f.args, f.options)).outcome, 'unknown');
writeFileSync(join(dir, name), JSON.stringify({ ...complete.receipt, outcome: 'invented' }), { mode: 0o600 });
assert.equal((await stopRun(f.args, f.options)).outcome, 'unknown');
const target = join(f.root, 'outside'); writeFileSync(target, JSON.stringify(complete.receipt), { mode: 0o600 });
unlinkSync(join(dir, name)); symlinkSync(target, join(dir, name));
assert.equal((await stopRun(f.args, f.options)).outcome, 'unknown');
assert.deepEqual(f.signals, ['SIGTERM', 'SIGKILL']);
});
test('a later exit gets separate halt evidence while the original alive result stays immutable', async t => {
const { spawn } = await import('node:child_process');
const { once } = await import('node:events');
const { processIdentity } = await import('../src/runtimes.mjs');
const f = setup(t);
const child = spawn(process.execPath, ['-e', "console.log('ready');setInterval(()=>{},1000)"],
{ detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
await once(child.stdout, 'data');
const identity = processIdentity(child.pid);
updateRuntime(f.root, f.r.id, { ...identity, group: false });
f.options.identity = () => identity; f.options.signal = () => false;
const first = await stopRun(f.args, f.options);
assert.equal(first.outcome, 'alive');
const bytes = JSON.stringify(first);
const ended = once(child, 'exit'); child.kill('SIGKILL'); await ended;
const state = transition(f.root, { business: 'demo', human: 'jason', halted: true });
assert.equal((await sweepRuntimes(f.root, state.generation)).quiescent, true);
assert.equal(JSON.stringify(await stopRun(f.args, f.options)), bytes);
const { readdirSync } = await import('node:fs');
assert.equal(readdirSync(join(f.root, 'control', 'run-stops')).filter(n => n.endsWith('.observation.json')).length, 1);
});
test('root aliases join but a different root never shares the run stop', async t => {
const { symlinkSync } = await import('node:fs');
const a = setup(t), b = setup(t);
const alias = a.root + '-alias'; symlinkSync(a.root, alias); t.after(() => rmSync(alias));
const first = stopRun(a.args, a.options);
assert.equal(stopRun({ ...a.args, root: alias }, a.options), first);
const other = stopRun(b.args, b.options);
assert.notEqual(other, first);
const [one, two] = await Promise.all([first, other]);
assert.notEqual(one.receipt.id, two.receipt.id);
assert.deepEqual(a.signals, ['SIGTERM', 'SIGKILL']); assert.deepEqual(b.signals, ['SIGTERM', 'SIGKILL']);
});
test('a changed runtime record cannot replace the original stop identity after TERM', async t => {
const f = setup(t);
f.options.signal = (_r, sig) => {
f.signals.push(sig); f.change(); updateRuntime(f.root, f.r.id, { startTime: '43' }); return true;
};
const result = await stopRun(f.args, f.options);
assert.equal(result.outcome, 'identity-changed'); assert.deepEqual(f.signals, ['SIGTERM']);
});
test('identity is rechecked at actual signal delivery, not just at the loop observation', async t => {
const f = setup(t); let reads = 0;
f.options.identity = pid => ({ pid, boot, startTime: ++reads === 1 ? '42' : '43' });
const result = await stopRun(f.args, f.options);
assert.equal(result.outcome, 'identity-changed'); assert.deepEqual(f.signals, []);
});
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { initializeControl, transition } from '../src/state.mjs';
const TASK = new URL('../../../scripts/mosaic-task.mjs', import.meta.url).pathname;
function setup(t, stop = false) {
const root = tmp(t), f = fixture(root), bin = join(root, 'bin'); mkdirSync(bin);
const source = new URL('../src/state.mjs', import.meta.url).href;
const runtimes = new URL('../src/runtimes.mjs', import.meta.url).href;
writeFileSync(join(bin, 'docker'), `#!${process.execPath}
(async()=>{const args=process.argv.slice(2);require('node:fs').writeFileSync(${JSON.stringify(join(root, 'called'))},JSON.stringify(args));
${stop ? `const {transition}=await import(${JSON.stringify(source)});const {updateRuntime}=await import(${JSON.stringify(runtimes)});const s=transition(process.env.MOSAIC_DATA_ROOT,{business:'acme',human:'jason',halted:true});const tag=args.find(x=>x.startsWith('mosaic.runtime='));if(tag)updateRuntime(process.env.MOSAIC_DATA_ROOT,tag.slice(15),{haltGeneration:s.generation});process.exit(137);` : `console.log('normal response');`}
})().catch(e=>{console.error(e.message);process.exit(1)});\n`, { mode: 0o755 });
f.env.PATH = bin + ':' + f.env.PATH;
const task = join(root, 'task.json'); writeFileSync(task, JSON.stringify({ taskVersion: 1, id: 'one', prompt: 'request' }));
const run = args => spawnSync(process.execPath, [TASK, ...args], { env: f.env, encoding: 'utf8', timeout: 10000 });
return { root, f, task, run };
}
test('task halt keeps the true Docker exit status, writes kill-switch once and leaves snapshots unchanged', t => {
const f = setup(t, true), result = f.run(['run', f.task]);
assert.equal(result.status, 1, result.stderr);
const runs = join(f.f.dataRoot, 'runs'), [id] = readdirSync(runs);
const file = join(runs, id, 'result.json'), bytes = readFileSync(file), record = JSON.parse(bytes);
assert.equal(record.reason, 'kill-switch'); assert.match(record.haltGeneration, /^[a-f0-9-]{36}$/);
assert.equal(record.exitCode, 137); assert.equal(record.signal, null);
assert.deepEqual(readFileSync(join(runs, id, 'task.json')), readFileSync(f.task));
const retry = f.run(['retry', id]); assert.equal(retry.status, 3, retry.stderr);
assert.deepEqual(readFileSync(file), bytes); assert.deepEqual(readdirSync(runs), [id]);
});
test('halt refuses direct task launch before Docker or a run record; clear allows a fresh normal result', t => {
const f = setup(t), actor = { business: 'acme', human: 'jason' };
initializeControl(f.f.dataRoot); const halted = transition(f.f.dataRoot, { ...actor, halted: true });
const result = f.run(['run', f.task]); assert.equal(result.status, 3, result.stderr);
assert.equal(existsSync(join(f.root, 'called')), false);
assert.equal(existsSync(join(f.f.dataRoot, 'runs')), false);
transition(f.f.dataRoot, { ...actor, halted: false, generation: halted.generation });
const normal = f.run(['run', f.task]); assert.equal(normal.status, 0, normal.stderr);
const [id] = readdirSync(join(f.f.dataRoot, 'runs'));
const record = JSON.parse(readFileSync(join(f.f.dataRoot, 'runs', id, 'result.json')));
assert.equal(record.reason, null); assert.equal(record.status, 'succeeded');
assert.equal(record.response, 'normal response'); assert.equal('haltGeneration' in record, false);
});
for (const [script, args] of [['hello.sh', []], ['verify.sh', []], ['agent.sh', ['fixture']]]) {
test(`halt refuses ${script} before Docker, credentials or release alignment`, t => {
const f = setup(t); initializeControl(f.f.dataRoot);
transition(f.f.dataRoot, { business: 'acme', human: 'jason', halted: true });
const r = spawnSync('bash', [new URL('../../../scripts/' + script, import.meta.url).pathname, ...args],
{ env: f.f.env, encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /processing-halted/);
assert.equal(existsSync(join(f.root, 'called')), false);
});
}
for (const starter of ['task', 'container-cli']) test(`a nonzero Docker client exit retains its unresolved runtime receipt (${starter})`, t => {
const f = setup(t);
writeFileSync(join(f.root, 'bin', 'docker'), `#!${process.execPath}\nprocess.exit(137);\n`, { mode: 0o755 });
const result = starter === 'task' ? f.run(['run', f.task]) : spawnSync(process.execPath,
[new URL('../src/container-cli.mjs', import.meta.url).pathname, 'request'], { env: f.f.env, encoding: 'utf8', timeout: 10000 });
assert.equal(result.status, starter === 'task' ? 1 : 137, result.stderr);
const files = readdirSync(join(f.f.dataRoot, 'control', 'runtimes'));
assert.equal(files.length, 1, 'a failed Docker client cannot prove the container stopped');
const runtime = JSON.parse(readFileSync(join(f.f.dataRoot, 'control', 'runtimes', files[0])));
assert.notEqual(runtime.phase, 'ended');
});
+4
View File
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs';
import { ProjectError } from './paths.mjs';
import { currentProcessing } from '../../processing-control/src/effects.mjs';
export const SAFE = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.ignorestat=false',
'-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', '-c', 'commit.gpgsign=false',
@@ -58,10 +59,13 @@ export const identity = name => ({ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: `${n
// Blob/log reads preserve bytes and bound memory before decoding untrusted Git data.
export async function gitBytes(repo, args, maxBytes) {
return new Promise((resolve, reject) => {
const processing = currentProcessing();
processing?.guard();
const child = spawn('git', [...SAFE, '-C', repo, ...args], {
env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(repo) },
detached: true, stdio: ['ignore', 'pipe', 'pipe'],
});
processing?.track(child);
let size = 0, failed = false, settled = false;
const chunks = [];
const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} };
+4
View File
@@ -45,6 +45,7 @@ import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, lstatSync, openSync, readdirSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { currentProcessing } from '../../processing-control/src/effects.mjs';
export const GIT_TIMEOUT_MS = 100_000;
// How long a git has after SIGTERM before SIGKILL.
@@ -106,6 +107,8 @@ export function gitEnv(env = process.env) {
// reads its token there, so the token is in no argv, env, URL or file.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = null } = {}) {
return new Promise((resolve) => {
const processing = currentProcessing();
processing?.guard();
let input = "ignore";
try {
if (stdin !== null) input = openSync(stdin, "r");
@@ -113,6 +116,7 @@ export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = nu
return resolve({ ok: false, out: "", err: "", why: e.code ?? e.message });
}
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe", ...(pass === null ? [] : ["pipe"])] });
processing?.track(child);
if (typeof input === "number") closeSync(input);
// A git that never asked for it closes the pipe unread: EPIPE is no fault.
if (pass !== null) child.stdio[3].on("error", () => {}).end(pass);
+3
View File
@@ -19,6 +19,7 @@ import { appendFileSync, existsSync, lstatSync, openSync, closeSync, readdirSync
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { gitEnv, runGit } from "./checkout.mjs";
import { currentProcessing } from '../../processing-control/src/effects.mjs';
// The new foundation's root commit, "chore: baseline container POC and atomic
// foundation plan" (2026-09-02, tag poc-container-hello-v0). A project whose
@@ -119,6 +120,7 @@ export async function runGate({ worktree, logs, env = process.env, signal = null
// The step's exit code, or null when the signal stopped it.
function runStep(step, worktree, log, env, signal) {
return new Promise((resolve) => {
const processing = currentProcessing(); processing?.guard();
const fd = openSync(log, "w");
const child = spawn("bash", ["-c", `set -o pipefail; ${step.command}`], {
cwd: join(worktree, step.dir),
@@ -126,6 +128,7 @@ function runStep(step, worktree, log, env, signal) {
detached: true,
stdio: ["ignore", fd, fd],
});
processing?.track(child);
closeSync(fd);
let kill = null;
const group = (sig) => {
+1
View File
@@ -60,6 +60,7 @@ export const RUNNER_FILES = Object.freeze([
"packages/bus/src/landing.mjs",
"packages/business/src/vocabulary.mjs",
"packages/plugins/src/kinds.mjs",
"packages/processing-control/src/state.mjs",
"packages/tasks/src/requirements.mjs",
].map((p) => join(REPO, p)));

Some files were not shown because too many files have changed in this diff Show More