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:
@@ -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