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
+120 -10
View File
@@ -2,9 +2,10 @@
Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
`decide`, `tasks`, `agents` and `trail`, and the bus host `mosaic bus
start|stop|status` with its Discord notifier. Brief:
`docs/plans/2026-10-04_slice-1.md`, row S4. Rulings: lead decision 70 in
`docs/plans/2026-09-26_lead-decisions.md`.
start|stop|status` with its Discord notifier (row S4, #1521); the session
launcher in the host and `mosaic talk`, `stop` and `launches` (row S6,
#1523). Brief: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6. Rulings:
lead decisions 70 and 77 in `docs/plans/2026-09-26_lead-decisions.md`.
Every command reads through the broker. None opens the SQLite file.
@@ -24,6 +25,10 @@ mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
```
- The business is `--business`, or else the business of the bus host running
@@ -49,11 +54,28 @@ mosaic trail <task|decision> [--business <id>] [--json]
- `trail` prints rows in the broker's order (at, table, seq). A decision's
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
does not pull in the task's rows.
- `talk` sends `<text>` to the role as a REQUEST, then polls the human's
inbox every 2 s for up to `--wait` seconds (default 900). It prints every
message that arrives and marks it read, and stops at the reply to its
REQUEST. No reply in time exits 1; the reply still lands in the inbox.
`--wait 0` only sends.
- `launches off` and `on` are the broker's `launch.revoke` and
`launch.restore`. While off, every `role.launch` refuses with
`launch-revoked`; running sessions keep running. Stop them with `stop`.
`bus start --pm` doesn't ask the broker (`launchPm` skips
`authorizeLaunch`): the human launches the PM, so `launches off` doesn't
stop it. The other checks in "Sessions" below still apply.
- `stop` and `launches list` read `<dataRoot>/bus-host/sessions.json`, not
the bus (see "Sessions" below). `stop` refuses inside an agent run;
`launches list` does not, because the file is readable to the same user
anyway.
## Bus host
```
mosaic bus start <business> the host; run by the unit, not by hand
mosaic bus start <business> [--pm]
the host; run by the unit, not by hand.
--pm also launches the business's PM
mosaic bus stop SIGTERM to the recorded host, then wait
mosaic bus status [--json] host state file, socket, writer.lock
```
@@ -76,12 +98,23 @@ mosaic bus status [--json] host state file, socket, writer.lock
5. It writes `<dataRoot>/bus-host/host.json` (0600): the pid, the process
start time, the business, the start time as text and the notifier
binding. The file holds no capability.
6. It starts the session launcher (`src/launcher.mjs`, "Sessions" below)
and, with `--pm`, launches the business's `launch.by` instance. A PM
launch that refuses closes the host with the refusal's exit code.
No capability appears in argv, stdout, the environment or a file.
`startHost()` in `src/host.mjs` is also the in-process API S6 uses:
`bindLaunch(record)` binds a launched run's process identity
(`pid`, `startTime`) and returns `{business, run, cap}`. Requests to the
broker process go one at a time, because its replies carry no request id.
`startHost()` in `src/host.mjs` is also the in-process API the launcher
uses: `bindLaunch(record)` binds a launched run's process identity
(`pid`, `startTime`) and returns `{business, run, cap}`; `op(message)` sends
one of the broker process's launch ops (`identity`, `authorizeLaunch`,
`refuse`, `endLaunch`, `credentialStatus`); `beforeClose(fn)` runs `fn`
before the broker closes, so the launcher stops its sessions first.
Requests to the broker process go one at a time. Each carries an id, and
the broker echoes it on the reply. If a reply doesn't arrive within 10 s, or
arrives with an id no request is waiting for, the channel is broken: the
waiting request refuses with `broker-channel-broken`, so does every later
one, and the host stops with exit 1 for the unit to restart. A late reply
never answers a later request, and a launch waiting on one refuses.
SIGTERM or SIGINT stops the notifier after its poll in flight, then closes
the broker and removes `host.json`. If either child dies on its own, the
@@ -96,6 +129,70 @@ and whose command line is `…/packages/cli/src/cli.mjs bus start`.
a broker was killed hard. Check, then remove the lock by hand
(`packages/bus/README.md`).
### Sessions
The launcher (`src/launcher.mjs`) starts managed role sessions
(`packages/seat/src/session.mjs`, `packages/harness`). It serves
`<dataRoot>/bus-host/launch.sock` (0600), which the PM's `launch` tool
calls through its runner. One request is one line,
`{"cap": "…", "instance": "<role instance>"}`, at most 4096 bytes; the reply
is `{"ok": true, "result": {run, instance, harness, model}}` or
`{"ok": false, "error": "<code>"}`. Launches run one at a time. In order:
| Check | Refusal |
|---|---|
| the request is one JSON line with a string instance | `invalid-request` |
| the capability is live and names a role of this business | `unauthenticated`, `not-a-role` |
| the business file has a `launch` block | `launch-not-configured` |
| `launch.instances` lists the instance (for `--pm`: it is `launch.by`) | `instance-not-listed` |
| the instance isn't running | `instance-running` |
| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
| its role has a contract file | `no-role-contract` |
| its model names exactly one `launch.max` family | `unknown-model-family` |
| that family has room; every running session counts, the PM's too | `capacity-full` |
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
| the bundle builds and the runner starts | `launch-failed` |
Every refusal is an `action.refused` event (the broker's `refuse`, or its
own refusal for the last broker check) and a line in the launch log. Then
the launcher reads the instance's credential status from the broker
(metadata only), builds the bundle in the run directory, spawns the runner
under `unshare`, binds the outer process's pid and start time, adds the
session to `sessions.json`, and writes the capability as one line on the
runner's stdin. The capability is never on disk, in argv or in the
environment. When the runner exits, the launcher sends `endLaunch` with the
end reason from its exit code (`packages/harness/README.md`), which writes
`session.ended`, releases the role and kills the run's capabilities.
The manifest records the Pi version from the repository's pin (the
`@earendil-works/pi-coding-agent` dependency in the root `package.json`)
and, for Claude Code, `claude --version`.
On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
refusal and end; `workspaces/<business>/<instance>/` (0700) is kept across
launches; `bus-host/sessions.json` (0600) lists the running sessions.
When the host closes, the launcher stops taking requests and drops every
open `launch.sock` connection, so a client that never ends its side can't
hold the close. It sends SIGTERM to
every runner (again each second, see "Limits"), waits up to 30 s, then
SIGKILLs what is left and ends those launches as `killed`.
A host that starts reads the `sessions.json` the last host left. An entry
for this business means that host died without ending the launch, and its
run would keep holding the role. Before the launch socket opens, the
launcher SIGKILLs each such session that still runs (same start time,
`unshare` on its command line), binds the run again with the recorded
identity and ends it as `host-lost`, which releases the role. A
`sessions.json` that can't be read refuses the host start with exit 3; move
it aside after checking which sessions still run.
`mosaic stop <run>` checks that the runner in `sessions.json` still runs with
the recorded start time and that its command line is the runner's, then
sends SIGTERM until it exits (30 s). A stale entry prints "not running" and
exits 0; an unknown run exits 2.
### Trackers
`config.trackers[<business>]` is `{baseUrl, project, pollSeconds,
@@ -223,7 +320,7 @@ exit 3, so a host never starts until someone decides whether it DMs.
|---|---|
| 0 | ok |
| 1 | failed, or outcome unknown: check before retrying |
| 2 | invalid input: unknown option, no such open decision, decision already closed |
| 2 | invalid input: unknown option, no such open decision, decision already closed, no such run, not a runner |
| 3 | refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start |
| 4 | usage |
@@ -238,9 +335,22 @@ exit 3, so a host never starts until someone decides whether it DMs.
- **The real human transport is untested here.** No test runs the real
`human-cli.mjs`, because its proof needs a human shell. The live run
covers it.
- **`stop` is same-UID process control.** It checks the start time and the
command line, not authority. Any process of the same user can signal a
runner directly.
- **Signals before the runner's handlers.** A session's runner is pid 1 of
its PID namespace, and pid 1 ignores a signal it has no handler for. The
runner installs its handlers first, but Node's startup leaves a short
window, so the launcher and `stop` resend SIGTERM every second.
- **If the host dies hard,** its sessions keep running until their broker
polls fail, then exit 23, and their launches stay open in the bus until
the next host starts and ends them as `host-lost` ("Sessions" above).
With no next host, nothing ends them.
- **`talk` and the launches verbs are tested only with the in-process
transport,** as the other human commands are.
- **Tracker boot is tested only without trackers.** The `trackers` boot case
is tested once S3's broker change lands.
## Not in this piece
`mosaic talk` (S6) and the WebUI (S5).
The WebUI (S5).
+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 };
}
+23
View File
@@ -0,0 +1,23 @@
// A bus host with its launcher in a process of its own, so a test can kill
// it hard. It launches the PM, prints the launch result as one JSON line and
// runs until it is killed. argv: the adapter to use for both harnesses.
import { bootConfig, loadSystem } from "../../src/config.mjs";
import { startHost } from "../../src/host.mjs";
import { createLauncher } from "../../src/launcher.mjs";
const [adapter] = process.argv.slice(2);
const system = loadSystem({ env: process.env });
const boot = bootConfig({ system, businessId: "acme", env: process.env });
const host = await startHost({ boot, business: "acme", log: () => {} });
const launcher = createLauncher({
host,
system,
env: process.env,
adapters: { pi: adapter, "claude-code": adapter },
sessionDefaults: { pollInterval: 100 },
versions: { pi: "0.85.1", claude: null },
log: () => {},
});
await launcher.listen();
process.stdout.write(`${JSON.stringify(await launcher.launchPm())}\n`);
+82
View File
@@ -203,6 +203,88 @@ test("a second host for the same data root refuses with exit 3 while the first r
assert.equal(await host.close(0), 0);
});
test("a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1", { timeout: 30000 }, async (t) => {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const logs = [];
const host = await startHost({ boot, business: "acme", requestTimeoutMs: 1000, log: (l) => logs.push(l) });
// Hooks run in order: the broker resumes before the close, which a
// stopped broker would hold.
t.after(() => {
try {
process.kill(host.pids.broker, "SIGCONT");
} catch {}
});
t.after(() => host.close(0));
const record = (role, run) => ({ business: "acme", role, run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
const coder = await host.bindLaunch(record("coder", "coder-run"));
assert.equal((await host.op({ op: "identity", cap: coder.cap })).role, "coder");
// Stall the broker with a request waiting and a bind queued behind it.
process.kill(host.pids.broker, "SIGSTOP");
const code = (p) => p.then((v) => ({ answered: v }), (e) => e.code);
const first = code(host.op({ op: "identity", cap: "bogus" }));
const second = code(host.bindLaunch(record("reviewer", "reviewer-run")));
assert.equal(await first, "broker-channel-broken");
assert.equal(await second, "broker-channel-broken", "a queued request never reaches the broker");
process.kill(host.pids.broker, "SIGCONT");
// The broker answers the stalled request late; nothing takes that reply.
assert.equal(await host.done, 1);
assert.ok(logs.some((l) => /^broker channel broken \(no reply within 1 s\); stopping the host$/.test(l)), logs.join("\n"));
assert.equal(await code(host.op({ op: "identity", cap: coder.cap })), "broker-channel-broken");
});
test("a broker reply with another request's id, or none, breaks the channel and the host exits 1", { timeout: 30000 }, async (t) => {
// The broker echoes whatever id it's sent, so changing the id on the way
// out gives the host the reply a confused broker would send.
let what, tamper;
spySends(t, (m) => m?.cap === "tamper" && tamper(m));
for ([what, tamper] of [
["another id", (m) => (m.id += 1000)],
["no id", (m) => delete m.id],
]) {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const logs = [];
const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
t.after(() => host.close(0));
const record = (role, run) => ({ business: "acme", role, run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
const coder = await host.bindLaunch(record("coder", "coder-run"));
await assert.rejects(host.op({ op: "identity", cap: "tamper" }), (e) => e.code === "broker-channel-broken", what);
await assert.rejects(host.op({ op: "identity", cap: coder.cap }), (e) => e.code === "broker-channel-broken", what);
assert.equal(await host.done, 1, what);
assert.ok(logs.includes("broker channel broken (reply for another request); stopping the host"), `${what}: ${logs.join("\n")}`);
}
});
test("a broker reply with no request waiting breaks the channel and the host exits 1", { timeout: 30000 }, async (t) => {
// A request sent to the real broker past the host's queue: its reply
// arrives when nothing is waiting (Darkwing round 2, Mr).
const children = [];
const onChild = ({ process: child }) => children.push(child);
subscribe("child_process", onChild);
t.after(() => unsubscribe("child_process", onChild));
const root = tmp(t);
const f = fixture(root);
makeDeployment(root);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const logs = [];
const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
t.after(() => host.close(0));
const record = { business: "acme", role: "coder", run: "coder-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) };
const coder = await host.bindLaunch(record);
children.find((c) => c.pid === host.pids.broker).send({ op: "identity", cap: coder.cap, id: 1_000_000 });
const late = new Promise((r) => setTimeout(r, 5000, "still running").unref());
assert.equal(await Promise.race([host.done, late]), 1);
assert.ok(logs.includes("broker channel broken (reply with no request waiting); stopping the host"), logs.join("\n"));
await assert.rejects(host.op({ op: "identity", cap: coder.cap }), (e) => e.code === "broker-channel-broken");
});
test("a notifier that refuses stops the broker and the host refuses with exit 3", async (t) => {
const root = tmp(t);
const f = fixture(root);
+394
View File
@@ -0,0 +1,394 @@
// The S6 launcher inside a real bus host: real broker child, real runners
// under unshare, the fake adapter from packages/harness standing in for the
// harness. The PM launches a coder through its `launch` tool; every host
// refusal comes back over the launch socket and lands in the launch log.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { createInterface } from "node:readline";
import { connect } from "node:net";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { launchLogFile, launchSocketPath, readSessions, Registry, sessionsFile, UNSHARE } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fixture, tmp } from "./helpers.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
const NAMESPACES = spawnSync(UNSHARE[0], [...UNSHARE.slice(1), "true"]).status === 0;
const skip = NAMESPACES ? false : "unprivileged user and PID namespaces are unavailable here";
// pm opus, coder and reviewer sonnet, one of each family; cto is listed but
// runs the system model, which is in no family.
function edit(doc) {
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
doc.launch = { by: "pm", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
return doc;
}
async function until(fn, ms = 15000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
// A config and data root with the fake adapter; setup() starts a host on it.
function prepare(t, more = (doc) => doc) {
const root = tmp(t, "mosaic-launch-");
const f = fixture(root, "acme", (doc) => more(edit(doc)));
mkdirSync(f.dataRoot, { mode: 0o700 });
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), options = {}) {
const system = loadSystem({ env: f.env });
const boot = bootConfig({ system, businessId: "acme", env: f.env });
const logs = [];
const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
let launcher;
try {
launcher = createLauncher({
host,
system,
env: f.env,
adapters: { pi: adapter, "claude-code": adapter },
sessionDefaults: { pollInterval: 100 },
versions: { pi: "0.85.1", claude: null },
log: (l) => logs.push(l),
...options,
});
await launcher.listen();
} catch (e) {
await host.close(1);
throw e;
}
let closed = false;
const close = async () => {
if (closed) return host.done;
closed = true;
return host.close(0);
};
t.after(close);
// A test caller: a cto launch bound to this process, claimed.
const cto = await host.bindLaunch({ business: "acme", role: "cto", run: "cto-test", harness: "claude-code", pid: process.pid, startTime: startTimeOf(process.pid) });
const client = new Client({ path: host.path, cap: cto.cap });
await client.call("role.claim");
return { root, f, host, launcher, cto, client, logs, close };
}
// One request on the launch socket, one reply line.
function ask(path, line) {
return new Promise((resolve, reject) => {
const s = connect(path);
let buf = "";
s.on("connect", () => s.write(line));
s.on("data", (b) => (buf += b));
s.on("end", () => resolve(JSON.parse(buf)));
s.on("error", reject);
});
}
const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, host, cto, close } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
assert.equal(statSync(sock).mode & 0o777, 0o600);
const pm = await launcher.launchPm();
assert.deepEqual({ ...pm, run: undefined }, { run: undefined, instance: "pm", harness: "pi", model: "claude-opus-5-5" });
assert.match(pm.run, /^r[0-9a-f]{12}$/);
const [s] = readSessions(f.dataRoot);
assert.equal(s.run, pm.run);
assert.equal(s.live, true);
assert.equal(s.family, "opus");
assert.equal(s.launchedBy, "jason");
assert.notEqual(s.pid, s.runnerPid, "the runner is the unshare process's child");
assert.equal(statSync(sessionsFile(f.dataRoot)).mode & 0o777, 0o600);
const runDir = s.runDir;
assert.equal(statSync(runDir).mode & 0o777, 0o700);
const session = JSON.parse(readFileSync(join(runDir, "session.json"), "utf8"));
assert.equal(session.launchedBy, "jason");
assert.equal(session.brokerSocket, host.path);
assert.equal(session.launchSocket, sock);
const manifest = JSON.parse(readFileSync(join(runDir, "bundle", "manifest.json"), "utf8"));
assert.equal(manifest.piVersion, "0.85.1");
assert.equal(manifest.claudeVersion, null);
const [launched] = log(f).filter((e) => e.event === "launch");
assert.equal(launched.run, pm.run);
assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json"));
assert.equal(manifest.model, "claude-opus-5-5");
// A second PM is refused before anything spawns.
await assert.rejects(launcher.launchPm(), /PM launch refused: instance-running/);
// The session's environment carries the run id and no capability; the
// capability is nowhere on disk or on any session process's command line.
await until(() => existsSync(join(runDir, "runner.log")) && readSessions(f.dataRoot)[0]?.live);
const proc = (pid, what) => {
try {
return readFileSync(`/proc/${pid}/${what}`, "utf8");
} catch {
return "";
}
};
const environ = proc(s.runnerPid, "environ").split("\0");
assert.ok(environ.includes(`MOSAIC_RUN_ID=${pm.run}`));
assert.ok(!environ.some((v) => v.startsWith("MOSAIC_CONFIG=")), "only allowlisted variables pass");
const files = (dir) => readdirSync(dir, { recursive: true, withFileTypes: true }).filter((d) => d.isFile()).map((d) => join(d.parentPath, d.name));
const capOf = cto.cap; // a known capability shape, to prove the scan finds nothing like it
for (const file of files(f.dataRoot)) {
const text = readFileSync(file, "latin1");
assert.ok(!text.includes(capOf), `${file} holds no capability`);
}
for (const pid of [s.pid, s.runnerPid]) {
assert.notEqual(proc(pid, "cmdline"), "");
assert.ok(!/[0-9a-f]{64}/.test(proc(pid, "cmdline")), "no capability in argv");
assert.ok(!/[0-9a-f]{64}/.test(proc(pid, "environ")), "no capability in the environment");
}
// Closing the host stops the session and ends the launch.
assert.equal(await close(), 0);
assert.equal(startTimeOf(s.runnerPid), null);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(sock), false);
const end = log(f).find((e) => e.event === "end");
assert.equal(end.run, pm.run);
assert.equal(end.reason, "stopped");
assert.equal(end.exitCode, 0);
});
test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, client, cto } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
const pm = await launcher.launchPm();
// cto asks pm to launch a coder; pm's turn calls the launch tool.
const sent = await client.call("message.send", { to: "pm", class: "REQUEST", body: 'TOOL launch {"instance":"coder"}' });
const reply = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === sent.id));
assert.equal(reply.from_role, "pm");
assert.equal(reply.class, "RESULT");
const result = JSON.parse(reply.body);
assert.equal(result.instance, "coder");
assert.equal(result.model, "claude-sonnet-5-5");
const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder");
assert.equal(coder.run, result.run);
assert.equal(coder.launchedBy, "pm");
assert.equal(coder.live, true);
// The coder claimed its role and answers its own messages.
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "hello" });
const echo = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id));
assert.equal(echo.from_role, "coder");
assert.match(echo.body, /^echo: Message .* from cto, class REQUEST:\n\nhello/);
// Host refusals, asked with the cto capability (steps 2 to 6 come before
// the broker's role.launch check, which cto would fail).
const refusals = [
["pm", "instance-not-listed"],
["nobody", "instance-not-listed"],
["coder", "instance-running"],
["reviewer", "capacity-full"],
["cto", "unknown-model-family"],
];
for (const [instance, code] of refusals) {
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: cto.cap, instance })}\n`), { ok: false, error: code }, instance);
}
// A listed, free instance with room still needs role.launch: cto lacks it.
// Stop the coder first so sonnet has room again.
const coderRun = coder.run;
process.kill(coder.runnerPid, "SIGTERM");
await until(() => !readSessions(f.dataRoot).some((s) => s.run === coderRun) && log(f).some((e) => e.event === "end" && e.run === coderRun));
const denied = await ask(sock, `${JSON.stringify({ cap: cto.cap, instance: "reviewer" })}\n`);
assert.equal(denied.ok, false);
assert.notEqual(denied.error, "capacity-full");
// Malformed requests.
assert.deepEqual(await ask(sock, "not json\n"), { ok: false, error: "invalid-request" });
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: "short", instance: "coder" })}\n`), { ok: false, error: "unauthenticated" });
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: "0".repeat(64), instance: "coder" })}\n`), { ok: false, error: "unauthenticated" });
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: cto.cap, instance: 7 })}\n`), { ok: false, error: "invalid-request" });
// The launch log has the launches, every refusal, and the coder's end.
const entries = log(f);
assert.deepEqual(entries.filter((e) => e.event === "launch").map((e) => [e.instance, e.launchedBy]), [["pm", "jason"], ["coder", "pm"]]);
const refused = entries.filter((e) => e.event === "refused").map((e) => [e.instance, e.code]);
for (const r of refusals) assert.ok(refused.some(([i, c]) => i === r[0] && c === r[1]), `${r} is logged`);
const end = entries.find((e) => e.event === "end" && e.run === coderRun);
assert.equal(end.reason, "stopped");
assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run));
});
test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => {
// pm's tracker token has expired: the runner's founder-credential stop
// exits 20 before role.claim, and the host records founder-credentials.
const { f, launcher } = await setup(t, (doc) => {
doc.roles.pm.credentials.vikunja.expires = "2000-01-01";
return doc;
});
const pm = await launcher.launchPm();
const end = await until(() => existsSync(launchLogFile(f.dataRoot, "acme")) && log(f).find((e) => e.event === "end" && e.run === pm.run));
assert.equal(end.reason, "founder-credentials");
assert.equal(end.exitCode, 20);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.match(readFileSync(join(f.dataRoot, "launches", "acme", pm.run, "runner.log"), "utf8"), /vikunja/);
});
for (const exited of [false, true]) {
test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
const { f } = given;
const old = spawn(process.execPath, [HOST, given.adapter], { env: f.env, stdio: ["ignore", "pipe", "inherit"] });
t.after(() => old.kill("SIGKILL"));
const [line] = await once(createInterface({ input: old.stdout }), "line");
const first = JSON.parse(line);
const [left] = readSessions(f.dataRoot);
assert.equal(left.run, first.run);
// Killed before the claim, the runner would exit 23 on its own.
const runnerLog = join(f.dataRoot, "launches", "acme", first.run, "runner.log");
await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
assert.equal(left.live, true);
old.kill("SIGKILL");
await once(old, "exit");
// The broker child exits on the lost IPC channel; the session runs on.
await until(() => !existsSync(join(f.dataRoot, "bus", "writer.lock")));
assert.equal(startTimeOf(left.runnerPid), left.runnerStartTime);
if (!exited) {
// Stopped, the leftover neither polls the dead broker nor answers
// SIGTERM: only the next host's SIGKILL ends it.
for (const pid of [left.pid, left.runnerPid]) process.kill(pid, "SIGSTOP");
t.after(() => {
for (const [pid, start] of [[left.pid, left.startTime], [left.runnerPid, left.runnerStartTime]]) {
if (startTimeOf(pid) === start) process.kill(pid, "SIGKILL");
}
});
}
if (exited) {
await until(() => startTimeOf(left.pid) === null);
assert.match(readFileSync(runnerLog, "utf8"), /broker unreachable after 30 tries[\s\S]*exiting 23/);
assert.equal(readSessions(f.dataRoot)[0].live, false);
}
const s = await setup(t, undefined, given);
assert.equal(startTimeOf(left.pid), null, "the old session process is gone");
assert.equal(startTimeOf(left.runnerPid), null, "and its runner with it");
assert.ok(s.logs.includes(`run ${first.run} (pm) from an earlier host ended: host-lost`), s.logs.join("\n"));
const ended = (await s.client.call("trail", { subject: first.run })).filter((r) => r.kind === "session.ended");
assert.equal(ended.length, 1);
assert.deepEqual(ended[0].body, { reason: "host-lost", exitCode: null, released: true });
const log = readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
assert.deepEqual(log.filter((e) => e.event === "end").map((e) => [e.run, e.reason]), [[first.run, "host-lost"]]);
assert.deepEqual(readSessions(f.dataRoot), []);
// Without the end, the old run would still hold pm and this runner would exit 21.
const again = await s.launcher.launchPm();
await until(async () => (await s.client.call("agents")).some((a) => a.role === "pm" && a.holder_run === again.run));
await s.client.call("role.release"); // the test's own cto claim, so the next host's can claim again
assert.equal(await s.close(), 0);
// Died after the end but before sessions.json changed: the run stays ended.
const { live, ...entry } = left;
new Registry(f.dataRoot).add(entry);
const third = await setup(t, undefined, given);
assert.ok(third.logs.includes(`run ${first.run} (pm) from an earlier host was already ended`), third.logs.join("\n"));
assert.equal((await third.client.call("trail", { subject: first.run })).filter((r) => r.kind === "session.ended").length, 1);
assert.equal(await third.close(), 0);
});
}
test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
const given = prepare(t);
const file = sessionsFile(given.f.dataRoot);
mkdirSync(join(given.f.dataRoot, "bus-host"), { mode: 0o700 });
writeFileSync(file, "{", { mode: 0o600 });
await assert.rejects(setup(t, undefined, given), (e) => e.exitCode === 3 && /sessions file is unreadable.*move the file aside/.test(e.message));
assert.equal(readFileSync(file, "utf8"), "{");
assert.equal(existsSync(join(given.f.dataRoot, "bus", "writer.lock")), false, "the host closed");
});
const within = (p, ms, what) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} took over ${ms} ms`)), ms).unref())]);
test("an over-long launch request is refused at once, not at the 10 s idle timeout", { timeout: 30000 }, async (t) => {
const { f } = await setup(t);
const s = connect(launchSocketPath(f.dataRoot));
t.after(() => s.destroy());
let buf = "";
s.on("data", (b) => (buf += b));
s.write("x".repeat(5000));
await within(once(s, "end"), 3000, "the refusal");
assert.deepEqual(JSON.parse(buf), { ok: false, error: "invalid-request" });
});
test("a launch client that never closes its side doesn't hold the host's close", { timeout: 30000 }, async (t) => {
const { f, close } = await setup(t);
const path = launchSocketPath(f.dataRoot);
// One got its reply and never ends; one never sends anything.
const answered = connect({ path, allowHalfOpen: true });
const silent = connect({ path, allowHalfOpen: true });
const connected = once(silent, "connect");
const gone = Promise.all([once(answered, "close"), once(silent, "close")]);
const drop = () => {
answered.destroy();
silent.destroy();
};
t.after(drop);
let buf = "";
answered.on("data", (b) => (buf += b));
answered.write("not json\n");
await once(answered, "end");
assert.deepEqual(JSON.parse(buf), { ok: false, error: "invalid-request" });
await within(connected, 2000, "connect");
// On a failure the clients go, so the host can still close and the test
// fails instead of hanging.
const code = await within(close(), 5000, "close").catch((e) => {
drop();
throw e;
});
assert.equal(code, 0);
assert.equal(existsSync(path), false);
answered.end();
silent.end();
await within(gone, 2000, "the clients' close");
});
test("a runner that ignores SIGTERM is killed when the host closes", { skip, timeout: 60000 }, async (t) => {
const { f, launcher, close } = await setup(t, undefined, undefined, { stopTimeoutMs: 1000 });
const pm = await launcher.launchPm();
const runnerLog = join(f.dataRoot, "launches", "acme", pm.run, "runner.log");
await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
const [s] = readSessions(f.dataRoot);
// Stopped from outside its namespace, it can't act on SIGTERM.
process.kill(s.runnerPid, "SIGSTOP");
const kill = () => {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
};
t.after(kill);
const started = Date.now();
const code = await within(close(), 15000, "close").catch((e) => {
kill();
throw e;
});
assert.equal(code, 0);
assert.ok(Date.now() - started >= 1000, "it waited for the stop timeout");
assert.equal(startTimeOf(s.pid), null);
assert.equal(startTimeOf(s.runnerPid), null);
assert.deepEqual(readSessions(f.dataRoot), []);
const ends = log(f).filter((e) => e.event === "end" && e.run === pm.run);
assert.equal(ends.length, 1);
assert.equal(ends[0].signal, "SIGKILL");
});
+132
View File
@@ -0,0 +1,132 @@
// The S6 human verbs: talk, stop and launches. The human transport is the
// in-process broker from helpers.mjs, because the real one refuses to run
// under an agent (src/transport.mjs). A real stop of a real runner is in
// packages/seat/tests/session.test.mjs and tests/launcher.test.mjs.
import { test } from "node:test";
import assert from "node:assert/strict";
import { main } from "../src/cli.mjs";
import { CliError } from "../src/errors.mjs";
import { startTimeOf } from "../src/host.mjs";
import { Registry } from "../../seat/src/session.mjs";
import { broker, io, tmp } from "./helpers.mjs";
function setup(t, deps = {}) {
const bus = broker(t);
const dataRoot = tmp(t);
const run = async (argv, x = io(), more = {}) => {
try {
await main(argv, x, { system: { dataRoot }, transport: bus.transport, talkPollMs: 20, ...deps, ...more });
return { code: 0, io: x };
} catch (e) {
if (!(e instanceof CliError)) throw e;
return { code: e.exitCode, error: e.message, io: x };
}
};
return { ...bus, dataRoot, run };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
test("talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply", async (t) => {
const s = setup(t);
const cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" });
call(s.b, cto, "role.claim");
// The coder answers on the second poll; a cto note arrives first and is printed too.
let polls = 0;
const transport = (business) => {
const inner = s.transport(business);
return async (verb, args) => {
if (verb === "message.receive" && ++polls === 2) {
const [m] = call(s.b, s.coder, "message.receive");
call(s.b, cto, "message.send", { to: m.from_role, body: "fyi", class: "INFO" });
call(s.b, s.coder, "message.send", { to: m.from_role, body: "done: 3 files", class: "RESULT", in_reply_to: m.id });
}
return inner(verb, args);
};
};
const r = await s.run(["talk", "coder", "fix the build", "--business", "demo"], io(), { transport });
assert.equal(r.code, 0, r.error);
const sent = s.calls.find((c) => c.verb === "message.send");
assert.deepEqual(sent.args, { to: "coder", body: "fix the build", class: "REQUEST" });
assert.match(r.io.out.text, /^sent [0-9a-f]{8} to coder\n/);
assert.match(r.io.out.text, /cto \(INFO\) [0-9a-f]{8}:\nfyi\n/);
assert.match(r.io.out.text, /coder \(RESULT, re [0-9a-f]{8}\) [0-9a-f]{8}:\ndone: 3 files\n$/);
assert.equal(s.calls.filter((c) => c.verb === "message.read").length, 2);
assert.deepEqual(await s.transport("demo")("message.receive"), [], "both were marked read");
});
test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show", async (t) => {
const s = setup(t);
const quick = await s.run(["talk", "coder", "hello", "--wait", "0", "--business", "demo"]);
assert.equal(quick.code, 0);
assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]);
const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
assert.equal(slow.code, 1);
assert.match(slow.error, /no reply from coder within 1 s; .*mosaic inbox/);
});
test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => {
const s = setup(t);
const agent = () => io({ ...io().env, CLAUDECODE: "1" });
for (const argv of [["talk", "coder", "hi", "--business", "demo"], ["stop", "r0123456789ab"], ["launches", "off", "--business", "demo"], ["launches", "on", "--business", "demo"]]) {
assert.equal((await s.run(argv, agent())).code, 3, argv.join(" "));
}
const usage = [
["talk", "coder", "--business", "demo"],
["talk", "coder", "a", "b", "--business", "demo"],
["talk", "coder", "hi", "--wait", "-1", "--business", "demo"],
["talk", "coder", "hi", "--wait", "1.5", "--business", "demo"],
["stop"],
["stop", "a", "b"],
["stop", "a", "--business", "demo"],
["launches"],
["launches", "maybe"],
["launches", "off", "x", "--business", "demo"],
["launches", "list", "x"],
];
for (const argv of usage) assert.equal((await s.run(argv)).code, 4, argv.join(" "));
assert.equal(s.calls.length, 0);
});
test("launches off and on go to the broker and change the business's launch state", async (t) => {
const s = setup(t);
const state = () => s.store.get("SELECT state FROM launch_state WHERE business=?", "demo")?.state;
const off = await s.run(["launches", "off", "--business", "demo"]);
assert.equal(off.code, 0, off.error);
assert.match(off.io.out.text, /^launches off for demo: .*running sessions keep running\n$/);
assert.equal(state(), "revoked");
const on = await s.run(["launches", "on", "--business", "demo"]);
assert.equal(on.io.out.text, "launches on for demo\n");
assert.notEqual(state(), "revoked");
assert.deepEqual(s.calls.map((c) => c.verb), ["launch.revoke", "launch.restore"]);
});
test("launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner", async (t) => {
const s = setup(t);
assert.equal((await s.run(["launches", "list"])).io.out.text, "no sessions\n");
const r = new Registry(s.dataRoot);
const base = { business: "demo", harness: "pi", model: "claude-sonnet-5-5", launchedBy: "pm", startedAt: "2026-10-09T00:00:00.000Z", runnerPid: process.pid };
r.add({ ...base, instance: "coder", run: "r000000000001", runnerStartTime: startTimeOf(process.pid) });
r.add({ ...base, instance: "reviewer", run: "r000000000002", runnerStartTime: "1" });
const list = await s.run(["launches", "list"]);
assert.equal(
list.io.out.text,
"r000000000001 demo/coder pi claude-sonnet-5-5 by pm since 2026-10-09T00:00:00.000Z running\n" +
"r000000000002 demo/reviewer pi claude-sonnet-5-5 by pm since 2026-10-09T00:00:00.000Z not running (stale)\n",
);
const json = JSON.parse((await s.run(["launches", "list", "--json"])).io.out.text);
assert.deepEqual(json.map((e) => [e.run, e.live]), [["r000000000001", true], ["r000000000002", false]]);
const stale = await s.run(["stop", "r000000000002"]);
assert.equal(stale.code, 0);
assert.equal(stale.io.out.text, "run r000000000002 is not running (stale entry)\n");
const unknown = await s.run(["stop", "nope"]);
assert.equal(unknown.code, 2);
assert.match(unknown.error, /no running session has run id nope/);
// This test process is live with the recorded start time but is not a runner.
const notRunner = await s.run(["stop", "r000000000001"]);
assert.equal(notRunner.code, 2);
assert.match(notRunner.error, /is not a session runner; refusing to signal it/);
assert.equal(s.calls.length, 0, "stop and list never touch the bus");
});