Files
stack/agents/dewey/work/queue-152/build.patch
T
jason.woltjeandClaude Opus 5.5 ecf1cec070 agents: Dewey row 152 round 1 packet (#1646)
Candidate on 56639c99b: waiting gains launchable, and a message to a role
outside launch.instances gets a notice saying the launch is refused and a
talk line saying launch.by can't launch it. Mutants 8/8 killed; gate green
(bus, cli, business, webui and every scripts/test-*.sh).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:47:11 -05:00

240 lines
15 KiB
Diff

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 <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
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 `<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
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) => {