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]>
133 lines
6.6 KiB
JavaScript
133 lines
6.6 KiB
JavaScript
// 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");
|
|
});
|