docs: Researcher row 109 candidate packet (#1603, PM reads current launch state and task status before it plans)

Candidate patch (state verb, runner state block, header time, pm.md rule, fake-adapter fixture), six new harness tests, six mutants, full suite outputs, candidate manifest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-13 18:10:24 -05:00
co-authored by Claude Sonnet 5.5
parent 02fe2d9d55
commit 8ea93f556e
41 changed files with 2117 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# Row 109 (#1603) build notes
Researcher, 2026-10-13. Brief: `docs/plans/2026-10-13_pm-tester-and-fresh-state.md`, "A new PM run reads current launch state and task status before it plans". BASE: `02fe2d9d5` (origin/refactor after row 106 landed as `8b58cb7e4`). Reviewer: Filbert. Gate owner: Sage.
Candidate change: `row109.patch` (8 files: 7 modified, 1 new). Built and run in `/mnt/storage/scratch/researcher/q109`, detached at `02fe2d9d5`, `node_modules` linked to the canonical one. The patch applies to BASE; the packet is not in it.
## What ships
- `packages/bus/src/broker.mjs`: read verb `state`, no arguments, open to role, human and reader sessions (reader allowlist gains `state`). It returns `{launch:{state,at,event}, tasks:{open:[{task_ref,title}], total, cap}}` for the capability's business.
- `launch` is the latest `launch.revoked` / `launch.restored` event of the business: `state` `revoked`, else `allowed`; `at` and `event` are that event's time and id, null when no event exists (`allowed`).
- `tasks.open` is `#tasks(business,{done:false})`, so it is the tracker snapshot's open tasks, tombstones left out, in `task_ref` order, refs and titles only. The cap is the `tasks` page size (`TASK_PAGE`, 50), stated as `cap`; `total` counts every open task.
- Nothing is stored. No schema change.
- `packages/harness/src/runner.mjs`: before each turn the runner calls `state` and prefixes the turn request with `stateBlock(state, readAt)`: `Current state, read at <iso>:`, `Launches: ...`, `Open tasks: N[, the first K of cap C].`, one line per task. The read time is the runner's clock at the call. A failed read (anything but a dead session) fails that turn without running the adapter: the sender gets the usual RESULT `The turn failed: could not read the current state: <code>`, the failure is logged, and `message.read` still runs. A dead session (`SESSION_GONE`) exits as it does on `message.receive`.
- `turnRequest()`: the header gains `sent <at>` from the message row's `at`, after the sender and before the class.
- `roles/pm.md`: one rule in Protocol: the block is the truth for launch state and task status; a message or a note that disagrees is out of date; before planning to resume or launch work on a task, check it with `get_task`.
- READMEs: one table row in `packages/bus/README.md`, one paragraph in `packages/harness/README.md`.
- `packages/harness/src/tools.mjs` is untouched: `state` is not exposed as a tool; only the runner calls it.
## Fixture edit (accepted by Sage, rows 109 scope)
`packages/harness/tests/fixtures/fake-adapter.mjs` took the message body as everything after the first blank line of the request. The state block adds a blank line above the `Message ...:` header, which broke every TOOL/SLEEP/FAIL/NOMARKER test. It now takes the body after the `Message ...:` header line (regex), with the old split as fallback. This is the only reason for the edit; no other harness test needed a change. Every other test using the fixture passes in the full harness suite (79/79).
## Tests
- `packages/bus/tests/state.test.mjs` (new, 6): never switched reads `allowed` with null time and event; revoked then restored reads `allowed` with the restore event's time and id (and `revoked` with the revoke id in between); a task closed on the tracker leaves the open list and `total` drops; a tombstone is not open; the list stops at the cap and says so while `total` counts all; role and reader sessions read the same state, an argument refuses `invalid-request`, an unbound cap refuses `unauthenticated`.
- `packages/harness/tests/runner.test.mjs` (6 new tests, 20 in the file; harness suite 73 -> 79). By name:
1. `turnRequest shows the message's own sent time, a day old as well as new`
2. `stateBlock names the launch state with its time and event, and the open tasks with the cap`
3. `launches revoked and then restored: the turn's block reads allowed and gives the restore time`
4. `a task closed on the tracker is missing from the next turn's open list`
5. `a message read a day after it was sent shows the time it was sent` (the broker's clock jumps a day after the send)
6. `a failed state read fails the turn: no adapter runs, the sender hears why, the message is read` (no adapter turn file, RESULT with the reason, message read, log line)
My message to Sage before the gate run said five new runner tests. That was a miscount: it left out test 2, the `stateBlock` unit test, which I wrote as a unit test beside test 1. The suite count (+6) is the evidence. The existing first end-to-end test, `a message becomes a turn, the answer goes back as a RESULT, ...`, is changed, not new: it now checks the exact block and the header with `sent <at>`.
- The state block and the time are checked by the runner tests through the fake adapter, which echoes the request.
## Mutants (the kills)
`mutants.sh <worktree> <outdir>` runs one test file for the control and one per mutant, each mutant one exact-once text edit, and restores every source on exit. Outputs in `mutants/`; run summary in `mutants-run.txt`. `mutants/<name>.patch` is `git diff` of that file against BASE, so it also shows the candidate's own edits; the mutation is the one changed expression. The candidate was byte-identical to `row109.patch` after the run.
| mutant | edit | result | failing tests |
|---|---|---|---|
| control | none | runner 20/20, bus 6/6 | none |
| m1 | request is `turnRequest(m)`, no block | runner 17/20 | the first end-to-end test; "launches revoked and then restored"; "a task closed on the tracker is missing" |
| m2 | header drops `sent <at>` | runner 17/20 | the first end-to-end test; "a message read a day after it was sent"; "turnRequest shows the message's own sent time" |
| m3 | a failed read sets `block = ""` instead of failing the turn | runner 19/20 | "a failed state read fails the turn" |
| m4 | launch event read oldest-first | bus 5/6 | "launches revoked and then restored read allowed, with the restore time and event id" |
| m5 | open list built without the `done:false` filter | bus 5/6 | "a task closed on the tracker leaves the open list" |
| m6 | `cap` dropped from the output | bus 4/6 | the closed-task test and the cap test |
The `roles/pm.md` rule has no test; it is prose. Out of scope as the brief says: expiring old messages, tracker poller or `list_tasks` defaults, and who may switch launches.
## Suites (all in q109, output teed to `suites/`)
BASE `02fe2d9d5`; slot held 23:04:51Z to 23:08:36Z, "clear" sent; no process of mine left in q109.
| suite | result | Sage's gate count |
|---|---|---|
| `node --test packages/bus/tests/*.test.mjs` | 134 pass, 0 fail | 128 (+6 new) |
| `node --test packages/harness/tests/*.test.mjs` | 79 pass, 0 fail | 73 (+6 new) |
| `node --test packages/queue/tests/*.test.mjs` | 156 pass, 0 fail | 156 |
| `scripts/test-release.sh` | 14 passed, 0 failed | 14 |
| `test-auth` 15, `test-conductor` 17, `test-config` 24, `test-discord` 66, `test-extension-package` 18, `test-foundation` 44, `test-task` 98 | 0 failed each | n/a |
| `scripts/test-queue.sh` | 28 passed, 0 failed | n/a |
The `queue verify and render --check` case of `test-queue.sh` is skipped in a worktree (q109 is not the queue's canonical root `/mnt/storage/src/mosaic-stack`; Filbert's N3 from row 101). Sage's canonical-root gate rerun is the evidence of record. I did not apply the patch to or run suites in the canonical checkout.
## PM workspace (read-only)
`pm-workspace-names.txt`: the PM's persistent workspace holds one planning-notes file, `findings-2026-10-12.md` (7737 bytes, 2026-10-12T14:53Z), and `stack/`, a git checkout of the repository. Names only; no contents read. The note is a likely second source of the stale picture alongside old inbox messages; the pm.md rule covers it. The file is left alone.
## Scope and live
Owned files only, plus the accepted fixture. Live use needs a new copy and re-pin under Mos's go, with decision 109 notices before and after (runner.mjs reaches new role sessions at their next start; the broker verb needs a bus restart).
@@ -0,0 +1,40 @@
62bce061032623636835100f60b3ab29d2d6399603c0c89100cfec4eaf98ee28 agents/researcher/work/queue-109/build.md
1a92b106886608a836d2b4683d5d0f4cfa7c6ea7a6c0eff0e0d2527ef60b6413 agents/researcher/work/queue-109/mutants/control-bus.out
9a271f2a916b0b6ee6cecb2426f0b3206ef074578be55d9bc94f6f3fe3ab86aa agents/researcher/work/queue-109/mutants/control-bus.rc
23883a50bb236ccbfdeb123097f29c4db25e8322c3b68549891fa825fc5149ab agents/researcher/work/queue-109/mutants/control-runner.out
9a271f2a916b0b6ee6cecb2426f0b3206ef074578be55d9bc94f6f3fe3ab86aa agents/researcher/work/queue-109/mutants/control-runner.rc
f48ac9ad7b681fbfa417604338d6cd320259e746d097b98bf7cf64c85544ac17 agents/researcher/work/queue-109/mutants/m1-drop-block.out
55662e82c005a5042cb09bed61ab28d088cf424871d0ec6200bf0d56aaa3d11b agents/researcher/work/queue-109/mutants/m1-drop-block.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 agents/researcher/work/queue-109/mutants/m1-drop-block.rc
fea2ebac6cedf9a2018a018454ae3ac16f744b5bec3c4ee2e031d03ffc7912aa agents/researcher/work/queue-109/mutants/m2-drop-time.out
f4598e1f4f1182dfdc5de727acb53f84632d81c16f0f52adff4262a4c73f5a08 agents/researcher/work/queue-109/mutants/m2-drop-time.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 agents/researcher/work/queue-109/mutants/m2-drop-time.rc
9a1ef422b9c1163cb95f58e481090d700bc077589f9d651d1ef822189c4f7cb6 agents/researcher/work/queue-109/mutants/m3-drop-failure-check.out
b30ee17aa4144cb6f4f2c64ea09a0eff00a2ba7a09f64ebb9a6c59a6ca06523a agents/researcher/work/queue-109/mutants/m3-drop-failure-check.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 agents/researcher/work/queue-109/mutants/m3-drop-failure-check.rc
d65461b462514360d80d7e1677b0f6dc1223041d1bb19f867f4d253e4a1ab603 agents/researcher/work/queue-109/mutants/m4-oldest-launch-event.out
ced0a5f192f1302bffb5d73608dbc3dddae4ee0c117ad92a8513d92c37c09ce7 agents/researcher/work/queue-109/mutants/m4-oldest-launch-event.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 agents/researcher/work/queue-109/mutants/m4-oldest-launch-event.rc
b7aeeda30f679b5bcb2d0132cdf32f90e15a092ca5cc618090b07a62e8ff3d6a agents/researcher/work/queue-109/mutants/m5-closed-in-open.out
758218267fd42cba65d4cb39a03dbd45b0cc54dbb8191499a29f1ec3b067dcdf agents/researcher/work/queue-109/mutants/m5-closed-in-open.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 agents/researcher/work/queue-109/mutants/m5-closed-in-open.rc
b4b2bec5b1ca6acf5f830c6eb3aeba6a74a1964168749c04d45ad673b15d9503 agents/researcher/work/queue-109/mutants/m6-cap-unstated.out
f047e709535a10ba427eb2a6585c7dde3c7ea16b9934a295450a6d8eea5db815 agents/researcher/work/queue-109/mutants/m6-cap-unstated.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 agents/researcher/work/queue-109/mutants/m6-cap-unstated.rc
c3ff3575420cebbb57aa665592c17c2e7cc49da90f9800f0d49283f58c129863 agents/researcher/work/queue-109/mutants-run.txt
34ba363fba2e5b9384a8662e2402bc1abcb6a796ad718b01571b218c2018f797 agents/researcher/work/queue-109/mutants.sh
b0ef977027ad0a39b69347dc2d3f20765d4d8e1d57d783b03c1cc653b96e5fdb agents/researcher/work/queue-109/pm-workspace-names.txt
55fcdf13135818abde7157f78f4f9afbfcedc292d95fe02109908392f69fc4c7 agents/researcher/work/queue-109/row109.patch
bc33dae0ca339fb80f8ed5a0de7d84ea73df1f8f684f5001eed10dcb1abad9ec agents/researcher/work/queue-109/suites/bus-node-suite.out
d5731e9121956e7629a2e8040b4ee329f74996a680707ed3a9ae8193568df775 agents/researcher/work/queue-109/suites/harness-node-suite.out
b996f93d6f321014cd4e9ef327b6004bf8c525accf4e7c74cf3ca6ff3b145dd7 agents/researcher/work/queue-109/suites/queue-node-suite.out
c7ed4247eb9916e67bf5a2dd86687110fbf3e0eaaaa234b31b551d26497c5856 agents/researcher/work/queue-109/suites.sh
f91fc95a173339137f981e24fbf630acfde957e32c3cd71e28304532083c6a77 agents/researcher/work/queue-109/suites/test-auth.out
e1206366f07f3d9029530dcdcb55c0282d3f052adc60fc7fd51642d5d4ec3600 agents/researcher/work/queue-109/suites/test-conductor.out
52d211444489ea729dafc3c9745d4474d9dfd50f41055cbad792424197f04073 agents/researcher/work/queue-109/suites/test-config.out
7d5016ded1994048642f8b9ae845e52e8af4a069ba05c056c8f580a4589bacbe agents/researcher/work/queue-109/suites/test-discord.out
bd12416272ae0a9d9d78e3a340663c68a8c7423a2c7ed0ad2c2558ec97d8be90 agents/researcher/work/queue-109/suites/test-extension-package.out
83dfaa21c4e4ad941088efab3f69e87ebd2f6bd3cc72ede938e21dc06da8a34f agents/researcher/work/queue-109/suites/test-foundation.out
4636814ca4819fa843fe9abf6285bb6e32a295997221252269cc4bf112f1aabf agents/researcher/work/queue-109/suites/test-queue.out
fa832794ee35025fb4559d58d01a270f6bff2eb65e863e9075b797f47fa68545 agents/researcher/work/queue-109/suites/test-release.out
ccea37f4ffde1f160dcd1a1864988083a645d974e892065c956b338d0f8c853a agents/researcher/work/queue-109/suites/test-task.out
@@ -0,0 +1,25 @@
control-runner: exit 0; ℹ tests 20 ℹ pass 20 ℹ fail 0
control-bus: exit 0; ℹ tests 6 ℹ pass 6 ℹ fail 0
m1-drop-block: exit 1; ℹ tests 20 ℹ pass 17 ℹ fail 3
failed: ✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0
failed: ✖ a task closed on the tracker is missing from the next turn's open list
failed: ✖ failing tests:
failed: ✖ launches revoked and then restored: the turn's block reads allowed and gives the restore time
m2-drop-time: exit 1; ℹ tests 20 ℹ pass 17 ℹ fail 3
failed: ✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0
failed: ✖ a message read a day after it was sent shows the time it was sent
failed: ✖ failing tests:
failed: ✖ turnRequest shows the message's own sent time, a day old as well as new
m3-drop-failure-check: exit 1; ℹ tests 20 ℹ pass 19 ℹ fail 1
failed: ✖ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read
failed: ✖ failing tests:
m4-oldest-launch-event: exit 1; ℹ tests 6 ℹ pass 5 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ launches revoked and then restored read allowed, with the restore time and event id
m5-closed-in-open: exit 1; ℹ tests 6 ℹ pass 5 ℹ fail 1
failed: ✖ a task closed on the tracker leaves the open list, and total counts the open ones
failed: ✖ failing tests:
m6-cap-unstated: exit 1; ℹ tests 6 ℹ pass 4 ℹ fail 2
failed: ✖ a task closed on the tracker leaves the open list, and total counts the open ones
failed: ✖ failing tests:
failed: ✖ the open list stops at the cap and says so; total still counts every open task
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Row 109 (#1603): each new check must fail on a mutant. For each mutant this runs the named test
# file against the real source (control) and against the source with one exact-once text edit.
# Runs in the given worktree, one test file at a time, and restores every source file on exit.
# Usage: mutants.sh <worktree> <outdir>
set -u
WT=$(cd "$1" && pwd); OUT=$(mkdir -p "$2" && cd "$2" && pwd)
cd "$WT" || exit 2
BAK=$(mktemp -d "$OUT/.bak.XXXXXX")
declare -A SAVED
save() { [ -n "${SAVED[$1]:-}" ] || { mkdir -p "$BAK/$(dirname "$1")"; cp "$1" "$BAK/$1"; SAVED[$1]=1; }; }
restore() { for f in "${!SAVED[@]}"; do cp "$BAK/$f" "$f"; done; rm -rf "$BAK"; }
trap restore EXIT
edit() { # file old new: exact-once replace
save "$1"
node -e '
const fs = require("fs"); const [f, a, b] = process.argv.slice(1);
const s = fs.readFileSync(f, "utf8"); const n = s.split(a).length - 1;
if (n !== 1) { console.error("expected one match, found " + n); process.exit(3); }
fs.writeFileSync(f, s.replace(a, () => b));
' "$1" "$2" "$3"
}
run() { # name testfile
setsid -w timeout -k 10 240 node --test "$2" > "$OUT/$1.out" 2>&1; echo "$?" > "$OUT/$1.rc"
echo "$1: exit $(cat "$OUT/$1.rc"); $(grep -E '^ℹ (tests|pass|fail)' "$OUT/$1.out" | tr '\n' ' ')"
grep -E '^✖' "$OUT/$1.out" | sort -u | sed 's/ ([0-9.]*ms)$//' | sed "s/^/ failed: /"
}
mutant() { # name testfile file old new
edit "$3" "$4" "$5" || { echo "$1: edit failed"; return; }
git diff -- "$3" > "$OUT/$1.patch"
run "$1" "$2"
cp "$BAK/$3" "$3"
}
R=packages/harness/src/runner.mjs; RT=packages/harness/tests/runner.test.mjs
B=packages/bus/src/broker.mjs; BT=packages/bus/tests/state.test.mjs
run control-runner "$RT"
run control-bus "$BT"
# m1: the turn request no longer carries the state block
mutant m1-drop-block "$RT" "$R" 'request: `${block}\n${turnRequest(m)}`' 'request: turnRequest(m)'
# m2: the header no longer carries the message time
mutant m2-drop-time "$RT" "$R" ' if (m.at) head.push(`sent ${m.at}`);
' ''
# m3: a failed state read no longer fails the turn; the turn runs with no block
mutant m3-drop-failure-check "$RT" "$R" 'unread = `could not read the current state: ${e.code ?? e.message}`;' 'block = "";'
# m4: the block's launch line shows the older event instead of the latest
mutant m4-oldest-launch-event "$BT" "$B" "kind IN ('launch.revoked','launch.restored') ORDER BY seq DESC LIMIT 1" "kind IN ('launch.revoked','launch.restored') ORDER BY seq ASC LIMIT 1"
# m5: the open list includes closed tasks
mutant m5-closed-in-open "$BT" "$B" 'const open = this.#tasks(s.business, { done: false });' 'const open = this.#tasks(s.business, { limit: TASK_PAGE });'
# m6: the cap isn't stated in the output
mutant m6-cap-unstated "$BT" "$B" 'total: open.total, cap: TASK_PAGE }' 'total: open.total }'
@@ -0,0 +1,14 @@
✔ a business that never switched launches reads allowed with no time and no event (12.740463ms)
✔ launches revoked and then restored read allowed, with the restore time and event id (10.115322ms)
✔ a task closed on the tracker leaves the open list, and total counts the open ones (10.612196ms)
✔ a tombstone is not open (7.867781ms)
✔ the open list stops at the cap and says so; total still counts every open task (12.014832ms)
✔ a role and a reader session read state; arguments, and an unbound cap, refuse (9.286701ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 117.997122
@@ -0,0 +1 @@
0
@@ -0,0 +1,28 @@
✔ founderCheck: founder variables, then a needed service without a usable token (1.105369ms)
✔ turnRequest names the sender, class, reply and decision (0.21491ms)
✔ turnRequest shows the message's own sent time, a day old as well as new (0.126433ms)
✔ stateBlock names the launch state with its time and event, and the open tasks with the cap (0.195396ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (152.361989ms)
✔ launches revoked and then restored: the turn's block reads allowed and gives the restore time (169.022648ms)
✔ a task closed on the tracker is missing from the next turn's open list (167.718931ms)
✔ a message read a day after it was sent shows the time it was sent (87.662171ms)
✔ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read (63.771972ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (48.50038ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (271.762253ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1192.335593ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (93.322815ms)
✔ founder credentials stop before the claim (20) (96.496233ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (100.411332ms)
✔ the launch ending under a running session exits 22 (102.903092ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (204.000168ms)
✔ a broker that is down at the claim exits 23, not 21 (47.277428ms)
✔ no capability, or a malformed one, on stdin exits 2 (151.489917ms)
✔ a missing or malformed policy exits 2 before the claim (87.115027ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10345.427283
@@ -0,0 +1 @@
0
@@ -0,0 +1,87 @@
✔ founderCheck: founder variables, then a needed service without a usable token (0.927996ms)
✔ turnRequest names the sender, class, reply and decision (0.187137ms)
✔ turnRequest shows the message's own sent time, a day old as well as new (0.121749ms)
✔ stateBlock names the launch state with its time and event, and the open tasks with the cap (0.176786ms)
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (147.584047ms)
✖ launches revoked and then restored: the turn's block reads allowed and gives the restore time (91.206024ms)
✖ a task closed on the tracker is missing from the next turn's open list (90.236176ms)
✔ a message read a day after it was sent shows the time it was sent (89.018287ms)
✔ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read (63.256302ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (48.492078ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (247.488656ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1191.072755ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (93.718876ms)
✔ founder credentials stop before the claim (20) (94.802529ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (98.777833ms)
✔ the launch ending under a running session exits 22 (102.953767ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (206.247136ms)
✔ a broker that is down at the claim exits 23, not 21 (45.3418ms)
✔ no capability, or a malformed one, on stdin exits 2 (156.779329ms)
✔ a missing or malformed policy exits 2 before the claim (83.562291ms)
ℹ tests 20
ℹ suites 0
ℹ pass 17
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10156.567034
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:174:1
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (147.584047ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /^echo: Current state, read at \S+:\nLaunches: allowed \(never switched\)\.\nOpen tasks: 0\.\n\nMessage 0d34f793-bec7-4d76-9425-fb1079503a1b from pm, sent 2026-10-13T23:07:58.732Z, class REQUEST:\n\nhello$/. Input:
'echo: Message 0d34f793-bec7-4d76-9425-fb1079503a1b from pm, sent 2026-10-13T23:07:58.732Z, class REQUEST:\n' +
'\n' +
'hello'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/harness/tests/runner.test.mjs:186:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'echo: Message 0d34f793-bec7-4d76-9425-fb1079503a1b from pm, sent 2026-10-13T23:07:58.732Z, class REQUEST:\n\nhello',
expected: /^echo: Current state, read at \S+:\nLaunches: allowed \(never switched\)\.\nOpen tasks: 0\.\n\nMessage 0d34f793-bec7-4d76-9425-fb1079503a1b from pm, sent 2026-10-13T23:07:58.732Z, class REQUEST:\n\nhello$/,
operator: 'match',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:217:1
✖ launches revoked and then restored: the turn's block reads allowed and gives the restore time (91.206024ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /\nLaunches: revoked since \S+ \(event \S+\)\.\n/. Input:
'echo: Message 4fe85a96-a724-4368-a91f-9a7c6dfb1888 from pm, sent 2026-10-13T23:07:58.825Z, class REQUEST:\n' +
'\n' +
'what now'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/harness/tests/runner.test.mjs:223:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'echo: Message 4fe85a96-a724-4368-a91f-9a7c6dfb1888 from pm, sent 2026-10-13T23:07:58.825Z, class REQUEST:\n\nwhat now',
expected: /\nLaunches: revoked since \S+ \(event \S+\)\.\n/,
operator: 'match',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:229:1
✖ a task closed on the tracker is missing from the next turn's open list (90.236176ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /\nOpen tasks: 2\.\n- vikunja:3\/1 Task 1\n- vikunja:3\/2 Task 2\n/. Input:
'echo: Message a189d6e4-745d-49d2-9ab6-60e13bf1b5e6 from pm, sent 2026-10-13T23:07:58.914Z, class REQUEST:\n' +
'\n' +
'what now'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/harness/tests/runner.test.mjs:236:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'echo: Message a189d6e4-745d-49d2-9ab6-60e13bf1b5e6 from pm, sent 2026-10-13T23:07:58.914Z, class REQUEST:\n\nwhat now',
expected: /\nOpen tasks: 2\.\n- vikunja:3\/1 Task 1\n- vikunja:3\/2 Task 2\n/,
operator: 'match',
diff: 'simple'
}
@@ -0,0 +1,64 @@
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index 00595542b..87d66dd36 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -62,7 +62,10 @@ export function founderCheck(policy, env) {
// The request text one message becomes.
export function turnRequest(m) {
- const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`];
+ const head = [`Message ${m.id} from ${m.from_role}`];
+ // Row 109 (#1603): when the sender sent it, so a message that waited reads as old.
+ if (m.at) head.push(`sent ${m.at}`);
+ head.push(`class ${m.class}`);
if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`);
if (m.decision) head.push(`citing decision ${m.decision}`);
// Row 90 (#1584): the human input id task_create cites as its request.
@@ -70,6 +73,22 @@ export function turnRequest(m) {
return `${head.join(", ")}:\n\n${m.body}`;
}
+// Row 109 (#1603): the block at the top of a turn's request. It is what the broker said at `readAt`,
+// the truth for launch state and task status over any older message.
+export function stateBlock(state, readAt) {
+ const { launch, tasks } = state;
+ const since = launch.at ? ` since ${launch.at} (event ${launch.event})` : " (never switched)";
+ const shown = tasks.open.length < tasks.total ? `, the first ${tasks.open.length} of cap ${tasks.cap}` : "";
+ const open = tasks.open.map((t) => `- ${t.task_ref} ${t.title ?? ""}`.trimEnd());
+ return [
+ `Current state, read at ${readAt}:`,
+ `Launches: ${launch.state}${since}.`,
+ `Open tasks: ${tasks.total}${shown}.`,
+ ...open,
+ "",
+ ].join("\n");
+}
+
function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
@@ -339,7 +358,23 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
}
for (const m of messages) {
if (stopping) break loop;
- const turn = await runTurn({ session, request: turnRequest(m), n: ++n, env: turnEnv, onChild: (c) => (child = c) });
+ // Row 109 (#1603): read the state now, not when the message was sent. A failed read fails the turn.
+ let block = null;
+ let unread = null;
+ try {
+ block = stateBlock(await client.call("state"), new Date().toISOString());
+ } catch (e) {
+ if (SESSION_GONE.has(e.code)) {
+ log(`runner: session stopped working: ${e.code}`);
+ code = EXIT.session;
+ break loop;
+ }
+ unread = `could not read the current state: ${e.code ?? e.message}`;
+ }
+ ++n;
+ const turn = unread
+ ? { ok: false, reason: unread }
+ : await runTurn({ session, request: turnRequest(m), n, env: turnEnv, onChild: (c) => (child = c) });
if (stopping) break loop;
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
@@ -0,0 +1 @@
1
@@ -0,0 +1,93 @@
✔ founderCheck: founder variables, then a needed service without a usable token (0.987417ms)
✔ turnRequest names the sender, class, reply and decision (0.198796ms)
✖ turnRequest shows the message's own sent time, a day old as well as new (0.813441ms)
✔ stateBlock names the launch state with its time and event, and the open tasks with the cap (0.188045ms)
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (145.435504ms)
✔ launches revoked and then restored: the turn's block reads allowed and gives the restore time (169.165152ms)
✔ a task closed on the tracker is missing from the next turn's open list (168.933118ms)
✖ a message read a day after it was sent shows the time it was sent (90.269575ms)
✔ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read (63.164189ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (50.491583ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (272.210562ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1192.639985ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (94.793583ms)
✔ founder credentials stop before the claim (20) (93.850655ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (99.075117ms)
✔ the launch ending under a running session exits 22 (101.888229ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (204.216765ms)
✔ a broker that is down at the claim exits 23, not 21 (49.593739ms)
✔ no capability, or a malformed one, on stdin exits 2 (156.481136ms)
✔ a missing or malformed policy exits 2 before the claim (91.206713ms)
ℹ tests 20
ℹ suites 0
ℹ pass 17
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10339.878414
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:60:1
✖ turnRequest shows the message's own sent time, a day old as well as new (0.813441ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'Message m5 from jason, class REQUEST:\n\nresume'
- 'Message m5 from jason, sent 2026-10-12T09:30:00.000Z, class REQUEST:\n\nresume'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/harness/tests/runner.test.mjs:62:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'Message m5 from jason, class REQUEST:\n\nresume',
expected: 'Message m5 from jason, sent 2026-10-12T09:30:00.000Z, class REQUEST:\n\nresume',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:174:1
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (145.435504ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /^echo: Current state, read at \S+:\nLaunches: allowed \(never switched\)\.\nOpen tasks: 0\.\n\nMessage 2b684cd9-8412-4498-9700-651ca8840067 from pm, sent 2026-10-13T23:08:08.949Z, class REQUEST:\n\nhello$/. Input:
'echo: Current state, read at 2026-10-13T23:08:08.986Z:\n' +
'Launches: allowed (never switched).\n' +
'Open tasks: 0.\n' +
'\n' +
'Message 2b684cd9-8412-4498-9700-651ca8840067 from pm, class REQUEST:\n' +
'\n' +
'hello'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/harness/tests/runner.test.mjs:186:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'echo: Current state, read at 2026-10-13T23:08:08.986Z:\nLaunches: allowed (never switched).\nOpen tasks: 0.\n\nMessage 2b684cd9-8412-4498-9700-651ca8840067 from pm, class REQUEST:\n\nhello',
expected: /^echo: Current state, read at \S+:\nLaunches: allowed \(never switched\)\.\nOpen tasks: 0\.\n\nMessage 2b684cd9-8412-4498-9700-651ca8840067 from pm, sent 2026-10-13T23:08:08.949Z, class REQUEST:\n\nhello$/,
operator: 'match',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:243:1
✖ a message read a day after it was sent shows the time it was sent (90.269575ms)
AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value:
assert.ok(reply.body.includes(`Message ${sent.id} from pm, sent ${sentAt}, class REQUEST:`))
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/harness/tests/runner.test.mjs:255:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
@@ -0,0 +1,63 @@
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index 00595542b..57f4b141a 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -62,7 +62,9 @@ export function founderCheck(policy, env) {
// The request text one message becomes.
export function turnRequest(m) {
- const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`];
+ const head = [`Message ${m.id} from ${m.from_role}`];
+ // Row 109 (#1603): when the sender sent it, so a message that waited reads as old.
+ head.push(`class ${m.class}`);
if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`);
if (m.decision) head.push(`citing decision ${m.decision}`);
// Row 90 (#1584): the human input id task_create cites as its request.
@@ -70,6 +72,22 @@ export function turnRequest(m) {
return `${head.join(", ")}:\n\n${m.body}`;
}
+// Row 109 (#1603): the block at the top of a turn's request. It is what the broker said at `readAt`,
+// the truth for launch state and task status over any older message.
+export function stateBlock(state, readAt) {
+ const { launch, tasks } = state;
+ const since = launch.at ? ` since ${launch.at} (event ${launch.event})` : " (never switched)";
+ const shown = tasks.open.length < tasks.total ? `, the first ${tasks.open.length} of cap ${tasks.cap}` : "";
+ const open = tasks.open.map((t) => `- ${t.task_ref} ${t.title ?? ""}`.trimEnd());
+ return [
+ `Current state, read at ${readAt}:`,
+ `Launches: ${launch.state}${since}.`,
+ `Open tasks: ${tasks.total}${shown}.`,
+ ...open,
+ "",
+ ].join("\n");
+}
+
function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
@@ -339,7 +357,23 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
}
for (const m of messages) {
if (stopping) break loop;
- const turn = await runTurn({ session, request: turnRequest(m), n: ++n, env: turnEnv, onChild: (c) => (child = c) });
+ // Row 109 (#1603): read the state now, not when the message was sent. A failed read fails the turn.
+ let block = null;
+ let unread = null;
+ try {
+ block = stateBlock(await client.call("state"), new Date().toISOString());
+ } catch (e) {
+ if (SESSION_GONE.has(e.code)) {
+ log(`runner: session stopped working: ${e.code}`);
+ code = EXIT.session;
+ break loop;
+ }
+ unread = `could not read the current state: ${e.code ?? e.message}`;
+ }
+ ++n;
+ const turn = unread
+ ? { ok: false, reason: unread }
+ : await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
if (stopping) break loop;
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
@@ -0,0 +1 @@
1
@@ -0,0 +1,52 @@
✔ founderCheck: founder variables, then a needed service without a usable token (1.027678ms)
✔ turnRequest names the sender, class, reply and decision (0.175568ms)
✔ turnRequest shows the message's own sent time, a day old as well as new (0.108091ms)
✔ stateBlock names the launch state with its time and event, and the open tasks with the cap (0.164473ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (151.370525ms)
✔ launches revoked and then restored: the turn's block reads allowed and gives the restore time (168.631533ms)
✔ a task closed on the tracker is missing from the next turn's open list (193.462068ms)
✔ a message read a day after it was sent shows the time it was sent (88.274542ms)
✖ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read (89.644081ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (50.931607ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (273.817356ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1189.440166ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (95.175856ms)
✔ founder credentials stop before the claim (20) (89.610808ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (96.280534ms)
✔ the launch ending under a running session exits 22 (100.621249ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (202.570602ms)
✔ a broker that is down at the claim exits 23, not 21 (49.247418ms)
✔ no capability, or a malformed one, on stdin exits 2 (161.38161ms)
✔ a missing or malformed policy exits 2 before the claim (86.000718ms)
ℹ tests 20
ℹ suites 0
ℹ pass 19
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10397.655899
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:259:1
✖ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read (89.644081ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'echo: \n' +
+ 'Message 586075ac-c0cc-4abf-9f4f-2d1289199157 from pm, sent 2026-10-13T23:08:19.896Z, class REQUEST:\n' +
+ '\n' +
+ 'plan the week'
- 'The turn failed: could not read the current state: state-unavailable'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/harness/tests/runner.test.mjs:273:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'echo: \nMessage 586075ac-c0cc-4abf-9f4f-2d1289199157 from pm, sent 2026-10-13T23:08:19.896Z, class REQUEST:\n\nplan the week',
expected: 'The turn failed: could not read the current state: state-unavailable',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,64 @@
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index 00595542b..c56e9fc38 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -62,7 +62,10 @@ export function founderCheck(policy, env) {
// The request text one message becomes.
export function turnRequest(m) {
- const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`];
+ const head = [`Message ${m.id} from ${m.from_role}`];
+ // Row 109 (#1603): when the sender sent it, so a message that waited reads as old.
+ if (m.at) head.push(`sent ${m.at}`);
+ head.push(`class ${m.class}`);
if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`);
if (m.decision) head.push(`citing decision ${m.decision}`);
// Row 90 (#1584): the human input id task_create cites as its request.
@@ -70,6 +73,22 @@ export function turnRequest(m) {
return `${head.join(", ")}:\n\n${m.body}`;
}
+// Row 109 (#1603): the block at the top of a turn's request. It is what the broker said at `readAt`,
+// the truth for launch state and task status over any older message.
+export function stateBlock(state, readAt) {
+ const { launch, tasks } = state;
+ const since = launch.at ? ` since ${launch.at} (event ${launch.event})` : " (never switched)";
+ const shown = tasks.open.length < tasks.total ? `, the first ${tasks.open.length} of cap ${tasks.cap}` : "";
+ const open = tasks.open.map((t) => `- ${t.task_ref} ${t.title ?? ""}`.trimEnd());
+ return [
+ `Current state, read at ${readAt}:`,
+ `Launches: ${launch.state}${since}.`,
+ `Open tasks: ${tasks.total}${shown}.`,
+ ...open,
+ "",
+ ].join("\n");
+}
+
function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
@@ -339,7 +358,23 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
}
for (const m of messages) {
if (stopping) break loop;
- const turn = await runTurn({ session, request: turnRequest(m), n: ++n, env: turnEnv, onChild: (c) => (child = c) });
+ // Row 109 (#1603): read the state now, not when the message was sent. A failed read fails the turn.
+ let block = null;
+ let unread = null;
+ try {
+ block = stateBlock(await client.call("state"), new Date().toISOString());
+ } catch (e) {
+ if (SESSION_GONE.has(e.code)) {
+ log(`runner: session stopped working: ${e.code}`);
+ code = EXIT.session;
+ break loop;
+ }
+ block = "";
+ }
+ ++n;
+ const turn = unread
+ ? { ok: false, reason: unread }
+ : await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
if (stopping) break loop;
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
@@ -0,0 +1,45 @@
✔ a business that never switched launches reads allowed with no time and no event (11.927647ms)
✖ launches revoked and then restored read allowed, with the restore time and event id (10.621051ms)
✔ a task closed on the tracker leaves the open list, and total counts the open ones (9.618177ms)
✔ a tombstone is not open (7.741209ms)
✔ the open list stops at the cap and says so; total still counts every open task (11.030111ms)
✔ a role and a reader session read state; arguments, and an unbound cap, refuse (8.605084ms)
ℹ tests 6
ℹ suites 0
ℹ pass 5
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 112.662835
✖ failing tests:
test at packages/bus/tests/state.test.mjs:42:1
✖ launches revoked and then restored read allowed, with the restore time and event id (10.621051ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
{
+ at: '2026-10-13T23:08:29.735Z',
+ event: '03630f89-6d18-4f43-8ced-8f36a81f8fbc',
+ state: 'revoked'
- at: '2026-10-13T23:08:29.737Z',
- event: 'f2c5df5c-6b5b-4f25-91ca-2137aa67912d',
- state: 'allowed'
}
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/bus/tests/state.test.mjs:51:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: { state: 'revoked', at: '2026-10-13T23:08:29.735Z', event: '03630f89-6d18-4f43-8ced-8f36a81f8fbc' },
expected: { state: 'allowed', at: '2026-10-13T23:08:29.737Z', event: 'f2c5df5c-6b5b-4f25-91ca-2137aa67912d' },
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,37 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 732c7310e..054b7acff 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -654,7 +654,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb))
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -1086,6 +1086,23 @@ export class Broker {
this.#agent(s);
return this.#requirements;
}
+ // Row 109 (#1603): what a run needs to know before it plans. The business's launch state with
+ // the time and id of the event that set it (`allowed`, no time and no id when none has), and
+ // the open tasks as the tracker snapshot shows them: refs and titles, at most `cap` of them,
+ // `total` counting every open task. A read for role and reader sessions; nothing is stored.
+ case 'state': {
+ keys(a, []);
+ if (!s.human && !s.reader) this.#agent(s);
+ const set = this.#store.get(
+ "SELECT id,kind,at FROM events WHERE business=? AND kind IN ('launch.revoked','launch.restored') ORDER BY seq ASC LIMIT 1",
+ s.business,
+ );
+ const open = this.#tasks(s.business, { done: false });
+ return {
+ launch: { state: set?.kind === 'launch.revoked' ? 'revoked' : 'allowed', at: set?.at ?? null, event: set?.id ?? null },
+ tasks: { open: open.tasks.map(({ task_ref, title }) => ({ task_ref, title })), total: open.total, cap: TASK_PAGE },
+ };
+ }
case 'inbox':
case 'agents':
case 'tasks':
@@ -0,0 +1,42 @@
✔ a business that never switched launches reads allowed with no time and no event (11.379419ms)
✔ launches revoked and then restored read allowed, with the restore time and event id (9.235032ms)
✖ a task closed on the tracker leaves the open list, and total counts the open ones (10.543996ms)
✔ a tombstone is not open (8.500886ms)
✔ the open list stops at the cap and says so; total still counts every open task (11.900535ms)
✔ a role and a reader session read state; arguments, and an unbound cap, refuse (8.938047ms)
ℹ tests 6
ℹ suites 0
ℹ pass 5
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 117.78508
✖ failing tests:
test at packages/bus/tests/state.test.mjs:55:1
✖ a task closed on the tracker leaves the open list, and total counts the open ones (10.543996ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
'vikunja:3/1',
+ 'vikunja:3/2',
'vikunja:3/3'
]
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/bus/tests/state.test.mjs:65:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ 'vikunja:3/1', 'vikunja:3/2', 'vikunja:3/3' ],
expected: [ 'vikunja:3/1', 'vikunja:3/3' ],
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,37 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 732c7310e..138991706 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -654,7 +654,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb))
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -1086,6 +1086,23 @@ export class Broker {
this.#agent(s);
return this.#requirements;
}
+ // Row 109 (#1603): what a run needs to know before it plans. The business's launch state with
+ // the time and id of the event that set it (`allowed`, no time and no id when none has), and
+ // the open tasks as the tracker snapshot shows them: refs and titles, at most `cap` of them,
+ // `total` counting every open task. A read for role and reader sessions; nothing is stored.
+ case 'state': {
+ keys(a, []);
+ if (!s.human && !s.reader) this.#agent(s);
+ const set = this.#store.get(
+ "SELECT id,kind,at FROM events WHERE business=? AND kind IN ('launch.revoked','launch.restored') ORDER BY seq DESC LIMIT 1",
+ s.business,
+ );
+ const open = this.#tasks(s.business, { limit: TASK_PAGE });
+ return {
+ launch: { state: set?.kind === 'launch.revoked' ? 'revoked' : 'allowed', at: set?.at ?? null, event: set?.id ?? null },
+ tasks: { open: open.tasks.map(({ task_ref, title }) => ({ task_ref, title })), total: open.total, cap: TASK_PAGE },
+ };
+ }
case 'inbox':
case 'agents':
case 'tasks':
@@ -0,0 +1 @@
1
@@ -0,0 +1,65 @@
✔ a business that never switched launches reads allowed with no time and no event (11.422277ms)
✔ launches revoked and then restored read allowed, with the restore time and event id (8.798469ms)
✖ a task closed on the tracker leaves the open list, and total counts the open ones (9.437594ms)
✔ a tombstone is not open (7.259551ms)
✖ the open list stops at the cap and says so; total still counts every open task (10.864472ms)
✔ a role and a reader session read state; arguments, and an unbound cap, refuse (7.816773ms)
ℹ tests 6
ℹ suites 0
ℹ pass 4
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 110.771289
✖ failing tests:
test at packages/bus/tests/state.test.mjs:55:1
✖ a task closed on the tracker leaves the open list, and total counts the open ones (9.437594ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
{
- cap: 50,
open: [
{
task_ref: 'vikunja:3/1',
title: 'Task 1'
},
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/bus/tests/state.test.mjs:58:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: { open: [ [Object], [Object], [Object] ], total: 3 },
expected: { open: [ [Object], [Object], [Object] ], total: 3, cap: 50 },
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/bus/tests/state.test.mjs:75:1
✖ the open list stops at the cap and says so; total still counts every open task (10.864472ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
undefined !== 50
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q109/packages/bus/tests/state.test.mjs:81:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: undefined,
expected: 50,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,37 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 732c7310e..6df80db83 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -654,7 +654,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb))
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -1086,6 +1086,23 @@ export class Broker {
this.#agent(s);
return this.#requirements;
}
+ // Row 109 (#1603): what a run needs to know before it plans. The business's launch state with
+ // the time and id of the event that set it (`allowed`, no time and no id when none has), and
+ // the open tasks as the tracker snapshot shows them: refs and titles, at most `cap` of them,
+ // `total` counting every open task. A read for role and reader sessions; nothing is stored.
+ case 'state': {
+ keys(a, []);
+ if (!s.human && !s.reader) this.#agent(s);
+ const set = this.#store.get(
+ "SELECT id,kind,at FROM events WHERE business=? AND kind IN ('launch.revoked','launch.restored') ORDER BY seq DESC LIMIT 1",
+ s.business,
+ );
+ const open = this.#tasks(s.business, { done: false });
+ return {
+ launch: { state: set?.kind === 'launch.revoked' ? 'revoked' : 'allowed', at: set?.at ?? null, event: set?.id ?? null },
+ tasks: { open: open.tasks.map(({ task_ref, title }) => ({ task_ref, title })), total: open.total },
+ };
+ }
case 'inbox':
case 'agents':
case 'tasks':
@@ -0,0 +1 @@
1
@@ -0,0 +1,12 @@
Row 109 (#1603): the PM's persistent workspace, read-only, names/sizes/mtimes only (no contents read).
Checked 2026-10-13, before 22:54Z with ls and find -printf; nothing opened, nothing changed.
Path: <dataRoot>/workspaces/mosaic-stack/pm (dataRoot /home/jwoltje/.mosaic-dev)
Top level (2 entries):
f 7737 bytes 2026-10-12T14:53Z findings-2026-10-12.md <- planning notes from an earlier run
d 2026-10-13T16:48Z stack/ <- a git checkout of this repository (10356 entries in the workspace in all), not notes
Finding: findings-2026-10-12.md is the only planning-notes file. It predates the current launch state and task
status, so it is a likely second source of the stale picture a PM run planned from, next to old inbox
messages. The roles/pm.md rule covers it ("a message or a note of yours that disagrees with the block is out
of date"). The file is left alone; this row doesn't expire, move or edit workspace files.
@@ -0,0 +1,405 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 0cbbc8192..b0c469263 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -157,6 +157,7 @@ an operator inspect content and delivery evidence after an uncertain result.
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
+| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
### Agent state (row 59, #1548)
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 732c7310e..7565e82da 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -654,7 +654,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb))
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -1086,6 +1086,23 @@ export class Broker {
this.#agent(s);
return this.#requirements;
}
+ // Row 109 (#1603): what a run needs to know before it plans. The business's launch state with
+ // the time and id of the event that set it (`allowed`, no time and no id when none has), and
+ // the open tasks as the tracker snapshot shows them: refs and titles, at most `cap` of them,
+ // `total` counting every open task. A read for role and reader sessions; nothing is stored.
+ case 'state': {
+ keys(a, []);
+ if (!s.human && !s.reader) this.#agent(s);
+ const set = this.#store.get(
+ "SELECT id,kind,at FROM events WHERE business=? AND kind IN ('launch.revoked','launch.restored') ORDER BY seq DESC LIMIT 1",
+ s.business,
+ );
+ const open = this.#tasks(s.business, { done: false });
+ return {
+ launch: { state: set?.kind === 'launch.revoked' ? 'revoked' : 'allowed', at: set?.at ?? null, event: set?.id ?? null },
+ tasks: { open: open.tasks.map(({ task_ref, title }) => ({ task_ref, title })), total: open.total, cap: TASK_PAGE },
+ };
+ }
case 'inbox':
case 'agents':
case 'tasks':
diff --git a/packages/bus/tests/state.test.mjs b/packages/bus/tests/state.test.mjs
new file mode 100644
index 000000000..3a14c7ae9
--- /dev/null
+++ b/packages/bus/tests/state.test.mjs
@@ -0,0 +1,89 @@
+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 { Store } from '../src/store.mjs';
+import { Broker, TASK_PAGE } from '../src/broker.mjs';
+// Row 109 (#1603): `state` is what a run reads before it plans: the launch state with the time and
+// id of the event that set it, and the open tasks from the tracker snapshot, capped.
+const authority = { withinRole: ['message.send', 'role.launch'], crossRole: [] };
+const roles = { pm: { authority }, coder: { authority } };
+const businesses = {
+ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, launch: { by: 'pm', instances: ['coder'], max: { opus: 2, sonnet: 2 } }, roles },
+};
+const at = '2026-10-12T12:00:00.000Z';
+const digest = 'd'.repeat(64);
+const ref = (i) => `vikunja:3/${i}`;
+const fields = (i, done = false) => ({ project_id: 3, title: `Task ${i}`, description: `<p>${i}</p>`, done, bucket: 11 });
+
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-state-'));
+ const store = new Store(root);
+ const b = new Broker({ store, businesses });
+ t.after(() => {
+ store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' });
+ b.request(pm, { verb: 'role.claim' });
+ const snap = (i, over = {}, read_at = at) => ({ task_ref: ref(i), updated: read_at, digest, fields: { ...fields(i), ...over }, via: 'board', read_at });
+ const record = (...snapshots) => b.recordTask({ business: 'demo', snapshots });
+ const state = (cap = pm, args = {}) => b.request(cap, { verb: 'state', args });
+ return { b, store, human, pm, snap, record, state, reader: b.bindReader({ business: 'demo' }) };
+}
+
+test('a business that never switched launches reads allowed with no time and no event', (t) => {
+ const { state } = setup(t);
+ assert.deepEqual(state().launch, { state: 'allowed', at: null, event: null });
+});
+
+test('launches revoked and then restored read allowed, with the restore time and event id', (t) => {
+ const { b, store, human, pm, state } = setup(t);
+ const revoked = b.request(human, { verb: 'launch.revoke' }).id;
+ assert.deepEqual(
+ { state: state().launch.state, event: state().launch.event },
+ { state: 'revoked', event: revoked },
+ );
+ const restored = b.request(human, { verb: 'launch.restore' }).id;
+ const row = store.get('SELECT at FROM events WHERE id=?', restored);
+ assert.deepEqual(state(pm).launch, { state: 'allowed', at: row.at, event: restored });
+ assert.notEqual(restored, revoked);
+});
+
+test('a task closed on the tracker leaves the open list, and total counts the open ones', (t) => {
+ const { snap, record, state } = setup(t);
+ record(snap(1), snap(2), snap(3));
+ assert.deepEqual(state().tasks, {
+ open: [1, 2, 3].map((i) => ({ task_ref: ref(i), title: `Task ${i}` })),
+ total: 3,
+ cap: TASK_PAGE,
+ });
+ record(snap(2, { done: true }, '2026-10-12T13:00:00.000Z'));
+ const { open, total } = state().tasks;
+ assert.deepEqual(open.map((x) => x.task_ref), [ref(1), ref(3)]);
+ assert.equal(total, 2);
+});
+
+test('a tombstone is not open', (t) => {
+ const { snap, record, state } = setup(t);
+ record(snap(1), { ...snap(2), fields: { gone: 'no-access' }, via: 'task' });
+ assert.deepEqual(state().tasks.open.map((x) => x.task_ref), [ref(1)]);
+});
+
+test('the open list stops at the cap and says so; total still counts every open task', (t) => {
+ const { snap, record, state } = setup(t);
+ record(...Array.from({ length: TASK_PAGE + 5 }, (_, k) => snap(k + 1)));
+ const { open, total, cap } = state().tasks;
+ assert.equal(open.length, TASK_PAGE);
+ assert.equal(total, TASK_PAGE + 5);
+ assert.equal(cap, TASK_PAGE);
+});
+
+test('a role and a reader session read state; arguments, and an unbound cap, refuse', (t) => {
+ const { reader, pm, state } = setup(t);
+ assert.deepEqual(state(reader), state(pm));
+ assert.throws(() => state(pm, { business: 'other' }), { code: 'invalid-request' });
+ assert.throws(() => state('f'.repeat(64)), /unauthenticated/);
+});
diff --git a/packages/harness/README.md b/packages/harness/README.md
index 6f87ac731..4d2d6fb96 100644
--- a/packages/harness/README.md
+++ b/packages/harness/README.md
@@ -182,6 +182,15 @@ it never touches the disk and isn't in the environment or argv.
Its stdout goes back to the sender as a RESULT; a failed turn replies
`The turn failed: <reason>`; an incoming RESULT never gets an automatic
reply. Then `message.read`.
+
+ Row 109 (#1603): before each turn the runner calls the broker's `state`
+ verb and puts a block at the top of the request, headed `Current state,
+ read at <time>`: the launch state with the time and id of the event that
+ set it, and the open tasks (refs and titles, at most the stated cap, with
+ the total). The block is the truth over any older message. The message
+ header after it names when the message was sent. A failed read fails the
+ turn without running the adapter: the sender gets `The turn failed: could
+ not read the current state: <code>`, and the message is read.
5. SIGTERM or SIGINT kills the turn's process group, releases the role and
exits 0. The handlers go in before anything else, because pid 1 of a
PID namespace never sees a signal it has no handler for; a stop before
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index 00595542b..f806cabed 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -62,7 +62,10 @@ export function founderCheck(policy, env) {
// The request text one message becomes.
export function turnRequest(m) {
- const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`];
+ const head = [`Message ${m.id} from ${m.from_role}`];
+ // Row 109 (#1603): when the sender sent it, so a message that waited reads as old.
+ if (m.at) head.push(`sent ${m.at}`);
+ head.push(`class ${m.class}`);
if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`);
if (m.decision) head.push(`citing decision ${m.decision}`);
// Row 90 (#1584): the human input id task_create cites as its request.
@@ -70,6 +73,22 @@ export function turnRequest(m) {
return `${head.join(", ")}:\n\n${m.body}`;
}
+// Row 109 (#1603): the block at the top of a turn's request. It is what the broker said at `readAt`,
+// the truth for launch state and task status over any older message.
+export function stateBlock(state, readAt) {
+ const { launch, tasks } = state;
+ const since = launch.at ? ` since ${launch.at} (event ${launch.event})` : " (never switched)";
+ const shown = tasks.open.length < tasks.total ? `, the first ${tasks.open.length} of cap ${tasks.cap}` : "";
+ const open = tasks.open.map((t) => `- ${t.task_ref} ${t.title ?? ""}`.trimEnd());
+ return [
+ `Current state, read at ${readAt}:`,
+ `Launches: ${launch.state}${since}.`,
+ `Open tasks: ${tasks.total}${shown}.`,
+ ...open,
+ "",
+ ].join("\n");
+}
+
function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
@@ -339,7 +358,23 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
}
for (const m of messages) {
if (stopping) break loop;
- const turn = await runTurn({ session, request: turnRequest(m), n: ++n, env: turnEnv, onChild: (c) => (child = c) });
+ // Row 109 (#1603): read the state now, not when the message was sent. A failed read fails the turn.
+ let block = null;
+ let unread = null;
+ try {
+ block = stateBlock(await client.call("state"), new Date().toISOString());
+ } catch (e) {
+ if (SESSION_GONE.has(e.code)) {
+ log(`runner: session stopped working: ${e.code}`);
+ code = EXIT.session;
+ break loop;
+ }
+ unread = `could not read the current state: ${e.code ?? e.message}`;
+ }
+ ++n;
+ const turn = unread
+ ? { ok: false, reason: unread }
+ : await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
if (stopping) break loop;
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
diff --git a/packages/harness/tests/fixtures/fake-adapter.mjs b/packages/harness/tests/fixtures/fake-adapter.mjs
index 4024172d1..4e1ec9bd3 100644
--- a/packages/harness/tests/fixtures/fake-adapter.mjs
+++ b/packages/harness/tests/fixtures/fake-adapter.mjs
@@ -11,7 +11,8 @@ import { appendFileSync, writeFileSync } from "node:fs";
import { callTool } from "../../src/tools.mjs";
const request = process.env.MOSAIC_REQUEST ?? "";
-const body = request.split("\n\n").slice(1).join("\n\n");
+// The message body follows the "Message ...:" header line; a state block (row 109) may sit above it.
+const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? request.split("\n\n").slice(1).join("\n\n");
if (process.env.FAKE_PIDS) appendFileSync(process.env.FAKE_PIDS, `${process.pid}\n`);
process.stderr.write(`fake adapter: ${process.env.MOSAIC_MODEL}\n`);
const done = () => writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index 777d16148..12b683cb7 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -11,9 +11,9 @@ import { open } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { Store } from "../../bus/src/store.mjs";
-import { Broker } from "../../bus/src/broker.mjs";
+import { Broker, BusError } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
-import { EXIT, founderCheck, turnRequest } from "../src/runner.mjs";
+import { EXIT, founderCheck, stateBlock, turnRequest } from "../src/runner.mjs";
import { scratch } from "./helpers.mjs";
const RUNNER = new URL("../src/runner.mjs", import.meta.url).pathname;
@@ -56,6 +56,25 @@ test("turnRequest names the sender, class, reply and decision", () => {
assert.equal(turnRequest({ id: "m4", from_role: "cto", class: "RESULT", request: null, body: "b" }), "Message m4 from cto, class RESULT:\n\nb");
});
+// Row 109 (#1603): the header carries when the message was sent, not when it is read.
+test("turnRequest shows the message's own sent time, a day old as well as new", () => {
+ const sent = "2026-10-12T09:30:00.000Z";
+ assert.equal(
+ turnRequest({ id: "m5", from_role: "jason", class: "REQUEST", at: sent, body: "resume" }),
+ `Message m5 from jason, sent ${sent}, class REQUEST:\n\nresume`,
+ );
+ assert.equal(turnRequest({ id: "m6", from_role: "cto", class: "RESULT", at: sent, in_reply_to: "m5", body: "b" }), `Message m6 from cto, sent ${sent}, class RESULT, in reply to m5:\n\nb`);
+});
+
+test("stateBlock names the launch state with its time and event, and the open tasks with the cap", () => {
+ const read = "2026-10-13T10:00:00.000Z";
+ const restored = stateBlock({ launch: { state: "allowed", at: "2026-10-12T20:00:00.000Z", event: "ev-9" }, tasks: { open: [{ task_ref: "vikunja:3/7", title: "Seven" }], total: 1, cap: 50 } }, read);
+ assert.equal(restored, "Current state, read at 2026-10-13T10:00:00.000Z:\nLaunches: allowed since 2026-10-12T20:00:00.000Z (event ev-9).\nOpen tasks: 1.\n- vikunja:3/7 Seven\n");
+ const capped = stateBlock({ launch: { state: "allowed", at: null, event: null }, tasks: { open: [{ task_ref: "vikunja:3/1", title: "One" }], total: 9, cap: 1 } }, read);
+ assert.match(capped, /Launches: allowed \(never switched\)\./);
+ assert.match(capped, /Open tasks: 9, the first 1 of cap 1\./);
+});
+
async function setup(t, { session = {}, policy = {}, tools } = {}) {
const dir = scratch(t);
const store = new Store(dir);
@@ -113,7 +132,8 @@ async function setup(t, { session = {}, policy = {}, tools } = {}) {
const pm = broker.bindLaunch({ business: "demo", role: "pm", run: "pm-run", harness: "pi" });
const call = (c, verb, args = {}) => broker.request(c, { verb, args });
call(pm, "role.claim");
- return { dir, runDir, store, broker, server, cap, pm, call, launches, pids: join(dir, "pids") };
+ const human = broker.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
+ return { dir, runDir, store, broker, server, cap, pm, human, call, launches, pids: join(dir, "pids") };
}
// A runner that hasn't exited after a minute is killed, so a test that
@@ -162,7 +182,8 @@ test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releas
assert.equal(reply.from_role, "coder");
assert.equal(reply.from_run, "coder-run");
assert.equal(reply.in_reply_to, sent.id);
- assert.equal(reply.body, `echo: Message ${sent.id} from pm, class REQUEST:\n\nhello`);
+ const sentAt = ctx.store.get("SELECT at FROM messages WHERE id=?", sent.id).at;
+ assert.match(reply.body, new RegExp(`^echo: Current state, read at \\S+:\nLaunches: allowed \\(never switched\\)\\.\nOpen tasks: 0\\.\n\nMessage ${sent.id} from pm, sent ${sentAt}, class REQUEST:\n\nhello$`));
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
const err = join(ctx.runDir, "turns", "0001.stderr");
assert.equal(statSync(err).mode & 0o777, 0o600);
@@ -176,6 +197,85 @@ test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releas
assert.ok(!existsSync(join(ctx.runDir, "tools.sock")));
});
+// Row 109 (#1603): each turn reads the state when it starts. The fake adapter echoes the request,
+// so a reply shows the block the turn was given.
+const ask = async (ctx, body = "what now") => {
+ const before = ctx.inbox.length;
+ ctx.call(ctx.pm, "message.send", { to: "coder", body, class: "REQUEST" });
+ await until(() => replies(ctx)() && ctx.inbox.length > before);
+ return ctx.inbox.at(-1);
+};
+const snapshot = (i, over = {}, read = "2026-10-12T12:00:00.000Z") => ({
+ task_ref: `vikunja:3/${i}`,
+ updated: read,
+ digest: "d".repeat(64),
+ fields: { project_id: 3, title: `Task ${i}`, description: "", done: false, bucket: 11, ...over },
+ via: "board",
+ read_at: read,
+});
+
+test("launches revoked and then restored: the turn's block reads allowed and gives the restore time", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ ctx.call(ctx.human, "launch.revoke");
+ assert.match((await ask(ctx)).body, /\nLaunches: revoked since \S+ \(event \S+\)\.\n/);
+ ctx.call(ctx.human, "launch.restore");
+ const restored = ctx.store.get("SELECT id,at FROM events WHERE kind='launch.restored'");
+ assert.ok((await ask(ctx)).body.includes(`\nLaunches: allowed since ${restored.at} (event ${restored.id}).\n`));
+});
+
+test("a task closed on the tracker is missing from the next turn's open list", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ ctx.broker.recordTask({ business: "demo", snapshots: [snapshot(1), snapshot(2)] });
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ const first = (await ask(ctx)).body;
+ assert.match(first, /\nOpen tasks: 2\.\n- vikunja:3\/1 Task 1\n- vikunja:3\/2 Task 2\n/);
+ ctx.broker.recordTask({ business: "demo", snapshots: [snapshot(2, { done: true }, "2026-10-12T13:00:00.000Z")] });
+ const second = (await ask(ctx)).body;
+ assert.match(second, /\nOpen tasks: 1\.\n- vikunja:3\/1 Task 1\n\nMessage /);
+ assert.ok(!second.includes("vikunja:3/2"));
+});
+
+test("a message read a day after it was sent shows the time it was sent", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "resume the launch", class: "REQUEST" });
+ const sentAt = ctx.store.get("SELECT at FROM messages WHERE id=?", sent.id).at;
+ // The broker's clock jumps a day before the runner reads: the header keeps the original time.
+ const now = Date.now;
+ Date.now = () => now() + 86_400_000;
+ t.after(() => (Date.now = now));
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ const [reply] = await until(replies(ctx));
+ assert.ok(reply.body.includes(`Message ${sent.id} from pm, sent ${sentAt}, class REQUEST:`));
+ assert.ok(Date.parse(reply.at) - Date.parse(sentAt) >= 86_000_000);
+});
+
+test("a failed state read fails the turn: no adapter runs, the sender hears why, the message is read", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ const request = ctx.broker.request.bind(ctx.broker);
+ ctx.broker.request = (cap, req) => {
+ if (req.verb === "state") throw new BusError("state-unavailable");
+ return request(cap, req);
+ };
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "plan the week", class: "REQUEST" });
+ const [reply] = await until(replies(ctx));
+ assert.equal(reply.class, "RESULT");
+ assert.equal(reply.in_reply_to, sent.id);
+ assert.equal(reply.body, "The turn failed: could not read the current state: state-unavailable");
+ await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
+ assert.ok(!existsSync(join(ctx.runDir, "turns", "0001.stderr")));
+ assert.match(r.stderr(), /turn 1 for message \S+ failed: could not read the current state: state-unavailable/);
+});
+
test("a SIGTERM before the claim stops the runner with exit 0 and no claim", async (t) => {
const ctx = await setup(t);
// session.json becomes a FIFO: main opens it right after its handlers go
diff --git a/roles/pm.md b/roles/pm.md
index 522ac9c0e..8ef433f58 100644
--- a/roles/pm.md
+++ b/roles/pm.md
@@ -53,5 +53,10 @@ cross-role. Raise it as a decision for the technical arbiter.
- You act on the tracker and on Gitea only through the typed tools the
broker gives you. You never hold a token.
+- Each turn opens with a "Current state" block, read when the turn
+ started. It is the truth for launch state and task status. A message
+ or a note of yours that disagrees with it is out of date. Before you
+ plan to resume or launch work on a task, check the task with
+ `get_task`.
- Send messages through the bus, addressed to a role, not a person.
- Report a blocker as soon as you find it, with what you tried.
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Row 109 (#1603): the gate suites, run in the given worktree with every output teed to <outdir>.
# Usage: suites.sh <worktree> <outdir>
set -u -o pipefail
WT=$(cd "$1" && pwd); OUT=$(mkdir -p "$2" && cd "$2" && pwd)
cd "$WT" || exit 2
node_suite() { # name dir
setsid -w timeout -k 10 900 node --test "$2"/tests/*.test.mjs 2>&1 | tee "$OUT/$1-node-suite.out" >/dev/null
echo "$1 node suite: rc=${PIPESTATUS[0]} $(grep -E '^ℹ (tests|pass|fail) ' "$OUT/$1-node-suite.out" | tr '\n' ' ')"
}
script() { # name
setsid -w timeout -k 10 1200 bash "scripts/$1.sh" 2>&1 | tee "$OUT/$1.out" >/dev/null
echo "$1: rc=${PIPESTATUS[0]} $(grep -iE 'passed|failed' "$OUT/$1.out" | tail -1)"
}
for d in bus harness; do node_suite $d packages/$d; done
for s in test-auth test-conductor test-config test-discord test-extension-package test-foundation test-queue test-release test-task; do script $s; done
echo done
@@ -0,0 +1,142 @@
✔ agents: a held role with nothing on the bus is idle; a role session gets the claim rows unchanged (28.297351ms)
✔ agents: working counts what the holder received and has not read; routed alone is not working (26.151014ms)
✔ agents: input needed is the role's open decisions routed to the human, gated first, then oldest (50.067427ms)
✔ agents: working wins over waiting, and Input needed still shows (22.161722ms)
✔ agents: a new holder doesn't inherit what the last one left unread; decisions stay with the role (31.653342ms)
✔ agents: state reads only its own business, even for a run name both use (33.064525ms)
✔ agents: a message the holder released unread is stranded; the next holder gets nothing and cannot read it (36.106172ms)
✔ agents: a message the holder read is not stranded; one the current holder has not read is not listed (30.478949ms)
✔ agents: stranded is oldest first and capped at 50 for a role (123.330395ms)
✔ agents: stranded is per role and per business (35.147374ms)
✔ agents: a message routed to a held role and not yet received is not stranded (14.397001ms)
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (29.665485ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (32.302635ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (36.864032ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (22.624026ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (26.418409ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (25.730416ms)
✔ task action subjects and linked decision trail are complete and ordered (42.04217ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (19.549861ms)
✔ business isolation includes inherited object names and cross-business message references (30.891376ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (36.279429ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (23.916643ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (33.145318ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (17.342213ms)
✔ both arbiters require human resolution when their cross-role route is themselves (26.232801ms)
✔ the shipped coder role raises task.reassign as gated to the human; task.scope.change stays cross-role (20.62886ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.778225ms)
✔ credentials gives human and reader sessions service, account, role, date and state, and nothing else (23.765412ms)
✔ credentials refuses a role session and any argument (20.404226ms)
✔ credentials reads the status on each request, and a broker without one lists none (24.326082ms)
✔ the broker process answers credentials without a token, path or variable in its output, log or store (93.242027ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (7.033452ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (3.013949ms)
✔ expiry refuses use and env references never become client data (1.544583ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.277426ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.764888ms)
✔ roles lists open decisions routed to a role, and never the human inbox (39.554268ms)
✔ recent lists closed decisions of every route, newest close first, with how each closed (37.199033ms)
✔ recent gives the closing event as closed when the decision was seen first (40.823498ms)
✔ recent stops at RECENT rows (146.209586ms)
✔ every row is what trail already gives the same session for that id (50.272751ms)
✔ a role session can't list decisions, so it can't read another role's open decisions (53.874347ms)
✔ decisions is a read: one business, strict arguments, nothing written (44.63042ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (24.633583ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (15.749064ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (16.279704ms)
✔ endLaunch leaves a claim another run took alone (23.202804ms)
✔ refuse records action.refused against the caller with the code only (18.116231ms)
✔ launches off refuses role.launch with launch-revoked until launches on (21.321294ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (133.544824ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.828563ms)
✔ process reader gets own kernel identity without exposing environment values (0.926592ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.124436ms)
✔ real pid 1 remains inspectable when its environment is protected (0.65044ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (32.979603ms)
✔ missing and foreign-business citations refuse and roll back message and grant (20.474351ms)
✔ cross-role sends still need a matching resolved decision and consume it once (34.541326ms)
✔ messages pages all human messages newest first without writes; read state is history (444.138897ms)
✔ messages refuses role callers, invalid paging and cursors outside this human feed (20.427029ms)
✔ views messages passes only its paging arguments through (0.347001ms)
✔ messages isolates businesses even with a valid foreign cursor (13.183145ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (102.394931ms)
✔ startup token refusal returns safe code without value or partial listening broker (63.271273ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (98.670383ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (137.631589ms)
✔ trusted host registers later launches; socket clients never have a registration verb (74.175173ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (41.416891ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (60.256132ms)
✔ a reply is listed for human and reader sessions, and its delivery rows are unchanged (49.259344ms)
✔ the reply still reaches the human message.receive (mosaic talk) after the Console showed it (25.624504ms)
✔ a message with no reply gives an empty list (20.016322ms)
✔ replies come oldest first and stop at REPLIES (120.444271ms)
✔ an id that names no message in the business refuses message-not-found, and the refusal is recorded (44.587049ms)
✔ strict arguments; a role session gets human-required (44.089256ms)
✔ views passes replies through as one call with the id (2.541981ms)
✔ requirements returns the committed list to a holding role session, and no one else (32.077319ms)
✔ the PM's prd.approve with no task, when no id fits, reaches the human's Inbox (22.074738ms)
✔ the PM's non-blocking role.revoke with no task reaches the human's Inbox (20.246744ms)
✔ a push is recorded as the action.allowed for git.push.working on the task, and only the host records one (36.151608ms)
✔ review.request names the latest pushed commit and sends the reviewer a REVIEW-REQUEST (30.682906ms)
✔ review.request says which run holds the reviewer role, or null, and a null request waits for a claim (40.310639ms)
✔ review.request refuses a commit that isn't the latest push, a non-reviewer and a role without the action (57.238224ms)
✔ reviewFetch answers only the addressed reviewer, for the request it cites (41.290709ms)
✔ review.verdict answers one request once, to the role that asked (46.002266ms)
✔ task.close on a pushed task needs an approve verdict for the latest push (55.690651ms)
✔ a refusal the host decided is filed on the task it names, and only a task ref counts (26.625638ms)
✔ v3b prototype refusals, views and append-only mutations (107.680716ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (41.650704ms)
✔ another run cannot consume an approval; a failed check leaves it usable (32.015671ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (26.932983ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (51.60255ms)
✔ class drift gated to cross-role refuses before consumption (36.98624ms)
✔ class drift cross-role to gated refuses before consumption (31.240912ms)
✔ class drift gated to within-role refuses before consumption (34.31432ms)
✔ class drift cross-role to within-role refuses before consumption (28.552349ms)
✔ class drift within-role to gated refuses before consumption (24.790443ms)
✔ class drift within-role to cross-role refuses before consumption (20.366658ms)
✔ message.send consumes approval and prevents a later send or authorize (23.511105ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (22.585085ms)
✔ a business that never switched launches reads allowed with no time and no event (21.965042ms)
✔ launches revoked and then restored read allowed, with the restore time and event id (21.91704ms)
✔ a task closed on the tracker leaves the open list, and total counts the open ones (21.469025ms)
✔ a tombstone is not open (22.705282ms)
✔ the open list stops at the cap and says so; total still counts every open task (30.531354ms)
✔ a role and a reader session read state; arguments, and an unbound cap, refuse (22.61769ms)
✔ creates private WAL store and excludes a second writer until explicit close (21.22909ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (23.375799ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (24.087794ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (80.380382ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (16.980031ms)
✔ async transactions refuse before invoking their function (12.165568ms)
✔ the session default fits under the reply cap and pages to the end with next (210.151753ms)
✔ the short form: no description, assignees by role, every listed key (101.057075ms)
✔ each filter, alone and together, and total counts every match (170.585166ms)
✔ task_ref reads one task in full, as the whole board has it (95.139805ms)
✔ no arguments is the read the Console and mosaic make, unchanged (78.859592ms)
✔ an unknown argument or a bad value refuses invalid-request (327.211074ms)
✔ tasks rows carry priority and requirement for every session that reads tasks (62.480313ms)
✔ the read model rebuilds from the ledger: a reopened bus and each trail give the same values (62.965212ms)
✔ an empty business and one with snapshots but no task.created read cleanly (16.835199ms)
✔ recordTask keeps sync reads and a role write apart (33.465124ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (12.037918ms)
✔ a bad entry refuses the whole record (12.462407ms)
✔ taskView reads the projection for one business (15.861849ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (35.14223ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (281.596722ms)
✔ without an adapter the server refuses every task verb (17.498175ms)
✔ the runtime refuses an invalid adapter and closes a valid one (18.80034ms)
✔ the process loads the S3 adapter from plain-data trackers (114.455343ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (42.344319ms)
✔ two wire claims serialize; a lost reply never automatically retries (28.837779ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (20.272911ms)
✔ client preserves UTF-8 when a response divides a multibyte character (14.60498ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (17.674993ms)
ℹ tests 134
ℹ suites 0
ℹ pass 134
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1259.645185
@@ -0,0 +1,87 @@
✔ sessionModel: agent vars win, then the system's execution settings (12.417381ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.775936ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.193608ms)
✔ the prompt's built-in tools line is the harness's own names, as the manifest lists them (12.569584ms)
✔ the prompt names the project clones in the workspace, and none when there are none (9.16228ms)
✔ the prompt explains the review loop to a role with push_working, and reviews to one with fetch_review (10.201009ms)
✔ a bundle is written once: an existing file refuses (2.631683ms)
✔ a path with a single quote can't go into the hook command (2.440308ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (153.578839ms)
✔ a missing or wrong policy, or a bad event, exits 2 (95.04587ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1120.585558ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (898.889587ms)
✔ claude: the hook alone blocks a path outside the workspace (622.876249ms)
✔ claude: a second turn resumes the first turn's session (979.48539ms)
✔ claude adapter: --restricted is always passed (7.782982ms)
✔ claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable (30.908728ms)
✔ claude adapter: a directory pin exits 2 before it runs; a pin with a space runs (10.149509ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (1244.844694ms)
✔ claude: a missing hook or MCP file refuses before claude starts (13.654284ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.589988ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.054042ms)
✔ file tool paths must resolve inside the workspace (2.167273ms)
✔ pi's own path normalisation can't be used to step out (1.5888ms)
✔ a symlink inside the workspace that points out is outside (1.29361ms)
✔ a dangling symlink is refused at any depth, in both harnesses (4.585817ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (21.533924ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.312075ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (6.537201ms)
✔ claude path fields per tool (1.068644ms)
✔ glob patterns stay inside the workspace (0.989193ms)
✔ a path that can't be checked is blocked (0.54534ms)
✔ fetch_review's have lists the working refs before origin's, and no more than HAVE_MAX (281.931805ms)
✔ fetch_review's have orders the working refs newest first within each clone, one clone after another (199.917049ms)
✔ a runner git past its time: SIGTERM reaches its process group, and SIGKILL follows after the grace period (1810.425036ms)
✔ once a signalled runner git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too (305.978604ms)
✔ initialize, ping and tools/list (56.659114ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (38.511849ms)
✔ replies are compact JSON; over the cap a tool error names the size and the cap (38.453315ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (35.634665ms)
✔ a missing argument is a usage error (34.545934ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (478.514817ms)
✔ pi: a reply over the cap is a tool error naming the size and the cap; list_tasks asks for open tasks (398.769327ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (473.025619ms)
✔ pi: a read is refused when pi would open another spelling outside (492.946075ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (408.34998ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (505.304754ms)
✔ pi: a missing extension refuses before any model call (10.805917ms)
✔ pi: an extension without its configuration fails pi's start (379.830008ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.385847ms)
✔ turnRequest names the sender, class, reply and decision (0.237243ms)
✔ turnRequest shows the message's own sent time, a day old as well as new (0.145817ms)
✔ stateBlock names the launch state with its time and event, and the open tasks with the cap (0.250355ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (194.770986ms)
✔ launches revoked and then restored: the turn's block reads allowed and gives the restore time (201.810113ms)
✔ a task closed on the tracker is missing from the next turn's open list (199.027864ms)
✔ a message read a day after it was sent shows the time it was sent (118.425302ms)
✔ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read (65.822541ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (60.525033ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (331.868858ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1291.178798ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (132.669006ms)
✔ founder credentials stop before the claim (20) (126.496724ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (134.206179ms)
✔ the launch ending under a running session exits 22 (131.363335ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (169.977483ms)
✔ a broker that is down at the claim exits 23, not 21 (71.616218ms)
✔ no capability, or a malformed one, on stdin exits 2 (242.257316ms)
✔ a missing or malformed policy exits 2 before the claim (160.454735ms)
✔ the PM gets launch, its task verbs and the reads (9.750552ms)
✔ list_requirements reads the requirement ids, for a task.create holder with a tracker (4.999651ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.97782ms)
✔ a coder with a tracker gets its own task tools and no task_reassign (1.807452ms)
✔ launch only when the business's launch block names the instance as launcher (2.467031ms)
✔ an action outside the instance's authority has no tool (2.321265ms)
✔ the review loop: push_working and request_review for the coder, fetch_review and review_verdict for the reviewer, none for the PM (7.019703ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.392769ms)
✔ list_tasks takes the broker's filters and pages; get_task takes a task_ref (3.6841ms)
✔ callTool sends list_tasks done false unless the session names done; other tools as given (3.669431ms)
✔ replyText: compact JSON up to the cap, an error naming the size and the cap over it (1.135804ms)
ℹ tests 79
ℹ suites 0
ℹ pass 79
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10721.559659
@@ -0,0 +1,166 @@
✔ F1: an ordinary commit after update-ref is refused until step 8; then it commits and the queue stays at C (1331.537794ms)
✔ F1: a plain `commit -e` whose guard ran before update-ref fails at its own HEAD update (1040.166322ms)
ℹ git commit -e: index.lock free during the editor
✔ F1: a `commit -e -- path` whose guard ran before update-ref fails at its own HEAD update (1036.333657ms)
ℹ git commit -e -- src.txt: index.lock held during the editor
✔ F1: step 8 with index.lock held exits 3, and ordinary commits stay refused until the printed command runs (1078.962776ms)
✔ F1: HEAD moving after the step-7 guard check and before update-ref: refused, nothing published (1029.985274ms)
✔ F1: H is recorded before the canary, so HEAD moving during the step-1 canary is refused at step 7 (1009.630991ms)
✔ F1: a queue commit landing after H is recorded: step 1 says HEAD moved, not the guard (1307.844422ms)
✔ F1: a queue commit landing between the HEAD check and the canary: the failed clean run is reported as HEAD moved (908.42089ms)
✔ F1: a shared-index change during the procedure is not committed (1016.921274ms)
✔ F1: a queue path staged after update-ref: step 8 stops and touches nothing (995.733784ms)
✔ F1: a missing or a different hook refuses (701.787272ms)
✔ F1: same bytes without the exec bit, a symlinked hook, and core.hooksPath in the local or global scope each refuse before update-ref (1169.662432ms)
✔ F1: the canary refuses a hook that git would not run (604.167816ms)
✔ F1: the guard deactivated after step 1 is refused at the step-7 recheck (862.581163ms)
✔ bootstrap: implementation-only HEAD, the guard, genesis, the --genesis commit, then an extending commit (883.291923ms)
✔ bootstrap: --genesis with a base present, no base without --genesis, an op before the first commit, a changed map, another branch (893.86907ms)
✔ bootstrap: the archived tests and validator run outside any repository (852.213168ms)
✔ general: an unrelated staged file stays staged, and the committed blobs are the snapshot bytes (846.129531ms)
✔ general: a queue write after the snapshot is not committed (1207.54959ms)
✔ general: a snapshot whose log does not extend the base refuses (908.263093ms)
✔ general: install-hook privilege, repair of a same-bytes hook, and its refusals (177.738465ms)
✔ general: environment overrides, a linked worktree and usage (559.853207ms)
✔ general: a queue path staged before the run refuses at step 1 (626.879303ms)
✔ general: HEAD's queue tests failing in the archive refuse (803.167551ms)
✔ genesis document serializes deterministically and replays (5.417804ms)
✔ a hand edit that stays valid JSON fails replay; a formatting-only edit fails re-serialization (2.706153ms)
✔ a tampered result, receipt or viewSha fails replay (3.234876ms)
✔ op ids: 8 to 72 characters for callers, 80 in the log for .outcome entries (0.256698ms)
✔ add: defaults for an ordinary seat, privileged extras, refusals (4.916827ms)
✔ matrix: queued→briefed privileged; briefed→in-progress owner with after satisfied (7.42298ms)
✔ matrix: release, review round, changes requested and waiting-on-jason (17.817401ms)
✔ matrix J5: in-review→done by the gate owner with evidence naming the current round (11.320296ms)
✔ review issue, lead decision 23: none refuses, one is used, several need --issue, later rounds keep it (26.418251ms)
✔ the row schema refuses a round with a null issue, and the A1 review shape (P2) (3.58877ms)
✔ matrix R1: every state × target × actor class matches 8.7, gate owner jason or not, required or not (1595.202608ms)
✔ matrix: blocked keeps the claim and returns only to previousState (4.237519ms)
✔ matrix J4: parking is Jason's, refused while required; unpark returns to queued (2.783084ms)
✔ field edits: who may change what (5.405367ms)
✔ set issues keeps a logged narrowing of closes (N10) (2.929429ms)
✔ text the table shows refuses \ and <, everywhere it enters (N8) (2.096091ms)
✔ every accepted text renders to nine cells on every row (N8) (24.693365ms)
✔ genesis: the map refuses an owner among its row's reviewers; replay doesn't (2026-10-04) (0.614657ms)
✔ times and dates must be calendar values, not just the shape (2026-10-04) (1.724399ms)
✔ replay holds every op id to the caller's rule (N11) (5.456925ms)
✔ note: owner, listed reviewer or privileged; empty clears (1.553456ms)
✔ assign moves the claim with the owner; done clears it (2.816043ms)
✔ render is byte-stable and escapes pipes (0.527543ms)
✔ view classification: current, genuine stale, edited stale marker, changed current body, markers (0.650843ms)
✔ next: resume, then review, then start, then wait, then nothing; lowest id first (18.726734ms)
✔ canonical args make a retry's identity independent of list order (0.342479ms)
✔ manifests, headings and blob ids (0.45433ms)
✔ the migration map: one queue-map block, exact keys (0.64276ms)
✔ every call but `queue` reaches the seat CLI exactly as before A2 (672.149433ms)
✔ no argument reaches the app entry in packages/cli, and only that call (77.444955ms)
✔ `queue` reaches the queue CLI with the rest of the arguments (210.095797ms)
✔ the pre-A2 fixture is the script A2 changed (0.312678ms)
✔ an old privileged add with no gate owner still replays through loadDoc, and gets jason (8.892933ms)
✔ a non-privileged add gets jason through the replay path and can't name a gate owner (3.365067ms)
✔ the CLI refuses a privileged add without --gate-owner, for jason and sage, and writes nothing (285.866234ms)
✔ the CLI add with --gate-owner carries the named owner and records a log entry (215.649063ms)
✔ the CLI add by a non-privileged seat gets jason and can't name a gate owner (262.213362ms)
✔ a retried sage add recorded with no gate owner answers (already recorded at rev N) and writes nothing (260.821426ms)
✔ acquire publishes the record by link; release removes only its own lock (6.854666ms)
✔ a kill between the temp write and the link leaves no lock (55.939657ms)
✔ a short or failed temp write refuses and leaves no lock and no temp (3.138078ms)
✔ a link error other than EEXIST refuses (1.76434ms)
✔ an error after the link releases the lock: unreadable gate, failing temp stat (4.523265ms)
✔ a release that fails on a gate path is reported, never a stack trace (P1) (5.276907ms)
✔ a paused holder: another writer waits 10 s, then refuses naming it live (10072.141373ms)
✔ two concurrent unlockers: the second refuses on the gate (23.173044ms)
✔ a writer publishing during an unlock, lock first: unlock sees it live and refuses (1.816655ms)
✔ a writer publishing during an unlock, gate first: the writer releases and refuses (1.682757ms)
✔ a gate swapped while held is left in place and reported, on success and on refusal (N1) (1.896234ms)
✔ a reused pid within one boot is mismatch; unlock removes the lock and never signals the process (3.512083ms)
✔ the same pid and start on a different boot is mismatch (0.360087ms)
✔ a foreign host is unknown whatever the local pid says; unlock refuses (48.27042ms)
✔ unreadable /proc: classification is unknown and acquire refuses (0.703917ms)
✔ invalid records: empty, unparsable, wrong keys, bad start or boot (0.220087ms)
✔ a stale gate blocks writers; --check-gate says mismatch for a reused pid (4.406312ms)
✔ a delayed release by a dead owner, after unlock and a new owner: the inode check keeps the new lock (2.295958ms)
✔ release checks the inode too: a byte-identical lock file with a new inode is left in place (0.932111ms)
✔ unlock refuses a live, unknown or invalid lock, and does nothing without one (3.308213ms)
✔ the migration map validates and renders the golden genesis table (5.787879ms)
✔ the marked QUEUE.md holds every row and parked item between its markers (2.211878ms)
✔ map-check reports each kind of drift (5.877366ms)
✔ a request posts once as the requester; a retry sends nothing (685.874397ms)
✔ each transport answer maps to posted, failed or uncertain (8.9 step 3) (3329.01943ms)
✔ the pre-send checks: GET user must name the requester, under the deadline (1040.62195ms)
✔ the lead's request refuses a token for login sage (556.54807ms)
✔ the credential file: the seat's own, 0600, no symlink, never the shared default (470.212133ms)
✔ an unresolved request blocks a new request, a new round, waiting-on-jason and done (1485.004114ms)
✔ a same-op retry after a kill sends nothing, even with a stale view (2261.101072ms)
✔ a held lock at the outcome exits 3 and names what the transport said (558.825927ms)
✔ late outcomes: after an abandon, and after a resolve with the same or another id (1499.434114ms)
✔ resolve checks the comment: issue, markers, round, candidate and author (1536.428058ms)
✔ the lead resolves a seat's request: the comment's author is the requester, fetched with the lead's token (486.17057ms)
✔ validateRow checks a request round's shape, which every replayed entry must keep (347.76106ms)
✔ request, changes, a new candidate, approval: every round pinned; no review files (1401.680358ms)
✔ a row with no reviewers opens a round that posts nothing (833.544903ms)
✔ verify-commit: a prospective tree must hold exactly the candidate's paths (1149.158313ms)
✔ semantics: v1 entries replay as before; review entries need v2 (336.448283ms)
✔ set reviewers refuses the row's owner (2026-09-28) (255.893084ms)
✔ the owner records no verdict, even as a listed reviewer (349.043307ms)
✔ a request comment over the length limit is not sent (371.761724ms)
✔ a late POST on a closed row leaves a conflict nothing can resolve, and resolve asks nothing (396.978823ms)
✔ a Jason-gated row reaches waiting-on-jason only on every reviewer's approval (1795.737561ms)
✔ genesis: refusals before anything is written (496.110406ms)
✔ genesis: the map must be committed, well formed, with committed briefs and seats (655.508284ms)
✔ genesis: markers, a stray witness, once only; a retry returns the receipt (549.119088ms)
✔ genesis: a file holding genesis alone with no witness is confirmed by sync or a retry (317.370597ms)
✔ canonical checks: worktree, second clone, detached HEAD, wrong branch, GIT_DIR, foreign code; a symlink works (629.929622ms)
✔ --by that differs from MOSAIC_AGENT_NAME warns on stderr and logs nothing more (N12) (577.934068ms)
✔ op ids: missing, too long, reserved; a retry answers; another payload refuses (633.221973ms)
✔ a retried add returns the id it first allocated, after reassignment and after done (670.461078ms)
✔ Rocko's S4 schedule: a lost result, another writer, then the retry opens no second round (453.67716ms)
✔ the review issue and the evidence round through the CLI (lead decision 23, 8.7) (888.043954ms)
✔ claims and add defaults through the CLI; candidates are manifests or reachable commits (609.035102ms)
✔ add, set reviewers and assign refuse the row's owner as a reviewer (384.942062ms)
✔ the working-brief check: a changed working copy refuses the start and flags next (558.05983ms)
✔ next: resume first, then nothing for an idle seat; needs a seat (283.289384ms)
✔ view stale: new ops and verify refuse naming the unshown op; retries answer; reads warn; render fixes (580.917293ms)
✔ view unknown: a hand edit, an old marker over an edited body, missing or duplicate markers (1029.920152ms)
✔ a hand edit to queue.json refuses every verb, reads included (529.36113ms)
✔ verify and render --check leave bytes and mtimes unchanged (392.789035ms)
✔ render is byte-stable across runs and repositories (238.578069ms)
✔ snapshot and verify --snapshot (689.489097ms)
✔ usage errors exit 4 (534.162651ms)
✔ a short write, ENOSPC or a file fsync failure: nothing visible, temp removed (220.128937ms)
✔ a rename failure: nothing visible, temp removed (155.157612ms)
✔ a directory fsync failure: uncertain, exit 3, no receipt; the tail refuses new ops; a retry confirms (192.032261ms)
✔ a directory fsync failure, then sync names the op (340.351152ms)
✔ a witness write failure: uncertain, durable, exit 3; the view is untouched (148.386967ms)
✔ the .git fsync after the witness rename fails: uncertain, exit 3, the witness says so (132.830175ms)
✔ confirming a tail fsyncs queue.json and docs/plans before the witness; either failure changes nothing (172.902304ms)
✔ the docs/plans fsync after the view rename fails: the op stands, the view is written, a warning says so (133.544513ms)
✔ a lock swapped while held is left in place and reported, on a receipt and on a refusal (141.93407ms)
✔ a lock that cannot be released after an op is reported, on a receipt and on a refusal (144.003935ms)
✔ unlock prints a swapped gate's warning on stderr, the result on stdout (122.309735ms)
✔ a view write that fails keeps the op and reports a stale view (108.058779ms)
✔ SIGKILL before the rename: nothing recorded; the retry removes the leftover temp (574.209053ms)
✔ SIGKILL after the rename, before the witness: the tail refuses new ops and sync names the op (630.590549ms)
✔ SIGKILL after the witness, before the view: the stale refusal names the op (579.42521ms)
✔ SIGKILL after the view, before the receipt: the retry returns the receipt (606.861953ms)
✔ git checkout between steps 1 and 7: step 7 refuses and nothing is written (140.748903ms)
✔ git stash restoring an older valid pair: history lost; accept-history needs privilege, a reason and --yes (837.169137ms)
✔ a deleted witness: refused after the locked recheck; accept-history records it absent (208.102677ms)
✔ a header edit during a write: the op stands, the view write is skipped with a warning (137.985522ms)
✔ a reader paused between the witness and the file while a writer finishes: no lost-history report (127.028152ms)
✔ rows: every row as show prints it, in list order; writes nothing; same notes and refusals as list (173.306074ms)
✔ file-then-witness order forced by a hook: the locked recheck prevents a false report (123.782197ms)
✔ a writer paused before and after the witness rename: readers see a tail, then a match (135.502219ms)
✔ a true rollback is reported only after the locked recheck; a held lock names its holder instead (491.896817ms)
✔ an accept-history in progress: an unlocked reader waits on the lock and never reports lost history (461.117436ms)
✔ the platform check refuses other filesystems (116.02771ms)
✔ tmpfs passes only a test layer that allows it (N5) (146.043828ms)
✔ unlock keeps a multi-line lock record on stdout (P3) (169.982853ms)
ℹ tests 156
ℹ suites 0
ℹ pass 156
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 21929.78614
@@ -0,0 +1,17 @@
OK status with missing harness credential exits 3 and still lists accounts
OK status reports harness credential (read-only) + mosaic accounts
OK api key material never reaches output
OK oauth token material never reaches output
OK unparseable credential file exits 2
OK symlinked credential file exits 4
OK env-side credential names reported
OK env var values never reach output
OK accounts without an accounts dir reports none and creates nothing
OK accounts lists files and marks the active one
OK loose account perms flagged in listing
OK agent --auth with missing account file refuses (exit 4)
OK agent --auth with non-0600 account file refuses
OK agent --auth with invalid account name refuses
OK auth.sh without valid config refuses
selftest: 15 passed, 0 failed
@@ -0,0 +1,23 @@
On branch refactor
Your branch is up to date with 'origin/refactor'.
nothing to commit, working tree clean
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
OK dry-run committed nothing
OK apply: allowed change exits 0 (exit 0)
OK apply: attribution in commit subject
OK apply: target tree clean after commit
OK disallowed path refused (exit 1)
OK disallowed path: target untouched
OK syntax gate refused broken .mjs (exit 1)
OK syntax gate: target untouched
OK suite failure refused (exit 1)
OK suite failure: target reverted to clean
OK disabled policy refused (exit 2)
OK disabled policy: target untouched
OK failed run refused (exit 1)
OK failed run: target untouched
OK missing run exits 4 (exit 4)
OK invalid policy exits 2 (exit 2)
selftest: 17 passed, 0 failed
@@ -0,0 +1,26 @@
OK absent adapter defaults to pi
OK adapter mock validates (exit 0)
OK unsupported adapter exits 2 (exit 2)
OK env exports adapter
OK bootstrap creates default when absent (exit 0)
OK bootstrap wrote config file
OK bootstrap is idempotent on existing config (exit 0)
OK bootstrap did not rewrite existing config
OK validate missing config exits 3 (exit 3)
OK malformed JSON exits 2 (exit 2)
OK unsupported configVersion exits 2 (exit 2)
OK unknown top-level key exits 2 (exit 2)
OK unknown execution key exits 2 (exit 2)
OK unsupported backend exits 2 (exit 2)
OK unsupported environment exits 2 (exit 2)
OK relative dataRoot exits 2 (exit 2)
OK non-canonical dataRoot exits 2 (exit 2)
OK filesystem root dataRoot exits 2 (exit 2)
OK home directory dataRoot exits 2 (exit 2)
OK dataRoot containing config dir exits 2 (exit 2)
OK control character in provider exits 2 (exit 2)
OK symlinked config file exits 2 (exit 2)
OK env exports resolve correctly
OK failed validation modified nothing
selftest: 24 passed, 0 failed
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,21 @@
OK initial ordinary-file install
OK installed tree matches canonical source
OK installed tree has no symlinks
OK check detects installation drift
OK sync refuses to overwrite installation drift
OK check detects an extra destination file
OK check detects an extra destination directory
OK check rejects a destination symlink
OK sync accepts a canonical source update
OK updated installation matches canonical source
scripts/test-extension-package.sh: line 14: 3879204 Killed "$@" > /dev/null 2>&1
OK forced interruption kills the replacing process
OK next invocation recovers old consistent installation
OK interrupted replacement rolled back
OK sync succeeds after interruption recovery
OK unlocked stale lock file does not block
OK active lock refuses a concurrent sync
OK source symlink fails closed
OK nested second entrypoint fails closed
extension package selftest: 18 passed, 0 failed
@@ -0,0 +1,53 @@
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
OK syntax: scripts/foundation-inspect.mjs
OK syntax: scripts/foundation/strict-json.mjs
OK syntax: scripts/foundation/canonical.mjs
OK syntax: scripts/foundation/resolve.mjs
OK syntax: scripts/foundation/validate-record.mjs
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
OK syntax: scripts/foundation/canonical.test.mjs
OK syntax: scripts/foundation/cli.test.mjs
OK syntax: scripts/foundation/fixtures.test.mjs
OK syntax: scripts/foundation/resolve.test.mjs
OK syntax: scripts/foundation/strict-json.test.mjs
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
OK fixture generator runs
OK checked-in fixtures/bundles equal a fresh generation
OK checked-in fixtures/raw equal a fresh generation
OK checked-in fixtures/index.json equal a fresh generation
OK checked-in demo bundles equal a fresh generation
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test scripts/foundation/ (ℹ pass 80)
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
OK oracle: zero schema-column disagreements with the pinned checker
OK oracle: strict-only profile refusals are counted and asserted
OK demo: permitted read preview exits 0 (exit 0)
OK demo: permitted file.change preview exits 0 (exit 0)
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
OK demo: revoked registration is refused (exit 3) (exit 3)
OK demo: message is not authority (exit 3) (exit 3)
OK usage: no arguments exits 2 (exit 2)
OK io: missing file exits 4 (exit 4)
OK io: directory exits 4 (exit 4)
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
OK bound: oversize fixture exits 2 (exit 2)
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
OK text output starts with the disclaimer
OK json output is valid JSON with result allowed and exactly the charter §7 fields
OK json golden matches byte-for-byte
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
OK canary never printed (bundle run and credential-file run)
OK a non-bundle JSON file is refused at the shape gate, not read into output
OK no field of the non-bundle file is echoed
selftest: 44 passed, 0 failed
@@ -0,0 +1,36 @@
toolchain: node v26.8.1, git version 2.55.0
OK syntax: packages/queue/src/cli.mjs
OK syntax: packages/queue/src/errors.mjs
OK syntax: packages/queue/src/io.mjs
OK syntax: packages/queue/src/lock.mjs
OK syntax: packages/queue/src/queue.mjs
OK syntax: packages/queue/src/review.mjs
OK syntax: packages/queue/src/store.mjs
OK syntax: packages/queue/tests/commit.test.mjs
OK syntax: packages/queue/tests/data.test.mjs
OK syntax: packages/queue/tests/dispatch.test.mjs
OK syntax: packages/queue/tests/gate-owner.test.mjs
OK syntax: packages/queue/tests/helpers.mjs
OK syntax: packages/queue/tests/lock.test.mjs
OK syntax: packages/queue/tests/migration.test.mjs
OK syntax: packages/queue/tests/review.test.mjs
OK syntax: packages/queue/tests/store.test.mjs
OK syntax: packages/queue/tests/write.test.mjs
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
OK syntax: scripts/queue-commit.sh
OK syntax: scripts/git-hooks/pre-commit
OK syntax: scripts/mosaic
OK queue-commit.sh, the guard and scripts/mosaic are executable
OK packages/queue declares no dependencies
ℹ tests 156
ℹ pass 156
ℹ fail 0
OK node --test packages/queue/tests/
OK scripts/mosaic queue help
skip queue verify and render --check: this checkout (/mnt/storage/scratch/researcher/q109) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
queue suite: 28 passed, 0 failed
@@ -0,0 +1,16 @@
OK valid RELEASE resolves (exit 0)
OK invalid RELEASE exits 1 (exit 1)
OK missing RELEASE exits 1 (exit 1)
OK valid RELEASE leaves image tag consistent with version
OK status safe on empty state (exit 0)
OK status created no pointer
OK fault-injected activation refuses (exit 1)
OK refused activation wrote no pointer
OK refusal logged exactly once with valid fields
OK healthy activation succeeds (exit 0)
OK pointer written with valid fields
OK repeat activation succeeds (log grows) (exit 0)
OK log is append-only across activations
OK rollback without previous refuses (exit 1)
selftest: 14 passed, 0 failed
@@ -0,0 +1,101 @@
OK valid task validates (exit 0)
OK unknown task key exits 2 (exit 2)
OK unsupported taskVersion exits 2 (exit 2)
OK invalid task id exits 2 (exit 2)
OK empty prompt exits 2 (exit 2)
OK NUL in expectExact exits 2 (exit 2)
OK out-of-range timeout exits 2 (exit 2)
OK missing mission file exits 4 (exit 4)
OK task with valid mission validates (exit 0)
OK invalid mission exits 2 (exit 2)
OK validate missing task exits 4 (exit 4)
OK validation does not modify the task file
OK prune dry-run exits 0 (exit 0)
OK dry-run deleted nothing
OK prune --keep=2 --yes removes oldest (exit 0)
OK kept exactly 2 newest runs
OK newest run kept, oldest pruned
OK append-only receipt written (3 entries)
OK sessions/workspaces untouched by prune
OK prune with invalid keep exits 4 (exit 4)
OK mock adapter: gate passes on matching mock response (exit 0)
OK mock adapter: expect-mismatch recorded (exit 1)
OK mismatch reason recorded
OK unknown adapter fails closed (exit 1)
OK mission task runs via mock adapter (exit 0)
OK mission section injected into generated prompt
OK retry of mission run succeeds (exit 0)
OK retriedFrom lineage recorded
OK mission section present after retry (relative path resolved)
OK retry of missing run exits 4 (exit 4)
OK skill install bundled ms-tools (exit 0)
OK installed to skills-available
OK double install refuses (exit 1)
OK activate enables skill (exit 0)
OK enabled dir populated
OK uninstall while enabled refuses (exit 1)
OK deactivate moves back to available (exit 0)
OK uninstall removes from available (exit 0)
OK seat with enabled skill launches
OK skill path delivered to adapter
OK shipped researcher contract resolves (ceiling + network)
OK resolve-role refuses a non-role document (conductor policy) (exit 2)
OK resolve-role refuses name/filename mismatch (exit 2)
OK resolve-role refuses unknown network declaration (exit 2)
OK resolve-role refuses duplicate role tool (exit 2)
OK resolve-role refuses unsupported tool (exit 2)
OK resolve-role refuses missing contract file (exit 4)
OK version 1 role prints no contract line
OK shipped version 2 roles resolve with their contracts (pm, cto, coder, reviewer)
OK resolve-role accepts a minimal version 2 role (exit 0)
OK resolve-role refuses an action outside the vocabulary (exit 2)
OK resolve-role refuses a gated-only action in a role file (exit 2)
OK resolve-role refuses a Vikunja verb no role may hold (exit 2)
OK resolve-role refuses a version 2 role whose contract is missing (exit 2)
OK resolve-role refuses a contract that is a symbolic link (exit 2)
OK seat launches under role ceiling
OK role ceiling narrows seat tools (read,write,bash -> read,bash)
OK narrowing recorded loudly
OK missing role contract refuses launch (exit 2)
OK missing-contract refusal names the role
OK empty ceiling intersection -> tool-free seat
OK tool-free outcome recorded loudly
OK overridden agents dir without seat definition refuses (exit 4)
OK seat-resolution refusal names the seat
OK policy: mission only -> mission tools
OK policy: task only -> task tools
OK policy: both -> intersection (task narrowed)
OK policy: empty intersection -> tool-free
OK invalid mission capabilities rejected (exit 2)
mosaic-task: unsupported mission tool: "sudo" (supported: read, write, edit, bash, grep, find, ls)
OK task run with user layer present (exit 0)
OK user context dispatched into generated prompt
OK workspace+tools task runs via mock (exit 0)
OK workspace path + tools delivered to adapter
OK persistent workspace created on host
OK plain task still runs (no workspace/tools) (exit 0)
OK workspace var present but empty when absent
OK tools empty when absent
OK unknown tool exits 2 (exit 2)
OK workspace traversal exits 2 (exit 2)
OK live hello task succeeds with exact marker
OK result.json written in run dir
OK result.json contents are correct
OK wrong expectExact fails with exit 1 (reason: expect-mismatch)
OK each run gets a distinct run dir (no clobber)
OK list shows both runs
OK fork base: teach succeeds (exit 0)
OK fork child recalls ancestor context (exit 0)
OK forked child recalled ancestor code word
OK ancestor session untouched by fork
OK child session dir has its own branch file
OK onboard without name exits 4 (non-interactive) (exit 4)
OK onboard --name renders profile (exit 0)
OK profile written
OK canon structure: required filled, optional placeholdered
OK canon sections present
OK user recall run succeeds
OK recalled user name (response: Jason)
OK no agent identity on headless run
selftest: 98 passed, 0 failed