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
+307 -9
View File
@@ -1,13 +1,20 @@
# conversation
Read-only Pi conversation histories for the Console: a catalogue of approved
session files, full branch history in CHAT-01 pages, and cursors. Opening a
conversation never resumes, forks, launches or controls anything, and nothing
here writes a file.
Two layers over Pi conversations, issue #1507 (row 5). Plain ESM, no
dependencies, Node 24 or newer.
Issue #1507 (CHAT-02, row 5). Brief: `agents/dewey/work/chat-02/BRIEF.md` R4.
Plain ESM, no dependencies, Node 24 or newer. A library with no server: the
control board serves it on two GET routes (D3).
- **The reader (CHAT-02).** Read-only histories for the Console: a catalogue
of approved session files, full branch history in CHAT-01 pages, and
cursors. Opening a conversation never resumes, forks, launches or controls
anything, and the reader writes no file. Brief:
`agents/dewey/work/chat-02/BRIEF.md` R4. A library with no server: the
control board serves it on two GET routes (D3).
- **Mediated control (CHAT-03, increment I1).** One controller process per
execution owns a sealed headless Pi's stdin and serves a local socket;
clients observe, prompt, take control, interrupt, force stop and recover
through it. Fixture sessions only. Brief:
`agents/dewey/work/chat-03/BRIEF.md` (pinned 1ef15ac0) with lead decisions
30–34 and 36. See [Mediated control](#mediated-control-chat-03).
## API
@@ -195,8 +202,10 @@ at most 680 ms per conversation.
## Tests
`node --test packages/conversation/tests/` covers fixtures F1–F15 and F17 of
the brief (F16 is in the control-board suite, which serves the routes).
`node --test packages/conversation/tests/` runs both layers. For the reader,
`reader.test.mjs` covers fixtures F1–F15 and F17 of the CHAT-02 brief (F16 is
in the control-board suite, which serves the routes). The CHAT-03 suites are
listed under [Mediated control tests](#mediated-control-tests).
- Every reader call runs inside a fingerprint of the fixture tree: size,
SHA-256, mtime, (dev, ino), mode and every directory listing, before and
@@ -205,3 +214,292 @@ the brief (F16 is in the control-board suite, which serves the routes).
`EACCES` rather than refuse.
- Every page and cursor is validated against
`docs/plans/chat-01/contracts.schema.json` with Python `jsonschema`.
# Mediated control (CHAT-03)
Increment I1 of CHAT-03. The approval races H5–H8 and the Claude adapter are
I4; recorded runs against a real model (I3) wait on Jason's go. Everything
here runs on fixture sessions in temporary directories. No code path opens a
live session, and the controller never writes a session file (W11).
## Pieces
| File | Role |
|---|---|
| `src/controller.mjs` | `Controller`: claim, launch, socket, admission, dispatch, stop chain, recovery |
| `src/client.mjs` | `ConversationClient`: the library every client uses, the terminal included |
| `src/transcript.mjs` | `Transcript`: page plus live stream, with the seam rules below |
| `src/terminal.mjs` | the mediated terminal, `node packages/conversation/src/terminal.mjs --socket <path> [--grant <id>]` |
| `src/claim.mjs` | `ClaimStore`: the writer claim per seat key and session key, revisions `r<10 digits>.json` |
| `src/cohort.mjs` | `ScopeLauncher` (systemd user scope plus `shim.mjs`), `PgroupLauncher`, force stop, cohort and boot proofs |
| `src/shim.mjs` | the scope's first process, outside the `engine` cgroup; ops `hello`, `events`, `members`, `term`, `freeze`, `kill`, `release`; ignores SIGTERM |
| `src/guard.mjs` | `LiveSessionGuard`: refuses live sessions and paths under live roots |
| `src/pi-pin.mjs` | the Pi pin (0.85.1 and its integrity) and the seal |
| `src/engine.mjs`, `src/framing.mjs` | the engine link and LF framing |
| `src/turns.mjs` | `Tracker`: runs, the dispatch slot, overlap signals O1–O6 |
| `src/events.mjs` | native event to CHAT-01 event mapping |
| `src/text-policy.mjs` | slash refusal and the prefix table |
| `src/records.mjs` | CHAT-01 v2 records, hashes, `FixtureVerifier` |
```js
import { Controller } from "./src/controller.mjs";
import { ConversationClient } from "./src/client.mjs";
import { Transcript } from "./src/transcript.mjs";
const ctrl = new Controller({ fixtureRoot, claimRoot, socketDir, sessionFile, seat, verifier });
await ctrl.start(); // claim, launch, K8 load check, listen
const client = new ConversationClient({ socketPath: ctrl.socketPath });
const view = new Transcript({ client }); // reads the page on every welcome
await client.connect(); // starts as an observer
await client.takeover(); // becomes the controller
const r = await client.prompt("hello"); // { outcome: "admitted", receipt } or { outcome: "refused:<code>", refusal }
await client.interrupt();
await client.confirmed("force-stop"); // issue, answer, then use a confirmation
```
The four required paths have no defaults: a missing one refuses
`configuration`. The session file must sit at
`<project>/.pi/state/<seat>/sessions/<name>.jsonl` inside `fixtureRoot`.
## Choices
Each is a reading of the brief or a lead decision, recorded so a reviewer
can disagree with it.
- **Seal.** The argv is `--mode rpc --no-extensions --no-prompt-templates
--no-themes --session <absolute file>`, then optional `engine.extraArgs`.
The seal is an allow-list: extraArgs may carry only `--model`,
`--provider` and `--thinking`, each at most once with one plain value
(not starting with `-` or `@`). Anything else refuses `unsealed-engine`
at construction and again at bind, before spawn: an `-e`/`--extension`
argument, a missing `--no-*` flag, a second `--mode` or `--session` (Pi
keeps the last of each), a session or output flag (`--no-session`,
`--fork`, `--export`, `--print`, `--continue`, ...) or a bare word, which
Pi reads as a prompt (lead decision 31; N24, including the missing flag
through `checkSeal`).
- **Engine command.** `engine.command` and `engine.preArgs` default to
`node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`.
A non-default value is a test hook for the fake engine. The pin check
reads only the lock files under `pinRoot` and the seal checks only Pi's
arguments, so neither says what runs under an overridden command. The
binding's `argvDigest` records the full command line. `preArgs` carrying
`--extension` still refuses.
- **Session key.** The claim's session key is the Pi header ID (D1), read
at construction. A hard link or a copy of a session under another seat
has a different conversation ID but the same header ID, so its
controller refuses `already-active` (W4). Every later read of the
session refuses `target` if the header ID changed.
- **Live roots.** The guard protects `~/.pi`, `~/.claude` and
`~/.mosaic-dev` through `os.homedir()`, which follows `$HOME`. Under a
scratch `HOME` the real directories are protected only by the
fixture-root containment, or by passing `guardOptions.homes`.
- **Seal and attribution.** Under the
seal the Mosaic `prompt` is the only input path, so `working` is attributed
through the seal: the slot's run is the one whose first user message
follows the ack with no overlap signal.
- **Overlap.** `aborted` with no stop in progress is an overlap signal, never
a stop link (lead decision 34, N9). An `aborted` links to the stop in
progress only once the controller wrote an abort in that stop's chain (the
stop or one it superseded). One that lands after the fence but before any
abort is the same overlap (N9). An overlap signal (O1–O6) closes
admission, makes the binding `uncertain` and settles the slot's receipt
with reason `run-overlap`.
- **Interrupt.** It fences admission, clears the queue, then aborts. With no
slot and no run it refuses `no-turn` and lifts only the fence it set. It
never reopens admission that a concurrent force stop, overlap signal or
revocation closed (Rocko's build note, brief line 288; H10). It checks for
an overlap again right before the abort, so an overlap read during the
pause (an O5 in the same chunk as the clear's response) means no abort,
which would run what was queued (H10). A stop that ends `uncertain` while
its queue state was still pending records `nativeQueue: "unknown"`.
- **Force stop** supersedes a running Interrupt: one stop chain (H10). One
escalation runs at a time: a second force stop while one runs refuses
`fenced`, and only the running stop's phases reach the claim. After an
escalation ends `uncertain`, a fresh confirmation can retry it (H10, H17).
The scope launcher runs a TERM phase first: the shim sends SIGTERM to each
member and waits a bounded grace for `populated 0`. The kill phase then
freezes `engine`, waits for `frozen 1`, enumerates the members for the
proof, writes `cgroup.kill` and waits for `populated 0` (K3, K12). The
shim sits in a `supervisor` cgroup outside `engine` and ignores SIGTERM
only so that a stray TERM never drops the scope's anchor. A missing or
unreadable `engine` cgroup is an absent observation, never an empty one:
the stop ends `uncertain` with no proof (K15). The process-group
fallback can't enumerate a member that left the group, so its force stop
ends `uncertain`, never `stopped` (K2). The fake launcher's `forceStop`
is fixture-only.
- **Confirmations** bind the target, operation and stop, and are consumed on
use (K6). One issued before the stop changed is refused (H17).
- **Recovery.** A confirmed `recover` after a proven stop returns a
single-use eligibility record, and the launcher calls `launch` with it.
A second call, a record from another incarnation, or a reserved claim that
changed refuses `eligibility` (K17); a session leaf or branch that moved
since eligibility refuses `target` (K18). In K7, "incarnation" means the execution:
a recovery mints a new execution and controller incarnation, generation
+1, on the same leaf. An orphan (H20) has no controller: its
`controllerConnection` is null.
- **Approval.** A stop's `approvalDisposition` is `resolved` unless a Pi
dialog was seen during the execution, then `uncertain`. Pi dialogs (`select`, `confirm`,
`input`, `editor`) are pushed to connected clients disabled with a reason
and never answered (lead decision 30, P3). A dialog pushed before a client
connected is not replayed to it; the controller's evidence keeps the list.
- **Run order.** N8 pins the wire order with the fake's `run-start` hold
point: the Mosaic ack, another run's `agent_start` and user message, then
the losing Mosaic settle, which is O3.
- **Startup.** `G2` refuses a symlinked live path at construction; `G3`
refuses a swap at bind. The K8 load check reads `get_state`
(`sessionFile`, `sessionId`) and `get_tree` (`leafId`) after launch. If the
engine loaded another file or leaf, the binding goes `uncertain`, admission
never opens, the claim stays held until a proven stop, and a prompt refuses
`preflight` (K8).
- **Observe.** `limit` (1–100) is advisory and marked `limitAdvisory: true`;
the reader's page size governs. Drafts are client-local: the library sends
a draft ID and revision 1 with each prompt and keeps no draft state.
- **Escape hatches.** K13 (a member writing its pid into another cgroup) is
refused by the cgroup namespace the shim gives the engine (`unshare
--cgroup`), on a host whose cgroup2 is mounted `nsdelegate`. Nothing
checks `nsdelegate` at runtime; on a host without it the refusal isn't
shown, and that is a portability question for the cutover increment.
## The seam
Replay is unavailable in CHAT-03, and page entries and live events carry
different IDs, so overlap at the seam can't be deduplicated (CHAT-01 lines
104–110). The controller's `observe` reply carries
`seam: { replay: "unavailable", streamEpoch, fromSequence, reconcile: true, quiet }`.
- `quiet` is true when no run was visible and no prompt held the slot as the
page was read. Pi persists each message on `message_end`, before the run
settles (agent-session.js 386–398), so a quiet cut misses nothing.
- A cut that isn't quiet puts a reconcile marker at the seam. Near it a
message may repeat or be missing. `Transcript` re-reads the page after the
next `run-settled` and clears the marker once a read is quiet (E4).
- A sequence gap, a new stream epoch, or a repeated event ID with different
bytes marks the seam and re-reads at once. Events past a gap are held,
never concatenated. An identical repeat is dropped (E3).
- A tool call shows while it runs. Once a finished message carries its
result, from the stream or the page, the progress item is hidden.
- `thinking_level_change` entries are not messages and never appear in pages.
## Slash text
`textPolicy` refuses any prompt whose text, after leading whitespace, starts
with `/`: pinned Pi runs extension commands, skills (`/skill:`) and prompt
templates from index 0 (S1, S2). `!`, `!!` and `@` are interpreted only by
Pi's interactive mode and CLI, not on the RPC prompt path, so they are
admitted and sent as text. A `/` on a later line is not interpreted
(`LATER_LINE_SLASH_INTERPRETED = false`, S3). The table with its source lines
is `PREFIXES` in `src/text-policy.mjs`; S2 iterates the same table.
## The terminal
A thin view over the client library; it renders the same `Transcript` (E7).
- Enter submits, Ctrl-J or Alt-Enter adds a newline, Ctrl-T takes control,
Ctrl-G interrupts, Ctrl-O reconnects if needed and re-reads the page,
PageUp and PageDown scroll, Ctrl-C or Ctrl-D quits.
- The composer is local. It clears after each submit and whenever the
controller changes (S4). An observer's Enter shows
`not admitted: controller` and sends nothing; the buffer is kept (S5).
- Enter takes the composer at that key: text after it in the same input
chunk starts the next message.
- A bracketed paste is inserted literally, newlines included, and never
submits by itself. A paste marker split across input chunks, even right
after its ESC, is still a paste marker; a lone trailing ESC waits for the
next chunk.
- Engine text is shown with C0 and C1 controls, DEL, U+2028, U+2029, bidi
controls (U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069) and
invisible characters (U+200B, U+2060–U+2064, U+FEFF, tag characters
U+E0000–U+E007F) made visible (`^[`, `<U+202E>`), so transcript content
can't drive or spoof the operator's terminal. ZWJ and ZWNJ pass, for emoji
sequences and joining scripts. The header, status, notices and dialogs
also show LF as `^J`, so each stays one line.
- A request whose outcome is lost shows `outcome unknown, check the
transcript`. Nothing is resent and no resend is offered.
## Refusals
Replies are `{ outcome: "refused:<code>", refusal: "<code>" }`. Admission
runs in CHAT-01 `check.mjs` order, then the CHAT-03 narrowings.
| Code | When |
|---|---|
| `malformed` | a line that isn't JSON, a message other than `hello` first, or a request that fails the envelope check |
| `grant` | the hello names no active grant |
| `channel` | the connection isn't connected or its channel isn't authenticated; also the library's reply when its socket is closed |
| `unsupported-capability` | a private-host transport, or an operation I1 doesn't verify |
| `scope`, `mapping`, `audit` | grant scope, source mapping or audit sink doesn't hold |
| `capability` | the grant lacks the operation's capability |
| `target` | the target names another conversation, execution or branch; the session header ID changed since construction (W4); the leaf moved since proof (recover) or since eligibility (launch, K18) |
| `generation` | the request's controller generation is stale (H1, H2) |
| `controller` | the connection isn't the controller |
| `conflicting-request` | a request ID reused with other content (H13) |
| `stale-incarnation` | the request carries an old controller incarnation (H21) |
| `fenced` | admission is closed (Interrupt, force stop, overlap, revocation, uncertain), or a force stop while one escalation runs (H10) |
| `busy` | a prompt already holds the dispatch slot; CHAT-03 has no broker queue (H18) |
| `text-policy` | slash text (S1, S2) |
| `draft` | prompt text missing or longer than 262,144 characters |
| `no-turn` | Interrupt with no slot and no run (N16) |
| `already-controller`, `controller-present` | self-takeover (H4); recovery control while a controller is connected |
| `confirmation` | missing, reused, foreign, expired or stop-changed confirmation (H17) |
| `stop-proof` | recovery without a verified stop |
| `preflight` | the K8 load check didn't pass; admission never opened |
| `cursor` | an observe cursor the reader refuses (`detail` names the reader code) |
| `eligibility` | launch with a used or unknown eligibility record, one from another incarnation, or a changed reservation (K17) |
| `already-active`, `unsafe-replacement`, `foreign-host` | the writer claim (W2, W3, W17) |
| `live-session-refused` | the live-session guard, at construction, bind and launch (G1–G3) |
| `unsealed-engine`, `engine-pin-mismatch` | the seal or the Pi pin (N24) |
| `configuration` | a required constructor path is missing or misplaced, the session file is unreadable, or the session header ID isn't a CHAT-01 ID |
`malformed`, `eligibility` and `preflight` are names this build adds; the
brief describes those refusals without naming them.
The controller, claim store, live-session guard and engine pin throw
`ControlRefusal` (`safe-fs.mjs`), a subclass of the reader's `Refusal`. These
codes reach a socket client, never the reader's HTTP routes, so the control
board's status map (`REFUSAL_STATUS`, which its tests check against every
`new Refusal("…")` in `src/`) covers only the reader table above. If the
board ever serves the controller, these codes need statuses there first.
Receipt reason codes, not refusals: `transport-unknown`,
`handled-without-run`, `ack-without-start`, `interrupted`, `run-overlap`.
## Findings against pinned Pi
From `tests/smoke.test.mjs`, which starts the pinned binary sealed, in a
scratch home with an empty agent dir and no inherited environment, and sends
no prompt:
- It starts with no credentials and answers `get_state`, `get_commands`,
`clear_queue`, `abort` and `get_tree`. Every field the fake engine sends
for those commands, pinned Pi sends with the same type. `clear_queue` emits
`queue_update` with empty queues before its response, as the fake does.
Pi writes `auth.json` (`{}`) and `models-store.json` into the agent dir.
- **Startup append.** Pi appends to the session at startup in two cases
(sdk.js 82–83 and 240–252). A session with messages but no
`thinking_level_change` on its branch gains one. A session with no
messages takes Pi's new-session branch and gains a `thinking_level_change`
at every start, plus a `model_change` when a model is set, whether or not
it already has a thinking entry. That covers a Pi-created session that was
opened and never prompted. Either way the leaf moves after launch, the K8
load check fails, the binding goes `uncertain` and prompts refuse
`preflight`. A pre-spawn check would refuse both kinds before launch; that
is a later increment, not built here.
- **One inline extension command.** Under the seal, `get_commands` still
lists `/llama` (source `extension`, inline: Pi's bundled llama.cpp router).
Slash text is refused at admission, so it can't be invoked. The smoke test
pins the list so a change shows.
## Mediated control tests
| Suite | Covers |
|---|---|
| `claim.test.mjs` | W1–W17, W20, G1–G3: the writer claim, crash barriers, the guard |
| `races.test.mjs` | H1–H4, H9–H23: takeover, Interrupt and force stop, retries, incarnations |
| `turns.test.mjs` | N1–N25: the turn tracker against the fake engine's Pi behaviors |
| `cohort.test.mjs` | K1–K18: scopes, force stop, proofs, recovery, eligibility (needs a systemd user manager) |
| `flows.test.mjs` | S1–S7, P3, E1–E7, the terminal, and a CHAT-01 schema check of every record produced |
| `smoke.test.mjs` | the pinned Pi binary, as above |
`fake-pi.mjs` models pinned Pi's RPC mode, including the startup append, and
`ctrl-child.mjs` runs a controller in a child process for the crash tests.
Fixtures live in temporary directories and are removed after each file.
+8 -2
View File
@@ -2,10 +2,16 @@
"name": "@mosaic/conversation",
"version": "0.1.0",
"private": true,
"description": "Read-only Pi conversation histories for the Console: approved roots, safe opens, branch pages and cursors. No server; the control board serves it.",
"description": "Pi conversations for the Console: read-only histories (CHAT-02) and mediated control of a sealed headless Pi through a local controller, client library and terminal (CHAT-03 I1, fixtures only).",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"exports": { ".": "./src/reader.mjs" },
"exports": {
".": "./src/reader.mjs",
"./controller": "./src/controller.mjs",
"./client": "./src/client.mjs",
"./transcript": "./src/transcript.mjs",
"./terminal": "./src/terminal.mjs"
},
"scripts": { "test": "node --test tests/" }
}
+354
View File
@@ -0,0 +1,354 @@
// The writer-claim record (#1507, CHAT-03 §2, D1).
//
// A claim holds two keys, the seat tuple (seat, project, workspace) and the
// native session identity. Each key is a directory under the claim root and
// each revision a numbered file in it (r0000000001.json, ...). A revision is
// published by writing a temporary file in the same directory, fsyncing it,
// link()ing it to the next revision name and fsyncing the directory. link()
// fails when the name exists, so publication is exclusive and a revision name
// only ever points at complete contents. Revisions are never rewritten.
//
// Keys are taken seat first, then session. Every step of the lifecycle
// publishes on the seat key and then on the session key. The pair's state is
// the more conservative of the two: uncertain > stopping > active > reserved
// > stopped. A key is held unless its highest revision is `stopped` with a
// proof reference. A highest revision that does not parse holds the key as
// `uncertain`; an older revision is never reused.
//
// The store is internal. It never crosses the wire, and it stores only the
// CHAT-01 binding fields it needs.
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, unlinkSync, writeSync } from "node:fs";
import { join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { bootId as readBootId, identityOf, ownerState } from "../../discord/src/journal.mjs";
import { ControlRefusal } from "./safe-fs.mjs";
import { ID } from "./parts.mjs";
export const CLAIM_VERSION = 1;
export const STATES = Object.freeze(["uncertain", "stopping", "active", "reserved", "stopped"]);
export const PROOF_KINDS = Object.freeze(["cohortProof", "boot", "no-unit"]);
export const ALREADY_ACTIVE = "already-active";
export const UNSAFE_REPLACEMENT = "unsafe-replacement";
export const FOREIGN_HOST = "foreign-host";
const REVISION = /^r(\d{10})\.json$/;
const revName = (n) => `r${String(n).padStart(10, "0")}.json`;
const rank = (state) => STATES.indexOf(state);
export function machineId() {
try {
const id = readFileSync("/etc/machine-id", "utf8").trim();
return /^[0-9a-f]{32}$/.test(id) ? id : null;
} catch {
return null;
}
}
export const defaultHost = Object.freeze({ machineId, bootId: readBootId });
export function newClaimId() {
return "c" + randomBytes(12).toString("hex");
}
export function unitNameFor(claimId) {
return `mosaic-chat-${claimId}`;
}
// The more conservative of two states.
export function conservative(a, b) {
return rank(a) <= rank(b) ? a : b;
}
const keyHash = (key) => createHash("sha256").update(JSON.stringify(key)).digest("hex").slice(0, 32);
export function seatKey({ seat, project, workspace }) {
return { kind: "seat", seat, project, workspace };
}
export function sessionKey(session) {
return { kind: "session", session };
}
function valid(rec, key, n) {
return rec && typeof rec === "object" && rec.version === CLAIM_VERSION && rec.kind === "writer-claim" &&
rec.revision === n && JSON.stringify(rec.key) === JSON.stringify(key) && typeof rec.claimId === "string" &&
ID.test(rec.claimId) && STATES.includes(rec.state) && rec.host && typeof rec.host === "object" &&
(rec.proof === null || (rec.proof && PROOF_KINDS.includes(rec.proof.kind)));
}
// A key's highest revision is free only when it is `stopped` with a proof.
export function held(head) {
if (head.n === 0) return false;
if (head.damaged) return true;
return !(head.record.state === "stopped" && head.record.proof);
}
export function headState(head) {
if (head.n === 0) return "stopped";
if (head.damaged) return "uncertain";
if (head.record.state === "stopped" && !head.record.proof) return "uncertain";
return head.record.state;
}
export class ClaimStore {
constructor({ root, host = defaultHost, identity = identityOf, barrier = null, now = () => new Date() }) {
if (typeof root !== "string" || !root) throw new ControlRefusal("configuration", "a claim root is required; CHAT-03 has no default");
this.root = root;
this.host = host;
this.identity = identity;
this.barrier = barrier;
this.now = now;
}
dir(key) {
return join(this.root, key.kind, keyHash(key));
}
async pause(name, detail) {
if (this.barrier) await this.barrier(name, detail);
}
head(key) {
const dir = this.dir(key);
let names;
try {
names = readdirSync(dir);
} catch (err) {
if (err.code === "ENOENT") return { key, n: 0, record: null, damaged: false };
throw err;
}
let n = 0;
for (const name of names) {
const m = REVISION.exec(name);
if (m) n = Math.max(n, Number(m[1]));
}
if (n === 0) return { key, n: 0, record: null, damaged: false };
let record = null;
try {
record = JSON.parse(readFileSync(join(dir, revName(n)), "utf8"));
} catch {
record = null;
}
if (!valid(record, key, n)) return { key, n, record: null, damaged: true };
return { key, n, record, damaged: false };
}
revisions(key) {
const dir = this.dir(key);
let names;
try {
names = readdirSync(dir);
} catch {
return [];
}
return names.filter((x) => REVISION.test(x)).sort().map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }));
}
// Publishes revision `n` on `key`. Returns null when another writer
// published that revision first.
async publish(key, n, body) {
const dir = this.dir(key);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const record = { ...body, key, revision: n, writtenAt: this.now().toISOString() };
const tmp = join(dir, `.tmp-${randomBytes(8).toString("hex")}`);
const fd = openSync(tmp, "wx", 0o400);
try {
writeSync(fd, JSON.stringify(record) + "\n");
await this.pause("temp-written", { key, n });
fsyncSync(fd);
} finally {
closeSync(fd);
}
await this.pause("temp-synced", { key, n });
let won = true;
try {
linkSync(tmp, join(dir, revName(n)));
} catch (err) {
if (err.code !== "EEXIST") throw err;
won = false;
}
unlinkSync(tmp);
if (!won) return null;
await this.pause("linked", { key, n });
const dfd = openSync(dir, "r");
try {
fsyncSync(dfd);
} finally {
closeSync(dfd);
}
await this.pause("dir-synced", { key, n });
return record;
}
owner(incarnation) {
const id = this.identity(process.pid);
return { pid: process.pid, start: id.start, boot: id.boot, incarnation };
}
// Why a held pair refuses a new claim.
refusal(seat, session) {
const heads = [seat, session].filter(held);
const mine = this.host.machineId();
if (heads.some((h) => !h.damaged && h.record.host.machineId !== mine)) return FOREIGN_HOST;
if (heads.some((h) => h.damaged)) return UNSAFE_REPLACEMENT;
if (heads.length === 2 && heads[0].record.claimId !== heads[1].record.claimId) return UNSAFE_REPLACEMENT;
const state = heads.map(headState).reduce(conservative, "stopped");
return state === "reserved" || state === "active" ? ALREADY_ACTIVE : UNSAFE_REPLACEMENT;
}
refuse(seat, session) {
const code = this.refusal(seat, session);
return new ControlRefusal(code, `the writer claim for this seat or session is held (${code})`);
}
// Reserves both keys under a new claim ID. `fields` holds the binding
// fields the record stores. Refuses when either key is held.
async acquire(seatK, sessionK, fields) {
for (let attempt = 0; attempt < 4; attempt++) {
const seat = this.head(seatK), session = this.head(sessionK);
if (held(seat) || held(session)) throw this.refuse(seat, session);
const claimId = fields.claimId ?? newClaimId();
const body = {
version: CLAIM_VERSION, kind: "writer-claim", claimId,
bindingId: fields.bindingId, harness: fields.harness, conversation: fields.conversation,
branch: fields.branch, leaf: fields.leaf, pins: fields.pins,
host: { machineId: this.host.machineId(), bootId: this.host.bootId() },
owner: fields.owner, unitName: unitNameFor(claimId), spawnMarker: false,
invocationId: null, engine: null, shim: null, generation: fields.generation,
state: "reserved", proof: null, stop: null, prior: fields.prior ?? null,
execution: fields.execution ?? null, cohortRef: fields.cohortRef ?? null,
};
const first = await this.publish(seatK, seat.n + 1, body);
if (!first) continue; // lost the seat key: re-read and re-decide
await this.pause("between-keys", { claimId });
const now = this.head(sessionK);
const second = held(now) ? null : await this.publish(sessionK, now.n + 1, body);
if (!second) {
// The session key is held by someone else: close our seat revision
// with a no-unit proof. Nothing was spawned.
await this.publish(seatK, seat.n + 2, { ...body, state: "stopped", proof: { kind: "no-unit", ref: null } });
throw this.refuse(this.head(seatK), this.head(sessionK));
}
return { claimId, seatKey: seatK, sessionKey: sessionK, n: { seat: first.revision, session: second.revision }, record: body };
}
throw new ControlRefusal(UNSAFE_REPLACEMENT, "the writer claim changed during every attempt");
}
// Publishes `patch` on both keys, seat first. Both heads must still be this
// claim's own last revisions.
async advance(claim, patch) {
const next = { ...claim.record, ...patch };
const n = { ...claim.n };
for (const kind of ["seat", "session"]) {
const key = kind === "seat" ? claim.seatKey : claim.sessionKey;
const head = this.head(key);
if (head.damaged || head.n !== n[kind] || head.record.claimId !== claim.claimId) {
throw new ControlRefusal(UNSAFE_REPLACEMENT, `the ${kind} key changed under claim ${claim.claimId}`);
}
const rec = await this.publish(key, head.n + 1, next);
if (!rec) throw new ControlRefusal(UNSAFE_REPLACEMENT, `another writer published on the ${kind} key of claim ${claim.claimId}`);
n[kind] = rec.revision;
if (kind === "seat") await this.pause("between-keys", { claimId: claim.claimId, patch });
}
claim.record = next;
claim.n = n;
return claim;
}
// Restart classification for a pair whose recorded owner may be gone. Acts
// only when the owner is proven gone: a different boot, or no process with
// the recorded pid and start time. `units.lookup(record)` reports the
// intended unit as { state: "absent" | "alive" | "unknown" }. `bootProof`
// issues the boot proof: it returns { proof, effects } once the verifier
// accepted both, or null, which leaves the pair `uncertain`. `adopt` is the
// owner record a recovery controller publishes when it takes over an
// orphan; without it nothing is adopted. `stoppedPatch` adds fields (the
// leaf at proof) to a `stopped` revision this classification publishes.
async classify(seatK, sessionK, { units, bootProof = null, adopt = null, stoppedPatch = {} } = {}) {
const seat = this.head(seatK), session = this.head(sessionK);
if (!held(seat) && !held(session)) return { state: "free" };
const code = this.refusal(seat, session);
if (code === FOREIGN_HOST) return { state: "uncertain", refusal: FOREIGN_HOST };
if (seat.damaged || session.damaged) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT, damaged: true };
const heads = [seat, session].filter(held);
if (heads.length === 2 && heads[0].record.claimId !== heads[1].record.claimId) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT };
const claimId = heads[0].record.claimId;
const ours = [seat, session].filter((h) => h.n > 0 && !h.damaged && h.record.claimId === claimId);
const latest = ours.reduce((a, b) => (a.record.writtenAt >= b.record.writtenAt ? a : b)).record;
const owner = latest.owner;
if (adopt && owner?.incarnation === adopt.incarnation && owner?.pid === adopt.pid) return { state: "owned", claimId, record: latest };
const status = ownerState(owner ? { pid: owner.pid, start: owner.start ?? null, boot: owner.boot ?? null } : { invalid: true }, { identity: this.identity });
if (status === "live" || status === "unknown" || status === "invalid") return { state: headState(heads[0]), refusal: ALREADY_ACTIVE, claimId, record: latest };
const pair = heads.map(headState).reduce(conservative, "stopped");
const marker = ours.some((h) => h.record.spawnMarker);
const claim = this.claimFrom(seatK, sessionK, claimId, seat, session, latest);
// A different boot on the same host: every process of that boot is gone.
if (latest.host.bootId !== this.host.bootId()) {
if (!bootProof) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT, claimId, record: latest, needs: "boot-proof" };
const issued = await bootProof(latest);
if (!issued) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT, claimId, record: latest, needs: "boot-proof" };
const { proof, effects = null } = issued;
await this.finish(claim, { ...stoppedPatch, state: "stopped", proof: { kind: "boot", ref: proof.id, effects: effects?.id ?? null }, spawnMarker: marker });
return { state: "stopped", proofKind: "boot", proof, effects, claimId, record: claim.record };
}
// Keys disagree because a release stopped between them: finish it under
// the same claim ID and proof.
const stoppedHead = ours.find((h) => h.record.state === "stopped" && h.record.proof);
if (stoppedHead) {
await this.finish(claim, { state: "stopped", proof: stoppedHead.record.proof, spawnMarker: marker || latest.spawnMarker, leafAtProof: stoppedHead.record.leafAtProof ?? null, branchAtProof: stoppedHead.record.branchAtProof ?? null });
return { state: "stopped", proofKind: stoppedHead.record.proof.kind, claimId, record: claim.record, completed: true };
}
const unit = await units.lookup(latest);
if (pair === "reserved" && !marker && unit.state === "absent") {
await this.finish(claim, { ...stoppedPatch, state: "stopped", proof: { kind: "no-unit", ref: null } });
return { state: "stopped", proofKind: "no-unit", claimId, record: claim.record };
}
// A marker with no unit, a unit that exists, or an engine that may run:
// the pair is uncertain. A collected scope is an absent observation, not
// proof that every process ended. The marker is copied, never dropped.
const patch = { state: latest.state === "stopping" && latest.stop ? "stopping" : "uncertain", spawnMarker: marker };
if (adopt) patch.owner = adopt;
await this.finish(claim, patch);
return { state: patch.state, claimId, record: claim.record, unit, adopted: Boolean(adopt), claim, resumeStop: latest.stop && latest.state === "stopping" ? latest.stop : null };
}
claimFrom(seatK, sessionK, claimId, seat, session, latest) {
return {
claimId, seatKey: seatK, sessionKey: sessionK,
n: { seat: seat.n, session: session.n },
heads: { seat, session },
record: { ...latest },
};
}
// Brings both keys of a claim to `patch`, seat first. A key whose head
// belongs to an older claim (a half-done acquire) gains the revision too.
async finish(claim, patch) {
const next = { ...claim.record, ...patch };
for (const kind of ["seat", "session"]) {
const key = kind === "seat" ? claim.seatKey : claim.sessionKey;
const head = this.head(key);
if (head.damaged) throw new ControlRefusal(UNSAFE_REPLACEMENT, `the ${kind} key is unreadable`);
if (head.n > 0 && head.record.claimId !== claim.claimId && held(head)) throw new ControlRefusal(UNSAFE_REPLACEMENT, `the ${kind} key belongs to another claim`);
const same = head.n > 0 && head.record.claimId === claim.claimId && head.record.state === next.state &&
JSON.stringify(head.record.proof) === JSON.stringify(next.proof) && head.record.spawnMarker === next.spawnMarker &&
JSON.stringify(head.record.owner) === JSON.stringify(next.owner);
if (same) {
claim.n[kind] = head.n;
continue;
}
const rec = await this.publish(key, head.n + 1, next);
if (!rec) throw new ControlRefusal(UNSAFE_REPLACEMENT, `another writer published on the ${kind} key of claim ${claim.claimId}`);
claim.n[kind] = rec.revision;
if (kind === "seat") await this.pause("between-keys", { claimId: claim.claimId, patch });
}
claim.record = next;
return claim;
}
}
+234
View File
@@ -0,0 +1,234 @@
// The CHAT-03 client library (#1507, CHAT-03 §1, deviation V-1).
//
// It speaks the controller's line protocol over the Unix socket: `hello`
// with a grant, then CHAT-01 v2 client requests, each carrying the
// controller's incarnation token. The library never resends. A request still
// pending when the connection drops, or one refused `stale-incarnation`, is
// shown as "outcome unknown, check the transcript" (lead decision 25, H21–
// H23). An actor may copy its text into a new request; the library doesn't
// present that as a safe retry.
//
// Drafts are client-local in CHAT-03: the draft ID and revision name the
// client's own buffer, and the text travels in the request envelope. CHAT-04
// owns server-side drafts.
import { connect as netConnect } from "node:net";
import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
import { newId, targetOf } from "./records.mjs";
export const OUTCOME_UNKNOWN = "outcome unknown, check the transcript";
const STALE = "stale-incarnation";
const FINAL = new Set(["finished", "failed", "dispatch-refused", "delivery-unknown"]);
export class ConversationClient {
constructor({ socketPath, grant = "grant-local", connect = netConnect }) {
this.socketPath = socketPath;
this.grant = grant;
this.netConnect = connect;
this.sock = null;
this.incarnation = null;
this.connection = null;
this.binding = null;
this.streamEpoch = null;
this.pending = new Map();
this.unknown = [];
this.receipts = new Map();
this.events = [];
this.pushes = [];
this.listeners = new Set();
this.waiters = new Set();
this.closed = true;
}
get target() {
return this.binding ? targetOf(this.binding) : null;
}
get isController() {
return !!this.connection && this.binding?.controllerConnection === this.connection.id;
}
// `fn(message)` for every push, reply and status change.
on(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
#notify(msg) {
for (const fn of this.listeners) fn(msg);
for (const w of [...this.waiters]) w();
}
// Resolves with the welcome. A new incarnation turns every request still
// pending under the old one into outcome-unknown; none is resent (H23).
async connect() {
this.#dropPending("disconnected");
const sock = this.netConnect(this.socketPath);
this.sock = sock;
await new Promise((resolve, reject) => {
sock.once("connect", resolve);
sock.once("error", reject);
});
this.closed = false;
const welcome = new Promise((resolve, reject) => {
this.welcomeWaiter = { resolve, reject };
});
const splitter = new LineSplitter((line) => this.#onLine(line));
sock.on("data", (c) => splitter.push(c));
sock.on("error", () => {});
sock.on("close", () => {
if (this.sock !== sock) return;
this.closed = true;
this.welcomeWaiter?.reject(new Error("the controller closed the connection"));
this.welcomeWaiter = null;
this.#dropPending("disconnected");
this.#notify({ type: "status", status: "disconnected" });
});
sock.write(encodeLine({ type: "hello", grant: this.grant }));
return welcome;
}
close() {
this.sock?.destroy();
}
#dropPending(reason) {
for (const [id, p] of this.pending) {
const entry = { request: id, operation: p.envelope.command.operation, incarnation: p.incarnation, reason, display: OUTCOME_UNKNOWN };
this.unknown.push(entry);
p.resolve({ outcome: "outcome-unknown", refusal: null, display: OUTCOME_UNKNOWN, reason, request: null, receipt: null });
this.#notify({ type: "outcome-unknown", ...entry });
}
this.pending.clear();
}
// A receipt last seen short of a final state belongs to the old controller;
// the new one doesn't know it. Its outcome is unknown (H20, H23).
#unsettled() {
for (const r of this.receipts.values()) {
if (FINAL.has(r.state) || this.unknown.some((u) => u.receipt === r.id)) continue;
const entry = { request: null, receipt: r.id, operation: "prompt", incarnation: this.incarnation, reason: STALE, display: OUTCOME_UNKNOWN };
this.unknown.push(entry);
this.#notify({ type: "outcome-unknown", ...entry });
}
}
#onLine(line) {
const parsed = parseLine(line);
if (parsed.error) return;
const msg = parsed.value;
if (msg.type === "welcome") {
if (this.incarnation !== null && this.incarnation !== msg.incarnation) {
this.#dropPending(STALE);
this.#unsettled();
}
this.incarnation = msg.incarnation;
this.connection = msg.connection;
this.binding = msg.binding;
this.streamEpoch = msg.streamEpoch;
this.welcomeWaiter?.resolve(msg);
this.welcomeWaiter = null;
this.#notify(msg);
return;
}
if (msg.type === "refused" && this.welcomeWaiter) {
this.welcomeWaiter.reject(Object.assign(new Error(`refused: ${msg.refusal}`), { refusal: msg.refusal }));
this.welcomeWaiter = null;
return;
}
if (msg.type === "reply") {
const p = this.pending.get(msg.id);
if (!p) return;
this.pending.delete(msg.id);
const reply = { ...msg };
if (msg.refusal === STALE) {
reply.display = OUTCOME_UNKNOWN;
this.unknown.push({ request: msg.id, operation: p.envelope.command.operation, incarnation: p.incarnation, reason: STALE, display: OUTCOME_UNKNOWN });
}
if (msg.receipt) this.receipts.set(msg.receipt.id, msg.receipt);
p.resolve(reply);
this.#notify(reply);
return;
}
if (msg.type === "push") {
this.pushes.push(msg);
if (msg.kind === "binding") this.binding = msg.binding;
else if (msg.kind === "connection" && msg.connection.id === this.connection?.id) this.connection = msg.connection;
else if (msg.kind === "receipt") this.receipts.set(msg.receipt.id, msg.receipt);
else if (msg.kind === "event") this.events.push(msg.event);
this.#notify(msg);
}
}
// The envelope for one operation on the current target.
envelope(operation, fields = {}) {
if (!this.connection || !this.binding) throw new Error("not connected");
return { version: 2, kind: "clientRequest", id: newId("req"), connection: this.connection.id, target: this.target, command: { operation, ...fields } };
}
// Sends one request and resolves with the controller's reply.
request(operation, fields = {}, text) {
return this.send(this.envelope(operation, fields), text);
}
// Sends an envelope under the token this client holds now. An envelope is
// sent once; the library has no retry path.
send(envelope, text, { incarnation = this.incarnation } = {}) {
if (this.closed) return Promise.resolve({ outcome: "refused:channel", refusal: "channel", display: "not connected" });
return new Promise((resolve) => {
this.pending.set(envelope.id, { envelope, incarnation, resolve });
const msg = { type: "request", incarnation, request: envelope };
if (text !== undefined) msg.text = text;
this.sock.write(encodeLine(msg));
});
}
prompt(text, draft = { id: newId("draft"), revision: 1 }) {
return this.request("prompt", { draft: draft.id, draftRevision: draft.revision }, text);
}
observe({ cursor = null, limit = 50 } = {}) {
return this.request("observe", { cursor, limit });
}
takeover() {
return this.request("takeover");
}
interrupt() {
return this.request("interrupt");
}
// Issue, confirm and use a confirmation for a force stop, a recovery or
// recovery control.
async confirmed(operation, fields = {}) {
const issued = await this.request("issue-confirmation", { operationToConfirm: operation });
if (issued.outcome !== "confirmation-issued") return issued;
const id = issued.data.confirmation.id;
const answered = await this.request("answer-confirmation", { confirmation: id, answer: "confirm" });
if (answered.outcome !== "confirmation-confirmed") return answered;
return this.request(operation, { ...fields, confirmation: id });
}
receipt(id) {
return this.receipts.get(id) ?? null;
}
// Resolves when `pred()` holds, or false after `ms`.
waitFor(pred, ms = 5000) {
if (pred()) return Promise.resolve(true);
return new Promise((resolve) => {
const w = () => {
if (!pred()) return;
this.waiters.delete(w);
clearTimeout(t);
resolve(true);
};
const t = setTimeout(() => {
this.waiters.delete(w);
resolve(false);
}, ms);
this.waiters.add(w);
});
}
}
+235
View File
@@ -0,0 +1,235 @@
// Engine launch, cohort observation and force stop (#1507, CHAT-03 §6).
//
// ScopeLauncher starts the supervisor shim (shim.mjs) in a delegated
// systemd user scope named after the claim. The cohort is the scope's
// `engine` cgroup, and the scope's invocation ID is the membership epoch. A
// unit with the right name but another invocation ID is a different cohort:
// it gets no signal, and its evidence is unavailable.
//
// PgroupLauncher is the fallback with no scope: the engine leads its own
// process group. A process group can't be enumerated completely, so a force
// stop on it always ends `uncertain`.
//
// Force stop: check the invocation ID against systemd and the shim; TERM
// every member and wait a bounded grace; freeze `engine` and wait for
// `frozen 1`; enumerate every member with pid and start time; write
// `cgroup.kill`; wait for `populated 0`. Only when all of that succeeded is
// membership complete. Anything unavailable ends the stop `uncertain`.
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { connect } from "node:net";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { processStart } from "../../discord/src/journal.mjs";
import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
import { hash, newId, record, sealProof } from "./records.mjs";
export const AUTHORITY = "mosaic-conversation-shim-fixture";
export const SHIM_PATH = join(dirname(fileURLToPath(import.meta.url)), "shim.mjs");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export function cohortRefOf({ machineId, bootId, unitName, invocationId }) {
return "cohort-" + hash({ machineId, bootId, unitName, invocationId }).slice(0, 40);
}
// One request per connection keeps a dead shim from wedging a caller.
export function shimRequest(socketPath, op, extra = {}, timeoutMs = 3000) {
return new Promise((resolve) => {
if (!socketPath || !existsSync(socketPath)) return resolve({ ok: false, unavailable: "the shim socket is gone" });
const sock = connect(socketPath);
let done = false;
const finish = (v) => {
if (done) return;
done = true;
clearTimeout(timer);
sock.destroy();
resolve(v);
};
const timer = setTimeout(() => finish({ ok: false, unavailable: `the shim did not answer ${op}` }), timeoutMs);
const splitter = new LineSplitter((line) => {
const p = parseLine(line);
finish(p.error ? { ok: false, unavailable: `shim answer ${p.error}` } : p.value);
});
sock.on("data", (c) => splitter.push(c));
sock.on("error", () => finish({ ok: false, unavailable: "the shim is unreachable" }));
sock.on("close", () => finish({ ok: false, unavailable: "the shim closed the connection" }));
sock.on("connect", () => sock.write(encodeLine({ id: 1, op, ...extra })));
});
}
export function systemctlShow(unitName) {
const r = spawnSync("systemctl", ["--user", "show", "-p", "LoadState,ActiveState,InvocationID,ControlGroup", `${unitName}.scope`], { encoding: "utf8", timeout: 5000 });
if (r.status !== 0) return null;
const out = {};
for (const line of r.stdout.split("\n")) {
const i = line.indexOf("=");
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
}
return { loadState: out.LoadState ?? null, activeState: out.ActiveState ?? null, invocationId: out.InvocationID || null, controlGroup: out.ControlGroup || null };
}
// claim.classify's unit lookup. A unit systemd has collected is absent; one
// that is loaded and active is alive; anything unreadable is unknown.
export const systemdUnits = Object.freeze({
lookup(rec) {
if (!rec?.unitName) return { state: "unknown" };
const s = systemctlShow(rec.unitName);
if (!s) return { state: "unknown" };
if (s.loadState === "not-found" || (s.activeState === "inactive" && !s.controlGroup)) return { state: "absent" };
if (["active", "activating", "deactivating", "reloading"].includes(s.activeState)) return { state: "alive", invocationId: s.invocationId };
return { state: "unknown", detail: s };
},
});
export function scopeAvailable() {
const r = spawnSync("systemd-run", ["--user", "--scope", "--quiet", "-p", "Delegate=yes", "--", "true"], { timeout: 10000 });
return r.status === 0;
}
export class ScopeLauncher {
constructor({ shimPath = SHIM_PATH, startTimeoutMs = 10000 } = {}) {
this.kind = "scope";
this.shimPath = shimPath;
this.startTimeoutMs = startTimeoutMs;
}
async launch({ unitName, socketPath, command, args, cwd, env }) {
const proc = spawn("systemd-run", ["--user", "--scope", "-p", "Delegate=yes", `--unit=${unitName}`, "--quiet", "--", process.execPath, this.shimPath, "--socket", socketPath, "--", command, ...args], {
cwd, env, stdio: ["pipe", "pipe", "pipe"],
});
const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal })));
const end = Date.now() + this.startTimeoutMs;
let hello = null;
while (Date.now() < end) {
const race = await Promise.race([exited.then((e) => ({ exited: e })), sleep(20).then(() => null)]);
if (race?.exited) throw Object.assign(new Error(`the scope exited before the shim answered (${JSON.stringify(race.exited)})`), { code: "launch-failed" });
if (existsSync(socketPath)) {
hello = await shimRequest(socketPath, "hello");
if (hello.ok) break;
}
}
if (!hello?.ok) throw Object.assign(new Error("the shim never answered"), { code: "launch-failed", proc });
const show = systemctlShow(unitName);
return {
kind: "scope", proc, stdin: proc.stdin, stdout: proc.stdout, stderr: proc.stderr,
pid: hello.enginePid, start: hello.engineStart === null ? null : String(hello.engineStart),
invocationId: hello.invocationId, scope: hello.scope, shimSocket: socketPath,
systemd: show, exited,
};
}
}
export class PgroupLauncher {
constructor() {
this.kind = "pgroup";
}
async launch({ command, args, cwd, env }) {
const proc = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal })));
await new Promise((resolve, reject) => {
proc.once("spawn", resolve);
proc.once("error", reject);
});
return { kind: "pgroup", proc, stdin: proc.stdin, stdout: proc.stdout, stderr: proc.stderr, pid: proc.pid, start: processStart(proc.pid), invocationId: null, scope: null, shimSocket: null, exited };
}
}
// Runs the force-stop escalation from the TERM phase. `onPhase(name)` is
// awaited before each phase begins, so the caller records the phase before
// any signal. Returns { outcome: "proven" | "unavailable", ... }. Nothing is
// recorded as done unless it was observed.
export async function forceStopCohort({ kind, unitName, invocationId, shimSocket, pid, graceMs = 1000, onPhase = async () => {} }) {
if (kind === "pgroup") {
await onPhase("term");
try {
process.kill(-pid, "SIGTERM");
} catch {
// the group is gone
}
await sleep(graceMs);
await onPhase("kill");
try {
process.kill(-pid, "SIGKILL");
} catch {
// the group is gone
}
return { outcome: "unavailable", reason: "process-group fallback: membership can't be enumerated completely" };
}
const show = systemctlShow(unitName);
if (!show || !invocationId || show.invocationId !== invocationId) {
return { outcome: "unavailable", reason: `invocation ID mismatch or unreadable (recorded ${invocationId}, found ${show?.invocationId ?? "none"}); no signal sent` };
}
const hello = await shimRequest(shimSocket, "hello");
if (!hello.ok) return { outcome: "unavailable", reason: hello.unavailable };
if (hello.invocationId !== invocationId || hello.scope !== show.controlGroup) {
return { outcome: "unavailable", reason: "the shim does not answer for the recorded scope; no signal sent" };
}
await onPhase("term");
const term = await shimRequest(shimSocket, "term");
if (!term.ok) return { outcome: "unavailable", reason: term.unavailable, phase: "term" };
const end = Date.now() + graceMs;
for (;;) {
const e = await shimRequest(shimSocket, "events");
if (!e.ok) return { outcome: "unavailable", reason: e.unavailable, phase: "term" };
if (e.populated === 0 || Date.now() >= end) break;
await sleep(20);
}
await onPhase("kill");
const frozen = await shimRequest(shimSocket, "freeze", { timeoutMs: 3000 }, 6000);
if (!frozen.ok) return { outcome: "unavailable", reason: frozen.unavailable, phase: "kill" };
if (frozen.timedOut) return { outcome: "unavailable", reason: "engine never reported frozen 1", phase: "kill" };
const listed = await shimRequest(shimSocket, "members");
if (!listed.ok) return { outcome: "unavailable", reason: listed.unavailable, phase: "kill" };
const killed = await shimRequest(shimSocket, "kill", { timeoutMs: 5000 }, 8000);
if (!killed.ok) return { outcome: "unavailable", reason: killed.unavailable, phase: "kill" };
if (killed.timedOut || killed.populated !== 0) return { outcome: "unavailable", reason: "engine never reported populated 0", phase: "kill" };
const observedAt = new Date().toISOString();
if (listed.members.some((m) => !Number.isInteger(m.startTicks) || m.startTicks < 1)) {
return { outcome: "unavailable", reason: "a member's start time was unreadable at enumeration", phase: "kill" };
}
return {
outcome: "proven", membershipComplete: true, epoch: invocationId, observedAt, boot: listed.boot,
members: listed.members.map((m) => ({ pid: m.pid, boot: listed.boot, startTicks: m.startTicks, terminatedAt: observedAt })),
};
}
export function cohortProof({ binding, stop, result }) {
return sealProof(record("cohortProof", {
id: newId("cohort-proof"), authority: AUTHORITY, conversation: binding.scope.conversation, execution: binding.execution,
cohortRef: binding.cohortRef, membershipEpoch: result.epoch, membershipComplete: result.membershipComplete === true,
members: result.members, observedAt: result.observedAt, verificationDigest: "", stop,
}));
}
// A tool-start with no tool-end at stop time is an `uncertain` effect.
// Killing never counts as rollback.
export function effectReport({ binding, stop, tools, observedAt }) {
return sealProof(record("effectReport", {
id: newId("effects"), authority: AUTHORITY, conversation: binding.scope.conversation, execution: binding.execution,
cohortRef: binding.cohortRef,
invocations: [...tools.values()].map((t) => ({ id: t.call, disposition: t.end ? "completed" : "uncertain", evidence: t.end ?? t.start })),
observedAt, verificationDigest: "", stop,
}));
}
// The current boot's start time, from /proc/stat btime. Every process of an
// earlier boot ended before it.
export function bootTime() {
const line = readFileSync("/proc/stat", "utf8").split("\n").find((l) => l.startsWith("btime "));
return new Date(Number(line.slice(6)) * 1000).toISOString();
}
// A boot proof for a claim recorded under an earlier boot of this host. Its
// evidence is the boot change; it goes through the same verifier.
export function bootProof({ claim, conversation, execution, cohortRef, stop, now = () => new Date() }) {
const terminatedAt = bootTime();
const members = claim.engine?.pid && claim.engine?.start ? [{ pid: claim.engine.pid, boot: claim.host.bootId, startTicks: Number(claim.engine.start), terminatedAt }] : [];
return sealProof(record("cohortProof", {
id: newId("boot-proof"), authority: AUTHORITY, conversation, execution, cohortRef,
membershipEpoch: claim.invocationId ?? `boot-${claim.host.bootId}`, membershipComplete: true, members,
observedAt: now().toISOString(), verificationDigest: "", stop,
}));
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
// The engine pipe (#1507, CHAT-03 §1).
//
// One EngineLink per execution owns the engine's stdin and reads its stdout.
// Output is split on LF only (framing.mjs); every line reaches the controller
// in order, tagged with the link's execution, so a replaced engine's late
// output can be dropped by incarnation (H14).
//
// Write outcomes (§1):
// written the whole line was accepted by the pipe (the write callback ran
// without an error). That is not native consumption.
// unknown the write returned an error (EPIPE), or its callback did not run
// within the bound. A partial line may be in the pipe, so the link
// is poisoned and never written again.
// `acknowledged` is the response to a request, which `request()` reports
// separately. A request whose response does not come within its bound is
// reported as a timeout; the controller decides what that means.
import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
export const WRITE_TIMEOUT_MS = 5000;
export class EngineLink {
// `onLine(link, value, bytes)` gets every parsed non-response line;
// `onResponse(link, value, entry)` every response, before its promise
// resolves; `onGap(link, kind, detail)` an unparseable line, an unmatched
// response, an overlong line or EOF with a partial line.
constructor({ execution, stdin, stdout, onLine, onResponse = () => {}, onGap, onEnd = () => {}, writeTimeoutMs = WRITE_TIMEOUT_MS }) {
this.execution = execution;
this.stdin = stdin;
this.onLine = onLine;
this.onResponse = onResponse;
this.onGap = onGap;
this.onEnd = onEnd;
this.writeTimeoutMs = writeTimeoutMs;
this.poisoned = null;
this.pending = new Map();
this.serial = 0;
this.bytesWritten = 0;
this.ended = false;
this.writes = [];
stdin.on("error", (err) => {
this.stdinError = err.code ?? err.message;
});
this.splitter = new LineSplitter((line) => this.#line(line), { onOverflow: () => this.onGap(this, "overlong-line", null) });
stdout.on("data", (chunk) => this.splitter.push(chunk));
stdout.on("end", () => {
this.ended = true;
if (this.splitter.pending() > 0) this.onGap(this, "partial-line-at-eof", { bytes: this.splitter.pending() });
this.onEnd(this);
});
stdout.on("error", () => {});
}
#line(line) {
const bytes = Buffer.byteLength(line, "utf8");
const parsed = parseLine(line);
if (parsed.error) return this.onGap(this, "unparseable-line", { bytes, error: parsed.error });
const value = parsed.value;
if (value.type === "response") {
const entry = typeof value.id === "string" ? this.pending.get(value.id) : undefined;
if (!entry) return this.onGap(this, "unmatched-response", { bytes, command: typeof value.command === "string" ? value.command.slice(0, 40) : null });
this.pending.delete(value.id);
entry.late = entry.timedOut;
this.onResponse(this, value, entry);
entry.resolve({ response: value, late: entry.late });
return undefined;
}
return this.onLine(this, value, bytes);
}
poison(reason) {
if (!this.poisoned) this.poisoned = reason;
}
// Writes one record. Never writes to a poisoned link.
write(value) {
if (this.poisoned) return Promise.resolve({ outcome: "refused", reason: "poisoned" });
const line = encodeLine(value);
const bytes = Buffer.byteLength(line, "utf8");
return new Promise((resolve) => {
let done = false;
const finish = (o) => {
if (done) return;
done = true;
clearTimeout(timer);
if (o.outcome === "unknown") this.poison(o.reason);
else this.bytesWritten += bytes;
this.writes.push({ type: value.type, id: value.id, outcome: o.outcome });
resolve(o);
};
const timer = setTimeout(() => finish({ outcome: "unknown", reason: "write-timeout" }), this.writeTimeoutMs);
try {
this.stdin.write(line, (err) => finish(err ? { outcome: "unknown", reason: err.code ?? "write-error" } : { outcome: "written" }));
} catch (err) {
finish({ outcome: "unknown", reason: err.code ?? "write-error" });
}
});
}
// Sends a command. Returns { id, written, response }: `written` settles
// with the write outcome, `response` with { response } or { timeout: true }
// or { unsent: outcome }. The response is registered before the write, so
// it can never be unmatched.
request(type, fields = {}, { timeoutMs = 5000, beforeWrite = null } = {}) {
const id = `${type}-${++this.serial}`;
let resolve;
const response = new Promise((r) => (resolve = r));
const entry = { id, type, resolve, timedOut: false, late: false };
this.pending.set(id, entry);
beforeWrite?.(id);
const written = this.write({ id, type, ...fields });
written.then((w) => {
if (w.outcome !== "written") {
this.pending.delete(id);
resolve({ unsent: w });
return;
}
setTimeout(() => {
if (!this.pending.has(id)) return;
entry.timedOut = true; // kept, so a late answer is recognised and not a gap
resolve({ timeout: true });
}, timeoutMs);
});
return { id, written, response };
}
}
+121
View File
@@ -0,0 +1,121 @@
// Pi RPC events to CHAT-01 events (#1507, CHAT-03 §3 "Events").
//
// Pinned Pi 0.85.1 (docs/rpc.md, rpc-types.d.ts) emits JSON lines. Mapped:
//
// message_start -> message-start
// message_update text_delta -> text-delta (append)
// message_update thinking_delta -> thinking-delta (append)
// tool_execution_start -> tool-start
// tool_execution_update -> tool-update (replace)
// tool_execution_end -> tool-end (replace)
// message_end -> message-end (replace, one or more parts)
// agent_settled -> run-settled (never cohort termination)
//
// Known and deliberately not shown: turn_start, turn_end, agent_start,
// agent_end, queue_update, compaction_*, auto_retry_*, summarization_retry_*,
// bash_execution_update, extension_error, extension_ui_request (P3 has its own notice), and the
// message_update subtypes that message-end supersedes (text_start, text_end,
// thinking_start, thinking_end, toolcall_*, start, done, error). The
// controller counts them in its evidence. Anything else is an unknown event:
// no client event, counted with its type and byte size, and never passed
// through raw.
//
// Pi's stream carries no entry ID. `entry` on message-end is a stream-local
// ID, not a session entry ID, so the seam between a history page and the
// stream can't be deduplicated by ID (E4).
import { fragments, safeId, LIMITS } from "./parts.mjs";
export const KNOWN_UNSHOWN = Object.freeze(new Set([
"turn_start", "turn_end", "agent_start", "agent_end", "queue_update", "compaction_start", "compaction_end",
"auto_retry_start", "auto_retry_end", "summarization_retry_scheduled", "summarization_retry_attempt_start",
"summarization_retry_finished", "bash_execution_update", "extension_error", "extension_ui_request", "response",
]));
export const MAPPED = Object.freeze(new Set(["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_update", "tool_execution_end", "agent_settled"]));
const FOLDED_UPDATES = new Set(["start", "text_start", "text_end", "thinking_start", "thinking_end", "toolcall_start", "toolcall_delta", "toolcall_end", "done", "error"]);
export const DIALOG_METHODS = Object.freeze(new Set(["select", "confirm", "input", "editor"]));
export const NOTIFY_METHODS = Object.freeze(new Set(["notify", "setStatus", "setWidget", "setTitle", "set_editor_text"]));
export function roleOf(message) {
switch (message?.role) {
case "user":
return "user";
case "assistant":
return "assistant";
case "toolResult":
return "tool";
case "compactionSummary":
return "compaction";
default:
return "notice";
}
}
const textOf = (content) => {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("");
};
function pushText(out, type, text, block, extra = {}) {
const parts = fragments(text);
parts.forEach((t, i) => out.push({ type, ...extra, text: t, block, fragment: i, lastFragment: i === parts.length - 1 }));
}
// Every content block of a finished native message, in CHAT-01 form.
export function messageBlocks(message) {
const out = [];
const role = roleOf(message);
if (role === "tool") {
pushText(out, "tool-result", textOf(message.content), 0, { call: safeId(message.toolCallId), isError: message.isError === true });
return out;
}
if (role === "compaction") {
const parts = fragments(typeof message.summary === "string" ? message.summary : "");
parts.forEach((t, i) => out.push({ type: "compaction", summary: t, nativeEntry: safeId(message.firstKeptEntryId ?? "compaction"), block: 0, fragment: i, lastFragment: i === parts.length - 1 }));
return out;
}
const content = typeof message?.content === "string" ? [{ type: "text", text: message.content }] : Array.isArray(message?.content) ? message.content : [];
content.forEach((c, block) => {
if (!c || typeof c !== "object") return;
if (c.type === "text") pushText(out, "text", String(c.text ?? ""), block);
else if (c.type === "thinking") {
if (c.redacted) out.push({ type: "thinking", text: "", visibility: "unavailable", block, fragment: 0, lastFragment: true });
else pushText(out, "thinking", String(c.thinking ?? ""), block, { visibility: "permitted-visible" });
} else if (c.type === "toolCall") {
const parts = fragments(JSON.stringify(c.arguments ?? {}));
parts.forEach((t, i) => out.push({ type: "tool-call", call: safeId(c.id), name: safeId(c.name), argumentsText: t, block, fragment: i, lastFragment: i === parts.length - 1 }));
} else if (c.type === "image") {
out.push({ type: "attachment", attachment: safeId(`image-${block}`), block, fragment: 0, lastFragment: true });
} else {
pushText(out, "text", `[${String(c.type).slice(0, 40)} block not shown]`, block);
}
});
return out;
}
// Splits blocks into message-end parts of at most LIMITS.blocks blocks.
export function partsOf(blocks) {
if (blocks.length === 0) return [[]];
const out = [];
for (let i = 0; i < blocks.length; i += LIMITS.blocks) out.push(blocks.slice(i, i + LIMITS.blocks));
return out;
}
export function deltaBlocks(kind, delta, contentIndex) {
const out = [];
const block = Number.isInteger(contentIndex) && contentIndex >= 0 ? contentIndex : 0;
if (kind === "thinking") pushText(out, "thinking", String(delta ?? ""), block, { visibility: "permitted-visible" });
else pushText(out, "text", String(delta ?? ""), block);
return out;
}
export function isFoldedUpdate(type) {
return FOLDED_UPDATES.has(type);
}
export function toolResultText(result) {
if (result && typeof result === "object") return textOf(result.content);
return typeof result === "string" ? result : "";
}
+68
View File
@@ -0,0 +1,68 @@
// JSONL framing for the engine pipe and the control socket (#1507, CHAT-03 §1).
//
// Records are split on LF (0x0A) only, and a trailing CR is stripped (rpc.md
// lines 30–38). Node's readline is not used, because it also splits on U+2028
// and U+2029, which JSON strings may carry raw (E2). Splitting happens on
// bytes, before UTF-8 decoding, so a multibyte character split across two
// chunks is never damaged.
export const MAX_LINE_BYTES = 64 * 1024 * 1024;
export class LineSplitter {
constructor(onLine, { maxBytes = MAX_LINE_BYTES, onOverflow = null } = {}) {
this.onLine = onLine;
this.maxBytes = maxBytes;
this.onOverflow = onOverflow;
this.parts = [];
this.size = 0;
this.overflowed = false;
}
push(chunk) {
if (this.overflowed) return;
let start = 0;
for (;;) {
const lf = chunk.indexOf(0x0a, start);
if (lf === -1) break;
this.parts.push(chunk.subarray(start, lf));
const line = Buffer.concat(this.parts);
this.parts = [];
this.size = 0;
const end = line.length > 0 && line[line.length - 1] === 0x0d ? line.length - 1 : line.length;
this.onLine(line.subarray(0, end).toString("utf8"));
start = lf + 1;
}
if (start < chunk.length) {
const rest = chunk.subarray(start);
this.size += rest.length;
if (this.size > this.maxBytes) {
this.overflowed = true;
this.parts = [];
this.onOverflow?.();
return;
}
this.parts.push(Buffer.from(rest));
}
}
// Bytes left without a terminating LF when the stream ends. They are never
// parsed as a record: a partial line is a transport gap, not data.
pending() {
return this.size;
}
}
export function encodeLine(value) {
return JSON.stringify(value) + "\n";
}
// Parses one line. Returns {value} or {error}; never throws.
export function parseLine(line) {
try {
const value = JSON.parse(line);
if (value === null || typeof value !== "object" || Array.isArray(value)) return { error: "not-an-object" };
return { value };
} catch {
return { error: "unparseable" };
}
}
+143
View File
@@ -0,0 +1,143 @@
// The live-session guard (#1507, CHAT-03 §2).
//
// CHAT-03 is fixture-only in code. The claim root, the socket directory and
// every session path are constructor arguments, with no default. At
// construction and again at bind, their real paths must lie inside the
// explicit fixture root and outside every protected location: the
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any
// path a seat registration names. A path is refused when it is inside a
// protected location or contains one. The real protections always apply;
// options only add to them, so a test can't switch them off.
//
// The guard comes out only at cutover (CHAT-07), as a reviewed data-map
// change.
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { ControlRefusal } from "./safe-fs.mjs";
export const LIVE_SESSION_REFUSED = "live-session-refused";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
// The real path of `p`, or of its nearest existing ancestor with the rest
// appended when `p` doesn't exist yet.
export function realPath(p) {
const abs = resolve(p);
const tail = [];
let cur = abs;
for (;;) {
try {
return join(realpathSync(cur), ...tail.reverse());
} catch (err) {
if (err.code !== "ENOENT" && err.code !== "ENOTDIR") throw err;
const up = dirname(cur);
if (up === cur) return abs;
tail.push(basename(cur));
cur = up;
}
}
}
const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
const overlaps = (a, b) => inside(a, b) || inside(b, a);
function configuredDataRoot(home) {
try {
const cfg = JSON.parse(readFileSync(join(home, ".config", "mosaic-dev", "config.json"), "utf8"));
if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home);
} catch {
// An unreadable config adds no location; the default data root still applies.
}
return null;
}
// Registrations under <dataRoot>/seats/<layout>/<seat>/registration.json. An
// unreadable one adds nothing; the data root itself is protected anyway.
function registrationsUnder(dataRoot) {
const out = [];
const seats = join(dataRoot, "seats");
let layouts = [];
try {
layouts = readdirSync(seats);
} catch {
return out;
}
for (const layout of layouts) {
let names = [];
try {
names = readdirSync(join(seats, layout));
} catch {
continue;
}
for (const seat of names) {
try {
out.push(JSON.parse(readFileSync(join(seats, layout, seat, "registration.json"), "utf8")));
} catch {
// skipped
}
}
}
return out;
}
function pathsIn(value, out) {
if (typeof value === "string") {
if (isAbsolute(value)) out.push(value);
} else if (Array.isArray(value)) {
for (const v of value) pathsIn(v, out);
} else if (value && typeof value === "object") {
for (const v of Object.values(value)) pathsIn(v, out);
}
return out;
}
export class LiveSessionGuard {
// `fixtureRoot` is required. `repoRoots`, `homes`, `dataRoots` and
// `registrations` add protected locations to the real ones.
constructor({ fixtureRoot, repoRoots = [], homes = [], dataRoots = [], registrations = [] } = {}) {
if (typeof fixtureRoot !== "string" || !isAbsolute(fixtureRoot)) throw new ControlRefusal(LIVE_SESSION_REFUSED, "an absolute fixture root is required");
if (!existsSync(fixtureRoot)) throw new ControlRefusal(LIVE_SESSION_REFUSED, "the fixture root does not exist");
this.fixtureRoot = fixtureRoot;
const home = homedir();
const allHomes = [home, ...homes];
const protectedPaths = [];
for (const repo of [REPO_ROOT, ...repoRoots]) protectedPaths.push({ path: join(repo, ".pi", "state"), why: "a repository .pi/state" });
for (const h of allHomes) {
protectedPaths.push({ path: join(h, ".pi"), why: "~/.pi" });
protectedPaths.push({ path: join(h, ".claude"), why: "~/.claude" });
protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" });
const configured = configuredDataRoot(h);
if (configured) protectedPaths.push({ path: configured, why: "the configured data root" });
}
for (const d of dataRoots) protectedPaths.push({ path: d, why: "the data root" });
const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path);
const found = roots.flatMap(registrationsUnder);
for (const reg of [...found, ...registrations]) for (const p of pathsIn(reg, [])) protectedPaths.push({ path: p, why: "a seat registration" });
this.protected = protectedPaths;
}
// Refuses unless every path is inside the fixture root and clear of every
// protected location, both as written and as real paths.
check(paths, when) {
const root = realPath(this.fixtureRoot);
const guarded = this.protected.flatMap((p) => [{ ...p, path: resolve(p.path) }, { ...p, path: realPath(p.path) }]);
for (const [name, p] of Object.entries(paths)) {
if (typeof p !== "string" || !isAbsolute(p)) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} must be an absolute path (${when})`);
const written = resolve(p), real = realPath(p);
if (!inside(written, root) && !inside(written, resolve(this.fixtureRoot))) {
throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} is outside the fixture root (${when})`);
}
// The real path must be inside the real fixture root: a symlink out of
// it is refused even when the link itself sits inside.
if (!inside(real, root)) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} resolves outside the fixture root (${when})`);
for (const candidate of [written, real]) {
const hit = guarded.find((g) => overlaps(candidate, g.path));
if (hit) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} overlaps ${hit.why} (${when})`);
}
}
return true;
}
}
+92
View File
@@ -0,0 +1,92 @@
// The Pi pin and the engine seal (#1507, CHAT-03 §3, lead decisions 31–32).
//
// Pin: package-lock.json and npm's installed record
// (node_modules/.package-lock.json) must both name the pinned version with the
// pinned integrity. That ties the install to the package through npm's record;
// it is not a hash of the files on disk. `pi` runs dist/bundle/cli.js, the
// package's bin, and the built-in llama.cpp extension ships inside it.
//
// Seal: the controller builds the launch argv. It always carries
// --no-extensions, --no-prompt-templates and --no-themes, and never an
// --extension argument (cli/args.js; usage.md 224 and 233–236). With
// --no-extensions Pi loads only command-line extension paths
// (resource-loader.js 316–318), so no explicit extension loads. Under the seal
// the Mosaic prompt in the slot is the only thing that can start a run, which
// is the basis for attributing a run to it by order.
//
// The seal is an allow-list. Pi's parser (cli/args.js) keeps the last --mode
// and the last --session, reads a bare word as a prompt and an `@` word as a
// file, so the argv must be exactly the controller's prefix followed by
// ENGINE_OPTIONS pairs, each at most once with one plain value.
import { readFileSync } from "node:fs";
import { isAbsolute, join } from "node:path";
import { createHash } from "node:crypto";
import { ControlRefusal } from "./safe-fs.mjs";
export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
export const PI_VERSION = "0.85.1";
export const PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==";
export const PI_BIN = join("node_modules", PI_PACKAGE, "dist", "bundle", "cli.js");
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes"]);
export const ENGINE_OPTIONS = Object.freeze(["--model", "--provider", "--thinking"]);
export const ENGINE_PIN_MISMATCH = "engine-pin-mismatch";
export const UNSEALED_ENGINE = "unsealed-engine";
function lockEntry(path) {
let lock;
try {
lock = JSON.parse(readFileSync(path, "utf8"));
} catch {
return null;
}
const entry = lock?.packages?.[`node_modules/${PI_PACKAGE}`];
return entry && typeof entry === "object" ? entry : null;
}
// `root` holds package-lock.json and node_modules/.package-lock.json.
export function checkEnginePin(root) {
for (const path of [join(root, "package-lock.json"), join(root, "node_modules", ".package-lock.json")]) {
const entry = lockEntry(path);
if (!entry || entry.version !== PI_VERSION || entry.integrity !== PI_INTEGRITY) {
throw new ControlRefusal(ENGINE_PIN_MISMATCH, `${path} does not pin ${PI_PACKAGE} ${PI_VERSION} with the pinned integrity`);
}
}
return { version: PI_VERSION, pin: PI_INTEGRITY };
}
export function buildPiArgs({ sessionFile, extraArgs = [] }) {
return ["--mode", "rpc", ...SEAL_FLAGS, "--session", sessionFile, ...extraArgs];
}
// Refuses any argv that is not `--mode rpc`, the three --no-* flags and
// `--session <absolute path>`, in that order, followed by ENGINE_OPTIONS
// pairs. That covers --extension in either spelling, a second --mode or
// --session, session and output flags (--no-session, --fork, --export, ...)
// and stray prompt words.
export function checkSeal(args) {
if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) throw new ControlRefusal(UNSEALED_ENGINE, "launch argv is not a list of strings");
const extension = args.find((a) => a === "-e" || a === "--extension" || a.startsWith("--extension="));
if (extension !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${extension}`);
for (const flag of SEAL_FLAGS) {
if (!args.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv lacks ${flag}`);
}
const prefix = ["--mode", "rpc", ...SEAL_FLAGS, "--session"];
if (prefix.some((a, i) => args[i] !== a)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv does not start with ${prefix.join(" ")}`);
const file = args[prefix.length];
if (typeof file !== "string" || !isAbsolute(file)) throw new ControlRefusal(UNSEALED_ENGINE, "the --session value is not an absolute path");
const seen = new Set();
for (let i = prefix.length + 1; i < args.length; i += 2) {
const flag = args[i], value = args[i + 1];
if (!ENGINE_OPTIONS.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${flag}, which is not one of ${ENGINE_OPTIONS.join(", ")}`);
if (seen.has(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv repeats ${flag}`);
if (typeof value !== "string" || !value || value.startsWith("-") || value.startsWith("@")) throw new ControlRefusal(UNSEALED_ENGINE, `${flag} needs one plain value`);
seen.add(flag);
}
return true;
}
export function argvDigest(command, args) {
return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex");
}
+101
View File
@@ -0,0 +1,101 @@
// CHAT-01 records for the live controller (#1507, CHAT-03).
//
// Every record the controller emits is a CHAT-01 v2 record (schema
// docs/plans/chat-01/contracts.schema.json). The digest rule is CHAT-01's:
// sha256 of JSON with sorted keys (check.mjs `hash`).
//
// The verifier is the fixture's trusted digest registry, as in CHAT-01
// (check.mjs `proof` and `stopped`). A proof the producer posts to it is
// self-posted, so no CHAT-03 proof is live authority (CHAT-01 lines 330–333).
// Without a verifier no proof verifies, and every stop ends `uncertain`.
import { createHash, randomBytes } from "node:crypto";
import { ID } from "./parts.mjs";
export const VERSION = 2;
const sortKeys = (v) =>
Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v;
export const canonical = (v) => JSON.stringify(sortKeys(v));
export const hash = (v) => createHash("sha256").update(canonical(v)).digest("hex");
export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
export const without = (v, key) => Object.fromEntries(Object.entries(v).filter(([k]) => k !== key));
export const equal = (a, b) => canonical(a) === canonical(b);
export const clone = (v) => structuredClone(v);
export function newId(prefix) {
const id = `${prefix}-${randomBytes(8).toString("hex")}`;
if (!ID.test(id)) throw new Error(`bad id prefix ${prefix}`);
return id;
}
export const record = (kind, fields) => ({ version: VERSION, kind, ...fields });
export function targetOf(binding) {
return {
conversation: binding.scope.conversation,
branch: binding.branch,
execution: binding.execution,
controllerGeneration: binding.controllerGeneration,
};
}
export const scopeMatch = (a, b) => a.conversation === b.conversation && a.branch === b.branch && a.execution === b.execution;
// Seals a proof: its verification digest covers every other field.
export function sealProof(p) {
const body = without(p, "verificationDigest");
return { ...body, verificationDigest: hash(body) };
}
export class FixtureVerifier {
constructor({ authorities = [] } = {}) {
this.authorities = new Set(authorities);
this.trusted = new Map();
}
// The fixture registry records a posted proof's digest (CHAT-01 fixtures'
// `trustedProofs`). Only proofs from a trusted authority are recorded.
post(p) {
if (!p || !this.authorities.has(p.authority)) return false;
const digest = hash(without(p, "verificationDigest"));
if (digest !== p.verificationDigest) return false;
this.trusted.set(p.id, digest);
return true;
}
// check.mjs `proof`: authority, scope, stop, time and digest.
verify(p, kind, { binding, stop, now }) {
if (!p || p.kind !== kind || !this.authorities.has(p.authority)) return null;
if (p.conversation !== binding.scope.conversation || p.execution !== binding.execution || p.cohortRef !== binding.cohortRef || p.stop !== stop) return null;
if (Date.parse(p.observedAt) > now.getTime()) return null;
const digest = hash(without(p, "verificationDigest"));
return digest === p.verificationDigest && this.trusted.get(p.id) === digest ? p : null;
}
// check.mjs `effects`.
effects(report, ctx) {
const p = this.verify(report, "effectReport", ctx);
return Boolean(p && p.invocations.every((i) => ["completed", "uncertain", "not-started"].includes(i.disposition) && (i.disposition === "not-started" || i.evidence)));
}
// check.mjs `stopped`, less the stop-record checks the controller makes.
cohort(proof, report, ctx) {
const p = this.verify(proof, "cohortProof", ctx);
if (!p || !p.membershipComplete || p.membershipEpoch !== ctx.epoch) return false;
const unique = new Set(p.members.map((m) => `${m.boot}:${m.pid}:${m.startTicks}`)).size === p.members.length;
const dead = p.members.every((m) => m.terminatedAt && Date.parse(m.terminatedAt) <= Date.parse(p.observedAt));
return unique && dead && this.effects(report, ctx);
}
}
// Receipt order (§3 rule 8): admitted < dispatched < acknowledged < working <
// finished | failed. dispatch-refused only before dispatched,
// delivery-unknown only before working.
const ORDER = { admitted: 0, dispatched: 1, acknowledged: 2, working: 3, finished: 4, failed: 4 };
export function receiptAllows(from, to) {
if (["finished", "failed", "dispatch-refused", "delivery-unknown"].includes(from)) return false;
if (to === "dispatch-refused") return from === "admitted";
if (to === "delivery-unknown") return ORDER[from] < ORDER.working;
return ORDER[to] > ORDER[from];
}
+5
View File
@@ -26,6 +26,11 @@ export class Refusal extends Error {
}
}
// Refusals of the CHAT-03 control layer (controller, claims, guard, engine
// pin). They reach a socket client, never the reader's HTTP routes, so the
// control board's status map covers only the reader's own codes.
export class ControlRefusal extends Refusal {}
const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*\.jsonl$/;
// Permission errors inside a root are one conversation's problem, not the
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
// The supervisor shim (#1507, CHAT-03 §6). Not a library: `cohort.mjs` starts
// it as
//
// systemd-run --user --scope -p Delegate=yes --unit=<name> --quiet -- \
// node shim.mjs --socket <path> -- <engine argv...>
//
// Inside the delegated scope it moves itself into a `supervisor` child cgroup
// and starts the engine in an `engine` child cgroup. A small shell writes its
// own pid into engine/cgroup.procs before it execs `unshare -U
// --map-current-user --cgroup`, which execs the engine, so the engine is
// contained from its first instruction and its cgroup namespace is rooted at
// `engine`. The engine inherits the controller's stdin and stdout directly;
// the shim closes its own copies. The shim is not a cohort member. It holds
// the scope open, so systemd can't collect the cgroup before emptiness is
// read, and it outlives its controller so a restarted controller can reach
// the cohort again.
//
// Control is JSON lines over a Unix socket in the controller's 0700 socket
// directory. Every request names an op; every answer is {ok, ...} or
// {ok:false, unavailable}. Emptiness is a readable engine/cgroup.events with
// `populated 0`. A missing or unreadable file is unavailable, never empty.
import { spawn } from "node:child_process";
import { closeSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
const args = process.argv.slice(2);
const sep = args.indexOf("--");
const opt = (name) => {
const i = args.indexOf(name);
return i >= 0 && i < sep ? args[i + 1] : null;
};
const socketPath = opt("--socket");
const engineArgv = sep >= 0 ? args.slice(sep + 1) : [];
if (!socketPath || engineArgv.length === 0) {
process.stderr.write("shim: usage: shim.mjs --socket <path> -- <engine argv...>\n");
process.exit(2);
}
const own = readFileSync("/proc/self/cgroup", "utf8").split("\n").find((l) => l.startsWith("0::"));
const scope = join("/sys/fs/cgroup", own.slice(3).trim());
const supervisor = join(scope, "supervisor");
const engine = join(scope, "engine");
const invocationId = process.env.INVOCATION_ID ?? null;
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
mkdirSync(supervisor, { recursive: true });
mkdirSync(engine, { recursive: true });
writeFileSync(join(supervisor, "cgroup.procs"), String(process.pid));
const startOf = (pid) => {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
return Number(fields[19]);
} catch {
return null;
}
};
const child = spawn(
"/bin/sh",
["-c", 'echo $$ > "$1/cgroup.procs" || exit 97; shift; exec unshare -U --map-current-user --cgroup -- "$@"', "mosaic-engine", engine, ...engineArgv],
{ stdio: [0, 1, 2] },
);
const enginePid = child.pid;
let engineExit = null;
child.on("exit", (code, signal) => {
engineExit = { code, signal, at: new Date().toISOString() };
});
// The engine holds the controller's pipes; the shim's copies would hide EOF.
closeSync(0);
closeSync(1);
function events() {
try {
const text = readFileSync(join(engine, "cgroup.events"), "utf8");
const out = {};
for (const line of text.split("\n")) {
const [k, v] = line.split(" ");
if (k) out[k] = Number(v);
}
if (out.populated !== 0 && out.populated !== 1) return { ok: false, unavailable: "cgroup.events has no populated field" };
return { ok: true, populated: out.populated, frozen: out.frozen ?? null };
} catch (err) {
return { ok: false, unavailable: `engine/cgroup.events unreadable (${err.code ?? err.message})` };
}
}
// Every member of `engine` and its descendants: the engine's namespace can
// create child cgroups, so enumeration is recursive.
function members() {
const out = [];
const walk = (dir) => {
const procs = readFileSync(join(dir, "cgroup.procs"), "utf8").split("\n").filter(Boolean).map(Number);
for (const pid of procs) out.push({ pid, startTicks: startOf(pid), cgroup: dir.slice(scope.length) || "/" });
for (const name of readdirSync(dir, { withFileTypes: true })) if (name.isDirectory()) walk(join(dir, name.name));
};
try {
walk(engine);
return { ok: true, members: out, boot: bootId };
} catch (err) {
return { ok: false, unavailable: `engine enumeration failed (${err.code ?? err.message})` };
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(pred, ms) {
const end = Date.now() + ms;
for (;;) {
const e = events();
if (!e.ok) return e;
if (pred(e)) return e;
if (Date.now() >= end) return { ...e, timedOut: true };
await sleep(10);
}
}
async function handle(req) {
switch (req.op) {
case "hello":
return { ok: true, invocationId, scope: scope.slice("/sys/fs/cgroup".length), enginePid, engineStart: startOf(enginePid), shimPid: process.pid, shimStart: startOf(process.pid), boot: bootId, engineExit };
case "events":
return events();
case "members":
return members();
case "term": {
const m = members();
if (!m.ok) return m;
const signalled = [];
for (const { pid, startTicks } of m.members) {
if (startOf(pid) !== startTicks) continue;
try {
process.kill(pid, "SIGTERM");
signalled.push(pid);
} catch {
// gone already
}
}
return { ok: true, signalled };
}
case "freeze":
try {
writeFileSync(join(engine, "cgroup.freeze"), "1");
} catch (err) {
return { ok: false, unavailable: `cgroup.freeze unwritable (${err.code ?? err.message})` };
}
return waitFor((e) => e.frozen === 1 || e.populated === 0, Number(req.timeoutMs) || 2000);
case "kill":
try {
writeFileSync(join(engine, "cgroup.kill"), "1");
} catch (err) {
return { ok: false, unavailable: `cgroup.kill unwritable (${err.code ?? err.message})` };
}
return waitFor((e) => e.populated === 0, Number(req.timeoutMs) || 5000);
case "release": {
const e = events();
if (!e.ok || e.populated !== 0) return { ok: false, unavailable: "the engine cgroup is not empty" };
setTimeout(() => {
try {
unlinkSync(socketPath);
} catch {
// already gone
}
process.exit(0);
}, 10);
return { ok: true };
}
default:
return { ok: false, unavailable: `unknown op ${String(req.op)}` };
}
}
const server = createServer((sock) => {
const splitter = new LineSplitter(async (line) => {
const parsed = parseLine(line);
const answer = parsed.error ? { ok: false, unavailable: parsed.error } : await handle(parsed.value);
if (!sock.destroyed) sock.write(encodeLine({ id: parsed.value?.id ?? null, ...answer }));
}, { maxBytes: 65536 });
sock.on("data", (chunk) => splitter.push(chunk));
sock.on("error", () => {});
});
server.listen(socketPath);
process.on("SIGTERM", () => {}); // a stray TERM never drops the scope's anchor
process.on("SIGHUP", () => {});
+280
View File
@@ -0,0 +1,280 @@
// The mediated terminal (#1507, CHAT-03 §1, §4, §7, §9).
//
// node packages/conversation/src/terminal.mjs --socket <path> [--grant <id>]
//
// A thin view over the client library. It renders the same Transcript the
// library offers (E7), so it shows what any other client shows. It holds no
// engine-side state.
//
// The composer is a local buffer (§4). It is empty at start, cleared after
// each submit and whenever the controller changes, and it submits only while
// this connection is the controller. An observer's submit is refused here,
// "not admitted: controller", and sends nothing (S5). A bracketed paste is
// inserted literally, newlines included; it never submits by itself.
//
// Keys: Enter submits; Ctrl-J or Alt-Enter adds a newline; Ctrl-T takes
// control; Ctrl-G interrupts; Ctrl-O reconnects if needed and re-reads the
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits.
//
// Engine text is shown with control characters made visible, so transcript
// content can't drive the operator's terminal.
import { pathToFileURL } from "node:url";
import { ConversationClient, OUTCOME_UNKNOWN } from "./client.mjs";
import { Transcript } from "./transcript.mjs";
export const NOT_CONTROLLER = "not admitted: controller";
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
const KEYS = Object.freeze({ "\r": "submit", "\n": "newline", "\x7f": "backspace", "\b": "backspace", "\x14": "takeover", "\x07": "interrupt", "\x0f": "reload", "\x03": "quit", "\x04": "quit" });
// Control characters, line and paragraph separators, bidi controls, invisible
// characters that can hide or spoof text (zero-width space, word joiner and
// invisible operators, BOM, tag characters) shown as text. ZWJ and ZWNJ pass:
// emoji sequences and joining scripts need them.
export function visible(s) {
return String(s).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]|\t/gu, (c) => {
if (c === "\t") return " ";
const n = c.codePointAt(0);
if (n < 0x20) return "^" + String.fromCharCode(n + 64);
if (n === 0x7f) return "^?";
return `<U+${n.toString(16).toUpperCase().padStart(4, "0")}>`;
});
}
// For lines that must stay one line (head, status, notices): LF shown too.
const oneLine = (s) => visible(s).replace(/\n/g, "^J");
export class Terminal {
constructor({ client, write = () => {}, rows = 24, onQuit = () => {} }) {
this.client = client;
this.write = write;
this.rows = rows;
this.onQuit = onQuit;
this.transcript = new Transcript({ client });
this.composer = "";
this.inPaste = false;
this.carry = "";
this.scroll = 0;
this.status = "";
this.unknownCount = 0;
this.dialogs = [];
this.notices = [];
this.lastReceipt = null;
this.controller = client.binding?.controllerConnection ?? null;
this.frame = [];
this.sent = 0;
this.queue = Promise.resolve();
client.on((m) => this.#onClient(m));
this.transcript.on(() => this.render());
}
#onClient(m) {
if (m.type === "welcome" || (m.type === "push" && m.kind === "binding")) {
const next = this.client.binding?.controllerConnection ?? null;
// §4: the composer clears on every control transfer (mutant 10).
if (next !== this.controller) this.composer = "";
this.controller = next;
} else if (m.type === "push" && m.kind === "unknown") {
this.unknownCount = m.count;
} else if (m.type === "push" && m.kind === "dialog") {
this.dialogs.push(m.dialog);
} else if (m.type === "push" && m.kind === "receipt") {
if (m.outcomeUnknown) this.notices.push(`prompt ${m.receipt.id}: ${OUTCOME_UNKNOWN}`);
if (m.receipt.id === this.lastReceipt?.id) this.lastReceipt = m.receipt;
} else if (m.type === "outcome-unknown") {
this.notices.push(`${m.operation}: ${OUTCOME_UNKNOWN}`);
} else if (m.type === "status" && m.status === "disconnected") {
this.status = "disconnected; Ctrl-O reconnects";
}
this.render();
}
// Feeds raw terminal input. Resolves when the actions it started finish.
key(data) {
const actions = [];
let s = this.carry + data;
this.carry = "";
let i = 0;
while (i < s.length) {
if (this.inPaste) {
const end = s.indexOf(PASTE_END, i);
if (end === -1) {
const keep = partialSuffix(s.slice(i), PASTE_END);
this.composer += s.slice(i, s.length - keep);
this.carry = s.slice(s.length - keep);
break;
}
this.composer += s.slice(i, end);
this.inPaste = false;
i = end + PASTE_END.length;
continue;
}
if (s.startsWith(PASTE_START, i)) {
this.inPaste = true;
i += PASTE_START.length;
continue;
}
if (s[i] === "\x1b") {
// A trailing ESC, alone or with more of the paste-start marker, waits
// for the next chunk. A lone Escape has no action here, so holding it
// costs nothing.
if (s.length - i < PASTE_START.length && PASTE_START.startsWith(s.slice(i))) {
this.carry = s.slice(i);
break;
}
const seq = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O.|[\s\S])?/.exec(s.slice(i))[0];
if (seq === "\x1b[5~") this.scroll += Math.max(1, this.rows - 4);
else if (seq === "\x1b[6~") this.scroll = Math.max(0, this.scroll - Math.max(1, this.rows - 4));
else if (seq === "\x1b\r") this.composer += "\n";
i += seq.length;
continue;
}
const action = KEYS[s[i]];
if (action === "newline") this.composer += "\n";
else if (action === "backspace") this.composer = Array.from(this.composer).slice(0, -1).join("");
else if (action === "submit") {
// The composer is taken at the Enter, so text after it in the same
// chunk starts the next message instead of joining this one.
const text = this.#take();
if (text !== null) actions.push(() => this.#send(text));
} else if (action) actions.push(() => this.#act(action));
else if (s[i] >= " ") this.composer += s[i];
i += 1;
}
this.render();
for (const run of actions) this.queue = this.queue.then(run);
return this.queue;
}
async #act(action) {
if (action === "takeover") return this.#show("takeover", await this.client.takeover());
if (action === "interrupt") return this.#show("interrupt", await this.client.interrupt());
if (action === "reload") {
if (this.client.closed) {
try {
await this.client.connect();
} catch (err) {
this.status = `connect: ${err.refusal ?? err.message}`;
}
} else await this.transcript.reload("manual");
return this.render();
}
if (action === "quit") return this.onQuit();
}
// Sends the composer as one prompt, only as the controller. A submit clears
// the composer whatever its outcome; an observer's Enter is refused before
// that and leaves the buffer for the operator.
async submit() {
const text = this.#take();
if (text === null) {
this.render();
return { sent: false, refusal: "controller" };
}
return this.#send(text);
}
// Empties the composer and returns its text, or refuses an observer and
// returns null, leaving the buffer.
#take() {
if (!this.client.isController) {
this.status = NOT_CONTROLLER;
return null;
}
const text = this.composer;
this.composer = "";
return text;
}
async #send(text) {
if (!text) return { sent: false, refusal: null };
this.sent += 1;
const r = await this.client.prompt(text);
if (r.receipt) this.lastReceipt = r.receipt;
this.#show("prompt", r);
return { sent: true, reply: r };
}
#show(op, r) {
if (r.outcome === "outcome-unknown") this.status = `${op}: ${OUTCOME_UNKNOWN}`;
else if (r.refusal) this.status = `not admitted: ${r.refusal}`;
else this.status = `${op}: ${r.outcome}`;
this.render();
}
// The frame: a header, the transcript window, dialogs, notices, the status
// line and the composer.
render() {
const b = this.client.binding;
const head = `${oneLine(b?.state ?? "disconnected")} | ${this.client.isController ? "controller" : "observer"} | unknown events: ${this.unknownCount}`;
const body = [];
for (const line of this.transcript.lines()) body.push(...visible(line.replace(/\r\n/g, "\n")).split("\n"));
for (const d of this.dialogs) body.push(oneLine(`[dialog ${d.method} disabled: ${d.reason}]`));
for (const n of this.notices) body.push(oneLine(n));
const receipt = this.lastReceipt ? `last prompt: ${this.lastReceipt.state}${this.lastReceipt.reasonCode ? ` (${this.lastReceipt.reasonCode})` : ""}` : "";
const status = oneLine([this.status, receipt].filter(Boolean).join(" | "));
const composer = (this.composer ? this.composer.split("\n") : [""]).map((l, i) => (i ? " " : "> ") + visible(l));
const room = Math.max(1, this.rows - 2 - composer.length);
const end = Math.max(0, body.length - this.scroll);
this.frame = [head, ...body.slice(Math.max(0, end - room), end), status, ...composer];
this.write("\x1b[H\x1b[2J" + this.frame.join("\r\n"));
return this.frame;
}
}
// How many trailing characters of `s` begin `marker`.
function partialSuffix(s, marker) {
for (let n = Math.min(s.length, marker.length - 1); n > 0; n--) if (marker.startsWith(s.slice(-n))) return n;
return 0;
}
function parseArgs(argv) {
const out = { socket: null, grant: "grant-local" };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--socket") out.socket = argv[++i];
else if (argv[i] === "--grant") out.grant = argv[++i];
else throw new Error(`unknown argument: ${argv[i]}`);
}
if (!out.socket) throw new Error("usage: terminal.mjs --socket <path> [--grant <id>]");
return out;
}
async function main() {
let args;
try {
args = parseArgs(process.argv.slice(2));
} catch (err) {
process.stderr.write(err.message + "\n");
process.exit(2);
}
const { stdin, stdout } = process;
const client = new ConversationClient({ socketPath: args.socket, grant: args.grant });
const restore = () => {
stdout.write("\x1b[?2004l\r\n");
if (stdin.isTTY) stdin.setRawMode(false);
};
const quit = () => {
restore();
client.close();
process.exit(0);
};
const term = new Terminal({ client, write: (s) => stdout.write(s), rows: stdout.rows || 24, onQuit: quit });
try {
await client.connect();
} catch (err) {
process.stderr.write(`could not connect: ${err.refusal ?? err.message}\n`);
process.exit(1);
}
if (stdin.isTTY) stdin.setRawMode(true);
stdout.write("\x1b[?2004h");
stdout.on("resize", () => {
term.rows = stdout.rows || 24;
term.render();
});
stdin.on("data", (c) => void term.key(c.toString("utf8")));
stdin.on("end", quit);
term.render();
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
+41
View File
@@ -0,0 +1,41 @@
// The text policy for mediated prompts (#1507, CHAT-03 §4).
//
// CHAT-01 lines 181–183 refuse `text-policy` when the first non-whitespace
// character is `/`. CHAT-01 lines 368–370 leave `!`, `@` and slashes on later
// lines open; CHAT-03 settles them from the pinned Pi 0.85.1 source. The RPC
// `prompt` command calls AgentSession.prompt(message) (rpc-mode.js 298–318),
// and that path interprets text only through three checks, all on the exact
// first character:
//
// - extension commands: `text.startsWith("/")` (agent-session.js 828);
// - skills: `text.startsWith("/skill:")` (agent-session.js 984);
// - prompt templates: `text.startsWith("/")` (prompt-templates.js 222).
//
// The bundle `pi` runs (dist/bundle/chunks/chunk-JVUZSMYM.js) carries the same
// three checks. `!` and `!!` are shell shortcuts of the interactive editor only
// (interactive-mode.js 2502), and `@` is a file argument of the command line
// only (cli/args.js 214); neither is on the RPC prompt path. Pi never trims
// before its checks, so a slash after leading whitespace or on a later line is
// plain text to Pi. CHAT-01's rule still refuses the leading-whitespace case.
//
// Every prefix is therefore classified: a first non-whitespace `/` is refused,
// and everything else is text. PREFIXES is the list the tests read (S2).
export const TEXT_POLICY = "text-policy";
export const PREFIXES = Object.freeze([
Object.freeze({ prefix: "/", interpreted: true, example: "/goal x", basis: "agent-session.js 828: extension commands run immediately" }),
Object.freeze({ prefix: "/skill:", interpreted: true, example: "/skill:ms-unslop", basis: "agent-session.js 984: skills expand" }),
Object.freeze({ prefix: "/<template>", interpreted: true, example: "/review this", basis: "prompt-templates.js 222: templates expand" }),
Object.freeze({ prefix: "!", interpreted: false, example: "!ls", basis: "interactive-mode.js 2502 only; not on the RPC prompt path" }),
Object.freeze({ prefix: "!!", interpreted: false, example: "!!ls", basis: "interactive-mode.js 2503 only; not on the RPC prompt path" }),
Object.freeze({ prefix: "@", interpreted: false, example: "@README.md", basis: "cli/args.js 214 only; not on the RPC prompt path" }),
]);
// Later-line slashes are not interpreted: every check is at index 0 (S3).
export const LATER_LINE_SLASH_INTERPRETED = false;
export function textPolicy(text) {
if (typeof text !== "string") return TEXT_POLICY;
return text.trimStart().startsWith("/") ? TEXT_POLICY : null;
}
+304
View File
@@ -0,0 +1,304 @@
// The transcript view shared by the library and the terminal (#1507, CHAT-03
// §9, E1–E7).
//
// It joins a history page with the live event stream. Replay is unavailable
// in CHAT-03, and page entries and events carry different IDs, so overlap at
// the seam can't be deduplicated (CHAT-01 lines 104–110). The rule is
// reconciliation instead:
//
// - The controller's seam says where live events start (`fromSequence`) and
// whether the cut was quiet: no run visible and no prompt in the slot, so
// every earlier message had settled and was persisted.
// - A cut that wasn't quiet puts a reconcile marker at the seam. Near it a
// message may show twice or be missing. The view re-reads the page after
// the next `run-settled` and clears the marker once a read is quiet.
// - A sequence gap, a new stream epoch or a repeated event ID with different
// bytes also marks the seam and re-reads at once. A gap is never
// concatenated across.
// - A repeated event ID with identical bytes is dropped (E3).
//
// Tool progress (`tool-start`, `tool-update`, `tool-end`) shows a call while
// it runs. Once a finished tool message carries that call's result, from the
// stream or the page, the progress item is hidden, so each message appears
// once.
export const RECONCILE_MARKER = "-- reconciling: messages near here may repeat or be missing until the run settles --";
const MESSAGE_EVENTS = new Set(["message-start", "text-delta", "thinking-delta", "message-end"]);
const TOOL_EVENTS = new Set(["tool-start", "tool-update", "tool-end"]);
export class Transcript {
// With a client, the view follows it: it reads the page on every welcome
// and re-reads when the stream asks for it.
constructor({ client = null } = {}) {
this.client = client;
this.entries = [];
this.events = [];
this.held = [];
this.seen = new Map();
this.seam = null;
this.expected = null;
this.reconcile = false;
this.reason = null;
this.refusal = null;
this.loads = 0;
this.loading = null;
this.again = false;
this.listeners = new Set();
if (client) client.on((m) => this.#onClient(m));
// Attached to a client that is already connected: no welcome will come,
// so read the page now.
if (client && !client.closed && client.connection) void this.reload("attach");
}
on(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
#changed() {
for (const fn of this.listeners) fn();
}
#onClient(m) {
if (m.type === "welcome") {
void this.reload("connect");
return;
}
if (m.type !== "push" || m.kind !== "event") return;
const why = this.event(m.event);
if (why) void this.reload(why);
this.#changed();
}
// Installs a full read of the page (every entry, in order) and its seam.
load(entries, seam) {
this.entries = entries.slice();
this.seam = { ...seam };
this.reconcile = seam.quiet !== true;
this.reason = this.reconcile ? "cut" : null;
this.loads += 1;
const kept = [...this.events, ...this.held].filter((e) => e.streamEpoch === seam.streamEpoch && e.sequence >= seam.fromSequence).sort((a, b) => a.sequence - b.sequence);
this.events = [];
this.held = [];
this.expected = seam.fromSequence;
for (const e of kept) {
if (e.sequence < this.expected) continue;
if (e.sequence > this.expected) {
this.#mark("gap");
this.held = kept.filter((x) => x.sequence > this.expected);
// A run settled past the gap: the next read starts after it.
if (this.held.some((x) => x.type === "run-settled")) this.again = true;
break;
}
this.events.push(e);
this.expected = e.sequence + 1;
}
}
// Takes one live event. Returns why the page must be re-read, or null.
event(e) {
const bytes = JSON.stringify(e);
const prior = this.seen.get(e.id);
if (prior !== undefined) return prior === bytes ? null : this.#mark("conflict");
this.seen.set(e.id, bytes);
// Events past a gap or in another epoch are held, not shown, until the
// next read; the read keeps those after its seam.
if (!this.seam) {
this.held.push(e);
return null;
}
// Once something is held a read is already due; a later held event asks
// again only when its run settles, when the page has caught up.
if (this.held.length) {
this.held.push(e);
return e.type === "run-settled" ? "settled" : null;
}
if (e.streamEpoch !== this.seam.streamEpoch) {
this.held.push(e);
return this.#mark("epoch");
}
if (e.sequence < this.expected) return null;
if (e.sequence > this.expected) {
this.held.push(e);
return this.#mark("gap");
}
this.events.push(e);
this.expected = e.sequence + 1;
return e.type === "run-settled" && this.reconcile ? "settled" : null;
}
#mark(why) {
this.reconcile = true;
this.reason = why;
return why;
}
// Reads every page and installs it. Overlapping calls coalesce; a call made
// while a read is in flight runs one more read after it.
reload(why = "manual") {
if (!this.client) return Promise.resolve();
if (this.loading) {
this.again = true;
return this.loading;
}
this.loading = (async () => {
do {
this.again = false;
const r = await this.#readAll();
if (!r.ok) {
this.refusal = r.refusal;
break;
}
this.refusal = null;
this.load(r.entries, r.seam);
} while (this.again);
})().finally(() => {
this.loading = null;
this.#changed();
});
this.lastReload = why;
return this.loading;
}
async #readAll() {
let r = await this.client.observe();
if (r.outcome !== "observing") return { ok: false, refusal: r.refusal ?? r.outcome };
const seam = r.data.seam;
const entries = [...r.data.page.entries];
while (r.data.page.hasMore) {
r = await this.client.observe({ cursor: r.data.page.nextCursor });
if (r.outcome !== "observing") return { ok: false, refusal: r.refusal ?? r.outcome };
entries.push(...r.data.page.entries);
}
return { ok: true, entries, seam };
}
// The view: page messages, the marker when the seam is unreconciled, then
// live messages. Each item is {key, role, source, final, text} or
// {marker: true, text, reason}.
messages() {
const items = [];
for (const en of this.entries) {
const last = items.at(-1);
const it = last && last.key === en.message ? last : null;
if (it) it.parts.set(en.part, en.content);
else items.push({ key: en.message, role: en.role, source: "page", parts: new Map([[en.part, en.content]]), lastPart: null, exec: false });
if (en.lastPart) (it ?? items.at(-1)).lastPart = en.part;
}
const pageCount = items.length;
const live = new Map();
const epoch = this.seam?.streamEpoch;
for (const e of this.events) {
if (!this.seam || e.streamEpoch !== epoch) continue;
if (!MESSAGE_EVENTS.has(e.type) && !TOOL_EVENTS.has(e.type)) continue;
let it = live.get(e.message);
if (!it) {
it = { key: e.message, role: e.role, source: "live", parts: new Map(), lastPart: null, stream: new Map(), exec: TOOL_EVENTS.has(e.type), status: null, call: null };
live.set(e.message, it);
items.push(it);
}
if (e.type === "text-delta" || e.type === "thinking-delta") {
for (const b of e.content) {
const s = it.stream.get(b.block) ?? { type: b.type, text: "", visibility: b.visibility };
s.text += b.text ?? "";
it.stream.set(b.block, s);
}
} else if (e.type === "message-end") {
it.role = e.role;
it.parts.set(e.part, e.content);
if (e.lastPart) it.lastPart = e.part;
} else if (TOOL_EVENTS.has(e.type)) {
it.call = e.content[0]?.call ?? it.call;
if (e.type === "tool-start") {
it.status = "running";
it.name = e.content[0]?.name ?? null;
} else {
it.status = e.type === "tool-end" ? "done" : "running";
it.result = e.content;
}
}
}
const finals = new Set();
for (const it of items) {
if (!isFinal(it) || it.exec) continue;
for (const b of blocksOf(it)) if (b.type === "tool-result") finals.add(b.call);
}
const out = [];
items.forEach((it, i) => {
if (i === pageCount && this.reconcile) out.push(this.#marker());
if (it.exec && finals.has(it.call)) return;
out.push({ key: it.key, role: it.role, source: it.source, final: isFinal(it), text: textOf(it) });
});
if (items.length === pageCount && this.reconcile) out.push(this.#marker());
return out;
}
#marker() {
return { marker: true, text: RECONCILE_MARKER, reason: this.reason };
}
// One line per message (a message may span several lines of text).
lines() {
return this.messages().map((m) => (m.marker ? m.text : `${m.role}: ${m.text}`));
}
}
function isFinal(it) {
if (it.lastPart === null) return false;
for (let p = 0; p <= it.lastPart; p++) if (!it.parts.has(p)) return false;
return true;
}
function blocksOf(it) {
const out = [];
const n = it.lastPart ?? Math.max(-1, ...it.parts.keys());
for (let p = 0; p <= n; p++) out.push(...(it.parts.get(p) ?? []));
return out;
}
// Joins fragments by block and renders each block.
export function renderBlocks(blocks) {
const joined = [];
for (const b of blocks) {
const last = joined.at(-1);
if (last && last.block === b.block && last.type === b.type && (b.fragment ?? 0) > 0 && (last.call ?? null) === (b.call ?? null)) {
last.text = (last.text ?? "") + (b.text ?? "");
last.argumentsText = (last.argumentsText ?? "") + (b.argumentsText ?? "");
last.summary = (last.summary ?? "") + (b.summary ?? "");
} else joined.push({ ...b });
}
return joined.map(renderBlock).join("\n");
}
function renderBlock(b) {
switch (b.type) {
case "text":
return b.text ?? "";
case "thinking":
return b.visibility === "unavailable" ? "(thinking not shown)" : `(thinking) ${b.text ?? ""}`;
case "tool-call":
return `[tool-call ${b.name} ${b.argumentsText ?? ""}]`;
case "tool-result":
return `[tool-result${b.isError ? " error" : ""}] ${b.text ?? ""}`;
case "compaction":
return `[compaction] ${b.summary ?? ""}`;
case "attachment":
return `[attachment ${b.attachment}]`;
default:
return `[${b.type}]`;
}
}
function textOf(it) {
if (isFinal(it)) {
const blocks = blocksOf(it);
return blocks.length ? renderBlocks(blocks) : "(empty)";
}
if (it.exec) {
const head = `[tool ${it.name ?? it.call} ${it.status}]`;
return it.result?.length ? `${head} ${renderBlocks(it.result)}` : head;
}
const parts = [...it.stream.entries()].sort(([a], [b]) => a - b).map(([, s]) => renderBlock(s));
return parts.length ? parts.join("\n") : "…";
}
+225
View File
@@ -0,0 +1,225 @@
// Run tracking, overlap signals and receipt settlement (#1507, CHAT-03 §3).
//
// Pinned Pi ties no event to a prompt. Under the seal (pi-pin.mjs) the Mosaic
// prompt in the slot is the only thing that can start a run, so a run that
// follows the slot's ack is attributed to it by order. The tracker watches
// for signs that the seal failed (O1–O6) and for lead decision 34's
// `aborted` with no stop in progress, and settles the slot's receipt only on
// evidence tied to its own prompt (§3 rule 4).
//
// Every line read from the engine gets an observation index (`read()`), so
// "before the ack", "since the fence" and "before the last abort" are exact
// comparisons on one ordered observation.
//
// A run is one prompt's run, from its first `agent_start` to its
// `agent_settled`; retries and compaction can add further `agent_start` …
// `agent_end` pairs inside it (agent-session.js 787–810).
export const DONE_STOPS = Object.freeze(new Set(["stop", "length", "toolUse"]));
export class Tracker {
// `onOverlap(signal, detail)`; `onWorking(slot)`; `onSlotSettled(slot,
// result)` with result { state, reason, stop? } or { outcomeUnknown };
// `stopLink()` returns the stop ID an `aborted` may be linked to, or null
// (lead decision 34).
constructor({ onOverlap, onWorking, onSlotSettled, stopLink }) {
this.onOverlap = onOverlap;
this.onWorking = onWorking;
this.onSlotSettled = onSlotSettled;
this.stopLink = stopLink;
this.idx = 0;
this.slot = null;
this.lastSlot = null;
this.runs = [];
this.current = null;
this.overlaps = [];
this.gap = null;
this.clearWindow = 0;
this.runSerial = 0;
this.waiters = new Set();
this.ignoredAgentEnds = 0;
this.looseSettles = [];
}
get overlapped() {
return this.overlaps.length > 0;
}
read() {
return ++this.idx;
}
overlap(signal, idx, detail = {}) {
const entry = { signal, idx, request: (this.slot ?? this.lastSlot)?.request ?? null, ...detail };
this.overlaps.push(entry);
this.onOverlap(signal, entry);
}
attach(slot) {
this.slot = slot;
Object.assign(slot, { ackIdx: null, runId: null, working: false, settleSeen: false, failureNoRun: false });
}
acked(idx) {
if (this.slot) this.slot.ackIdx = idx;
}
release() {
if (this.slot) this.lastSlot = this.slot;
this.slot = null;
}
// Runs whose first agent_start was read after `idx`.
startedAfter(idx) {
return this.runs.filter((r) => r.startIdx > idx);
}
settledAfter(idx) {
return this.runs.some((r) => r.settleIdx !== null && r.settleIdx > idx) || this.looseSettles.some((i) => i > idx);
}
waitSettle(run, ms) {
if (run.settleIdx !== null) return Promise.resolve(true);
return new Promise((resolve) => {
const w = () => {
if (run.settleIdx === null) return;
this.waiters.delete(w);
clearTimeout(t);
resolve(true);
};
const t = setTimeout(() => {
this.waiters.delete(w);
resolve(false);
}, ms);
this.waiters.add(w);
});
}
// A run in the slot's window, or null.
slotRun() {
return this.slot?.runId ? this.runs.find((r) => r.id === this.slot.runId) ?? null : null;
}
event(ev, idx) {
switch (ev.type) {
case "agent_start":
return this.#agentStart(idx);
case "agent_end":
if (this.current && this.current.openStarts > 0) this.current.openStarts -= 1;
else this.ignoredAgentEnds += 1;
return undefined;
case "message_start":
if (ev.message?.role === "user") return this.#userStart(idx);
return undefined;
case "message_end":
if (ev.message?.role === "assistant") return this.#assistantEnd(ev.message, idx);
return undefined;
case "agent_settled":
return this.#settled(idx);
case "queue_update": {
const empty = (Array.isArray(ev.steering) ? ev.steering.length : 1) === 0 && (Array.isArray(ev.followUp) ? ev.followUp.length : 1) === 0;
// Pi's own clear emits an empty queue_update before its response
// (agent-session.js 1201, rpc-mode.js 334): that one is caused (N25).
if (empty && this.clearWindow > 0) return undefined;
return this.overlap("O5", idx, { cause: empty ? "uncaused-queue-update" : "queue-update", items: queueDigestCount(ev) });
}
default:
return undefined;
}
}
#agentStart(idx) {
if (this.current) {
this.current.openStarts += 1;
return;
}
const run = { id: `run-${++this.runSerial}`, startIdx: idx, settleIdx: null, openStarts: 1, userStarts: 0, lastStop: null, lastError: null, failureBeforeUser: false, slot: false };
this.runs.push(run);
this.current = run;
const s = this.slot;
if (!s || s.ackIdx === null || s.runId !== null) {
this.overlap("O1", idx, { run: run.id, why: !s ? "no slot held" : s.ackIdx === null ? "before the slot's ack" : "after the slot's run" });
return;
}
s.runId = run.id;
run.slot = true;
}
#userStart(idx) {
const run = this.current;
if (!run) return;
run.userStarts += 1;
if (run.userStarts === 2) this.overlap("O6", idx, { run: run.id });
const s = this.slot;
if (s && run.id === s.runId && run.userStarts === 1 && !s.working && !this.overlapped && !this.gap) {
s.working = true;
this.onWorking(s);
}
}
#assistantEnd(message, idx) {
const stopReason = typeof message.stopReason === "string" ? message.stopReason : null;
// Lead decision 34: only the controller's abort produces `aborted`.
if (stopReason === "aborted" && !this.stopLink()) this.overlap("aborted-without-stop", idx, { run: this.current?.id ?? null });
const run = this.current;
if (!run) {
// Pi's run-failure handler can emit a failure message with no
// agent_start (pi-agent-core agent.js 349–364).
if (this.slot && this.slot.ackIdx !== null && (stopReason === "error" || stopReason === "aborted")) this.slot.failureNoRun = true;
return;
}
run.lastStop = stopReason;
run.lastError = typeof message.errorMessage === "string" ? message.errorMessage.slice(0, 2000) : null;
if ((stopReason === "error" || stopReason === "aborted") && run.userStarts === 0) run.failureBeforeUser = true;
}
#settled(idx) {
const run = this.current;
const s = this.slot;
if (!run) {
this.looseSettles.push(idx);
if (!s || s.ackIdx === null) return this.overlap("O2", idx, { why: !s ? "no slot held" : "before the slot's ack" });
if (s.settleSeen) return this.overlap("O2", idx, { why: "a second settle for one ack" });
s.settleSeen = true;
if (s.runId !== null) return this.overlap("O2", idx, { why: "a second settle for one ack" });
if (!s.failureNoRun) return this.overlap("O4", idx, { why: "settle after the ack with no agent_start and no failure message" });
if (this.overlapped || this.gap) return undefined;
return this.onSlotSettled(s, { state: "delivery-unknown", reason: "ack-without-start" });
}
if (run.openStarts > 0) {
// O3: the settle does not close this run; the run stays current.
return this.overlap("O3", idx, { run: run.id });
}
run.settleIdx = idx;
this.current = null;
for (const w of [...this.waiters]) w();
if (!run.slot) {
if (!s || s.ackIdx === null) this.overlap("O2", idx, { run: run.id, why: "settle of a run that is not the slot's" });
return undefined;
}
if (!s || s.runId !== run.id) return this.overlap("O2", idx, { run: run.id, why: "the slot's run settled after the slot moved on" });
if (s.settleSeen) return this.overlap("O2", idx, { why: "a second settle for one ack" });
s.settleSeen = true;
if (this.overlapped || this.gap) return undefined;
return this.onSlotSettled(s, classify(s, run, this.stopLink));
}
}
// §3 rule 4, for a settle that closes the slot's own run with no overlap.
export function classify(slot, run, stopLink) {
if (!slot.working) return { state: "delivery-unknown", reason: "ack-without-start" };
const s = run.lastStop;
if (DONE_STOPS.has(s)) return { state: "finished", reason: null };
if (s === "error") return { state: "failed", reason: null, nativeError: run.lastError };
if (s === "aborted") {
const stop = stopLink();
// Unreachable without a stop: #assistantEnd raised the overlap already.
return stop ? { state: "failed", reason: "interrupted", stop } : { outcomeUnknown: "aborted-without-stop" };
}
return { outcomeUnknown: s === null ? "no-final-assistant-message" : "unrecognised-stop-reason" };
}
function queueDigestCount(ev) {
const all = [...(Array.isArray(ev.steering) ? ev.steering : []), ...(Array.isArray(ev.followUp) ? ev.followUp : [])];
return all.length;
}
+707
View File
@@ -0,0 +1,707 @@
// CHAT-03 §2 writer claim and live-session guard (#1507): W1–W9, W11–W17,
// W20, G1–G3. Fixtures that need a dead or crashed owner run the controller
// in a child process (ctrl-child.mjs); the owner check sees this test's own
// pid as live.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { appendFileSync, copyFileSync, linkSync, mkdirSync, statSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, lstatSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { ALREADY_ACTIVE, ClaimStore, FOREIGN_HOST, UNSAFE_REPLACEMENT, held, machineId } from "../src/claim.mjs";
import { Controller } from "../src/controller.mjs";
import { ConversationClient } from "../src/client.mjs";
import { AUTHORITY, scopeAvailable } from "../src/cohort.mjs";
import { FixtureVerifier } from "../src/records.mjs";
import { LIVE_SESSION_REFUSED } from "../src/guard.mjs";
import { ENGINE_PIN_MISMATCH, PI_PACKAGE } from "../src/pi-pin.mjs";
import { bootId } from "../../discord/src/journal.mjs";
import { FakeLauncher } from "./fake-pi.mjs";
import {
REPO, FAST, fixture, controllerFor, started, spawnController, killChildren, cleanupAll, reap, claimRecords,
userEntry, assistantEntry, thinkingEntry, receiptState, noUnits, tick,
} from "./harness.mjs";
const reaped = [];
after(() => {
for (const fx of reaped) reap(fx);
killChildren();
cleanupAll();
});
const track = (fx) => (reaped.push(fx), fx);
const SCOPE = scopeAvailable();
const OTHER_BOOT = "00000000-0000-4000-8000-000000000000";
// A pid that existed and has exited: an owner proven gone.
function deadPid() {
return spawnSync(process.execPath, ["-e", "process.stdout.write(String(process.pid))"], { encoding: "utf8" }).stdout.trim() * 1;
}
const deadOwner = () => ({ pid: deadPid(), start: "1", boot: bootId(), incarnation: "dead".padEnd(32, "0") });
const pins = { engineVersion: "0.85.1", enginePin: "x", argvDigest: "y" };
const fields = (extra = {}) => ({ bindingId: "binding-1", harness: "pi", conversation: "pi-c1", branch: "main", leaf: "b2c3d4e5", pins, owner: deadOwner(), generation: 1, ...extra });
// The keys a controller for `fx` uses, without starting it.
function keysFor(fx, opts = {}) {
const { ctrl } = controllerFor(fx, opts);
return { store: ctrl.store, seatK: ctrl.seatK, sessionK: ctrl.sessionK, ctrl };
}
function revisionTexts(store, key) {
return store.revisions(key).map((r) => r.text);
}
function treeDigest(dir) {
const out = {};
const walk = (p) => {
const st = lstatSync(p);
if (st.isDirectory()) for (const n of readdirSync(p).sort()) walk(join(p, n));
else out[p] = createHash("sha256").update(readFileSync(p)).digest("hex");
};
walk(dir);
return out;
}
async function connectClient(socketPath) {
const c = new ConversationClient({ socketPath });
await c.connect();
return c;
}
// Force stop through the wire, with a confirmation.
async function forceStop(c) {
const r = await c.confirmed("force-stop");
assert.equal(r.outcome, "force-stop-fenced", JSON.stringify(r));
return r.stop;
}
async function waitBinding(c, states, ms = 8000) {
const want = new Set([states].flat());
const ok = await c.waitFor(() => want.has(c.binding?.state), ms);
assert.ok(ok, `binding stayed ${c.binding?.state}; wanted ${[...want]}`);
}
// ---- W1–W4: acquisition --------------------------------------------------
test("W1: two processes acquire the same pair at once; exactly one claim", async () => {
const fx = track(fixture());
const a = spawnController({ fx, launcher: "pgroup", units: "absent" });
const b = spawnController({ fx, launcher: "pgroup", units: "absent" });
const [ra, rb] = await Promise.all([a.next((m) => m.ready || m.error), b.next((m) => m.ready || m.error)]);
const results = [ra, rb];
assert.equal(results.filter((r) => r.ready).length, 1, JSON.stringify(results));
assert.equal(results.find((r) => r.error).error, ALREADY_ACTIVE);
const ids = new Set(claimRecords(fx).map((r) => r.record.claimId));
assert.equal(ids.size, 1);
for (const ch of [a, b]) ch.send("kill-engine");
await Promise.all([a.exited, b.exited]);
});
test("W1: two writers publish the same revision at once: one wins, the other gets null, the winner's record stays", async () => {
// Both hold at temp-synced, so both reach link() with the name free when
// they read the head. A rename would let the second replace the first.
const fx = track(fixture());
const { seatK } = keysFor(fx);
let arrived = 0, go;
const ready = new Promise((r) => (go = r));
const barrier = async (name) => {
if (name !== "temp-synced") return;
if (++arrived === 2) go();
await ready;
};
const stores = [0, 1].map(() => new ClaimStore({ root: fx.claimRoot, barrier }));
const results = await Promise.all(stores.map((s, i) => s.publish(seatK, 1, { claimId: `claim-${i}`, state: "reserved" })));
assert.equal(results.filter((r) => r === null).length, 1, JSON.stringify(results));
const winner = results.find((r) => r !== null);
const texts = revisionTexts(stores[0], seatK);
assert.equal(texts.length, 1);
assert.equal(JSON.parse(texts[0]).claimId, winner.claimId, "the published revision is the winner's, never replaced");
assert.deepEqual(readdirSync(stores[0].dir(seatK)).filter((n) => n.startsWith(".tmp-")), [], "no temp file left behind");
});
test("W1: a revision name appears only after its bytes are synced; before that, only a temp file exists", async () => {
const fx = track(fixture());
const { seatK } = keysFor(fx);
let release = null;
const barrier = (name) => (name === "temp-written" ? new Promise((r) => (release = r)) : undefined);
const store = new ClaimStore({ root: fx.claimRoot, barrier });
const p = store.publish(seatK, 1, { claimId: "claim-a", state: "reserved" });
for (let i = 0; !release && i < 200; i++) await tick(10);
assert.ok(release, "publish reached temp-written");
assert.deepEqual(revisionTexts(store, seatK), [], "no revision is visible while its bytes are unsynced");
assert.equal(readdirSync(store.dir(seatK)).filter((n) => n.startsWith(".tmp-")).length, 1);
release();
assert.equal((await p).claimId, "claim-a");
assert.equal(revisionTexts(store, seatK).length, 1);
});
test("W2: acquire while a claim is reserved or active refuses already-active", async () => {
const fx = fixture();
const { store, seatK, sessionK } = keysFor(fx);
const claim = await store.acquire(seatK, sessionK, fields());
await assert.rejects(store.acquire(seatK, sessionK, fields()), { code: ALREADY_ACTIVE });
await store.advance(claim, { state: "active" });
await assert.rejects(store.acquire(seatK, sessionK, fields()), { code: ALREADY_ACTIVE });
// A live controller holds it: a second controller refuses before it
// touches the first one's socket.
const fx2 = fixture();
const h = await started({ fx: fx2 });
try {
const sock = lstatSync(h.ctrl.socketPath);
const { ctrl } = controllerFor(fx2);
await assert.rejects(ctrl.start(), { code: ALREADY_ACTIVE });
assert.equal(lstatSync(h.ctrl.socketPath).ino, sock.ino);
assert.equal(h.launcher.engines.length, 1);
} finally {
await h.close();
}
});
test("W3: acquire while stopping, uncertain, or stopped without proof refuses unsafe-replacement", async () => {
for (const patch of [{ state: "stopping" }, { state: "uncertain" }, { state: "stopped", proof: null }]) {
const fx = fixture();
const { store, seatK, sessionK } = keysFor(fx);
const claim = await store.acquire(seatK, sessionK, fields());
await store.advance(claim, patch);
await assert.rejects(store.acquire(seatK, sessionK, fields()), { code: UNSAFE_REPLACEMENT }, JSON.stringify(patch));
}
});
test("W4: same session with another seat tuple, and the reverse, both refuse; a loser on the seat key closes it no-unit", async () => {
const fx = fixture();
const { store, seatK, sessionK } = keysFor(fx);
await store.acquire(seatK, sessionK, fields());
const otherSeat = { ...seatK, seat: "other-seat" };
const otherSession = { ...sessionK, session: { harness: "pi", nativeSession: "other" } };
await assert.rejects(store.acquire(otherSeat, sessionK, fields()), { code: ALREADY_ACTIVE });
await assert.rejects(store.acquire(seatK, otherSession, fields()), { code: ALREADY_ACTIVE });
assert.equal(store.head(otherSeat).n, 0, "a contender that read a held key publishes nothing");
// The race: the loser publishes on its seat key, then finds the session
// key taken.
const fx2 = fixture();
const k = keysFor(fx2);
let winner = null;
const loserStore = new ClaimStore({
root: fx2.claimRoot,
barrier: async (name) => {
if (name === "between-keys" && !winner) winner = await k.store.acquire({ ...k.seatK, seat: "winner-seat" }, k.sessionK, fields());
},
});
const winnerBefore = () => [revisionTexts(k.store, { ...k.seatK, seat: "winner-seat" }), revisionTexts(k.store, k.sessionK)];
await assert.rejects(loserStore.acquire(k.seatK, k.sessionK, fields()), { code: ALREADY_ACTIVE });
const head = k.store.head(k.seatK);
assert.equal(head.n, 2);
assert.equal(head.record.state, "stopped");
assert.deepEqual(head.record.proof, { kind: "no-unit", ref: null });
assert.equal(head.record.spawnMarker, false);
assert.equal(held(head), false);
const [ws, wss] = winnerBefore();
assert.equal(ws.length, 1);
assert.equal(wss.length, 1);
assert.equal(JSON.parse(wss[0]).claimId, winner.claimId);
});
// ---- W5: SIGKILL at every publication barrier ------------------------------
// The claim invariants after any crash: every visible revision parses, no two
// claim IDs hold the pair, and a pair with any non-stopped revision is not
// free.
function assertClaimInvariants(fx, label) {
const recs = claimRecords(fx);
for (const r of recs) assert.ok(r.record && r.record.kind === "writer-claim", `${label}: incomplete revision ${r.path}`);
const k = keysFor(fx);
const heads = [k.store.head(k.seatK), k.store.head(k.sessionK)];
const holders = new Set(heads.filter(held).map((h) => h.record.claimId));
assert.ok(holders.size <= 1, `${label}: two holders`);
return { k, heads };
}
async function lifecycleTrace(fx, launcher) {
const ch = spawnController({ fx, launcher, units: launcher === "scope" ? undefined : "absent", trace: true });
const ready = await ch.next((m) => m.ready || m.error, 15000);
assert.ok(ready.ready, JSON.stringify(ready));
return { ch, ready };
}
// W4 through the controller's own key mapping: the session key is the Pi
// header ID, so a second path to the same session collides with the first.
for (const [how, place] of [["hard link", linkSync], ["copy", copyFileSync]]) {
test(`W4: a ${how} of one session under another seat is the same session: the second controller refuses already-active and launches nothing`, async () => {
const fx = track(fixture());
const sessionsB = join(fx.proj, ".pi", "state", "seat-b", "sessions");
mkdirSync(sessionsB, { recursive: true });
const fileB = join(sessionsB, "s1.jsonl");
place(fx.sessionFile, fileB);
if (how === "hard link") assert.equal(statSync(fileB).ino, statSync(fx.sessionFile).ino);
const fxB = { ...fx, seat: "seat-b", sessionFile: fileB, socketDir: join(fx.base, "sock-b") };
const h = await started({ fx });
try {
const { ctrl: b, launcher } = controllerFor(fxB);
assert.notEqual(b.conversation, h.ctrl.conversation, "two conversation IDs");
assert.deepEqual(b.sessionK, h.ctrl.sessionK, "one session key");
await assert.rejects(b.start(), { code: ALREADY_ACTIVE });
assert.equal(launcher.launches.length, 0, "no second engine");
assert.equal(h.ctrl.binding.state, "active");
await b.close();
} finally {
await h.close();
}
});
}
test("W4: a session header ID that changes after construction refuses target; nothing is claimed or launched", async () => {
const fx = track(fixture());
const { ctrl, launcher } = controllerFor(fx);
writeFileSync(fx.sessionFile, readFileSync(fx.sessionFile, "utf8").replace(/"id":"[^"]+"/, '"id":"11111111-2222-4333-8444-555555555555"'));
await assert.rejects(ctrl.start(), { code: "target" });
assert.equal(launcher.launches.length, 0);
assert.equal(ctrl.store.head(ctrl.seatK).n, 0, "the seat key is untouched");
});
test("W5: SIGKILL between every publication barrier of acquire and transition; restart never finds two holders or a lost claim", { timeout: 240000 }, async () => {
const probe = track(fixture());
const { ch } = await lifecycleTrace(probe, "pgroup");
ch.send("kill-engine");
await ch.exited;
const points = ch.msgs.filter((m) => m.barrier).map((m) => [m.barrier, m.n]);
assert.ok(points.length >= 20, `barrier trace too short: ${JSON.stringify(points)}`);
for (const [name, n] of points) {
const fx = track(fixture());
const dying = spawnController({ fx, launcher: "pgroup", units: "absent", dieAt: { [name]: n } });
await dying.next((m) => m.dying || m.ready || m.error, 15000);
await dying.exited;
reap(fx);
const label = `${name}#${n}`;
const { heads } = assertClaimInvariants(fx, label);
const anyHeld = heads.some(held);
const restart = spawnController({ fx, launcher: "pgroup", units: "absent" });
const r = await restart.next((m) => m.ready || m.error, 15000);
assert.ok(r.ready, `${label}: restart refused ${JSON.stringify(r)}`);
// A held pair is completed or classified under its claim ID; never
// launched over.
if (anyHeld) assert.equal(r.started.launched, false, `${label}: launched over a held pair`);
const after = assertClaimInvariants(fx, `${label} after restart`);
if (anyHeld) {
const before = heads.find(held).record.claimId;
for (const h of after.heads) if (held(h)) assert.equal(h.record.claimId, before, `${label}: claim lost`);
}
restart.send("kill-engine");
await restart.exited;
reap(fx);
}
});
test("W5: SIGKILL between every publication barrier of release; restart finishes or holds the release", { skip: !SCOPE && "systemd user scopes unavailable", timeout: 300000 }, async () => {
// One traced force stop enumerates the release barriers: every barrier
// after the force stop is sent.
const runRelease = async (fx, dieAt = null) => {
const ch = spawnController({ fx, launcher: "scope", trace: !dieAt, dieAt: dieAt ?? undefined });
const ready = await ch.next((m) => m.ready || m.error, 15000);
assert.ok(ready.ready, JSON.stringify(ready));
const c = await connectClient(ready.socketPath);
await c.takeover();
await c.waitFor(() => false, 200); // the generation publication settles
const mark = ch.msgs.length;
const r = await c.confirmed("force-stop");
if (!dieAt) {
assert.equal(r.outcome, "force-stop-fenced", JSON.stringify(r));
await waitBinding(c, "stopped", 15000);
} else await ch.next((m) => m.dying, 20000);
c.close();
return { ch, mark };
};
const probe = track(fixture());
const { ch, mark } = await runRelease(probe);
ch.send("close");
await ch.exited;
const points = ch.msgs.slice(mark).filter((m) => m.barrier).map((m) => [m.barrier, m.n]);
assert.ok(points.some(([b]) => b === "phase-kill"));
for (const [name, n] of points) {
const fx = track(fixture());
const { ch: dying } = await runRelease(fx, { [name]: n });
await dying.exited;
const label = `release ${name}#${n}`;
const { heads } = assertClaimInvariants(fx, label);
const claimId = heads.find((h) => h.n > 0).record.claimId;
const anyHeld = heads.some(held);
const restart = spawnController({ fx, launcher: "scope" });
const r = await restart.next((m) => m.ready || m.error, 15000);
assert.ok(r.ready, `${label}: restart refused ${JSON.stringify(r)}`);
if (!anyHeld) {
// The release had published on both keys: the pair is free.
assert.equal(r.started.launched, true, label);
restart.send("kill-engine");
await restart.exited;
reap(fx);
continue;
}
assert.equal(r.started.launched, false, `${label}: launched over a release in progress`);
const after = assertClaimInvariants(fx, `${label} after restart`);
for (const h of after.heads) assert.equal(h.record.claimId, claimId, `${label}: claim lost`);
// Either both keys are stopped with one proof, or the pair is held.
const states = after.heads.map((h) => h.record.state);
if (states.includes("stopped") && after.heads.every((h) => !held(h))) {
assert.deepEqual(after.heads[0].record.proof, after.heads[1].record.proof, label);
} else assert.ok(after.heads.some(held), `${label}: ${states}`);
restart.send("close");
await restart.exited;
reap(fx);
}
});
// ---- W6, W12–W15, W20: crash and restart -----------------------------------
test("W6: controller killed mid-turn while the engine lives; restart is uncertain, no launch, prompts refuse", async () => {
const fx = track(fixture());
const log = join(fx.base, "fake.log");
const fakeEnv = { FAKE_PI_CONTROL: join(fx.base, "fake.sock"), FAKE_PI_LOG: log, FAKE_PI_SCRIPT: JSON.stringify([[{ pause: "mid" }, { text: "late" }]]) };
const a = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv });
const ra = await a.next((m) => m.ready);
const c = await connectClient(ra.socketPath);
await c.takeover();
const r = await c.prompt("long turn");
await receiptState(c, r.receipt.id, "working");
a.proc.kill("SIGKILL");
await a.exited;
c.close();
const engineCount = () => readFileSync(log, "utf8").split("\n").filter((l) => l.includes('"t":"argv"')).length;
const prompts = () => readFileSync(log, "utf8").split("\n").filter((l) => l.includes('"type":"prompt"')).length;
const b = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv });
const rb = await b.next((m) => m.ready || m.error);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.launched, false);
assert.equal(rb.started.classified.state, "uncertain");
assert.equal(engineCount(), 1, "no second engine");
const c2 = await connectClient(rb.socketPath);
assert.equal(c2.binding.state, "uncertain");
assert.equal((await c2.takeover()).refusal, "fenced");
const rc = await c2.confirmed("acquire-recovery-control");
assert.equal(rc.outcome, "recovery-control-acquired");
const p = await c2.prompt("after restart");
assert.ok(p.outcome.startsWith("refused:"), JSON.stringify(p));
assert.equal(prompts(), 1);
c2.close();
b.send("close");
await b.exited;
});
test("W12: a live owner paused with SIGSTOP; a second controller refuses already-active and changes nothing", async () => {
const fx = track(fixture());
const a = spawnController({ fx, launcher: "pgroup", units: "absent" });
await a.next((m) => m.ready);
const k = keysFor(fx);
const before = [revisionTexts(k.store, k.seatK), revisionTexts(k.store, k.sessionK)];
a.proc.kill("SIGSTOP");
try {
await assert.rejects(k.ctrl.start(), { code: ALREADY_ACTIVE });
} finally {
a.proc.kill("SIGCONT");
}
assert.deepEqual([revisionTexts(k.store, k.seatK), revisionTexts(k.store, k.sessionK)], before);
a.send("kill-engine");
await a.exited;
});
test("W13: crash after the engine spawns, before active; restart finds the live unit: uncertain, no second spawn, force stop only", { skip: !SCOPE && "systemd user scopes unavailable", timeout: 60000 }, async () => {
const fx = track(fixture());
const log = join(fx.base, "fake.log");
const fakeEnv = { FAKE_PI_CONTROL: join(fx.base, "fake.sock"), FAKE_PI_LOG: log };
const a = spawnController({ fx, launcher: "scope", dieAt: { spawned: 1 }, fakeEnv });
await a.next((m) => m.dying, 15000);
await a.exited;
const b = spawnController({ fx, launcher: "scope", fakeEnv });
const rb = await b.next((m) => m.ready || m.error, 15000);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.launched, false);
assert.equal(rb.started.classified.state, "uncertain");
assert.equal(rb.started.classified.unit, "alive");
const engines = readFileSync(log, "utf8").split("\n").filter((l) => l.includes('"t":"argv"')).length;
assert.equal(engines, 1, "no second spawn");
const c = await connectClient(rb.socketPath);
assert.equal((await c.prompt("x")).refusal, "controller");
assert.equal((await c.confirmed("acquire-recovery-control")).outcome, "recovery-control-acquired");
await forceStop(c);
await waitBinding(c, "stopped", 15000);
const k = keysFor(fx);
for (const key of [k.seatK, k.sessionK]) {
const h = k.store.head(key);
assert.equal(h.record.state, "stopped");
assert.equal(h.record.proof.kind, "cohortProof");
}
c.close();
b.send("close");
await b.exited;
});
test("W14: crash after reservation, before the spawn marker: stopped with a no-unit observation; the pair is free", async () => {
const fx = track(fixture());
const a = spawnController({ fx, launcher: "pgroup", units: "absent", dieAt: { reserved: 1 } });
await a.next((m) => m.dying);
await a.exited;
const b = spawnController({ fx, launcher: "pgroup", units: "absent" });
const rb = await b.next((m) => m.ready || m.error);
assert.deepEqual(rb.started, { launched: false, classified: { state: "stopped", proofKind: "no-unit" } });
b.send("close");
await b.exited;
const k = keysFor(fx);
for (const key of [k.seatK, k.sessionK]) {
const h = k.store.head(key);
assert.equal(h.record.state, "stopped");
assert.deepEqual(h.record.proof, { kind: "no-unit", ref: null });
assert.equal(held(h), false);
}
const c = spawnController({ fx, launcher: "pgroup", units: "absent" });
const rc = await c.next((m) => m.ready || m.error);
assert.equal(rc.started.launched, true);
c.send("kill-engine");
await c.exited;
});
test("W20: crash after the spawn marker, scope collected; uncertain in both runs, the marker is copied, no launch until a boot proof", async () => {
// Run 1: marker on both keys. Run 2: marker on the seat key only (the
// second between-keys is the spawn-marker advance's).
for (const dieAt of [{ "spawn-marker": 1 }, { "between-keys": 2 }]) {
const fx = track(fixture());
const a = spawnController({ fx, launcher: "pgroup", units: "absent", dieAt });
await a.next((m) => m.dying);
await a.exited;
const k = keysFor(fx);
const seatMarker = k.store.head(k.seatK).record.spawnMarker;
const sessionMarker = k.store.head(k.sessionK).record.spawnMarker;
assert.equal(seatMarker, true);
assert.equal(sessionMarker, !dieAt["between-keys"]);
const b = spawnController({ fx, launcher: "pgroup", units: "absent" });
const rb = await b.next((m) => m.ready || m.error);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.launched, false);
assert.equal(rb.started.classified.state, "uncertain");
b.send("close");
await b.exited;
for (const key of [k.seatK, k.sessionK]) {
assert.equal(k.store.head(key).record.spawnMarker, true, "the marker is copied, never dropped");
assert.equal(k.store.head(key).record.state, "uncertain");
}
// Only a boot proof moves it on: the same host under another boot.
const { ctrl } = controllerFor(fx, { host: { machineId, bootId: () => OTHER_BOOT } });
const res = await ctrl.start();
assert.deepEqual(res, { launched: false, classified: { state: "stopped", proofKind: "boot" } });
await ctrl.close();
}
});
test("W15: crash between the two keys during release; restart finishes it under the same claim ID", async () => {
const fx = fixture();
const k = keysFor(fx);
const claim = await k.store.acquire(k.seatK, k.sessionK, fields());
await k.store.advance(claim, { spawnMarker: true, state: "active" });
await k.store.advance(claim, { state: "stopping" });
const crashing = new ClaimStore({ root: fx.claimRoot, barrier: async (name) => {
if (name === "between-keys") throw Object.assign(new Error("crash"), { code: "crash" });
} });
await assert.rejects(crashing.finish({ ...claim, record: { ...claim.record } }, { state: "stopped", proof: { kind: "cohortProof", ref: "cohort-proof-1", effects: "effects-1" }, leafAtProof: "b2c3d4e5", branchAtProof: "main" }), { code: "crash" });
assert.equal(k.store.head(k.seatK).record.state, "stopped");
assert.equal(k.store.head(k.sessionK).record.state, "stopping");
assert.ok(held(k.store.head(k.sessionK)));
const res = await k.ctrl.start();
assert.equal(res.launched, false);
assert.equal(res.classified.state, "stopped");
for (const key of [k.seatK, k.sessionK]) {
const h = k.store.head(key);
assert.equal(h.record.claimId, claim.claimId);
assert.equal(h.record.state, "stopped");
assert.equal(h.record.proof.ref, "cohort-proof-1");
}
await k.ctrl.close();
});
// ---- W7–W9: boot proof and resume ------------------------------------------
test("W7: recorded boot ID differs on the same machine: stopped with a boot proof; open tool calls become uncertain", async () => {
const toolCall = { type: "message", id: "c3d4e5f6", parentId: "b2c3d4e5", timestamp: new Date().toISOString(), message: { role: "assistant", content: [{ type: "toolCall", id: "tool-open-1", name: "bash", arguments: {} }], stopReason: "toolUse" } };
const fx = track(fixture({ entries: [thinkingEntry(), userEntry("a1b2c3d4", "f0e1d2c3", "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi"), toolCall] }));
const a = spawnController({ fx, launcher: "pgroup", units: "absent", host: { bootId: OTHER_BOOT } });
await a.next((m) => m.ready);
a.proc.kill("SIGKILL");
await a.exited;
reap(fx);
const posted = [];
const verifier = new FixtureVerifier({ authorities: [AUTHORITY] });
const post = verifier.post.bind(verifier);
verifier.post = (p) => (posted.push(p), post(p));
const { ctrl } = controllerFor(fx, { verifier });
const res = await ctrl.start();
assert.deepEqual(res, { launched: false, classified: { state: "stopped", proofKind: "boot" } });
const proof = posted.find((p) => p.kind === "cohortProof");
const effects = posted.find((p) => p.kind === "effectReport");
assert.ok(proof.id.startsWith("boot-proof-"));
assert.deepEqual(effects.invocations.map((i) => [i.id, i.disposition]), [["tool-open-1", "uncertain"]]);
const k = keysFor(fx);
for (const key of [k.seatK, k.sessionK]) assert.deepEqual(k.store.head(key).record.proof, { kind: "boot", ref: proof.id, effects: effects.id });
await ctrl.close();
});
// A proven stop in process: the fixture launcher's cohort stop.
async function provenStop(fx, opts = {}) {
const h = await started({ fx, ...opts });
await forceStop(h.client);
await waitBinding(h.client, "stopped");
const claim = h.ctrl.claim.record;
await h.close();
return claim;
}
test("W8: resume after a proven stop with the same pins: new claim ID, generation +1, same conversation, branch and leaf", async () => {
const fx = fixture();
const first = await provenStop(fx);
const h = await started({ fx, control: false });
try {
const next = h.ctrl.claim.record;
assert.notEqual(next.claimId, first.claimId);
assert.equal(next.generation, first.generation + 1);
assert.equal(next.conversation, first.conversation);
assert.equal(next.branch, first.branch);
assert.equal(next.leaf, first.leafAtProof);
assert.deepEqual(next.prior, { claimId: first.claimId, stop: first.stop.id });
} finally {
await h.close();
}
});
test("W9: resume with a changed binary, argv digest, branch or leaf is refused and the claim is unchanged", async () => {
const cases = {
binary: (fx) => {
const root = mkdtempSync(join(fx.base, "pin-"));
const lock = { packages: { [`node_modules/${PI_PACKAGE}`]: { version: "0.85.2", integrity: "sha512-other" } } };
mkdirSync(join(root, "node_modules"));
writeFileSync(join(root, "package-lock.json"), JSON.stringify(lock));
writeFileSync(join(root, "node_modules", ".package-lock.json"), JSON.stringify(lock));
return [{ pinRoot: root }, ENGINE_PIN_MISMATCH];
},
argv: () => [{ engine: { extraArgs: ["--model", "other"] } }, ENGINE_PIN_MISMATCH],
leaf: (fx) => {
appendFileSync(fx.sessionFile, JSON.stringify(userEntry("d4e5f6a7", "b2c3d4e5", "outside", 9)) + "\n");
return [{}, "target"];
},
branch: (fx) => {
appendFileSync(fx.sessionFile, JSON.stringify(userEntry("e5f6a7b8", "a1b2c3d4", "a fork", 9)) + "\n");
return [{}, "target"];
},
};
for (const [name, change] of Object.entries(cases)) {
const fx = fixture();
await provenStop(fx);
const k = keysFor(fx);
const before = [revisionTexts(k.store, k.seatK), revisionTexts(k.store, k.sessionK)];
const [opts, code] = change(fx);
const { ctrl, launcher } = controllerFor(fx, opts);
await assert.rejects(ctrl.start(), { code }, name);
assert.equal(launcher.engines.length, 0, name);
assert.deepEqual([revisionTexts(k.store, k.seatK), revisionTexts(k.store, k.sessionK)], before, name);
}
});
// ---- W11, W16, W17 ----------------------------------------------------------
test("W11: the controller writes no session file; only the fake engine's own appends appear", async () => {
const fx = fixture();
const original = readFileSync(fx.sessionFile, "utf8");
const listing = readdirSync(fx.sessions).sort();
const h = await started({ fx });
try {
const r = await h.client.prompt("one");
await receiptState(h.client, r.receipt.id, "finished");
await h.client.interrupt();
await forceStop(h.client);
await waitBinding(h.client, "stopped");
} finally {
await h.close();
}
assert.deepEqual(readdirSync(fx.sessions).sort(), listing);
const lines = readFileSync(fx.sessionFile, "utf8").slice(original.length).split("\n").filter(Boolean).map((l) => JSON.parse(l).id);
assert.ok(readFileSync(fx.sessionFile, "utf8").startsWith(original));
assert.deepEqual(lines, h.engine.appends, "every added line is the fake engine's");
});
test("W16: a highest revision that won't parse holds the pair uncertain; the older stopped revision is not reused", async () => {
const fx = fixture();
const k = keysFor(fx);
const claim = await k.store.acquire(k.seatK, k.sessionK, fields());
await k.store.finish(claim, { state: "stopped", proof: { kind: "no-unit", ref: null } });
const dir = k.store.dir(k.sessionK);
const top = readdirSync(dir).filter((n) => n.startsWith("r")).sort().at(-1);
const next = `r${String(Number(top.slice(1, 11)) + 1).padStart(10, "0")}.json`;
writeFileSync(join(dir, next), "{\"version\":1,\"kind\":\"writer-cl");
assert.equal(k.store.head(k.sessionK).damaged, true);
await assert.rejects(k.store.acquire(k.seatK, k.sessionK, fields()), { code: UNSAFE_REPLACEMENT });
await assert.rejects(k.ctrl.start(), { code: UNSAFE_REPLACEMENT });
assert.equal(readdirSync(dir).filter((n) => n.startsWith("r")).length, 3);
});
test("W17: a claim root copied from another host refuses foreign-host and promotes nothing", async () => {
const fx = fixture();
const k = keysFor(fx);
const foreign = new ClaimStore({ root: fx.claimRoot, host: { machineId: () => "f".repeat(32), bootId } });
await foreign.acquire(k.seatK, k.sessionK, fields());
const before = treeDigest(fx.claimRoot);
await assert.rejects(k.store.acquire(k.seatK, k.sessionK, fields()), { code: FOREIGN_HOST });
await assert.rejects(k.ctrl.start(), { code: FOREIGN_HOST });
assert.deepEqual(treeDigest(fx.claimRoot), before);
});
// ---- G1–G3: live-session guard ---------------------------------------------
test("G1: a session path or claim root under .pi/state, ~/.claude, the data root or a registration refuses at construction", () => {
const fx = fixture();
const base = { claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat, launcher: new FakeLauncher(), units: noUnits };
const repoSession = join(REPO, ".pi", "state", "x-seat", "sessions", "s.jsonl");
const claudeRoot = join(homedir(), ".claude", "mosaic-claims");
const dataRoot = join(fx.base, "data");
mkdirSync(dataRoot);
const regDir = join(fx.base, "registered");
const cases = [
{ fixtureRoot: REPO, sessionFile: repoSession, claimRoot: join(REPO, "tmp-claims"), socketDir: join(REPO, "tmp-sock") },
{ fixtureRoot: homedir(), claimRoot: claudeRoot, sessionFile: join(homedir(), "x", ".pi", "state", "s", "sessions", "a.jsonl"), socketDir: join(homedir(), "x-sock") },
{ fixtureRoot: fx.base, claimRoot: join(dataRoot, "claims"), guardOptions: { dataRoots: [dataRoot] } },
{ fixtureRoot: fx.base, guardOptions: { registrations: [{ sessionsDir: fx.sessions }] } },
{ fixtureRoot: fx.base, claimRoot: join(regDir, "claims"), guardOptions: { registrations: [{ workspace: regDir }] } },
];
for (const c of cases) {
assert.throws(() => new Controller({ ...base, ...c }), { code: LIVE_SESSION_REFUSED }, JSON.stringify(c));
}
assert.equal(readdirSync(fx.base).includes("claims"), false, "nothing created");
});
test("G2: a symlink inside the fixture root to a live session file is refused by the real-path check", () => {
const fx = fixture();
const live = mkdtempSync(join(tmpdir(), "chat03-live-"));
try {
const liveFile = join(live, "live.jsonl");
writeFileSync(liveFile, readFileSync(fx.sessionFile));
unlinkSync(fx.sessionFile);
symlinkSync(liveFile, fx.sessionFile);
// Construction already applies the real-path check; bind repeats it.
assert.throws(() => controllerFor(fx, { guardOptions: { registrations: [{ sessionsDir: live }] } }), { code: LIVE_SESSION_REFUSED });
} finally {
rmSync(live, { recursive: true, force: true });
}
});
test("G3: a fixture path swapped for a live path after construction is refused at bind", async () => {
const fx = fixture();
const live = mkdtempSync(join(tmpdir(), "chat03-live-"));
try {
const liveFile = join(live, "live.jsonl");
writeFileSync(liveFile, readFileSync(fx.sessionFile));
const { ctrl, launcher } = controllerFor(fx, { guardOptions: { registrations: [{ sessionsDir: live }] } });
unlinkSync(fx.sessionFile);
symlinkSync(liveFile, fx.sessionFile);
await assert.rejects(ctrl.start(), { code: LIVE_SESSION_REFUSED });
assert.equal(launcher.engines.length, 0);
assert.equal(claimRecords(fx).length, 0);
} finally {
rmSync(live, { recursive: true, force: true });
}
});
+716
View File
@@ -0,0 +1,716 @@
// CHAT-03 §6 stop, cohort proof and recovery (#1507): K1–K18. The scope
// fixtures run the fake engine as a real process under ScopeLauncher, so the
// shim, the `engine` cgroup and systemd's invocation ID are all real; they
// skip when systemd user scopes are unavailable. K2 runs on the process-group
// fallback. K6–K9 and K16–K18 use the in-process fake, whose force stop is a
// fixture stand-in (see FakeLauncher). Controllers that must die run in
// ctrl-child.mjs.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { appendFileSync, chmodSync, copyFileSync, mkdirSync, readFileSync, rmdirSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { ClaimStore, FOREIGN_HOST } from "../src/claim.mjs";
import { ConversationClient } from "../src/client.mjs";
import { AUTHORITY, PgroupLauncher, ScopeLauncher, scopeAvailable, shimRequest, systemctlShow, systemdUnits } from "../src/cohort.mjs";
import { Controller, ELIGIBILITY } from "../src/controller.mjs";
import { ENGINE_PIN_MISMATCH } from "../src/pi-pin.mjs";
import { FixtureVerifier, newId } from "../src/records.mjs";
import { ControlClient, FakeLauncher } from "./fake-pi.mjs";
import { FAST, REPO, assistantEntry, claimRecords, cleanupAll, controllerFor, fixture, killChildren, noUnits, reap, receiptState, spawnController, started, tick } from "./harness.mjs";
const reaped = [];
const strays = new Set();
after(() => {
for (const pid of strays) {
try {
process.kill(pid, "SIGKILL");
} catch {
// gone
}
}
for (const fx of reaped) reap(fx);
killChildren();
cleanupAll();
});
const track = (fx) => (reaped.push(fx), fx);
const SCOPE = scopeAvailable();
const NEEDS_SCOPE = { skip: !SCOPE && "systemd user scopes unavailable", timeout: 60000 };
const FAKE_PI = join(import.meta.dirname, "fake-pi.mjs");
async function until(pred, ms = 4000, what = "condition") {
const end = Date.now() + ms;
while (!(await pred())) {
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await tick(10);
}
}
// A zombie has exited; only its parent hasn't reaped it yet.
function alive(pid) {
try {
const st = readFileSync(`/proc/${pid}/stat`, "utf8");
return st.slice(st.lastIndexOf(")") + 2)[0] !== "Z";
} catch {
return false;
}
}
class SpyVerifier extends FixtureVerifier {
constructor() {
super({ authorities: [AUTHORITY] });
this.posted = [];
}
post(p) {
this.posted.push(structuredClone(p));
return super.post(p);
}
}
// Holds the controller at named barriers (as in races.test.mjs).
function gate() {
const want = new Set(), held = new Map();
return {
barrier: async (name) => {
if (!want.has(name)) return;
want.delete(name);
await new Promise((r) => held.set(name, r));
},
hold: (name) => want.add(name),
waitHeld: (name, ms = 8000) => until(() => held.has(name), ms, `barrier ${name}`),
release: (name) => {
const r = held.get(name);
held.delete(name);
r?.();
},
};
}
// A controller in this process on a real engine process: the fake engine
// under ScopeLauncher ("scope") or PgroupLauncher ("pgroup").
async function live({ kind = "scope", barrier = null, verifier = new FixtureVerifier({ authorities: [AUTHORITY] }) } = {}) {
const fx = track(fixture());
const control = join(fx.base, "fake.sock");
const ctrl = new Controller({
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
launcher: kind === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
engine: { command: process.execPath, preArgs: [FAKE_PI], env: { ...process.env, FAKE_PI_CONTROL: control, FAKE_PI_LOG: join(fx.base, "fake.log") }, cwd: fx.proj },
verifier, units: kind === "scope" ? systemdUnits : noUnits, timeouts: FAST, barrier,
});
await ctrl.start();
assert.equal(ctrl.binding.state, "active", JSON.stringify(ctrl.evidence.uncertain));
const c = new ConversationClient({ socketPath: ctrl.socketPath });
await c.connect();
assert.equal((await c.takeover()).outcome, "transferred");
const fake = new ControlClient(control);
await fake.connect();
const close = async () => {
c.close();
fake.close();
await ctrl.close({ killEngine: true });
reap(fx);
};
return { fx, ctrl, c, fake, rec: () => ctrl.claim.record, close };
}
const childOf = async (h, args) => {
const r = await h.fake.call("child", { args });
assert.ok(r.ok, JSON.stringify(r));
strays.add(r.result.pid);
return r.result.pid;
};
const memberPids = async (shim) => {
const m = await shimRequest(shim, "members");
assert.ok(m.ok, JSON.stringify(m));
return m.members.map((x) => x.pid);
};
// A confirmed force stop, waited to its end (`stopped` or `uncertain`).
async function forceStop(h, c = h.c, ms = 20000) {
const fs = await c.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced", JSON.stringify(fs));
await until(() => ["stopped", "uncertain"].includes(h.ctrl.binding.state), ms, "the force stop to end");
return fs.stop.id;
}
// ---- scope fixtures --------------------------------------------------------
test("K1: force stop kills a tool child that called setsid; stopped with a verified proof", NEEDS_SCOPE, async () => {
const h = await live();
try {
// It also ignores TERM, so only the cgroup kill ends it.
const child = await childOf(h, { setsid: true, ignoreTerm: true });
assert.ok((await memberPids(h.rec().shim)).includes(child), "setsid leaves the process group, not the cgroup");
const stop = await forceStop(h);
assert.equal(h.ctrl.binding.state, "stopped", JSON.stringify(h.ctrl.evidence.stops));
const { proof } = h.ctrl.stoppedProof;
assert.equal(proof.stop, stop);
assert.equal(proof.membershipComplete, true);
assert.equal(proof.membershipEpoch, h.rec().invocationId);
assert.ok(proof.members.some((m) => m.pid === child), "the escaped child is a listed member");
assert.ok(h.ctrl.verifier.cohort(proof, h.ctrl.stoppedProof.effects, { binding: h.ctrl.binding, stop, now: new Date(), epoch: h.rec().invocationId }));
assert.equal(alive(child), false);
} finally {
await h.close();
}
});
test("K2: K1 on the process-group fallback ends uncertain, never stopped", async () => {
const h = await live({ kind: "pgroup" });
try {
const child = await childOf(h, { setsid: true });
await forceStop(h);
assert.equal(h.ctrl.binding.state, "uncertain");
assert.equal(h.ctrl.stoppedProof ?? null, null);
const last = h.ctrl.evidence.stops.at(-1);
assert.equal(last.outcome, "uncertain");
assert.match(last.reason, /process-group fallback/);
assert.ok(alive(child), "the setsid child left the group; a stopped claim here would have been false");
assert.equal((await h.c.prompt("after an uncertain stop")).refusal, "fenced");
} finally {
await h.close();
}
});
test("K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM", NEEDS_SCOPE, async () => {
const g = gate();
const h = await live({ barrier: g.barrier });
try {
const child = await childOf(h, { ignoreTerm: true });
g.hold("phase-kill");
const fs = await h.c.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced");
await g.waitHeld("phase-kill");
assert.equal(h.ctrl.binding.state, "stopping");
assert.notEqual(h.ctrl.stops.get(fs.stop.id).state, "stopped");
assert.equal(h.rec().state, "stopping");
assert.equal(h.rec().proof ?? null, null);
assert.equal(h.rec().stop.phaseStarted, "kill");
assert.ok(alive(child), "the member ignored TERM");
assert.equal((await shimRequest(h.rec().shim, "events")).populated, 1);
g.release("phase-kill");
await until(() => h.ctrl.binding.state !== "stopping", 15000, "the kill phase");
assert.equal(h.ctrl.binding.state, "stopped");
assert.equal(alive(child), false);
assert.ok(h.ctrl.stoppedProof.proof.members.some((m) => m.pid === child));
} finally {
g.release("phase-kill");
await h.close();
}
});
test("K4: two engines; force stop one; the other survives by independent observation", NEEDS_SCOPE, async () => {
const a = await live();
const b = await live();
try {
assert.notEqual(a.rec().unitName, b.rec().unitName);
await forceStop(a);
assert.equal(a.ctrl.binding.state, "stopped");
const ev = await shimRequest(b.rec().shim, "events");
assert.equal(ev.populated, 1, "b's own engine cgroup is still populated");
const st = await b.fake.call("state");
assert.ok(st.ok);
assert.equal(st.result.pid, b.rec().engine.pid);
assert.equal(systemctlShow(b.rec().unitName).invocationId, b.rec().invocationId);
const p = await b.c.prompt("still here?");
assert.equal(p.outcome, "admitted", JSON.stringify(p));
await receiptState(b.c, p.receipt.id, "finished", 8000);
assert.equal(b.ctrl.binding.state, "active");
} finally {
await a.close();
await b.close();
}
});
test("K5: a stop during a tool call leaves the effect uncertain, and it is shown", NEEDS_SCOPE, async () => {
const h = await live();
try {
await h.fake.call("script", { steps: [{ tool: { id: "call-k5", name: "bash", args: { command: "touch x" }, hold: true } }, { text: "never", stop: "stop" }] });
await h.fake.call("arm", { point: "tool:call-k5" });
const p = await h.c.prompt("run a tool");
assert.equal(p.outcome, "admitted");
await h.fake.call("waitPaused", { point: "tool:call-k5" });
await until(() => [...(h.ctrl.exec?.tools.values() ?? [])].some((t) => t.start && !t.end), 4000, "the tool start");
const stop = await forceStop(h);
assert.equal(h.ctrl.binding.state, "stopped");
const s = h.ctrl.stops.get(stop);
assert.equal(s.externalEffects, "uncertain");
const inv = h.ctrl.stoppedProof.effects.invocations;
assert.equal(inv.length, 1);
assert.equal(inv[0].disposition, "uncertain", "killing is never a rollback");
await until(() => h.c.pushes.some((m) => m.kind === "stop" && m.stop.id === stop && m.stop.state === "stopped" && m.stop.externalEffects === "uncertain"), 4000, "the stop push");
} finally {
await h.close();
}
});
// The freeze is shown two ways that don't depend on timing: engine's
// cgroup.freeze still reads 1 after the stop (the shim holds the scope), and
// every child the loop forked after its SIGTERM, which nothing else ends
// before the kill, is in the proof's list. Mutants r2-B5 (no freeze write,
// `frozen 1` answered) and r2-B5b (freeze written, not waited on) fail here.
test("K12: a member forking in a loop: the freeze stops it, enumeration is complete, populated 0 after cgroup.kill", NEEDS_SCOPE, async () => {
const h = await live();
try {
const pidLog = join(h.fx.base, "fork-pids.log");
const forker = await childOf(h, { forkLoop: true, ignoreTerm: true, pidLog });
await until(async () => (await memberPids(h.rec().shim)).length >= 6, 4000, "the fork loop");
const engineDir = join("/sys/fs/cgroup", (await shimRequest(h.rec().shim, "hello")).scope, "engine");
await forceStop(h);
assert.equal(h.ctrl.binding.state, "stopped", JSON.stringify(h.ctrl.evidence.stops));
const { members } = h.ctrl.stoppedProof.proof;
assert.ok(members.some((m) => m.pid === forker));
assert.ok(members.length >= 2, `members ${members.length}`);
for (const m of members) assert.equal(alive(m.pid), false, `member ${m.pid}`);
assert.equal(readFileSync(join(engineDir, "cgroup.freeze"), "utf8").trim(), "1", "the freeze was written");
const lines = readFileSync(pidLog, "utf8").split("\n").filter(Boolean);
assert.ok(lines.includes("term"), "the fork loop got the TERM phase");
const listed = new Set(members.map((m) => m.pid));
const late = lines.slice(lines.indexOf("term") + 1).map(Number);
assert.ok(late.length > 0, "the loop forked after its TERM");
for (const pid of late) {
strays.add(pid);
assert.ok(listed.has(pid), `child ${pid} forked after TERM is in the proof's list`);
}
const ev = await shimRequest(h.rec().shim, "events");
assert.equal(ev.populated, 0);
assert.deepEqual(await memberPids(h.rec().shim), []);
} finally {
await h.close();
}
});
test("K13: a member writing its pid into another cgroup is refused by the namespace; the kill is complete", NEEDS_SCOPE, async () => {
const h = await live();
const engine = h.rec().engine.pid;
try {
const hello = await shimRequest(h.rec().shim, "hello");
const scope = join("/sys/fs/cgroup", hello.scope);
for (const target of [join(scope, "supervisor", "cgroup.procs"), join(dirname(scope), "cgroup.procs")]) {
const r = await h.fake.call("escape", { target });
assert.ok(r.ok, JSON.stringify(r));
assert.equal(r.result.escaped, false, `escape into ${target}`);
}
assert.ok((await memberPids(h.rec().shim)).includes(engine), "the engine is still in its cgroup");
await forceStop(h);
assert.equal(h.ctrl.binding.state, "stopped");
assert.equal(alive(engine), false);
} finally {
strays.add(engine);
await h.close();
}
});
test("K15: the shim gone, engine/cgroup.events unreadable, or the engine cgroup missing: evidence unavailable, not empty; uncertain", NEEDS_SCOPE, async () => {
{
const h = await live();
const engine = h.rec().engine.pid;
try {
const hello = await shimRequest(h.rec().shim, "hello");
process.kill(hello.shimPid, "SIGKILL");
await until(() => !alive(hello.shimPid), 4000, "the shim to die");
await forceStop(h);
assert.equal(h.ctrl.binding.state, "uncertain");
assert.match(h.ctrl.evidence.stops.at(-1).reason, /shim/);
assert.ok(alive(engine), "no signal reached the engine without the shim's evidence");
} finally {
await h.close();
}
}
{
const h = await live();
try {
const hello = await shimRequest(h.rec().shim, "hello");
chmodSync(join("/sys/fs/cgroup", hello.scope, "engine", "cgroup.events"), 0o000);
assert.equal((await shimRequest(h.rec().shim, "events")).ok, false);
await forceStop(h);
assert.equal(h.ctrl.binding.state, "uncertain");
assert.match(h.ctrl.evidence.stops.at(-1).reason, /cgroup\.events unreadable/);
assert.equal(h.ctrl.stoppedProof ?? null, null);
} finally {
await h.close();
}
}
// The `engine` cgroup path missing: its processes moved to a sibling and
// the directory removed. Absent, never empty. Mutant r2-B6 (ENOENT read as
// `populated 0`) fails here.
{
const h = await live();
const engine = h.rec().engine.pid;
try {
const scope = join("/sys/fs/cgroup", (await shimRequest(h.rec().shim, "hello")).scope);
const aside = join(scope, "aside");
mkdirSync(aside);
for (const pid of readFileSync(join(scope, "engine", "cgroup.procs"), "utf8").split("\n").filter(Boolean).map(Number)) {
strays.add(pid);
writeFileSync(join(aside, "cgroup.procs"), String(pid));
}
rmdirSync(join(scope, "engine"));
for (const op of ["events", "members"]) {
const r = await shimRequest(h.rec().shim, op);
assert.equal(r.ok, false, `${op}: ${JSON.stringify(r)}`);
assert.match(r.unavailable, /ENOENT/);
}
await forceStop(h);
assert.equal(h.ctrl.binding.state, "uncertain");
assert.match(h.ctrl.evidence.stops.at(-1).reason, /ENOENT/);
assert.equal(h.ctrl.stoppedProof ?? null, null);
assert.ok(alive(engine), "no signal reached the engine without the cgroup's evidence");
} finally {
await h.close();
}
}
});
// ---- controller death during a force stop ----------------------------------
function childFixture() {
const fx = track(fixture());
return { fx, fakeEnv: { FAKE_PI_CONTROL: join(fx.base, "fake.sock"), FAKE_PI_LOG: join(fx.base, "fake.log") } };
}
async function connectTo(socketPath, client = null) {
const c = client ?? new ConversationClient({ socketPath });
c.socketPath = socketPath;
await c.connect();
return c;
}
const stopping = (fx) => claimRecords(fx).map((r) => r.record).filter((r) => r?.state === "stopping" && r.stop);
// The controller dies at `barrier` during a confirmed force stop; a tool
// child that ignores TERM keeps the cohort populated past the TERM phase.
async function crashDuringStop(barrier) {
const { fx, fakeEnv } = childFixture();
const a = spawnController({ fx, launcher: "scope", fakeEnv, dieAt: { [barrier]: 1 } });
const ra = await a.next((m) => m.ready || m.error);
assert.ok(ra.ready, JSON.stringify(ra));
const c = await connectTo(ra.socketPath);
assert.equal((await c.takeover()).outcome, "transferred");
const fake = new ControlClient(fakeEnv.FAKE_PI_CONTROL);
await fake.connect();
const child = (await fake.call("child", { args: { ignoreTerm: true } })).result.pid;
strays.add(child);
fake.close();
void c.confirmed("force-stop");
await a.next((m) => m.dying === barrier, 15000);
await a.exited;
const recs = stopping(fx);
assert.ok(recs.length > 0, "the stop was recorded before any signal");
const last = recs.at(-1);
return { fx, fakeEnv, c, child, stopId: last.stop.id, last, engine: last.engine.pid };
}
async function restartAndResume({ fx, fakeEnv, c, stopId }) {
const b = spawnController({ fx, launcher: "scope", fakeEnv });
const rb = await b.next((m) => m.ready || m.error, 15000);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.launched, false);
assert.equal(rb.started.classified.state, "stopping");
await connectTo(rb.socketPath, c);
assert.ok(await c.waitFor(() => ["stopped", "uncertain"].includes(c.binding?.state), 20000), `binding ${c.binding?.state}`);
b.send("evidence");
const ev = (await b.next((m) => m.evidence !== undefined)).evidence;
b.send("proof");
const proof = (await b.next((m) => m.proof !== undefined)).proof;
const recs = claimRecords(fx).map((r) => r.record).filter((r) => r?.stop);
assert.ok(recs.every((r) => r.stop.id === stopId), "the restart continues the recorded stop; no new stop");
return { b, ev, proof };
}
for (const [id, barrier, title] of [
["K10", "phase-kill", "controller killed between the TERM and kill phases"],
["K11", "force-stop-recorded", "controller killed after the confirmation is recorded, before TERM"],
]) {
test(`${id}: ${title}: restart checks the invocation ID and re-runs from TERM for the same stop`, NEEDS_SCOPE, async () => {
const crashed = await crashDuringStop(barrier);
const { fx, c, child, stopId, last, engine } = crashed;
assert.equal(last.stop.phaseStarted, barrier === "phase-kill" ? "kill" : null);
assert.ok(!claimRecords(fx).some((r) => r.record?.state === "stopped"), "nothing recorded as stopped before the restart");
assert.ok(alive(child), "the member is alive across the crash");
if (barrier === "force-stop-recorded") assert.ok(alive(engine), "no TERM was sent before the crash");
const { b, ev, proof } = await restartAndResume(crashed);
try {
assert.equal(c.binding.state, "stopped", JSON.stringify(ev.stops));
const done = ev.stops.at(-1);
assert.equal(done.stop, stopId);
assert.equal(done.resumed, true);
assert.equal(done.outcome, "stopped");
assert.equal(proof.stop, stopId);
assert.ok(proof.members.some((m) => m.pid === child), "the member observed at the freeze is listed");
if (barrier === "phase-kill") assert.ok(!proof.members.some((m) => m.pid === engine), "the engine ended at TERM before the crash; it isn't listed as killed");
assert.equal(alive(child), false);
} finally {
c.close();
b.send("close");
await b.exited;
reap(fx);
}
});
}
test("K14: a unit with the recorded name but another invocation ID: evidence unavailable, no signals, uncertain", NEEDS_SCOPE, async () => {
const { fx, fakeEnv } = childFixture();
const a = spawnController({ fx, launcher: "scope", fakeEnv });
const ra = await a.next((m) => m.ready || m.error);
assert.ok(ra.ready, JSON.stringify(ra));
const rec = claimRecords(fx).map((r) => r.record).filter((r) => r?.invocationId).at(-1);
const unit = rec.unitName;
a.proc.kill("SIGKILL");
await a.exited;
// The shim ignores TERM by design, so the scope goes with SIGKILL.
spawnSync("systemctl", ["--user", "kill", "--signal=SIGKILL", `${unit}.scope`], { stdio: "ignore", timeout: 10000 });
await until(() => systemctlShow(unit)?.loadState === "not-found", 10000, "the original scope to go");
let impostor = null;
await until(() => {
if (impostor && systemctlShow(unit)?.activeState === "active") return true;
if (!impostor || impostor.exitCode !== null) {
impostor = spawn("systemd-run", ["--user", "--scope", `--unit=${unit}`, "--quiet", "--", "sleep", "300"], { stdio: "ignore", detached: true });
impostor.unref();
}
return false;
}, 10000, "the impostor unit");
strays.add(impostor.pid);
const theirs = systemctlShow(unit).invocationId;
assert.notEqual(theirs, rec.invocationId);
const b = spawnController({ fx, launcher: "scope", fakeEnv });
try {
const rb = await b.next((m) => m.ready || m.error, 15000);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.classified.state, "uncertain");
const c = await connectTo(rb.socketPath);
assert.equal((await c.confirmed("acquire-recovery-control")).outcome, "recovery-control-acquired");
const fs = await c.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced", JSON.stringify(fs));
assert.ok(await c.waitFor(() => c.binding?.state === "uncertain" && c.pushes.some((m) => m.kind === "stop" && m.stop.id === fs.stop.id && m.stop.state === "uncertain"), 15000));
b.send("evidence");
const ev = (await b.next((m) => m.evidence !== undefined)).evidence;
assert.match(ev.stops.at(-1).reason, /invocation ID mismatch/);
assert.ok(alive(impostor.pid), "the other cohort got no signal");
assert.equal(systemctlShow(unit).invocationId, theirs);
c.close();
} finally {
b.send("close");
await b.exited;
spawnSync("systemctl", ["--user", "stop", `${unit}.scope`], { stdio: "ignore", timeout: 10000 });
reap(fx);
}
});
// ---- in-process fake: recovery, launch and the K9 fence ---------------------
async function provenStop(h, c = h.client) {
const fs = await c.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced", JSON.stringify(fs));
await until(() => h.ctrl.binding.state === "stopped", 4000, "the proven stop");
return fs.stop.id;
}
function pinRootCopy(fx) {
const root = join(fx.base, "pins");
mkdirSync(join(root, "node_modules"), { recursive: true });
copyFileSync(join(REPO, "package-lock.json"), join(root, "package-lock.json"));
copyFileSync(join(REPO, "node_modules", ".package-lock.json"), join(root, "node_modules", ".package-lock.json"));
return root;
}
test("K6: recover without proof, without confirmation, or with changed pins is refused", async () => {
{
const h = await started();
try {
const notYet = await h.client.confirmed("recover", { stop: newId("stop") });
assert.equal(notYet.refusal, "stop-proof", "no stop at all");
} finally {
await h.close();
}
}
{
const h = await started({ launcher: new FakeLauncher({ stopOutcome: "unavailable" }) });
try {
const fs = await h.client.confirmed("force-stop");
await until(() => h.ctrl.binding.state === "uncertain", 4000, "the uncertain stop");
assert.equal((await h.client.confirmed("recover", { stop: fs.stop.id })).refusal, "stop-proof", "an uncertain stop is no proof");
} finally {
await h.close();
}
}
{
const fx = track(fixture());
const pinRoot = pinRootCopy(fx);
const h = await started({ fx, pinRoot });
try {
const stop = await provenStop(h);
const missing = await h.client.request("recover", { stop });
assert.equal(missing.refusal, "malformed", "no confirmation field");
const unknown = await h.client.request("recover", { stop, confirmation: newId("confirmation") });
assert.equal(unknown.refusal, "confirmation");
const issued = await h.client.request("issue-confirmation", { operationToConfirm: "force-stop" });
const id = issued.data.confirmation.id;
await h.client.request("answer-confirmation", { confirmation: id, answer: "confirm" });
assert.equal((await h.client.request("recover", { stop, confirmation: id })).refusal, "confirmation", "a confirmation for another operation");
const lock = join(pinRoot, "package-lock.json");
const original = readFileSync(lock, "utf8");
const changed = JSON.parse(original);
changed.packages["node_modules/@earendil-works/pi-coding-agent"].version = "0.0.0";
writeFileSync(lock, JSON.stringify(changed));
assert.equal((await h.client.confirmed("recover", { stop })).refusal, ENGINE_PIN_MISMATCH);
writeFileSync(lock, original);
const again = await h.client.request("issue-confirmation", { operationToConfirm: "recover" });
const once = again.data.confirmation.id;
await h.client.request("answer-confirmation", { confirmation: once, answer: "confirm" });
assert.equal((await h.client.request("recover", { stop, confirmation: once })).outcome, "recovery-eligible", "the same request with the pins restored");
assert.equal(h.ctrl.confirmations.get(once).state, "consumed");
assert.equal((await h.client.request("recover", { stop, confirmation: once })).refusal, "confirmation", "a confirmation is single-use");
} finally {
await h.close();
}
}
});
test("K7: recover after proof, then launch: new claim and execution, generation +1, same leaf; the cancelled prompt is not replayed", async () => {
const h = await started();
try {
h.engine.script([{ pause: "p1" }, { text: "never", stop: "stop" }]);
h.engine.arm("p1");
const p = await h.client.prompt("cancelled by the stop");
assert.equal(p.outcome, "admitted");
await receiptState(h.client, p.receipt.id, "working");
await h.engine.waitPaused("p1");
const before = { claim: h.ctrl.claim.claimId, execution: h.ctrl.binding.execution, generation: h.ctrl.binding.controllerGeneration };
const stop = await provenStop(h);
const leafAtProof = h.ctrl.claim.record.leafAtProof;
const rec = await h.client.confirmed("recover", { stop });
assert.equal(rec.outcome, "recovery-eligible", JSON.stringify(rec));
assert.equal(rec.data.generation, before.generation + 1);
const engines = h.launcher.engines.length;
const r = await h.ctrl.launch(rec.data.eligibility);
assert.equal(h.launcher.engines.length, engines + 1);
assert.notEqual(r.claim, before.claim);
assert.equal(r.claim, rec.data.claim);
assert.notEqual(h.ctrl.binding.execution, before.execution, "a new execution (K7's incarnation)");
assert.equal(h.ctrl.binding.controllerGeneration, before.generation + 1);
assert.equal(h.ctrl.binding.state, "active");
assert.equal(h.ctrl.claim.record.leaf, leafAtProof);
assert.equal(h.ctrl.claim.record.prior.stop, stop);
const fresh = h.launcher.last;
assert.equal(fresh.leaf, leafAtProof, "the new engine loaded the leaf at proof");
assert.ok(!fresh.commands.some((x) => x.type === "prompt"));
assert.ok(!fresh.bytes().toString().includes("cancelled by the stop"));
} finally {
await h.close();
}
});
test("K8: an engine that loads another leaf on resume is refused before admission; it stays claimed until a proven stop", async () => {
const h = await started();
try {
const stop = await provenStop(h);
const rec = await h.client.confirmed("recover", { stop });
assert.equal(rec.outcome, "recovery-eligible");
h.launcher.opts.leaf = "ffff0000";
await h.ctrl.launch(rec.data.eligibility);
assert.equal(h.ctrl.binding.state, "uncertain");
assert.ok(await h.client.waitFor(() => h.client.binding?.id === h.ctrl.binding.id && h.client.binding.state === "uncertain"), "the client sees the new binding");
assert.equal(h.ctrl.binding.admission, "closed");
assert.ok(h.ctrl.evidence.uncertain.some((u) => u.reason === "loaded-session" && /another leaf/.test(u.detail)));
assert.equal(h.ctrl.claim.claimId, rec.data.claim);
assert.notEqual(h.ctrl.claim.record.state, "active", "never promoted");
assert.ok(h.ctrl.claim.record.engine?.pid, "the engine stays recorded under the claim");
const stops = h.launcher.stops;
assert.equal(h.launcher.last.ended ?? false, false, "nothing killed it outside a force stop");
assert.equal((await h.client.confirmed("acquire-recovery-control")).outcome, "recovery-control-acquired");
assert.equal((await h.client.prompt("admitted?")).refusal, "preflight", "the loaded-session check never passed");
await provenStop(h);
assert.equal(h.launcher.stops, stops + 1);
assert.equal(h.ctrl.claim.record.state, "stopped");
} finally {
await h.close();
}
});
test("K9: an interrupt that never settles stays uncertain; force stop stays available; takeover is refused while fenced", async () => {
const h = await started({ clients: 2 });
try {
const [c1, c2] = h.clients;
h.engine.script([{ hang: true }]);
const p = await c1.prompt("hangs");
assert.equal(p.outcome, "admitted");
await receiptState(c1, p.receipt.id, "working");
const r = await c1.interrupt();
const stop = r.stop?.id ?? h.ctrl.binding.stop;
await until(() => h.ctrl.stops.get(stop)?.state === "uncertain", 10000, "the interrupt to end uncertain");
assert.equal(h.ctrl.binding.admission, "closed");
assert.ok(!h.ctrl.events.some((e) => e.type === "reconciled"));
assert.equal((await c1.prompt("again")).refusal, "fenced");
await until(() => c2.binding?.controllerGeneration === h.ctrl.binding.controllerGeneration, 2000, "c2 synced");
assert.equal((await c2.takeover()).refusal, "fenced");
await provenStop(h, c1);
assert.equal(h.ctrl.binding.state, "stopped");
} finally {
await h.close();
}
});
test("K16: a claim from another machine ID refuses foreign-host; no boot proof is issued", async () => {
const fx = track(fixture());
const verifier = new SpyVerifier();
const { ctrl } = controllerFor(fx, { verifier });
const foreign = new ClaimStore({ root: fx.claimRoot, host: { machineId: () => "f".repeat(32), bootId: () => "00000000-0000-4000-8000-000000000000" } });
await foreign.acquire(ctrl.seatK, ctrl.sessionK, {
bindingId: "binding-1", harness: "pi", conversation: ctrl.conversation, branch: "main", leaf: "b2c3d4e5",
pins: { engineVersion: "0.85.1", enginePin: "x", argvDigest: "y" },
owner: { pid: 1, start: "1", boot: "00000000-0000-4000-8000-000000000000", incarnation: "f".repeat(32) }, generation: 1,
});
await assert.rejects(ctrl.start(), { code: FOREIGN_HOST });
assert.deepEqual(verifier.posted, [], "no proof of any kind was posted");
});
test("K17: two launcher calls with one eligibility record: one launch, the other refuses, no second engine", async () => {
const h = await started();
try {
const stop = await provenStop(h);
const rec = await h.client.confirmed("recover", { stop });
const engines = h.launcher.engines.length;
const [x, y] = await Promise.allSettled([h.ctrl.launch(rec.data.eligibility), h.ctrl.launch(rec.data.eligibility)]);
const ok = [x, y].filter((v) => v.status === "fulfilled");
const no = [x, y].filter((v) => v.status === "rejected");
assert.equal(ok.length, 1);
assert.equal(no.length, 1);
assert.equal(no[0].reason.code, ELIGIBILITY);
assert.equal(h.launcher.engines.length, engines + 1);
await assert.rejects(h.ctrl.launch(rec.data.eligibility), { code: ELIGIBILITY });
assert.equal(h.launcher.engines.length, engines + 1);
} finally {
await h.close();
}
});
test("K18: the leaf changes after eligibility: launch refused; the reservation stays until released with proof", async () => {
const h = await started();
try {
const stop = await provenStop(h);
const rec = await h.client.confirmed("recover", { stop });
const leaf = h.ctrl.claim.record.leafAtProof;
appendFileSync(h.fx.sessionFile, JSON.stringify(assistantEntry("c3d4e5f6", leaf, "written after eligibility", 9)) + "\n");
const engines = h.launcher.engines.length;
await assert.rejects(h.ctrl.launch(rec.data.eligibility), { code: "target" });
assert.equal(h.launcher.engines.length, engines, "no engine started");
for (const key of [h.ctrl.seatK, h.ctrl.sessionK]) {
const head = h.ctrl.store.head(key);
assert.equal(head.record.claimId, rec.data.claim);
assert.equal(head.record.state, "reserved");
assert.equal(head.record.spawnMarker, false);
}
assert.equal((await h.ctrl.release(rec.data.eligibility)).released, rec.data.claim);
for (const key of [h.ctrl.seatK, h.ctrl.sessionK]) {
const head = h.ctrl.store.head(key);
assert.equal(head.record.claimId, rec.data.claim);
assert.equal(head.record.state, "stopped");
assert.equal(head.record.proof.kind, "no-unit");
}
await assert.rejects(h.ctrl.launch(rec.data.eligibility), { code: ELIGIBILITY });
} finally {
await h.close();
}
});
@@ -0,0 +1,88 @@
#!/usr/bin/env node
// A controller in its own process, for the crash fixtures (#1507: W5, W6,
// W12–W15, W20, H18, H21–H23, K-series). A test can't kill the controller it
// runs in, and the claim's owner check sees the test's own pid as live, so
// these fixtures run the controller here.
//
// argv[2] is JSON: { fx, launcher: "pgroup" | "scope", dieAt, holdAt,
// timeouts, host, fakeEnv, verifier }. Lines on stdout are JSON:
// { ready, ... } after start, { held: name } at a held barrier,
// { dying: name } just before a SIGKILL at `dieAt`. Lines on stdin:
// "go" releases a held barrier; "close" closes and exits; "kill-engine"
// closes with the engine killed; "launch <eligibility>" launches after a
// recover; "evidence" and "proof" print the controller's evidence and its
// verified cohort proof.
import { createInterface } from "node:readline";
import { join } from "node:path";
import { Controller } from "../src/controller.mjs";
import { AUTHORITY, PgroupLauncher, ScopeLauncher, systemdUnits } from "../src/cohort.mjs";
import { FixtureVerifier } from "../src/records.mjs";
import { defaultHost } from "../src/claim.mjs";
const cfg = JSON.parse(process.argv[2]);
const out = (v) => process.stdout.write(JSON.stringify(v) + "\n");
const dieAt = new Map(Object.entries(cfg.dieAt ?? {}));
const holdAt = new Set(cfg.holdAt ?? []);
let release = null;
// The test's stdin pipe is line-oriented control, not engine input.
const rl = createInterface({ input: process.stdin });
let ctrl = null;
rl.on("line", async (line) => {
if (line === "go" && release) {
const r = release;
release = null;
r();
} else if (line === "close" || line === "kill-engine") {
await ctrl?.close({ killEngine: line === "kill-engine" });
out({ closed: true });
process.exit(0);
} else if (line === "evidence") out({ evidence: ctrl?.evidence ?? null, binding: ctrl?.binding ?? null });
else if (line === "proof") out({ proof: ctrl?.stoppedProof?.proof ?? null });
else if (line.startsWith("launch ")) {
// The launcher's call after `recover` (Q15), never a client command.
try {
const r = await ctrl.launch(line.slice(7));
out({ launched: true, binding: r.binding, incarnation: ctrl.incarnation });
} catch (err) {
out({ launched: false, error: String(err.code ?? "error"), message: String(err.message) });
}
}
});
// `dieAt` maps a barrier name to the occurrence to die at (1 = first).
const seen = new Map();
async function barrier(name, detail) {
const n = (seen.get(name) ?? 0) + 1;
seen.set(name, n);
if (cfg.trace) out({ barrier: name, n });
if (dieAt.get(name) === n) {
out({ dying: name, n, detail });
process.kill(process.pid, "SIGKILL");
await new Promise(() => {});
}
if (holdAt.has(`${name}#${n}`) || (n === 1 && holdAt.has(name))) {
out({ held: name, n, detail });
await new Promise((r) => (release = r));
}
}
const host = cfg.host ? { machineId: () => cfg.host.machineId ?? defaultHost.machineId(), bootId: () => cfg.host.bootId ?? defaultHost.bootId() } : undefined;
const fx = cfg.fx;
const fakePi = join(import.meta.dirname, "fake-pi.mjs");
try {
ctrl = new Controller({
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: cfg.socketDir ?? fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
launcher: cfg.launcher === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
engine: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) }, cwd: fx.proj },
verifier: cfg.verifier === false ? null : new FixtureVerifier({ authorities: [AUTHORITY] }),
units: cfg.units === "absent" ? { lookup: async () => ({ state: "absent" }) } : systemdUnits,
host, barrier, timeouts: cfg.timeouts,
});
const started = await ctrl.start();
out({ ready: true, started, socketPath: ctrl.socketPath, incarnation: ctrl.incarnation, pid: process.pid, binding: ctrl.binding, claim: ctrl.claim ? { claimId: ctrl.claim.claimId, record: ctrl.claim.record } : null });
} catch (err) {
out({ error: String(err.code ?? "error"), message: String(err.message) });
process.exit(3);
}
+694
View File
@@ -0,0 +1,694 @@
#!/usr/bin/env node
// The fake engine for CHAT-03 I1 (#1507, brief §3 N10).
//
// It models pinned Pi 0.85.1's RPC prompt path where CHAT-03 depends on it
// (agent-session.js 821–949, rpc-mode.js 298–335):
// - `prompt` awaits the input handlers (line 843), checks `isStreaming` and
// throws with no streamingBehavior (line 860), awaits the auth and
// compaction checks (895) and before_agent_start (915), acks, and then
// starts the run. A Mosaic prompt is never queued.
// - a prompt that collides with a running agent is acked, its throw is
// swallowed, it settles with no agent_start, and it leaves isStreaming
// false while the other run goes on;
// - `agent_settled` comes from a `finally`, and one run can hold several
// agent_start … agent_end pairs;
// - `clear_queue` emits an empty `queue_update` before its response,
// returns only the steer and follow-up items, drops agent-level custom
// messages without returning them, and leaves nextTurn messages;
// - `abort` waits for idle, and anything still queued then runs inside the
// same run.
// Unsealed-extension effects (queueing, extension prompts, triggerTurn,
// input handlers) are simulated here; no real extension ever loads.
//
// Two ways to run it. In process, a test builds `FakePi` on a pair of streams
// through `FakeLauncher` and drives it directly. As a process
// (`node fake-pi.mjs --mode rpc ...`), it is driven over the Unix socket in
// FAKE_PI_CONTROL and logs argv, every byte received and every line sent to
// FAKE_PI_LOG. The process form exists for the cohort fixtures (K-series),
// which need a real process tree.
import { appendFileSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { createServer, connect } from "node:net";
import { PassThrough, Writable } from "node:stream";
import { randomBytes } from "node:crypto";
import { pathToFileURL } from "node:url";
import { LineSplitter, encodeLine, parseLine } from "../src/framing.mjs";
import { parseSnapshot } from "../src/pi.mjs";
export const BUSY_ERROR = "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.";
export const DEFAULT_SCRIPT = Object.freeze([{ text: "ok", stop: "stop" }]);
const entryId = () => randomBytes(4).toString("hex");
export class FakePi {
constructor({ input, output, argv = [], leaf, append = true, log = null, now = () => new Date() }) {
this.input = input;
this.output = output;
this.argv = argv;
this.log = log;
this.now = now;
this.append = append;
const i = argv.indexOf("--session");
this.sessionFile = i >= 0 ? argv[i + 1] : null;
this.sessionId = null;
this.leaf = null;
if (this.sessionFile) {
const parsed = parseSnapshot(readFileSync(this.sessionFile, "utf8"));
this.sessionId = parsed.header.id;
this.leaf = parsed.defaultLeaf?.entry.id ?? null;
// Pi's startup append (sdk.js 240–252): a session with no messages, or
// whose branch has no thinking_level_change, gains one when Pi starts,
// so the loaded leaf moves. (A new session with a model also gains a
// model_change; the fake has no model.)
const branch = [];
for (let r = parsed.defaultLeaf; r; r = typeof r.entry.parentId === "string" ? parsed.byId.get(r.entry.parentId) : null) branch.push(r.entry);
if (!branch.some((e) => e.type === "message") || !branch.some((e) => e.type === "thinking_level_change")) this.startupAppend = true;
}
this.leafOverride = leaf;
this.streaming = false;
this.run = null;
this.runs = [];
this.steering = [];
this.followUp = [];
this.agentQueue = [];
this.nextTurn = [];
this.scripts = [];
this.plans = [];
this.extPlans = [];
this.drop = new Map();
this.fail = new Map();
this.armed = new Map();
this.paused = new Map();
this.pauseWaiters = [];
this.idleWaiters = [];
this.received = [];
this.commands = [];
this.sent = [];
this.appends = [];
this.uiSerial = 0;
this.persistHeld = false;
this.unpersisted = [];
if (this.startupAppend && append) this.#appendEntry({ type: "thinking_level_change", thinkingLevel: "off" });
this.onPaused = null;
this.closed = false;
this.log?.({ t: "argv", argv });
const splitter = new LineSplitter((line) => this.#command(line));
input.on("data", (c) => {
this.received.push(Buffer.from(c));
this.log?.({ t: "in", b64: Buffer.from(c).toString("base64") });
splitter.push(c);
});
input.on("error", () => {});
output.on?.("error", () => {});
}
// ---- test surface -----------------------------------------------------
bytes() {
return Buffer.concat(this.received);
}
// The script for the next run a prompt starts (FIFO). Steps: {text, stop,
// errorMessage}, {pause: name}, {tool: {id, name, args, result, isError,
// hold}}, {emit: object}, {raw: string}, {continue: true} (another
// agent_start … agent_end pair, as a retry would), {hang: true} (ignores
// abort). A first step {failBeforeUser: text, raw?} ends the run in a
// failure message before any user message.
//
// Pause points (`arm`): "843", "895", "915", "run-start" (after the ack),
// "after-start" (after agent_start), "clear", "clear-response", "abort",
// "state" (holds a get_state reply), a step's {pause} name and
// `tool:<id>`. An extension prompt's points carry an `ext:` prefix.
script(steps) {
this.scripts.push(steps);
}
// How the next RPC prompt's preflight goes: {handled: true} (an input
// handler takes it: ack, no run), {error: message, at: "843"|"895"|"915"}.
plan(p) {
this.plans.push(p);
}
// Leaves the next `n` commands of `type` unanswered.
dropResponse(type, n = 1) {
this.drop.set(type, (this.drop.get(type) ?? 0) + n);
}
// Answers the next `n` commands of `type` with `success: false`, as Pi's
// RPC loop does when a handler throws.
failResponse(type, n = 1) {
this.fail.set(type, (this.fail.get(type) ?? 0) + n);
}
arm(point, times = 1) {
this.armed.set(point, (this.armed.get(point) ?? 0) + times);
}
resume(point) {
const p = this.paused.get(point);
if (!p) throw new Error(`fake-pi: not paused at ${point}`);
this.paused.delete(point);
p.resolve();
}
waitPaused(point) {
if (this.paused.has(point)) return Promise.resolve();
return new Promise((resolve) => this.pauseWaiters.push({ point, resolve }));
}
waitIdle() {
if (!this.run) return Promise.resolve();
return new Promise((resolve) => this.idleWaiters.push(resolve));
}
// An unsealed extension's queueing. steer and followUp go into Pi's
// session queues and emit queue_update; "agent" is a custom message queued
// straight into the agent (no event, never returned by clear); "nextTurn"
// waits for the next prompt.
queue(kind, text) {
const msg = { role: kind === "steer" || kind === "followUp" ? "user" : "custom", text };
if (kind === "steer") this.steering.push(msg);
else if (kind === "followUp") this.followUp.push(msg);
else if (kind === "agent") this.agentQueue.push(msg);
else if (kind === "nextTurn") this.nextTurn.push(msg);
else throw new Error(`fake-pi: unknown queue ${kind}`);
if (kind === "steer" || kind === "followUp") this.#queueUpdate();
}
// An extension prompt. With preflight it goes through the prompt path (as
// pi.sendUserMessage would) with points named `ext:<point>`; without, it is
// a sendCustomMessage triggerTurn, which starts a run with no preflight
// (agent-session.js 1120–1121).
// `steps` is the extension run's script (default DEFAULT_SCRIPT). A
// triggerTurn while a run streams is queued straight into the agent
// (lines 1112–1118) and gives no event.
extensionPrompt({ text = "extension", preflight = true, plan = {}, steps = DEFAULT_SCRIPT } = {}) {
if (preflight) {
this.extPlans.push(plan);
return this.#prompt(null, text, "ext", steps);
}
if (this.run) {
this.agentQueue.push({ role: "custom", text });
return Promise.resolve({ queued: true });
}
return this.#runAgent({ role: "custom", text }, "ext", steps);
}
// Holds session appends after message_end until `flushPersist()`, so a
// page can be read between an event and its entry (E4).
holdPersist() {
this.persistHeld = true;
}
flushPersist() {
this.persistHeld = false;
for (const m of this.unpersisted.splice(0)) this.#persist(m);
}
dialog(method) {
this.#out({ type: "extension_ui_request", id: `ui-${++this.uiSerial}`, method, title: `fake ${method}` });
}
emit(value) {
this.#out(value);
}
raw(text) {
this.sent.push(text);
this.log?.({ t: "out", line: text });
if (!this.closed) this.output.write(text);
}
end() {
this.closed = true;
this.output.end?.();
}
// ---- protocol ----------------------------------------------------------
#out(value) {
const line = encodeLine(value);
this.raw(line);
}
#respond(id, command, success, data, error) {
const v = { id, type: "response", command, success };
if (success && data !== undefined) v.data = data;
if (!success) v.error = error;
this.#out(v);
}
async #point(name, run = null) {
const n = this.armed.get(name) ?? 0;
if (n <= 0) return;
this.armed.set(name, n - 1);
await new Promise((resolve) => {
this.paused.set(name, { resolve, run });
for (const w of this.pauseWaiters.filter((x) => x.point === name)) w.resolve();
this.pauseWaiters = this.pauseWaiters.filter((x) => x.point !== name);
this.onPaused?.(name);
});
}
#command(line) {
const parsed = parseLine(line);
if (parsed.error) return;
const cmd = parsed.value;
this.commands.push(cmd);
this.log?.({ t: "cmd", cmd: { id: cmd.id, type: cmd.type } });
const drop = this.drop.get(cmd.type) ?? 0;
if (drop > 0) {
this.drop.set(cmd.type, drop - 1);
return;
}
const fail = this.fail.get(cmd.type) ?? 0;
if (fail > 0) {
this.fail.set(cmd.type, fail - 1);
return this.#respond(cmd.id, cmd.type, false, undefined, "fake-pi: scripted failure");
}
switch (cmd.type) {
case "prompt":
if (typeof cmd.message !== "string") return this.#respond(cmd.id, "prompt", false, undefined, "message required");
void this.#prompt(cmd.id, cmd.message, null);
return;
case "abort":
void this.#abort().then(() => this.#respond(cmd.id, "abort", true));
return;
case "clear_queue":
void this.#clear(cmd.id);
return;
case "get_state": {
// Armed "state" holds the reply; the state is read when it goes out.
const reply = () => this.#respond(cmd.id, "get_state", true, {
isStreaming: this.streaming, isCompacting: false, sessionFile: this.sessionFile, sessionId: this.sessionId,
pendingMessageCount: this.steering.length + this.followUp.length, messageCount: this.appends.length,
});
if ((this.armed.get("state") ?? 0) > 0) {
void this.#point("state").then(reply);
return undefined;
}
return reply();
}
case "get_tree":
return this.#respond(cmd.id, "get_tree", true, { tree: [], leafId: this.leafOverride !== undefined ? this.leafOverride : this.leaf });
default:
return this.#respond(cmd.id, cmd.type, false, undefined, `Unknown command: ${cmd.type}`);
}
}
async #prompt(id, text, tag, steps) {
const plan = (tag ? this.extPlans : this.plans).shift() ?? {};
const P = (n) => this.#point(tag ? `${tag}:${n}` : n);
try {
await P("843");
if (plan.at === "843" && plan.error) throw new Error(plan.error);
if (plan.handled) {
if (id) this.#respond(id, "prompt", true);
return;
}
if (this.streaming) throw new Error(BUSY_ERROR);
await P("895");
if (plan.at === "895" && plan.error) throw new Error(plan.error);
await P("915");
if ((plan.at ?? "915") === "915" && plan.error) throw new Error(plan.error);
} catch (err) {
if (id) this.#respond(id, "prompt", false, undefined, err.message);
return;
}
if (id) this.#respond(id, "prompt", true);
await P("run-start");
await this.#runAgent({ role: "user", text }, tag, steps);
}
async #runAgent(first, tag = null, extSteps = DEFAULT_SCRIPT) {
if (this.run) {
// agent.prompt throws on a running agent; the throw is swallowed by
// the RPC prompt's catch, and the finally settles (agent-session.js
// 772–785).
this.streaming = false;
this.#out({ type: "agent_settled" });
return { collided: true };
}
const steps = tag ? extSteps : this.scripts.shift() ?? DEFAULT_SCRIPT;
const run = { id: this.runs.length + 1, aborted: false, hang: false, first: first.text };
this.runs.push(run);
this.run = run;
this.streaming = true;
try {
if (steps[0]?.failBeforeUser !== undefined) {
this.#out({ type: "agent_start" });
if (steps[0].raw !== undefined) this.raw(steps[0].raw);
else this.#assistantEnd({ text: "", stop: "error", errorMessage: steps[0].failBeforeUser });
this.#out({ type: "agent_end", messages: [] });
return run;
}
this.#out({ type: "agent_start" });
// Between agent_start and the first message (N19's window).
await this.#point(tag ? `${tag}:after-start` : "after-start", run);
const pending = [first, ...(first.role === "user" ? this.nextTurn.splice(0) : [])];
let current = steps;
for (;;) {
for (const m of pending.splice(0)) this.#message(m);
await this.#steps(run, current);
const next = this.steering.shift() ?? this.followUp.shift() ?? this.agentQueue.shift();
if (!next) break;
if (next.role === "user") this.#queueUpdate();
run.aborted = false;
pending.push(next);
current = DEFAULT_SCRIPT;
}
this.#out({ type: "agent_end", messages: [] });
return run;
} finally {
this.run = null;
this.streaming = false;
this.#out({ type: "agent_settled" });
for (const w of this.idleWaiters.splice(0)) w();
}
}
async #steps(run, steps) {
for (const s of steps) {
if (run.aborted) break;
if (s.pause) await this.#point(s.pause, run);
else if (s.hang) {
run.hang = true;
await new Promise(() => {});
} else if (s.emit) this.#out(s.emit);
else if (s.raw !== undefined) this.raw(s.raw);
else if (s.continue) {
this.#out({ type: "agent_end", messages: [] });
this.#out({ type: "agent_start" });
} else if (s.tool) await this.#tool(run, s.tool);
else if (s.text !== undefined || s.stop) {
this.#out({ type: "message_start", message: { role: "assistant", content: [] } });
if (s.text) this.#out({ type: "message_update", assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: s.text }, message: { role: "assistant" } });
if (s.final !== false) this.#assistantEnd(s);
}
}
if (run.aborted) {
this.#out({ type: "message_start", message: { role: "assistant", content: [] } });
this.#assistantEnd({ text: "", stop: "aborted", errorMessage: "Request was aborted" });
}
}
async #tool(run, t) {
const call = { type: "toolCall", id: t.id, name: t.name ?? "bash", arguments: t.args ?? {} };
this.#out({ type: "message_start", message: { role: "assistant", content: [] } });
const m = { role: "assistant", content: [call], stopReason: "toolUse" };
this.#out({ type: "message_end", message: m });
this.#persist(m);
this.#out({ type: "tool_execution_start", toolCallId: t.id, toolName: call.name, args: call.arguments });
if (t.hold) await this.#point(`tool:${t.id}`, run);
if (run.aborted) return;
const result = { content: [{ type: "text", text: t.result ?? "done" }] };
this.#out({ type: "tool_execution_end", toolCallId: t.id, toolName: call.name, result, isError: t.isError === true });
const r = { role: "toolResult", toolCallId: t.id, toolName: call.name, content: result.content, isError: t.isError === true };
this.#out({ type: "message_start", message: r });
this.#out({ type: "message_end", message: r });
this.#persist(r);
}
#message(m) {
const msg = m.role === "user" ? { role: "user", content: [{ type: "text", text: m.text }] } : { role: "custom", customType: "fake", content: [{ type: "text", text: m.text }], display: true };
this.#out({ type: "message_start", message: msg });
this.#out({ type: "message_end", message: msg });
this.#persist(msg);
}
#assistantEnd({ text = "", stop = "stop", errorMessage }) {
const m = { role: "assistant", content: text ? [{ type: "text", text }] : [], stopReason: stop };
if (errorMessage) m.errorMessage = errorMessage;
this.#out({ type: "message_end", message: m });
this.#persist(m);
}
// Pi persists on message_end (agent-session.js 386–398). These appends are
// the engine's own and are recorded so W11 can exclude them.
#persist(message) {
if (!this.append || !this.sessionFile || message.role === "custom") return;
if (this.persistHeld) return void this.unpersisted.push(message);
this.#appendEntry({ type: "message", message });
}
#appendEntry(fields) {
const id = entryId();
const { type, ...rest } = fields;
const entry = { type, id, parentId: this.leaf, timestamp: this.now().toISOString(), ...rest };
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
this.appends.push(id);
this.leaf = id;
}
#queueUpdate() {
this.#out({ type: "queue_update", steering: this.steering.map((m) => m.text), followUp: this.followUp.map((m) => m.text) });
}
async #clear(id) {
await this.#point("clear");
const steering = this.steering.splice(0).map((m) => m.text);
const followUp = this.followUp.splice(0).map((m) => m.text);
this.agentQueue.length = 0;
this.#queueUpdate();
await this.#point("clear-response");
this.#respond(id, "clear_queue", true, { steering, followUp });
}
async #abort() {
await this.#point("abort");
const run = this.run;
if (!run) return;
if (!run.hang) {
run.aborted = true;
for (const [name, p] of [...this.paused]) {
if (p.run === run) {
this.paused.delete(name);
p.resolve();
}
}
}
await this.waitIdle();
}
}
// In-process launcher: the controller gets the fake's streams. Its kind is
// "fake", so no signal ever reaches a real process. Its `forceStop` is a
// fixture stand-in for the shim: the K fixtures prove real scopes.
export class FakeLauncher {
constructor({ leaf, append = true, stdinFailAfter = null, onEngine = null, stopOutcome = "proven" } = {}) {
this.kind = "fake";
this.stopOutcome = stopOutcome;
this.opts = { leaf, append };
this.stdinFailAfter = stdinFailAfter;
this.onEngine = onEngine;
this.engines = [];
this.launches = [];
}
async launch({ unitName, command, args, cwd }) {
this.launches.push({ unitName, command, args, cwd });
const toEngine = new PassThrough();
const fromEngine = new PassThrough();
const stderr = new PassThrough();
// H19: the pipe takes `gate.left` more bytes, then fails mid-line. A
// test may set `engine.stdinGate.left` after the preflight commands.
const gate = { left: this.stdinFailAfter };
const stdin = new Writable({
write(chunk, _enc, cb) {
if (gate.left === null) {
toEngine.write(chunk);
return cb();
}
if (gate.left <= 0) return cb(Object.assign(new Error("EPIPE"), { code: "EPIPE" }));
const take = chunk.subarray(0, gate.left);
gate.left -= take.length;
toEngine.write(take);
if (take.length < chunk.length) return cb(Object.assign(new Error("EPIPE"), { code: "EPIPE" }));
return cb();
},
final(cb) {
toEngine.end();
cb();
},
});
const piArgs = args.slice(args.indexOf("--mode"));
const engine = new FakePi({ input: toEngine, output: fromEngine, argv: piArgs, ...this.opts });
engine.stdinGate = gate;
this.engines.push(engine);
this.onEngine?.(engine);
let exit;
const exited = new Promise((r) => (exit = r));
engine.kill = () => {
engine.end();
exit({ code: null, signal: "SIGKILL" });
};
const pid = 4194304 + this.engines.length;
const invocationId = `fake-inv-${this.engines.length}`;
engine.identity = { pid, invocationId, unitName };
return { kind: "fake", proc: null, stdin, stdout: fromEngine, stderr, pid, start: "1", invocationId, scope: null, shimSocket: null, exited, kill: () => engine.kill() };
}
// The fixture cohort stop. `stopOutcome` "proven" kills the fake and
// reports a complete one-member cohort; anything else is unavailable.
async forceStop({ unitName, invocationId, onPhase = async () => {} }) {
const engine = this.engines.find((e) => e.identity?.unitName === unitName && e.identity?.invocationId === invocationId);
this.stops = (this.stops ?? 0) + 1;
if (!engine) return { outcome: "unavailable", reason: "no fake engine for this unit and invocation" };
await onPhase("term");
await onPhase("kill");
if (this.stopOutcome !== "proven") return { outcome: "unavailable", reason: "fixture: cohort evidence unavailable" };
engine.kill();
const observedAt = new Date().toISOString();
return { outcome: "proven", membershipComplete: true, epoch: invocationId, observedAt, boot: "fake-boot", members: [{ pid: engine.identity.pid, boot: "fake-boot", startTicks: 1, terminatedAt: observedAt }] };
}
get last() {
return this.engines[this.engines.length - 1];
}
}
// ---- process form --------------------------------------------------------
export class ControlClient {
constructor(path) {
this.path = path;
this.serial = 0;
this.pending = new Map();
this.events = [];
this.eventWaiters = [];
}
async connect(timeoutMs = 10000) {
const end = Date.now() + timeoutMs;
for (;;) {
try {
await new Promise((resolve, reject) => {
this.sock = connect(this.path);
this.sock.once("connect", resolve);
this.sock.once("error", reject);
});
break;
} catch (err) {
if (Date.now() > end) throw err;
await new Promise((r) => setTimeout(r, 25));
}
}
const splitter = new LineSplitter((line) => {
const v = JSON.parse(line);
if (v.id !== undefined && this.pending.has(v.id)) {
this.pending.get(v.id)(v);
this.pending.delete(v.id);
} else {
this.events.push(v);
for (const w of this.eventWaiters.filter((x) => x.pred(v))) w.resolve(v);
this.eventWaiters = this.eventWaiters.filter((x) => !x.pred(v));
}
});
this.sock.on("data", (c) => splitter.push(c));
this.sock.on("error", () => {});
return this;
}
call(op, args = {}) {
const id = ++this.serial;
return new Promise((resolve) => {
this.pending.set(id, resolve);
this.sock.write(encodeLine({ id, op, ...args }));
});
}
waitEvent(pred) {
const hit = this.events.find(pred);
if (hit) return Promise.resolve(hit);
return new Promise((resolve) => this.eventWaiters.push({ pred, resolve }));
}
close() {
this.sock?.destroy();
}
}
const children = [];
// A tool child. `setsid` leaves the engine's session and process group (K1,
// K2); `forkLoop` forks every 5 ms (K12); `ignoreTerm` survives SIGTERM, so
// only the kill phase ends it (K3, K10, K11). With `pidLog`, the fork loop
// appends each child's pid and a `term` line when it gets SIGTERM (K12).
function spawnChild({ setsid = false, forkLoop = false, ignoreTerm = false, pidLog = null } = {}) {
const note = pidLog ? `const note=(s)=>require('node:fs').appendFileSync(${JSON.stringify(pidLog)},s+'\\n');` : "const note=()=>{};";
const code = note + (ignoreTerm ? "process.on('SIGTERM',()=>note('term'));" : "") + (forkLoop
? "const {spawn}=require('node:child_process');setInterval(()=>{try{const c=spawn('sleep',['1000'],{stdio:'ignore'});if(c.pid)note(String(c.pid))}catch{}},5);setInterval(()=>{},1e9)"
: "setInterval(()=>{},1e9)");
const child = spawn(process.execPath, ["-e", code], { stdio: "ignore", detached: setsid });
children.push(child.pid);
return child.pid;
}
// K13: a member writes its own pid to another cgroup's cgroup.procs.
function escape(target) {
try {
writeFileSync(target, String(process.pid));
return { escaped: true };
} catch (err) {
return { escaped: false, code: err.code ?? String(err.message) };
}
}
async function main() {
const argv = process.argv.slice(2);
const logPath = process.env.FAKE_PI_LOG ?? null;
const log = logPath ? (v) => appendFileSync(logPath, JSON.stringify({ ...v, pid: process.pid }) + "\n") : null;
const leaf = process.env.FAKE_PI_LEAF !== undefined ? (process.env.FAKE_PI_LEAF === "null" ? null : process.env.FAKE_PI_LEAF) : undefined;
const fake = new FakePi({ input: process.stdin, output: process.stdout, argv, leaf, log });
if (process.env.FAKE_PI_SCRIPT) for (const s of JSON.parse(process.env.FAKE_PI_SCRIPT)) fake.script(s);
process.stdout.on("error", () => {});
process.stdin.on("end", () => log?.({ t: "stdin-end" }));
const controlPath = process.env.FAKE_PI_CONTROL;
if (!controlPath) return;
// A force-stopped predecessor (SIGKILL) leaves its socket file behind; the path is per-fixture.
rmSync(controlPath, { force: true });
const clients = new Set();
fake.onPaused = (point) => {
for (const c of clients) c.write(encodeLine({ event: "paused", point }));
};
createServer((sock) => {
clients.add(sock);
sock.on("close", () => clients.delete(sock));
sock.on("error", () => {});
const splitter = new LineSplitter((line) => {
const req = JSON.parse(line);
const reply = (result) => sock.write(encodeLine({ id: req.id, ok: true, result }));
const ops = {
script: () => fake.script(req.steps),
plan: () => fake.plan(req.plan),
arm: () => fake.arm(req.point, req.times ?? 1),
resume: () => fake.resume(req.point),
queue: () => fake.queue(req.kind, req.text),
dialog: () => fake.dialog(req.method),
emit: () => fake.emit(req.value),
raw: () => fake.raw(req.text),
drop: () => fake.dropResponse(req.type, req.n ?? 1),
extension: () => void fake.extensionPrompt(req.args ?? {}),
state: () => ({ streaming: fake.streaming, runs: fake.runs.length, commands: fake.commands, pid: process.pid, children, appends: fake.appends }),
child: () => ({ pid: spawnChild(req.args ?? {}) }),
escape: () => escape(req.target),
cgroup: () => readFileSync(`/proc/${req.pid ?? process.pid}/cgroup`, "utf8"),
waitPaused: () => fake.waitPaused(req.point),
// H19: stop reading stdin so the controller's write fills the pipe.
stall: () => void process.stdin.pause(),
};
if (!ops[req.op]) return sock.write(encodeLine({ id: req.id, ok: false, error: `unknown op ${req.op}` }));
try {
const out = ops[req.op]();
if (out && typeof out.then === "function") out.then(reply);
else reply(out ?? null);
} catch (err) {
sock.write(encodeLine({ id: req.id, ok: false, error: String(err.message) }));
}
});
sock.on("data", (c) => splitter.push(c));
}).listen(controlPath);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
+618
View File
@@ -0,0 +1,618 @@
// CHAT-03 §4, §7 and §9 (#1507): the slash path S1–S7, Pi dialogs P3 and the
// return flow E1–E7, on the in-process fake engine, the library client, the
// Transcript view and the mediated terminal. Every record the controllers
// produce here is checked against the CHAT-01 schema at the end.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { ConversationClient, OUTCOME_UNKNOWN } from "../src/client.mjs";
import { Transcript, RECONCILE_MARKER } from "../src/transcript.mjs";
import { Terminal, NOT_CONTROLLER, visible } from "../src/terminal.mjs";
import { LATER_LINE_SLASH_INTERPRETED, PREFIXES, TEXT_POLICY } from "../src/text-policy.mjs";
import { LineSplitter, encodeLine } from "../src/framing.mjs";
import { replyToRow } from "../../control-board/src/serve.mjs";
import { REPO, started, receiptState, cleanupAll, tick } from "./harness.mjs";
after(() => cleanupAll());
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
const PAGE = ["user: hello", "assistant: hi"];
const records = [];
async function until(pred, ms = 4000, what = "condition") {
const end = Date.now() + ms;
while (!pred()) {
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await tick(5);
}
}
// A view reads the page once when it attaches to a connected client.
const loaded = (view) => until(() => view.loads >= 1 && !view.loading, 4000, "the first page read");
const prompts = (engine) => engine.commands.filter((c) => c.type === "prompt");
// Every CHAT-01 record a fixture produced, for the schema check.
function collect(h, clients = h.clients) {
records.push(...h.ctrl.events, ...h.ctrl.requests.values());
for (const c of clients) {
records.push(...c.receipts.values());
for (const p of c.pushes) {
for (const key of ["binding", "connection", "confirmation", "stop"]) if (p[key]?.version === 2) records.push(p[key]);
}
}
}
// A client that follows the conversation through a Transcript from its first
// welcome, as a library user or the terminal would.
async function follower(h) {
const client = new ConversationClient({ socketPath: h.ctrl.socketPath });
const view = new Transcript({ client });
await client.connect();
await loaded(view);
return { client, view };
}
async function admitted(client, text) {
const r = await client.prompt(text);
assert.equal(r.outcome, "admitted", JSON.stringify(r));
return r.receipt.id;
}
// Each message exactly once: no line repeats.
function once(lines) {
assert.equal(new Set(lines).size, lines.length, `a message repeats: ${JSON.stringify(lines)}`);
}
// ---- S: the slash path ------------------------------------------------------
test("S1: `/goal x`, with leading spaces or a tab, refuses text-policy at admission; zero engine bytes", async () => {
const h = await started();
try {
for (const text of ["/goal x", " /goal x", "\t/goal x", "\n/goal x"]) {
const before = h.engine.bytes().length;
const r = await h.client.prompt(text);
assert.equal(r.refusal, TEXT_POLICY, JSON.stringify(text));
assert.equal(h.engine.bytes().length, before, `${JSON.stringify(text)} reached the engine`);
}
assert.equal(prompts(h.engine).length, 0);
collect(h);
} finally {
await h.close();
}
});
test("S2: every prefix pinned Pi interprets is refused, from the list the code uses; the rest reach the engine exactly", async () => {
const h = await started();
try {
assert.ok(PREFIXES.some((p) => p.example === "/skill:ms-unslop" && p.interpreted));
for (const p of PREFIXES) {
const before = h.engine.bytes().length;
const r = await h.client.prompt(p.example);
if (p.interpreted) {
assert.equal(r.refusal, TEXT_POLICY, p.prefix);
assert.equal(h.engine.bytes().length, before, `${p.prefix} reached the engine`);
} else {
assert.equal(r.outcome, "admitted", `${p.prefix}: ${JSON.stringify(r)}`);
await receiptState(h.client, r.receipt.id, "finished");
assert.equal(prompts(h.engine).at(-1).message, p.example);
}
}
assert.equal(prompts(h.engine).length, PREFIXES.filter((p) => !p.interpreted).length);
collect(h);
} finally {
await h.close();
}
});
test("S3: `/goal` on the second line is pinned from the source: Pi checks only index 0, so it is admitted and sent exactly", async () => {
const h = await started();
try {
const text = "please note\n/goal x";
const r = await h.client.prompt(text);
if (LATER_LINE_SLASH_INTERPRETED) {
assert.equal(r.refusal, TEXT_POLICY);
} else {
assert.equal(r.outcome, "admitted", JSON.stringify(r));
await receiptState(h.client, r.receipt.id, "finished");
assert.equal(prompts(h.engine).at(-1).message, text);
}
collect(h);
} finally {
await h.close();
}
});
test("S4: a `/` left in the composer is cleared when control transfers and returns; the next submit sends only the new text", async () => {
const h = await started({ clients: 2 });
try {
const [mine, other] = h.clients;
const term = new Terminal({ client: mine });
await term.key("/");
assert.equal(term.composer, "/");
await until(() => other.binding?.controllerGeneration === h.ctrl.binding.controllerGeneration, 2000, "the binding push");
assert.equal((await other.takeover()).outcome, "transferred");
await until(() => !mine.isController, 2000, "the transfer to reach the terminal");
assert.equal(term.composer, "", "the composer clears on transfer");
await term.key("\x14");
await until(() => mine.isController, 2000, "control back");
await term.key("hello");
await term.key("\r");
await until(() => prompts(h.engine).length === 1, 2000, "the prompt");
assert.equal(prompts(h.engine)[0].message, "hello");
const line = h.engine.bytes().toString("utf8").split("\n").find((l) => l.includes('"type":"prompt"'));
assert.equal(JSON.parse(line).message, "hello", "the engine's exact bytes");
assert.equal(term.composer, "");
await receiptState(mine, term.lastReceipt.id, "finished");
collect(h);
} finally {
await h.close();
}
});
test("S5: an observer terminal gets a paste then Enter, as send-message.sh does: not admitted: controller, nothing sent", async () => {
const h = await started({ clients: 2 });
try {
const observer = h.clients[1];
let sends = 0;
const send = observer.send.bind(observer);
observer.send = (...a) => {
if (a[0].command.operation !== "observe") sends += 1;
return send(...a);
};
const term = new Terminal({ client: observer });
const before = h.engine.bytes().length;
await term.key(`${PASTE_START}run the deploy\n/goal x${PASTE_END}`);
assert.equal(term.composer, "run the deploy\n/goal x", "a paste is literal and never submits by itself");
const r = await term.submit();
await term.key("\r");
assert.deepEqual(r, { sent: false, refusal: "controller" });
assert.equal(term.status, NOT_CONTROLLER);
assert.ok(term.frame.includes(NOT_CONTROLLER));
assert.equal(sends, 0, "the client sent nothing but its page read");
assert.equal(h.engine.bytes().length, before, "zero engine bytes");
assert.equal(h.ctrl.requests.size, 0);
collect(h);
} finally {
await h.close();
}
});
test("S6: a mediated-shaped registration (no tmux) passed to the board's replyToRow: 409 no tmux session; exec never runs", () => {
const calls = [];
const exec = (...a) => {
calls.push(a);
return { status: 0, stdout: "", stderr: "" };
};
const index = { sessions: [{ project: "proj", agent: "mediated-seat", registered: { pid: process.pid, alive: true, tmux: null, mediated: { socket: "/nonexistent/control.sock" } } }] };
const r = replyToRow({ index, key: "proj/mediated-seat", text: "hello", exec });
assert.equal(r.status, 409);
assert.match(r.body.error, /no tmux session/);
assert.equal(calls.length, 0);
});
test("S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as one JSON string; the engine receives the exact text in one record", async () => {
const h = await started();
try {
const text = `a\x1b[31mred\x1b[0m ${PASTE_START}pasted${PASTE_END} b
c
d\r\ne`;
const id = await admitted(h.client, text);
await receiptState(h.client, id, "finished");
assert.equal(prompts(h.engine).length, 1);
assert.equal(prompts(h.engine)[0].message, text);
const lines = [];
const split = new LineSplitter((l) => lines.push(l));
split.push(h.engine.bytes());
const records = lines.filter((l) => l.includes('"type":"prompt"'));
assert.equal(records.length, 1, "no record split");
assert.equal(JSON.parse(records[0]).message, text);
collect(h);
} finally {
await h.close();
}
});
// ---- P3: Pi dialogs ---------------------------------------------------------
test("P3: a Pi confirm, select, input or editor dialog is shown disabled with a reason and never answered", async () => {
const h = await started();
try {
const term = new Terminal({ client: h.client });
for (const method of ["confirm", "select", "input", "editor"]) h.engine.dialog(method);
await until(() => term.dialogs.length === 4, 2000, "four dialogs");
for (const d of term.dialogs) {
assert.equal(d.disabled, true);
assert.match(d.reason, /not answered/);
assert.ok(term.frame.some((l) => l.startsWith(`[dialog ${d.method} disabled: `)), d.method);
}
await tick(100);
assert.equal(h.engine.commands.filter((c) => c.type === "extension_ui_response").length, 0, "no response is sent");
assert.equal(h.ctrl.evidence.dialogs.length, 4);
collect(h);
} finally {
await h.close();
}
});
// ---- E: the return flow -----------------------------------------------------
test("E1: send, ack, user, toolCall, toolResult, final answer: shown once, no refresh, draft and reading position kept", async () => {
const h = await started({ clients: 2 });
try {
const lib = new Transcript({ client: h.client });
await loaded(lib);
const term = new Terminal({ client: h.clients[1], rows: 8 });
await loaded(term.transcript);
assert.equal(term.transcript.reconcile, false, "an idle engine gives a quiet cut");
await term.key("half-typed draft");
term.scroll = 1;
h.engine.script([{ tool: { id: "call-e1", name: "bash", args: { cmd: "ls" }, result: "file.txt" } }, { text: "final answer", stop: "stop" }]);
const id = await admitted(h.client, "list the files");
await receiptState(h.client, id, "finished");
await until(() => term.transcript.events.some((e) => e.type === "run-settled"), 2000, "the settle");
const want = [...PAGE, "user: list the files", 'assistant: [tool-call bash {"cmd":"ls"}]', "tool: [tool-result] file.txt", "assistant: final answer"];
assert.deepEqual(term.transcript.lines(), want);
assert.deepEqual(lib.lines(), want);
assert.equal(term.transcript.loads, 1, "no refresh");
assert.equal(lib.loads, 1, "no refresh");
assert.equal(term.composer, "half-typed draft", "the draft is kept");
assert.equal(term.scroll, 1, "the reading position is kept");
collect(h);
} finally {
await h.close();
}
});
test("E2: U+2028, U+2029 inside JSON strings and CRLF line ends each parse as one record, on the splitter and through the controller", async () => {
const lines = [];
const split = new LineSplitter((l) => lines.push(l));
const a = JSON.stringify({ type: "x", text: "one
two
three" });
const b = JSON.stringify({ type: "y", text: "crlf" });
const bytes = Buffer.from(`${a}\r\n${b}\n`);
for (let i = 0; i < bytes.length; i += 7) split.push(bytes.subarray(i, i + 7));
assert.deepEqual(lines.map((l) => JSON.parse(l).type), ["x", "y"]);
assert.equal(JSON.parse(lines[0]).text, "one
two
three");
const h = await started();
try {
const text = "line
sep
para";
h.engine.script([
{ raw: JSON.stringify({ type: "message_start", message: { role: "assistant", content: [] } }) + "\r\n" },
{ raw: JSON.stringify({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text }], stopReason: "stop" } }) + "\r\n" },
]);
const id = await admitted(h.client, "go");
await receiptState(h.client, id, "finished");
const end = h.ctrl.events.find((e) => e.type === "message-end" && e.role === "assistant");
assert.equal(end.content[0].text, text);
assert.equal(Object.keys(h.ctrl.evidence.unknownEvents).length, 0);
assert.equal(h.ctrl.evidence.dropped.lines, 0);
collect(h);
} finally {
await h.close();
}
});
// Synthetic events for the Transcript alone.
const target = { conversation: "pi-c", branch: "main", execution: "exec-1", controllerGeneration: 1 };
const ev = (sequence, type, fields = {}, streamEpoch = "E1") => ({
version: 2, kind: "event", id: `${streamEpoch}.${sequence}`, target, sequence, streamEpoch, request: null, entry: null, type, contentIndex: null,
updateMode: "none", content: [], visibility: "permitted-visible", createdAt: "2026-10-04T12:00:00.000Z", stop: null, message: null, part: null, lastPart: null, role: null, ...fields,
});
const text = (t, block = 0) => ({ type: "text", text: t, block, fragment: 0, lastFragment: true });
const end = (sequence, message, part, lastPart, content, epoch) => ev(sequence, "message-end", { message, role: "assistant", entry: `${message}.e`, part, lastPart, updateMode: "replace", content }, epoch);
test("E3: a multipart final, two blocks, null request correlation and duplicate delivery", async () => {
const v = new Transcript();
v.load([], { streamEpoch: "E1", fromSequence: 1, quiet: true });
const stream = [
ev(1, "message-start", { message: "m1", role: "assistant" }),
end(2, "m1", 0, false, [text("part zero")]),
end(3, "m1", 1, true, [text("part one", 1)]),
ev(4, "message-start", { message: "m2", role: "assistant" }),
end(5, "m2", 0, true, [text("block A", 0), text("block B", 1)]),
];
for (const e of stream) {
assert.equal(v.event(e), null);
assert.equal(v.event(structuredClone(e)), null, "an identical repeat is dropped");
}
assert.ok(stream.every((e) => e.request === null));
assert.deepEqual(v.lines(), ["assistant: part zero\npart one", "assistant: block A\nblock B"]);
assert.equal(v.reconcile, false);
assert.equal(v.event({ ...stream[4], content: [text("changed")] }), "conflict", "a repeated ID with other bytes reconciles");
assert.equal(v.reconcile, true);
// Live: a final message of 70 blocks crosses the 64-block part limit.
const h = await started();
try {
const blocks = Array.from({ length: 70 }, (_, i) => ({ type: "text", text: `b${i}` }));
h.engine.script([{ emit: { type: "message_start", message: { role: "assistant", content: [] } } }, { emit: { type: "message_end", message: { role: "assistant", content: blocks, stopReason: "stop" } } }]);
const { view } = await follower(h);
const id = await admitted(h.client, "many blocks");
await receiptState(h.client, id, "finished");
const ends = h.ctrl.events.filter((e) => e.type === "message-end" && e.role === "assistant");
assert.deepEqual(ends.map((e) => [e.part, e.lastPart]), [[0, false], [1, true]]);
await until(() => view.events.some((e) => e.type === "run-settled"), 2000, "the settle");
assert.equal(view.lines().at(-1), "assistant: " + blocks.map((b) => b.text).join("\n"));
collect(h);
} finally {
await h.close();
}
});
test("E4: a page read after message_end but before its entry is persisted: marker at the seam, re-read after run-settled, each message once", async () => {
const h = await started();
try {
h.engine.holdPersist();
h.engine.script([{ text: "one", stop: "stop" }, { pause: "p1" }, { text: "two", stop: "stop" }]);
h.engine.arm("p1");
const id = await admitted(h.client, "count");
await h.engine.waitPaused("p1");
const { client, view } = await follower(h);
assert.equal(view.seam.replay, "unavailable");
assert.equal(view.seam.quiet, false);
assert.equal(view.reconcile, true);
assert.deepEqual(view.lines(), [...PAGE, RECONCILE_MARKER], "the unpersisted messages are missing, and the seam says so");
h.engine.flushPersist();
h.engine.resume("p1");
await receiptState(h.client, id, "finished");
await until(() => view.loads >= 2 && !view.loading && !view.reconcile, 4000, "the re-read after run-settled");
assert.equal(view.lastReload, "settled");
const lines = view.lines();
assert.deepEqual(lines, [...PAGE, "user: count", "assistant: one", "assistant: two"]);
once(lines);
client.close();
collect(h);
} finally {
await h.close();
}
});
test("E4: a gap or a new epoch also reconciles; nothing is concatenated across a gap", async () => {
const entry = (id, role, t) => ({ version: 2, kind: "entry", id: `n.${id}:0`, message: id, role, content: [text(t)], part: 0, lastPart: true });
let page = { entries: [], seam: { replay: "unavailable", streamEpoch: "E1", fromSequence: 1, quiet: true } };
const listeners = new Set();
const stub = {
observes: 0,
on: (fn) => listeners.add(fn),
observe: async () => {
stub.observes += 1;
return { outcome: "observing", data: { page: { entries: page.entries, hasMore: false, nextCursor: null }, seam: page.seam } };
},
};
const push = (event) => listeners.forEach((fn) => fn({ type: "push", kind: "event", event }));
const v = new Transcript({ client: stub });
await v.reload("initial");
push(ev(1, "message-start", { message: "m1", role: "assistant" }));
push(ev(2, "text-delta", { message: "m1", role: "assistant", content: [text("hel")] }));
page = { entries: [entry("x1", "assistant", "hello world")], seam: { replay: "unavailable", streamEpoch: "E1", fromSequence: 5, quiet: true } };
push(ev(4, "text-delta", { message: "m1", role: "assistant", content: [text("WORLD")] }));
assert.equal(v.reason, "gap");
assert.ok(!v.lines().some((l) => l.includes("WORLD")), "no concatenation across the gap");
await until(() => !v.loading && !v.reconcile, 2000, "the re-read after the gap");
assert.deepEqual(v.lines(), ["assistant: hello world"]);
page = { entries: page.entries, seam: { replay: "unavailable", streamEpoch: "E2", fromSequence: 2, quiet: true } };
push(ev(1, "message-start", { message: "n1", role: "assistant" }, "E2"));
assert.equal(v.reason, "epoch");
push(ev(2, "message-start", { message: "n2", role: "assistant" }, "E2"));
push(end(3, "n2", 0, true, [text("after the epoch")], "E2"));
await until(() => !v.loading && !v.reconcile, 2000, "the re-read after the epoch");
const lines = v.lines();
assert.deepEqual(lines, ["assistant: hello world", "assistant: after the epoch"]);
once(lines);
assert.equal(stub.observes, 3, "one read per reconcile, none repeated");
});
test("E5: an unknown native event gives no client event; evidence records its type and bytes; the terminal count goes up", async () => {
const h = await started();
try {
const term = new Terminal({ client: h.client });
const events = h.client.events.length;
const line = { type: "brand_new_event", payload: "x".repeat(40) };
h.engine.emit(line);
await until(() => term.unknownCount === 1, 2000, "the count");
h.engine.emit({ type: "another_new_one" });
await until(() => term.unknownCount === 2, 2000, "the second count");
assert.equal(h.client.events.length, events, "no client event");
assert.equal(h.ctrl.evidence.unknownEvents.brand_new_event.count, 1);
assert.equal(h.ctrl.evidence.unknownEvents.brand_new_event.bytes, Buffer.byteLength(JSON.stringify(line)));
assert.match(term.frame[0], /unknown events: 2/);
collect(h);
} finally {
await h.close();
}
});
test("E6: a tool result delayed across a pause and a reconnect is reconciled without a manual refresh", async () => {
const h = await started();
try {
h.engine.script([{ tool: { id: "call-e6", name: "bash", args: {}, result: "late result", hold: true } }, { pause: "p2" }, { text: "after", stop: "stop" }]);
h.engine.arm("tool:call-e6");
h.engine.arm("p2");
const { client, view } = await follower(h);
const id = await admitted(h.client, "slow tool");
await h.engine.waitPaused("tool:call-e6");
await until(() => view.lines().some((l) => l.includes("[tool bash running]")), 2000, "the running call");
client.close();
await until(() => client.closed, 2000, "the disconnect");
h.engine.resume("tool:call-e6");
await h.engine.waitPaused("p2");
await client.connect();
await until(() => view.loads >= 2 && !view.loading, 2000, "the read on reconnect");
h.engine.resume("p2");
await receiptState(h.client, id, "finished");
await until(() => view.lastReload === "settled" && !view.loading && !view.reconcile, 4000, "the settled re-read");
const lines = view.lines();
assert.deepEqual(lines, [...PAGE, "user: slow tool", "assistant: [tool-call bash {}]", "tool: [tool-result] late result", "assistant: after"]);
once(lines);
assert.notEqual(view.lastReload, "manual");
client.close();
collect(h);
} finally {
await h.close();
}
});
test("E7: the terminal renders the same stream as the library client, as observer and then as controller, and submits only as controller", async () => {
const h = await started({ clients: 2 });
try {
const lib = new Transcript({ client: h.client });
await loaded(lib);
const term = new Terminal({ client: h.clients[1], rows: 40 });
await loaded(term.transcript);
const body = () => term.frame.slice(1, 1 + term.transcript.lines().join("\n").split("\n").length);
const id = await admitted(h.client, "first");
await receiptState(h.client, id, "finished");
await until(() => term.transcript.events.some((e) => e.type === "run-settled"), 2000, "settle at the terminal");
assert.deepEqual(term.transcript.lines(), lib.lines());
term.render();
assert.deepEqual(body(), lib.lines().join("\n").split("\n").map(visible));
await term.key("blocked\r");
assert.equal(term.status, NOT_CONTROLLER);
assert.equal(prompts(h.engine).length, 1);
term.composer = "";
await term.key("\x14");
await until(() => h.clients[1].isController, 2000, "terminal control");
await term.key("second\r");
await until(() => prompts(h.engine).length === 2, 2000, "the terminal's prompt");
assert.equal(prompts(h.engine)[1].message, "second");
await receiptState(h.client, term.lastReceipt.id, "finished");
await until(() => term.transcript.events.filter((e) => e.type === "run-settled").length === 2 && lib.events.filter((e) => e.type === "run-settled").length === 2, 2000, "both settles");
assert.deepEqual(term.transcript.lines(), lib.lines());
assert.deepEqual(term.transcript.lines().slice(-2), ["user: second", "assistant: ok"]);
collect(h);
} finally {
await h.close();
}
});
test("terminal: engine control characters are made visible; a lost connection refuses submit", async () => {
const h = await started();
try {
const term = new Terminal({ client: h.client });
h.engine.script([{ text: "\x1b]52;c;ZXZpbA==\x07 bell‮", stop: "stop" }]);
await term.key("go\r");
await receiptState(h.client, term.lastReceipt.id, "finished");
await until(() => term.transcript.lines().some((l) => l.includes("bell")), 2000, "the reply");
const shown = term.frame.find((l) => l.includes("bell"));
assert.equal(shown, "assistant: ^[]52;c;ZXZpbA==^G bell<U+202E>");
assert.ok(!term.frame.join("").includes("\x1b"), "no raw ESC in the frame");
h.client.close();
await until(() => h.client.closed, 2000, "the disconnect");
assert.match(term.status, /disconnected/);
term.composer = "x";
const r = await term.submit();
assert.equal(r.reply.refusal, "channel");
assert.equal(prompts(h.engine).length, 1);
} finally {
await h.close();
}
});
test("terminal: outcome unknown is shown as such, with no resend offer, and nothing is resent", async () => {
const listeners = new Set();
let calls = 0;
const stub = {
closed: false, connection: { id: "conn-1" }, binding: { state: "active", controllerConnection: "conn-1" }, isController: true,
on: (fn) => listeners.add(fn),
observe: async () => ({ outcome: "refused:channel", refusal: "channel" }),
prompt: async () => {
calls += 1;
return { outcome: "outcome-unknown", refusal: null, display: OUTCOME_UNKNOWN, reason: "disconnected", request: null, receipt: null };
},
};
const term = new Terminal({ client: stub });
await term.key("held\r");
assert.equal(term.status, `prompt: ${OUTCOME_UNKNOWN}`);
for (const fn of listeners) fn({ type: "outcome-unknown", request: "req-1", operation: "prompt", reason: "disconnected", display: OUTCOME_UNKNOWN });
for (const fn of listeners) fn({ type: "push", kind: "receipt", receipt: { id: "receipt-1", state: "admitted" }, outcomeUnknown: true });
assert.ok(term.notices.includes(`prompt: ${OUTCOME_UNKNOWN}`));
assert.ok(term.notices.includes(`prompt receipt-1: ${OUTCOME_UNKNOWN}`));
await term.key("\r");
assert.equal(calls, 1, "an empty Enter sends nothing; nothing is resent");
assert.equal(term.composer, "");
assert.ok(!term.frame.some((l) => /resend|retry/i.test(l)));
});
// A controller-side stub that records every prompt.
function promptStub() {
const sent = [];
const stub = {
closed: false, connection: { id: "conn-1" }, binding: { state: "active", controllerConnection: "conn-1" }, isController: true,
on: () => {},
observe: async () => ({ outcome: "refused:channel", refusal: "channel" }),
prompt: async (text) => (sent.push(text), { outcome: "admitted", refusal: null, receipt: { id: `receipt-${sent.length}`, state: "admitted" } }),
};
return { stub, sent };
}
test("terminal: text after Enter in the same input chunk starts the next message; it never joins the one submitted", async () => {
{
const { stub, sent } = promptStub();
const term = new Terminal({ client: stub });
await term.key("first\rsecond\r");
assert.deepEqual(sent, ["first", "second"]);
assert.equal(term.composer, "");
}
{
const { stub, sent } = promptStub();
const term = new Terminal({ client: stub });
await term.key("abc\rdef");
assert.deepEqual(sent, ["abc"]);
assert.equal(term.composer, "def", "the rest waits in the composer");
}
});
test("terminal: a paste-start marker split right after its ESC still opens the paste; the Enter inside it never submits", async () => {
for (const cut of [1, 2, 3, 4, 5]) {
const { stub, sent } = promptStub();
const term = new Terminal({ client: stub });
const input = `${PASTE_START}a\rb${PASTE_END}`;
await term.key(input.slice(0, cut));
await term.key(input.slice(cut));
assert.deepEqual(sent, [], `cut ${cut}: nothing sent`);
assert.equal(term.composer, "a\rb", `cut ${cut}: the paste is literal`);
}
// A lone Escape has no action: it waits, then is consumed with what follows.
const { stub, sent } = promptStub();
const term = new Terminal({ client: stub });
await term.key("x\x1b");
await term.key("\r");
assert.deepEqual(sent, [], "ESC then CR is Alt-Enter, a newline");
assert.equal(term.composer, "x\n");
});
test("terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line", () => {
assert.equal(visible("a\u061cb\u200bc\u2060d\ufeffe\u{e0041}f"), "a<U+061C>b<U+200B>c<U+2060>d<U+FEFF>e<U+E0041>f");
assert.equal(visible("\u{1F469}\u200d\u{1F4BB}"), "\u{1F469}\u200d\u{1F4BB}", "ZWJ sequences pass");
const { stub } = promptStub();
const term = new Terminal({ client: stub });
term.status = "one\ntwo";
term.notices.push("three\nfour");
term.render();
assert.ok(term.frame.includes("one^Jtwo"), "status");
assert.ok(term.frame.includes("three^Jfour"), "notice");
});
test("every record these fixtures produced is a valid CHAT-01 record (E5: no record fails the schema)", () => {
assert.ok(records.length > 100, `only ${records.length} records`);
const kinds = [...new Set(records.map((r) => r.kind))].sort();
for (const k of ["binding", "connection", "event", "receipt", "request"]) assert.ok(kinds.includes(k), `no ${k} records`);
const script = `
import json, sys
from jsonschema import Draft202012Validator, FormatChecker
s = json.load(open(sys.argv[1]))
bad = []
for i, r in enumerate(json.load(sys.stdin)):
v = Draft202012Validator({"$defs": s["$defs"], "$ref": "#/$defs/" + r["kind"]}, format_checker=FormatChecker())
for e in v.iter_errors(r):
bad.append(f"{i} {r['kind']}: {e.message[:200]}")
break
print(json.dumps(bad))
`;
const run = spawnSync("python3", ["-c", script, join(REPO, "docs", "plans", "chat-01", "contracts.schema.json")], { input: JSON.stringify(records), encoding: "utf8", maxBuffer: 1 << 30 });
assert.equal(run.status, 0, run.stderr);
assert.deepEqual(JSON.parse(run.stdout), [], `kinds checked: ${kinds.join(", ")}`);
});
+219
View File
@@ -0,0 +1,219 @@
// Shared CHAT-03 fixture setup (#1507). Every fixture lives in its own temp
// directory: fixture root, project, session file, claim root and socket
// directory. Nothing here touches a live session.
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { Controller } from "../src/controller.mjs";
import { ConversationClient } from "../src/client.mjs";
import { AUTHORITY } from "../src/cohort.mjs";
import { FixtureVerifier } from "../src/records.mjs";
import { FakeLauncher } from "./fake-pi.mjs";
export const REPO = resolve(import.meta.dirname, "..", "..", "..");
export const SESSION_ID = "0f5e1c2a-1111-4222-8333-944455556666";
export const FAST = Object.freeze({ ack: 1500, state: 1500, start: 1500, clear: 1500, abort: 3000, settle: 3000, grace: 50, write: 1500, maxRounds: 3 });
export const time = (i) => new Date(Date.UTC(2026, 9, 4, 12, 0, 0) + i * 1000).toISOString();
const tmp = mkdtempSync(join(tmpdir(), "chat03-"));
let serial = 0;
export function cleanupAll() {
spawnSync("chmod", ["-R", "u+rwx", tmp]);
rmSync(tmp, { recursive: true, force: true });
}
export const header = (cwd) => ({ type: "session", version: 3, id: SESSION_ID, timestamp: time(0), cwd });
export const userEntry = (id, parentId, text, i = 1) => ({ type: "message", id, parentId, timestamp: time(i), message: { role: "user", content: [{ type: "text", text }] } });
// Pi writes this when it creates a session (sdk.js 247–251), so a session Pi
// created and prompted carries one. Pi appends at startup to a session with
// messages and no thinking entry, and to any session with no messages.
export const thinkingEntry = (id = "f0e1d2c3", parentId = null) => ({ type: "thinking_level_change", id, parentId, timestamp: time(0), thinkingLevel: "off" });
export const assistantEntry = (id, parentId, text, i = 2) => ({ type: "message", id, parentId, timestamp: time(i), message: { role: "assistant", content: [{ type: "text", text }], stopReason: "stop" } });
// A fixture tree with one session file. `entries` default to one exchange.
export function fixture({ seat = "fixture-seat", name = "s1.jsonl", entries } = {}) {
const base = join(tmp, `f${++serial}`);
const proj = join(base, "proj");
const sessions = join(proj, ".pi", "state", seat, "sessions");
mkdirSync(sessions, { recursive: true });
const sessionFile = join(sessions, name);
const list = entries ?? [thinkingEntry(), userEntry("a1b2c3d4", "f0e1d2c3", "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
writeFileSync(sessionFile, [header(proj), ...list].map((v) => JSON.stringify(v) + "\n").join(""));
return { base, proj, sessions, sessionFile, seat, claimRoot: join(base, "claims"), socketDir: join(base, "sock") };
}
export const noUnits = Object.freeze({ lookup: async () => ({ state: "absent" }) });
// A controller on the in-process fake engine.
export function controllerFor(fx, { launcher = new FakeLauncher(), verifier = new FixtureVerifier({ authorities: [AUTHORITY] }), timeouts = FAST, ...opts } = {}) {
const ctrl = new Controller({
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
launcher, verifier, units: noUnits, timeouts, ...opts,
});
return { ctrl, launcher, verifier };
}
// Starts a controller and connects `n` clients; the first takes control.
export async function started(opts = {}) {
const fx = opts.fx ?? fixture(opts.fixture);
const { ctrl, launcher, verifier } = controllerFor(fx, opts);
const started = await ctrl.start();
const clients = [];
for (let i = 0; i < (opts.clients ?? 1); i++) {
const c = new ConversationClient({ socketPath: ctrl.socketPath });
await c.connect();
clients.push(c);
}
if (opts.control !== false && clients[0]) {
const r = await clients[0].takeover();
if (r.outcome !== "transferred") throw new Error(`takeover: ${JSON.stringify(r)}`);
}
const close = async () => {
for (const c of clients) c.close();
await ctrl.close({ killEngine: true });
};
return { fx, ctrl, launcher, verifier, started, clients, client: clients[0], engine: launcher.last, close };
}
// Waits for a receipt to reach one of `states` (or outcome unknown).
export async function receiptState(client, receiptId, states, ms = 4000) {
const want = new Set(Array.isArray(states) ? states : [states]);
const ok = await client.waitFor(() => {
const r = client.receipt(receiptId);
return r && want.has(r.state);
}, ms);
if (!ok) throw new Error(`receipt ${receiptId} stayed ${client.receipt(receiptId)?.state}; wanted ${[...want]}`);
return client.receipt(receiptId);
}
export function outcomeUnknownPush(client, receiptId) {
return client.pushes.find((p) => p.kind === "receipt" && p.receipt.id === receiptId && p.outcomeUnknown === true) ?? null;
}
export const sessionText = (fx) => readFileSync(fx.sessionFile, "utf8");
export const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
export { tmp };
// ---- controller in a child process -----------------------------------------
import { spawn } from "node:child_process";
const CHILD = join(import.meta.dirname, "ctrl-child.mjs");
const childPids = new Set();
export function killChildren() {
for (const pid of childPids) {
try {
process.kill(pid, "SIGKILL");
} catch {
// gone
}
}
childPids.clear();
}
// Spawns ctrl-child.mjs. `msgs` collects its JSON lines; `next(pred)` waits
// for one.
export function spawnController(cfg) {
const proc = spawn(process.execPath, [CHILD, JSON.stringify({ timeouts: FAST, ...cfg })], { stdio: ["pipe", "pipe", "pipe"] });
childPids.add(proc.pid);
const msgs = [];
const waiters = new Set();
let buf = "";
let stderr = "";
proc.stderr.on("data", (c) => (stderr += c));
proc.stdout.on("data", (c) => {
buf += c;
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
try {
msgs.push(JSON.parse(line));
} catch {
msgs.push({ raw: line });
}
for (const w of [...waiters]) w();
}
});
const exited = new Promise((r) => proc.on("exit", (code, signal) => {
childPids.delete(proc.pid);
r({ code, signal });
for (const w of [...waiters]) w();
}));
let done = false;
exited.then(() => (done = true));
const next = (pred, ms = 8000) => new Promise((resolve, reject) => {
const check = () => {
const hit = msgs.find(pred);
if (hit) {
waiters.delete(check);
clearTimeout(t);
resolve(hit);
} else if (done) {
waiters.delete(check);
clearTimeout(t);
reject(new Error(`controller child exited; lines ${JSON.stringify(msgs)} stderr ${stderr.slice(-2000)}`));
}
};
const t = setTimeout(() => {
waiters.delete(check);
reject(new Error(`timeout; lines ${JSON.stringify(msgs)} stderr ${stderr.slice(-2000)}`));
}, ms);
waiters.add(check);
check();
});
return { proc, msgs, next, exited, send: (line) => proc.stdin.write(line + "\n"), stderr: () => stderr };
}
// Stops every scope and kills every pgroup engine a fixture's claim records
// name, so a crashed controller leaves nothing running.
import { readdirSync as lsdir } from "node:fs";
import { processStart } from "../../discord/src/journal.mjs";
export function claimRecords(fx) {
const out = [];
const walk = (dir) => {
let names = [];
try {
names = lsdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const d of names) {
const p = join(dir, d.name);
if (d.isDirectory()) walk(p);
else if (/^r\d{10}\.json$/.test(d.name)) {
try {
out.push({ path: p, record: JSON.parse(readFileSync(p, "utf8")) });
} catch {
out.push({ path: p, record: null });
}
}
}
};
walk(fx.claimRoot);
return out;
}
export function reap(fx) {
const units = new Set(), engines = new Map();
for (const { record } of claimRecords(fx)) {
if (!record) continue;
if (record.unitName && record.spawnMarker) units.add(record.unitName);
if (record.engine?.kind === "pgroup" && record.engine.pid) engines.set(record.engine.pid, record.engine.start);
}
for (const u of units) spawnSync("systemctl", ["--user", "kill", "--signal=SIGKILL", `${u}.scope`], { stdio: "ignore", timeout: 5000 });
for (const u of units) spawnSync("systemctl", ["--user", "stop", `${u}.scope`], { stdio: "ignore", timeout: 5000 });
for (const [pid, start] of engines) {
if (processStart(pid) !== start) continue;
try {
process.kill(-pid, "SIGKILL");
} catch {
// gone
}
}
}
+872
View File
@@ -0,0 +1,872 @@
// CHAT-03 §5 control races (#1507): H1–H4 and H9–H23. H5–H8 are approval
// races on Claude permission requests and run in I4. Each race lands at an
// exact step through the controller's barriers or the fake engine's pause
// points, and asserts the engine bytes, the receipts and the events.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { PassThrough, Writable } from "node:stream";
import { ALREADY_ACTIVE } from "../src/claim.mjs";
import { ConversationClient, OUTCOME_UNKNOWN } from "../src/client.mjs";
import { BUSY, NO_TURN, STALE_INCARNATION, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
import { EngineLink } from "../src/engine.mjs";
import { newId } from "../src/records.mjs";
import { ControlClient, FakeLauncher } from "./fake-pi.mjs";
import { scopeAvailable } from "../src/cohort.mjs";
import { controllerFor, started, receiptState, spawnController, killChildren, cleanupAll, reap, fixture, tick } from "./harness.mjs";
const reaped = [];
after(() => {
for (const fx of reaped) reap(fx);
killChildren();
cleanupAll();
});
const track = (fx) => (reaped.push(fx), fx);
const SCOPE = scopeAvailable();
async function until(pred, ms = 4000, what = "condition") {
const end = Date.now() + ms;
while (!pred()) {
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await tick(5);
}
}
// Holds the controller at named barriers. `hold(name)` holds the next
// occurrence; `release(name)` lets it go.
function gate() {
const want = new Set(), held = new Map();
return {
barrier: async (name) => {
if (!want.has(name)) return;
want.delete(name);
await new Promise((r) => held.set(name, r));
},
hold: (name) => want.add(name),
waitHeld: (name) => until(() => held.has(name), 4000, `barrier ${name}`),
release: (name) => {
const r = held.get(name);
held.delete(name);
r?.();
},
};
}
const cmds = (engine, type) => engine.commands.filter((c) => c.type === type);
const draft = () => ({ draft: newId("draft"), draftRevision: 1 });
const promptEnvelope = (c) => c.envelope("prompt", draft());
async function confirm(c, operation) {
const issued = await c.request("issue-confirmation", { operationToConfirm: operation });
assert.equal(issued.outcome, "confirmation-issued", JSON.stringify(issued));
const id = issued.data.confirmation.id;
const answered = await c.request("answer-confirmation", { confirmation: id, answer: "confirm" });
assert.equal(answered.outcome, "confirmation-confirmed", JSON.stringify(answered));
return id;
}
async function heldRun(h, c = h.client) {
h.engine.script([{ pause: "p1" }, { text: "never", stop: "stop" }]);
h.engine.arm("p1");
const r = await c.prompt("long turn");
assert.equal(r.outcome, "admitted");
await receiptState(c, r.receipt.id, "working");
await h.engine.waitPaused("p1");
return r.receipt.id;
}
const waitState = (h, state, ms = 6000) => until(() => h.ctrl.binding.state === state, ms, `binding ${state}`);
const synced = (h, ...cs) => until(() => cs.every((c) => c.binding?.controllerGeneration === h.ctrl.binding.controllerGeneration), 2000, "binding pushes");
test("H1: two takeovers with the same expected generation: one wins, +1; the other refuses generation", async () => {
const h = await started({ clients: 3 });
try {
const [, c2, c3] = h.clients;
const gen = h.ctrl.binding.controllerGeneration;
await synced(h, c2, c3);
const before = h.engine.bytes().length;
const [r2, r3] = await Promise.all([c2.send(c2.envelope("takeover")), c3.send(c3.envelope("takeover"))]);
const outcomes = [r2.outcome, r3.outcome].sort();
assert.deepEqual(outcomes, ["refused:generation", "transferred"]);
assert.equal(h.ctrl.binding.controllerGeneration, gen + 1);
const winner = r2.outcome === "transferred" ? c2 : c3;
assert.equal(h.ctrl.binding.controllerConnection, winner.connection.id);
assert.equal(h.engine.bytes().length, before);
} finally {
await h.close();
}
});
test("H2: the old controller's prompt after a takeover commits is refused with zero engine bytes", async () => {
const h = await started({ clients: 2 });
try {
const [c1, c2] = h.clients;
await synced(h, c2);
const stale = promptEnvelope(c1);
assert.equal((await c2.takeover()).outcome, "transferred");
const before = h.engine.bytes().length;
const r = await c1.send(stale, "late");
assert.equal(r.refusal, "generation");
await synced(h, c1);
const fresh = await c1.prompt("late, current generation");
assert.equal(fresh.refusal, "controller");
await tick(50);
assert.equal(h.engine.bytes().length, before);
assert.equal(h.ctrl.receipts.size, 0);
} finally {
await h.close();
}
});
test("H3: a takeover while a prompt holds the dispatch lock: written under the old actor, or refused; never both", async () => {
// The prompt holds the lock at its recheck: the takeover waits, the write
// completes first, and the receipt keeps the old controller.
{
const g = gate();
const h = await started({ clients: 2, barrier: g.barrier });
try {
const [c1, c2] = h.clients;
await synced(h, c2);
g.hold("before-recheck");
const p = await c1.prompt("held at the lock");
await g.waitHeld("before-recheck");
let took = null;
const t = c2.takeover().then((r) => (took = r));
await tick(50);
assert.equal(took, null, "the takeover waits for the dispatch lock");
g.release("before-recheck");
await t;
assert.equal(took.outcome, "transferred");
const r = await receiptState(c1, p.receipt.id, "finished");
assert.equal(r.state, "finished");
const req = h.ctrl.requests.get(p.receipt.request);
assert.equal(req.connection, c1.connection.id, "the receipt keeps the old actor's request");
assert.equal(cmds(h.engine, "prompt").length, 1);
} finally {
await h.close();
}
}
// The takeover commits before the prompt reaches the lock: the recheck
// refuses it and nothing is written.
{
const g = gate();
const h = await started({ clients: 2, barrier: g.barrier });
try {
const [c1, c2] = h.clients;
await synced(h, c2);
const before = h.engine.bytes().length;
g.hold("admitted");
const p = await c1.prompt("refused at recheck");
await g.waitHeld("admitted");
assert.equal((await c2.takeover()).outcome, "transferred");
g.release("admitted");
const r = await receiptState(c1, p.receipt.id, "dispatch-refused");
assert.ok(["controller", "generation"].includes(r.reasonCode), r.reasonCode);
assert.equal(h.engine.bytes().length, before, "never dispatched under either controller");
} finally {
await h.close();
}
}
// Control leaves and comes back (A, B, A) while the prompt waits: the
// controller matches again, so only the generation check refuses it.
{
const g = gate();
const h = await started({ clients: 2, barrier: g.barrier });
try {
const [c1, c2] = h.clients;
await synced(h, c2);
const before = h.engine.bytes().length;
g.hold("admitted");
const p = await c1.prompt("refused after A, B, A");
await g.waitHeld("admitted");
assert.equal((await c2.takeover()).outcome, "transferred");
await synced(h, c1);
assert.equal((await c1.takeover()).outcome, "transferred");
assert.equal(h.ctrl.binding.controllerConnection, c1.connection.id);
g.release("admitted");
const r = await receiptState(c1, p.receipt.id, "dispatch-refused");
assert.equal(r.reasonCode, "generation");
assert.equal(h.engine.bytes().length, before);
} finally {
await h.close();
}
}
});
test("H4: self-takeover is refused", async () => {
const h = await started();
try {
const gen = h.ctrl.binding.controllerGeneration;
const r = await h.client.takeover();
assert.equal(r.refusal, "already-controller");
assert.equal(h.ctrl.binding.controllerGeneration, gen);
} finally {
await h.close();
}
});
test("H9: Interrupt racing a prompt's dispatch: before the write, dispatch-refused and no-turn; after, §3 rules", async () => {
{
const g = gate();
const h = await started({ barrier: g.barrier });
try {
const before = h.engine.bytes().length;
g.hold("admitted");
const p = await h.client.prompt("never written");
await g.waitHeld("admitted");
const i = await h.client.interrupt();
assert.equal(i.refusal, NO_TURN);
assert.deepEqual(i.effect, { dispatchRefused: p.receipt.id });
g.release("admitted");
const r = await receiptState(h.client, p.receipt.id, "dispatch-refused");
assert.equal(r.reasonCode, "fenced");
await tick(50);
assert.equal(h.engine.bytes().length, before, "no engine bytes");
assert.equal(h.ctrl.stops.size, 0, "no stop record");
assert.equal(h.ctrl.binding.admission, "open");
} finally {
await h.close();
}
}
{
const g = gate();
const h = await started({ barrier: g.barrier });
try {
h.engine.script([{ pause: "p1" }, { text: "never" }]);
h.engine.arm("p1");
g.hold("written");
const p = await h.client.prompt("written first");
await g.waitHeld("written");
const i = await h.client.interrupt();
assert.equal(i.outcome, "interrupt-fenced");
g.release("written");
await until(() => h.ctrl.events.some((e) => e.type === "reconciled" && e.stop === i.stop.id), 6000, "reconciled");
const r = await receiptState(h.client, p.receipt.id, "failed");
assert.equal(r.reasonCode, "interrupted");
assert.equal(cmds(h.engine, "prompt").length, 1);
} finally {
await h.close();
}
}
});
test("H10: Interrupt and force stop together: one stop chain, force stop supersedes", async () => {
// Force stop lands while the Interrupt's exchange is under way.
{
const g = gate();
const h = await started({ barrier: g.barrier });
try {
await heldRun(h);
g.hold("before-abort");
const i = await h.client.interrupt();
assert.equal(i.outcome, "interrupt-fenced");
await g.waitHeld("before-abort");
await synced(h, h.client);
const fs = await h.client.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced", JSON.stringify(fs));
g.release("before-abort");
await waitState(h, "stopped");
const istop = h.ctrl.stops.get(i.stop.id), fstop = h.ctrl.stops.get(fs.stop.id);
assert.equal(istop.state, "superseded");
assert.equal(fstop.supersedes, istop.id);
assert.equal(fstop.state, "stopped");
assert.equal(h.ctrl.binding.stop, fstop.id);
assert.equal(cmds(h.engine, "abort").length, 0, "the superseded Interrupt writes nothing more");
assert.ok(!h.ctrl.events.some((e) => e.type === "reconciled"));
} finally {
await h.close();
}
}
// Both sent at once on a confirmation issued before either. The Interrupt
// yields once between its fence and the dispatch lock, so either may
// land first; there is one live stop either way. A stop the force stop
// didn't see changes its confirmation's context (H17).
for (const order of ["interrupt first", "force stop first"]) {
const h = await started();
try {
await heldRun(h);
const x = await confirm(h.client, "force-stop");
const ei = h.client.envelope("interrupt"), ef = h.client.envelope("force-stop", { confirmation: x });
const [ri, rf] = order === "interrupt first"
? await Promise.all([h.client.send(ei), h.client.send(ef)])
: await Promise.all([h.client.send(ef), h.client.send(ei)]).then(([f, i]) => [i, f]);
const won = [ri.outcome === "interrupt-fenced", rf.outcome === "force-stop-fenced"];
assert.equal(won.filter(Boolean).length, 1, `exactly one lands: ${ri.outcome} / ${rf.outcome}`);
if (won[0]) assert.equal(rf.refusal, "confirmation");
else assert.equal(ri.refusal, "fenced");
const live = [...h.ctrl.stops.values()].filter((s) => s.state !== "superseded");
assert.equal(live.length, 1, "one stop chain");
if (won[1]) await waitState(h, "stopped");
else await until(() => !["fenced", "cancelling", "stopping"].includes(h.ctrl.stops.get(ri.stop.id).state), 6000, "interrupt settles");
} finally {
await h.close();
}
}
});
// n2: an overlap read while the Interrupt waits before its abort means no
// abort, which would run whatever was queued. Mutant r2-n2 (no recheck
// after the pause) fails here.
test("H10: an overlap during the pause before the abort: no abort, the stop ends uncertain", async () => {
const g = gate();
const h = await started({ barrier: g.barrier });
try {
await heldRun(h);
g.hold("before-abort");
const i = await h.client.interrupt();
assert.equal(i.outcome, "interrupt-fenced");
await g.waitHeld("before-abort");
h.engine.queue("followUp", "queued in the pause");
await until(() => h.ctrl.evidence.overlaps.some((o) => o.signal === "O5"), 4000, "the overlap");
g.release("before-abort");
await until(() => h.ctrl.stops.get(i.stop.id)?.state === "uncertain", 6000, "stop uncertain");
assert.equal(cmds(h.engine, "abort").length, 0, "no abort after the overlap");
assert.equal(h.engine.runs.length, 1, "the queued item never ran");
} finally {
await h.close();
}
});
test("H10: a no-turn Interrupt lifts only its own fence; admission stays closed under force stop, overlap or revocation", async () => {
const cases = {
"force stop": async (h) => {
const fs = await h.client.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced", JSON.stringify(fs));
return "force-stop";
},
"overlap signal": async (h) => {
h.engine.emit({ type: "queue_update", steering: ["from an extension"], followUp: [] });
await until(() => h.ctrl.closers.has("overlap"), 2000, "overlap closer");
return "overlap";
},
revocation: async (h) => {
h.ctrl.revokeConnection(h.client.connection.id);
return "revocation";
},
};
for (const [name, close] of Object.entries(cases)) {
const g = gate();
const h = await started({ barrier: g.barrier });
try {
g.hold("interrupt-fenced");
const pending = h.client.interrupt();
await g.waitHeld("interrupt-fenced");
const reason = await close(h);
g.release("interrupt-fenced");
const r = await pending;
assert.equal(r.refusal, NO_TURN, name);
assert.equal(h.ctrl.binding.admission, "closed", `${name}: admission stays closed`);
assert.ok(h.ctrl.closers.has(reason), `${name}: the surviving reason holds`);
assert.ok(![...h.ctrl.closers].some((k) => k.startsWith("interrupt:")), `${name}: the Interrupt's own fence is gone`);
assert.ok([...h.ctrl.stops.values()].every((s) => s.mode !== "interrupt"), `${name}: no interrupt stop`);
if (name === "force stop") await waitState(h, "stopped");
} finally {
await h.close();
}
}
});
test("H11: the controller disconnects mid-turn: work continues, the claim is unchanged, control stays put", async () => {
const h = await started({ clients: 2 });
try {
const [c1, c2] = h.clients;
const id = await heldRun(h, c1);
const claimBefore = structuredClone(h.ctrl.claim.record);
const controller = h.ctrl.binding.controllerConnection;
c1.close();
await until(() => h.ctrl.connections.get(controller).rec.state === "disconnected", 2000, "disconnected");
h.engine.resume("p1");
await until(() => h.ctrl.receipts.get(id).state === "finished", 4000, "the turn finishes");
assert.deepEqual(h.ctrl.claim.record, claimBefore, "the claim is unchanged");
assert.equal(h.ctrl.binding.controllerConnection, controller, "control stays with the disconnected connection");
await synced(h, c2);
assert.equal((await c2.prompt("observer")).refusal, "controller");
await tick(100);
assert.equal(h.ctrl.binding.controllerConnection, controller, "nothing transfers automatically");
assert.equal((await c2.takeover()).outcome, "transferred", "an explicit takeover");
} finally {
await h.close();
}
});
test("H12: an exact retry after reconnecting to the same incarnation returns the same receipt; one dispatch", async () => {
const h = await started();
try {
const c = h.client;
const e = promptEnvelope(c);
const first = await c.send(e, "once");
assert.equal(first.outcome, "admitted");
await receiptState(c, first.receipt.id, "finished");
const incarnation = c.incarnation;
c.close();
await c.connect();
assert.equal(c.incarnation, incarnation);
const again = await c.send({ ...e, connection: c.connection.id }, "once");
assert.equal(again.outcome, "existing:finished");
assert.equal(again.receipt.id, first.receipt.id);
assert.equal(cmds(h.engine, "prompt").length, 1);
} finally {
await h.close();
}
});
test("H13: a retry with the same request ID and different text is refused", async () => {
const h = await started();
try {
const e = promptEnvelope(h.client);
const first = await h.client.send(e, "original");
await receiptState(h.client, first.receipt.id, "finished");
const r = await h.client.send(e, "changed");
assert.equal(r.refusal, "conflicting-request");
assert.equal(cmds(h.engine, "prompt").length, 1);
} finally {
await h.close();
}
});
// The old engine's stdout stays open after its stop, as a pipe with bytes
// still in it would.
class LateLauncher extends FakeLauncher {
async launch(a) {
const r = await super.launch(a);
const e = this.last;
const kill = e.kill;
e.kill = () => {
const end = e.end;
e.end = () => {};
kill();
e.end = end;
};
return r;
}
}
test("H14: late stdout from the old engine after a replacement is dropped by incarnation, counted, never rendered", async () => {
const launcher = new LateLauncher();
const h = await started({ launcher });
try {
const c = h.client;
const old = launcher.last;
const fs = await c.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced");
await waitState(h, "stopped");
await synced(h, c);
const rec = await c.confirmed("recover", { stop: fs.stop.id });
assert.equal(rec.outcome, "recovery-eligible", JSON.stringify(rec));
await h.ctrl.launch(rec.data.eligibility);
await waitState(h, "active");
assert.notEqual(launcher.last, old);
const dropped = h.ctrl.evidence.dropped.lines;
const events = c.events.length;
old.emit({ type: "agent_start" });
old.emit({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "LATE FROM THE OLD ENGINE" }] } });
await tick(100);
assert.equal(h.ctrl.evidence.dropped.lines, dropped + 2, "counted in the evidence");
assert.equal(c.events.length, events, "never rendered");
assert.ok(!JSON.stringify(c.pushes).includes("LATE FROM THE OLD ENGINE"));
assert.deepEqual(h.ctrl.evidence.overlaps, [], "no signal on the new execution");
assert.equal(h.ctrl.binding.admission, "open");
} finally {
await h.close();
}
});
test("H15: a revoked connection's command is refused; the revocation fence holds", async () => {
const h = await started({ clients: 2 });
try {
const [c1, c2] = h.clients;
const gen = h.ctrl.binding.controllerGeneration;
const before = h.engine.bytes().length;
h.ctrl.revokeConnection(c1.connection.id);
assert.equal(h.ctrl.binding.controllerConnection, null);
assert.equal(h.ctrl.binding.controllerGeneration, gen + 1);
assert.equal(h.ctrl.binding.admission, "closed");
assert.ok([...h.ctrl.stops.values()].some((s) => s.mode === "revocation" && s.revokedConnection === c1.connection.id));
assert.equal((await c1.prompt("after revocation")).refusal, "channel");
await synced(h, c2);
assert.equal((await c2.takeover()).refusal, "fenced", "CHAT-03 never lifts the revocation fence");
await tick(50);
assert.equal(h.engine.bytes().length, before);
} finally {
await h.close();
}
});
test("H16: a second controller for the same session refuses already-active; the first is untouched", async () => {
const h = await started();
try {
const claim = structuredClone(h.ctrl.claim.record);
const { ctrl: second, launcher } = controllerFor(h.fx);
await assert.rejects(() => second.start(), (e) => e.code === ALREADY_ACTIVE);
assert.equal(launcher.launches.length, 0);
assert.deepEqual(h.ctrl.claim.record, claim);
assert.ok(existsSync(h.ctrl.socketPath), "the first controller's socket is still there");
const p = await h.client.prompt("still mine");
await receiptState(h.client, p.receipt.id, "finished");
} finally {
await h.close();
}
});
test("H10: a second force stop while the first escalation runs refuses fenced; one escalation, and the claim records only the first stop's phases", async () => {
{
const g = gate();
const h = await started({ barrier: g.barrier });
try {
const x1 = await confirm(h.client, "force-stop");
g.hold("phase-term");
assert.equal((await h.client.request("force-stop", { confirmation: x1 })).outcome, "force-stop-fenced");
await g.waitHeld("phase-term");
const first = h.ctrl.binding.stop;
const bytes = h.engine.bytes().length;
await synced(h, h.client);
// Issued after the first stop began, so the stop it binds is current.
const x2 = await confirm(h.client, "force-stop");
const second = await h.client.request("force-stop", { confirmation: x2 });
assert.equal(second.refusal, "fenced");
assert.equal(h.ctrl.binding.stop, first, "no second stop record");
assert.notEqual(h.ctrl.confirmations.get(x2).state, "consumed", "the refusal comes before the confirmation is used");
assert.deepEqual(h.ctrl.store.head(h.ctrl.sessionK).record.stop.id, first);
assert.equal(h.ctrl.store.head(h.ctrl.sessionK).record.stop.phaseStarted, "term");
g.release("phase-term");
await waitState(h, "stopped");
assert.equal(h.launcher.stops, 1, "one escalation");
for (const key of [h.ctrl.seatK, h.ctrl.sessionK]) {
const recs = h.ctrl.store.revisions(key).map((r) => JSON.parse(r.text));
assert.ok(recs.every((r) => !r.stop || r.stop.id === first), "every stop on the claim is the first");
assert.deepEqual(recs.at(-1).stop.phaseStarted, "kill");
assert.equal(recs.at(-1).state, "stopped");
}
assert.equal(h.ctrl.events.filter((e) => e.type === "stopping").length, 1);
assert.equal(h.engine.bytes().length, bytes, "no engine bytes after the first fence");
} finally {
await h.close();
}
}
// Once the first ends uncertain, a force stop can be retried.
{
const h = await started({ launcher: new FakeLauncher({ stopOutcome: "unavailable" }) });
try {
const x1 = await confirm(h.client, "force-stop");
assert.equal((await h.client.request("force-stop", { confirmation: x1 })).outcome, "force-stop-fenced");
await waitState(h, "uncertain");
await until(() => h.ctrl.escalating === null, 2000, "the first escalation ends");
await synced(h, h.client);
assert.equal((await h.client.request("force-stop", { confirmation: x1 })).refusal, "confirmation", "H17: a used confirmation is refused");
assert.equal((await h.client.request("force-stop", { confirmation: await confirm(h.client, "force-stop") })).outcome, "force-stop-fenced");
await until(() => h.launcher.stops === 2, 2000, "a second escalation");
} finally {
await h.close();
}
}
});
test("H17: a confirmation reused, answered from another connection, or used after the stop changed is refused", async () => {
// Reused while its force stop is still running.
{
const g = gate();
const h = await started({ barrier: g.barrier });
try {
const x = await confirm(h.client, "force-stop");
g.hold("force-stop-recorded");
const first = await h.client.request("force-stop", { confirmation: x });
assert.equal(first.outcome, "force-stop-fenced");
await g.waitHeld("force-stop-recorded");
await synced(h, h.client);
// One escalation at a time: the reuse refuses `fenced` before its
// confirmation is looked at. The reuse after it ends is below.
const reuse = await h.client.request("force-stop", { confirmation: x });
assert.equal(reuse.refusal, "fenced");
g.release("force-stop-recorded");
await waitState(h, "stopped");
assert.equal(h.launcher.stops, 1);
} finally {
await h.close();
}
}
// Answered from another connection.
{
const h = await started({ clients: 2 });
try {
const [c1, c2] = h.clients;
await synced(h, c2);
const issued = await c1.request("issue-confirmation", { operationToConfirm: "force-stop" });
const id = issued.data.confirmation.id;
const r = await c2.request("answer-confirmation", { confirmation: id, answer: "confirm" });
assert.equal(r.refusal, "confirmation");
assert.equal(h.ctrl.confirmations.get(id).state, "pending");
assert.equal((await c2.request("force-stop", { confirmation: id })).refusal, "controller");
} finally {
await h.close();
}
}
// Used after an Interrupt changed the stop context.
{
const h = await started();
try {
const x = await confirm(h.client, "force-stop");
await heldRun(h);
const i = await h.client.interrupt();
await until(() => h.ctrl.events.some((e) => e.type === "reconciled" && e.stop === i.stop.id), 6000, "reconciled");
await synced(h, h.client);
const r = await h.client.request("force-stop", { confirmation: x });
assert.equal(r.refusal, "confirmation");
assert.equal(h.ctrl.binding.state, "active");
} finally {
await h.close();
}
}
});
test("H18: two prompts before any native output: the second refuses busy; one engine write", async () => {
const h = await started();
try {
h.engine.arm("843");
const [a, b] = await Promise.all([h.client.send(promptEnvelope(h.client), "first"), h.client.send(promptEnvelope(h.client), "second")]);
assert.equal(a.outcome, "admitted");
assert.equal(b.refusal, BUSY);
await h.engine.waitPaused("843");
h.engine.resume("843");
await receiptState(h.client, a.receipt.id, "finished");
assert.equal(cmds(h.engine, "prompt").length, 1);
assert.ok(!h.engine.bytes().toString().includes("second"));
} finally {
await h.close();
}
});
test("H19: the pipe fails mid-line under a large prompt: delivery-unknown transport-unknown, poisoned, no later write", async () => {
const h = await started();
try {
const before = h.engine.bytes().length;
h.engine.stdinGate.left = 40000;
const text = "x".repeat(250000);
const p = await h.client.prompt(text);
const r = await receiptState(h.client, p.receipt.id, "delivery-unknown");
assert.equal(r.reasonCode, TRANSPORT_UNKNOWN);
assert.equal(h.engine.bytes().length - before, 40000, "the engine got a partial line");
assert.equal(cmds(h.engine, "prompt").length, 0);
assert.ok(h.ctrl.exec.link.poisoned);
await waitState(h, "uncertain");
const after = h.engine.bytes().length;
h.engine.stdinGate.left = null;
assert.equal((await h.client.prompt("again")).refusal, "fenced");
await tick(100);
assert.equal(h.engine.bytes().length, after, "no later write and no retry");
} finally {
await h.close();
}
});
test("H19: the link itself never writes again after an unknown outcome, whoever calls it", async () => {
// The controller checks the poison before every write; this pins the
// link's own refusal, so a caller that forgets the check still can't
// write after a partial line.
const calls = [];
const stdin = new Writable({
write(chunk, _enc, cb) {
calls.push(chunk.length);
cb(calls.length === 1 ? Object.assign(new Error("broken pipe"), { code: "EPIPE" }) : null);
},
});
stdin.on("error", () => {});
const link = new EngineLink({ execution: "exec-x", stdin, stdout: new PassThrough(), onLine() {}, onGap() {} });
assert.deepEqual(await link.write({ type: "prompt", id: "p1", message: "x" }), { outcome: "unknown", reason: "EPIPE" });
assert.equal(link.poisoned, "EPIPE");
assert.deepEqual(await link.write({ type: "get_state", id: "s1" }), { outcome: "refused", reason: "poisoned" });
assert.equal(calls.length, 1, "one write reached the pipe");
assert.equal(link.bytesWritten, 0);
});
// ---- the controller in a child process ------------------------------------
const logOf = (path) => (existsSync(path) ? readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
const promptCmds = (path) => logOf(path).filter((v) => v.t === "cmd" && v.cmd.type === "prompt").length;
const engineCount = (path) => logOf(path).filter((v) => v.t === "argv").length;
const inText = (path) => Buffer.concat(logOf(path).filter((v) => v.t === "in").map((v) => Buffer.from(v.b64, "base64"))).toString();
function childFixture() {
const fx = track(fixture());
const log = join(fx.base, "fake.log");
return { fx, log, fakeEnv: { FAKE_PI_CONTROL: join(fx.base, "fake.sock"), FAKE_PI_LOG: log } };
}
async function connectTo(socketPath, client = null) {
const c = client ?? new ConversationClient({ socketPath });
c.socketPath = socketPath;
await c.connect();
return c;
}
test("H19: the controller dies mid-write of a large line: after restart the outcome is unknown and nothing is resent", async () => {
const { fx, log, fakeEnv } = childFixture();
const a = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv });
const ra = await a.next((m) => m.ready || m.error);
assert.ok(ra.ready, JSON.stringify(ra));
const fake = new ControlClient(fakeEnv.FAKE_PI_CONTROL);
await fake.connect();
const c = await connectTo(ra.socketPath);
await c.takeover();
await fake.call("stall");
const p = await c.prompt("y".repeat(250000));
assert.equal(p.outcome, "admitted");
await tick(300);
assert.notEqual(c.receipt(p.receipt.id).state, "dispatched", "the write never completed");
a.proc.kill("SIGKILL");
await a.exited;
const b = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv });
const rb = await b.next((m) => m.ready || m.error);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.launched, false);
assert.equal(rb.started.classified.state, "uncertain");
await connectTo(rb.socketPath, c);
assert.ok(c.unknown.some((u) => u.receipt === p.receipt.id && u.display === OUTCOME_UNKNOWN), "shown as outcome unknown");
const st = (await fake.call("state")).result;
assert.ok(!st.commands.some((x) => x.type === "prompt"), "the engine never read a complete prompt");
assert.equal(engineCount(log), 1);
c.close();
fake.close();
b.send("close");
await b.exited;
reap(fx);
});
test("H20: the line is written but the ack is lost when the controller dies: orphan, outcome unknown, nothing resent", async () => {
const { fx, log, fakeEnv } = childFixture();
const a = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv, dieAt: { written: 1 } });
const ra = await a.next((m) => m.ready || m.error);
assert.ok(ra.ready, JSON.stringify(ra));
const c = await connectTo(ra.socketPath);
await c.takeover();
const p = await c.prompt("written, then the controller dies");
await a.next((m) => m.dying === "written");
await a.exited;
const b = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv });
const rb = await b.next((m) => m.ready || m.error);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.launched, false);
assert.equal(rb.started.classified.state, "uncertain");
await connectTo(rb.socketPath, c);
assert.ok(c.unknown.some((u) => u.receipt === p.receipt.id && u.display === OUTCOME_UNKNOWN));
assert.equal(c.binding.state, "uncertain");
await tick(200);
assert.equal(promptCmds(log), 1, "nothing resent");
assert.equal(engineCount(log), 1);
c.close();
b.send("close");
await b.exited;
reap(fx);
});
// A controller that dies after its prompt line is written, and its restart.
async function crashAfterWrite(launcher) {
const { fx, log, fakeEnv } = childFixture();
const units = launcher === "scope" ? undefined : "absent";
const a = spawnController({ fx, launcher, units, fakeEnv, dieAt: { written: 1 } });
const ra = await a.next((m) => m.ready || m.error);
assert.ok(ra.ready, JSON.stringify(ra));
const c = await connectTo(ra.socketPath);
await c.takeover();
const oldToken = c.incarnation;
const e = promptEnvelope(c);
const text = "dispatched before the crash";
const sent = c.send(e, text);
await a.next((m) => m.dying === "written");
await a.exited;
await sent;
const b = spawnController({ fx, launcher, units, fakeEnv });
const rb = await b.next((m) => m.ready || m.error);
assert.ok(rb.ready, JSON.stringify(rb));
assert.equal(rb.started.launched, false);
await connectTo(rb.socketPath, c);
assert.notEqual(c.incarnation, oldToken);
return { fx, log, c, b, e, text, oldToken };
}
test("H21: a retry of the exact request with the old token after a crash is stale-incarnation; no second write", async () => {
const { fx, log, c, b, e, text, oldToken } = await crashAfterWrite("pgroup");
const retry = await c.send({ ...e, connection: c.connection.id }, text, { incarnation: oldToken });
assert.equal(retry.refusal, STALE_INCARNATION);
assert.equal(retry.display, OUTCOME_UNKNOWN);
await tick(100);
assert.equal(promptCmds(log), 1, "no second engine write");
// The process-group fallback can't prove the orphan's cohort, so its
// force stop ends uncertain; recovery needs a scope (H22).
assert.equal((await c.confirmed("acquire-recovery-control")).outcome, "recovery-control-acquired");
assert.equal((await c.confirmed("force-stop")).outcome, "force-stop-fenced");
assert.ok(await c.waitFor(() => c.binding?.state === "uncertain" && b.msgs.length > 0, 10000));
c.close();
b.send("close");
await b.exited;
reap(fx);
});
test("H22: after H21 and a valid recovery, a new request with the new token is admitted", { skip: !SCOPE && "systemd user scopes unavailable", timeout: 60000 }, async () => {
const { fx, log, c, b, e, text, oldToken } = await crashAfterWrite("scope");
assert.equal((await c.send({ ...e, connection: c.connection.id }, text, { incarnation: oldToken })).refusal, STALE_INCARNATION);
assert.equal((await c.confirmed("acquire-recovery-control")).outcome, "recovery-control-acquired");
const fs = await c.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced", JSON.stringify(fs));
assert.ok(await c.waitFor(() => c.binding?.state === "stopped", 15000), `binding ${c.binding?.state}`);
const rec = await c.confirmed("recover", { stop: fs.stop.id });
assert.equal(rec.outcome, "recovery-eligible", JSON.stringify(rec));
b.send(`launch ${rec.data.eligibility}`);
const launched = await b.next((m) => m.launched !== undefined, 15000);
assert.ok(launched.launched, JSON.stringify(launched));
assert.equal(launched.incarnation, c.incarnation, "the token the client holds is the new one");
if (!(await c.waitFor(() => c.binding?.state === "active" && c.binding.admission === "open", 15000))) {
b.send("evidence");
const ev = await b.next((m) => m.evidence !== undefined);
assert.fail(`binding ${c.binding?.state}: ${JSON.stringify({ uncertain: ev.evidence.uncertain, stops: ev.evidence.stops, internal: ev.evidence.internal, gaps: ev.evidence.gaps, overlaps: ev.evidence.overlaps, stderr: ev.evidence.stderrTail.slice(-800) })}`);
}
assert.equal((await c.takeover()).outcome, "transferred");
const p = await c.prompt("a new request");
assert.equal(p.outcome, "admitted", JSON.stringify(p));
await receiptState(c, p.receipt.id, "finished", 8000);
assert.equal(promptCmds(log), 2, "one per engine; the old request was never resent");
assert.equal(inText(log).split(text).length - 1, 1);
c.close();
b.send("kill-engine");
await b.exited;
reap(fx);
});
test("H23: requests pending at a restart are not resent; each shows outcome unknown", async () => {
const { fx, log, fakeEnv } = childFixture();
const a = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv, holdAt: ["before-recheck"] });
const ra = await a.next((m) => m.ready || m.error);
assert.ok(ra.ready, JSON.stringify(ra));
const c = await connectTo(ra.socketPath);
await c.takeover();
const p1 = await c.prompt("held at the recheck");
assert.equal(p1.outcome, "admitted");
await a.next((m) => m.held === "before-recheck");
// Both wait behind the held dispatch lock.
const p2 = c.prompt("pending one");
const ob = c.observe();
await tick(100);
assert.equal(c.pending.size, 2);
a.proc.kill("SIGKILL");
await a.exited;
const [r2, rob] = await Promise.all([p2, ob]);
assert.equal(r2.outcome, "outcome-unknown");
assert.equal(rob.outcome, "outcome-unknown");
const b = spawnController({ fx, launcher: "pgroup", units: "absent", fakeEnv });
const rb = await b.next((m) => m.ready || m.error);
assert.ok(rb.ready, JSON.stringify(rb));
await connectTo(rb.socketPath, c);
await tick(200);
assert.equal(c.pending.size, 0, "the library resends none");
assert.equal(promptCmds(log), 0, "zero engine bytes for them");
assert.ok(!inText(log).includes("pending one") && !inText(log).includes("held at the recheck"));
const shown = c.unknown.filter((u) => u.display === OUTCOME_UNKNOWN);
assert.equal(shown.filter((u) => u.operation === "prompt" && u.request).length, 1, "the pending prompt");
assert.equal(shown.filter((u) => u.operation === "observe").length, 1, "the pending observe");
assert.ok(shown.some((u) => u.receipt === p1.receipt.id), "the admitted prompt");
c.close();
b.send("close");
await b.exited;
reap(fx);
});
+179
View File
@@ -0,0 +1,179 @@
// CHAT-03 (#1507): the pinned Pi binary, started sealed in a scratch home with
// no credentials, answers the RPC commands the controller sends with the
// shapes the fake engine models. No prompt is sent, so no model is called.
//
// It also records pinned Pi's startup append (sdk.js 240–252): a session
// whose branch has no thinking_level_change entry gains one at every start,
// so the leaf moves after launch and the K8 load check fails closed on it.
//
// Set CHAT03_SMOKE_OUT=<file> to keep the exchange as evidence.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { LineSplitter, encodeLine } from "../src/framing.mjs";
import { PI_BIN, PI_VERSION, buildPiArgs, checkEnginePin, checkSeal } from "../src/pi-pin.mjs";
import { FakePi } from "./fake-pi.mjs";
import { REPO, assistantEntry, header, thinkingEntry, userEntry } from "./harness.mjs";
const scratch = mkdtempSync(join(tmpdir(), "chat03-smoke-"));
after(() => rmSync(scratch, { recursive: true, force: true }));
const COMMANDS = ["get_state", "get_commands", "clear_queue", "abort", "get_tree"];
const exchanges = {};
function session(name, entries) {
const dir = join(scratch, "proj", ".pi", "state", "smoke", "sessions");
mkdirSync(dir, { recursive: true });
const file = join(dir, name);
writeFileSync(file, [header(join(scratch, "proj")), ...entries].map((v) => JSON.stringify(v) + "\n").join(""));
return file;
}
// A minimal environment: no inherited variables, so no provider key or token
// can reach Pi; HOME and the agent dir are empty scratch directories.
function scratchEnv(tag) {
const home = join(scratch, `home-${tag}`);
const agent = join(scratch, `agent-${tag}`);
for (const d of [home, agent, join(home, ".config"), join(home, ".cache"), join(home, ".local", "share")]) mkdirSync(d, { recursive: true });
return {
home, agent,
env: {
PATH: process.env.PATH, HOME: home, PI_CODING_AGENT_DIR: agent, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0",
XDG_CONFIG_HOME: join(home, ".config"), XDG_CACHE_HOME: join(home, ".cache"), XDG_DATA_HOME: join(home, ".local", "share"),
},
};
}
// Sends each command after the previous response arrives and records every
// line in order.
async function converse(input, output, { onExit = null } = {}) {
const lines = [];
const waiters = new Set();
const split = new LineSplitter((l) => {
lines.push(JSON.parse(l));
for (const w of [...waiters]) w();
});
output.on("data", (c) => split.push(c));
const reply = (id) => new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`no response to ${id}; lines ${JSON.stringify(lines).slice(0, 2000)}`)), 20000);
const check = () => {
const hit = lines.find((v) => v.type === "response" && v.id === id);
if (hit) {
waiters.delete(check);
clearTimeout(t);
resolve(hit);
}
};
waiters.add(check);
onExit?.(() => reject(new Error(`pi exited; lines ${JSON.stringify(lines).slice(0, 2000)}`)));
check();
});
const responses = {};
for (const type of COMMANDS) {
const id = `smoke-${type}`;
input.write(encodeLine({ id, type }));
responses[type] = await reply(id);
}
return { lines, responses };
}
async function realPi(tag, sessionFile) {
const { home, agent, env } = scratchEnv(tag);
assert.equal(existsSync(join(home, ".pi", "agent", "auth.json")), false);
assert.deepEqual(readdirSync(agent), [], "the agent dir starts empty");
const args = buildPiArgs({ sessionFile });
checkSeal(args);
const pi = spawn(process.execPath, [join(REPO, PI_BIN), ...args], { cwd: join(scratch, "proj"), env, stdio: ["pipe", "pipe", "pipe"] });
let stderr = "";
pi.stderr.on("data", (c) => (stderr += c));
const exited = new Promise((r) => pi.on("exit", (code, signal) => r({ code, signal })));
try {
const out = await converse(pi.stdin, pi.stdout, { onExit: (fn) => exited.then(fn) });
return { ...out, stderr, agentFiles: readdirSync(agent).sort() };
} finally {
pi.stdin.end();
pi.kill("SIGTERM");
const t = setTimeout(() => pi.kill("SIGKILL"), 3000);
await exited;
clearTimeout(t);
}
}
function fakePi(sessionFile) {
const input = new PassThrough();
const output = new PassThrough();
new FakePi({ input, output, argv: buildPiArgs({ sessionFile }) });
return converse(input, output);
}
const lastId = (file) => JSON.parse(readFileSync(file, "utf8").trim().split("\n").at(-1)).id;
const typesOf = (data) => Object.fromEntries(Object.entries(data ?? {}).map(([k, v]) => [k, v === null ? "null" : Array.isArray(v) ? "array" : typeof v]));
test("the engine pin holds for the installed package", () => {
assert.deepEqual(checkEnginePin(REPO).version, PI_VERSION);
});
test("pinned Pi, sealed and without credentials, answers the controller's commands with the shapes the fake models", async () => {
const entries = [thinkingEntry(), userEntry("a1b2c3d4", "f0e1d2c3", "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
const realFile = session("real.jsonl", entries);
const fakeFile = session("fake.jsonl", entries);
const before = readFileSync(realFile, "utf8");
const real = await realPi("real", realFile);
const fake = await fakePi(fakeFile);
exchanges.sealed = real;
assert.equal(readFileSync(realFile, "utf8"), before, "a session that carries a thinking entry is not appended to at start");
assert.deepEqual(real.agentFiles.filter((f) => f !== "auth.json" && f !== "models-store.json"), [], `agent dir: ${real.agentFiles}`);
if (real.agentFiles.includes("auth.json")) assert.deepEqual(JSON.parse(readFileSync(join(scratch, "agent-real", "auth.json"), "utf8")), {}, "no credential was written");
const st = real.responses.get_state;
assert.equal(st.success, true);
assert.equal(st.data.sessionFile, realFile);
assert.equal(st.data.isStreaming, false);
assert.equal(st.data.sessionId, fake.responses.get_state.data.sessionId);
// K8 reads get_tree's leafId; for this session it is the file's last entry.
assert.equal(real.responses.get_tree.data.leafId, lastId(realFile));
assert.equal(real.responses.get_tree.data.leafId, fake.responses.get_tree.data.leafId);
// Sealed, Pi still registers one bundled inline extension command, /llama
// (llama.cpp router). No file extension, template or skill loads. Any slash
// text is refused at admission (S1, S2), so it can't be invoked; this pins
// the set so a change shows here.
const offered = (real.responses.get_commands.data?.commands ?? []).map((c) => `${c.name}:${c.source}:${c.sourceInfo?.source}`);
assert.deepEqual(offered, ["llama:extension:inline"]);
for (const type of ["get_state", "clear_queue", "abort", "get_tree"]) {
const r = real.responses[type], f = fake.responses[type];
assert.equal(r.command, f.command, type);
assert.equal(r.success, f.success, type);
const rt = typesOf(r.data), ft = typesOf(f.data);
for (const [k, t] of Object.entries(ft)) assert.equal(rt[k], t, `${type}.${k}: the fake models a field pinned Pi doesn't send that way`);
}
// clear_queue: one queue_update with both queues empty precedes the response, in both.
for (const side of [real, fake]) {
const i = side.lines.findIndex((v) => v.type === "response" && v.id === "smoke-clear_queue");
const updates = side.lines.slice(0, i).filter((v) => v.type === "queue_update");
assert.deepEqual(updates.at(-1), { type: "queue_update", steering: [], followUp: [] });
}
});
test("pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed)", async () => {
const entries = [userEntry("a1b2c3d4", null, "hello"), assistantEntry("b2c3d4e5", "a1b2c3d4", "hi")];
const realFile = session("bare-real.jsonl", entries);
const fakeFile = session("bare-fake.jsonl", entries);
const real = await realPi("bare", realFile);
const fake = await fakePi(fakeFile);
exchanges.startupAppend = real;
for (const [file, side] of [[realFile, real], [fakeFile, fake]]) {
const added = readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l)).slice(1 + entries.length);
assert.deepEqual(added.map((e) => e.type), ["thinking_level_change"], file);
assert.equal(added[0].parentId, "b2c3d4e5");
assert.notEqual(side.responses.get_tree.data.leafId, "b2c3d4e5", "the leaf moved off the loaded leaf");
assert.equal(side.responses.get_tree.data.leafId, added[0].id);
}
if (process.env.CHAT03_SMOKE_OUT) writeFileSync(process.env.CHAT03_SMOKE_OUT, JSON.stringify({ pi: PI_VERSION, commands: COMMANDS, exchanges }, null, 2) + "\n");
});
+983
View File
@@ -0,0 +1,983 @@
// CHAT-03 §3 turns, dispatch and Interrupt (#1507): N1–N25 on the in-process
// fake engine. Unsealed-extension effects are simulated by the fake; no real
// extension loads (the binding would refuse it, N24).
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { PassThrough } from "node:stream";
import { join } from "node:path";
import { Controller, HANDLED_WITHOUT_RUN, ACK_WITHOUT_START, NO_TURN, RUN_OVERLAP, TRANSPORT_UNKNOWN } from "../src/controller.mjs";
import { AUTHORITY } from "../src/cohort.mjs";
import { FixtureVerifier, sha256 } from "../src/records.mjs";
import { SEAL_FLAGS, UNSEALED_ENGINE, checkSeal } from "../src/pi-pin.mjs";
import { LineSplitter, encodeLine, parseLine } from "../src/framing.mjs";
import { BUSY_ERROR, FakeLauncher, FakePi } from "./fake-pi.mjs";
import { fixture, started, receiptState, outcomeUnknownPush, sessionText, cleanupAll, tick, noUnits, FAST } from "./harness.mjs";
after(() => cleanupAll());
// Records every proof the controller posts, so a test can see that no
// turnProof other than `turnState: interrupted` was ever produced (mutant 32).
class SpyVerifier extends FixtureVerifier {
constructor() {
super({ authorities: [AUTHORITY] });
this.posted = [];
}
post(p) {
this.posted.push(structuredClone(p));
return super.post(p);
}
}
async function until(pred, ms = 4000, what = "condition") {
const end = Date.now() + ms;
while (!pred()) {
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await tick(5);
}
}
const cmds = (engine, type) => engine.commands.filter((c) => c.type === type);
const stopEvidence = (ctrl, id) => ctrl.evidence.stops.filter((e) => e.stop === id);
const lastStopEvidence = (ctrl, id) => stopEvidence(ctrl, id).at(-1) ?? null;
const signals = (ctrl) => ctrl.evidence.overlaps.map((o) => o.signal);
const reconciled = (ctrl, id) => ctrl.events.some((e) => e.type === "reconciled" && e.stop === id);
const turnProofs = (v) => v.posted.filter((p) => p.kind === "turnProof");
async function setup(opts = {}) {
const verifier = new SpyVerifier();
const h = await started({ verifier, ...opts });
return { ...h, verifier };
}
// Admits a prompt and returns its receipt id.
async function prompt(h, text = "do the thing") {
const r = await h.client.prompt(text);
assert.equal(r.outcome, "admitted", JSON.stringify(r));
return r.receipt.id;
}
// A Mosaic run held at step `p1` with its receipt `working`.
async function heldRun(h, rest = [{ text: "never", stop: "stop" }]) {
h.engine.script([{ pause: "p1" }, ...rest]);
h.engine.arm("p1");
const id = await prompt(h);
await receiptState(h.client, id, "working");
await h.engine.waitPaused("p1");
return id;
}
async function interrupt(h) {
const r = await h.client.interrupt();
assert.equal(r.outcome, "interrupt-fenced", JSON.stringify(r));
return r.stop.id;
}
async function stopSettled(h, id) {
await until(() => {
const s = h.ctrl.stops.get(id);
return s && (s.state === "uncertain" || s.state === "turn-interrupted" || s.state === "superseded");
}, 8000, `stop ${id} to settle`);
return h.ctrl.stops.get(id);
}
// The stop is uncertain: no reconciled, prompts refuse, force stop works.
async function assertUncertainStop(h, stopId, outcome) {
const s = await stopSettled(h, stopId);
assert.equal(s.state, "uncertain");
assert.equal(lastStopEvidence(h.ctrl, stopId)?.outcome, outcome);
assert.equal(reconciled(h.ctrl, stopId), false);
assert.equal(h.ctrl.binding.admission, "closed");
assert.ok(turnProofs(h.verifier).every((p) => p.turnState === "interrupted"), "no other turnState is ever written");
assert.ok(!turnProofs(h.verifier).some((p) => p.stop === stopId), "no turnProof for an uncertain stop");
const before = h.engine.bytes().length;
const p = await h.client.prompt("again");
assert.equal(p.refusal, "fenced");
assert.equal(h.engine.bytes().length, before, "a refused prompt writes no engine bytes");
const fs = await h.client.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced", JSON.stringify(fs));
await until(() => h.ctrl.binding.state === "stopped", 4000, "force stop");
}
test("N25: ordinary Interrupt reconciles; a non-empty queue_update in the window is O5", async () => {
const h = await setup();
try {
const id = await heldRun(h);
const stopId = await interrupt(h);
await until(() => reconciled(h.ctrl, stopId), 6000, "reconciled");
assert.deepEqual(signals(h.ctrl), [], "Pi's empty queue_update before each clear response is no signal");
const s = h.ctrl.stops.get(stopId);
assert.equal(s.state, "turn-interrupted");
assert.equal(s.nativeQueue, "cleared");
assert.equal((await receiptState(h.client, id, "failed")).reasonCode, "interrupted");
// Each clear_queue answered after Pi's empty queue_update.
const clears = cmds(h.engine, "clear_queue");
assert.equal(clears.length, 2, "the interrupt clear and the post-settle clear");
const types = h.engine.sent.map((l) => JSON.parse(l)).map((v) => (v.type === "response" ? `response:${v.command}` : v.type));
for (let i = 0; i < types.length; i++) if (types[i] === "response:clear_queue") assert.equal(types[i - 1], "queue_update");
assert.equal(h.ctrl.binding.admission, "open");
const next = await h.client.prompt("next");
assert.equal(next.outcome, "admitted");
await receiptState(h.client, next.receipt.id, "finished");
} finally {
await h.close();
}
// The same window with a non-empty queue_update.
const h2 = await setup();
try {
await heldRun(h2);
h2.engine.arm("clear");
const stopId = await interrupt(h2);
await h2.engine.waitPaused("clear");
h2.engine.queue("steer", "slipped in");
h2.engine.resume("clear");
const s = await stopSettled(h2, stopId);
assert.equal(s.state, "uncertain");
assert.ok(signals(h2.ctrl).includes("O5"));
assert.equal(reconciled(h2.ctrl, stopId), false);
} finally {
await h2.close();
}
});
test("N1: an extension's follow-up queued after the fence is cleared before any abort; O5, Unknown", async () => {
const h = await setup();
try {
const id = await heldRun(h);
h.engine.arm("clear");
const stopId = await interrupt(h);
// The clear_queue is in Pi's hands but not yet run; the extension queues.
await h.engine.waitPaused("clear");
h.engine.queue("followUp", "external item");
h.engine.resume("clear");
const s = await stopSettled(h, stopId);
const order = h.engine.commands.map((c) => c.type).filter((t) => t === "clear_queue" || t === "abort");
assert.equal(order[0], "clear_queue");
assert.equal(cmds(h.engine, "abort").length, 0, "a non-empty clear stops before abort");
assert.equal(h.engine.runs.length, 1);
assert.ok(!h.engine.sent.some((l) => l.includes("external item") && l.includes('"message_start"')), "the follow-up never ran");
assert.ok(signals(h.ctrl).filter((x) => x === "O5").length >= 2, "the queue_update and the non-empty clear");
assert.equal(s.state, "uncertain");
assert.equal(s.nativeQueue, "unknown", "a non-empty clear leaves the native queue unknown");
assert.equal(lastStopEvidence(h.ctrl, stopId).outcome, "unknown");
const digest = sha256("external item");
assert.ok(h.ctrl.evidence.overlaps.some((o) => o.removed?.some((r) => r.digest === digest && r.bytes === 13)), "evidence holds the item's digest and size");
assert.equal(h.client.receipt(id).state, "working");
await until(() => outcomeUnknownPush(h.client, id), 2000, "the receipt shown as outcome unknown");
assert.equal(outcomeUnknownPush(h.client, id).reason, RUN_OVERLAP);
assert.ok(!JSON.stringify(h.ctrl.receipts.get(id)).includes("external item"), "the removed item is never attributed to the request");
assert.equal(cmds(h.engine, "prompt").length, 1, "nothing is resent");
await assertUncertainStop(h, stopId, "unknown");
} finally {
await h.close();
}
});
test("N1: a follow-up queued before the fence is O5 at once; the Interrupt refuses fenced", async () => {
const h = await setup();
try {
await heldRun(h);
h.engine.queue("followUp", "early");
await until(() => h.ctrl.binding.state === "uncertain", 2000, "uncertain");
const r = await h.client.interrupt();
assert.equal(r.refusal, "fenced");
assert.equal(h.ctrl.stops.size, 0);
} finally {
await h.close();
}
});
test("N2: with abort first, the fake runs the external item (the ordering guard has teeth)", async () => {
// The mutant's order, driven straight at the fake: abort before clear.
const drive = async (order) => {
const input = new PassThrough(), output = new PassThrough();
const fake = new FakePi({ input, output });
const lines = [];
output.on("data", (c) => lines.push(...c.toString().split("\n").filter(Boolean).map((l) => JSON.parse(l))));
fake.script([{ pause: "p1" }, { text: "x" }]);
fake.arm("p1");
input.write(encodeLine({ id: "p", type: "prompt", message: "mosaic" }));
await fake.waitPaused("p1");
fake.queue("followUp", "external item");
for (const [i, type] of order.entries()) {
input.write(encodeLine({ id: `c${i}`, type }));
await until(() => lines.some((v) => v.id === `c${i}`));
}
await fake.waitIdle();
return lines.some((v) => v.type === "message_start" && v.message?.role === "user" && v.message.content?.[0]?.text === "external item");
};
assert.equal(await drive(["abort", "clear_queue"]), true, "abort first continues the queued item in the same run");
assert.equal(await drive(["clear_queue", "abort"]), false, "clear first removes it");
});
test("N3: the fence lands in preflight, preflight errors, no run: failed, No run, uncertain", async () => {
const h = await setup();
try {
h.engine.arm("915");
h.engine.plan({ error: "auth check failed", at: "915" });
const id = await prompt(h);
await h.engine.waitPaused("915");
await receiptState(h.client, id, "dispatched");
const stopId = await interrupt(h);
await until(() => cmds(h.engine, "abort").length === 1, 3000, "abort");
h.engine.resume("915");
const r = await receiptState(h.client, id, "failed");
assert.equal(r.reasonCode, null);
assert.ok(h.client.pushes.some((p) => p.kind === "receipt" && p.receipt.id === id && p.nativeError === "auth check failed"), "native error kept");
assert.equal(h.engine.runs.length, 0);
await assertUncertainStop(h, stopId, "no-run");
} finally {
await h.close();
}
});
test("N4: the ack arrives after the first abort and a run starts: clear and abort again; Interrupted", async () => {
const h = await setup();
try {
h.engine.arm("915");
h.engine.script([{ pause: "p1" }, { text: "never" }]);
h.engine.arm("p1");
const id = await prompt(h);
await h.engine.waitPaused("915");
const stopId = await interrupt(h);
await until(() => cmds(h.engine, "abort").length === 1, 3000, "first abort");
h.engine.resume("915");
await until(() => reconciled(h.ctrl, stopId), 8000, "reconciled");
assert.equal(cmds(h.engine, "abort").length, 2, "a second abort for the late run");
assert.equal(cmds(h.engine, "clear_queue").length, 3, "two interrupt clears and the post-settle clear");
const r = await receiptState(h.client, id, "failed");
assert.equal(r.reasonCode, "interrupted");
assert.equal(h.ctrl.stops.get(stopId).state, "turn-interrupted");
assert.equal(turnProofs(h.verifier).at(-1).turnState, "interrupted");
assert.equal(h.ctrl.binding.admission, "open");
} finally {
await h.close();
}
});
test("N5: an input handler takes the prompt: ack, no run, delivery-unknown handled-without-run", async () => {
const h = await setup();
try {
h.engine.plan({ handled: true });
const id = await prompt(h);
const r = await receiptState(h.client, id, "delivery-unknown");
assert.equal(r.reasonCode, HANDLED_WITHOUT_RUN);
assert.ok(!h.client.pushes.some((p) => p.kind === "receipt" && p.receipt.id === id && ["working", "finished"].includes(p.receipt.state)));
await tick(100);
assert.equal(cmds(h.engine, "prompt").length, 1, "nothing is resent");
assert.equal(h.engine.runs.length, 0);
assert.equal(h.ctrl.binding.admission, "open");
} finally {
await h.close();
}
});
test("N6: an extension queues between clear_queue and abort: O5 and O6, Unknown", async () => {
const h = await setup();
try {
const id = await heldRun(h);
h.engine.arm("abort");
const stopId = await interrupt(h);
await h.engine.waitPaused("abort");
h.engine.queue("followUp", "between");
h.engine.resume("abort");
const s = await stopSettled(h, stopId);
assert.ok(signals(h.ctrl).includes("O5"));
assert.ok(signals(h.ctrl).includes("O6"), "abort continued the queued item in the same run");
assert.equal(h.engine.runs.length, 1);
assert.equal(s.state, "uncertain");
assert.equal(h.client.receipt(id).state, "working");
await until(() => outcomeUnknownPush(h.client, id), 2000, "outcome unknown");
assert.equal(outcomeUnknownPush(h.client, id).reason, RUN_OVERLAP);
await assertUncertainStop(h, stopId, "unknown");
} finally {
await h.close();
}
});
test("N7: clear_queue times out: no abort, nativeQueue unknown, force stop still ends it", async () => {
const h = await setup();
try {
const id = await heldRun(h);
h.engine.dropResponse("clear_queue");
const stopId = await interrupt(h);
const s = await stopSettled(h, stopId);
assert.equal(cmds(h.engine, "abort").length, 0, "abort would run whatever is queued");
assert.equal(s.nativeQueue, "unknown");
assert.equal(s.state, "uncertain");
assert.equal(h.ctrl.binding.state, "uncertain");
assert.equal(h.client.receipt(id).state, "working");
await until(() => outcomeUnknownPush(h.client, id), 2000, "outcome unknown");
assert.equal(outcomeUnknownPush(h.client, id).reason, TRANSPORT_UNKNOWN);
assert.equal(lastStopEvidence(h.ctrl, stopId).outcome, "unknown");
const fs = await h.client.confirmed("force-stop");
assert.equal(fs.outcome, "force-stop-fenced");
await until(() => h.ctrl.binding.state === "stopped", 4000, "force stop");
} finally {
await h.close();
}
});
test("N7: clear_queue answers an error: no abort, nativeQueue unknown, the link not poisoned", async () => {
// An error response is the one clear failure that leaves the link
// unpoisoned, so nothing but rule 2 keeps the abort off the pipe.
const h = await setup();
try {
const id = await heldRun(h);
h.engine.failResponse("clear_queue");
const stopId = await interrupt(h);
const s = await stopSettled(h, stopId);
assert.equal(h.ctrl.exec.link.poisoned, null);
assert.equal(cmds(h.engine, "abort").length, 0, "abort would run whatever is queued");
assert.equal(s.nativeQueue, "unknown");
assert.equal(s.state, "uncertain");
assert.equal(h.client.receipt(id).state, "working");
} finally {
await h.close();
}
});
test("N8: an extension prompt starts a run during Mosaic preflight; the losing settle is O3", async () => {
const h = await setup();
try {
h.engine.arm("915");
const id = await prompt(h);
await h.engine.waitPaused("915");
// The order: Mosaic ack, the other run's agent_start and user
// message_start, then the losing Mosaic prompt's agent_settled.
h.engine.arm("run-start");
h.engine.arm("state");
h.engine.resume("915");
await h.engine.waitPaused("run-start");
await h.engine.waitPaused("state");
h.engine.arm("ext-hold");
void h.engine.extensionPrompt({ text: "from an extension", steps: [{ pause: "ext-hold" }, { text: "ext done" }] });
await h.engine.waitPaused("ext-hold");
await receiptState(h.client, id, "working");
h.engine.resume("run-start");
await until(() => signals(h.ctrl).includes("O3"), 2000, "O3");
h.engine.resume("state");
h.engine.resume("ext-hold");
await h.engine.waitIdle();
await tick(50);
const wire = h.engine.sent.map((l) => JSON.parse(l)).filter((v) => v.type !== "message_update");
const ack = wire.findIndex((v) => v.type === "response" && v.command === "prompt");
const start = wire.findIndex((v, i) => i > ack && v.type === "agent_start");
const user = wire.findIndex((v, i) => i > start && v.type === "message_start" && v.message.role === "user");
const settle = wire.findIndex((v, i) => i > user && v.type === "agent_settled");
assert.ok(ack >= 0 && ack < start && start < user && user < settle, "the pinned wire order");
assert.equal(h.client.receipt(id).state, "working", "never finished or failed");
await until(() => outcomeUnknownPush(h.client, id), 2000, "outcome unknown");
assert.equal(outcomeUnknownPush(h.client, id).reason, RUN_OVERLAP);
assert.equal(h.ctrl.binding.state, "uncertain");
assert.equal(h.ctrl.binding.admission, "closed");
assert.equal(cmds(h.engine, "prompt").length, 1, "nothing resent");
} finally {
await h.close();
}
});
test("N9: a run that started before the fence and ends aborted: failed interrupted, Interrupted", async () => {
const h = await setup();
try {
const id = await heldRun(h);
const stopId = await interrupt(h);
await until(() => reconciled(h.ctrl, stopId), 6000, "reconciled");
const r = await receiptState(h.client, id, "failed");
assert.equal(r.reasonCode, "interrupted");
assert.ok(h.client.pushes.some((p) => p.kind === "receipt" && p.receipt.id === id && p.stop === stopId), "evidence links the stop");
assert.equal(lastStopEvidence(h.ctrl, stopId).outcome, "interrupted");
assert.equal(turnProofs(h.verifier).at(-1).turnState, "interrupted");
} finally {
await h.close();
}
});
// Lead decision 34: only the controller's abort produces `aborted`. Mutant
// r2-D34 (turns.mjs drops the aborted-without-stop overlap) fails here.
test("N9: decision 34: a run that ends aborted with no stop in progress: aborted-without-stop, uncertain, outcome unknown", async () => {
const h = await setup();
try {
h.engine.script([{ text: "", stop: "aborted", errorMessage: "Request was aborted" }]);
const id = await prompt(h);
await until(() => signals(h.ctrl).includes("aborted-without-stop"), 4000, "the overlap");
assert.equal(h.ctrl.stops.size, 0);
assert.equal(h.ctrl.binding.state, "uncertain");
assert.equal(h.ctrl.binding.admission, "closed");
await until(() => outcomeUnknownPush(h.client, id), 2000, "outcome unknown");
assert.equal(outcomeUnknownPush(h.client, id).reason, RUN_OVERLAP);
assert.notEqual(h.client.receipt(id).state, "failed");
assert.equal(turnProofs(h.verifier).length, 0);
} finally {
await h.close();
}
});
// n1: the fence alone doesn't link an `aborted`; an abort must have been
// written in the stop's chain. Mutant r2-n1 (link to any stop in progress)
// fails here.
test("N9: an aborted that lands after the fence but before any abort is written: aborted-without-stop, Unknown", async () => {
const h = await setup();
try {
const id = await heldRun(h, [{ text: "", stop: "aborted", errorMessage: "Request was aborted" }]);
h.engine.arm("clear");
const stopId = await interrupt(h);
await h.engine.waitPaused("clear");
h.engine.resume("p1");
await until(() => signals(h.ctrl).includes("aborted-without-stop"), 4000, "the overlap");
h.engine.resume("clear");
assert.equal(cmds(h.engine, "abort").length, 0, "no abort was written");
assert.notEqual(h.client.receipt(id).state, "failed");
await until(() => outcomeUnknownPush(h.client, id), 2000, "outcome unknown");
await assertUncertainStop(h, stopId, "unknown");
} finally {
await h.close();
}
});
// N10: the fake's own behaviour, against pinned Pi's (agent-session.js
// 821–949, rpc-mode.js 298–335). Mutant 28 (a fake that queues a Mosaic
// prompt while streaming) fails here.
function rawFake(opts = {}) {
const input = new PassThrough(), output = new PassThrough();
const fake = new FakePi({ input, output, ...opts });
const lines = [];
output.pipe(new PassThrough()).on("data", () => {});
const splitter = new LineSplitter((l) => lines.push(parseLine(l).value));
output.on("data", (c) => splitter.push(c));
let n = 0;
const send = (type, fields = {}) => {
const id = `r${++n}`;
input.write(encodeLine({ id, type, ...fields }));
return id;
};
const response = (id) => until(() => lines.some((v) => v.type === "response" && v.id === id)).then(() => lines.find((v) => v.type === "response" && v.id === id));
return { fake, lines, send, response };
}
test("N10: fake conformance", async () => {
// Acks before running; agent_settled from a finally; several agent_start …
// agent_end pairs in one run.
{
const f = rawFake();
f.fake.script([{ continue: true }, { text: "ok", stop: "stop" }]);
const id = f.send("prompt", { message: "a" });
await until(() => f.lines.some((v) => v.type === "agent_settled"));
const types = f.lines.map((v) => v.type);
const ack = f.lines.findIndex((v) => v.type === "response" && v.id === id);
assert.ok(ack >= 0 && ack < types.indexOf("agent_start"), "ack before the run");
assert.equal(types.filter((t) => t === "agent_start").length, 2);
assert.equal(types.filter((t) => t === "agent_settled").length, 1);
assert.equal(types.at(-1), "agent_settled");
}
// A failing run still settles (finally).
{
const f = rawFake();
f.fake.script([{ failBeforeUser: "boom" }]);
f.send("prompt", { message: "a" });
await until(() => f.lines.some((v) => v.type === "agent_settled"));
assert.ok(!f.lines.some((v) => v.type === "message_start" && v.message.role === "user"));
}
// A prompt while streaming throws (no streamingBehavior); it is never
// queued.
{
const f = rawFake();
f.fake.script([{ pause: "p1" }, { text: "x" }]);
f.fake.arm("p1");
f.send("prompt", { message: "first" });
await f.fake.waitPaused("p1");
const before = f.lines.length;
const r = await f.response(f.send("prompt", { message: "second" }));
assert.equal(r.success, false);
assert.equal(r.error, BUSY_ERROR);
assert.ok(!f.lines.slice(before).some((v) => v.type === "queue_update"), "no queue_update: the prompt was not queued");
assert.equal(f.fake.steering.length + f.fake.followUp.length + f.fake.agentQueue.length + f.fake.nextTurn.length, 0);
f.fake.resume("p1");
await f.fake.waitIdle();
assert.equal(f.fake.runs.length, 1);
assert.ok(!f.lines.some((v) => v.type === "message_start" && v.message.content?.[0]?.text === "second"), "the second prompt never ran");
}
// Pauses at 843, 895 and 915, between the line-860 check and the run start:
// no ack until all three pass.
{
const f = rawFake();
for (const p of ["843", "895", "915"]) f.fake.arm(p);
const id = f.send("prompt", { message: "a" });
for (const p of ["843", "895", "915"]) {
await f.fake.waitPaused(p);
assert.ok(!f.lines.some((v) => v.id === id), `no ack while paused at ${p}`);
f.fake.resume(p);
}
await f.response(id);
await f.fake.waitIdle();
}
// A colliding prompt (past line 860 before the other run started) is
// acked, its throw swallowed, it settles with no agent_start, and leaves
// isStreaming false while the other run goes on.
{
const f = rawFake();
f.fake.script([{ pause: "p1" }, { text: "x" }]);
f.fake.arm("p1");
f.fake.arm("run-start");
const a = f.send("prompt", { message: "A" });
await f.fake.waitPaused("run-start");
await f.response(a);
f.fake.arm("915");
const b = f.send("prompt", { message: "B" });
await f.fake.waitPaused("915");
f.fake.resume("run-start");
await f.fake.waitPaused("p1");
const mark = f.lines.length;
f.fake.resume("915");
const rb = await f.response(b);
assert.equal(rb.success, true, "the colliding prompt is acked");
await until(() => f.lines.slice(mark).some((v) => v.type === "agent_settled"));
assert.ok(!f.lines.slice(mark).some((v) => v.type === "agent_start"), "no agent_start for the loser");
const st = await f.response(f.send("get_state"));
assert.equal(st.data.isStreaming, false, "isStreaming false while the other run goes on");
assert.ok(f.fake.run, "the other run is still going");
f.fake.resume("p1");
await f.fake.waitIdle();
}
// clear_queue: an empty queue_update before its response; returns steer and
// follow-up only; drops agent-level custom messages; keeps nextTurn.
{
const f = rawFake();
f.fake.queue("steer", "s");
f.fake.queue("followUp", "f");
f.fake.queue("agent", "custom");
f.fake.queue("nextTurn", "nt");
const mark = f.lines.length;
const id = f.send("clear_queue");
const r = await f.response(id);
const window = f.lines.slice(mark);
const at = window.findIndex((v) => v.id === id);
assert.deepEqual(window[at - 1], { type: "queue_update", steering: [], followUp: [] });
assert.deepEqual(r.data, { steering: ["s"], followUp: ["f"] });
assert.equal(f.fake.agentQueue.length, 0, "agent-level custom messages are dropped, not returned");
assert.equal(f.fake.nextTurn.length, 1, "nextTurn survives the clear");
}
});
test("N11: the run fails before any user message_start: delivery-unknown ack-without-start, never failed", async () => {
const h = await setup();
try {
const before = sessionText(h.fx);
h.engine.script([{ failBeforeUser: "provider refused" }]);
const id = await prompt(h);
const r = await receiptState(h.client, id, "delivery-unknown");
assert.equal(r.reasonCode, ACK_WITHOUT_START);
assert.ok(!h.client.pushes.some((p) => p.kind === "receipt" && p.receipt.id === id && p.receipt.state === "failed"));
const added = sessionText(h.fx).slice(before.length).split("\n").filter(Boolean).map((l) => JSON.parse(l));
assert.ok(!added.some((e) => e.message?.role === "user"), "the session gains no user entry");
assert.equal(cmds(h.engine, "prompt").length, 1);
} finally {
await h.close();
}
// The same schedule with the failure line unparseable.
const h2 = await setup();
try {
h2.engine.script([{ failBeforeUser: "x", raw: "{not json\n" }]);
const id = await prompt(h2);
const r = await receiptState(h2.client, id, "delivery-unknown");
assert.equal(r.reasonCode, TRANSPORT_UNKNOWN);
assert.equal(h2.ctrl.binding.state, "uncertain");
} finally {
await h2.close();
}
});
test("N12: input that starts a run after the final empty clear is O1 and not part of the stop's proof", async () => {
const h = await setup();
try {
await heldRun(h);
const stopId = await interrupt(h);
await until(() => reconciled(h.ctrl, stopId), 6000, "reconciled");
const ev = lastStopEvidence(h.ctrl, stopId);
const lastClear = ev.clears.at(-1);
const proof = turnProofs(h.verifier).find((p) => p.stop === stopId);
assert.equal(proof.observedAt, lastClear.at, "the stop records the final clear's observedAt");
void h.engine.extensionPrompt({ text: "late" });
await until(() => signals(h.ctrl).includes("O1"), 2000, "O1");
const o1 = h.ctrl.evidence.overlaps.find((o) => o.signal === "O1");
assert.equal(o1.why, "no slot held");
assert.ok(o1.idx > lastClear.idx, "the run came after the proof's observation");
assert.equal(h.ctrl.binding.state, "uncertain");
assert.equal(h.ctrl.binding.admission, "closed");
assert.equal(h.ctrl.stops.get(stopId).state, "turn-interrupted", "the stopped turn's proof is unchanged");
} finally {
await h.close();
}
});
test("N13: agent_start with no slot held is O1; a later prompt refuses with zero engine bytes", async () => {
const h = await setup();
try {
h.engine.emit({ type: "agent_start" });
await until(() => signals(h.ctrl).includes("O1"), 2000, "O1");
assert.equal(h.ctrl.binding.state, "uncertain");
assert.equal(h.ctrl.binding.admission, "closed");
assert.ok(h.ctrl.evidence.uncertain.some((u) => u.reason === RUN_OVERLAP));
const before = h.engine.bytes().length;
const r = await h.client.prompt("after");
assert.equal(r.refusal, "fenced");
await tick(50);
assert.equal(h.engine.bytes().length, before);
} finally {
await h.close();
}
});
test("N14: the run completes while clear_queue is in flight: finished, Completed first, uncertain", async () => {
const h = await setup();
try {
const id = await heldRun(h, [{ text: "done", stop: "stop" }]);
h.engine.arm("clear");
const stopId = await interrupt(h);
await h.engine.waitPaused("clear");
h.engine.resume("p1");
await receiptState(h.client, id, "finished");
h.engine.resume("clear");
await stopSettled(h, stopId);
assert.equal(cmds(h.engine, "abort").length, 1, "the abort reached an idle engine");
assert.ok(lastStopEvidence(h.ctrl, stopId).clears.every((c) => c.empty));
assert.equal(h.client.receipt(id).state, "finished", "never relabelled interrupted");
assert.equal(h.client.receipt(id).reasonCode, null);
await assertUncertainStop(h, stopId, "completed-first");
} finally {
await h.close();
}
});
test("N14: the run completes after the abort is written, before Pi applies it: finished, never relabelled", async () => {
const h = await setup();
try {
const id = await heldRun(h, [{ text: "done", stop: "stop" }]);
h.engine.arm("abort");
const stopId = await interrupt(h);
await h.engine.waitPaused("abort");
h.engine.resume("p1");
await receiptState(h.client, id, "finished");
h.engine.resume("abort");
await stopSettled(h, stopId);
assert.equal(cmds(h.engine, "abort").length, 1);
assert.equal(h.client.receipt(id).state, "finished", "a run that ended stop is never relabelled interrupted");
assert.equal(h.client.receipt(id).reasonCode, null);
await assertUncertainStop(h, stopId, "completed-first");
} finally {
await h.close();
}
});
test("N15: the fence lands in preflight, then an input handler takes it: handled-without-run, No run", async () => {
const h = await setup();
try {
h.engine.arm("843");
h.engine.plan({ handled: true });
const id = await prompt(h);
await h.engine.waitPaused("843");
const stopId = await interrupt(h);
await until(() => cmds(h.engine, "abort").length === 1, 3000, "abort");
h.engine.resume("843");
const r = await receiptState(h.client, id, "delivery-unknown");
assert.equal(r.reasonCode, HANDLED_WITHOUT_RUN);
await stopSettled(h, stopId);
assert.equal(cmds(h.engine, "abort").length, 1, "no second abort");
assert.equal(cmds(h.engine, "clear_queue").length, 1, "no second clear");
await assertUncertainStop(h, stopId, "no-run");
} finally {
await h.close();
}
});
test("N16: Interrupt with no slot and no run refuses no-turn: no stop, no bytes, admission open", async () => {
const h = await setup();
try {
const before = h.engine.bytes().length;
const r = await h.client.interrupt();
assert.equal(r.refusal, NO_TURN);
assert.deepEqual(r.effect, { dispatchRefused: null });
assert.equal(h.ctrl.stops.size, 0);
assert.equal(h.ctrl.binding.stop, null);
assert.equal(h.engine.bytes().length, before);
assert.equal(h.ctrl.binding.admission, "open");
const p = await h.client.prompt("next");
assert.equal(p.outcome, "admitted");
await receiptState(h.client, p.receipt.id, "finished");
} finally {
await h.close();
}
});
test("N17: the run fails on its own during the exchange: failed, Failed on its own", async () => {
const h = await setup();
try {
const id = await heldRun(h, [{ text: "", stop: "error", errorMessage: "model error" }]);
h.engine.arm("clear");
const stopId = await interrupt(h);
await h.engine.waitPaused("clear");
h.engine.resume("p1");
const r = await receiptState(h.client, id, "failed");
assert.equal(r.reasonCode, null);
h.engine.resume("clear");
await assertUncertainStop(h, stopId, "failed-on-its-own");
} finally {
await h.close();
}
});
test("N18: no final assistant message_end, or a lost line: working stays working; before working, transport-unknown", async () => {
// No final assistant message during the exchange.
{
const h = await setup();
try {
const id = await heldRun(h, [{ text: "partial", final: false }]);
h.engine.arm("clear");
const stopId = await interrupt(h);
await h.engine.waitPaused("clear");
h.engine.resume("p1");
await until(() => outcomeUnknownPush(h.client, id), 2000, "outcome unknown");
h.engine.resume("clear");
const s = await stopSettled(h, stopId);
assert.equal(s.state, "uncertain");
assert.equal(h.client.receipt(id).state, "working");
assert.equal(lastStopEvidence(h.ctrl, stopId).outcome, "unknown");
assert.equal(h.ctrl.binding.state, "uncertain");
} finally {
await h.close();
}
}
// A lost (unparseable) line after working never moves it back.
{
const h = await setup();
try {
const id = await heldRun(h, [{ raw: "{lost\n" }, { text: "x" }]);
h.engine.resume("p1");
await until(() => outcomeUnknownPush(h.client, id), 2000, "outcome unknown");
await tick(50);
assert.equal(h.client.receipt(id).state, "working");
assert.equal(outcomeUnknownPush(h.client, id).reason, TRANSPORT_UNKNOWN);
assert.ok(!h.client.pushes.some((p) => p.kind === "receipt" && p.receipt.id === id && p.receipt.state === "delivery-unknown"));
} finally {
await h.close();
}
}
// A lost line before working.
{
const h = await setup();
try {
h.engine.arm("run-start");
h.engine.arm("state");
const id = await prompt(h);
await h.engine.waitPaused("run-start");
await receiptState(h.client, id, "acknowledged");
h.engine.raw("{lost\n");
const r = await receiptState(h.client, id, "delivery-unknown");
assert.equal(r.reasonCode, TRANSPORT_UNKNOWN);
h.engine.resume("state");
h.engine.resume("run-start");
} finally {
await h.close();
}
}
});
test("N19: a losing extension prompt settles inside the Mosaic run before its user message: O3, run-overlap", async () => {
const h = await setup();
try {
h.engine.arm("ext:915");
void h.engine.extensionPrompt({ text: "loser" });
await h.engine.waitPaused("ext:915");
h.engine.arm("after-start");
const id = await prompt(h);
await h.engine.waitPaused("after-start");
await receiptState(h.client, id, "acknowledged");
h.engine.resume("ext:915");
await until(() => signals(h.ctrl).includes("O3"), 2000, "O3");
h.engine.resume("after-start");
await h.engine.waitIdle();
const r = await receiptState(h.client, id, "delivery-unknown");
assert.equal(r.reasonCode, RUN_OVERLAP, "never failed ack-without-start");
await tick(50);
assert.equal(h.client.receipt(id).state, "delivery-unknown");
assert.equal(h.ctrl.binding.state, "uncertain");
} finally {
await h.close();
}
// Before the Mosaic agent_start: O4.
const h2 = await setup();
try {
h2.engine.arm("run-start");
h2.engine.arm("state");
const id = await prompt(h2);
await h2.engine.waitPaused("run-start");
await h2.engine.waitPaused("state");
h2.engine.emit({ type: "agent_settled" });
await until(() => signals(h2.ctrl).includes("O4"), 2000, "O4");
const r = await receiptState(h2.client, id, "delivery-unknown");
assert.equal(r.reasonCode, RUN_OVERLAP);
h2.engine.resume("state");
h2.engine.resume("run-start");
await h2.engine.waitIdle();
await tick(50);
assert.equal(h2.client.receipt(id).state, "delivery-unknown");
assert.equal(h2.ctrl.binding.state, "uncertain");
} finally {
await h2.close();
}
});
test("N20: an extension triggerTurn during Mosaic preflight starts first; while streaming it queues with no signal", async () => {
const h = await setup();
try {
h.engine.arm("915");
const id = await prompt(h);
await h.engine.waitPaused("915");
h.engine.arm("ext-hold");
void h.engine.extensionPrompt({ text: "timer", preflight: false, steps: [{ pause: "ext-hold" }, { text: "t" }] });
await h.engine.waitPaused("ext-hold");
await until(() => signals(h.ctrl).includes("O1"), 2000, "O1");
h.engine.resume("915");
h.engine.resume("ext-hold");
await h.engine.waitIdle();
const r = await receiptState(h.client, id, "delivery-unknown");
assert.equal(r.reasonCode, RUN_OVERLAP);
await tick(50);
assert.ok(!h.client.pushes.some((p) => p.kind === "receipt" && p.receipt.id === id && ["finished", "failed"].includes(p.receipt.state)), "never finished or failed");
assert.equal(h.ctrl.binding.state, "uncertain");
} finally {
await h.close();
}
// While the Mosaic run streams: queued straight into the agent, no signal.
const h2 = await setup();
try {
const id = await heldRun(h2, [{ text: "done", stop: "stop" }]);
const q = await h2.engine.extensionPrompt({ text: "timer", preflight: false });
assert.deepEqual(q, { queued: true });
h2.engine.resume("p1");
await receiptState(h2.client, id, "finished");
assert.deepEqual(signals(h2.ctrl), [], "Limit 7: no signal fires");
assert.ok(h2.engine.sent.some((l) => l.includes('"customType":"fake"') && l.includes("timer")), "the custom message ran inside the Mosaic run");
} finally {
await h2.close();
}
});
test("N21: a losing settle after the receipt settled finished is O2; the receipt stays finished", async () => {
const h = await setup();
try {
const id = await prompt(h);
await receiptState(h.client, id, "finished");
h.engine.emit({ type: "agent_settled" });
await until(() => signals(h.ctrl).includes("O2"), 2000, "O2");
assert.equal(h.client.receipt(id).state, "finished");
const o2 = h.ctrl.evidence.overlaps.find((o) => o.signal === "O2");
assert.equal(o2.request, h.ctrl.receipts.get(id).request, "evidence records the overlap against it");
assert.equal(h.ctrl.binding.state, "uncertain");
assert.equal(h.ctrl.binding.admission, "closed");
assert.ok(h.ctrl.evidence.uncertain.some((u) => u.reason === RUN_OVERLAP));
} finally {
await h.close();
}
});
test("N22: an agent-level custom message is dropped by the clear with no signal; evidence names the seal", async () => {
const h = await setup();
try {
await heldRun(h);
h.engine.queue("agent", "custom from an extension");
const stopId = await interrupt(h);
await until(() => reconciled(h.ctrl, stopId), 6000, "reconciled");
assert.deepEqual(signals(h.ctrl), [], "Limit 7: no signal fires");
assert.equal(h.engine.agentQueue.length, 0);
assert.ok(!h.engine.sent.some((l) => l.includes("custom from an extension")), "the item is gone, never run");
const ev = lastStopEvidence(h.ctrl, stopId);
assert.equal(ev.agentLevelQueues, "unobservable");
assert.match(ev.queueBasis, /^seal:/);
assert.ok(!JSON.stringify(ev).includes("nothing removed"));
} finally {
await h.close();
}
});
test("N23: a nextTurn message survives clear and abort and attaches to the next prompt, with no signal", async () => {
const h = await setup();
try {
await heldRun(h);
h.engine.queue("nextTurn", "rides along");
const stopId = await interrupt(h);
await until(() => reconciled(h.ctrl, stopId), 6000, "reconciled");
assert.equal(h.engine.nextTurn.length, 1, "it survived");
const p = await h.client.prompt("next");
await receiptState(h.client, p.receipt.id, "finished");
assert.equal(h.engine.nextTurn.length, 0);
assert.ok(h.engine.sent.some((l) => l.includes("rides along") && l.includes('"message_start"')), "it attached to the next prompt");
assert.deepEqual(signals(h.ctrl), [], "Limit 7: no signal fires");
} finally {
await h.close();
}
});
test("N24: the seal is an allow-list: --extension, a missing --no-* flag, a second --mode or --session, a session or output flag, or a stray word refuses unsealed-engine; no engine starts", async () => {
const fx = fixture();
const outside = join(fx.base, "outside", "proj", ".pi", "state", "other", "sessions", "s1.jsonl");
for (const extraArgs of [
["--extension", "./ext.ts"], ["--extension=npm:some-ext"], ["-e", "git:github.com/x/y"], ["--extension", "npm:@scope/pkg"],
// Pi keeps the last --session and the last --mode (cli/args.js).
["--session", outside], ["--mode", "json"], ["--mode", "text"], ["--mode", "rpc"],
["--no-session"], ["--session-dir", fx.base], ["--session-id", "x"], ["--fork", fx.sessionFile], ["--continue"], ["-c"], ["--resume"],
["--print"], ["-p"], ["--export", join(fx.base, "out.html")], ["--prompt-template", "x"], ["--approve"], ["--no-extensions"],
// A bare word is a prompt, an @ word a file.
["hello"], ["@notes.md"],
// Allowed options: once each, one plain value.
["--model", "a", "--model", "b"], ["--model"], ["--model", "--mode"], ["--thinking", "-x"], ["--provider", "@p"], ["--model=other"],
]) {
const launcher = new FakeLauncher();
assert.throws(
() => new Controller({ fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat, launcher, units: noUnits, timeouts: FAST, engine: { extraArgs } }),
(e) => e.code === UNSEALED_ENGINE,
JSON.stringify(extraArgs),
);
assert.equal(launcher.launches.length, 0);
}
{
const launcher = new FakeLauncher();
assert.throws(
() => new Controller({ fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat, launcher, units: noUnits, engine: { preArgs: ["cli.js", "--extension", "x"] } }),
(e) => e.code === UNSEALED_ENGINE,
);
}
for (const engine of [{ extraArgs: "--model x" }, { extraArgs: 3 }, { preArgs: "cli.js" }]) {
assert.throws(
() => new Controller({ fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat, launcher: new FakeLauncher(), units: noUnits, engine }),
(e) => e.code === UNSEALED_ENGINE,
JSON.stringify(engine),
);
}
// The controller always builds the prefix itself, so a missing flag, a
// reordered prefix or a relative session path is reachable only through
// checkSeal, which bind runs on the argv.
for (const flag of SEAL_FLAGS) {
const args = ["--mode", "rpc", ...SEAL_FLAGS.filter((f) => f !== flag), "--session", fx.sessionFile];
assert.throws(() => checkSeal(args), (e) => e.code === UNSEALED_ENGINE, flag);
}
for (const args of [
["--mode", "rpc", "--session", fx.sessionFile, ...SEAL_FLAGS],
["--mode", "json", ...SEAL_FLAGS, "--session", fx.sessionFile],
["--mode", "rpc", ...SEAL_FLAGS, "--session", "s1.jsonl"],
["--mode", "rpc", ...SEAL_FLAGS, "--session"],
]) {
assert.throws(() => checkSeal(args), (e) => e.code === UNSEALED_ENGINE, JSON.stringify(args));
}
assert.equal(checkSeal(["--mode", "rpc", ...SEAL_FLAGS, "--session", fx.sessionFile, "--model", "m", "--provider", "p", "--thinking", "off"]), true);
// The argv a real bind launches carries all three and no --extension.
const h = await started({ fx: fixture() });
try {
const args = h.launcher.launches[0].args;
for (const flag of SEAL_FLAGS) assert.ok(args.includes(flag), flag);
assert.ok(!args.some((a) => a === "-e" || a.startsWith("--extension")));
assert.deepEqual(h.engine.argv.slice(0, 2), ["--mode", "rpc"]);
} finally {
await h.close();
}
});