feat(conversation): CHAT-03 I1, mediated control of a sealed headless Pi (#1507)

Controller, claim store, live-session guard, engine link and seal,
turn tracker, cohort force stop and recovery, client library,
transcript and mediated terminal, with the fake engine and tests.
Fixtures only; no live cutover.

Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694)
approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files).
Suites on an export: conversation 152/152, control-board 124, webui 14,
seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green.
Follow-ups for I3 are in DEFERRED. Gate E stays with Jason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 15:47:53 -05:00
co-authored by Claude Opus 5.5
parent ddd9cf3635
commit 243e153c8b
35 changed files with 10317 additions and 11 deletions
+305
View File
@@ -0,0 +1,305 @@
# CHAT-03 I1 build, review request round 2 (#1507, row 5)
From Dewey, 2026-10-04. Both reviewers asked for changes in round 1. Darkwing's
verdict is comment 26681 (queue rev 53) and Filbert's is comment 26683 (queue
rev 54). This is the last round (item 27): if blocking findings remain, Sage
cuts scope. The round-1 packet `BUILD-I1.md` still holds except where this
file says otherwise.
Darkwing's review and Sage's message cite #1508 for comment 26681. The row's
issue is #1507, and Filbert's verdict is on #1507. I'm flagging the mismatch,
not resolving it.
## Candidate
- Manifest: `agents/dewey/work/chat-03/I1-r2-manifest.sha256`, sha256
`2b48e333a0f09185364359ae6f8277cc88c0b9ff39058de45cc2c1f0ec9d5c4a`, the same
27 files as round 1.
- Base `1c724958`, as in round 1. `git diff 1c724958 HEAD` shows no change
under `packages/conversation`, `docs/plans/chat-0*`, `contracts/` or
`roles/`, so the round-1 export method still works: `git archive
1c724958` plus the 27 files.
- Nothing is staged or committed.
## Darkwing's findings
- **B1, the seal is a deny-list.** `checkSeal` (`src/pi-pin.mjs`) is now an
allow-list. The argv must start with exactly `--mode rpc`, the three
`--no-*` seal flags and `--session <absolute path>`. After that only
`--model`, `--provider` and `--thinking` (`ENGINE_OPTIONS`) may follow,
each at most once with one value. A value may not be empty or start with
`-` or `@`. Anything else refuses `unsealed-engine` before spawn. That
covers a second `--mode` or `--session`, every session or output flag you
listed, `--approve`, `--no-extensions` repeated, a bare word (a prompt) and
`@file`. A non-list `engine.preArgs` or `engine.extraArgs` refuses too. A
non-default `engine.command` or `preArgs` is documented as a test hook:
the pin and the seal don't bind under it, and `argvDigest` records the
whole command line (README "Engine command"). N24 now has cases for
`--session`, `--mode json|text|rpc` and `--no-session` in extraArgs, plus
the rest of the list. Your repro `/tmp/r5/seal-escape.mjs session|json`
now refuses at construction; no Pi process starts.
- **B2, the session key is the conversation ID.** The session key is the
Pi header ID, read at construction (`controller.mjs`, constructor). A
later read refuses `target` if the header ID changed. New controller-level
W4 tests: a hard link and a copy of one session under another seat. The
second controller refuses `already-active` and launches nothing. On a copy:
the brief keys on the native session identity, and a copy carries the same
header ID, so it counts as the same session. Your
`/tmp/r5/hardlink.mjs` gives B `already-active` with 0 launches.
- **n1.** The startup-append finding now states Pi's rule
(`messages.length > 0`) and names both cases (README "Findings against
pinned Pi", corrected below).
- **n2.** README "Live roots" says the guard follows `$HOME`.
## Filbert's findings
- **B1, text after Enter joins the message.** The composer is taken at the
Enter key, so text after it in the same chunk starts the next message
(`terminal.mjs` `key`, `#take`). New flows test: `first\rsecond\r` sends
two prompts; `abc\rdef` sends `abc` and leaves `def` in the composer.
- **B2, a paste-start split after its ESC.** A lone trailing ESC is carried
to the next chunk; a lone Escape has no action, so holding it costs
nothing. New flows test: the marker split at every cut 1–5 is still a
paste.
- **B3, a second force stop runs a parallel escalation.** One escalation
runs at a time (`controller.escalating`). A second force stop while one
runs refuses `fenced` and doesn't consume its confirmation. Once an
escalation ends `uncertain`, a fresh confirmation can retry. New H10 test:
hold at `phase-term`, a second force stop is fenced, `launcher.stops` is 1,
and every claim revision carries only the first stop's phases. Its second
block retries after an `uncertain` end. H17's reuse-during-the-stop case
now expects `fenced`.
- **B4, decision 34 untested.** New N9 test: a run that ends `aborted` with
no stop in progress raises `aborted-without-stop`, the binding goes
`uncertain` with admission closed, and the receipt is outcome unknown
(`run-overlap`), never `failed`. Mutant r2-D34 (your line 163) is in the
table.
- **B5, K12 doesn't prove the freeze.** K12 now checks two things that don't
depend on timing. First, `engine/cgroup.freeze` still reads 1 after the
stop; the shim holds the scope, so a freeze never written reads 0. Second,
the fork loop logs each child's pid and its own SIGTERM. Every child forked
after that TERM, which nothing ends before the kill, must be in the
proof's member list, and there must be at least one. Mutant r2-B5 (no
freeze write, `frozen 1` answered) is killed. Mutant r2-B5b (freeze
written, not waited on) survives, and `frozen 1` is not asserted before
enumeration. Both are explained under "Mutation pass".
- **B6, no missing-path case in K15.** A third K15 block moves the engine's
processes to a sibling cgroup and removes `engine`. `events` and
`members` both answer unavailable with ENOENT, the force stop ends
`uncertain` with no proof, and the engine is still alive. Mutants r2-B6
(your ENOENT-as-`populated 0`) and r2-B6b (ENOENT as empty for both
`events` and `members`) are in the table.
- **n1.** An `aborted` links to the stop in progress only once an abort was
written in that stop's chain. New N9 test: an `aborted` after the fence
but before any abort is the overlap. Mutant r2-n1. The fix hid round-1
mutant 31 from N14, so a second N14 test now has the run complete after
the abort is written; the receipt stays `finished`.
- **n2.** The interrupt checks `tr.overlapped`, the gap and the poisoned
link again after the `before-abort` pause. New H10 test: an O5 read during
the pause means no abort and the queued item never runs. Mutant r2-n2.
- **n3.** A stop that ends `uncertain` with `nativeQueue` still `pending`
records `unknown`. N1 (non-empty clear) asserts it. Mutant r2-n3.
- **n8.** README "Escape hatches" now says K13's refusal comes from the
shim's `unshare --cgroup` on an `nsdelegate` host, and that nothing checks
`nsdelegate` at runtime.
- **n13.** `visible()` now also shows U+061C, U+200B, U+2060–U+2064, U+FEFF
and tag characters U+E0000–U+E007F. ZWJ and ZWNJ pass, because emoji
sequences and joining scripts need them. The header, status, notices and
dialogs show LF as `^J`. New flows test; mutants r2-n13 and r2-n13b.
- **n19, n20.** README force-stop text now follows the code: a TERM phase,
then freeze, enumerate, `cgroup.kill`, `populated 0`. The shim ignores
SIGTERM only to keep the scope's anchor. The pieces table lists the shim's
ops.
## Notes not taken
Recorded here as limits or bounded follow-ups. None widens I1.
- n4 (labels after K9 and after supersession): labels only, as you say.
- n5, n18 (test gaps): `rounds-exhausted`, O5 from a `get_state` count, the
two O2 variants, the socket-directory refusals, an overlong or split
multibyte engine line, events in H12 and H13. Follow-up. n18's escaping
gap is partly closed by the new `visible()` test.
- n6 (counted, no signal): outside O1–O6.
- n7 (TERM resume after restart not asserted; K3 doesn't assert TERM
delivery): follow-up test.
- n11 (orphan's tool map taken at restart), n12 (answer vs use after a stop
change): low impact, outcome matches.
- n14 (`LineSplitter` limit overshoot of at most one chunk): follow-up.
- n15 (5 s ack timeout for real Pi): for I3. It fails closed.
- n16 (compaction summaries appear only after a re-read): follow-up for the
seam rules.
- n17 (two interrupts with one request ID; no `onOverflow` or backpressure):
follow-up.
## Suites
Run on the candidate bytes in the canonical checkout, one run each, no
reruns for failures. The conversation suite ran twice, both 152/152: the
second run came after a one-character fix to N15's title (a space my N14
insertion dropped), so the frozen bytes have their own run. Logs are in `~/dewey-scratch/suites/` this session; `/tmp` was full
(other seats' files, 17 of 19 GB), so my scratch moved out of it.
| Suite | Result |
|---|---|
| `node --test packages/conversation/tests/` | 152 pass, 0 fail, 0 skipped |
| `packages/control-board` tests | 124 pass, 0 fail |
| `packages/webui` tests | 14 pass, 0 fail |
| `packages/seat` tests | 19 pass, 0 fail |
| `docs/plans/chat-00/check.mjs` | 48 checks passed |
| `docs/plans/chat-01/check.mjs` | R3 PASS |
| `docs/plans/chat-01c/check.mjs` | PASS |
| `scripts/test-auth.sh` | 15 passed, 0 failed |
| `scripts/test-conductor.sh` | 17 passed, 0 failed |
| `scripts/test-config.sh` | 24 passed, 0 failed |
| `scripts/test-discord.sh` | 64 passed, 0 failed |
| `scripts/test-extension-package.sh` | 18 passed, 0 failed |
| `scripts/test-foundation.sh` | 44 passed, 0 failed |
| `scripts/test-queue.sh` | 29 passed, 0 failed (node 148/148) |
| `scripts/test-release.sh` | 14 passed, 0 failed |
| `scripts/test-task.sh` | 90 passed, 0 failed |
Conversation tests went from 141 in round 1 to 152. The new and extended
tests are named under each finding above.
## Contracts
The twelve contract files in the brief's table (`docs/plans/chat-00`,
`chat-01`, `chat-01c`) hash to the brief's values (`sha256sum -c`, 12 OK).
The pinned brief hashes to `1ef15ac0ed31…cbc1`. `git diff 1c724958 HEAD`
and `git status` show no change under `docs/plans/chat-0*`, `contracts/`,
`roles/` or `packages/conversation` outside the 27 candidate files.
## Mutation pass
Run in scratch copies under `~/dewey-scratch`, never the served tree. Each
mutant is applied alone, then the whole conversation package runs. The
round-1 table's 41 mutants were rerun against this candidate, plus 20 new
ones that guard the round-1 fixes. All 61 anchors are unique
(`mutants.py check`). Timeout is 900 s per run; "killed (timeout after
failures)" means tests had already failed when the timer ran out, as with
13 and 14 in round 1.
The pass ran on the candidate minus one test: the N14 abort-pause test
below, added after the pass. A test added can only kill more, so every
other row stands.
Two survived the pass:
- **31**, a run that ended `stop` relabelled `failed interrupted` when it
links to a stop. Round 1's N14 killed it. The n1 fix made N14 blind to it:
N14's run completes before any abort is written, so `stopLink()` now
returns null there and the mutant changes nothing. New N14 test: the run
completes after the abort is written but before Pi applies it, and the
receipt stays `finished`. It passes on the candidate and fails under
mutant 31 (whole package under mutant 31: 151 pass, 1 fail, the new test).
- **r2-B5b**, the shim writes `cgroup.freeze` but answers `frozen 1`
without waiting. It still survives. On a real cgroup the kernel finishes
the freeze within microseconds, so nothing the suite can observe changes.
Stopped, traced and vfork-waiting tasks count as frozen, so an
unprivileged fixture can't stall a freeze with those. I tried a member
spinning under `cpu.max 1000 1000000`. The freeze stalled in 5 of 7 runs
and completed in 2, too flaky for the suite, so that test is not in the
candidate. r2-B5, Filbert's no-freeze mutant, is killed by K12's
`cgroup.freeze` check. Filbert's fix text asked for `frozen 1` asserted
*before enumeration*, and the candidate doesn't do that literally. The
only seams are the claim's phase names (a new phase changes the claim) and
the shim socket path (the controller takes it from the launcher). The wait
guards forks still in flight when the freeze is written. Follow-up: a FUSE
or privileged fixture that holds a member in uninterruptible sleep, so the
freeze can't finish.
Result: 60 of 61 killed, one survivor (r2-B5b).
| # | Mutant | Result | Failing tests (first 60 chars each) |
|---|---|---|---|
| 1 | dispatch skips the generation recheck | killed | H3: a takeover while a prompt holds the dispatch lock: writt |
| 2 | abort is sent before clear_queue | killed | H10: an overlap during the pause before the abort: no abort,; H10: Interrupt and force stop together: one stop chain, forc; N14: the run completes while clear_queue is in flight: finis … |
| 3 | a revision is published by rename, so an existing revision can be replaced | killed | W1: two processes acquire the same pair at once; exactly one; W1: two writers publish the same revision at once: one wins, |
| 4 | the late-event filter ignores the incarnation | killed | H14: late stdout from the old engine after a replacement is |
| 5 | the text policy checks only the first character | killed | S1: `/goal x`, with leading spaces or a tab, refuses text-po |
| 6 | an acknowledged SIGTERM promotes a stop to stopped | killed | K10: controller killed between the TERM and kill phases: res; K11: controller killed after the confirmation is recorded, b; K12: a member forking in a loop: the freeze stops it, enumer … |
| 7 | the process-group fallback can reach stopped | killed | H21: a retry of the exact request with the old token after a; K2: K1 on the process-group fallback ends uncertain, never s |
| 8 | a confirmation is not consumed on use | killed | K6: recover without proof, without confirmation, or with cha |
| 9 | disconnect releases control | killed | H11: the controller disconnects mid-turn: work continues, th |
| 10 | the composer isn't cleared on transfer | killed | S4: a `/` left in the composer is cleared when control trans |
| 11 | engine output is read with readline | killed | E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as |
| 12 | a revision is published by exclusive create in place, so a partial file is visible | killed | W1: a revision name appears only after its bytes are synced;; W1: two writers publish the same revision at once: one wins, |
| 13 | a second controller reclassifies a claim whose owner is alive | killed (timeout after failures) | H16: a second controller for the same session refuses alread; packages/conversation/tests/claim.test.mjs; packages/conversation/tests/races.test.mjs … |
| 14 | the live-session guard skips the real-path check | killed (timeout after failures) | G2: a symlink inside the fixture root to a live session file; G3: a fixture path swapped for a live path after constructio; packages/conversation/tests/claim.test.mjs |
| 15 | the pending slot is released at agent_start | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … |
| 16 | a pipe is written again after an unknown write outcome | killed | H19: the link itself never writes again after an unknown out |
| 17 | an ack followed by no run marks the item finished | killed | N15: the fence lands in preflight, then an input handler tak; N5: an input handler takes the prompt: ack, no run, delivery |
| 18 | admission reopens without the post-settle empty clear | killed | N25: ordinary Interrupt reconciles; a non-empty queue_update; N4: the ack arrives after the first abort and a run starts: |
| 19 | abort is sent after a clear_queue timeout | killed | N7: clear_queue answers an error: no abort, nativeQueue unkn |
| 20 | the incarnation token check is skipped | killed | H21: a retry of the exact request with the old token after a; H22: after H21 and a valid recovery, a new request with the |
| 21 | a missing cgroup path counts as empty | killed | K15: the shim gone, engine/cgroup.events unreadable, or the |
| 22 | a unit with a different invocation ID is signalled | killed | K14: a unit with the recorded name but another invocation ID |
| 23 | a restart during force stop records the kill phase as done | killed | K10: controller killed between the TERM and kill phases: res |
| 24 | an eligibility record can be used twice | killed | K17: two launcher calls with one eligibility record: one lau |
| 26 | a non-empty clear_queue reaches reconciled | killed | N1: an extension's follow-up queued after the fence is clear |
| 27 | the pending slot's in-flight preflight is abandoned at the fence | killed | N15: the fence lands in preflight, then an input handler tak; N3: the fence lands in preflight, preflight errors, no run: ; N4: the ack arrives after the first abort and a run starts: |
| 28 | the fake engine queues a Mosaic prompt while streaming instead of throwing | killed | N10: fake conformance |
| 29 | a no-unit observation frees a pair that has a spawn marker | killed | W20: crash after the spawn marker, scope collected; uncertai |
| 30 | a settled run with empty clears is taken as interruption evidence without aborted | killed | N14: the run completes while clear_queue is in flight: finis; N17: the run fails on its own during the exchange: failed, F; N18: no final assistant message_end, or a lost line: working |
| 31 | a receipt whose run ended stop is relabelled failed interrupted during a stop | survived the pass; killed by the new N14 test (full package, 151/152) | N14: the run completes after the abort is written, before Pi |
| 32 | turnState input-reconciled is used to reconcile an Interrupt | killed | N4: the ack arrives after the first abort and a run starts: ; N9: a run that started before the fence and ends aborted: fa |
| 33 | Interrupt with no slot and no run creates a stop | killed | H10: a no-turn Interrupt lifts only its own fence; admission; H9: Interrupt racing a prompt's dispatch: before the write, ; N16: Interrupt with no slot and no run refuses no-turn: no s |
| 34 | a run is attributed to the slot with the overlap checks skipped | killed | N18: no final assistant message_end, or a lost line: working; N8: an extension prompt starts a run during Mosaic preflight |
| 34b | an overlap signal leaves the binding active | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N19: a losing extension prompt settles inside the Mosaic run … |
| 35 | a settle that doesn't close the slot's own run is the slot's settle (open agent_start) | killed | N19: a losing extension prompt settles inside the Mosaic run; N8: an extension prompt starts a run during Mosaic preflight |
| 35b | a settle with no agent_start since the ack is the slot's settle | killed | N19: a losing extension prompt settles inside the Mosaic run |
| 36 | agent_start or agent_settled with no slot held is ignored | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N21: a losing settle after the receipt settled finished is O |
| 37 | the launch seal check accepts --extension and missing --no-* flags | killed | N24: the seal is an allow-list: --extension, a missing --no- |
| 37b | the binding launches without the --no-* flags | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … |
| 38 | an ack-without-start run settles failed | killed | N11: the run fails before any user message_start: delivery-u |
| 39 | an empty clear is recorded as proof no external input was removed | killed | N22: an agent-level custom message is dropped by the clear w |
| r2-B1 | the seal ignores everything after the prefix (extraArgs unchecked) | killed | N24: the seal is an allow-list: --extension, a missing --no- |
| r2-B1b | the seal skips the prefix order check | killed | N24: the seal is an allow-list: --extension, a missing --no- |
| r2-B1c | the seal accepts a relative --session value | killed | N24: the seal is an allow-list: --extension, a missing --no- |
| r2-B1d | the seal accepts a repeated option | killed | N24: the seal is an allow-list: --extension, a missing --no- |
| r2-B1e | the seal accepts an option value that is a flag or @file | killed | N24: the seal is an allow-list: --extension, a missing --no- |
| r2-B2 | the session key is the conversation ID again | killed | W4: a copy of one session under another seat is the same ses; W4: a hard link of one session under another seat is the sam |
| r2-B2b | a changed header ID after construction is not refused | killed (timeout after failures) | packages/conversation/tests/claim.test.mjs; W4: a session header ID that changes after construction refu |
| r2-D34 | an aborted with no stop in progress raises no overlap (lead decision 34) | killed | N9: an aborted that lands after the fence but before any abo; N9: decision 34: a run that ends aborted with no stop in pro |
| r2-n1 | an aborted links to any stop in progress, abort written or not | killed | N9: an aborted that lands after the fence but before any abo |
| r2-n2 | no overlap recheck after the pause before the abort | killed | H10: an overlap during the pause before the abort: no abort, |
| r2-n3 | an uncertain stop keeps nativeQueue pending | killed | N1: an extension's follow-up queued after the fence is clear |
| r2-B3 | a second force stop runs while the first escalation runs | killed | H10: a second force stop while the first escalation runs ref; H17: a confirmation reused, answered from another connection |
| r2-TB1 | Enter takes the composer when the send runs, not at the key | killed | terminal: text after Enter in the same input chunk starts th |
| r2-TB2 | a lone trailing ESC is not carried to the next chunk | killed | terminal: a paste-start marker split right after its ESC sti |
| r2-n13 | zero-width, BOM and tag characters pass visible() | killed | terminal: invisible and bidi characters are made visible; he |
| r2-n13b | one-line output keeps LF | killed | terminal: invisible and bidi characters are made visible; he |
| r2-B5 | the shim skips the freeze write and answers frozen 1 | killed | K12: a member forking in a loop: the freeze stops it, enumer |
| r2-B5b | the shim writes the freeze but doesn't wait for frozen 1 | **SURVIVED** | none (see above) |
| r2-B6 | a missing engine/cgroup.events reads as populated 0 | killed | K15: the shim gone, engine/cgroup.events unreadable, or the |
| r2-B6b | a missing engine cgroup reads as empty: populated 0 and no members | killed | K15: the shim gone, engine/cgroup.events unreadable, or the |
## Findings against pinned Pi 0.85.1 (corrected)
- **Startup append** (replaces the round-1 text, Darkwing n1). Pi's rule is
`hasExistingSession = messages.length > 0` (sdk.js 82–83). A session with
messages but no `thinking_level_change` entry gains one at every start. A
session with no messages takes the new-session branch and gains a
`thinking_level_change` at every start, plus `model_change` when a model is
set. That includes a Pi-created session that was opened and never
prompted. In both cases the leaf moves after launch, so K8 fails closed:
the binding goes `uncertain` and prompts refuse `preflight`. A pre-spawn
check covering both cases is a later increment's decision.
- The inline `/llama` command finding is unchanged.
## Limits (added)
- A member can ask the user systemd manager for a new unit outside the scope
(`systemd-run --user` lands in `app.slice`). That is a same-uid escape
(Filbert n9; brief limits 3 and 4).
- The shim channel is not authenticated. A same-uid engine that kills the
shim and binds its socket could answer as it (Filbert n10; limit 1).
Recording the shim's pid and start time in the claim would narrow it. That
is a follow-up.
- The guard's home roots follow `$HOME` (Darkwing n2).
- A cgroup throttled by `cpu.max` can hold off both the freeze and
`cgroup.kill` for seconds. Seen in the r2-B5b fixture attempt, with the
harness writing `engine/cpu.max`. The stop then ends `uncertain` at the
freeze, which fails closed. Whether a member can throttle itself from
inside its cgroup namespace is untested.
+215
View File
@@ -0,0 +1,215 @@
# CHAT-03 I1 build, review request round 1 (#1507, row 5)
From Dewey, 2026-10-04. Sage assigned the build under lead decision 36.
Reviewers are Filbert and Darkwing (rev 40). Darkwing takes the controller
binding and the extension-load refusal, which Rocko had before decision 42.
There are two rounds at most (item 27). Row 5's gate is Jason's (Gate E), so
after the reviewers approve, the row goes to waiting-on-jason, not done.
## Candidate
- Manifest: `agents/dewey/work/chat-03/I1-manifest.sha256`, sha256
`1404341eaeaf7d1e684c9f27e76718061ed08c25a52da5f190425feb1274ba69`,
27 files.
- Base `1c724958`. Nothing is staged or committed; the files are untracked
or modified in the canonical checkout. They won't change during the
round. A change means a new manifest and a new round.
- Spec: the pinned brief `agents/dewey/work/chat-03/BRIEF.md`, sha256
`1ef15ac0…`, plus lead decisions 30–34 and 36. Nothing outside the brief
is built.
- Scope: increment I1 only. H5–H8 and the Claude adapter are I4. I3
(recorded runs against a real model) waits on Jason's go.
What the manifest lists:
- `packages/conversation/src/`: the CHAT-03 modules (`claim`, `client`,
`cohort`, `controller`, `engine`, `events`, `framing`, `guard`, `pi-pin`,
`records`, `shim`, `terminal`, `text-policy`, `transcript`, `turns`) and
`safe-fs.mjs`, which gains `ControlRefusal`;
- `packages/conversation/tests/`: `claim`, `cohort`, `flows`, `races`,
`smoke` and `turns` test files, plus `fake-pi.mjs`, `harness.mjs` and
`ctrl-child.mjs`;
- `packages/conversation/README.md`: the "Mediated control (CHAT-03)" part;
- `packages/conversation/package.json`: the description and four new
`exports` entries (`controller`, `client`, `transcript`, `terminal`), no
new dependency.
Where to start: the README's "Choices" section lists each reading of the
brief that a reviewer could disagree with. "Refusals" lists every code,
including the three names this build adds (`malformed`, `eligibility`,
`preflight`). "Findings against pinned Pi" has the two findings below.
## For Darkwing: binding and extension-load refusal
- The seal: `src/pi-pin.mjs` (`buildPiArgs`, `checkSeal`, `SEAL_FLAGS`)
and `src/controller.mjs` (`SEAL_BASIS`, and the K8 load check in
`#launchInto`). Any `-e`/`--extension` argument or a missing `--no-*` flag
refuses `unsealed-engine` before spawn. Bound seats get no explicit
extensions (lead decision 31).
- The pin: `src/pi-pin.mjs` requires `package-lock.json` and
`node_modules/.package-lock.json` to name Pi 0.85.1 with the pinned
integrity (n1 bundle pin, decision 32). A mismatch refuses
`engine-pin-mismatch` at start and at recovery (K6).
- Tests: N24 (seal), K6 and K8 (pin and load check), mutants 37 and 37b.
`smoke.test.mjs` starts the pinned binary sealed with no credentials and
no prompt.
- The controller binding: claim (`src/claim.mjs`), the live-session guard
(`src/guard.mjs`), and the takeover and generation path in
`src/controller.mjs` (`#evaluateOp`, `#recheck`, `#transfer`). Tests:
W1–W20, G1–G3, H1–H4.
## Suites
Run on the candidate bytes in the canonical checkout, logs kept under
`/tmp/dewey-suites/` for this session:
| Suite | Result |
|---|---|
| `node --test packages/conversation/tests/` | 141 pass, 0 fail |
| `packages/control-board` tests | 124 pass, 0 fail |
| `packages/webui` tests | 14 pass, 0 fail |
| `packages/seat` tests | 19 pass, 0 fail |
| `docs/plans/chat-00/check.mjs` | 48 checks passed |
| `docs/plans/chat-01/check.mjs` | R3 PASS |
| `docs/plans/chat-01c/check.mjs` | PASS |
| `scripts/test-auth.sh` | 15 passed, 0 failed |
| `scripts/test-conductor.sh` | 17 passed, 0 failed |
| `scripts/test-config.sh` | 24 passed, 0 failed |
| `scripts/test-discord.sh` | 64 passed, 0 failed |
| `scripts/test-extension-package.sh` | 18 passed, 0 failed |
| `scripts/test-foundation.sh` | 44 passed, 0 failed |
| `scripts/test-queue.sh` | 29 passed, 0 failed (node 148/148) |
| `scripts/test-release.sh` | 14 passed, 0 failed |
| `scripts/test-task.sh` | 90 passed, 0 failed |
The nine `scripts/test-*.sh` suites are the brief's "every suite at the
base". One run each, no reruns.
## Contracts
The twelve contract files in the brief's table (`docs/plans/chat-00`,
`chat-01`, `chat-01c`) hash to the brief's values (`sha256sum -c` on the
table, 12 OK). `git diff 1c724958`
shows no change under `docs/plans/chat-0*`, `contracts/` or `roles/`.
## Mutation pass
Run in scratch copies under `/tmp`, never the served tree: every mutant
applied alone, then the whole conversation package run. The first pass ran
on the source before the new tests. Seven mutants survived it, and each now
has a test that kills it:
| # | Why it survived | Kill test |
|---|---|---|
| 1 | H3's refused branch accepted `controller` or `generation`, and the controller check alone caught it | H3, third block: control goes A, B, A while the prompt waits, so only the generation check refuses |
| 3 | No test raced two writers on one revision | W1: two stores publish revision 1 at once; one wins, the other gets null, the winner's file is intact |
| 8 | H17's reuse case changed the stop, so the stop check refused it first | K6: a confirmed `recover` used twice; the second refuses `confirmation` |
| 12 | Nothing looked for the revision name before the bytes were synced | W1: held at `temp-written`, no revision file is visible, only the temp file |
| 14 | The mutated line was unreachable: the loop above already tested `realPath(p)`, so the first mutant was equivalent | `guard.check` now tests the real path in one place; mutant 14 removes that check and the real-path overlap. G2 and G3 kill it |
| 16 | Every controller path checks `poisoned` before writing, which masked the link's own guard | H19: an `EngineLink` whose first write fails EPIPE refuses the next write and sends nothing |
| 19 | A `clear_queue` timeout poisons the link, so the mutant's abort never reached the pipe | N7: `clear_queue` answers `success: false`, the one failure that leaves the link unpoisoned; no abort is sent |
Every mutant fails at least one test now. Table:
| # | Mutant | First pass | Final | Failing tests (first 60 chars each) |
|---|---|---|---|---|
| 1 | dispatch skips the generation recheck | SURVIVED | killed | H3: a takeover while a prompt holds the dispatch lock: writt |
| 2 | abort is sent before clear_queue | killed | killed | H10: Interrupt and force stop together: one stop chain, forc; N14: the run completes while clear_queue is in flight: finis; N15: the fence lands in preflight, then an input handler tak … |
| 3 | a revision is published by rename, so an existing revision can be replaced | SURVIVED | killed | W1: two processes acquire the same pair at once; exactly one; W1: two writers publish the same revision at once: one wins, |
| 4 | the late-event filter ignores the incarnation | killed | killed | H14: late stdout from the old engine after a replacement is |
| 5 | the text policy checks only the first character | killed | killed | S1: '/goal x', with leading spaces or a tab, refuses text-po |
| 6 | an acknowledged SIGTERM promotes a stop to stopped | killed | killed | K10: controller killed between the TERM and kill phases: res; K11: controller killed after the confirmation is recorded, b; K12: a member forking in a loop: the freeze stops it, enumer … |
| 7 | the process-group fallback can reach stopped | killed | killed | H21: a retry of the exact request with the old token after a; K2: K1 on the process-group fallback ends uncertain, never s |
| 8 | a confirmation is not consumed on use | SURVIVED | killed | K6: recover without proof, without confirmation, or with cha |
| 9 | disconnect releases control | killed | killed | H11: the controller disconnects mid-turn: work continues, th |
| 10 | the composer isn't cleared on transfer | killed | killed | S4: a '/' left in the composer is cleared when control trans |
| 11 | engine output is read with readline | killed | killed | E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as |
| 12 | a revision is published by exclusive create in place, so a partial file is visible | SURVIVED | killed | W1: a revision name appears only after its bytes are synced;; W1: two writers publish the same revision at once: one wins, |
| 13 | a second controller reclassifies a claim whose owner is alive | killed | killed (timeout after failures) | H16: a second controller for the same session refuses alread; W12: a live owner paused with SIGSTOP; a second controller r; W2: acquire while a claim is reserved or active refuses alre |
| 14 | the live-session guard skips the real-path check | SURVIVED | killed (timeout after failures) | G2: a symlink inside the fixture root to a live session file; G3: a fixture path swapped for a live path after constructio |
| 15 | the pending slot is released at agent_start | killed | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … |
| 16 | a pipe is written again after an unknown write outcome | SURVIVED | killed | H19: the link itself never writes again after an unknown out |
| 17 | an ack followed by no run marks the item finished | killed | killed | N15: the fence lands in preflight, then an input handler tak; N5: an input handler takes the prompt: ack, no run, delivery |
| 18 | admission reopens without the post-settle empty clear | killed | killed | N25: ordinary Interrupt reconciles; a non-empty queue_update; N4: the ack arrives after the first abort and a run starts: |
| 19 | abort is sent after a clear_queue timeout | SURVIVED | killed | N7: clear_queue answers an error: no abort, nativeQueue unkn |
| 20 | the incarnation token check is skipped | killed | killed | H21: a retry of the exact request with the old token after a; H22: after H21 and a valid recovery, a new request with the |
| 21 | a missing cgroup path counts as empty | killed | killed | K15: the shim gone or engine/cgroup.events unreadable: evide |
| 22 | a unit with a different invocation ID is signalled | killed | killed | K14: a unit with the recorded name but another invocation ID |
| 23 | a restart during force stop records the kill phase as done | killed | killed | K10: controller killed between the TERM and kill phases: res |
| 24 | an eligibility record can be used twice | killed | killed | K17: two launcher calls with one eligibility record: one lau |
| 26 | a non-empty clear_queue reaches reconciled | killed | killed | N1: an extension's follow-up queued after the fence is clear |
| 27 | the pending slot's in-flight preflight is abandoned at the fence | killed | killed | N15: the fence lands in preflight, then an input handler tak; N3: the fence lands in preflight, preflight errors, no run: ; N4: the ack arrives after the first abort and a run starts: |
| 28 | the fake engine queues a Mosaic prompt while streaming instead of throwing | killed | killed | N10: fake conformance |
| 29 | a no-unit observation frees a pair that has a spawn marker | killed | killed | W20: crash after the spawn marker, scope collected; uncertai |
| 30 | a settled run with empty clears is taken as interruption evidence without aborted | killed | killed | N14: the run completes while clear_queue is in flight: finis; N17: the run fails on its own during the exchange: failed, F; N18: no final assistant message_end, or a lost line: working |
| 31 | a receipt whose run ended stop is relabelled failed interrupted during a stop | killed | killed | N14: the run completes while clear_queue is in flight: finis |
| 32 | turnState input-reconciled is used to reconcile an Interrupt | killed | killed | N4: the ack arrives after the first abort and a run starts: ; N9: a run that started before the fence and ends aborted: fa |
| 33 | Interrupt with no slot and no run creates a stop | killed | killed | H10: a no-turn Interrupt lifts only its own fence; admission; H9: Interrupt racing a prompt's dispatch: before the write, ; N16: Interrupt with no slot and no run refuses no-turn: no s |
| 34 | a run is attributed to the slot with the overlap checks skipped | killed | killed | N18: no final assistant message_end, or a lost line: working; N8: an extension prompt starts a run during Mosaic preflight |
| 34b | an overlap signal leaves the binding active | killed | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N19: a losing extension prompt settles inside the Mosaic run … |
| 35 | a settle that doesn't close the slot's own run is the slot's settle (open agent_start) | killed | killed | N19: a losing extension prompt settles inside the Mosaic run; N8: an extension prompt starts a run during Mosaic preflight |
| 35b | a settle with no agent_start since the ack is the slot's settle | killed | killed | N19: a losing extension prompt settles inside the Mosaic run |
| 36 | agent_start or agent_settled with no slot held is ignored | killed | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N21: a losing settle after the receipt settled finished is O |
| 37 | the launch seal check accepts --extension and missing --no-* flags | killed | killed | N24: any --extension argument, or a missing --no-* flag, ref |
| 37b | the binding launches without the --no-* flags | killed | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … |
| 38 | an ack-without-start run settles failed | killed | killed | N11: the run fails before any user message_start: delivery-u |
| 39 | an empty clear is recorded as proof no external input was removed | killed | killed | N22: an agent-level custom message is dropped by the clear w |
For mutant 14, "First pass" is the equivalent variant described above.
Mutants 13 and 14 also hit the 900-second run timeout. Each fails its
tests first. With two live controllers (13) or a guard that admits the
symlinked path (14), later fixtures in the same files hang.
`tests/fake-pi.mjs` gained `failResponse(type)` for the N7 case. It
answers `success: false`, as Pi's RPC loop does when a handler throws.
## Defects found and fixed during the build
All are in my files. They were found by tests while I wrote them, and none
reached a commit.
1. A refused contender's controller unlinked the live controller's socket.
The socket directory is now prepared only after the claim classifies.
2. `#evaluate` read `res.outcome` from `recover`'s Promise, so `recover`
over the wire threw `internal`. W8 missed it because it resumes through
a restart.
3. `#admission` and `#uncertain` skipped the binding push when only the
state changed, so clients kept showing `stopping` after a failed force
stop.
4. The client kept non-final receipts live across a new incarnation. It
now marks them outcome unknown.
5. The `unknown` push's `type` field overwrote the envelope's
`type: "push"`, so clients dropped it. The field is now `nativeType`.
6. A `Transcript` attached to a connected client didn't read the page until
the next welcome. It reads at once.
7. The controller's new refusal codes broke control-board's
`REFUSAL_STATUS` completeness test, which scans every `new Refusal("…")`
in `conversation/src`. Control-layer refusals now throw `ControlRefusal`,
a subclass in `safe-fs.mjs`. The README says why. `packages/control-board`
is untouched and green.
## Findings against pinned Pi 0.85.1
Reported, not built around:
- **Startup append.** A session whose branch has no
`thinking_level_change` entry gains one at every start (sdk.js
240–252). The leaf moves after launch, so the K8 load check fails closed:
the binding goes `uncertain` and prompts refuse `preflight`. Sessions Pi
created carry the entry, so this affects only sessions written by
something else. A pre-spawn check would refuse them before launch. That
is a later increment's decision.
- **One inline extension command under the seal.** `get_commands` still
lists `/llama` (Pi's bundled llama.cpp router). Slash text is refused at
admission, so it can't be invoked. The smoke test pins the command list
so a change shows.
## Limits
- The mutation pass proves each listed mutant fails a test. It doesn't
prove the tests catch every other fault.
- K13 is refused on this host by cgroup namespace delegation; the test
records the host behaviour, not a controller check.
- `cohort.test.mjs` needs a systemd user manager; without one the scope
tests skip and say so.
@@ -0,0 +1,27 @@
2ed3790eb877414b8e985ca16031d8963adfb5c9555d415efb76288b76a5e1a6 packages/conversation/package.json
974f75fc5e7770d0be84448e2d7ff34acdd3855397bf6b5dd59e9072b22dedcb packages/conversation/README.md
6c3e112e13e69f5a9cfd6b217d3f9ba439000c8f353e556207b2e5063bcdee91 packages/conversation/src/claim.mjs
a45beb82438bb42d97d1e322010e175e785767664e548b90c4125e98b7660003 packages/conversation/src/client.mjs
6a5ef01d36111af8f5d872e30e0dd03b079f4650dd2b977c0ade5a1cf22ba3fe packages/conversation/src/cohort.mjs
e2140d9cec046c58b09a91b40a83cb7c93b640840933ef0e68889a9c8178de5d packages/conversation/src/controller.mjs
a833f130f0086e0942de2f46d3d4acfeebf898659f0d7e5d6f375a2780ac5e70 packages/conversation/src/engine.mjs
2fe414b4d2382e0ebfaa63e63dbd17ff4bb6ffc6bd7c5d0bbcc806797d6b7129 packages/conversation/src/events.mjs
eaf0772b60bb17fbf9548267c9cfeb0104f92f5cbafdfcb60c1633883eec6432 packages/conversation/src/framing.mjs
a96fd4304cfb7a72d36ebf7f9c1fad81881df4621ba17719558428be60559a8a packages/conversation/src/guard.mjs
6496b1aa72c130f44999a7389708535321e10f5c0ae16cb9f7bb2f6dd80f4703 packages/conversation/src/pi-pin.mjs
96f7b64c2e2c3493e270224542818fd14f042e3cf0ac14788b3f881010bc25b1 packages/conversation/src/records.mjs
62b0f7e9f798f584fcc0e4ead0522e44fd113bd0803f2bad92ffa480cadb7527 packages/conversation/src/safe-fs.mjs
83dd41c5a6b40a2f5e4b65a0e75c9cf9517b6e89fdfe8361368e993583a97a1d packages/conversation/src/shim.mjs
94f2b114c3ad2b7f217de8fe0ee0babf145ef7441534c24e9fab0528676ed475 packages/conversation/src/terminal.mjs
071e14db0bc6933a438d0fbf5a2f0f1a9daefbef913908fc1cd5e1171cc9177a packages/conversation/src/text-policy.mjs
f834ee740e7149e7d2ed976112d0719ce0660270e3b61cf93e1774d112c992a2 packages/conversation/src/transcript.mjs
e778f2836f7062a414d7542954b54b83e1d39459529d6b03f5306eb4091793f0 packages/conversation/src/turns.mjs
4fddb664719d25d8c0d4871a621be68190392aacb2e3212f431660d3511d63e7 packages/conversation/tests/claim.test.mjs
10a24b347b07426b2b7f65ce6284c9fc38d6ff3046b91d55ea5baba54d8d9774 packages/conversation/tests/cohort.test.mjs
492ed353b2b755cc5fbe0fb4e645fab60bea3ccd68fbbe48c18bfef3a51cd5c2 packages/conversation/tests/ctrl-child.mjs
855879fd9a4830cd23453814c2c36418e006bde8457e46173af8968cea5a45cb packages/conversation/tests/fake-pi.mjs
a867751872d1a47383a21b71772b26b0a8c8d48253d03ca6003a2496af413836 packages/conversation/tests/flows.test.mjs
c59af12539f272c5f8ce6ca7ea026cc230c54d76cde35d5cf85962a596d681d4 packages/conversation/tests/harness.mjs
269ab45c7bd50c31068caa1d2b34329da928317bd5871e290fe57fbacf8a3028 packages/conversation/tests/races.test.mjs
8535ad8eee7384b29557dfa6cf8517d22fbc5ff9693f513ffc5e35ff627d71a8 packages/conversation/tests/smoke.test.mjs
18d5b4b0eecf4ca37d04b46b9103e704a133eee4973b6e51719c93ac9a1e1255 packages/conversation/tests/turns.test.mjs
@@ -0,0 +1,27 @@
2ed3790eb877414b8e985ca16031d8963adfb5c9555d415efb76288b76a5e1a6 packages/conversation/package.json
3d32a202743e99696bf3ab58d03eab86a34f59006978549f846eb3c52a793eef packages/conversation/README.md
6c3e112e13e69f5a9cfd6b217d3f9ba439000c8f353e556207b2e5063bcdee91 packages/conversation/src/claim.mjs
a45beb82438bb42d97d1e322010e175e785767664e548b90c4125e98b7660003 packages/conversation/src/client.mjs
6a5ef01d36111af8f5d872e30e0dd03b079f4650dd2b977c0ade5a1cf22ba3fe packages/conversation/src/cohort.mjs
06afe06161775b03d2f7def129bcaae04167c7484e3e9fa112652f5e4a8e6c06 packages/conversation/src/controller.mjs
a833f130f0086e0942de2f46d3d4acfeebf898659f0d7e5d6f375a2780ac5e70 packages/conversation/src/engine.mjs
2fe414b4d2382e0ebfaa63e63dbd17ff4bb6ffc6bd7c5d0bbcc806797d6b7129 packages/conversation/src/events.mjs
eaf0772b60bb17fbf9548267c9cfeb0104f92f5cbafdfcb60c1633883eec6432 packages/conversation/src/framing.mjs
a96fd4304cfb7a72d36ebf7f9c1fad81881df4621ba17719558428be60559a8a packages/conversation/src/guard.mjs
f813184cbef5151449b2e6e7b3f1b375fb20f26600d2b8101aa72f13181b60fe packages/conversation/src/pi-pin.mjs
96f7b64c2e2c3493e270224542818fd14f042e3cf0ac14788b3f881010bc25b1 packages/conversation/src/records.mjs
62b0f7e9f798f584fcc0e4ead0522e44fd113bd0803f2bad92ffa480cadb7527 packages/conversation/src/safe-fs.mjs
83dd41c5a6b40a2f5e4b65a0e75c9cf9517b6e89fdfe8361368e993583a97a1d packages/conversation/src/shim.mjs
5dc53b8617db15df6e4103fb40407013f0e4a96da1e9b1cb722aef0de1d24f37 packages/conversation/src/terminal.mjs
071e14db0bc6933a438d0fbf5a2f0f1a9daefbef913908fc1cd5e1171cc9177a packages/conversation/src/text-policy.mjs
f834ee740e7149e7d2ed976112d0719ce0660270e3b61cf93e1774d112c992a2 packages/conversation/src/transcript.mjs
e778f2836f7062a414d7542954b54b83e1d39459529d6b03f5306eb4091793f0 packages/conversation/src/turns.mjs
64ca6e7e2a65ddcaf0b5dfd54abe2af904e991d3d9e7ff8ffef3bb7e82e5a2eb packages/conversation/tests/claim.test.mjs
41d4a2f3a0a7f3ba1b1c03059c055b15866ff571d106ecd4948d857c9f8da259 packages/conversation/tests/cohort.test.mjs
492ed353b2b755cc5fbe0fb4e645fab60bea3ccd68fbbe48c18bfef3a51cd5c2 packages/conversation/tests/ctrl-child.mjs
d0476d48bd0d4015c0f17c8facf52a5c409dbdef211d5c43340ac68e56b91cdc packages/conversation/tests/fake-pi.mjs
abdafbe8f8e8fdde559b99ad3b81f00d463abbc8663d202be0b14f1b9f5ad890 packages/conversation/tests/flows.test.mjs
ac664988c350f4437137fc50e2ab90122ab2ec7368caf790ab94e195ed225257 packages/conversation/tests/harness.mjs
3dfb2e6afbe41b53de2b4d4a52caa7a3a08663d5b0f3494cf28aa9d311e3bf6c packages/conversation/tests/races.test.mjs
8535ad8eee7384b29557dfa6cf8517d22fbc5ff9693f513ffc5e35ff627d71a8 packages/conversation/tests/smoke.test.mjs
52425185e4393f019a06879db02eac9cde2b1f90e60c29a40cd361e70fb8c0cb packages/conversation/tests/turns.test.mjs
@@ -0,0 +1,140 @@
# CHAT-03 I1, row 5, round 2 review (Filbert)
Issue #1507. Candidate: manifest `agents/dewey/work/chat-03/I1-r2-manifest.sha256`,
sha256 `2b48e333a0f09185364359ae6f8277cc88c0b9ff39058de45cc2c1f0ec9d5c4a`,
27 files on base `1c724958`. Packet: `agents/dewey/work/chat-03/BUILD-I1-r2.md`.
Round 1 review: `agents/filbert/work/chat-03-i1-review-r1-2026-10-04.md` (comment 26683).
Verdict: **approve.** All six of my round 1 blockers are fixed. Three
follow-ups below; none of them blocks the commit.
## Method
- Scratch clone at `~/filbert-scratch/f5r2/repo` (push URL `DISABLED`),
checked out at `1c724958`, plus the 27 candidate files copied from the
canonical checkout. `sha256sum -c` on the manifest: 27 OK.
`node_modules` is a symlink to the canonical checkout's, as in round 1.
- Round 1 to round 2 diff (`r1-r2.diff`, against my round 1 export, which
matches the round 1 manifest): 11 files changed. `cohort.mjs` and
`shim.mjs` are unchanged since round 1.
- Mutants run in a separate copy (`~/filbert-scratch/f5r2/mut`) with the
same 27 files. Baseline there: 152/152.
## Suites (one run each, in the clone)
| Suite | Result |
|---|---|
| `node --test packages/conversation/tests/` | 152 pass, 0 fail (49.7 s) |
| control-board | 124 pass, 0 fail |
| webui | 14 pass, 0 fail |
| seat | 19 pass, 0 fail |
| chat-00 / chat-01 / chat-01c checks | 48 checks / R3 PASS / PASS |
| test-auth, conductor, config | 15, 17, 24 passed, 0 failed |
| test-discord, extension-package, foundation | 64, 18, 44 passed, 0 failed |
| test-queue | 27 passed, 0 failed (node 148/148) |
| test-release, test-task | 14, 90 passed, 0 failed |
test-queue's 27 against Dewey's 29: two checks (`queue verify` and
`render --check`) skip outside the canonical root. The node part matches.
My first conversation run had no `node_modules` and refused
`engine-pin-mismatch` everywhere; that was my setup. The log is kept as
`conv-0-no-node_modules.txt`.
## Round 1 blockers
| # | Finding | Check | Result |
|---|---|---|---|
| B1 | Text after Enter joins the message | Round 1 probe: `key("first\rsecond\r")` | `["first","second"]` (round 1: `["firstsecond"]`). Mutant TB1 (send reads the composer when it runs): killed by the new flows test |
| B2 | Paste-start split after its ESC | Round 1 probe: `key("\x1b")`, then `key("[200~a\rb\x1b[201~")` | Nothing sent; stays a paste (round 1: `["[200~ab"]`). Mutant TB2 (round 1's `>= 2` carry rule): killed |
| B3 | A second force stop runs a parallel escalation | Read `controller.mjs` 680–690 and 1451–1460; new H10 test | Fixed. The new H10 test asserts one stop record, the confirmation not consumed, `launcher.stops` 1, every claim revision on the first stop and no engine bytes. Mutant (drop `\|\| this.escalating`): H10 and H17 fail |
| B4 | Decision 34 untested | Round 1 mutant: `turns.mjs:163` disabled | Killed: both new N9 tests fail (150/152) |
| B5 | K12 doesn't prove the freeze | Round 1 mutant C3: no freeze write, answers `frozen 1` | Killed by K12's `cgroup.freeze` read. The code waits for `frozen 1` before enumerating (below). The remaining test gap is follow-up F1 |
| B6 | K15 has no missing-path case | Round 1 mutant D: ENOENT in `events()` answers `populated 0` | Killed by the new third K15 block (17/18) |
### The freeze path (Sage's ruling)
I read the code myself:
- `shim.mjs` `freeze` op: it writes `engine/cgroup.freeze`, then
`waitFor(e => e.frozen === 1 || e.populated === 0, timeoutMs)`. That
polls `engine/cgroup.events` every 10 ms. An unreadable file returns
`ok: false`, and the timeout returns `timedOut: true`.
- `cohort.mjs` 181–185: `freeze` is sent with `timeoutMs: 3000` and a
6000 ms client limit. `!frozen.ok` or `frozen.timedOut` returns
`unavailable` at phase `kill` before `members` is called. `members` runs
only after a `frozen 1` (or `populated 0`) was read.
So the controller does wait for the frozen state before it lists the pids.
Under the ruling, the missing test is a follow-up and doesn't block.
## Darkwing's blockers (touched files only; the verdicts are Darkwing's)
- B1 seal. `checkSeal` is an allow-list. A direct probe of
`buildPiArgs` + `checkSeal` with 24 extra-argument lists accepted only
the three that use `--model`, `--provider` and `--thinking` with plain
values. It refused `--session /outside`, `--mode json`, `--no-session`, a
repeat, a `-x` or `@f` value, a missing or empty value, a bare word,
`@file`, `--model=m`, `--approve`, `--fork`, `--export`, `--print`, `-p`,
`-e`, `--extension`, `--continue`, `--api-key`, `--system-prompt` and
`--tools`. A relative session path refuses too. Note for Darkwing: a
caller can keep the default `engine.command` and pass its own `preArgs`,
for example the Pi `cli.js` path plus `--no-session`. Only `--extension`
in `preArgs` is checked, so that argv reaches real Pi unsealed. The README
documents a non-default `preArgs` as a test hook, and I1 has no
production caller. I list it as a follow-up (F3), not a blocker.
- B2 session key. The key is the header ID, read at construction. Every
later read refuses `target` if it changed, and a missing file now refuses
`configuration` instead of throwing. `claim.mjs` `sessionKey` is
unchanged and takes any value. The three new W4 tests pass.
## Round 1 notes
n1, n2 and n3 are code changes. I read them: `stopLink` walks the
supersede chain for an abort written, the re-check after `before-abort`
covers `overlapped`, `gap` and `poisoned`, and `#stopUncertain` sets
`nativeQueue` to `unknown`. Dewey's r2-n1, r2-n2 and r2-n3 mutants are in
the table; I didn't rerun them. Dewey's dispositions of n4–n20 are
acceptable as stated.
## Follow-ups (none blocks the commit)
- **F1. No test proves enumeration happens under the freeze.** The packet
says both K12 checks "don't depend on timing". That's true of the
`cgroup.freeze` read, but the read only proves the file was written at
some point. I added mutant C4: no write at the `freeze` op, an answer of
`frozen 1`, and `cgroup.freeze` written just before `cgroup.kill`. It
survives the cohort suite 3 out of 3 runs (18/18 each). The pid-log check
catches a missing freeze only if the 5 ms fork loop forks inside the gap
between `members` and `kill`, which is about one socket round trip.
Dewey's r2-B5b (written, not waited on) also survives here (18/18).
Proposal: a test-only hold after `members` and before `kill` would let
the loop fork into the gap, which makes C4 fail every time. r2-B5b still
needs the FUSE or privileged fixture already proposed.
- **F2. Takeover and Enter in one input chunk.** `key("\x14hi\r")` from an
observer: round 1 took over and sent `hi`. Round 2 takes the composer
when it parses the Enter, which happens before the queued takeover runs.
The Enter is refused as observer, so `hi` stays in the composer and is
sent on the next Enter. Nothing is sent that shouldn't be; it costs an
extra keypress. Fix: either document it or check control when the send
runs (TB1 already holds the text).
- **F3. Overriding `preArgs` with the default command** (see Darkwing B1
above). Refuse a non-default `engine.command` or `preArgs` unless an
explicit test option is set, so a production caller can't use the test
hook by accident.
## Mutants run in this round
| Mutant | Target | Result |
|---|---|---|
| B3-no-escalating-fence | `controller.mjs` force-stop guard | killed (H10, H17) |
| D34-line163 | `turns.mjs:163` | killed (two N9 tests) |
| TB1-send-at-run | `terminal.mjs` Enter | killed (flows) |
| TB2-lone-esc | `terminal.mjs` ESC carry | killed (flows) |
| C3-no-freeze-answer-frozen | `shim.mjs` freeze | killed (K12) |
| C4-freeze-at-kill ×3 | `shim.mjs` freeze moved to the kill | survived 3/3 (F1) |
| C2-no-wait-frozen | `shim.mjs` freeze not awaited (= r2-B5b) | survived (F1) |
| D-enoent-populated0 | `shim.mjs` `events()` | killed (K15) |
Logs: `~/filbert-scratch/f5r2/logs/` (`mut-*.txt`, `mut-summary.txt`,
`conv-1.txt`, `summary.txt` and one log per suite). Probes:
`b1probe.mjs`, `sealprobe.mjs` and `tb-edge.mjs` in `~/filbert-scratch/f5r2/`.