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:
2026-10-11 12:22:50 -05:00
co-authored by Claude Opus 5.5
parent ee7f909bd3
commit fe5c68fa03
42 changed files with 4998 additions and 63 deletions
+113 -6
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env node
// mosaic inbox | decide | tasks | agents | trail, and mosaic bus start|stop|status.
// See packages/cli/README.md. Exit codes are in src/errors.mjs.
// mosaic inbox | decide | tasks | agents | trail | talk | stop | launches,
// and mosaic bus start|stop|status. See packages/cli/README.md. Exit codes
// are in src/errors.mjs.
//
// The human commands read and resolve through the broker's human transport
// (src/transport.mjs), never the SQLite file. `bus start` is the trusted
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs.
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs; with
// --pm it also launches the business's PM (src/launcher.mjs).
import { lstatSync, readFileSync } from "node:fs";
import { join } from "node:path";
@@ -16,6 +18,9 @@ import { bootConfig, loadSystem, socketPath } from "./config.mjs";
import { humanTransport, refuseInsideAgent } from "./transport.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
export const USAGE = `usage:
mosaic inbox [--business <id>] [--json]
@@ -23,7 +28,11 @@ export const USAGE = `usage:
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic bus start <business>
mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
mosaic bus start <business> [--pm]
mosaic bus stop
mosaic bus status [--json]`;
@@ -146,17 +155,112 @@ async function human(verb, rest, io, deps) {
return print(await call("agents"), formatAgents);
}
const TALK_WAIT_S = 900;
const TALK_POLL_MS = 2000;
function formatMessage(m) {
const meta = [m.class, m.in_reply_to ? `re ${shortId(m.in_reply_to)}` : null].filter(Boolean).join(", ");
return `${m.from_role} (${meta}) ${shortId(m.id)}:\n${m.body.trimEnd()}\n`;
}
// `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox
// until the reply arrives or --wait runs out. Every message received on the
// way is printed and marked read, so a late reply shows up on the next talk.
async function talk(rest, io, deps) {
const parsed = parse(rest, { values: ["--business", "--wait"] });
if (parsed.positional.length !== 2) throw usage("talk takes a role and one quoted text");
const wait = parsed.values["--wait"] === undefined ? TALK_WAIT_S : Number(parsed.values["--wait"]);
if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const business = businessFor(parsed, system.dataRoot);
const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
const [to, body] = parsed.positional;
const sent = await call("message.send", { to, body, class: "REQUEST" });
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
if (wait === 0) return;
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
const deadline = Date.now() + wait * 1000;
for (;;) {
let answered = false;
for (const m of await call("message.receive")) {
io.stdout.write(formatMessage(m));
await call("message.read", { id: m.id });
if (m.in_reply_to === sent.id) answered = true;
}
if (answered) return;
if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk or mosaic inbox shows it`, 1);
await new Promise((r) => setTimeout(r, pollMs));
}
}
// Seat errors carry the seat's exit codes; here they become CLI errors.
async function seat(fn) {
try {
return await fn();
} catch (e) {
if (e instanceof SeatError) throw new CliError(e.message, e.exitCode === 2 ? 2 : 1);
throw e;
}
}
async function stop(rest, io, deps) {
const parsed = parse(rest, {});
if (parsed.positional.length !== 1) throw usage("stop takes one run id; see mosaic launches list");
refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const r = await seat(() => stopSession(system.dataRoot, parsed.positional[0], deps.stopTimeoutMs ? { timeoutMs: deps.stopTimeoutMs } : {}));
io.stdout.write(r.stopped ? `stopped ${r.business}/${r.instance} run ${r.run}\n` : `${r.reason}\n`);
}
async function launches(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "off" || sub === "on") {
const parsed = parse(args, { values: ["--business"] });
if (parsed.positional.length !== 0) throw usage(`launches ${sub} takes no arguments`);
refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const business = businessFor(parsed, system.dataRoot);
const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
await call(sub === "off" ? "launch.revoke" : "launch.restore");
io.stdout.write(sub === "off" ? `launches off for ${business}: the PM's launch requests are refused until mosaic launches on; running sessions keep running\n` : `launches on for ${business}\n`);
return;
}
if (sub === "list") {
const parsed = parse(args, { flags: ["--json"] });
if (parsed.positional.length !== 0) throw usage("launches list takes no arguments");
const system = deps.system ?? loadSystem({ env: io.env });
const list = await seat(() => readSessions(system.dataRoot));
if (parsed.flags.has("--json")) return io.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
if (list.length === 0) return io.stdout.write("no sessions\n");
const lines = list.map((s) => `${s.run} ${s.business}/${s.instance} ${s.harness} ${s.model} by ${s.launchedBy} since ${s.startedAt} ${s.live ? "running" : "not running (stale)"}`);
return io.stdout.write(`${lines.join("\n")}\n`);
}
throw usage();
}
async function bus(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "start") {
const parsed = parse(args, {});
const parsed = parse(args, { flags: ["--pm"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
const businessId = parsed.positional[0];
const system = deps.system ?? loadSystem({ env: io.env });
const boot = bootConfig({ system, businessId, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
const binding = readNotifyConfig(system.dataRoot, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
try {
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), ...(deps.launcher ?? {}) });
await launcher.listen();
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
if (parsed.flags.has("--pm")) {
const pm = await launcher.launchPm();
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
}
} catch (e) {
await host.close(1);
throw e;
}
const stop = () => host.close(0);
process.on("SIGTERM", stop);
process.on("SIGINT", stop);
@@ -195,6 +299,9 @@ async function bus(rest, io, deps) {
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "talk") return talk(rest, io, deps);
if (verb === "stop") return stop(rest, io, deps);
if (verb === "launches") return launches(rest, io, deps);
if (verb === "bus") return bus(rest, io, deps);
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
throw usage();
+79 -34
View File
@@ -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
+420
View File
@@ -0,0 +1,420 @@
// The S6 launcher, inside the trusted bus host: it starts managed role
// sessions (packages/seat/src/session.mjs) and serves the launch socket the
// PM's `launch` tool calls. One launch at a time.
//
// A session asks over `<dataRoot>/bus-host/launch.sock` (0600) with one line,
// `{"cap": "<its capability>", "instance": "<role instance>"}`, and gets one
// line back: `{"ok": true, "result": {run, instance, harness, model}}` or
// `{"ok": false, "error": "<code>"}`. The host checks, in order:
//
// 1. identity: the capability names a role of this business
// 2. the business file's launch.instances lists the instance
// (instance-not-listed)
// 3. the instance isn't already running (instance-running)
// 4. it resolves, has a role contract (no-role-contract) and a supported
// harness and model (unsupported-harness)
// 5. its model is in exactly one launch.max family (unknown-model-family)
// 6. that family has room; every running session counts, the PM's too
// (capacity-full)
// 7. the broker authorizes role.launch for the caller (its own codes:
// launch-revoked, gated, ...)
//
// Steps 2 to 6 refuse through the broker's refuse(), so every refusal is an
// action.refused event; step 7's refusals the broker records itself. Then
// the host builds the bundle, spawns the runner, binds the run's process
// identity (the outer unshare pid) and writes the new capability on the
// runner's stdin. When the runner exits, the host ends the launch.
//
// `bus start <business> --pm` launches launch.by the same way with
// launchedBy = the business's human and no caller capability.
import { spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { buildBundle, sessionModel } from "../../harness/src/bundle.mjs";
import {
ADAPTERS,
appendLaunchLog,
endReason,
family,
launchSocketPath,
newRun,
readSessions,
Registry,
runnerOf,
sessionEnv,
spawnSession,
UNSHARE,
workspaceDir,
writeSessionFile,
} from "../../seat/src/session.mjs";
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
import { REPO, rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// A refusal the host decided; `code` goes back to the caller and to the broker.
class Refusal extends Error {
constructor(code, message = code) {
super(message);
this.code = code;
}
}
// The exact pi pin from the repository's package.json.
export function piVersion(repo = REPO) {
try {
const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8"));
return pkg.dependencies?.[PI_PACKAGE] ?? pkg.devDependencies?.[PI_PACKAGE] ?? null;
} catch {
return null;
}
}
// `claude --version`, or null when it doesn't answer.
export function claudeVersion(env = process.env) {
const r = spawnSync("claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
}
// host: startHost()'s handle. tracker: true when the broker has task verbs for
// the business. adapters, namespace, sessionDefaults, versions and
// stopTimeoutMs exist for the tests; the command line never sets them.
export function createLauncher({
host,
system,
env = process.env,
tracker = false,
adapters = ADAPTERS,
namespace = true,
sessionDefaults = {},
versions = null,
stopTimeoutMs = STOP_TIMEOUT_MS,
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
const dataRoot = system.dataRoot;
const b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
const vars = systemVars(system);
// Sessions an earlier host left in sessions.json: it died without ending
// them, so their runs still hold their roles. listen() ends them. A file
// that can't be read refuses the host rather than drop them unseen.
let leftover;
try {
leftover = readSessions(dataRoot).filter((s) => s.business === b.id);
} catch (e) {
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
}
const registry = new Registry(dataRoot);
const children = new Map();
let chain = Promise.resolve();
let closed = false;
let server = null;
const sockets = new Set();
const record = (entry) => {
try {
appendLaunchLog(dataRoot, b.id, entry);
} catch (e) {
log(`launch log: ${e.message}`);
}
};
function serialize(fn) {
const run = chain.then(fn);
chain = run.catch(() => {});
return run;
}
// Steps 2 to 6. Returns what the launch needs. Only `--pm` may name
// launch.by itself.
function check(instance, { pm = false } = {}) {
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
if (registry.running(b.id, instance)) throw new Refusal("instance-running");
let resolved, model;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
model = sessionModel(resolved);
} catch (e) {
throw new Refusal("unsupported-harness", e.message);
}
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
const fam = family(model.model, b.launch.max);
if (!fam) throw new Refusal("unknown-model-family", `model ${model.model} is in none, or more than one, of ${Object.keys(b.launch.max).join(", ")}`);
if (registry.count(b.id, fam) >= b.launch.max[fam]) throw new Refusal("capacity-full", `${fam} sessions: ${registry.count(b.id, fam)} of ${b.launch.max[fam]}`);
return { resolved, model, fam };
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, fam }) {
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
const { run, runDir } = newRun(dataRoot, b.id);
const workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 });
const toolSocket = join(runDir, "tools.sock");
const turnMarker = join(runDir, "turn.done");
const v = versions ?? { pi: piVersion(), claude: model.harness === "claude-code" ? claudeVersion(env) : null };
const bundle = buildBundle({
dir: join(runDir, "bundle"),
resolved,
contract: readFileSync(resolved.contract, "utf8"),
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
credentials,
session: { run, launchedBy, workspace, toolSocket, turnMarker },
piVersion: v.pi,
claudeVersion: v.claude,
});
const { manifest, ...files } = bundle.files;
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
instance,
run,
launchedBy,
harness: model.harness,
provider: model.provider,
model: model.model,
brokerSocket: host.path,
launchSocket: launchSocketPath(dataRoot),
workspace,
sessionDir,
adapter: adapters[model.harness],
bundle: files,
toolSocket,
turnMarker,
});
const child = spawnSession({ runDir, env: sessionEnv(env, run), namespace });
let bound = false;
let entry = null;
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal })));
child.on("error", (e) => log(`run ${run}: ${e.message}`));
child.stdin.on("error", () => {});
try {
if (child.pid === undefined) throw new Error("the session process did not start");
const outerStart = startTimeOf(child.pid);
const runner = await runnerOf(child, { namespace });
if (!outerStart) throw new Error("the session process exited at once");
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, pid: child.pid, startTime: outerStart });
bound = true;
entry = {
business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, family: fam,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
child.stdin.end(`${JSON.stringify({ cap: launch.cap })}\n`);
} catch (e) {
child.kill("SIGKILL");
await exited;
if (bound) await endRun(run, "failed", null);
throw new Refusal("launch-failed", e.message);
}
children.set(run, exited);
exited.then(async ({ code, signal }) => {
const reason = endReason(code, signal);
try {
await endRun(run, reason, signal ? null : code);
registry.remove(b.id, run);
record({ event: "end", instance, run, reason, exitCode: code, signal });
log(`run ${run} (${instance}) ended: ${reason}`);
} catch (e) {
log(`run ${run}: ${e.message}`);
} finally {
children.delete(run);
}
});
record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, family: fam, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
async function endRun(run, reason, exitCode) {
try {
await host.op({ op: "endLaunch", record: { business: b.id, run, reason, exitCode } });
} catch (e) {
log(`run ${run}: endLaunch refused: ${e.code ?? e.message}`);
}
}
// A launch a session asked for. Resolves to the reply line's object.
function fromSession(cap, instance) {
return serialize(async () => {
if (closed) return { ok: false, error: "host-closing" };
let who;
try {
who = await host.op({ op: "identity", cap });
} catch (e) {
return { ok: false, error: e.code ?? "unauthenticated" };
}
if (!who.role || who.business !== b.id) {
await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
return { ok: false, error: "not-a-role" };
}
let checked;
try {
if (typeof instance !== "string") throw new Refusal("invalid-request");
checked = check(instance);
} catch (e) {
if (!(e instanceof Refusal)) throw e;
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message });
return { ok: false, error: e.code };
}
try {
await host.op({ op: "authorizeLaunch", cap, instance });
} catch (e) {
record({ event: "refused", by: who.role, instance, code: e.code ?? "not-authorized" });
return { ok: false, error: e.code ?? "not-authorized" };
}
try {
return { ok: true, result: await start({ instance, launchedBy: who.role, ...checked }) };
} catch (e) {
const code = e instanceof Refusal ? e.code : "launch-failed";
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
await host.op({ op: "refuse", cap, code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code, why: e.message });
return { ok: false, error: code };
}
});
}
// `bus start --pm`: the business's launcher instance, launched by the human.
function launchPm() {
return serialize(async () => {
if (!b.launch) throw new CliError(`business ${b.id} has no launch block, so --pm has nothing to launch`, 3);
try {
return await start({ instance: b.launch.by, launchedBy: b.human, ...check(b.launch.by, { pm: true }) });
} catch (e) {
if (!(e instanceof Refusal)) throw e;
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message });
throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
}
});
}
// The earlier host's sessions. Their capabilities died with its broker, so
// one that still runs can do nothing: kill it (the namespace takes its
// turn with it). Then end each run in the bus, which releases its role.
// This broker never bound those runs, so each is bound again first with
// its identical launch record, which the broker accepts.
async function recover() {
for (const s of leftover) {
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === UNSHARE[0]) {
try {
process.kill(s.pid, "SIGKILL");
} catch {}
const end = Date.now() + 5000;
while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
}
try {
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime });
} catch (e) {
// A host that died after ending the run but before updating sessions.json.
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
else log(`run ${s.run} (${s.instance}) from an earlier host: not ended, bindLaunch refused: ${e.code ?? e.message}`);
continue;
}
await endRun(s.run, "host-lost", null);
record({ event: "end", instance: s.instance, run: s.run, reason: "host-lost", exitCode: null, signal: null });
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
}
leftover = [];
}
async function listen() {
await recover();
const path = launchSocketPath(dataRoot);
if (existsSync(path)) {
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
rmSync(path);
}
server = createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
let buf = "";
let answered = false;
const reply = (obj) => {
if (answered) return;
answered = true;
socket.end(`${JSON.stringify(obj)}\n`);
};
socket.setTimeout(10000, () => reply({ ok: false, error: "invalid-request" }));
socket.on("error", () => {});
socket.on("data", (chunk) => {
if (answered) return;
buf += chunk;
if (buf.length > LINE_MAX) return reply({ ok: false, error: "invalid-request" });
const nl = buf.indexOf("\n");
if (nl < 0) return;
socket.setTimeout(0);
let req;
try {
req = JSON.parse(buf.slice(0, nl));
} catch {
return reply({ ok: false, error: "invalid-request" });
}
if (typeof req?.cap !== "string" || !/^[0-9a-f]{64}$/.test(req.cap)) return reply({ ok: false, error: "unauthenticated" });
fromSession(req.cap, req.instance).then(reply, (e) => {
log(`launch socket: ${e.message}`);
reply({ ok: false, error: "launch-failed" });
});
});
});
// bus-host/ is 0700, so the moment before the chmod exposes nothing.
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(path, resolve);
});
chmodSync(path, 0o600);
}
// SIGTERM every runner, wait, then end what's left. Runs before the host
// stops the broker, so endLaunch still works.
async function close() {
closed = true;
if (server) {
// server.close waits for every connection, and a client that never
// ends its side (or never sends) would hold it; so they go first.
const stopped = new Promise((r) => server.close(r));
for (const socket of sockets) socket.destroy();
await stopped;
rmSync(launchSocketPath(dataRoot), { force: true });
}
await chain;
// Again every second: a runner still starting has no handler yet, and as
// pid 1 of its namespace it never sees a signal sent before it has one.
const term = () => {
for (const s of registry.sessions) {
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
}
};
term();
const again = setInterval(term, 1000);
const pending = [...children.values()];
const timer = setTimeout(() => {
for (const s of registry.sessions) {
try {
if (startTimeOf(s.pid) === s.startTime) process.kill(s.pid, "SIGKILL");
} catch {}
}
}, stopTimeoutMs);
await Promise.all(pending);
// The exit handlers run on the same tick as the close events; let them finish.
while (children.size) await new Promise((r) => setTimeout(r, 20));
clearInterval(again);
clearTimeout(timer);
}
host.beforeClose(close);
return { business: b, registry, listen, launchPm, fromSession, close };
}