diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 045b14eaf..e5c2cc0ff 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -265,7 +265,10 @@ crashed host left as `host-lost` and refuses with exit 3 on a `sessions.json` it can't read. `talk` sends a REQUEST and prints only the reply to it, marking only that read (polls every 2 s, `--wait` default 900; `0` only sends); other messages stay unread for `mosaic inbox`, and -`--follow` and `follow` print and read everything. `stop` sends SIGTERM to +`--follow` and `follow` print and read everything. When no run holds the +role, a line after `sent` says whether `launch.by` was told, or that the role +isn't in the launch block and `launch.by` can't launch it (row 152); the cli +README, "Human commands", lists the lines. `stop` sends SIGTERM to a run's runner after checking it in `sessions.json`. `launches off|on` revokes and restores `role.launch` for the business; running sessions keep running. `launches list` reads diff --git a/packages/bus/README.md b/packages/bus/README.md index ce50f8d7b..acf77bea0 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -380,6 +380,17 @@ any other, so a stopped `launch.by` gets it at its next claim. The body is Message from to waits: has no running session. Launch within the launch block's limits, or tell why not with message.send in_reply_to . ``` +When the role isn't one of `launch.instances` (row 152, #1646), `launch.by` +can't launch it, and the body says so: + +``` +Message from to waits: has no running session and isn't in the launch block's instances, so a launch of is refused. Tell why not with message.send in_reply_to . +``` + +`launch.by` is still told in that case. The message stays stored and reaches +the role at a later claim, and the Console shows the notice as a reply to the +human's message; it doesn't read the `waiting` field. + `_bus` can't be a role: an instance name matches the business `ID_PATTERN`, which starts with `[a-z0-9]`. So `message.send` to `_bus` refuses `unknown-role`, and a caller can't set `from_role` or `from_run` (unknown keys @@ -388,9 +399,12 @@ automatic RESULT reply to every other class, and the bus would refuse a reply to `_bus`; the runner would log it and skip the `message.read`. On a RESULT the runner takes one turn, sends nothing and marks it read. -The result's `waiting` is `{role,launcher,told,launcherRunning}`: the target, -`launch.by` (`null` with no launch block), whether the notice went out, and -whether a run holds `launch.by` (`null` with no launch block). No `waiting` +The result's `waiting` is `{role,launcher,told,launcherRunning,launchable}`: +the target, `launch.by` (`null` with no launch block), whether the notice went +out, whether a run holds `launch.by` (`null` with no launch block), and whether +the target is one of `launch.instances` (`null` with no launch block; `false` +for `launch.by` itself, which `instances` can't list). `launchable` came with +row 152; a reader that ignores it sees row 146's shape. No `waiting` field means a run held the target, or the message went to the human, the tester, or a thread instance inside its thread (row 121's thread launcher starts that session). `told` is `false` and no notice goes out for a message to diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index bd0cd0ae7..b8f9f1da5 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1897,20 +1897,24 @@ export class Broker { // told and whether launch.by itself has a holder. A run launched but not yet claimed counts as // no holder; the instance-running refusal stops a second launch. launch.by isn't told of a // message to it, of its own message (this result tells it) or of a tester's (a PM test - // authorizes nothing). + // authorizes nothing). Row 152 (#1646): launchable says whether the target is one of + // launch.instances, so the sender learns at once that no launch can follow. launch.by is + // still told: the Console shows the notice as the reply to the human's message. #waiting(s, mid, to, thread) { if (to === 'human' || to === 'tester' || (thread && this.#threaded(s.business, to))) return null; if (this.#recipient(s.business, to, thread)) return null; - const by = this.#business(s.business).launch?.by ?? null; + const launch = this.#business(s.business).launch; + const by = launch?.by ?? null; + const launchable = launch ? launch.instances.includes(to) : null; const sender = this.#sender(s); const told = by !== null && to !== by && sender !== by && sender !== 'tester'; - if (told) this.#notice(s.business, mid, by, to, sender); - return { role: to, launcher: by, told, launcherRunning: by === null ? null : this.#recipient(s.business, by, null) !== null }; + if (told) this.#notice(s.business, mid, by, to, sender, launchable); + return { role: to, launcher: by, told, launcherRunning: by === null ? null : this.#recipient(s.business, by, null) !== null, launchable }; } // One notice per waiting message: only message.send calls this, once for the message it stores, so // a re-route at a claim or a broker restart never sends another. Its class is RESULT: the runner // takes a turn on it and sends no automatic reply, which NOTICE_ROLE could not receive. - #notice(business, mid, by, to, sender) { + #notice(business, mid, by, to, sender, launchable) { const nid = randomUUID(); this.#store.run( 'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)', @@ -1924,7 +1928,9 @@ export class Broker { mid, null, null, - `Message ${mid} from ${sender} to ${to} waits: ${to} has no running session. Launch ${to} within the launch block's limits, or tell ${sender} why not with message.send in_reply_to ${mid}.`, + launchable + ? `Message ${mid} from ${sender} to ${to} waits: ${to} has no running session. Launch ${to} within the launch block's limits, or tell ${sender} why not with message.send in_reply_to ${mid}.` + : `Message ${mid} from ${sender} to ${to} waits: ${to} has no running session and isn't in the launch block's instances, so a launch of ${to} is refused. Tell ${sender} why not with message.send in_reply_to ${mid}.`, ); this.#route(business, nid, by, null); } diff --git a/packages/bus/tests/launch-notice.test.mjs b/packages/bus/tests/launch-notice.test.mjs index 0bf1703a2..a4121bdb8 100644 --- a/packages/bus/tests/launch-notice.test.mjs +++ b/packages/bus/tests/launch-notice.test.mjs @@ -68,7 +68,7 @@ function setup(t, businesses = demo()) { test('a message to a stopped role sends launch.by one notice, and the result says so', (t) => { const ctx = setup(t); const sent = ctx.call('human', 'message.send', { to: 'pm', body: 'is the plan current?', class: 'REQUEST' }); - assert.deepEqual(sent.waiting, { role: 'pm', launcher: 'chief', told: true, launcherRunning: false }); + assert.deepEqual(sent.waiting, { role: 'pm', launcher: 'chief', told: true, launcherRunning: false, launchable: true }); const [n, ...more] = ctx.notices(); assert.deepEqual(more, []); assert.equal(n.from_run, 'bus'); @@ -90,6 +90,7 @@ test('a message to a stopped role sends launch.by one notice, and the result say launcher: 'chief', told: true, launcherRunning: true, + launchable: true, }); assert.equal(ctx.notices().length, 2); // Row 151 (#1645): the notice is routed to launch.by's holder, which the Console's waiting lists read. @@ -106,6 +107,31 @@ test('a message to a stopped role sends launch.by one notice, and the result say assert.match(ctx.notices().at(-1).body, / from coder to pm waits: /); }); +// Row 152 (#1646): a target outside launch.instances. The result says so at once, the message is +// stored for a later claim, and launch.by is still told, in words that say no launch can follow. +test('a message to a role outside launch.instances says so in its result and in the notice', (t) => { + const ctx = setup(t, demo({ launch: { by: 'chief', instances: ['pm'], models: {}, limits: {} } })); + const sent = ctx.call('human', 'message.send', { to: 'coder', body: 'can you take this?', class: 'REQUEST' }); + assert.deepEqual(sent.waiting, { role: 'coder', launcher: 'chief', told: true, launcherRunning: false, launchable: false }); + assert.equal(ctx.store().get('SELECT to_role FROM messages WHERE id=?', sent.id).to_role, 'coder'); + const [n, ...more] = ctx.notices(); + assert.deepEqual(more, []); + assert.equal(n.to_role, 'chief'); + assert.equal(n.in_reply_to, sent.id); + assert.equal( + n.body, + `Message ${sent.id} from human to coder waits: coder has no running session and isn't in the launch block's instances, so a launch of coder is refused. Tell human why not with message.send in_reply_to ${sent.id}.`, + ); + // The same business's launchable target keeps row 146's text. + assert.equal(ctx.call('human', 'message.send', { to: 'pm', body: 'and you?' }).waiting.launchable, true); + assert.match(ctx.notices().at(-1).body, / waits: pm has no running session\. Launch pm within /); + // A later claim still delivers the stored message. + ctx.launch('coder', 'coder'); + ctx.claim('coder'); + assert.deepEqual(ctx.call('coder', 'message.receive').map((m) => m.id), [sent.id]); + assert.equal(ctx.notices().length, 2); +}); + test('a message to a running role sends no notice and its result is unchanged', (t) => { const ctx = setup(t); ctx.launch('pm', 'pm'); @@ -149,6 +175,7 @@ test('no notice for a message to launch.by, from it, from a tester, to the human launcher: 'chief', told: false, launcherRunning: false, + launchable: false, }); // From launch.by: the result tells it. ctx.launch('chief', 'chief'); @@ -158,6 +185,7 @@ test('no notice for a message to launch.by, from it, from a tester, to the human launcher: 'chief', told: false, launcherRunning: true, + launchable: true, }); // From a tester: a PM test authorizes nothing, so it wakes no launcher. ctx.caps.tester = ctx.b.bindTester({ business: 'demo' }); @@ -186,6 +214,7 @@ test('a business with no launch block tells nobody and says there is no launcher launcher: null, told: false, launcherRunning: null, + launchable: null, }); assert.deepEqual(ctx.notices(), []); }); diff --git a/packages/cli/README.md b/packages/cli/README.md index 2a32f158a..2fca3224c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -69,10 +69,13 @@ mosaic launches list [--json] `mosaic inbox` shows it. When no run holds the role (row 146, #1640), a line after `sent` says so: `pm isn't running, chief has been told` when the bus told `launch.by`; `pm isn't running, and the business names - no launch.by` with no launch block; `pm isn't running; a tester's message + no launch.by` with no launch block; `pm isn't running and isn't in the + launch block, so chief can't launch it` when the role isn't one of + `launch.instances` (row 152, #1646); `pm isn't running; a tester's message tells nobody to launch it` for `--tester`. When `launch.by` itself isn't running, talk prints ` isn't running` (or `isn't running - either` after the told line) and the command that starts it: + either` after the told line, but not after the launch-block line) and the + command that starts it: `systemctl --user restart mosaic-bus@`, which starts it only if the unit runs with `--pm` (`scripts/bus-service.sh status ` shows `pm: on`) and stops running sessions. A hand-started host takes diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 26cddd24f..113f93137 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -221,6 +221,9 @@ function waitingLines(w, business) { `${role} isn't running${either}. systemctl --user restart mosaic-bus@${business()} starts it if the unit runs with --pm (scripts/bus-service.sh status ${business()} shows pm: on). The restart stops running sessions.\n`; if (w.launcher === null) return `${w.role} isn't running, and the business names no launch.by\n`; if (w.role === w.launcher) return restart(w.role); + // Row 152 (#1646): launch.by can launch only launch.instances. An older bus sends no + // launchable, so only an explicit false prints this line. + if (w.launchable === false) return `${w.role} isn't running and isn't in the launch block, so ${w.launcher} can't launch it\n`; // A human's message to another role always tells launch.by; a tester's never does. if (!w.told) return `${w.role} isn't running; a tester's message tells nobody to launch it\n`; return `${w.role} isn't running, ${w.launcher} has been told\n` + (w.launcherRunning ? "" : restart(w.launcher, " either")); diff --git a/packages/cli/tests/verbs.test.mjs b/packages/cli/tests/verbs.test.mjs index c9d0b9d17..e0d6f0dc3 100644 --- a/packages/cli/tests/verbs.test.mjs +++ b/packages/cli/tests/verbs.test.mjs @@ -118,6 +118,24 @@ test("talk says when the role isn't running, whether launch.by was told, and how assert.equal(await text(s, "coder"), ""); }); +// Row 152 (#1646): a role outside launch.instances. talk says so instead of "has been told", and +// prints no restart line for launch.by, whose start wouldn't launch the role. +test("talk to a role launch.by can't launch says it isn't in the launch block", async (t) => { + const roles = { ...BUSINESSES.demo.roles, qa: { authority: { withinRole: ["message.send"], crossRole: [] } } }; + const s = setup(t, {}, { demo: { ...BUSINESSES.demo, roles, launch: { by: "pm", instances: ["cto", "coder"], models: {}, limits: {} } } }); + const text = async (role) => { + const r = await s.run(["talk", role, "hello", "--wait", "0", "--business", "demo"]); + assert.equal(r.code, 0, r.error); + return r.io.out.text.replace(/^sent [0-9a-f]{8} to \S+\n/, ""); + }; + assert.equal(await text("qa"), "qa isn't running and isn't in the launch block, so pm can't launch it\n"); + assert.equal(await text("cto"), "cto isn't running, pm has been told\n" + `pm isn't running either. systemctl --user restart mosaic-bus@demo starts it if the unit runs with --pm (scripts/bus-service.sh status demo shows pm: on). The restart stops running sessions.\n`); + const notices = s.store.all("SELECT body FROM messages WHERE from_role='_bus' ORDER BY seq").map((m) => m.body); + assert.equal(notices.length, 2, "pm is told of both"); + assert.match(notices[0], / waits: qa has no running session and isn't in the launch block's instances, so a launch of qa is refused\. /); + assert.match(notices[1], / waits: cto has no running session\. Launch cto within /); +}); + // Row 151 (#1645): rows 146 and 142 in one talk. The waiting line prints once after "sent", a // message that isn't the reply arrives first and stays unread, then the reply prints and talk exits 0. test("talk to a stopped role prints the waiting line once, skips a non-reply and prints the reply", async (t) => {