Compare commits

..
Author SHA1 Message Date
jason.woltje 6fdfe3790c gizmoduck: row 307 run logs: capture.mjs makes each fixture's data root before sessionStore (base session.mjs:78 needs it), BUILD.md expected red adds claude-history test B (duckworth-0287), steps 1-6 results on 1f6db1589 and 1f00d4f91 with skips named and M1-M4 killed (sage-3455, sage-3465) 2026-10-23 09:15:12 -05:00
jason.woltje 1f00d4f91e gizmoduck: row 307 BUILD.md: commits, old-test edits against mos-1459, new tests and the empty-session correction to test B, expected red on 1f6db1589, run plan with mos-1275 limits, mutants M1-M4 2026-10-23 08:27:32 -05:00
jason.woltje 44104adb19 gizmoduck: row 307 (#1814, Vikunja 598) harness kind gains compactions: pi's stream scan and Claude-history check move to packages/plugins/harness/pi/compactions.mjs, claude-code answers harness-unsupported, core keeps the ledger and checks each answer, readLedger reads the reason only, the launcher passes the loaded module; four old-test edits under mos-1459 2026-10-23 08:26:29 -05:00
jason.woltje 1f6db15899 gizmoduck: row 307 (#1814, Vikunja 598) new tests, red first: row 270's ledgers per reason read the same count and the next collection goes through the harness module, core refuses an answer out of contract, pi answers what base scanPi read, claude-code answers harness-unsupported, a module without compactions refuses missing-export; capture.mjs checks the literals against base 1e629c892 2026-10-23 08:26:29 -05:00
jason.woltje cb048b93ce gizmoduck: row 307 design r1 notes: seat suite whole and compactions-launch T3 run-not-skip in the gate (Duckworth C1, C2), launcher closure comment on bypassing call() (N1), line refs :301 and :302 (N2) (duckworth-0280) 2026-10-23 08:17:41 -05:00
jason.woltje 67daf64033 gizmoduck: row 307 (#1814, Vikunja 598) design: harness export compactions({ store, cursors, budget, streams, first }), interfaceVersion stays 1, core keeps the ledger, readLedger reads the reason, ledger compatibility table, four old-test edits for Mos (sage-3440) 2026-10-23 08:14:29 -05:00
jason.woltje 1e629c892d queue: revs 3565-3571: row 272 round 3 moves on 72690cae7 (huey 3565-3567) and request (sage 3569-3570, comment 31174); row 307 added and briefed (#1814, Vikunja 598, mos-1455 item 3; sage 3568, 3571); queue only 2026-10-23 08:08:15 -05:00
jason.woltje 84de3fbca3 plans: row 270 pi compaction reading moves behind the harness kind (#1814, Vikunja 598, mos-1455 item 3, Gizmoduck, reviewer Duckworth); gate template runs all five suites and sets MOSAIC_REQUIRE_EDITOR_BUILD=1 (mos-1455 item 4, mos-1453) 2026-10-23 08:02:15 -05:00
jason.woltje 1e4c1e6e94 queue: revs 3539-3564: rows 300 and 301 round 1 claims and in-review moves (launchpad 3539-3544), round 1 requests (sage 3545-3548), round 2 moves (launchpad 3549-3554), round 2 requests (sage 3555-3558), Duckworth approve records 300 (3559) and 301 (3560), landing notes and done at 07315e651 and 5b846b048 (sage 3561-3564); queue only 2026-10-23 07:33:26 -05:00
jason.woltje 5b846b048a launchpad: row 301 (#1808): library-cli and thread-claims bus.mjs fixtures export throwing busBusiness and userCaller stubs (mos-1451 under mos-1272); BUILD.md repaired with run 1 and the ruling; manifest seven paths; unrun 2026-10-23 07:26:44 -05:00
jason.woltje 59f0e44f47 launchpad: row 301 (#1808): channel-proof re-pins webui bus.mjs at 39fcb27c for the user transport fold, humanCall unchanged (mos-1449 under mos-1272); unrun 2026-10-23 07:26:44 -05:00
jason.woltje bb031ec840 launchpad: row 301 (#1808): R6-N4, user-bus.mjs folded into bus.mjs beside humanCall, unchanged; channel-proof re-pin held for Mos's go; unrun 2026-10-23 07:26:44 -05:00
jason.woltje 07315e6514 launchpad: row 300 (#1807): run 1 gate results in BUILD.md (1/0, webui 375/0, M12 killed) and Duckworth's N1; packet only 2026-10-23 07:26:44 -05:00
jason.woltje fb04cbc431 launchpad: row 300 (#1807): run 1 first-run red placed (mos-1272), the test's call helper resolves headers again as granted-user.test does; manifest re-hashed 2026-10-23 07:26:44 -05:00
jason.woltje 9d929911c8 launchpad: row 300 (#1807): R6-N1, an HTTP-level test of the user-session option swap with the userRoute gate stubbed open by a load hook, and mutant M12; unrun 2026-10-23 07:26:44 -05:00
jason.woltje bcf3c1dacf queue: revs 3531-3538: row 303 landing note and done at e551cd846 (sage 3531/3532); row 304 approve record (dewey 3533), row 305 approve record (filbert 3534); row 304 landing note and done at a863015a5 (sage 3535/3536); row 305 landing note and done at d52d9f1f2 (sage 3537/3538); queue only 2026-10-23 06:54:46 -05:00
jason.woltje d52d9f1f2f row 305 (#1812): gate run 1 evidence (sage-3374): both suites green, mutants a and b killed in both, c a known survivor 2026-10-23 06:49:49 -05:00
jason.woltje 830f3d8bff row 305 (#1812): BUILD.md notes the SIGKILL leak and its cleanup (Filbert N1) 2026-10-23 06:49:49 -05:00
jason.woltje e48d602165 row 305 (#1812): BUILD.md with the trace, change, gate and the three mos-1445 mutants 2026-10-23 06:49:49 -05:00
jason.woltje cabcb81d49 row 305 (#1812): each suite's network check also requires the network present under the run's project before the remove (mos-1445) 2026-10-23 06:49:49 -05:00
jason.woltje 353d65f577 row 305 (#1812, Vikunja 591) draft: per-run COMPOSE_PROJECT_NAME, exit trap removes that one network, one absent-network check per suite; for Mos through Sage before the build (mos-1272) 2026-10-23 06:49:49 -05:00
jason.woltje 2c289f6f0d SESSIONS: filbert row 305 round 1 approve (c31127, rev 3534) 2026-10-23 06:47:32 -05:00
Rocko a863015a51 test: match Claude memory slug normalization under mos-1442 2026-10-23 06:46:02 -05:00
jason.woltje e551cd8466 launchpad: row 303 (#1810): R9 asserts the rehearsal directory is set before the placeholders check joins it, so a null shows the rehearsal output (mos-1422, Vikunja 589) 2026-10-23 06:41:07 -05:00
58 changed files with 3514 additions and 295 deletions

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
# Row 305 (#1812, Vikunja 591): shell suites remove the compose network they create
Owner Gizmoduck. Reviewer Filbert. Gate owner Sage. Brief:
docs/plans/2026-10-23_gate-env-rows.md, "Brief: shell suites remove the compose network they create
(Vikunja 591)", at c16e943b9. Base c16e943b9. Mos approved the change in mos-1442 point 2. Mos approved
the two new checks in mos-1445 under mos-1272, adding the presence half.
## The leak, as the code has it at c16e943b9
- `packages/processing-control/src/containers.mjs:16-19`. `containerArgs` builds
`docker compose run --rm ...`. `--rm` removes the container. The project's `<project>_default`
network stays.
- `compose.yaml` has no top-level `name:`, so compose names the project after the checkout
directory. Every gate worktree left one network. By 10-23 they had used up Docker's address pools.
## Does COMPOSE_PROJECT_NAME reach compose? Yes, on every path the two suites take
- Task path. `scripts/run-task.sh:29` execs `node scripts/mosaic-task.mjs`. Nothing scrubs the env
before that: `scripts/common.sh` has no `env -i`, no `unset` and no COMPOSE. `mosaic-task.mjs:326`
sets `spawnEnv = { ...process.env }`, `:405` passes `env: spawnEnv` to `runContainer`, and
`containers.mjs:25` passes those options to `spawnSync('docker', ...)`.
- test-release. `release.sh activate` calls `health_check`, and `health_check` calls `run-task.sh`
at `:54`, which is the task path above. `env MOSAIC_MOCK_RESPONSE=...` adds to the env and keeps
the rest. The suite calls only `status`, `activate` and `rollback`, so `release.sh:122`
`compose build` is off its path.
- test-task seat cases (`:240`, `:315-351`). `agent.sh:209` execs
`packages/processing-control/src/container-cli.mjs`, and `container-cli.mjs:16` calls
`runContainer` with `env: process.env`.
- `build.sh:13`, `release.sh:122` and `verify.sh:40` also run compose. Neither suite calls them.
## Change (scripts/test-release.sh, scripts/test-task.sh only)
- After `SANDBOX="$(mktemp -d)"`, export
`COMPOSE_PROJECT_NAME=mosaic-test-<release|task>-<id>`. `<id>` is the sandbox name after its last
dot, lowercased, alphanumeric only.
- `remove_compose_network` runs `docker network rm "${COMPOSE_PROJECT_NAME}_default"`:
- If there's no docker binary, it returns 0.
- If the error says `not found` or `No such network`, it returns 0.
- On any other failure it prints one stderr line naming the network, then returns 0. That
includes a daemon that is down. Sage kept that line in sage-3367 (a).
- Each existing EXIT trap keeps everything it did and gains `; remove_compose_network` at the end.
The trap's own exit status doesn't change the script's.
- One new check per suite, just before the summary. That is after the last Docker step whether or
not the live cases ran. Both halves sit inside a `docker info` branch:
- the network is present under the run's project name before the explicit
`remove_compose_network` (this proves the export reached compose);
- after the remove, it is absent.
A failing `docker network ls` fails the check. Without the daemon, the check prints a skip line.
- No `docker compose down`, and no other network is touched. No product file changes.
Commits: 541dee2b7 (draft), 3bce852d5 (presence half, mos-1445).
## Gate (Sage's slot, in a gap)
- Before the first run, run `docker network rm` on a name that was never created and record the
daemon's exact wording (sage-3367 b). If the wording matches neither pattern, stop and report to
Sage.
- Each suite runs whole from a gate worktree, under `timeout -k 10 900`. That is far more than
3x the row 302 gate's walls: test-release 3 s, test-task 16 s (mos-1275).
- TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`.
- Before and after each run, record `docker network ls` filtered to `mosaic-test-`. The run's
network must be absent after.
- Grep `^skip` and name every skip.
- A run killed by the timeout's SIGKILL skips the EXIT trap and leaks its one network. The name is
per-run under `mosaic-test-`, so the gate's after-listing finds it and removes it by its exact name
(Filbert N1; no code change).
- If `docker info` works but there is no network to find, that's a first-run red. It goes to Sage
for Mos, unfixed (mos-1445).
## Mutants (mos-1445)
- (a) Drop the export. The new check must go red, because compose uses the directory's project and
nothing exists under the run's name.
- (b) Make the function's `docker network rm` a no-op. The new check must go red, because the
network is still present after the remove.
- (c) Drop `remove_compose_network` from the trap. This one is a known survivor: the explicit call
covers the normal path, and the trap covers early exits.
Each mutant runs on an isolated copy of the file. Restores are checked by sha256. After each mutant, the network it
leaves is removed by its exact name (mos-1438). Mutant (a) leaves the worktree directory's
`<dir>_default`, and mutant (b) leaves the run's `${COMPOSE_PROJECT_NAME}_default`.
@@ -0,0 +1,88 @@
start 2026-10-23T11:14:01Z HEAD 8ddba89b7 refactor b99ca86b9 docker 29.7.2
== wording
rc=1 stderr: Error response from daemon: network mosaic-test-absent-46829331_default not found
exit status 1
== green
TMPDIR /mnt/storage/scratch/tmp/gIj4
nets[before test-task]:
test-task rc=0 wall 16 s :: selftest: 92 passed, 0 failed
OK compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after test-task]:
TMPDIR /mnt/storage/scratch/tmp/gDYF
nets[before test-release]:
test-release rc=0 wall 4 s :: selftest: 15 passed, 0 failed
OK compose network made under the run's project and removed after the run
nets[after test-release]:
== mutants
Ma scripts/test-task.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g6SN
nets[before Ma-task]:
Ma-task rc=1 wall 16 s :: selftest: 91 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after Ma-task]: gizmo-305_default
restore ok scripts/test-task.sh
Ma-task KILLED
cleaned gizmo-305_default
nets[after cleanup Ma-task]:
Ma scripts/test-release.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g48Q
nets[before Ma-release]:
Ma-release rc=1 wall 3 s :: selftest: 14 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
nets[after Ma-release]: gizmo-305_default
restore ok scripts/test-release.sh
Ma-release KILLED
cleaned gizmo-305_default
nets[after cleanup Ma-release]:
Mb scripts/test-task.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/gcm1
nets[before Mb-task]:
Mb-task rc=1 wall 15 s :: selftest: 91 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after Mb-task]: mosaic-test-task-pf33rz8tic_default
restore ok scripts/test-task.sh
Mb-task KILLED
cleaned mosaic-test-task-pf33rz8tic_default
nets[after cleanup Mb-task]:
Mb scripts/test-release.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/gSxg
nets[before Mb-release]:
Mb-release rc=1 wall 3 s :: selftest: 14 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
nets[after Mb-release]: mosaic-test-release-db4p70csnf_default
restore ok scripts/test-release.sh
Mb-release KILLED
cleaned mosaic-test-release-db4p70csnf_default
nets[after cleanup Mb-release]:
Mc scripts/test-task.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g6WJ
nets[before Mc-task]:
Mc-task rc=0 wall 16 s :: selftest: 92 passed, 0 failed
OK compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after Mc-task]:
restore ok scripts/test-task.sh
Mc-task survived (known)
nets[after cleanup Mc-task]:
Mc scripts/test-release.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g2jz
nets[before Mc-release]:
Mc-release rc=0 wall 3 s :: selftest: 15 passed, 0 failed
OK compose network made under the run's project and removed after the run
nets[after Mc-release]:
restore ok scripts/test-release.sh
Mc-release survived (known)
nets[after cleanup Mc-release]:
tree clean after mutants
end 2026-10-23T11:15:17Z
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Row 305 gate (sage-3374): wording, both suites whole, mutants a/b/c with exact-name cleanup.
set -uo pipefail
WT=/mnt/storage/scratch/gizmo-305
OUT=/mnt/storage/scratch/gizmo-305-out/gate
mkdir -p "$OUT"
cd "$WT" || exit 9
[ "$(git rev-parse HEAD)" = 8ddba89b7442f09fc71cf24d7f250e40a7f60fdf ] || { echo "HEAD drift"; exit 9; }
[ -z "$(git status --porcelain)" ] || { echo "dirty tree"; exit 9; }
[ -L node_modules ] || ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules
[ -L node_modules ] || { echo "no node_modules link"; exit 8; }
DIRNET=gizmo-305_default
nets() { echo " nets[$1]: $(docker network ls --format '{{.Name}}' | grep -E '^(mosaic-test-|gizmo-305_)' | tr '\n' ' ')"; }
newtmp() { export TMPDIR="$(mktemp -d /mnt/storage/scratch/tmp/gXXX)"; echo " TMPDIR $TMPDIR"; }
run() { # name script
local name=$1 s=$2; newtmp; nets "before $name"
local t0=$SECONDS
timeout -k 10 900 "$s" > "$OUT/$name.log" 2>&1; local rc=$?
echo "$name rc=$rc wall $((SECONDS-t0)) s :: $(grep -E '^selftest:' "$OUT/$name.log")"
[ $rc -eq 124 ] || [ $rc -eq 137 ] && echo "SHELL-PHASE TIMEOUT (mos-1275): to Mos unplaced"
grep -E 'compose network' "$OUT/$name.log" | sed 's/^/ /'
grep -E '^FAIL|could not remove' "$OUT/$name.log" | sed 's/^/ red: /'
grep -E '^skip' "$OUT/$name.log" | sed 's/^/ skip: /'
nets "after $name"
LASTRC=$rc
}
echo "start $(date -u +%FT%TZ) HEAD $(git rev-parse --short HEAD) refactor $(git rev-parse --short origin/refactor) docker $(docker version --format '{{.Server.Version}}')"
echo "== wording"
ABS="mosaic-test-absent-$RANDOM$RANDOM"_default
docker network rm "$ABS" >/dev/null 2>"$OUT/absent.txt"; echo " rc=$? stderr: $(cat "$OUT/absent.txt")"
grep -qE 'not found|No such network' "$OUT/absent.txt" || { echo "STOP: wording matches neither pattern"; exit 3; }
echo "== green"
for s in task release; do run "test-$s" "scripts/test-$s.sh"; [ $LASTRC -eq 0 ] || { echo "STOP: red on green run"; exit 4; }; done
nets "after green" | grep -qE 'mosaic-test-|gizmo-305_' && { echo "STOP: network left after green"; exit 5; }
echo "== mutants"
mut() { # tag python-edit expected(red|green)
local tag=$1 edit=$2 want=$3
for s in task release; do
local f="scripts/test-$s.sh" sum; sum=$(sha256sum "$f" | cut -d' ' -f1)
cp -p "$f" "$OUT/$s.orig"
python3 -B -c "$edit" "$f" || { echo "mutant $tag edit failed"; exit 6; }
git diff --stat -- "$f" | head -1 | sed "s/^/ M$tag /"
run "M$tag-$s" "$f"
cp -p "$OUT/$s.orig" "$f"
[ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ] && echo " restore ok $f" || { echo "RESTORE MISMATCH $f"; exit 7; }
if [ "$want" = red ]; then grep -q '^FAIL.*compose network' "$OUT/M$tag-$s.log" && echo " M$tag-$s KILLED" || echo " M$tag-$s SURVIVED (unexpected)"
else grep -q '^OK.*compose network' "$OUT/M$tag-$s.log" && echo " M$tag-$s survived (known)" || echo " M$tag-$s unexpected red"; fi
# exact-name cleanup (mos-1438)
for n in $(docker network ls --format '{{.Name}}' | grep -E '^(mosaic-test-(task|release)-[a-z0-9]+_default|gizmo-305_default)$'); do
docker network rm "$n" >/dev/null && echo " cleaned $n"
done
nets "after cleanup M$tag-$s"
done
}
E='import sys;p=sys.argv[1];s=open(p).read();o=%s;n=%s;assert s.count(o)==1;open(p,"w").write(s.replace(o,n))'
mut a "$(printf "$E" "'export COMPOSE_PROJECT_NAME='" "'COMPOSE_PROJECT_NAME='")" red
mut b "$(printf "$E" "'err=\"\$(docker network rm \"\$net\" 2>&1 >/dev/null)\" && return 0'" "'err=\"\" && return 0'")" red
mut c "$(printf "$E" "'; remove_compose_network'\"'\"' EXIT'" "''\"'\"' EXIT'")" green
[ -z "$(git status --porcelain | grep -v '^?? node_modules$')" ] && echo "tree clean after mutants" || { echo "TREE DIRTY"; git status --porcelain; }
echo "end $(date -u +%FT%TZ)"
+180
View File
@@ -0,0 +1,180 @@
# Row 307 (#1814, Vikunja 598): move row 270's pi compaction reading behind the harness kind
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
docs/plans/2026-10-23_harness-kind-compactions.md. Base 1e629c892. Design: DESIGN.md beside this file,
approved (static) by Duckworth on 67daf6403 (duckworth-0280), with C1, C2, N1 and N2 folded in at
cb048b93c. Mos ruled go on the four old-test edits in mos-1459, with the bounds below.
## Commits
1. 1f6db1589, red first. The two new test files and capture.mjs. Its product files are base's:
`git diff --stat 1e629c892 1f6db1589` lists only this packet, capture.mjs and the two tests.
2. 44104adb1, the build. The product files and the four old-test edits.
## What the build does
- `packages/plugins/src/kinds.mjs:52-57`: the harness kind's exports end in `"compactions"`, and the
comment gains its contract line. `interfaceVersion` stays 1 (DESIGN.md "interfaceVersion stays 1").
- New `packages/plugins/harness/pi/compactions.mjs`: base's `scanPi`, `readRange`, `digest`, `STREAM`,
`TAIL`, the chunk size, `PI_VERSION` and `claudeHistory`, unchanged in behaviour, plus the export.
`scanPi` is no longer exported and takes no defaults: its one caller passes all four arguments.
- `pi/index.mjs` re-exports it. `claude-code/index.mjs` answers harness-unsupported with version null.
- `packages/seat/src/compactions.mjs`: keeps the ledger. `readLedger` sets `unsupported` on
`r.reason === "harness-unsupported"` only (:113). `checkAnswer` (:147-164) refuses an answer out of
contract. `collectCompactions(store, { name, compactions }, opts)` (:174-205) calls the export, checks
it, and writes the same lines in the same order as base.
- `packages/cli/src/launcher.mjs`: the `compactions` closure takes the loaded module and calls the
export directly, with the N1 comment on why it skips `call()`. Both call sites pass `harness`.
- `packages/plugins/README.md`: the Harness section gains the `compactions` bullet.
## Old-test edits (mos-1459 bounds)
| File | Edit | Bound |
| --- | --- | --- |
| plugins/tests/kinds.test.mjs | `"compactions"` appended to the pinned list | :17-21, append only |
| seat/tests/compactions.test.mjs | two module imports; `collect` passes `{ name, compactions }` | :11/:21, helper only |
| seat/tests/compactions-budget.test.mjs | pi import; `collect` passes `{ name, compactions }` | :13/:24-25, helper only |
| cli/tests/compactions-launch.test.mjs | pi import; base :344's call (now :345) passes `{ name: "pi", compactions: pi }` | call only, `.count === 1` kept |
No call or expected value in those files changed. harness.test.mjs, cli helpers.mjs:133-151,
launch-routes.test.mjs:17-23 and launcher.test.mjs:694-697 are unedited.
## New tests
- `packages/seat/tests/compactions-ledger.test.mjs`:
- FIXTURES: seven ledgers row 270's code writes (known, store-incomplete, store-unreadable,
not-collected, harness-unsupported from claude-code, harness-unsupported from Claude history, and
torn), each with its streams, projects and steps. Per fixture, test A reads `compactionCount` and
test B runs one collection through `{ name: "pi", compactions: pi.compactions }`.
- Test B runs over the fixture's own streams, not an empty session as DESIGN.md:189 says. Over an
empty session every fixture with a cursor would go store-incomplete (the stream went away), so
"the others go known" could not hold. The expected answers: known stays known (1), store-unreadable
goes known with 0 (the cursor is at the end), not-collected goes known with 1, and
store-incomplete, torn and both harness-unsupported fixtures stay unknown.
- The malformed-answer test (17 answers, plus a module that throws, over a fresh store and over the
known fixture) and the argument test (exact keys; core passes a copy of the cursors).
- `packages/plugins/tests/harness-compactions.test.mjs`: CASES plain, budget (100 bytes), cap (1
stream of 2), shrank and header against pi; pi's `first` with and without Claude history;
claude-code; and a module re-exporting every pi export but `compactions`, refused `missing-export`.
- capture.mjs reads both capture blocks from the test files. It replays each fixture with base's
`collectCompactions(store, "<name>", ...)` and compares the ledger byte for byte, and it runs base's
`scanPi` per case and compares the answer under base's reason order (:301 at base) and
`PI_VERSION`. It prints base's `compactionCount` per fixture: that is the before half.
## Expected red on 1f6db1589
- seat compactions-ledger: the seven test A pass (the ledger rules are base's). Test B fails for known,
store-unreadable and not-collected: base takes the object as a harness that isn't pi and writes
harness-unsupported. Test B passes on base for the other four, whose expected answer is unknown with
the same reason. The malformed-answer and argument tests fail: base never calls the module.
Correction after the run: test B fails on base for claude-history too, so the reds are known,
store-unreadable, not-collected and claude-history. On base the object harness takes :304, which
writes `collected` without a scan, so the count stays null. Pi on the tip scans on a later
collection, finds c1 and answers 1, still unknown with harness-unsupported. The test is right and
this list was wrong (Duckworth agreed, duckworth-0287).
- plugins harness-compactions: the five CASES, the `first` test and the claude-code test fail with a
TypeError (`compactions` isn't exported on base). The missing-export test fails: base's loader loads
the module.
- harness.test.mjs:192 is already red on base. That's the brief's starting point.
## Run plan (mos-1275, `timeout -k 10`)
All on a CLEAR from Sage. TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`. Gate worktrees
under /mnt/storage/scratch, node_modules linked right after `git worktree add`, pipefail and tee.
| Run | Longest recorded | LIMIT |
| --- | --- | --- |
| capture.mjs on a 1f6db1589 worktree (base and candidate both it) | none | 120 s |
| red first: the two new files on 1f6db1589, each whole | none (seat suite 7 s) | 300 s |
| the touched test files whole on 44104adb1, one at a time | compactions-launch alone: none; other files 15 s | 600 s |
| bus suite | 25 s (270 r5) | 900 s |
| cli suite | 682 s (270 r5) | 2100 s |
| plugins suite | none recorded | 900 s |
| webui suite | 58.2 s (294) | 600 s |
| business suite | none recorded | 900 s |
| seat suite whole, `node --test tests/` (Duckworth C1) | 7 s (270 r5) | 600 s |
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
| each mutant, its kill files whole | 1 s (264a probes) | 300 s |
- In the cli run, compactions-launch's T3 tests (base :206, and :305 with the :344 edit; :207, :306
and :345 after the import line) must show as run, not skipped on `sandboxProblem()` (base :36, now
:37). The report names every skip (Duckworth C2).
- Plugins must have zero failures.
- A timeout with no failed test is a hang and goes to Sage. A timeout in the shell phase goes to Mos
unplaced. A first-run red in a new 307 test is placed and reported after (mos-1272). A red in an old
test goes to Sage, unfixed.
## Mutants
Each on an isolated copy of the file, restore checked by sha256, only on a green base.
| # | Change | Kill files |
| --- | --- | --- |
| M1 | `readLedger`: `if (r.harness !== "pi" \|\| r.reason === "harness-unsupported")` restored | plugins harness.test.mjs (:192) |
| M2 | pi `compactions` drops the `first && claudeHistory(store)` branch | seat compactions-budget.test.mjs (base :86, now :87), plugins harness-compactions.test.mjs |
| M3 | `readLedger` never sets `unsupported` | seat compactions-ledger.test.mjs, seat compactions.test.mjs (base :197, now :199) |
| M4 | `collectCompactions` skips `checkAnswer` | seat compactions-ledger.test.mjs |
## Runs
Sage's CLEARs: sage-3455 for steps 1-3, sage-3465 for steps 4-6. Gate worktrees at
/mnt/storage/scratch/g307-base (1f6db1589), g307-tip and g307-mut (1f00d4f91), with node_modules
linked. TMPDIR was /mnt/storage/scratch/tmp/gTIK. Outputs went to /mnt/storage/scratch/gizmo-307-out/s1 and s2.
### Steps 1-3 (13:31:21Z-13:31:36Z, then the capture rerun at 13:32Z)
- capture.mjs crashed with ENOENT on its first run. Base `sessionStore` (session.mjs:78
`privateDirs`, called from :117) makes its directories one level at a time, and :44 passed a
per-fixture root that didn't exist yet. The fix is one line before :44,
`` mkdirSync(join(root, `f-${name}`)) ``. A copy with that line, run on g307-base as both base and
candidate, exited rc 0 and printed `all match`: 7 ledgers byte for byte, 7 base counts and 5 scans.
Base counts: known 1 known; store-incomplete, store-unreadable and not-collected null, unknown
with their own reason; claude-code and claude-history null, harness-unsupported; torn 1,
store-incomplete.
- Red first on 1f6db1589, both rc 1. Seat compactions-ledger: 9 pass, 7 fail (the corrected list
above, plus the malformed-answer and argument tests). Plugins harness-compactions: 0 pass, 8 fail.
Seven fail with `TypeError: pi.compactions is not a function` or `claude.compactions is not a
function`. The missing-export test fails because base loads the module.
- Touched files whole on 1f00d4f91, all rc 0: compactions-ledger 16/16, harness-compactions 8/8,
kinds 5/5, harness 9/9 (:192 green), compactions 20/20, compactions-budget 10/10,
compactions-launch 5/5 with 0 skipped.
### Steps 4-6 (14:00:10Z-14:14:00Z, on 1f00d4f91)
| Run | rc | Time | Result |
| --- | --- | --- | --- |
| bus | 0 | 24 s | 890 pass, 0 fail, 0 skipped |
| cli | 0 | 631 s | 927 pass, 0 fail, 0 skipped; T3 :207, :306, :345 ran |
| plugins | 0 | 0 s | 119 pass, 0 fail, 0 skipped |
| webui | 0 | 57 s | 374 pass, 0 fail, 1 skipped (below) |
| business | 0 | 2 s | 231 pass, 0 fail, 0 skipped |
| seat | 0 | 7 s | 218 pass, 0 fail, 0 skipped |
| test-auth | 0 | 0 s | 15 passed, 0 failed |
| test-conductor | 0 | 7 s | |
| test-config | 0 | 3 s | |
| test-discord | 0 | 7 s | |
| test-extension-package | 0 | 2 s | |
| test-foundation | 0 | 39 s | |
| test-land-work | 0 | 12 s | |
| test-queue | 0 | 20 s | one skip (below) |
| test-release | 0 | 3 s | 15 passed, 0 failed |
| test-task | 0 | 14 s | 92 passed, 0 failed, two skips (below) |
Skips:
- webui: "rebuilding from the source gives the same bytes" needs the pinned esbuild. 307 doesn't
touch the editor.
- test-queue: "queue verify and render --check", because the gate worktree isn't the queue's
canonical root.
- test-task: "live provider cases" twice (row 244, no provider call in the gate).
Mutants ran in g307-mut. Each restore matched its sha256, and the tree was clean after M4.
| # | Killed by |
| --- | --- |
| M1 | plugins harness.test.mjs:192 |
| M2 | seat compactions-budget.test.mjs:87 and :104; plugins harness-compactions.test.mjs:47 |
| M3 | seat compactions-ledger.test.mjs:123 (test B, claude-code and claude-history); seat compactions.test.mjs:199 |
| M4 | seat compactions-ledger.test.mjs:151 (the malformed-answer test) |
There were no reds, so nothing was placed under mos-1272. The sweep found no node or chromium left in
my cwds.
+241
View File
@@ -0,0 +1,241 @@
# Row 307 (#1814, Vikunja 598): row 270's pi compaction reading moves behind the harness kind
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
`docs/plans/2026-10-23_harness-kind-compactions.md` at 1e629c892, which is the base. Mos ruled the
move in mos-1455 item 3 and declined an EXCEPT entry.
## What is red, as the code has it at 1e629c892
`packages/plugins/tests/harness.test.mjs:192` greps core sources for a quoted harness name. It finds
three lines in `packages/seat/src/compactions.mjs`:
- `:232` in `readLedger`: `if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;`
- `:283` and `:287` in `collectCompactions`: the `harness === "pi"` branches.
Everything else row 270 put in that file is either harness-free (the ledger, the budget and stream
settings, `storeDir`, `compactionCount`) or pi-only (`scanPi`, `PI_VERSION`, `claudeHistory`, and
the helpers only `scanPi` uses: `readRange`, `digest`, `STREAM`, `TAIL`).
## The new export: `compactions`
`packages/plugins/src/kinds.mjs:54` appends `"compactions"` to the harness kind's exports, so the
loader refuses a module without it (`loader.mjs:61-66`, code `missing-export`).
```
compactions({ store, cursors, budget, streams, first })
-> { found, cursors, known, reason, version }
```
Arguments, all from core:
- `store` is `{ session, projects }`, the two native store directories `sessionStore` makes. The
module never gets `store.dir`, the directory that holds the ledger.
- `cursors` is a copy of the ledger's cursor map, `{ [stream]: { bytes, tail } }`.
- `budget` and `streams` are `readBudget(...)` and `readStreams(...)`, as the launcher passes them
now (`launcher.mjs:299`, `:305`). Core keeps both readers and their defaults.
- `first` is true when the store has no ledger yet (today's `!prior`).
The answer:
- `found`: `[{ entry, completedAt, stream }]`, the compactions read past the cursors. `entry` is a
non-empty string, `completedAt` a string or null, `stream` a non-empty string.
- `cursors`: `{ [stream]: { bytes, tail } }` for each stream the module read. `bytes` is a safe
integer of 0 or more and `tail` 64 lowercase hex.
- `reason`: null, `store-incomplete`, `store-unreadable`, `not-collected` or `harness-unsupported`.
- `known`: a boolean, true exactly when `reason` is null.
- `version`: the store format the module read against as a string, or null when it read nothing.
Core checks the answer before it writes. Anything else throws `harness <name> compactions: bad
answer: <what>` and nothing is appended. A throw from the module itself is the same case. Both reach
the launcher's existing catch (`launcher.mjs:313-316`), which logs the message and records the run's
`compactions` event as unknown with `store-unreadable`, as a scan that throws today does.
### interfaceVersion stays 1
- Every harness module lives in this tree, and the loader loads nothing else (decision 143,
`loader.mjs:1-5`). Both shipped modules gain the export in the same commit.
- A module without the export already refuses at load, as `missing-export` naming `compactions`.
That is the brief's third test. A bump would add a second refusal for the same fact.
- No existing export changes its arguments or answer. Row 125 moved limit-check to 2 because
`mayLaunch`, `mustStop` and `report` changed shape. Nothing like that happens here.
- `harness.test.mjs:36` pins `interfaceVersion` 1, and the brief keeps that file unchanged.
The tradeoff: a module someone wrote against version 1 without `compactions` reports
`missing-export`, not `interface-version`. The message names the export, which is the more useful of
the two.
## What moves where
New file `packages/plugins/harness/pi/compactions.mjs`, imported by `pi/index.mjs`. The loader
checks only `index.mjs` itself (`loader.mjs:42-54`), and claude-code's module already imports files
outside its directory (`claude-code/index.mjs:11-12`). It takes, unchanged in behaviour:
- `scanPi`, with `readRange`, `digest`, `STREAM`, `TAIL` and the chunk size it reads by;
- `PI_VERSION` ("0.85.1");
- `claudeHistory(store)`;
- the reason order `["store-incomplete", "store-unreadable", "not-collected"]` from `:301`;
- the header comment's Pi paragraphs (`compactions.mjs:12-19`, `:21-24`).
pi's `compactions`:
- `first` and `claudeHistory(store)`: answers `{ found: [], cursors: {}, known: false, reason:
"harness-unsupported", version: "0.85.1" }`. That is today's `:283-286`.
- otherwise: `scanPi(store.session, cursors, budget, streams)`, and answers its `found` and
`cursors` with the first reason in the order above, `known` when that's null, and version
"0.85.1". That is today's `:287-302` before the dedupe.
claude-code's `compactions` reads nothing and answers `{ found: [], cursors: {}, known: false,
reason: "harness-unsupported", version: null }`. That is today's `:303-304`. Vikunja 466 still has
no pinned Claude Code format.
## Core keeps the ledger
`packages/seat/src/compactions.mjs` keeps `LEDGER`, `READ_BYTES`, `STREAMS`, `readBudget`,
`readStreams`, `storeDir`, `readLedger`, `compactionCount`, the key and the single O_APPEND write.
`collectCompactions(store, harness, { now, budget, streams })` takes `harness = { name,
compactions }`, the name the route used and the loaded module's export. It:
1. reads the ledger (`prior`), as now;
2. calls `harness.compactions({ store: { session: store.session, projects: store.projects },
cursors: { ...ledger.cursors }, budget, streams, first: prior === null })` and checks the answer;
3. appends, in today's order: one `compaction` line per key the ledger doesn't hold yet, one
`cursor` line per stream whose cursor moved, then `{ v: 1, kind: "collected", at, known, reason,
harness: name, version }`. These are the same lines and field order as today's `:288-302` and
`:304`. `name` is a variable, never a literal;
4. returns `compactionCount(store.dir)`.
`readLedger`'s unsupported test becomes `r.reason === "harness-unsupported"`, a field every answer
carries. No core line names a harness. The core comments drop the quoted names too, because the
grep reads comments.
`packages/cli/src/launcher.mjs:309-318`: the `compactions` closure takes the loaded module too and
calls `collectCompactions(store, { name: model.harness, compactions: harness.compactions }, {
budget, streams })`. Both call sites, `:509` and `:650`, have `harness` in scope (it's
`startLocked`'s `let harness` from `:503`; the `exited.then` callback at `:632` closes over it).
The closure calls the export directly, not through `call()` (`launcher.mjs:196`), because `call()`
turns a throw into a run refusal. The closure gets a comment saying so (Duckworth N1).
`bus/src/runtime.mjs:282` and `broker.mjs:885-889` keep calling `compactionCount` and don't change.
`packages/plugins/README.md`, Harness section, and the comment at `kinds.mjs:35-51` each gain the
`compactions` line with the contract above.
## A ledger row 270 wrote reads the same count
Row 270's code writes a `collected` line in exactly three places:
| Line | harness | reason | version | old rule (`:232`) | new rule |
| --- | --- | --- | --- | --- | --- |
| `:286`, Claude history on the first pi collection | `"pi"` | `harness-unsupported` | "0.85.1" | unsupported | unsupported |
| `:302`, a pi scan | `"pi"` | null, `store-incomplete`, `store-unreadable` or `not-collected` | "0.85.1" | supported | supported |
| `:304`, any other harness name (`claude-code`, or `claude` in the tests) | that name | `harness-unsupported` | null | unsupported | unsupported |
The two rules agree on every line row 270 can write. The rest of `readLedger` (the `bad`, `torn`,
`incomplete` and `events` handling) and all of `compactionCount` don't change. So every ledger row
270 wrote reads the same `{ count, known, reason, at }` after the move. Nothing rewrites a ledger;
the next collection only appends.
The rules would differ only on a `collected` line naming a harness other than `"pi"` with a reason
other than `harness-unsupported`. Row 270 never writes one, and the launcher is the ledger's only
writer (`compactions.mjs:7-10`).
New collections after the move append the same bytes as before for the same store state. pi's
answer is today's scan result, core keeps the dedupe and the line format, and `harness` in the line
is still the route's name.
## Existing tests that change (to Mos through Sage, mos-1272)
Grepped for `interfaceVersion`, the harness exports, `write("harness"`, `fakeHarnesses` and
`collectCompactions(` in `packages/*/tests/*` and `scripts/`.
Four existing files change:
1. `packages/plugins/tests/kinds.test.mjs:17-21`: the pinned harness export list gains
`"compactions"` at the end.
2. `packages/seat/tests/compactions.test.mjs:11` and `:21`: import the two shipped modules. `setup`'s
`collect(harness = "pi")` passes `{ name: harness, compactions: MODULES[harness].compactions }`,
with `pi` mapped to the pi module and `claude` to claude-code's. The 27 `f.collect()` calls and
the two named ones at `:199` and `:201` stay as written.
3. `packages/seat/tests/compactions-budget.test.mjs:13` and `:24-25`: the same change in its
`collect`. Its calls at `:67-69` and `:129-134` stay as written.
4. `packages/cli/tests/compactions-launch.test.mjs:344`: the one direct
`collectCompactions(legacy, "pi")` becomes `collectCompactions(legacy, { name: "pi",
compactions: pi.compactions })`, with an import of the pi module.
Fixtures that build a harness module and need no edit, because they re-export a real module and
inherit `compactions`:
- `packages/cli/tests/helpers.mjs:133-151` `fakeHarnesses`, used by `compactions-launch`,
`idle-stop-race`, `launcher`, `lifecycle`, `session-clear`, `session-launch`, `thread-launch` and
`thread-revoke`;
- `packages/cli/tests/launch-routes.test.mjs:17-23`: `down`, `sparse`, `metered` and `throws`
re-export pi. `broken` lacks every export but `adapter` on purpose. The loader still refuses it at
`levels` first, so `:114` and `:138` (`missing-export`) hold;
- `packages/cli/tests/launcher.test.mjs:694-697`: `down` and `fixture` re-export pi.
`packages/plugins/tests/harness.test.mjs` doesn't change. `:33-37` loops over the kind's exports,
which both modules then have, and `:192` goes green.
No `scripts/test-*.sh` builds a harness module. `test-config.sh:266-280` checks only the two
config keys.
## New tests, red first (mos-1272)
1. `packages/seat/tests/compactions-ledger.test.mjs`: one fixture ledger per reason row 270 writes:
known, `store-incomplete`, `store-unreadable`, `not-collected`, `harness-unsupported` from
claude-code, `harness-unsupported` from Claude history, and a torn ledger.
- Each is literal JSONL in the test. Before the build, in the slot, a capture script runs base
1e629c892's `collectCompactions` with a fixed `now` over the same stores. The literals must
equal its output byte for byte. That proves row 270's code wrote them.
- `compactionCount` reads each one. I run this half on the base and on the candidate, and record
both outputs in BUILD.md as the before and after.
- Then one more collection through `{ name, compactions }` over an empty session gives each
fixture's expected next answer: unsupported and `store-incomplete` stay, the others go known.
This half is red on the base, where core treats the object as a harness that isn't pi.
2. `packages/plugins/tests/harness-compactions.test.mjs`:
- pi's `compactions` over fixture streams answers what base `scanPi` answers, captured by the same
script: a plain count, a budget that stops short (cursor at the last whole line,
`not-collected`), more streams than the cap (no cursor for unopened streams, `not-collected`),
a stream that shrank under its cursor (`store-incomplete`), a non-session header
(`store-unreadable`). Each answer has version "0.85.1".
- pi with `first` and a non-empty `projects` answers `harness-unsupported` without scanning.
Without `first`, the same store scans.
- claude-code answers `harness-unsupported` with version null, over directories that don't exist.
- A harness module that re-exports every other pi export by name, without `compactions`, refuses
at load: `missing-export`, "doesn't export compactions".
3. In the same seat file: core refuses each malformed answer and appends nothing. The cases are
`found` not an array, an empty `entry`, a cursor whose `bytes` is negative or `tail` isn't 64
hex, `known` true with a reason, an unknown reason, a numeric `version`, and a module that
throws. The module gets exactly `{ store: { session, projects }, cursors, budget, streams,
first }`.
## Mutants
- M1, the brief's first: core branches on a harness name again (`readLedger`'s `r.harness !== "pi"
||` restored). Killed by `harness.test.mjs:192`.
- M2, the brief's second: pi's module drops the Claude-history check. Killed by
`compactions-budget.test.mjs:86` and new test 2's `first` case.
- M3, the brief's third: `readLedger` never sets `unsupported`. Killed by new test 1's two
`harness-unsupported` fixtures and `compactions.test.mjs:197`.
- M4, mine: core skips the answer check. Killed by new test 3.
## Gate
Duckworth approves this design, then the manifest. On Sage's CLEAR:
- the capture script on base 1e629c892 first, then the new tests red on the base;
- the bus, cli, plugins (zero failures), webui and business suites, and every `scripts/test-*.sh`;
- the seat suite whole (`packages/seat`, `node --test tests/`), where two old-test edits, new
tests 1 and 3, and the M2 and M3 kills live (Duckworth C1);
- in the cli run, `compactions-launch`'s T3 tests (`:206`, and `:305` with the `:344` edit) must
run, not skip on `sandboxProblem()` (`:36`). BUILD.md names every skip (Duckworth C2);
- the mutants, each on an isolated copy with its restore checked by sha256.
Each suite runs under `timeout -k 10` at 3x or more of its longest recorded wall (mos-1275). I'll
put the numbers in BUILD.md before the first run. TMPDIR comes from
`mktemp -d /mnt/storage/scratch/tmp/gXXX`.
## Round 1
Duckworth approved round 1 on 67daf6403 (duckworth-0280). This revision takes C1 and C2 into
the gate, adds the N1 comment to the launcher plan, and corrects the N2 line refs (`:302`, `:301`).
@@ -0,0 +1,79 @@
// Row 307 (#1814): checks the new tests' literals against row 270's code at base 1e629c892.
//
// node capture.mjs <base checkout at 1e629c892> <candidate checkout>
//
// Reads the FIXTURES and CASES blocks (between `// capture:begin` and `// capture:end`) from the
// candidate's two new test files. Replays each fixture's steps with the base's collectCompactions,
// harness named by string as row 270 takes it, with the clock at 12:00:00 and one second a step,
// and compares the ledger byte for byte. Prints the base's compactionCount for each. Runs the
// base's scanPi over each case and compares the answer the base's reason order gives (:301) with
// version PI_VERSION. Exit 0 only when every one matches. Writes only under TMPDIR.
import assert from "node:assert/strict";
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const [base, cand] = process.argv.slice(2).map((p) => p && resolve(p));
if (!base || !cand) {
console.error("usage: node capture.mjs <base checkout> <candidate checkout>");
process.exit(2);
}
function block(file) {
const text = readFileSync(join(cand, file), "utf8");
const m = /\n\/\/ capture:begin\nconst [A-Z]+ = (\{[\s\S]*?\});\n\/\/ capture:end\n/.exec(text);
if (!m) throw new Error(`${file}: no capture block`);
return JSON.parse(m[1]);
}
const FIXTURES = block("packages/seat/tests/compactions-ledger.test.mjs");
const CASES = block("packages/plugins/tests/harness-compactions.test.mjs");
const seat = await import(pathToFileURL(join(base, "packages/seat/src/compactions.mjs")).href);
const { sessionStore } = await import(pathToFileURL(join(base, "packages/seat/src/session.mjs")).href);
const root = mkdtempSync(join(tmpdir(), "g307-capture-"));
let failed = 0;
const report = (what, same, detail) => {
console.log(`${same ? "MATCH" : "DIFF "} ${what}${detail ? ` ${detail}` : ""}`);
if (!same) failed++;
};
try {
for (const [name, fx] of Object.entries(FIXTURES)) {
mkdirSync(join(root, `f-${name}`));
const store = sessionStore(join(root, `f-${name}`), "acme", "th1", "s1");
for (const [file, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, file), text);
for (const [file, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, file), text);
let tick = 0;
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
for (const step of fx.steps) {
if (step.tear !== undefined) appendFileSync(join(store.dir, seat.LEDGER), step.tear);
else seat.collectCompactions(store, step.collect, { now, ...(step.budget === undefined ? {} : { budget: step.budget }) });
}
const got = readFileSync(join(store.dir, seat.LEDGER), "utf8");
const want = fx.ledger.join("\n") + "\n";
report(`ledger ${name}`, got === want, got === want ? `(${Buffer.byteLength(got)} bytes)` : `\n--- base wrote\n${got}--- test has\n${want}`);
const count = seat.compactionCount(store.dir);
report(`base count ${name}`, JSON.stringify(count) === JSON.stringify(fx.count), JSON.stringify(count));
}
for (const [name, c] of Object.entries(CASES)) {
const dir = join(root, `c-${name}`);
mkdirSync(dir);
for (const [file, text] of Object.entries(c.streams)) writeFileSync(join(dir, file), text);
const scan = seat.scanPi(dir, c.cursors, c.budget, c.cap);
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
const answer = { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: seat.PI_VERSION };
let same = true;
try {
assert.deepStrictEqual(answer, c.answer);
} catch {
same = false;
}
report(`scan ${name}`, same, JSON.stringify(scan));
}
} finally {
rmSync(root, { recursive: true, force: true });
}
console.log(failed ? `${failed} differ` : "all match");
process.exit(failed ? 1 : 0);
+67
View File
@@ -0,0 +1,67 @@
# Row 301 build notes (Launchpad): R6-N4, fold user-bus.mjs back into bus.mjs (#1808)
Brief: docs/plans/2026-10-23_r6-followup-rows.md § Brief: R6-N4. Reviewer Duckworth, gate owner Sage.
Base e551cd846 (refactor after row 303). Branch launchpad/row301.
## Change
- packages/webui/src/bus.mjs: the user transport moves in beside humanCall, after `busReader`:
`USER_CLI`, `userCall`, `busBusiness` and `userCaller`, byte for byte from user-bus.mjs (checked
with diff), under a three-line header. One import is added, `fileURLToPath` from node:url.
`readHostState`, `spawn`, `randomBytes` and `BusReadError` were already in bus.mjs. humanCall and
busCaller are untouched.
- packages/webui/src/user-bus.mjs: deleted.
- packages/webui/src/cli.mjs: the two imports become one, from './bus.mjs'.
- packages/webui/src/console-bus.mjs: a comment names bus.mjs instead of user-bus.mjs.
- packages/webui/tests/granted-user.test.mjs:22-23: the two imports become one, from '../src/bus.mjs'.
Only the import path changes. Mos ruled (mos-1449, via sage-3399) that this is covered by "update
its imports" and is not an old-test change.
No other reference to user-bus.mjs remains under packages/. tools/agent-watch's "user-bus" means
something unrelated. The 294 packet's mutants name the old file as history only.
## Re-pin
channel-proof.test.mjs:28 pinned packages/webui/src/bus.mjs at 15e8123f…3313. After the fold the
file hashes to 39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c. A pin change is an
old-test change. Mos approved it under mos-1272 in mos-1449 (via sage-3399), as its own commit, with
row 301 making its own re-pin window. Mos checked fd4b8c36e read-only: bus.mjs hashes to 39fcb27c,
its diff removes no lines, user-bus.mjs from `export const USER_CLI` to the end is in it byte for
byte, and nothing else imports user-bus.mjs. The second commit changes that one value and adds one
comment line in the row 165/171/194 style.
## Gate
Duckworth approves the manifest. channel-proof.test, the webui suite and the 294 user-transport
tests (granted-user.test.mjs) run whole and green, on Sage's CLEAR.
MANIFEST.sha256 lists the changed paths. DELETED.txt names the one removed path.
## Run 1 (sage-3408 CLEAR, 12:08Z, head 04a179740)
npm ci root and webui green. channel-proof.test whole 2 pass 0 fail. The webui suite whole went
374 tests, 372 pass, 2 fail, 0 skipped, so the gate stopped. granted-user.test whole, run after the
stop to see whether the stop hid another red: 19 pass 0 fail. No file changed.
Both reds were in old tests: library-cli.test.mjs:10 (C2-04) and thread-claims.test.mjs:54 (row278
Console adapter). Each failed with `SyntaxError: The requested module './bus.mjs' does not provide an
export named 'busBusiness'` at cli.mjs:5. Each test spawns cli.mjs with a registerHooks load hook
that replaces /webui/src/bus.mjs with a fixture exporting only BusReadError and busCaller. Before
the fold, cli.mjs took busBusiness and userCaller from the real user-bus.mjs. Now they come from the
stubbed bus.mjs, so cli.mjs fails to link. cli.mjs calls them only under --sso (:98, :103), and
neither test passes --sso. A URL-keyed stub is not an import, so the "nothing else imports
user-bus.mjs" check and the static reviews missed it. `git grep "webui/src/bus.mjs" --
'packages/*/tests/*'` finds exactly channel-proof, library-cli and thread-claims.
## Fixture change (mos-1451 under mos-1272, via sage-3418)
Mos approved option A with one change and refused option B, a namespace import in cli.mjs. Each
of the two busSource template strings (library-cli.test.mjs:17, thread-claims.test.mjs:58) gets
this appended, and nothing else changes in either file:
export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};
The stubs throw rather than return null, so a test that reaches them outside --sso fails loudly.
The text has no backtick, `${` or backslash, so the template literal carries it unchanged. The
manifest goes from five paths to seven. Sage's gate: webui 374/0, channel-proof 2/0,
granted-user 19/0. Any other red stops the gate and goes to Mos.
@@ -0,0 +1 @@
deleted packages/webui/src/user-bus.mjs
@@ -0,0 +1,7 @@
db1f05e312b9bee7ece7df7df78047a2965024109a297cb8a24112135a6e5f04 packages/bus/tests/channel-proof.test.mjs
39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c packages/webui/src/bus.mjs
0862b2b5bf6511d2c5deca1f318eee643edaee489c0ce8c83e57364f2f54a3d8 packages/webui/src/cli.mjs
653d2087c0796a35c3884b7243d1adf8a78c52950a4924049317063f2d3c0974 packages/webui/src/console-bus.mjs
34b6d052855369c8f7cf1faf8bab3c59ddf96ccd4c42731bd86a4e61e5e70640 packages/webui/tests/granted-user.test.mjs
7e782c2b6b46b6b8b6e58cd3d148bf041d49e8fbf2cbbed51080cdb02fc11d89 packages/webui/tests/library-cli.test.mjs
dd1e62d14a59bb8fe7cd4654dd1de8e518270c36d104ee356e165faee7f95a02 packages/webui/tests/thread-claims.test.mjs
+26
View File
@@ -0,0 +1,26 @@
# Row 303 build notes (Launchpad): R9 shows its output when rehearse makes no directory (#1810)
Brief: docs/plans/2026-10-23_r9-guard-row.md (Vikunja 589). Reviewer Duckworth, gate owner Sage.
Base 80793d824 (refactor after row 294 landed at d3059c2c3). Branch launchpad/row303.
## Change
One added line in packages/cli/tests/stack-release.test.mjs, R9 only, at :2178:
assert.ok(r.dir!==null, r.out+r.err);
It sits right after R9's exit-code check and right before the `includes` check whose message builds
`join(r.dir,'placeholders')`. rehearse() (:2040) sets `dir` to null when the output has no
`rehearsal directory:` line, so the guard fails first and its message carries the rehearsal's
output and stderr, including the FAIL line that stopped it. This is a test-only change to a
pre-existing test, approved under mos-1272 and mos-1316 (mos-1422 point 2). It adds an assertion
and changes none. No other line in R9 or any other test changes. The lines the brief names (:2058,
:2113, :2126, :2267, :2280) put r.dir in a template, where a null prints as `null` and the message
still carries r.out, so they get no guard.
MANIFEST.sha256 lists the one changed path, hashed from the commit that adds this file.
## Gate
Duckworth approves the diff. stack-release.test.mjs runs whole and green on Sage's gate, in a gap.
Not run yet.
@@ -0,0 +1 @@
e936808bb12361acf164b675fe40ead416720dfd48c5d51ea5bfee1e1893cc33 packages/cli/tests/stack-release.test.mjs
+49
View File
@@ -0,0 +1,49 @@
# Row 304 / Vikunja 590 / Gitea #1811
Owner Rocko; reviewer Dewey; gate owner Sage. Base c16e943b90ef71ac96cfa5314bbea6040634d14d.
Brief docs/plans/2026-10-23_gate-env-rows.md first section, approved exact
one-line old-test change under mos-1442 point 1 / sage-3362.
265 is queued and has no active build, so 304 goes first. Slot requested;
no Node, test, queue claim or push before Sage CLEAR.
Before the edit, run the unchanged whole claude-session.test.mjs with
fixture-only HOME, CLAUDE_CONFIG_DIR and scripted API under an underscore
TMPDIR and a hyphen TMPDIR. Expected: underscore 9/1 only at the unrestricted
memory control (:298), hyphen 10/0. Record both raw results here. Stop for
Sage if the installed CLI's mapping differs from [^A-Za-z0-9] -> '-'.
Only then replace /[/.]/g with /[^A-Za-z0-9]/g on the slug line. Every
assertion and every other test line stays byte-identical. No product change.
Acceptance is whole file green under mktemp, underscore, and space TMPDIR
(if the CLI accepts space); old-regex mutant must fail the :298 assertion
under underscore. Run scope and claim wait for Sage's slot grant.
Use short fixture TMPDIRs under /mnt/storage/scratch/tmp, process groups,
explicit TAP and bounded runs. No Docker, browser, suites or live state.
Keep token/config paths and real provider access out of every fixture.
## Evidence, sage-3366 (mos-1442 point 1)
queue next selected304; claim rev3521, rc0. Base test unchanged until both
mapping probes finished. Correct restricted-check citation: :293, not :291.
| Run | TMPDIR | Exit | Pass/fail/skip |
|---|---|---|---|
| baseline-underscore | /mnt/storage/scratch/tmp/rk304_OV81Op | 1 | 9/1/0 |
| baseline-hyphen | /mnt/storage/scratch/tmp/rk304-UnWJbm | 0 | 10/0/0 |
| fixed-mktemp | /mnt/storage/scratch/tmp/rk304yw6LWT | 0 | 10/0/0 |
| fixed-underscore | /mnt/storage/scratch/tmp/rk304_MyhpSs | 0 | 10/0/0 |
| fixed-space | /mnt/storage/scratch/tmp/rk304 i5d7HA | 0 | 10/0/0 |
| M1-old-regex | /mnt/storage/scratch/tmp/rk304_595gfj | 1 | 9/1/0 |
Baseline underscore and M1 both fail only the named unrestricted positive
control at claude-session.test.mjs:298:10, ERR_ASSERTION, missing MARKER-MEMORY.
Hyphen baseline is green. This confirms the expected installed-CLI mapping.
Space is accepted and passes after the fix. All3 fixed whole-file runs30/0/0;
baseline hyphen10/0/0; M1 one assertion kill (whole file9/1/0).0cancellations,
0skips throughout. Tests11:05:01Z-11:05:36Z, no unexpected red or retry.
Every other source byte, including all assertions, is unchanged, recorded in
assertion-preservation.json. Fixed test sha2567690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9.
Cwd node/chromium/claude sweep empty; no Docker/browser/suite/live activity.
Sage's separate gate and Dewey's review still required; no gate claimed here.
@@ -0,0 +1,6 @@
{
"allOtherBytesUnchanged": true,
"assertionLinesUnchanged": true,
"baseSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0",
"candidateSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9"
}
+1
View File
@@ -0,0 +1 @@
c16e943b90ef71ac96cfa5314bbea6040634d14d
+13
View File
@@ -0,0 +1,13 @@
diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs
index bf5cb2d29..d9c45e446 100644
--- a/packages/harness/tests/claude-session.test.mjs
+++ b/packages/harness/tests/claude-session.test.mjs
@@ -269,7 +269,7 @@ test("claude adapter: a directory pin exits 2 before it runs; a pin with a space
test("claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do", { skip }, async (t) => {
const { dir, workspace, env } = await session(t, []);
- const slug = realpathSync(workspace).replace(/[/.]/g, "-");
+ const slug = realpathSync(workspace).replace(/[^A-Za-z0-9]/g, "-");
const plant = {
"MARKER-USER": [join(env.HOME, ".claude", "CLAUDE.md"), join(env.CLAUDE_CONFIG_DIR, "CLAUDE.md")],
"MARKER-PARENT": [join(dir, "CLAUDE.md")],
@@ -0,0 +1 @@
7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9 packages/harness/tests/claude-session.test.mjs
@@ -0,0 +1 @@
packages/harness/tests/claude-session.test.mjs
@@ -0,0 +1 @@
[]
+14
View File
@@ -0,0 +1,14 @@
START baseline-underscore 2026-10-23T11:05:01.371651+00:00 /mnt/storage/scratch/tmp/rk304_OV81Op
END {"label": "baseline-underscore", "start": "2026-10-23T11:05:01.371651+00:00", "end": "2026-10-23T11:05:06.998843+00:00", "seconds": 5.627, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_OV81Op", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-underscore.tap"}
START baseline-hyphen 2026-10-23T11:05:07.003823+00:00 /mnt/storage/scratch/tmp/rk304-UnWJbm
END {"label": "baseline-hyphen", "start": "2026-10-23T11:05:07.003823+00:00", "end": "2026-10-23T11:05:12.829873+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304-UnWJbm", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-hyphen.tap"}
START fixed-mktemp 2026-10-23T11:05:12.834143+00:00 /mnt/storage/scratch/tmp/rk304yw6LWT
END {"label": "fixed-mktemp", "start": "2026-10-23T11:05:12.834143+00:00", "end": "2026-10-23T11:05:18.560124+00:00", "seconds": 5.726, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304yw6LWT", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-mktemp.tap"}
START fixed-underscore 2026-10-23T11:05:18.564553+00:00 /mnt/storage/scratch/tmp/rk304_MyhpSs
END {"label": "fixed-underscore", "start": "2026-10-23T11:05:18.564553+00:00", "end": "2026-10-23T11:05:24.340312+00:00", "seconds": 5.776, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_MyhpSs", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-underscore.tap"}
START fixed-space 2026-10-23T11:05:24.344312+00:00 /mnt/storage/scratch/tmp/rk304 i5d7HA
END {"label": "fixed-space", "start": "2026-10-23T11:05:24.344312+00:00", "end": "2026-10-23T11:05:30.170101+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304 i5d7HA", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-space.tap"}
START M1-old-regex 2026-10-23T11:05:30.173937+00:00 /mnt/storage/scratch/tmp/rk304_595gfj
END {"label": "M1-old-regex", "start": "2026-10-23T11:05:30.173937+00:00", "end": "2026-10-23T11:05:36.100361+00:00", "seconds": 5.926, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_595gfj", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/M1-old-regex.tap"}
BUILD_GREEN 2026-10-23T11:05:36.103508+00:00
CWD_SWEEP []
@@ -0,0 +1,99 @@
TAP version 13
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
---
duration_ms: 810.176546
type: 'test'
...
# Subtest: claude: the hook alone blocks a path outside the workspace
ok 2 - claude: the hook alone blocks a path outside the workspace
---
duration_ms: 503.543336
type: 'test'
...
# Subtest: claude: a second turn resumes the first turn's session
ok 3 - claude: a second turn resumes the first turn's session
---
duration_ms: 915.865915
type: 'test'
...
# Subtest: claude adapter: --restricted is always passed
ok 4 - claude adapter: --restricted is always passed
---
duration_ms: 4.807726
type: 'test'
...
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
---
duration_ms: 15.271577
type: 'test'
...
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
---
duration_ms: 5.309932
type: 'test'
...
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
not ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
---
duration_ms: 888.601244
type: 'test'
location: '/mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:270:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
[
'MARKER-USER',
'MARKER-PARENT',
'MARKER-WS',
- 'MARKER-MEMORY'
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0: 'MARKER-USER'
1: 'MARKER-PARENT'
2: 'MARKER-WS'
3: 'MARKER-MEMORY'
actual:
0: 'MARKER-USER'
1: 'MARKER-PARENT'
2: 'MARKER-WS'
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:298:10)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: claude: a missing hook or MCP file refuses before claude starts
ok 8 - claude: a missing hook or MCP file refuses before claude starts
---
duration_ms: 9.250346
type: 'test'
...
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
---
duration_ms: 1764.786892
type: 'test'
...
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
---
duration_ms: 871.328606
type: 'test'
...
1..10
# tests 10
# suites 0
# pass 9
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5870.59342
@@ -0,0 +1,70 @@
TAP version 13
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
---
duration_ms: 780.047981
type: 'test'
...
# Subtest: claude: the hook alone blocks a path outside the workspace
ok 2 - claude: the hook alone blocks a path outside the workspace
---
duration_ms: 503.370776
type: 'test'
...
# Subtest: claude: a second turn resumes the first turn's session
ok 3 - claude: a second turn resumes the first turn's session
---
duration_ms: 848.822885
type: 'test'
...
# Subtest: claude adapter: --restricted is always passed
ok 4 - claude adapter: --restricted is always passed
---
duration_ms: 4.629499
type: 'test'
...
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
---
duration_ms: 15.371187
type: 'test'
...
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
---
duration_ms: 5.545444
type: 'test'
...
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
---
duration_ms: 947.229815
type: 'test'
...
# Subtest: claude: a missing hook or MCP file refuses before claude starts
ok 8 - claude: a missing hook or MCP file refuses before claude starts
---
duration_ms: 8.467575
type: 'test'
...
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
---
duration_ms: 1738.629452
type: 'test'
...
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
---
duration_ms: 857.865121
type: 'test'
...
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5790.544695
@@ -0,0 +1,100 @@
TAP version 13
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
---
duration_ms: 748.693342
type: 'test'
...
# Subtest: claude: the hook alone blocks a path outside the workspace
ok 2 - claude: the hook alone blocks a path outside the workspace
---
duration_ms: 499.864544
type: 'test'
...
# Subtest: claude: a second turn resumes the first turn's session
ok 3 - claude: a second turn resumes the first turn's session
---
duration_ms: 829.415272
type: 'test'
...
# Subtest: claude adapter: --restricted is always passed
ok 4 - claude adapter: --restricted is always passed
---
duration_ms: 4.710643
type: 'test'
...
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
---
duration_ms: 14.085981
type: 'test'
...
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
---
duration_ms: 4.998474
type: 'test'
...
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
not ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
---
duration_ms: 863.150735
type: 'test'
location: '/mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:270:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
[
'MARKER-USER',
'MARKER-PARENT',
'MARKER-WS',
- 'MARKER-MEMORY'
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0: 'MARKER-USER'
1: 'MARKER-PARENT'
2: 'MARKER-WS'
3: 'MARKER-MEMORY'
actual:
0: 'MARKER-USER'
1: 'MARKER-PARENT'
2: 'MARKER-WS'
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:298:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: claude: a missing hook or MCP file refuses before claude starts
ok 8 - claude: a missing hook or MCP file refuses before claude starts
---
duration_ms: 8.695573
type: 'test'
...
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
---
duration_ms: 1671.315421
type: 'test'
...
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
---
duration_ms: 823.801717
type: 'test'
...
1..10
# tests 10
# suites 0
# pass 9
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5552.702962
@@ -0,0 +1,70 @@
TAP version 13
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
---
duration_ms: 822.315273
type: 'test'
...
# Subtest: claude: the hook alone blocks a path outside the workspace
ok 2 - claude: the hook alone blocks a path outside the workspace
---
duration_ms: 473.785584
type: 'test'
...
# Subtest: claude: a second turn resumes the first turn's session
ok 3 - claude: a second turn resumes the first turn's session
---
duration_ms: 834.093184
type: 'test'
...
# Subtest: claude adapter: --restricted is always passed
ok 4 - claude adapter: --restricted is always passed
---
duration_ms: 4.587717
type: 'test'
...
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
---
duration_ms: 15.598032
type: 'test'
...
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
---
duration_ms: 5.369426
type: 'test'
...
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
---
duration_ms: 831.362199
type: 'test'
...
# Subtest: claude: a missing hook or MCP file refuses before claude starts
ok 8 - claude: a missing hook or MCP file refuses before claude starts
---
duration_ms: 8.237071
type: 'test'
...
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
---
duration_ms: 1687.124498
type: 'test'
...
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
---
duration_ms: 884.665674
type: 'test'
...
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5663.888591
@@ -0,0 +1,70 @@
TAP version 13
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
---
duration_ms: 740.530837
type: 'test'
...
# Subtest: claude: the hook alone blocks a path outside the workspace
ok 2 - claude: the hook alone blocks a path outside the workspace
---
duration_ms: 484.250736
type: 'test'
...
# Subtest: claude: a second turn resumes the first turn's session
ok 3 - claude: a second turn resumes the first turn's session
---
duration_ms: 857.991709
type: 'test'
...
# Subtest: claude adapter: --restricted is always passed
ok 4 - claude adapter: --restricted is always passed
---
duration_ms: 5.19059
type: 'test'
...
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
---
duration_ms: 16.380045
type: 'test'
...
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
---
duration_ms: 5.47195
type: 'test'
...
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
---
duration_ms: 869.204192
type: 'test'
...
# Subtest: claude: a missing hook or MCP file refuses before claude starts
ok 8 - claude: a missing hook or MCP file refuses before claude starts
---
duration_ms: 8.011154
type: 'test'
...
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
---
duration_ms: 1760.816087
type: 'test'
...
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
---
duration_ms: 951.447392
type: 'test'
...
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5778.476849
@@ -0,0 +1,70 @@
TAP version 13
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
---
duration_ms: 766.913918
type: 'test'
...
# Subtest: claude: the hook alone blocks a path outside the workspace
ok 2 - claude: the hook alone blocks a path outside the workspace
---
duration_ms: 496.038436
type: 'test'
...
# Subtest: claude: a second turn resumes the first turn's session
ok 3 - claude: a second turn resumes the first turn's session
---
duration_ms: 839.816577
type: 'test'
...
# Subtest: claude adapter: --restricted is always passed
ok 4 - claude adapter: --restricted is always passed
---
duration_ms: 4.571987
type: 'test'
...
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
---
duration_ms: 17.17544
type: 'test'
...
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
---
duration_ms: 5.448032
type: 'test'
...
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
---
duration_ms: 873.897762
type: 'test'
...
# Subtest: claude: a missing hook or MCP file refuses before claude starts
ok 8 - claude: a missing hook or MCP file refuses before claude starts
---
duration_ms: 8.282957
type: 'test'
...
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
---
duration_ms: 1738.792709
type: 'test'
...
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
---
duration_ms: 863.908172
type: 'test'
...
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5704.886282
@@ -0,0 +1,16 @@
0b487adf165fb6b83e9cba38fc49167c6ec54b66261d955052f7ee5a877fde07 BUILD.md
a75846e9e3217b524ad09bb423033975da6c44074d8d74dc14193550ec1666b9 assertion-preservation.json
5ddf1af4e2d23bc579e6a4005c2b355794dcc33fbd29c0421a6d1e9ca60f70ec base.txt
85dc6f969a11d933b04cc7b2fe49199d745029779c66bf1815f549c51ec91568 build.patch
a09b498872aaf4f3b3af9b297d839d4ddb6fdc57d7a01f05cc1a48200c30cfd4 candidate-manifest.sha256
8588e890cdfadd6911fd3b473aad3ff826f6dd91a52e8d10aeb4a28f7929d995 changed-paths.txt
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 cwd-sweep.json
767c5c04bc6442bb6efc8de3bf86e5ef8d2bbdd90d20d72fd604d18e69290f91 driver.log
f6f8a66ccc0c025782cc6932de9745720ca7574a0376d6122074275332abaf43 logs/M1-old-regex.tap
dc290774a454c85033fdc8c71226f7cd56a07e52106b2868ca91ca5ea652ddca logs/baseline-hyphen.tap
960114150fbf0a3069762d356b6cffe0068208a69e805bb0422de0da8825af35 logs/baseline-underscore.tap
1d0836115cdf344e7cdcbe1ef9de7a3f2e57ca06a4cca42a07adb14ba82bd29a logs/fixed-mktemp.tap
a69191da6fd3eb20ec99b92521df9418e9815b7879a159cc7b2abbd5b790261c logs/fixed-space.tap
9776e07a392bc567609573a66a2a17619ded0863b8ee35cbc53482e42c58220b logs/fixed-underscore.tap
3adeed328506abdeff0bb108ac16d306736e2caba1c8188d48173e5ea75b5c81 results.jsonl
084a848241bf874a5873a881d2c47efd29deff7b6682947fc7738053556845d0 run-build.py
@@ -0,0 +1,6 @@
{"label": "baseline-underscore", "start": "2026-10-23T11:05:01.371651+00:00", "end": "2026-10-23T11:05:06.998843+00:00", "seconds": 5.627, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_OV81Op", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-underscore.tap"}
{"label": "baseline-hyphen", "start": "2026-10-23T11:05:07.003823+00:00", "end": "2026-10-23T11:05:12.829873+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304-UnWJbm", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-hyphen.tap"}
{"label": "fixed-mktemp", "start": "2026-10-23T11:05:12.834143+00:00", "end": "2026-10-23T11:05:18.560124+00:00", "seconds": 5.726, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304yw6LWT", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-mktemp.tap"}
{"label": "fixed-underscore", "start": "2026-10-23T11:05:18.564553+00:00", "end": "2026-10-23T11:05:24.340312+00:00", "seconds": 5.776, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_MyhpSs", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-underscore.tap"}
{"label": "fixed-space", "start": "2026-10-23T11:05:24.344312+00:00", "end": "2026-10-23T11:05:30.170101+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304 i5d7HA", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-space.tap"}
{"label": "M1-old-regex", "start": "2026-10-23T11:05:30.173937+00:00", "end": "2026-10-23T11:05:36.100361+00:00", "seconds": 5.926, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_595gfj", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/M1-old-regex.tap"}
+66
View File
@@ -0,0 +1,66 @@
from pathlib import Path
import subprocess,os,signal,re,json,hashlib,time
from datetime import datetime,timezone
ROOT=Path(__file__).resolve().parents[4];P=Path(__file__).resolve().parent;O=P/'logs';O.mkdir(exist_ok=True)
FILE='packages/harness/tests/claude-session.test.mjs';source=ROOT/FILE
BASE='c16e943b90ef71ac96cfa5314bbea6040634d14d'
old='realpathSync(workspace).replace(/[/.]/g, "-")';new='realpathSync(workspace).replace(/[^A-Za-z0-9]/g, "-")'
original=source.read_bytes();assert original.decode().count(old)==1
fixed=original.decode().replace(old,new,1).encode()
rows=[];temps=[]
def stamp():return datetime.now(timezone.utc).isoformat()
def verify(data):
assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()==BASE
assert source.read_bytes()==data
def run(label,template,data):
verify(data);tmp=subprocess.check_output(['mktemp','-d',template],text=True).strip();temps.append(tmp)
env=os.environ.copy();env.update(TMPDIR=tmp,DOCKER_HOST='unix:///mnt/storage/scratch/rocko-r304-dead.sock',NO_COLOR='1');env.pop('MOSAIC_GITEA_CREDENTIAL_FILE',None);env.pop('MOSAIC_LIVE_PROVIDER',None)
args=['timeout','-k','10','180','node','--test','--test-reporter=tap',FILE];log=O/(label+'.tap');start=stamp();begin=time.monotonic()
print('START',label,start,tmp,flush=True)
with log.open('wb') as f:
child=subprocess.Popen(args,cwd=ROOT,env=env,stdout=f,stderr=subprocess.STDOUT,start_new_session=True)
try:rc=child.wait(timeout=195)
except subprocess.TimeoutExpired:
os.killpg(child.pid,signal.SIGKILL);child.wait();rc=124
finally:
try:os.killpg(child.pid,signal.SIGTERM)
except ProcessLookupError:pass
text=log.read_text(errors='replace');counts={k:int(m[-1]) if (m:=re.findall(r'^# '+k+r' (\d+)\s*$',text,re.M)) else None for k in ['tests','pass','fail','cancelled','skipped']}
row=dict(label=label,start=start,end=stamp(),seconds=round(time.monotonic()-begin,3),exit=rc,counts=counts,tmpdir=tmp,head=BASE,testSha256=hashlib.sha256(data).hexdigest(),skips=[x for x in text.splitlines() if re.search(r'^skip\b|# SKIP\b',x,re.I)],log=str(log.relative_to(ROOT)))
rows.append(row)
with (P/'results.jsonl').open('a') as f:f.write(json.dumps(row)+'\n')
print('END',json.dumps(row),flush=True)
assert rc not in [124,137,-9] and 'SyntaxError:' not in text,'invalid run '+label
verify(data);return row,text
def red(label,template,data):
r,t=run(label,template,data)
assert r['exit']!=0 and r['counts']['tests']==10 and r['counts']['pass']==9 and r['counts']['fail']==1 and r['counts']['cancelled']==0 and r['counts']['skipped']==0,'unexpected red counts '+label
names=re.findall(r'^not ok \d+ - (.*)$',t,re.M)
assert len(names)==1 and "CLAUDE.md files and auto-memory" in names[0] and ':298:10' in t and 'ERR_ASSERTION' in t and 'MARKER-MEMORY' in t,'unexpected red '+label
def green(label,template,data):
r,t=run(label,template,data)
assert r['exit']==0 and r['counts']['tests']==10 and r['counts']['pass']==10 and r['counts']['fail']==0 and r['counts']['cancelled']==0 and r['counts']['skipped']==0,'unexpected mapping/refusal '+label
try:
red('baseline-underscore','/mnt/storage/scratch/tmp/rk304_XXXXXX',original)
green('baseline-hyphen','/mnt/storage/scratch/tmp/rk304-XXXXXX',original)
# Both installed-CLI probes now confirm the approved mapping before editing.
source.write_bytes(fixed)
green('fixed-mktemp','/mnt/storage/scratch/tmp/rk304XXXXXX',fixed)
green('fixed-underscore','/mnt/storage/scratch/tmp/rk304_XXXXXX',fixed)
green('fixed-space','/mnt/storage/scratch/tmp/rk304 XXXXXX',fixed)
source.write_bytes(original)
try:red('M1-old-regex','/mnt/storage/scratch/tmp/rk304_XXXXXX',original)
finally:source.write_bytes(fixed)
verify(fixed);print('BUILD_GREEN',stamp(),flush=True)
finally:
found=[]
for proc in Path('/proc').glob('[0-9]*'):
try:
name=(proc/'comm').read_text().strip();cwd=str((proc/'cwd').resolve())
if any(x in name for x in ['node','chrom','claude']) and any(cwd==r or cwd.startswith(r+'/') for r in [str(ROOT),*temps]):found.append(dict(pid=int(proc.name),name=name,cwd=cwd))
except (FileNotFoundError,PermissionError,ProcessLookupError):pass
(P/'cwd-sweep.json').write_text(json.dumps(found,indent=2)+'\n');print('CWD_SWEEP',found,flush=True)
@@ -12,6 +12,11 @@ export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
# Gate rule (mos-1455 item 4, 2026-10-23): every landing gate runs the bus, cli, plugins, webui and business
# suites whatever the row touches. The 270+271 gate skipped plugins, and its red sat on refactor unseen.
for p in bus cli plugins webui business; do [ -d "$G/packages/$p/tests" ] || { echo "STOP no packages/$p/tests"; exit 8; }; done
# mos-1453: a gate on the canonical node_modules must not let the webui editor rebuild test skip (Vikunja 597).
export MOSAIC_REQUIRE_EDITOR_BUILD=1
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
run() { local name="$1" dir="$2" cmd="$3"
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
+1
View File
@@ -1576,3 +1576,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-10-23T08:10:29Z | sage (Opus 5.5, 1ef1e4f8) | 299 gate and landing, 303 review, 219/295 rebases | 299 LANDED ffb2fedfb 08:03Z (done 3487, #1806 c31087); queue-commit 8a35ffadb (revs 3474-3487); 303 Duckworth approve rev 3480; requests posted as sage for 303 (c31082) and 299 (c31083); 219 full restart on bfa421b6c from 08:08Z (sage-3283)
- 2026-10-23T10:48:40Z | sage (Opus 5.5, 1ef1e4f8) | 302/295/219 landings, Docker pool diag, 590/591 briefs, 272 rebase | 302 LANDED 3ed9fd754, 295 LANDED 2efd127ce, 219 LANDED 5fdfd7275 (#1720 c31110, P19 416 c913); test-release 11/3 from exhausted Docker pools, 18 networks removed (mos-1437/1438); queue-commit 90633c864 (revs 3495-3510, 3510 Huey's); 272 rebase and fallback ruled (sage-3357)
2026-10-23T11:03:46Z | Gyro (Codex gpt-6.1-sol, mos-1444) | Vikunja571 PRD d8 docs alignment | R6 vocabulary boundary, mapped-folder eligibility and R14 whole-host recovery updated in parent/W4; static checks only, no tests or live changes. Fenton reviews; Sage lands.
- 2026-10-23T11:47:32Z | filbert (Claude Opus 5.5, T3 048f3c63) | row 305 round 1 (#1812, Vikunja 591, Gizmoduck, candidate 7e8218a2e) | approve in 31127, rev 3534; static on git objects (scripts = 3bce852d5; traps kept, no compose down, ls failure fails, skip only on docker info); gate receipts read, not rerun: Gizmoduck run1 + Sage gate task 92/0, release 15/0, mutants a/b red, c survives
@@ -0,0 +1,61 @@
# Row 270's pi compaction reading moves behind the harness kind
Found during row 272's gate (#1777) on 10-23 (sage-3432). Mos ruled the move in mos-1455 item 3 and
declined an EXCEPT entry. Vikunja 598.
## Brief: row 270's pi compaction reading moves behind the harness kind (Vikunja 598)
Gitea #1814. Owner Gizmoduck, who wrote row 270. Reviewer Duckworth, who reviewed it. Gate owner Sage.
Base: refactor at this brief's commit. Design first, in `agents/gizmoduck/work/queue-307/DESIGN.md`.
`packages/plugins/tests/harness.test.mjs:192` (row 65) checks that no core source outside
`packages/plugins` names a harness. It is red on refactor 1e4c1e6e9. Its `named` list is three lines
of `packages/seat/src/compactions.mjs`:
- :232, where `readLedger` marks a ledger unsupported when a collection's harness isn't `"pi"`;
- :283 and :287, where `collectCompactions` branches on `harness === "pi"`.
Row 270 (#1775, 8c28ee731) added them. The 270+271 gate didn't run the plugins suite.
REQ-PLUG-2 (`docs/prd/mosaic-stack.md:1001`) sends new work that calls out to a part of a plug-in kind
through that kind from the start. Row 270 was new work, so its pi reading belongs in the pi harness
module, not in core.
Change:
- The harness kind (`packages/plugins/src/kinds.mjs:52`) gains an export that collects a session
store's compactions. A core change through `kinds.mjs` is in scope, as are its callers and its
contract in `packages/plugins/README.md`. The design names the export, its arguments, its answer,
and whether `interfaceVersion` moves.
- pi's module owns the stream reading (`scanPi`, `PI_VERSION`, the Claude-history check) and answers
what `collectCompactions` writes for pi today. claude-code's module answers harness-unsupported, as
it does now.
- Core keeps the ledger: `readLedger`, `compactionCount`, the O_APPEND write and the `collected` line.
No core line names a harness. `readLedger`'s unsupported test reads a field the module's answer
carries, not a harness name.
- `packages/cli/src/launcher.mjs:312` calls through the kind. `packages/bus/src/runtime.mjs:282` and
`broker.mjs` keep reading counts through `compactionCount`.
- A ledger that row 270's code wrote gives the same count after the move, including the pi,
claude-code and Claude-history cases. Nothing rewrites a ledger.
Tests, red first under mos-1272:
- a fixture ledger written by the current code reads the same count before and after the move, one
per reason row 270 writes;
- pi's module counts a fixture stream the way `scanPi` does now, with the read budget and stream cap;
- a harness module without the export refuses at load (`packages/plugins/src/loader.mjs:61-64`).
`harness.test.mjs` stays unchanged and goes green. Any change to an existing test goes to Mos through
Sage first. A new required export breaks every existing test fixture that builds a harness module, so
DESIGN.md lists those fixtures (grep the tests for `interfaceVersion` and the harness exports). Those
edits go to Mos with the design.
Mutants:
- core branches on a harness name again;
- pi's module drops the Claude-history check;
- `readLedger` treats every collection as supported.
Gate: Duckworth approves the design and then the manifest. On Sage's gate, the bus, cli, plugins,
webui and business suites run green, plugins with no failures at all, along with every
`scripts/test-*.sh`, and the mutants are killed.
+8 -7
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 3530 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3571 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -308,7 +308,7 @@ Generated from `docs/plans/queue.json` revision 3530 by `queue render`. Do not e
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | done | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Landed efcc9ea8d fast-forward on 94c33a43c (13 commits, 12 paths, manifest 455c12ee). r3 is a carry of r2 aafcc857c, range-diff = (Duckworth 0194, c30756, rev 3307). Gate r3 18:37-18:50Z all green, 0 skips: bus 834, seat 172, cli 869, case2-unit 43, singles 38. M1-M12 kills carry. Re-pin needs the Console restart by Jason or Mos right after the host. A 264a rollback refuses a v2 sessions.json, see BUILD.md Rollback. |
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | done | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 7328da8ca (with 271, mos-1351) 02:13Z 10-23 on 767bb62c7: 33 commits cherry-picked from gizmoduck/row270-271-r4 49cee846e (base 25c09f19f), range-diff all '=', 40 paths identical to 49cee846e. Manifest f4174458 (15 paths) matches the tip. Duckworth APPROVE c30930 rev 3389. C2 run 5 GREEN on 53406f3e4: bus 873, seat 202, cli 905, ten scripts, 38/38 mutants killed. #1775 closes. |
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | done | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 7328da8ca (with 270, mos-1351) 02:13Z 10-23: tip 2b964a082 is in the 33-commit pick, range-diff all '='. Manifest 3ab1f5c8 (33 paths): 29 match the tip; BUILD.md(270), bus README, launcher.mjs and compactions.mjs moved under 270 and match 270's f4174458. Duckworth APPROVE rev 3380. Combined C2 run 5 GREEN, 271 M1-M14 killed. #1776 closes. |
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | huey; reviewers scrooge | #1777 | in-review, round 2, request posted | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | huey; reviewers scrooge | #1777 | in-review, round 3, request posted | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | done | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | landed 0bbf0f6d1 03:35:55Z, FF of 378a76b85; rebase of cac7d0f67, 6 range-diff pairs =, patch-ids equal, manifest e1031023 unchanged. Gate by Darkwing on 0bbf0f6d1: stack-release.test 218/218, external 25/25, test-release.sh rc 0, T58 c0 pass c1 fail, M1-M7 killed. Filbert APPROVE rev 3111 c30308, carry c30319. Docs 5365adaef (TOOLS.md N1 + BUILD-LOG). #1778. |
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | done | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | LANDED 4c485c63b ff from 47a35bf43 (6 Morgana commits). Dewey APPROVE c30487 on 4c485c63b (manifest 00047b52, 13 paths). Gate 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, 10 scripts rc=0 (skips test-queue 1, test-task 2), 22/22 mutants. M3x wall code not-holder (kit comment said human-required). r5 f58cfc361 red, void. Overlap 13/257 clean. |
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | done | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | Landed 3410df7c3 on refactor (fast-forward from 48e450ce8, 3 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 39847b15 (4 paths) blobs match the commit. Gate: whole files 7/22/6, bus 834, cli 861, harness 151, business 231, seat 167, 0 fail; 10 scripts/test-*.sh rc 0 with 3 named skips; M6 and M10 killed. Duckworth APPROVE round 1, Gitea #1780 comment 30717. |
@@ -336,13 +336,14 @@ Generated from `docs/plans/queue.json` revision 3530 by `queue render`. Do not e
| 297 | GitHub grant method (Bushroot's half of #436b) | bushroot; reviewers dewey | #1803 | briefed | 198 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the GitHub grant method (Bushroot's half of #436b) | — |
| 298 | Q228: chief-of-staff grant scope | megavolt; reviewers dewey | #1804 | briefed | 252 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the chief-of-staff grant scope (Q228) | — |
| 299 | 573: the P10 hop band (Vikunja 573) | gander; reviewers filbert | #1806 | done | 293 done | Filbert approves. The whole file under load at least 3 times, then the cli suite, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 573, the P10 hop band (Vikunja 573) | landed ffb2fedfb 08:03Z on 15e688cc0 via Filbert SESSIONS c1f910b4c; 5 commits = gander/row299-gate-277ace0b 63db96d8b (range-diff 5/5 =, tree differs only BUILD-LOG/SESSIONS); manifest 277ace0b sha256 -c OK. Gate green on ffa93b054: case1 whole 3x 53/0/0, cli whole 917/0/0 (59 files), P10 inner 2.27 outer 4.01, M1-M6 killed (gander-0189). Filbert approve rev 3485 c31084; N1 M1 1.23 s margin. |
| 300 | R6-N1: HTTP-level test of the user-session option swap (294 N1) | launchpad; reviewers duckworth | #1807 | briefed | 294 done | Duckworth approves the manifest; new test file whole, webui suite, mutant M12 killed (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N1, an HTTP-level test of the user-session option swap | — |
| 301 | R6-N4: fold user-bus.mjs back into bus.mjs (294 N4) | launchpad; reviewers duckworth | #1808 | briefed | 294 done | Duckworth approves the manifest; channel-proof.test, webui suite and 294 user-transport tests whole and green; re-pin only with Mos go (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N4, fold user-bus.mjs back into bus.mjs | — |
| 300 | R6-N1: HTTP-level test of the user-session option swap (294 N1) | launchpad; reviewers duckworth | #1807 | done | 294 done | Duckworth approves the manifest; new test file whole, webui suite, mutant M12 killed (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N1, an HTTP-level test of the user-session option swap | Landed on refactor as 9d929911c..07315e651 (from launchpad/row300 a00b25b37, range-diff equal), pushed 5b846b048 12:28Z. Duckworth approved r2 (c31158, rev 3559). Sage gate on bcf3c1dac: user-option-swap 1/0, webui 375/0 with pinned esbuild and MOSAIC_REQUIRE_EDITOR_BUILD=1, M12 killed at :151. Run 1 red placed under mos-1272 (headers). N1 deferred: board proxy upstream has no recorder. |
| 301 | R6-N4: fold user-bus.mjs back into bus.mjs (294 N4) | launchpad; reviewers duckworth | #1808 | done | 294 done | Duckworth approves the manifest; channel-proof.test, webui suite and 294 user-transport tests whole and green; re-pin only with Mos go (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N4, fold user-bus.mjs back into bus.mjs | Landed on refactor as bb031ec84..5b846b048 (from launchpad/row301 acc97fb9c, range-diff equal), pushed 12:28Z. Re-pin of webui bus.mjs to 39fcb27c is its own commit 59f0e44f4 (mos-1449); fixture stubs per mos-1451. Duckworth approved r2 (c31159, rev 3560). Sage gate: channel-proof 2/0, webui 374/0 pinned esbuild, granted-user 19/0. Live unchanged; 301 makes its own re-pin window. |
| 302 | Idle-stop race: an idle-released run's last call ends it session-invalid/22 (Vikunja 588) | gizmoduck; reviewers dewey | #1809 | done | 292 done | Dewey approves the design, then the manifest; forcing test, compactions-launch.test whole, bus/cli/harness suites, all scripts/test-*.sh; fix-drop mutant dies (sage) | `docs/plans/2026-10-23_idle-stop-race-row.md` § Brief: the idle-stop race (Vikunja 588) | Landed 3ed9fd754 08:49Z 10-23: Sage cherry-picked 0e30a85fa..2a8281a6f (7 commits) onto 1ac655005; range-diff all equal, patch-ids match, tree equals gated merge 783602e06 plus packet-only 2a8281a6f files. Gate run 2 green (bus 876, harness 151, cli 918, ten test-*.sh, M1-M5 M7-M9 killed). Dewey approve rev 3492 c31092. mos-1430 fixed launcher-released.test:89. mos-1411 rerun allowance ends. |
| 303 | R9 shows its output when rehearse makes no directory (Vikunja 589) | launchpad; reviewers duckworth | #1810 | in-review, round 1, request posted | 294 done | Duckworth approves the diff; stack-release.test.mjs whole and green on Sage's gate, in a gap (sage) | `docs/plans/2026-10-23_r9-guard-row.md` § Brief: R9 shows its output when rehearse makes no directory (Vikunja 589) | — |
| 304 | claude-session test builds the memory slug the way the CLI does (Vikunja 590) | rocko; reviewers dewey | #1811 | in-review, round 1, request posted | — | Dewey approves the diff; claude-session.test.mjs whole and green 3x (mktemp, underscore, space TMPDIRs) on Sage's gate, in a gap; old-regex mutant red at :298 (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: claude-session test builds the memory slug the way the CLI does (Vikunja 590) | — |
| 305 | Shell suites remove the compose network they create (Vikunja 591) | gizmoduck; reviewers filbert | #1812 | in-review, round 1, request posted | — | Filbert approves the diff; test-release.sh and test-task.sh whole and green on Sage's gate, in a gap, project network absent after each run; dropped-rm mutant red (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: shell suites remove the compose network they create (Vikunja 591) | — |
| 303 | R9 shows its output when rehearse makes no directory (Vikunja 589) | launchpad; reviewers duckworth | #1810 | done | 294 done | Duckworth approves the diff; stack-release.test.mjs whole and green on Sage's gate, in a gap (sage) | `docs/plans/2026-10-23_r9-guard-row.md` § Brief: R9 shows its output when rehearse makes no directory (Vikunja 589) | Landed e551cd846 from launchpad 88ffecaf7, patch-id 559c802b equal, test blob e936808b matches the manifest. Gate: Duckworth approve c31079; Sage gate on c13c337e1 plus the test blob, stack-release.test.mjs whole 224/0 rc 0 in 595 s, alnum mktemp TMPDIR, default networks 6 before and 6 after. No overlap with in-review 272, 304, 305. |
| 304 | claude-session test builds the memory slug the way the CLI does (Vikunja 590) | rocko; reviewers dewey | #1811 | done | — | Dewey approves the diff; claude-session.test.mjs whole and green 3x (mktemp, underscore, space TMPDIRs) on Sage's gate, in a gap; old-regex mutant red at :298 (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: claude-session test builds the memory slug the way the CLI does (Vikunja 590) | Landed a863015a5 from rocko 5e8354ebb, range-diff equal, patch-id 808fa765. Gate: Dewey approve c31129 rev 3533; Sage gate on e551cd846 with file 7690a4a9, claude 2.1.309: claude-session.test.mjs whole 10/0 under mktemp, underscore and space TMPDIRs; old-regex mutant under underscore 9/1 at :298. Gates may drop the alnum TMPDIR rule (mos-1432 ends here). |
| 305 | Shell suites remove the compose network they create (Vikunja 591) | gizmoduck; reviewers filbert | #1812 | done | — | Filbert approves the diff; test-release.sh and test-task.sh whole and green on Sage's gate, in a gap, project network absent after each run; dropped-rm mutant red (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: shell suites remove the compose network they create (Vikunja 591) | Landed 353d65f57..d52d9f1f2 from gizmoduck 541dee2b7..7e8218a2e, range-diff all equal, on top of filbert 2c289f6f0. Gate: Filbert approve c31127 rev 3534; Sage gate on e551cd846 with task 7d6fbf67, release b29850ce: test-task 92/0, test-release 15/0, network check OK; mutants a and b red in both, c a known survivor; own mutant networks removed, none left. Gates stop the manual network rm (mos-1438 ends here). |
| 306 | A revoked run's late bus call stops it instead of exiting 22 (295 N1) | morgana; reviewers dewey | #1813 | briefed | 272 done | Dewey approves the design, then the manifest; bus, harness and cli suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: a revoked run's late bus call stops it instead of exiting 22 (295 N1) | — |
| 307 | Row 270's pi compaction reading moves behind the harness kind (REQ-PLUG-2, Vikunja 598) | gizmoduck; reviewers duckworth | #1814 | briefed | — | Duckworth approves the design, then the manifest; bus, cli, plugins (0 failures), webui and business suites, every scripts/test-*.sh green and the mutants killed on Sage's gate (sage) | `docs/plans/2026-10-23_harness-kind-compactions.md` § Brief: row 270's pi compaction reading moves behind the harness kind (Vikunja 598) | — |
<!-- mosaic-queue:end -->
+1306 -34
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -25,7 +25,8 @@ const baselines = {
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
"packages/webui/src/bus.mjs": "39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+7 -4
View File
@@ -306,10 +306,13 @@ export function createLauncher({
if (system?.compactionStreams !== undefined && streams !== system.compactionStreams) {
log(`system config: compactionStreams ${JSON.stringify(system.compactionStreams)} isn't a whole number from 32 to 65536; compaction collections open up to the default ${STREAMS} streams`);
}
const compactions = (store, harness, { instance, thread, session, run }) => {
// Row 307 (#1814): the route's harness module reads its own store through the kind's
// `compactions`. The call skips call(): call() turns a throw into a launch refusal, and a
// collection that fails must never stop or fail the run, so this catch records it instead.
const compactions = (store, name, harness, { instance, thread, session, run }) => {
let c;
try {
c = collectCompactions(store, harness, { budget, streams });
c = collectCompactions(store, { name, compactions: harness.compactions }, { budget, streams });
} catch (e) {
log(`run ${run}: compactions: ${e.message}`);
c = { count: null, known: false, reason: "store-unreadable" };
@@ -506,7 +509,7 @@ export function createLauncher({
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
if (store) compactions(store, model.harness, { instance, thread, session, run });
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
const toolSocket = join(runDir, "tools.sock");
const turnMarker = join(runDir, "turn.done");
const version = harnessVersion !== undefined ? harnessVersion : call(model.harness, "version", () => harness.version({ env }));
@@ -647,7 +650,7 @@ export function createLauncher({
} finally {
try { unregisterRuntime(dataRoot, managed.id); }
catch { log(`run ${run}: processing receipt cleanup refused`); }
if (store) compactions(store, model.harness, { instance, thread, session, run });
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
try { lock?.release(); }
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
children.delete(run);
@@ -28,6 +28,7 @@ import { collectCompactions, LEDGER } from "../../seat/src/compactions.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, SESSION_CREDENTIALS, sessionStore } from "../../seat/src/session.mjs";
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
@@ -341,7 +342,7 @@ test("REQ-THREAD-2 T3 T4 a clear leaves the retired session's count as it was, i
seeded.close();
const legacy = sessionStore(f.dataRoot, "acme", L, L, { legacy: true });
writeFileSync(join(legacy.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c7", parentId: null, timestamp: "2026-10-22T11:07:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
assert.equal(collectCompactions(legacy, "pi").count, 1);
assert.equal(collectCompactions(legacy, { name: "pi", compactions: pi }).count, 1);
host = await start(t, prepared);
const [row] = host.call("session.list", { thread: L });
assert.equal(row.id, L);
@@ -2175,6 +2175,7 @@ test('R9 mos-0963: a role reference left outside placeholders/, or left on an en
ownScript(e,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,r.out+r.err);
assert.ok(r.dir!==null, r.out+r.err);
assert.ok(r.out.includes(`FAIL credential-ref-live: pm gitea is ${tok}, not a file in ${join(r.dir,'placeholders')}\n`),r.out);
assert.deepEqual(r.names.slice(-3),['FAIL credential-ref-live','PASS backup-unchanged','PASS live-config-unchanged']);
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
@@ -269,7 +269,7 @@ test("claude adapter: a directory pin exits 2 before it runs; a pin with a space
test("claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do", { skip }, async (t) => {
const { dir, workspace, env } = await session(t, []);
const slug = realpathSync(workspace).replace(/[/.]/g, "-");
const slug = realpathSync(workspace).replace(/[^A-Za-z0-9]/g, "-");
const plant = {
"MARKER-USER": [join(env.HOME, ".claude", "CLAUDE.md"), join(env.CLAUDE_CONFIG_DIR, "CLAUDE.md")],
"MARKER-PARENT": [join(dir, "CLAUDE.md")],
+15
View File
@@ -153,6 +153,21 @@ export returns:
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
how a turn ends and where its transcripts land.
- `compactions({store, cursors, budget, streams, first})` reads a thread
session's native store for row 270's compaction count (row 307). Core keeps
the ledger (`packages/seat/src/compactions.mjs`) and passes `store` as
`{session, projects}`, the ledger's `cursors` by stream, the read `budget`
and stream cap `streams` from the system config, and `first` when the
store has no ledger yet. The answer is `{found, cursors, known, reason,
version}`: `found` lists `{entry, completedAt, stream}` past the cursors,
`cursors` maps each stream read to `{bytes, tail}` (`tail` is the sha256 of
up to 4096 bytes ending there), `reason` is null, `store-incomplete`,
`store-unreadable`, `not-collected` or `harness-unsupported`, `known` is
true exactly when `reason` is null, and `version` names the store format
read, or null. Core refuses any other answer and writes nothing. pi reads
its streams within the budget and the cap, and answers
`harness-unsupported` on a first collection that finds Claude history.
Claude Code answers `harness-unsupported` with version null (Vikunja 466).
- Row 66: both modules wire the unslop check from `tools/unslop-hook`.
`mounts` adds its three files read-only and `bundle` names them in the
manifest's sources. Pi's `env` loads `extension.ts` as the second entry of
@@ -128,3 +128,6 @@ export function env({ files, policy, session }) {
// A turn ends when claude exits; there is no marker.
export const turnEnd = () => "exit";
export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects"));
// Row 307 (#1814): Claude Code has no pinned compaction format yet (Vikunja 466), so a collection
// reads nothing and the count is unknown with harness-unsupported, never 0.
export const compactions = () => ({ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
+166
View File
@@ -0,0 +1,166 @@
// Row 307 (#1814, Vikunja 598): pi's half of row 270's compaction count (264c, #1775, T1 item 7
// D6), moved here from packages/seat/src/compactions.mjs. Core keeps the ledger and calls
// `compactions` through the harness kind (packages/plugins/README.md has the contract). This file
// reads only the session's native pi store and the Claude history beside it.
//
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
// written by SessionManager.appendCompaction after the summary exists. A start
// or an aborted compaction writes none. A `compactionSummary` message, a
// message inside a compaction's retainedTail and a branch_summary are not
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
// file of the same directory with their id and timestamp unchanged, so a
// compaction's key is (entry id, entry timestamp) over the whole store, and the
// stream it was first seen in is recorded beside it.
//
// A session whose first collection finds Claude history in claude-projects/
// from before the ledger existed is unknown with harness-unsupported (row 270
// r2, B3): those runs' compactions can't be counted (Vikunja 466).
import { createHash } from "node:crypto";
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
import { join } from "node:path";
export const PI_VERSION = "0.85.1";
const TAIL = 4096;
const CHUNK = 1 << 20;
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
// store-incomplete outranks store-unreadable: lost entries don't come back.
// not-collected is last: the next collection reads on.
const REASONS = ["store-incomplete", "store-unreadable", "not-collected"];
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
// Only the short tail reads (at most TAIL bytes) use this.
function readRange(fd, from, to) {
const out = Buffer.alloc(to - from);
let off = 0;
while (off < out.length) {
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
if (n === 0) break;
off += n;
}
return out.subarray(0, off);
}
// One collection over a Pi session directory. `cursors` are the ledger's,
// by stream. Opens at most `streams` streams, in name order, and reads at
// most `budget` bytes past their cursors, over all of them.
// Returns the compactions found past each cursor, the new cursors, and the
// problems: store-unreadable (a file that can't be read or isn't a Pi
// session), store-incomplete (a stream that shrank, changed or went away, or
// a line that isn't JSON) and not-collected (the budget or the stream cap
// ran out first). A
// missing directory is empty.
function scanPi(dir, cursors, budget, streams) {
const found = [], next = {}, problems = new Set();
let left = budget;
let names;
try {
names = readdirSync(dir);
} catch (e) {
if (e.code !== "ENOENT") problems.add("store-unreadable");
names = [];
}
const present = new Set(names);
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
if (visit.length > streams) problems.add("not-collected");
for (const name of visit.slice(0, streams)) {
const path = join(dir, name);
let fd;
try {
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
} catch {
problems.add("store-unreadable");
continue;
}
try {
const size = fstatSync(fd).size;
const c = cursors[name];
let from = 0;
if (c) {
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
else problems.add("store-incomplete");
}
let header = from > 0;
const take = (raw) => {
const line = raw.toString("utf8");
if (!line.trim()) return;
let e;
try {
e = JSON.parse(line);
} catch {
e = null;
}
if (!e || typeof e !== "object" || Array.isArray(e)) {
problems.add("store-incomplete");
return;
}
if (!header) {
header = true;
if (e.type !== "session") problems.add("store-unreadable");
return;
}
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
}
};
// A CHUNK at a time, up to the budget left. Complete lines only: a line
// split across chunks waits in `carry`, and a partial last line waits
// for its newline. `end` is past the last whole line.
const stop = Math.min(size, from + left);
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
let pos = from, end = from, carry = [];
while (pos < stop) {
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
if (n === 0) break;
const view = chunk.subarray(0, n);
let i = 0;
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
carry = [];
i = nl + 1;
end = pos + i;
}
if (i < n) carry.push(Buffer.from(view.subarray(i)));
pos += n;
}
left -= pos - from;
if (stop < size) problems.add("not-collected");
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
} catch {
problems.add("store-unreadable");
} finally {
closeSync(fd);
}
}
return { found, cursors: next, problems: [...problems].sort() };
}
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
// directory that can't be read throws, so the collection is not recorded and
// the next one checks again.
function claudeHistory(store) {
if (!store.projects) return false;
try {
return readdirSync(store.projects).length > 0;
} catch (e) {
if (e.code === "ENOENT") return false;
throw e;
}
}
// The harness kind's `compactions` for pi: the compactions past the ledger's
// cursors, the moved cursors, and the reason the count isn't whole, if any.
export function compactions({ store, cursors, budget, streams, first }) {
if (first && claudeHistory(store)) {
// Claude runs before this ledger: their compactions can't be counted
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
return { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: PI_VERSION };
}
const scan = scanPi(store.session, cursors, budget, streams);
const reason = REASONS.find((p) => scan.problems.includes(p)) ?? null;
return { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: PI_VERSION };
}
+2
View File
@@ -73,3 +73,5 @@ export function env({ policy, tools, session }) {
// The extension writes the turn marker at agent_end; an exit without it is a failed turn.
export const turnEnd = () => "marker";
export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session"));
// Row 307 (#1814): the session store's compactions, read from its pi streams (compactions.mjs).
export { compactions } from "./compactions.mjs";
+3 -1
View File
@@ -49,10 +49,12 @@ export const KINDS = Object.freeze({
// env({ files, policy, tools, session }) -> its adapter variables, written into session.json
// turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit"
// transcript({ runDir, store }) -> the directory its transcripts land in, for usage
// compactions({ store, cursors, budget, streams, first }) -> { found, cursors, known, reason, version },
// the session store's compactions past the ledger's cursors (row 307)
harness: Object.freeze({
kind: "harness", interfaceVersion: 1, switch: "list",
exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"]),
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"]),
}),
});
@@ -0,0 +1,75 @@
// Row 307 (#1814, Vikunja 598): the harness kind's `compactions`. pi's answers what row 270's
// scanPi read at 1e629c892, with its reason order and version; claude-code reads nothing and
// answers harness-unsupported; a module without the export doesn't load.
//
// CASES: each case's streams and cursors, scanned at 1e629c892 with its budget and cap, give its
// answer (agents/gizmoduck/work/queue-307/capture.mjs checks that).
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { KINDS } from "../src/kinds.mjs";
import * as pi from "../harness/pi/index.mjs";
import * as claude from "../harness/claude-code/index.mjs";
import { tree } from "./helpers.mjs";
// capture:begin
const CASES = {
"plain": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": true, "reason": null, "version": "0.85.1"}},
"budget": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 100, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
"cap": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n", "b.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
"shrank": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "store-incomplete", "version": "0.85.1"}},
"header": {"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 29, "tail": "91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df"}}, "known": false, "reason": "store-unreadable", "version": "0.85.1"}}
};
// capture:end
function stores(t, streams = {}, projects = {}) {
const root = mkdtempSync(join(tmpdir(), "mosaic-harness-compactions-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const store = { session: join(root, "session"), projects: join(root, "claude-projects") };
for (const [dir, files] of [[store.session, streams], [store.projects, projects]]) {
mkdirSync(dir);
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
}
return store;
}
for (const [name, c] of Object.entries(CASES)) {
test(`row 307 pi answers what row 270 read: ${name}`, (t) => {
const store = stores(t, c.streams);
assert.deepEqual(pi.compactions({ store, cursors: c.cursors, budget: c.budget, streams: c.cap, first: false }), c.answer);
});
}
test("row 307 pi's first collection over Claude history answers harness-unsupported without a scan, and a later one scans", (t) => {
const { streams, budget, cap, answer } = CASES.plain;
const store = stores(t, streams, { "p.jsonl": "{}\n" });
const args = { store, cursors: {}, budget, streams: cap };
assert.deepEqual(pi.compactions({ ...args, first: true }), { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: "0.85.1" });
assert.deepEqual(pi.compactions({ ...args, first: false }), answer);
// With no Claude history, the first collection scans too.
assert.deepEqual(pi.compactions({ ...args, store: stores(t, streams), first: true }), answer);
});
test("row 307 claude-code reads nothing and answers harness-unsupported with no version", () => {
const store = { session: "/nonexistent/session", projects: "/nonexistent/claude-projects" };
for (const first of [true, false]) {
assert.deepEqual(claude.compactions({ store, cursors: {}, budget: 1 << 20, streams: 32, first }),
{ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
}
});
test("row 307 a harness module without compactions doesn't load: missing-export", async (t) => {
const s = tree(t);
const url = pathToFileURL(resolve(fileURLToPath(new URL("../harness/pi/index.mjs", import.meta.url)))).href;
const names = ["interfaceVersion", ...KINDS.harness.exports.filter((k) => k !== "compactions")];
s.write("harness", "no-compactions", `export { ${names.join(", ")} } from ${JSON.stringify(url)};\n`);
const l = await s.loader();
const result = await l.load({ harness: ["no-compactions"] });
assert.deepEqual(result.refused.map((e) => [e.kind, e.name, e.code]), [["harness", "no-compactions", "missing-export"]]);
assert.match(result.refused[0].message, /^harness no-compactions doesn't export compactions$/);
assert.deepEqual(l.loaded().harness, []);
});
+1 -1
View File
@@ -17,7 +17,7 @@ test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1,
assert.deepEqual({ ...KINDS.harness, exports: [...KINDS.harness.exports] }, {
kind: "harness", interfaceVersion: 1, switch: "list",
exports: ["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"],
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"],
});
assert.ok(Object.isFrozen(KINDS.harness) && Object.isFrozen(KINDS.harness.exports));
assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports));
+58 -171
View File
@@ -9,19 +9,14 @@
// workspace lock (session.mjs lockSessionWorkspace); each collection is one
// O_APPEND write. The count is rebuilt from the ledger, never from a page.
//
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
// written by SessionManager.appendCompaction after the summary exists. A start
// or an aborted compaction writes none. A `compactionSummary` message, a
// message inside a compaction's retainedTail and a branch_summary are not
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
// file of the same directory with their id and timestamp unchanged, so a
// compaction's key is (entry id, entry timestamp) over the whole store, and the
// stream it was first seen in is recorded beside it.
//
// Claude Code has no pinned compaction format yet (Vikunja 466): a session
// any Claude run collected for is unknown with harness-unsupported, never 0.
// So is one whose first collection finds Claude history in claude-projects/
// from before this ledger existed (row 270 r2, B3).
// What a compaction is depends on the harness, so core never reads a native
// stream itself (row 307, #1814, Vikunja 598). Each collection calls the route's
// harness module's `compactions` (packages/plugins/README.md has the contract),
// checks its answer, and writes the ledger lines from it. A compaction's key is
// (entry, completedAt) over the whole store, and the stream it was first seen in
// is recorded beside it. A harness with no pinned compaction format answers
// harness-unsupported, and so does one that can't count the store's history: a
// session any such collection ran for is unknown for good, never 0.
//
// One collection reads at most a budget of stream bytes (row 270 r2, B1).
// The budget is the system config's compactionReadBytes, which only a human
@@ -46,14 +41,11 @@
// that shrank, changed under its cursor or went away is store-incomplete from
// then on: entries may be lost, so the count stays a lower bound for good.
import { createHash } from "node:crypto";
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, writeSync } from "node:fs";
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
import { join } from "node:path";
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export const PI_VERSION = "0.85.1";
export const LEDGER = "compactions.jsonl";
const TAIL = 4096;
const CHUNK = 1 << 20;
// BUILD.md (queue-270, round 2) has the measured stream sizes behind the default.
export const READ_BYTES = 64 * CHUNK;
@@ -61,9 +53,7 @@ const READ_MIN = CHUNK, READ_MAX = 1024 * CHUNK;
// BUILD.md (queue-270, round 3) has the measured stream counts behind the default.
export const STREAMS = 1024;
const STREAMS_MIN = 32, STREAMS_MAX = 65536;
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
const keyOf = (entry, completedAt) => JSON.stringify([entry, completedAt]);
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
@@ -87,115 +77,6 @@ export function readStreams(value) {
return Number.isSafeInteger(value) && value >= STREAMS_MIN && value <= STREAMS_MAX ? value : STREAMS;
}
// Only the short tail reads (at most TAIL bytes) use this.
function readRange(fd, from, to) {
const out = Buffer.alloc(to - from);
let off = 0;
while (off < out.length) {
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
if (n === 0) break;
off += n;
}
return out.subarray(0, off);
}
// One collection over a Pi session directory. `cursors` are the ledger's,
// by stream. Opens at most `streams` streams, in name order, and reads at
// most `budget` bytes past their cursors, over all of them.
// Returns the compactions found past each cursor, the new cursors, and the
// problems: store-unreadable (a file that can't be read or isn't a Pi
// session), store-incomplete (a stream that shrank, changed or went away, or
// a line that isn't JSON) and not-collected (the budget or the stream cap
// ran out first). A
// missing directory is empty.
export function scanPi(dir, cursors = {}, budget = READ_BYTES, streams = STREAMS) {
const found = [], next = {}, problems = new Set();
let left = budget;
let names;
try {
names = readdirSync(dir);
} catch (e) {
if (e.code !== "ENOENT") problems.add("store-unreadable");
names = [];
}
const present = new Set(names);
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
if (visit.length > streams) problems.add("not-collected");
for (const name of visit.slice(0, streams)) {
const path = join(dir, name);
let fd;
try {
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
} catch {
problems.add("store-unreadable");
continue;
}
try {
const size = fstatSync(fd).size;
const c = cursors[name];
let from = 0;
if (c) {
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
else problems.add("store-incomplete");
}
let header = from > 0;
const take = (raw) => {
const line = raw.toString("utf8");
if (!line.trim()) return;
let e;
try {
e = JSON.parse(line);
} catch {
e = null;
}
if (!e || typeof e !== "object" || Array.isArray(e)) {
problems.add("store-incomplete");
return;
}
if (!header) {
header = true;
if (e.type !== "session") problems.add("store-unreadable");
return;
}
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
}
};
// A CHUNK at a time, up to the budget left. Complete lines only: a line
// split across chunks waits in `carry`, and a partial last line waits
// for its newline. `end` is past the last whole line.
const stop = Math.min(size, from + left);
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
let pos = from, end = from, carry = [];
while (pos < stop) {
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
if (n === 0) break;
const view = chunk.subarray(0, n);
let i = 0;
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
carry = [];
i = nl + 1;
end = pos + i;
}
if (i < n) carry.push(Buffer.from(view.subarray(i)));
pos += n;
}
left -= pos - from;
if (stop < size) problems.add("not-collected");
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
} catch {
problems.add("store-unreadable");
} finally {
closeSync(fd);
}
}
return { found, cursors: next, problems: [...problems].sort() };
}
// The ledger as recorded. Throws on a ledger that can't be read. `bad` is set
// by any line that isn't a v 1 record, a torn last line included.
export function readLedger(dir) {
@@ -229,28 +110,16 @@ export function readLedger(dir) {
else if (r.kind === "collected") {
last = r;
if (r.reason === "store-incomplete") incomplete = true;
if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;
if (r.reason === "harness-unsupported") unsupported = true;
} else bad = true;
}
return { events, cursors, last, incomplete, unsupported, bad, torn };
}
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
// directory that can't be read throws, so the collection is not recorded and
// the next one checks again.
function claudeHistory(store) {
if (!store.projects) return false;
try {
return readdirSync(store.projects).length > 0;
} catch (e) {
if (e.code === "ENOENT") return false;
throw e;
}
}
// What session.list shows: { count, known, reason, at }. Known only when the
// ledger is whole, every collection ran on Pi and none lost entries. Any other
// answer is unknown with a reason and a lower bound, or null for none: never 0.
// ledger is whole, no collection was harness-unsupported and none lost
// entries. Any other answer is unknown with a reason and a lower bound, or null
// for none: never 0.
export function compactionCount(dir) {
const unknown = (reason, count = null, at = null) => ({ count: count || null, known: false, reason, at });
let ledger;
@@ -270,39 +139,57 @@ export function compactionCount(dir) {
return { count, known: true, reason: null, at };
}
// One collection for a session's store ({ dir, session } from sessionStore),
// at run start (resume) and run end, under the session's workspace lock.
// Appends the new compactions, the moved cursors and one collected line in a
// single O_APPEND write, then answers the count. A key already in the ledger
// is never written again, and nothing rewrites the file.
const REASONS = ["store-incomplete", "store-unreadable", "not-collected", "harness-unsupported"];
const HEX = /^[0-9a-f]{64}$/;
// The harness module's answer, checked before anything is written: what the
// export's contract allows and nothing else. Returns what's wrong, or null.
function checkAnswer(a) {
const plain = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
if (!plain(a)) return "not an object";
if (!Array.isArray(a.found)) return "found isn't a list";
for (const f of a.found) {
if (!plain(f) || typeof f.entry !== "string" || !f.entry) return "a found entry has no entry id";
if (f.completedAt !== null && typeof f.completedAt !== "string") return `found ${f.entry} has a completedAt that isn't a string or null`;
if (typeof f.stream !== "string" || !f.stream) return `found ${f.entry} has no stream`;
}
if (!plain(a.cursors)) return "cursors isn't an object";
for (const [stream, c] of Object.entries(a.cursors)) {
if (!plain(c) || !Number.isSafeInteger(c.bytes) || c.bytes < 0 || typeof c.tail !== "string" || !HEX.test(c.tail)) return `cursor ${JSON.stringify(stream)} isn't { bytes, tail }`;
}
if (a.reason !== null && !REASONS.includes(a.reason)) return `reason ${JSON.stringify(a.reason)} isn't one of ${REASONS.join(", ")}`;
if (a.known !== (a.reason === null)) return "known must be true exactly when reason is null";
if (a.version !== null && typeof a.version !== "string") return "version isn't a string or null";
return null;
}
// One collection for a session's store ({ dir, session, projects } from
// sessionStore), at run start (resume) and run end, under the session's
// workspace lock. `harness` is { name, compactions }: the route's harness name
// and its loaded module's export. Appends the new compactions, the moved
// cursors and one collected line in a single O_APPEND write, then answers the
// count. A key already in the ledger is never written again, and nothing
// rewrites the file. A module that throws or answers out of contract throws
// here, before anything is written.
export function collectCompactions(store, harness, { now = () => new Date().toISOString(), budget = READ_BYTES, streams = STREAMS } = {}) {
const prior = readLedger(store.dir);
const ledger = prior ?? { events: new Map(), cursors: {}, torn: false };
const at = now();
const answer = harness.compactions({ store: { session: store.session, projects: store.projects }, cursors: { ...ledger.cursors }, budget, streams, first: prior === null });
const wrong = checkAnswer(answer);
if (wrong) throw new Error(`harness ${harness.name} compactions: bad answer: ${wrong}`);
const out = [];
if (harness === "pi" && !prior && claudeHistory(store)) {
// Claude runs before this ledger: their compactions can't be counted
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: PI_VERSION });
} else if (harness === "pi") {
const scan = scanPi(store.session, ledger.cursors, budget, streams);
for (const f of scan.found) {
const key = keyOf(f.entry, f.completedAt);
if (ledger.events.has(key)) continue;
ledger.events.set(key, f);
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
}
for (const [stream, c] of Object.entries(scan.cursors)) {
const old = ledger.cursors[stream];
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
}
// store-incomplete outranks store-unreadable: lost entries don't come back.
// not-collected is last: the next collection reads on.
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
out.push({ v: 1, kind: "collected", at, known: reason === null, reason, harness, version: PI_VERSION });
} else {
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: null });
for (const f of answer.found) {
const key = keyOf(f.entry, f.completedAt);
if (ledger.events.has(key)) continue;
ledger.events.set(key, f);
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
}
for (const [stream, c] of Object.entries(answer.cursors)) {
const old = ledger.cursors[stream];
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
}
out.push({ v: 1, kind: "collected", at, known: answer.known, reason: answer.reason, harness: harness.name, version: answer.version });
const fd = openSync(join(store.dir, LEDGER), fsc.O_WRONLY | fsc.O_APPEND | fsc.O_CREAT | fsc.O_NOFOLLOW, 0o600);
try {
if (!fstatSync(fd).isFile()) throw Object.assign(new Error(`${join(store.dir, LEDGER)} isn't a regular file`), { code: "ledger-unsafe" });
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectCompactions, LEDGER, READ_BYTES, readBudget, readStreams, STREAMS } from "../src/compactions.mjs";
import { sessionStore } from "../src/session.mjs";
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
const MiB = 1 << 20;
@@ -22,7 +23,7 @@ function setup(t) {
let tick = 0;
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
const collect = (harness = "pi", budget = undefined, streams = undefined) =>
collectCompactions(store, harness, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
collectCompactions(store, { name: harness, compactions: { pi }[harness] }, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
const records = () => readFileSync(join(store.dir, LEDGER), "utf8").trim().split("\n").map((l) => JSON.parse(l));
return { root, store, collect, records };
}
@@ -0,0 +1,184 @@
// Row 307 (#1814, Vikunja 598): core keeps the ledger and asks the harness module for the
// compactions. A ledger row 270 wrote reads the same count after the move, the next collection
// goes through the module's `compactions`, and core refuses an answer outside the contract before
// it writes anything.
//
// FIXTURES are what row 270's collectCompactions wrote at 1e629c892, one ledger per reason it
// writes plus a torn one: each fixture's streams, Claude projects and steps, replayed there with
// the clock at 12:00:00 and one second a step, give its ledger byte for byte
// (agents/gizmoduck/work/queue-307/capture.mjs checks that). `count` is compactionCount over it,
// and `next` is the count after one more pi collection at the next second.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectCompactions, compactionCount, LEDGER } from "../src/compactions.mjs";
import { sessionStore } from "../src/session.mjs";
import * as pi from "../../plugins/harness/pi/index.mjs";
// capture:begin
const FIXTURES = {
"known": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
},
"store-incomplete": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\nnot json\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":99,\"tail\":\"14607eb2c2f70f460e1a420da1b4ead7f00f2a81cc4105e9e55e6c5c828d6626\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-incomplete\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"}
},
"store-unreadable": {
"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":29,\"tail\":\"91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-unreadable\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "store-unreadable", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 0, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
},
"not-collected": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"steps": [{"collect": "pi", "budget": 100}],
"ledger": [
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":90,\"tail\":\"2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"not-collected\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "not-collected", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
},
"claude-code": {
"streams": {},
"steps": [{"collect": "claude-code"}],
"ledger": [
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"claude-code\",\"version\":null}"
],
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
},
"claude-history": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"projects": {"p.jsonl": "{}\n"},
"steps": [{"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
"next": {"count": 1, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
},
"torn": {
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
"steps": [{"collect": "pi"}, {"tear": "{\"v\":1,\"kind\":\"coll"}, {"collect": "pi"}],
"ledger": [
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}",
"{\"v\":1,\"kind\":\"coll",
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:01.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
],
"count": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"},
"next": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:02.000Z"}
}
};
// capture:end
function setup(t, fx = { streams: {}, ledger: null }) {
const root = mkdtempSync(join(tmpdir(), "seat-compactions-ledger-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const store = sessionStore(root, "acme", "th1", "s1");
for (const [name, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, name), text);
for (const [name, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, name), text);
if (fx.ledger) writeFileSync(join(store.dir, LEDGER), fx.ledger.join("\n") + "\n", { mode: 0o600 });
const ledger = () => {
try {
return readFileSync(join(store.dir, LEDGER), "utf8");
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
};
return { root, store, ledger };
}
for (const [name, fx] of Object.entries(FIXTURES)) {
test(`row 307 the ${name} ledger row 270 wrote reads the same count`, (t) => {
const f = setup(t, fx);
assert.deepEqual(compactionCount(f.store.dir), fx.count);
});
test(`row 307 after the ${name} ledger, one collection through pi's module answers the next count`, (t) => {
const f = setup(t, fx);
assert.deepEqual(collectCompactions(f.store, { name: "pi", compactions: pi.compactions }, { now: () => fx.next.at }), fx.next);
});
}
const ok = { found: [], cursors: {}, known: true, reason: null, version: null };
const HEX = "0".repeat(64);
const BAD = [
[null, "not an object"],
[[], "not an object"],
[{ ...ok, found: {} }, "found isn't a list"],
[{ ...ok, found: [{ entry: "", completedAt: null, stream: "a.jsonl" }] }, "a found entry has no entry id"],
[{ ...ok, found: ["c1"] }, "a found entry has no entry id"],
[{ ...ok, found: [{ entry: "c1", completedAt: 5, stream: "a.jsonl" }] }, "found c1 has a completedAt that isn't a string or null"],
[{ ...ok, found: [{ entry: "c1", completedAt: null, stream: "" }] }, "found c1 has no stream"],
[{ ...ok, cursors: [] }, "cursors isn't an object"],
[{ ...ok, cursors: { "a.jsonl": { bytes: -1, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, cursors: { "a.jsonl": { bytes: 1.5, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: HEX.toUpperCase().replace(/0/g, "A") } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: "0".repeat(63) } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
[{ ...ok, reason: "lost", known: false }, 'reason "lost" isn\'t one of store-incomplete, store-unreadable, not-collected, harness-unsupported'],
[{ ...ok, reason: "not-collected" }, "known must be true exactly when reason is null"],
[{ ...ok, known: false }, "known must be true exactly when reason is null"],
[{ ...ok, known: 1 }, "known must be true exactly when reason is null"],
[{ ...ok, version: 1 }, "version isn't a string or null"],
];
test("row 307 core refuses an answer outside the contract and appends nothing", (t) => {
for (const fx of [undefined, FIXTURES.known]) {
const f = setup(t, fx);
const before = f.ledger();
for (const [answer, what] of BAD) {
const message = `harness odd compactions: bad answer: ${what}`;
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => answer }), { message }, JSON.stringify(answer));
assert.equal(f.ledger(), before, JSON.stringify(answer));
}
// The module's own throw reaches the caller as it is, and nothing is written either.
const boom = new Error("boom");
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => { throw boom; } }), (e) => e === boom);
assert.equal(f.ledger(), before);
// A good answer is written: the check refuses only what's out of contract.
assert.equal(collectCompactions(f.store, { name: "odd", compactions: () => ok }, { now: () => "2026-10-22T12:00:09.000Z" }).at, "2026-10-22T12:00:09.000Z");
}
});
test("row 307 the module gets the store's two native directories, a copy of the cursors, the budget, the cap and first", (t) => {
const seen = [];
// The module writes into the cursors it got and answers the same cursor: core's ledger copy
// doesn't have it, so core writes it.
const harness = { name: "odd", compactions: (args) => (seen.push(structuredClone(args)), args.cursors.x = { bytes: 0, tail: HEX }, { ...ok, cursors: { x: { bytes: 0, tail: HEX } } }) };
const fresh = setup(t);
collectCompactions(fresh.store, harness, { budget: 123, streams: 45 });
const known = setup(t, FIXTURES.known);
collectCompactions(known.store, harness);
const store = (f) => ({ session: f.store.session, projects: f.store.projects });
assert.deepEqual(seen, [
{ store: store(fresh), cursors: {}, budget: 123, streams: 45, first: true },
{ store: store(known), cursors: { "a.jsonl": { bytes: 233, tail: "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a" } }, budget: 64 << 20, streams: 1024, first: false },
]);
assert.equal(readFileSync(join(known.store.dir, LEDGER), "utf8").split("\n").filter((l) => l.includes('"stream":"x"')).length, 1);
});
+3 -1
View File
@@ -11,6 +11,8 @@ import { join, relative, isAbsolute } from "node:path";
import { collectCompactions, compactionCount, LEDGER, readLedger, storeDir } from "../src/compactions.mjs";
import { binds } from "../src/sandbox.mjs";
import { sessionStore } from "../src/session.mjs";
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
import { compactions as claude } from "../../plugins/harness/claude-code/index.mjs";
function setup(t, { legacy = false } = {}) {
const root = mkdtempSync(join(tmpdir(), "seat-compactions-"));
@@ -18,7 +20,7 @@ function setup(t, { legacy = false } = {}) {
const store = sessionStore(root, "acme", "th1", legacy ? "th1" : "s1", { legacy });
let tick = 0;
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
const collect = (harness = "pi") => collectCompactions(store, harness, { now });
const collect = (harness = "pi") => collectCompactions(store, { name: harness, compactions: { pi, claude }[harness] }, { now });
const ledger = () => readFileSync(join(store.dir, LEDGER), "utf8");
const records = () => ledger().trim().split("\n").map((l) => JSON.parse(l));
return { root, store, collect, ledger, records };
+58
View File
@@ -10,6 +10,7 @@
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -72,6 +73,63 @@ export function busCaller({ dataRoot, socket, business = null, env = process.env
}
export const busReader = options => views({ call: busCaller(options) });
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Same
// protocol as humanCall: the request on stdin, JSON on stdout, or a bus error code as the last
// `^[a-z-]{1,64}$` line of stderr. Row 301 (#1808) moved it here from user-bus.mjs.
export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url));
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
// can never pass as human-cli, and it only ever runs USER_CLI.
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
out.push(chunk);
});
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
child.on('close', status => {
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
if (status !== 0) {
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
return done(reject, new BusReadError(code));
}
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
catch { done(reject, new BusReadError('invalid-response')); }
});
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
});
}
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
export function busBusiness({ dataRoot, business = null }) {
return () => {
if (business) return business;
let state;
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
return state.business;
};
}
// One granted user's caller, for that user's session only.
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
const which = busBusiness({ dataRoot, business });
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
}
// A send's error, sorted. The broker writes in one transaction, so any code it
// returns means nothing was written. A transport that lost the answer, or an
// answer that can't be read, leaves the outcome open: the message may exist.
+1 -2
View File
@@ -2,8 +2,7 @@
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller } from './bus.mjs';
import { busBusiness, userCaller } from './user-bus.mjs';
import { busCaller, busBusiness, userCaller } from './bus.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
+1 -1
View File
@@ -1,6 +1,6 @@
// FED R6 (#1800): the Console's bus-backed options for startServer, built over one transport
// `call`. cli.mjs builds them once over the human transport. A granted user's session gets the same
// set over its own user transport (user-bus.mjs userCaller), so no route a user reaches holds a
// set over its own user transport (bus.mjs userCaller), so no route a user reaches holds a
// human-transport object, and the broker decides every verb the user sends (design 3.1 D4).
import { createChat } from './chat.mjs';
import { views } from '../../bus/src/views.mjs';
-63
View File
@@ -1,63 +0,0 @@
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Kept apart
// from bus.mjs so the human transport's reviewed bytes stay as row 164 froze them
// (bus/tests/channel-proof.test.mjs). Same protocol as humanCall: the request on stdin, JSON on
// stdout, or a bus error code as the last `^[a-z-]{1,64}$` line of stderr.
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { readHostState } from '../../cli/src/host.mjs';
import { BusReadError } from './bus.mjs';
export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url));
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
// can never pass as human-cli, and it only ever runs USER_CLI.
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
out.push(chunk);
});
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
child.on('close', status => {
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
if (status !== 0) {
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
return done(reject, new BusReadError(code));
}
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
catch { done(reject, new BusReadError('invalid-response')); }
});
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
});
}
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
export function busBusiness({ dataRoot, business = null }) {
return () => {
if (business) return business;
let state;
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
return state.business;
};
}
// One granted user's caller, for that user's session only.
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
const which = busBusiness({ dataRoot, business });
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
}
+1 -2
View File
@@ -19,8 +19,7 @@ import { startServer, userRoute } from '../src/serve.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { consoleAccess } from '../src/access.mjs';
import { consoleBus, userOptionsFor } from '../src/console-bus.mjs';
import { BusReadError } from '../src/bus.mjs';
import { USER_CLI, userCall } from '../src/user-bus.mjs';
import { BusReadError, USER_CLI, userCall } from '../src/bus.mjs';
import { close, fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
+1 -1
View File
@@ -14,7 +14,7 @@ test('C2-04 CLI: both listener callback pairs dispatch library verbs with their
// The loader supplies a fixture bus and captures callbacks instead of
// opening an HTTP listener. Production imports and routes are unchanged.
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){if(typeof o.libraryFile!=='function'||typeof o.libraryStatus!=='function')throw Error('missing library callbacks');await o.libraryFile({project:'books',path:'Book One/A.md'});await o.libraryStatus({project:'books'});return {address:()=>({port:0}),close:()=>{}};}";
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};`;
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';
registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
+1 -1
View File
@@ -55,7 +55,7 @@ test('row278 Console adapter calls argument-free thread.claims through the norma
const root = tmp(t, 'claims-cli-'), f = fixture(root), log = join(root, 'calls.jsonl'), hook = join(root, 'hooks.mjs');
writeFileSync(log, '');
const serveSource = `export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){await o.bus['thread.claims']();return {address:()=>({port:0}),close:()=>{}};}`;
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,...args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,tester:!!o.cli})+'\\n');return [];};`;
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,...args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,tester:!!o.cli})+'\\n');return [];};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
+30 -1
View File
@@ -12,7 +12,22 @@ RELEASE_BACKUP="$(mktemp)"
cp RELEASE "$RELEASE_BACKUP"
# One exit trap: the repo RELEASE is ALWAYS restored from the backup,
# regardless of how the test run ends.
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT
# Compose names the project after the checkout directory unless told otherwise,
# and `compose run --rm` leaves the project's <project>_default network behind.
# Every run gets its own project, and the exit trap removes that one network
# (row 305). It never runs `compose down` and never touches another network.
COMPOSE_ID="$(basename "$SANDBOX")"
COMPOSE_ID="$(printf '%s' "${COMPOSE_ID##*.}" | tr -cd '[:alnum:]' | tr '[:upper:]' '[:lower:]')"
export COMPOSE_PROJECT_NAME="mosaic-test-release-$COMPOSE_ID"
remove_compose_network() { # absent is fine; any other failure is one stderr line, never a status
command -v docker >/dev/null 2>&1 || return 0
local net="${COMPOSE_PROJECT_NAME}_default" err
err="$(docker network rm "$net" 2>&1 >/dev/null)" && return 0
case "$err" in *"not found"*|*"No such network"*) return 0 ;; esac
echo "test-release: could not remove network $net: ${err%%$'\n'*}" >&2
return 0
}
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"; remove_compose_network' EXIT
# Compose mounts PI_AUTH_FILE. Point it at a fixture so no activation reaches
# the host's pi credential (row 244). chmod after the write: umask-proof (row 243).
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
@@ -108,6 +123,20 @@ else
echo "skip state-machine cases (docker daemon unavailable)"
fi
# The compose network goes with the run (row 305). Present before the remove
# proves compose used this run's project name; absent after proves the trap's
# cleanup removes it (mos-1445).
if docker info >/dev/null 2>&1; then
NET="${COMPOSE_PROJECT_NAME}_default"
BEFORE="$(docker network ls -q --filter "name=^${NET}\$")" && [ -n "$BEFORE" ] \
&& remove_compose_network \
&& AFTER="$(docker network ls -q --filter "name=^${NET}\$")" && [ -z "$AFTER" ] \
&& check "compose network made under the run's project and removed after the run" 0 \
|| check "compose network made under the run's project and removed after the run" 1
else
echo "skip compose network check (docker daemon unavailable)"
fi
echo
echo "selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
+30 -1
View File
@@ -8,7 +8,22 @@ set -uo pipefail
cd "$(dirname "$0")/.."
SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
# Compose names the project after the checkout directory unless told otherwise,
# and `compose run --rm` leaves the project's <project>_default network behind.
# Every run gets its own project, and the exit trap removes that one network
# (row 305). It never runs `compose down` and never touches another network.
COMPOSE_ID="$(basename "$SANDBOX")"
COMPOSE_ID="$(printf '%s' "${COMPOSE_ID##*.}" | tr -cd '[:alnum:]' | tr '[:upper:]' '[:lower:]')"
export COMPOSE_PROJECT_NAME="mosaic-test-task-$COMPOSE_ID"
remove_compose_network() { # absent is fine; any other failure is one stderr line, never a status
command -v docker >/dev/null 2>&1 || return 0
local net="${COMPOSE_PROJECT_NAME}_default" err
err="$(docker network rm "$net" 2>&1 >/dev/null)" && return 0
case "$err" in *"not found"*|*"No such network"*) return 0 ;; esac
echo "test-task: could not remove network $net: ${err%%$'\n'*}" >&2
return 0
}
trap 'rm -rf "$SANDBOX"; remove_compose_network' EXIT
# Real-model cases need the operator's credential and a billed provider call,
# so they run only on request (row 244, sage-2061). The gate lane never passes
# MOSAIC_LIVE_PROVIDER through.
@@ -558,6 +573,20 @@ fi
git -C "$SANDBOX/repo" rev-parse >/dev/null 2>&1 || true
unset MOSAIC_CONFIG
# The compose network goes with the run (row 305). Present before the remove
# proves compose used this run's project name; absent after proves the trap's
# cleanup removes it (mos-1445).
if docker info >/dev/null 2>&1; then
NET="${COMPOSE_PROJECT_NAME}_default"
BEFORE="$(docker network ls -q --filter "name=^${NET}\$")" && [ -n "$BEFORE" ] \
&& remove_compose_network \
&& AFTER="$(docker network ls -q --filter "name=^${NET}\$")" && [ -z "$AFTER" ] \
&& check "compose network made under the run's project and removed after the run" 0 \
|| check "compose network made under the run's project and removed after the run" 1
else
echo "skip compose network check (docker daemon unavailable)"
fi
echo
echo "selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]