honker: row 194 S3b cli: business instance launch, regenerate, terminate and delete with --wait polling instance.request, business veto, the instances read gains running, every lifecycle outcome but done exits 3 with the request id, the CLI end to end through the pass, T2a, T4 unbound and mismatched runs, T6 evidence bytes, T11 two roots and two businesses on one bus

This commit is contained in:
2026-10-20 17:03:48 -05:00
parent 8ddd8ada58
commit 0622680756
8 changed files with 547 additions and 16 deletions
+48 -1
View File
@@ -23,7 +23,7 @@ brief commit). Slices that don't need row 191 build first. S6 waits for row 191'
|---|---|---|---|
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) and S2e (PRD 1.7 routing, sage-1471) done |
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) done, S3b (verbs) next |
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) and S3b (verbs, veto, end to end, T2a, T4, T6, T11) done |
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | |
| S5 | webui: Agents section and routes | T16 | |
| S6 | row 191 chain: halted checks, fire SIGKILL through row 191's stop | T9a, T9b, T15 | waits on row 191 |
@@ -330,3 +330,50 @@ Listed here as the build changes them.
- Singles at S3a: seat sandbox 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24,
bus lifecycle-verbs 12/12, cli instances 6/6, cli lifecycle 9/9, bus process 8/8. broker.mjs
imports did not change, and the four must-run files ran anyway.
## S3b notes (the CLI verbs)
- `packages/cli/src/instances.mjs`: `business instance launch|regenerate BUSINESS I [--wait S]
[--tester]`, `terminate BUSINESS I [--reason TEXT] [--tester]`, `delete BUSINESS I --revision REV
[--wait S]` and `business veto BUSINESS keep|unkeep INSTANCE|nohire|hire DEFINITION --revision
REV`. `--wait` takes 0 to 600 s and defaults to 30. Every usage error exits 4 before any bus call.
add and set refuse `--wait` and `--reason`.
- Launch, regenerate and delete print the request id on stderr, then poll `instance.request` every
200 ms. Closed with a run or a transaction prints it and exits 0. A refusal prints its reason, the
request id and any transaction. A cancel prints who cancelled it. Still open at `--wait` says so and
names the 60 s expiry. Taken with no done is `outcome-unknown` and points at the launch log.
Terminate doesn't wait: it prints the revoked runs, the cancelled requests and any taken request
past cancelling.
- Exit codes: every lifecycle outcome that isn't done (refusal, cancel, still open, outcome unknown)
exits 3, as the design's `--wait` timeout does. Tradeoff: errors.mjs has 1 for an unknown outcome
elsewhere. One code lets a script treat "the request did not do it" as one case, and the message
says which. A refusal at the verb keeps the transport's code (2), as add and set do.
- The bus `instances` read gains `running` per instance (`#running`, from the claims), so the CLI
and S5 can show it without a second verb. An answer with no instances map passes through as
before. `bus instances-verb.test.mjs`'s stub writer answers `{ok: true}` and caught the first
version, which threw. The CLI view marks `kept` and `running r1, r2` and adds `retired:` and
`the chief may not hire:` lines.
- `scripts/mosaic` sends `business veto` to packages/cli with instances and instance.
- Tests: cli `instances.test.mjs` +8: the V6 veto ops and kept-delete refusal, the `running` read,
launch and regenerate printing the run, a closed refusal with its id (exit 3), `--wait 0` still
open and taken `outcome-unknown`, request-cancelled, terminate output and `not-held`, delete to
its transaction and `workspace-rename-failed` naming it, usage errors (exit 4, no call), and the
scripts/mosaic veto route. The existing route test gains the veto assertions. cli
`lifecycle.test.mjs` +5: the CLI end to end through the pass (launch, regenerate, `capacity-full`
with the request id, terminate, delete), T2a (`launch-revoked` at the take with no process, a
done that fails once leaves the request taken and the next pass starts nothing), T4 (a spawned
run the bus never bound is signalled, a mismatched start time is not), T6 (run evidence bytes
unchanged by a delete, the launch log only grows, a create and a launch of the retired name
refuse), T11 (two roots with business `acme`: a launch, a terminate and a delete in one leave the
other's files byte-identical). bus `chief-of-staff.test.mjs` +1: T11's bus half, a chief on a bus
with two businesses hires and fires only in its own.
- Not covered here: T6's `instance-starting` and the V5 refusal order are bus tests from S2c.
`check()` refusing a retired name is shown through the verb and the take, since `check()` reads a
file that no longer lists the name.
- Singles at S3b: cli instances 14/14, cli lifecycle 14/14, bus lifecycle-verbs 12/12, bus
instances-verb 3/3 (after the fix), bus chief-of-staff 40/40, webui instances 5/5, seat sandbox
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, usage 2/2, project 4/4, app
30/30, host 42/42, tester-threads 5/5, tester-reads 6/6, stack-copy 12/12, queue dispatch 4/4,
runs task-cli 9/9, webui pinned-copy 10/10, service 31/31, views 3/3. The broker change adds no
import. The four must-run files ran while the `instances` guard was being written, and the guard
touches only the read. No test pins a hash of scripts/mosaic.
+4
View File
@@ -1538,6 +1538,10 @@ export class Broker {
}
if (role && !revisionRead) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
// Row 194 (#1692, D1): the read gains each instance's running runs, from the bus claims. An
// answer with no instances map passes through as it did before.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object")
for (const [name, entry] of Object.entries(result.instances)) entry.running = this.#running(s.business, name);
return result;
}
return this.#store.transaction(() => {
@@ -752,6 +752,35 @@ test('row 194 T8: the chief hires with a reason and no decision, the writer gets
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
+1 -1
View File
@@ -576,7 +576,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
+133 -6
View File
@@ -3,12 +3,19 @@
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
// instance.request until the request closes or --wait runs out (D1.1 item 5).
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]";
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] | " +
"business instance launch|regenerate BUSINESS INSTANCE [--wait SECONDS] [--tester] | " +
"business instance terminate BUSINESS INSTANCE [--reason TEXT] [--tester] | " +
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
// The bus answers a refusal with its code alone, so the reason a human reads is here.
export const REASONS = {
@@ -23,8 +30,32 @@ export const REASONS = {
"organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it",
"organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why",
"revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`",
// Row 194 (#1692): the lifecycle and veto refusals.
"organization-instance-referenced": "the instance is launch.by or an arbiter; change that reference first",
"organization-instance-kept": "the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first",
"organization-instance-retired": "that name was deleted and stays retired; pick another name",
"instance-running": "the instance has a run; end it with `mosaic business instance terminate` first",
"instance-starting": "a request for the instance has not closed yet; wait for it and try again",
"instance-retired": "that name was deleted and launches no more",
"not-thread-launch": "a thread instance starts on a thread message, not by launch",
"regenerate-thread": "a thread instance isn't regenerated; its context is the thread's",
"not-held": "the instance has no run and no open request",
"launch-revoked": "launches are revoked for the business; `mosaic launches on --business BUSINESS` lifts it",
"launches-disabled": "the bus host was started with --no-launch",
"request-expired": "the launcher didn't take the request within its 60 s",
"regenerate-timeout": "the old run didn't exit within the request's 60 s, so no new run started; it still runs",
"workspace-rename-failed": "the business file is changed and the name retired, but the workspace stayed where it was; " +
"run the same delete again to retry the rename",
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"unknown-role": "the business has no such instance",
};
const LIFECYCLE_OPS = ["launch", "terminate", "regenerate", "delete"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const WAIT_SECONDS = 30;
const POLL_MS = 200;
export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it";
const NO_RESTART = "no restart: the launcher reads the change at its next launch check";
@@ -35,7 +66,7 @@ function parse(args) {
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
out.flags.add(arg);
} else if (["--definition", "--vars", "--revision"].includes(arg)) {
} else if (["--definition", "--vars", "--revision", "--wait", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4);
@@ -48,6 +79,7 @@ function parse(args) {
function setArgs(parsed) {
const [, op, , instance] = parsed.positional;
const { values, flags } = parsed;
if (values["--wait"] !== undefined || values["--reason"] !== undefined) throw new CliError(INSTANCES_USAGE, 4);
const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined;
if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") ||
(flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) ||
@@ -67,10 +99,89 @@ function setArgs(parsed) {
return args;
}
// Row 194: the lifecycle and veto args, or null when `instance OP` is add or set. Usage errors exit 4.
function lifecycleArgs(parsed) {
const { positional, values, flags } = parsed;
const [sub, op, , name] = positional;
const only = (allowed) => Object.keys(values).every((k) => allowed.includes(k)) && [...flags].every((f) => allowed.includes(f));
let wait = WAIT_SECONDS;
if (values["--wait"] !== undefined) {
if (!/^[0-9]{1,3}$/.test(values["--wait"]) || Number(values["--wait"]) > 600) throw new CliError(INSTANCES_USAGE, 4);
wait = Number(values["--wait"]);
}
if (sub === "veto") {
const [, , vop, target] = positional;
if (positional.length !== 4 || !VETO_OPS.includes(vop) || !values["--revision"] || !only(["--revision"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "veto.set", args: { revision: values["--revision"], op: vop, name: target } };
}
if (!LIFECYCLE_OPS.includes(op)) return null;
if (positional.length !== 4) throw new CliError(INSTANCES_USAGE, 4);
if (op === "terminate") {
if (!only(["--reason", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "instance.end", args: { instance: name, ...(values["--reason"] !== undefined ? { reason: values["--reason"] } : {}) } };
}
if (op === "delete") {
if (!values["--revision"] || !only(["--revision", "--wait"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "instances.set", args: { revision: values["--revision"], instance: name, op: "delete" }, wait };
}
if (!only(["--wait", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: `instance.${op}`, args: { instance: name }, wait };
}
// D1.1 item 5: polls instance.request until the request closes or `wait` seconds pass. A closed
// request with a refusal or a cancel is exit 3 (refused) with its code. Still open or taken at the
// end is exit 3 with the request id, as the design's timeout is: a taken request with no done is
// outcome-unknown.
async function follow(call, request, wait) {
const end = Date.now() + wait * 1000;
for (;;) {
const r = await call("instance.request", { request });
if (r.state === "closed") {
if (r.cancelled) throw refusal("request-cancelled", `cancelled by ${r.cancelled.by} (request ${request})`);
if (r.done?.refusal) {
const kept = r.done.transaction ? `, transaction ${r.done.transaction}` : "";
throw refusal(r.done.refusal, `${REASONS[r.done.refusal] ?? "the launcher refused the request"} (request ${request}${kept})`);
}
return r;
}
if (Date.now() >= end) {
const error = r.state === "taken"
? new CliError(`outcome-unknown: request ${request} was taken and has not closed after ${wait} s; the launch log names any run it started`, 3)
: new CliError(`request ${request} is still open after ${wait} s; the launcher takes it within its 60 s or closes it request-expired`, 3);
error.code = r.state === "taken" ? "outcome-unknown" : "request-open";
error.final = true;
throw error;
}
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, end - Date.now()))));
}
}
// `final`: the catch below passes it on as it is, with its request id.
function refusal(code, why) {
const error = new CliError(`${code}: ${why}`, 3);
error.code = code;
error.final = true;
return error;
}
function lifecycleLine(business, op, instance, r) {
if (op === "delete") return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n`;
return `business ${business}: ${op === "launch" ? "launched" : "regenerated"} ${instance}, run ${r.done.run} (request ${r.request})\n`;
}
function terminateLines(business, instance, out) {
const lines = [`business ${business}: ended ${instance} (request ${out.request})`];
lines.push(out.runs.length ? `runs ${out.runs.join(", ")}: claims revoked, the launcher signals each` : "no run held a claim");
if (out.cancelled.length) lines.push(`cancelled ${out.cancelled.join(", ")}`);
if (out.taken.length) lines.push(`taken ${out.taken.join(", ")}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`);
return `${lines.join("\n")}\n`;
}
export function formatInstances(view) {
const lines = [`business ${view.business}, revision ${view.revision}`];
for (const [name, entry] of Object.entries(view.instances)) {
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean);
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected", entry.kept && "kept",
entry.running?.length && `running ${entry.running.join(", ")}`].filter(Boolean);
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
}
@@ -79,6 +190,8 @@ export function formatInstances(view) {
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`);
if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`);
if (view.retired?.length) lines.push(`retired: ${view.retired.join(", ")}`);
if (view.noHire?.length) lines.push(`the chief may not hire: ${view.noHire.join(", ")}`);
return `${lines.join("\n")}\n`;
}
@@ -86,16 +199,29 @@ export function formatInstances(view) {
export async function instancesCommand(args, io, callFor) {
const parsed = parse(args);
const sub = parsed.positional[0];
const business = parsed.positional[sub === "instances" ? 1 : 2];
if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
const business = parsed.positional[sub === "instance" ? 2 : 1];
if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
let request;
if (sub === "instances") {
const lifecycle = sub === "instances" ? null : lifecycleArgs(parsed);
if (lifecycle) {
// veto and the lifecycle verbs, below.
} else if (sub === "instances") {
if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) {
throw new CliError(INSTANCES_USAGE, 4);
}
} else request = setArgs(parsed);
try {
const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } });
if (lifecycle) {
const out = await call(lifecycle.verb, lifecycle.args);
if (sub === "veto") {
return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n`);
}
const op = parsed.positional[1];
if (op === "terminate") return io.stdout.write(terminateLines(business, lifecycle.args.instance, out));
io.stderr.write(`mosaic: request ${out.request}, waiting up to ${lifecycle.wait} s\n`);
return io.stdout.write(lifecycleLine(business, op, lifecycle.args.instance, await follow(call, out.request, lifecycle.wait)));
}
if (!request) {
const view = await call("instances", {});
return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view));
@@ -109,6 +235,7 @@ export async function instancesCommand(args, io, callFor) {
result.restart ? RESTART_NEEDED : NO_RESTART,
].join("\n") + "\n");
} catch (error) {
if (error.final) throw error;
const code = error.code;
if (Object.hasOwn(REASONS, code)) {
const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2);
+162 -2
View File
@@ -21,7 +21,9 @@ import { fixture, io, tmp } from "./helpers.mjs";
const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url));
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
async function setup(t, { stub = false } = {}) {
// hook(verb, args, bus), when given, runs before each call reaches the broker (row 194: a test moves a
// request on as the launcher's pass would).
async function setup(t, { stub = false, hook = null } = {}) {
const root = tmp(t, "mosaic-cli-instances-");
const f = fixture(root, "acme");
const dir = join(root, "config");
@@ -40,6 +42,7 @@ async function setup(t, { stub = false } = {}) {
system: { dataRoot: f.dataRoot },
transport: (business) => async (verb, args = {}) => {
calls.push({ business, verb, args });
await hook?.(verb, args, bus);
try {
return structuredClone(bus.broker.request(human, { verb, args }));
} catch (e) {
@@ -160,12 +163,169 @@ test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session
await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/);
});
test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => {
test("row 165: scripts/mosaic sends business instances, instance and (row 194) veto to packages/cli, and validate still to packages/business", (t) => {
const root = tmp(t, "mosaic-cli-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" });
assert.equal(routed.status, 4);
assert.match(routed.stderr, /mosaic business instances BUSINESS/);
// Row 194: veto goes to packages/cli too.
const veto = spawnSync(MOSAIC, ["business", "veto"], { env, encoding: "utf8" });
assert.equal(veto.status, 4);
assert.match(veto.stderr, /\| business veto BUSINESS keep\|unkeep INSTANCE/);
const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" });
assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/);
});
test("row 194 CLI V6: veto keep, unkeep, nohire and hire write through veto.set, the read shows kept and noHire, and a no-op refuses", async (t) => {
const s = await setup(t);
const kept = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(kept.code, 0, kept.err);
assert.deepEqual(s.calls.at(-1), { business: "acme", verb: "veto.set", args: { revision: s.calls.at(-1).args.revision, op: "keep", name: "coder" } });
const entry = s.log().at(-1);
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`, ""]);
assert.equal(entry.actor, "jason");
const nohire = await s.mosaic(["veto", "acme", "nohire", "merge-gate", "--revision", await s.revision()]);
assert.equal(nohire.code, 0, nohire.err);
const view = await s.mosaic(["instances", "acme"]);
assert.match(view.out, /^ {2}coder: coder \(launched, kept\)/m);
assert.match(view.out, /^the chief may not hire: merge-gate$/m);
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.kept, json.noHire, json.retired], [true, ["merge-gate"], []]);
// A kept instance refuses delete at the verb.
const del = await s.mosaic(["instance", "delete", "acme", "coder", "--revision", await s.revision()]);
assert.equal(del.code, 2);
assert.match(del.err, /^mosaic: organization-instance-kept: the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first$/m);
const again = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(again.code, 2);
assert.match(again.err, /^mosaic: organization-no-change: /);
for (const [op, name] of [["unkeep", "coder"], ["hire", "merge-gate"]]) {
const r = await s.mosaic(["veto", "acme", op, name, "--revision", await s.revision()]);
assert.equal(r.code, 0, r.err);
}
const after = JSON.parse(readFileSync(s.file, "utf8"));
assert.deepEqual([after.launch.keep, after.launch.noHire], [[], []]);
});
test("row 194 CLI: the read lists each instance's running run from the bus claims", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.running, json.instances.reviewer.running], [["run-c1"], []]);
assert.match((await s.mosaic(["instances", "acme"])).out, /^ {2}coder: coder \(launched, running run-c1\)/m);
});
// The launcher's pass, played by the test: on the first poll the request is taken, then closed with `done`.
const closing = (done) => (verb, args, bus) => {
if (verb !== "instance.request") return;
if (bus.lifecycleTake({ business: "acme", request: args.request }).go && done) bus.lifecycleDone({ business: "acme", request: args.request, ...done(bus, args.request) });
};
test("row 194 CLI D1.1 item 5: launch and regenerate poll instance.request and print the run; a done with a refusal exits 3 with its code and the request id", async (t) => {
let done = () => ({ run: "run-new" });
const s = await setup(t, { hook: closing((bus, request) => done(bus, request)) });
const launched = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(launched.code, 0, launched.err);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.deepEqual(s.calls.find((c) => c.verb === "instance.launch").args, { instance: "reviewer" });
assert.equal(launched.out, `business acme: launched reviewer, run run-new (request ${request})\n`);
assert.match(launched.err, new RegExp(`^mosaic: request ${request}, waiting up to 30 s$`, "m"));
const regen = await s.mosaic(["instance", "regenerate", "acme", "reviewer"]);
assert.equal(regen.code, 0, regen.err);
assert.match(regen.out, /^business acme: regenerated reviewer, run run-new \(request [^)]+\)$/m);
done = () => ({ refusal: "capacity-full" });
const full = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(full.code, 3);
const id = s.calls.filter((c) => c.verb === "instance.request").at(-1).args.request;
assert.match(full.err, new RegExp(`^mosaic: capacity-full: a launch limit is reached; .* \\(request ${id}\\)$`, "m"));
done = () => ({ refusal: "some-new-code" });
const other = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(other.code, 3);
assert.match(other.err, /^mosaic: some-new-code: the launcher refused the request \(request [^)]+\)$/m);
});
test("row 194 CLI D1.1 item 5: an open request at --wait exits 3 with its id, and a taken one with no done is outcome-unknown", async (t) => {
const s = await setup(t);
const open = await s.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "0"]);
assert.equal(open.code, 3);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(open.err, new RegExp(`^mosaic: request ${request} is still open after 0 s; the launcher takes it within its 60 s or closes it request-expired$`, "m"));
const t2 = await setup(t, { hook: closing(null) });
const taken = await t2.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "1"]);
assert.equal(taken.code, 3);
const id = t2.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(taken.err, new RegExp(`^mosaic: outcome-unknown: request ${id} was taken and has not closed after 1 s; the launch log names any run it started$`, "m"));
assert.ok(t2.calls.filter((c) => c.verb === "instance.request").length > 1, "it polled until --wait ran out");
});
test("row 194 CLI: a request cancelled by a terminate exits 3 request-cancelled", async (t) => {
const s = await setup(t, { hook: (verb, args, bus) => {
if (verb === "instance.request") bus.broker.request(bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }), { verb: "instance.end", args: { instance: "reviewer" } });
} });
const r = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(r.code, 3);
assert.match(r.err, /^mosaic: request-cancelled: cancelled by jason \(request [^)]+\)$/m);
});
test("row 194 CLI V3: terminate prints the runs, the cancelled and the taken requests, and nothing held refuses not-held", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const human = s.bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
const open = s.bus.broker.request(human, { verb: "instance.launch", args: { instance: "coder" } }).request;
const taken = s.bus.broker.request(human, { verb: "instance.regenerate", args: { instance: "coder" } }).request;
s.bus.lifecycleTake({ business: "acme", request: taken });
const r = await s.mosaic(["instance", "terminate", "acme", "coder", "--reason", "stuck"]);
assert.equal(r.code, 0, r.err);
assert.deepEqual(s.calls.at(-1).args, { instance: "coder", reason: "stuck" });
const lines = r.out.split("\n");
assert.match(lines[0], /^business acme: ended coder \(request [^)]+\)$/);
assert.deepEqual(lines.slice(1), [
"runs run-c1: claims revoked, the launcher signals each", `cancelled ${open}`,
`taken ${taken}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`, "",
]);
const none = await s.mosaic(["instance", "terminate", "acme", "reviewer"]);
assert.equal(none.code, 2);
assert.match(none.err, /^mosaic: not-held: the instance has no run and no open request$/m);
});
test("row 194 CLI V5: delete polls to the transaction and the read lists the name retired; workspace-rename-failed names the transaction", async (t) => {
let rename = null;
const s = await setup(t, { hook: closing((bus, request) => ({ transaction: bus.lifecycleDelete({ business: "acme", request }).transaction, ...(rename ? { refusal: rename } : {}) })) });
for (const name of ["sec-1", "sec-2"]) {
const added = await s.mosaic(["instance", "add", "acme", name, "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
}
const del = await s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
assert.equal(del.code, 0, del.err);
const entry = s.log().at(-1);
assert.equal(del.out, `business acme: deleted sec-1, now retired\ntransaction ${entry.transaction}\n`);
assert.deepEqual(s.calls.find((c) => c.verb === "instances.set" && c.args.op === "delete").args, { revision: s.calls.find((c) => c.args.op === "delete").args.revision, instance: "sec-1", op: "delete" });
assert.match((await s.mosaic(["instances", "acme"])).out, /^retired: sec-1$/m);
rename = "workspace-rename-failed";
const failed = await s.mosaic(["instance", "delete", "acme", "sec-2", "--revision", await s.revision()]);
assert.equal(failed.code, 3);
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
});
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
for (const args of [
["instance", "launch", "acme"], ["instance", "launch", "acme", "coder", "extra"],
["instance", "launch", "acme", "coder", "--wait", "x"], ["instance", "launch", "acme", "coder", "--wait", "601"],
["instance", "launch", "acme", "coder", "--revision", rev], ["instance", "launch", "acme", "coder", "--reason", "x"],
["instance", "terminate", "acme", "coder", "--wait", "5"], ["instance", "terminate", "acme", "coder", "--revision", rev],
["instance", "delete", "acme", "coder"], ["instance", "delete", "acme", "coder", "--revision", rev, "--tester"],
["instance", "delete", "acme", "coder", "--revision", rev, "--reason", "x"],
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", rev, "--wait", "5"],
["veto", "acme", "keep", "coder"], ["veto", "acme", "drop", "coder", "--revision", rev],
["veto", "acme", "keep", "--revision", rev], ["veto", "acme", "keep", "coder", "--revision", rev, "--tester"],
["veto", "acme", "keep", "coder", "--revision", rev, "--json"], ["veto"],
]) {
const r = await s.mosaic(args);
assert.equal(r.code, 4, args.join(" "));
}
assert.equal(s.calls.length, 0);
});
+168 -4
View File
@@ -9,8 +9,9 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
@@ -19,10 +20,11 @@ import { startBroker } from "../../bus/src/runtime.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, SESSION_CREDENTIALS, workspaceDir } from "../../seat/src/session.mjs";
import { run } from "../src/cli.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
import { fakeHarnesses, fixture, io, tmp } from "./helpers.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
@@ -101,12 +103,15 @@ async function start(t, { f, adapter }, options = {}) {
};
const hooks = [];
let closing = null;
// A host op named here throws once, as a bus that refused it would.
const faults = new Set();
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
if (faults.delete(m.op)) throw new BusError("storage-refused");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
@@ -146,7 +151,26 @@ async function start(t, { f, adapter }, options = {}) {
const file = join(f.dataRoot, "launches", "acme", run, "runner.log");
return existsSync(file) && readFileSync(file, "utf8").includes("claimed by run");
});
return { runtime, launcher, call, state, carry, running, claimed, logs, close };
// The CLI through the in-process human. Each poll of instance.request runs one pass first, so a
// --wait sees the request closed as the launcher's ticker would close it.
const cli = async (args) => {
const x = io(f.env);
const deps = {
system: { dataRoot: f.dataRoot },
transport: () => async (verb, a = {}) => {
if (verb === "instance.request") await launcher.tick();
try {
return structuredClone(call(verb, a));
} catch (e) {
if (!(e instanceof BusError)) throw e;
throw Object.assign(new Error(e.code), { code: e.code });
}
},
};
const code = await run(["business", ...args], x, deps);
return { code, out: x.out.text, err: x.err.text };
};
return { runtime, launcher, call, state, carry, running, claimed, logs, close, faults, cli };
}
const launchLog = (f) =>
@@ -340,3 +364,143 @@ test("row 194 V5: a delete with no workspace commits and closes with the transac
assert.ok(r.done.transaction);
assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false);
});
// Every regular file under root, by path, as a sha256. Sockets and other special files are left out.
function snapshot(root) {
const out = {};
const walk = (dir) => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const st = lstatSync(path);
if (st.isDirectory()) walk(path);
else if (st.isFile()) out[path.slice(root.length)] = createHash("sha256").update(readFileSync(path)).digest("hex");
}
};
walk(root);
return out;
}
test("row 194 D1.1 item 5: the CLI's launch, regenerate, terminate and delete end to end through the pass, and a closed refusal exits 3 with the request id", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const launched = await s.cli(["instance", "launch", "acme", "reviewer"]);
assert.equal(launched.code, 0, launched.err);
const [r] = s.running("reviewer");
assert.match(launched.out, new RegExp(`^business acme: launched reviewer, run ${r.run} \\(request [^)]+\\)\n$`));
await s.claimed(r.run);
const regen = await s.cli(["instance", "regenerate", "acme", "reviewer"]);
assert.equal(regen.code, 0, regen.err);
const [r2] = s.running("reviewer");
assert.notEqual(r2.run, r.run);
assert.match(regen.out, new RegExp(`^business acme: regenerated reviewer, run ${r2.run} \\(request [^)]+\\)\n$`));
await s.claimed(r2.run);
// One sonnet run at a time: the launcher closes coder's request capacity-full.
const full = await s.cli(["instance", "launch", "acme", "coder"]);
assert.equal(full.code, 3);
const refused = launchLog(p.f).find((e) => e.event === "refused" && e.instance === "coder");
assert.match(full.err, new RegExp(`^mosaic: capacity-full: .* \\(request ${refused.request}\\)$`, "m"));
assert.equal(s.running("coder").length, 0);
const ended = await s.cli(["instance", "terminate", "acme", "reviewer", "--reason", "done for today"]);
assert.equal(ended.code, 0, ended.err);
assert.match(ended.out, new RegExp(`^runs ${r2.run}: claims revoked, the launcher signals each$`, "m"));
await s.launcher.tick();
await until(() => s.running("reviewer").length === 0);
const revision = JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision;
const del = await s.cli(["instance", "delete", "acme", "sec-1", "--revision", revision]);
assert.equal(del.code, 0, del.err);
assert.match(del.out, /^business acme: deleted sec-1, now retired\ntransaction [^\n]+\n$/);
assert.match((await s.cli(["instances", "acme"])).out, /^retired: sec-1$/m);
});
test("row 194 T2a: a launch.revoke between the request and the pass closes it launch-revoked with no process; a done that fails once leaves the request taken and the next pass starts nothing", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const request = s.call("instance.launch", { instance: "coder" }).request;
s.call("launch.revoke");
const revoked = await s.carry(request);
assert.deepEqual([revoked.state, revoked.done.refusal, revoked.done.run], ["closed", "launch-revoked", null]);
assert.equal(s.launcher.registry.sessions.length, 0);
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
s.call("launch.restore");
const held = s.call("instance.launch", { instance: "coder" }).request;
s.faults.add("lifecycleDone");
const taken = await s.carry(held);
assert.deepEqual([taken.state, taken.done], ["taken", null]);
const [r] = s.running("coder");
assert.ok(s.logs.some((l) => l === `request ${held} (coder): lifecycleDone refused: storage-refused; run ${r.run} started`), s.logs.join("\n"));
await s.launcher.tick();
assert.equal(s.state(held).state, "taken");
assert.equal(s.running("coder").length, 1);
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
});
test("row 194 T4: terminate signals a spawned run in the registry that the bus never bound, and not a run whose start time differs", { timeout: 30000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const spawned = (instance) => {
const child = spawn("sleep", ["60"], { stdio: "ignore" });
t.after(() => child.kill("SIGKILL"));
const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal)));
return { child, exited, instance };
};
const a = spawned("coder");
const b = spawned("reviewer");
await until(() => startTimeOf(a.child.pid) !== null && startTimeOf(b.child.pid) !== null);
// Neither holds a claim. An open request each lets the end through, and the end cancels it.
for (const [x, start] of [[a, startTimeOf(a.child.pid)], [b, startTimeOf(b.child.pid) + 1]])
s.launcher.registry.add({ business: "acme", instance: x.instance, run: `run-${x.instance}`, pid: x.child.pid, startTime: start, runnerPid: x.child.pid, runnerStartTime: start });
t.after(() => ["coder", "reviewer"].forEach((i) => s.launcher.registry.remove("acme", `run-${i}`)));
for (const x of [a, b]) {
s.call("instance.launch", { instance: x.instance });
const out = s.call("instance.end", { instance: x.instance });
assert.deepEqual([out.runs, out.cancelled.length], [[], 1]);
await s.carry(out.request);
}
assert.equal(await a.exited, "SIGTERM");
await new Promise((r) => setTimeout(r, 300));
assert.equal(b.child.exitCode, null);
assert.equal(b.child.signalCode, null);
const ends = launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run);
assert.deepEqual(ends, ["run-coder", "run-reviewer"]);
});
test("row 194 T6: a delete leaves the run evidence bytes unchanged, and the retired name refuses a create and a launch", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const run = (await s.carry(s.call("instance.launch", { instance: "sec-1" }).request)).done.run;
await s.claimed(run);
await s.carry(s.call("instance.end", { instance: "sec-1" }).request);
await until(() => s.running("sec-1").length === 0 && launchLog(p.f).some((e) => e.event === "end" && e.run === run));
const runDir = join(p.f.dataRoot, "launches", "acme", run);
const before = snapshot(runDir);
const log = readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8");
assert.ok(Object.keys(before).length > 0);
const del = await s.carry(s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request);
assert.equal(del.done.refusal, null);
assert.deepEqual(snapshot(runDir), before);
assert.ok(readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8").startsWith(log), "the launch log only grows");
assert.throws(
() => s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "add", definition: "security-reviewer" }),
(e) => e.code === "organization-instance-retired",
);
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
});
test("row 194 T11: two stack roots with the same business id; a launch, a terminate and a delete in one leave the other byte-identical", { skip, timeout: 90000 }, async (t) => {
const pa = prepare(t);
const pb = prepare(t);
const a = await start(t, pa);
const b = await start(t, pb);
b.call("instances");
const before = snapshot(pb.root);
const run = (await a.carry(a.call("instance.launch", { instance: "coder" }).request)).done.run;
await a.claimed(run);
await a.carry(a.call("instance.end", { instance: "coder" }).request);
await until(() => a.running("coder").length === 0);
const del = await a.carry(a.call("instances.set", { revision: a.call("instances").revision, instance: "sec-1", op: "delete" }).request);
assert.equal(del.done.refusal, null);
await b.launcher.tick();
assert.deepEqual(snapshot(pb.root), before);
assert.equal(b.launcher.registry.sessions.length, 0);
assert.equal(Object.hasOwn(b.call("instances").instances, "sec-1"), true);
});
+2 -2
View File
@@ -27,9 +27,9 @@ if [ "${1:-}" = queue ]; then
fi
if [ "${1:-}" = business ]; then
# Row 165 (#1662): the human's role instance commands need the human proof,
# so they run in packages/cli. validate and resolve stay in packages/business.
# so they run in packages/cli. validate and resolve stay in packages/business. Row 194 adds veto.
case "${2:-}" in
instances|instance) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
instances|instance|veto) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
shift
exec node "$REPO/packages/business/src/cli.mjs" "$@"