bus, cli: a message to a role launch.by can't launch says so at once (row 152, #1646)

Row 152 round 1, Dewey's candidate on 56639c99b. Filbert approved
(comment 28741). The bus marks a waiting message launchable or not;
when the target isn't in the launch block, launch.by is still told,
with a notice saying the launch would be refused, and talk prints that
launch.by can't launch the role. Sage's landing gate ran on 5f57d85e5
plus the candidate: 25 steps, all rc 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-15 19:58:56 -05:00
1 parent ccfcc31512
commit 26a0b0fab4
7 files changed
+89 -13

No files matched your search

+4 -1
View File
@@ -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
+17 -3
View File
@@ -380,6 +380,17 @@ any other, so a stopped `launch.by` gets it at its next claim. The body is
Message <id> from <sender> to <role> waits: <role> has no running session. Launch <role> within the launch block's limits, or tell <sender> why not with message.send in_reply_to <id>.
```
When the role isn't one of `launch.instances` (row 152, #1646), `launch.by`
can't launch it, and the body says so:
```
Message <id> from <sender> to <role> waits: <role> has no running session and isn't in the launch block's instances, so a launch of <role> is refused. Tell <sender> why not with message.send in_reply_to <id>.
```
`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
+12 -6
View File
@@ -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);
}
+30 -1
View File
@@ -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(), []);
});
+5 -2
View File
@@ -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 `<launch.by> 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@<business>`, which starts it only if
the unit runs with `--pm` (`scripts/bus-service.sh status <business>`
shows `pm: on`) and stops running sessions. A hand-started host takes
+3
View File
@@ -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"));
+18
View File
@@ -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) => {