Files
stack/packages/conversation/tests/harness.mjs
T
jason.woltjeandClaude Opus 5.5 243e153c8b 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]>
2026-10-04 15:47:53 -05:00

220 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}
}