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:
@@ -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.
|
||||
|
||||
@@ -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/" }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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") : "…";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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(", ")}`);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user