feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+79
-34
@@ -5,8 +5,12 @@
|
||||
// host runs, `<dataRoot>/bus-host/host.json` (0600) names it for `mosaic bus
|
||||
// stop|status` and for the human commands' default business.
|
||||
//
|
||||
// startHost() is also the in-process API S6 uses: bindLaunch(record) binds
|
||||
// a launched run's process identity and returns its capability.
|
||||
// startHost() is also the in-process API the S6 launcher (src/launcher.mjs)
|
||||
// uses: bindLaunch(record) binds a launched run's process identity and
|
||||
// returns its capability; op(message) runs one of the broker's launch ops
|
||||
// (identity, authorizeLaunch, refuse, endLaunch, credentialStatus); and
|
||||
// beforeClose(fn) runs fn before the host stops its children, while those
|
||||
// ops still work.
|
||||
|
||||
import { fork } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
@@ -14,35 +18,19 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
|
||||
const BROKER = fileURLToPath(new URL("../../bus/src/process.mjs", import.meta.url));
|
||||
const NOTIFIER = fileURLToPath(new URL("./notifier-process.mjs", import.meta.url));
|
||||
export const BOOT_TIMEOUT_MS = 30000;
|
||||
const START_TIMEOUT_MS = 15000;
|
||||
const CLOSE_TIMEOUT_MS = 20000;
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
export const hostDir = (dataRoot) => join(dataRoot, "bus-host");
|
||||
export const hostFile = (dataRoot) => join(hostDir(dataRoot), "host.json");
|
||||
|
||||
// `/proc/<pid>/stat` field 22, the start time in clock ticks. Null when the
|
||||
// process is gone or has exited and waits to be reaped (state Z).
|
||||
export function startTimeOf(pid) {
|
||||
try {
|
||||
const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const fields = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
|
||||
return fields[0] === "Z" ? null : fields[19];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cmdlineOf(pid) {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export { cmdlineOf, startTimeOf };
|
||||
|
||||
// The state file, or null. `live` is true only when the pid still runs with
|
||||
// the recorded start time, so a recycled pid never counts as the host.
|
||||
@@ -107,8 +95,9 @@ export function watchChildren(children, onDeath) {
|
||||
|
||||
// boot: the {op:'boot'} config from bootConfig(). notifier: null, or
|
||||
// {binding, base?, pollMs?}; base and pollMs exist for the tests, and the
|
||||
// command line never sets them. Resolves once both children are up.
|
||||
export async function startHost({ boot, business, notifier = null, bootTimeoutMs = BOOT_TIMEOUT_MS, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`) }) {
|
||||
// command line never sets them, nor requestTimeoutMs. Resolves once both
|
||||
// children are up.
|
||||
export async function startHost({ boot, business, notifier = null, bootTimeoutMs = BOOT_TIMEOUT_MS, requestTimeoutMs = REQUEST_TIMEOUT_MS, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`) }) {
|
||||
const dataRoot = boot.dataRoot;
|
||||
const prior = readHostState(dataRoot);
|
||||
if (prior?.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
|
||||
@@ -157,27 +146,83 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
|
||||
rmSync(hostFile(dataRoot), { force: true });
|
||||
writeHostState(dataRoot, state);
|
||||
|
||||
// stopping: close() has started (no new close, child deaths are expected).
|
||||
// closing: the before-close hooks have run, so no more broker requests.
|
||||
let stopping = false;
|
||||
let closing = false;
|
||||
let finish;
|
||||
const done = new Promise((r) => (finish = r));
|
||||
const hooks = [];
|
||||
|
||||
// Replies from process.mjs carry no request id, so requests go one at a time.
|
||||
// Requests go one at a time, each with an id that process.mjs echoes. A
|
||||
// reply that misses the wait, or carries an id no request is waiting for,
|
||||
// breaks the channel: the waiting request and every later one refuse, and
|
||||
// the host stops with exit 1 so the unit restarts it. A late reply can
|
||||
// never answer a later request.
|
||||
let queue = Promise.resolve();
|
||||
function bindLaunch(record) {
|
||||
let seq = 0;
|
||||
let pending = null;
|
||||
let broken = null;
|
||||
const fail = (code, text) => Object.assign(new CliError(text, 1), { code });
|
||||
function breakChannel(why) {
|
||||
if (broken) return;
|
||||
broken = why;
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(fail("broker-channel-broken", `broker channel broken: ${why}`));
|
||||
pending = null;
|
||||
}
|
||||
if (stopping) return;
|
||||
log(`broker channel broken (${why}); stopping the host`);
|
||||
close(1);
|
||||
}
|
||||
broker.on("message", (m) => {
|
||||
if (pending && m?.id === pending.id) {
|
||||
clearTimeout(pending.timer);
|
||||
const { resolve } = pending;
|
||||
pending = null;
|
||||
resolve(m);
|
||||
} else breakChannel(pending ? "reply for another request" : "reply with no request waiting");
|
||||
});
|
||||
broker.once("exit", () => {
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(fail("broker-exited", "broker exited before it replied"));
|
||||
pending = null;
|
||||
});
|
||||
function request(message, { what, pick }) {
|
||||
const run = queue.then(async () => {
|
||||
if (closing) throw new CliError("bus host is closing", 1);
|
||||
const r = firstReply(broker, 10000, "broker");
|
||||
broker.send({ op: "bindLaunch", record });
|
||||
const m = await r;
|
||||
if (m?.ok !== true) throw new CliError(`launch bind refused: ${m?.error ?? "bind-refused"}`, 3);
|
||||
return m.launch;
|
||||
if (broken) throw fail("broker-channel-broken", `broker channel broken: ${broken}`);
|
||||
if (closing || !broker.connected) throw fail("host-closing", "bus host is closing");
|
||||
const id = ++seq;
|
||||
const m = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => breakChannel(`no reply within ${Math.round(requestTimeoutMs / 1000)} s`), requestTimeoutMs);
|
||||
pending = { id, resolve, reject, timer };
|
||||
broker.send({ ...message, id });
|
||||
});
|
||||
if (m?.ok !== true) {
|
||||
const code = typeof m?.error === "string" ? m.error : `${what}-refused`;
|
||||
throw Object.assign(new CliError(`${what} refused: ${code}`, 3), { code });
|
||||
}
|
||||
return pick(m);
|
||||
});
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
const bindLaunch = (record) => request({ op: "bindLaunch", record }, { what: "launch bind", pick: (m) => m.launch });
|
||||
const op = (message) => request(message, { what: message.op, pick: (m) => m.result });
|
||||
const beforeClose = (fn) => void hooks.push(fn);
|
||||
|
||||
async function close(code = 0) {
|
||||
if (closing) return done;
|
||||
if (stopping) return done;
|
||||
stopping = true;
|
||||
for (const fn of hooks) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
log(`before close: ${e.message}`);
|
||||
}
|
||||
}
|
||||
closing = true;
|
||||
let result = code;
|
||||
if (notify) {
|
||||
@@ -197,12 +242,12 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
|
||||
// restarts the pair rather than run a broker without its notifier.
|
||||
// Runs after `queue` exists, since close() awaits it.
|
||||
watchChildren({ broker, notifier: notify }, (name, code, signal) => {
|
||||
if (closing) return;
|
||||
if (stopping) return;
|
||||
log(`${name} exited (${signal ?? code}); stopping the host`);
|
||||
close(1);
|
||||
});
|
||||
|
||||
return Object.freeze({ path: ready.path, business, bindLaunch, close, done, pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
|
||||
return Object.freeze({ path: ready.path, dataRoot, business, bindLaunch, op, beforeClose, close, done, pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
|
||||
}
|
||||
|
||||
// `mosaic bus stop`: SIGTERM to the recorded host, after checking that the
|
||||
|
||||
Reference in New Issue
Block a user