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]>
421 lines
17 KiB
JavaScript
421 lines
17 KiB
JavaScript
// 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 };
|
|
}
|