Files
stack/packages/conversation/src/engine.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

127 lines
5.1 KiB
JavaScript

// The engine pipe (#1507, CHAT-03 §1).
//
// One EngineLink per execution owns the engine's stdin and reads its stdout.
// Output is split on LF only (framing.mjs); every line reaches the controller
// in order, tagged with the link's execution, so a replaced engine's late
// output can be dropped by incarnation (H14).
//
// Write outcomes (§1):
// written the whole line was accepted by the pipe (the write callback ran
// without an error). That is not native consumption.
// unknown the write returned an error (EPIPE), or its callback did not run
// within the bound. A partial line may be in the pipe, so the link
// is poisoned and never written again.
// `acknowledged` is the response to a request, which `request()` reports
// separately. A request whose response does not come within its bound is
// reported as a timeout; the controller decides what that means.
import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
export const WRITE_TIMEOUT_MS = 5000;
export class EngineLink {
// `onLine(link, value, bytes)` gets every parsed non-response line;
// `onResponse(link, value, entry)` every response, before its promise
// resolves; `onGap(link, kind, detail)` an unparseable line, an unmatched
// response, an overlong line or EOF with a partial line.
constructor({ execution, stdin, stdout, onLine, onResponse = () => {}, onGap, onEnd = () => {}, writeTimeoutMs = WRITE_TIMEOUT_MS }) {
this.execution = execution;
this.stdin = stdin;
this.onLine = onLine;
this.onResponse = onResponse;
this.onGap = onGap;
this.onEnd = onEnd;
this.writeTimeoutMs = writeTimeoutMs;
this.poisoned = null;
this.pending = new Map();
this.serial = 0;
this.bytesWritten = 0;
this.ended = false;
this.writes = [];
stdin.on("error", (err) => {
this.stdinError = err.code ?? err.message;
});
this.splitter = new LineSplitter((line) => this.#line(line), { onOverflow: () => this.onGap(this, "overlong-line", null) });
stdout.on("data", (chunk) => this.splitter.push(chunk));
stdout.on("end", () => {
this.ended = true;
if (this.splitter.pending() > 0) this.onGap(this, "partial-line-at-eof", { bytes: this.splitter.pending() });
this.onEnd(this);
});
stdout.on("error", () => {});
}
#line(line) {
const bytes = Buffer.byteLength(line, "utf8");
const parsed = parseLine(line);
if (parsed.error) return this.onGap(this, "unparseable-line", { bytes, error: parsed.error });
const value = parsed.value;
if (value.type === "response") {
const entry = typeof value.id === "string" ? this.pending.get(value.id) : undefined;
if (!entry) return this.onGap(this, "unmatched-response", { bytes, command: typeof value.command === "string" ? value.command.slice(0, 40) : null });
this.pending.delete(value.id);
entry.late = entry.timedOut;
this.onResponse(this, value, entry);
entry.resolve({ response: value, late: entry.late });
return undefined;
}
return this.onLine(this, value, bytes);
}
poison(reason) {
if (!this.poisoned) this.poisoned = reason;
}
// Writes one record. Never writes to a poisoned link.
write(value) {
if (this.poisoned) return Promise.resolve({ outcome: "refused", reason: "poisoned" });
const line = encodeLine(value);
const bytes = Buffer.byteLength(line, "utf8");
return new Promise((resolve) => {
let done = false;
const finish = (o) => {
if (done) return;
done = true;
clearTimeout(timer);
if (o.outcome === "unknown") this.poison(o.reason);
else this.bytesWritten += bytes;
this.writes.push({ type: value.type, id: value.id, outcome: o.outcome });
resolve(o);
};
const timer = setTimeout(() => finish({ outcome: "unknown", reason: "write-timeout" }), this.writeTimeoutMs);
try {
this.stdin.write(line, (err) => finish(err ? { outcome: "unknown", reason: err.code ?? "write-error" } : { outcome: "written" }));
} catch (err) {
finish({ outcome: "unknown", reason: err.code ?? "write-error" });
}
});
}
// Sends a command. Returns { id, written, response }: `written` settles
// with the write outcome, `response` with { response } or { timeout: true }
// or { unsent: outcome }. The response is registered before the write, so
// it can never be unmatched.
request(type, fields = {}, { timeoutMs = 5000, beforeWrite = null } = {}) {
const id = `${type}-${++this.serial}`;
let resolve;
const response = new Promise((r) => (resolve = r));
const entry = { id, type, resolve, timedOut: false, late: false };
this.pending.set(id, entry);
beforeWrite?.(id);
const written = this.write({ id, type, ...fields });
written.then((w) => {
if (w.outcome !== "written") {
this.pending.delete(id);
resolve({ unsent: w });
return;
}
setTimeout(() => {
if (!this.pending.has(id)) return;
entry.timedOut = true; // kept, so a late answer is recognised and not a gap
resolve({ timeout: true });
}, timeoutMs);
});
return { id, written, response };
}
}