cli: mosaic talk prints only the reply to its own message (row 142, #1636)

Researcher's row 142 round 3 (agents/researcher/work/queue-142/r3/
row142.patch, packet dcc2b2936), applied unchanged on dcc2b2936.
Candidate manifest c847d8fe...dd81, 4 paths, rebased onto 277d1fa21
after row 146. Sage's landing gate passed, 24 steps. Researcher's 6
mutants fail by assertion on the new base. Filbert approved round 3 in
comment 28652.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 18:41:48 -05:00
co-authored by Claude Opus 5.5
parent 7908ebc65e
commit 02ee7f6403
4 changed files with 44 additions and 24 deletions
+5 -3
View File
@@ -262,9 +262,11 @@ unit's `MOSAIC_CLAUDE_BIN`, from `--claude PATH` or else `command -v claude`
PID namespace with a generated bundle, and `--pm` launches the business's PID namespace with a generated bundle, and `--pm` launches the business's
`launch.by` instance at start. A host that starts ends the launches a `launch.by` instance at start. A host that starts ends the launches a
crashed host left as `host-lost` and refuses with exit 3 on a 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 what `sessions.json` it can't read. `talk` sends a REQUEST and prints only the
arrives until the reply (polls every 2 s, `--wait` default 900; `0` only reply to it, marking only that read (polls every 2 s, `--wait` default 900;
sends). `stop` sends SIGTERM to a run's runner after checking it in `0` only sends); other messages stay unread for `mosaic inbox`, and
`--follow` and `follow` print and read everything. `stop` sends SIGTERM to
a run's runner after checking it in
`sessions.json`. `launches off|on` revokes and restores `role.launch` for `sessions.json`. `launches off|on` revokes and restores `role.launch` for
the business; running sessions keep running. `launches list` reads the business; running sessions keep running. `launches list` reads
`sessions.json` and needs no broker. `talk`, `stop` and `launches off|on` `sessions.json` and needs no broker. `talk`, `stop` and `launches off|on`
+7 -6
View File
@@ -62,10 +62,11 @@ mosaic launches list [--json]
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
does not pull in the task's rows. does not pull in the task's rows.
- `talk` sends `<text>` to the role as a REQUEST, then polls the human's - `talk` sends `<text>` to the role as a REQUEST, then polls the human's
inbox every 2 s for up to `--wait` seconds (default 900). It prints every inbox every 2 s for up to `--wait` seconds (default 900). It prints the
message that arrives and marks it read, and stops at the reply to its reply to its REQUEST, marks only that read, and stops. Any other message
REQUEST. No reply in time exits 1; the reply may still come, and the next that arrives meanwhile is not printed and stays unread; `mosaic inbox`
`mosaic talk` prints it. When no run holds the role (row 146, #1640), lists it. No reply in time exits 1; the reply may still come, and
`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` 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 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; a tester's message
@@ -77,8 +78,8 @@ mosaic launches list [--json]
shows `pm: on`) and stops running sessions. A hand-started host takes shows `pm: on`) and stops running sessions. A hand-started host takes
`mosaic bus stop`, then `mosaic bus start <business> --pm`. No verb `mosaic bus stop`, then `mosaic bus start <business> --pm`. No verb
launches `launch.by` on a running host. `--wait 0` only sends. `talk --follow` keeps launches `launch.by` on a running host. `--wait 0` only sends. `talk --follow` keeps
printing and marking messages read until Ctrl-C, including messages with printing and marking every message read until Ctrl-C, including messages
no `in_reply_to`. `follow` runs that same loop without sending. `--wait` with no `in_reply_to`. `follow` runs that same loop without sending. `--wait`
and `--follow` together refuse. These commands consume the same human and `--follow` together refuse. These commands consume the same human
inbox; run one receiving CLI at a time. Console only reads history. inbox; run one receiving CLI at a time. Console only reads history.
- `launches off` and `on` are the broker's `launch.revoke` and - `launches off` and `on` are the broker's `launch.revoke` and
+6 -3
View File
@@ -225,8 +225,9 @@ function waitingLines(w, business) {
} }
// `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox // `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox
// until the reply arrives or --wait runs out. --follow keeps receiving after // until the reply to it arrives or --wait runs out; other messages stay unread.
// that reply; follow starts the same receive loop without sending. // --follow prints and reads every message and keeps receiving after that
// reply; follow starts the same receive loop without sending.
async function talk(rest, io, deps, receiveOnly = false) { async function talk(rest, io, deps, receiveOnly = false) {
const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] }); const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] });
if (parsed.positional.length !== (receiveOnly ? 0 : 2)) throw usage(receiveOnly ? "follow takes no arguments" : "talk takes a role and one quoted text"); if (parsed.positional.length !== (receiveOnly ? 0 : 2)) throw usage(receiveOnly ? "follow takes no arguments" : "talk takes a role and one quoted text");
@@ -258,12 +259,14 @@ async function talk(rest, io, deps, receiveOnly = false) {
while (!controller.signal.aborted) { while (!controller.signal.aborted) {
let answered = false; let answered = false;
for (const m of await call("message.receive")) { for (const m of await call("message.receive")) {
// Row 142 (#1636): without --follow only the reply to the sent message is printed and marked read; the rest stays unread for mosaic inbox.
if (!follow && m.in_reply_to !== sent.id) continue;
io.stdout.write(formatMessage(m)); io.stdout.write(formatMessage(m));
await call("message.read", { id: m.id }); await call("message.read", { id: m.id });
if (m.in_reply_to === sent?.id) answered = true; if (m.in_reply_to === sent?.id) answered = true;
} }
if (!follow && answered) return; if (!follow && answered) return;
if (!follow && Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1); if (!follow && Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and mosaic inbox shows it`, 1);
if (controller.signal.aborted) break; if (controller.signal.aborted) break;
await new Promise(resolve => { await new Promise(resolve => {
const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); }; const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); };
+26 -12
View File
@@ -28,32 +28,42 @@ function setup(t, deps = {}, businesses) {
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); 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) => { // Row 142 (#1636): without --follow, talk prints and reads the reply to its own message and nothing else.
test("talk sends a REQUEST, prints and reads only the reply to it, and leaves other messages unread", async (t) => {
const s = setup(t); const s = setup(t);
const cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" }); const cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" });
call(s.b, cto, "role.claim"); call(s.b, cto, "role.claim");
// The coder answers on the second poll; a cto note arrives first and is printed too. // The coder answers on the second poll; a cto note and an unsolicited coder note arrive first.
let polls = 0; let polls = 0;
let note;
let unsolicited;
const transport = (business) => { const transport = (business) => {
const inner = s.transport(business); const inner = s.transport(business);
return async (verb, args) => { return async (verb, args) => {
if (verb === "message.receive" && ++polls === 2) { if (verb === "message.receive" && ++polls === 2) {
const [m] = call(s.b, s.coder, "message.receive"); const [m] = call(s.b, s.coder, "message.receive");
call(s.b, cto, "message.send", { to: m.from_role, body: "fyi", class: "INFO" }); note = call(s.b, cto, "message.send", { to: m.from_role, body: "fyi", class: "INFO" });
unsolicited = call(s.b, s.coder, "message.send", { to: m.from_role, body: "side note", class: "INFO" });
call(s.b, s.coder, "message.send", { to: m.from_role, body: "done: 3 files", class: "RESULT", in_reply_to: m.id }); 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); return inner(verb, args);
}; };
}; };
const r = await s.run(["talk", "coder", "fix the build", "--business", "demo"], io(), { transport }); // A short --wait makes a reply that is never printed fail here instead of waiting out the default 900 s.
const r = await s.run(["talk", "coder", "fix the build", "--wait", "5", "--business", "demo"], io(), { transport });
assert.equal(r.code, 0, r.error); assert.equal(r.code, 0, r.error);
const sent = s.calls.find((c) => c.verb === "message.send"); const sent = s.calls.find((c) => c.verb === "message.send");
assert.deepEqual(sent.args, { to: "coder", body: "fix the build", class: "REQUEST" }); 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, /^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.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.doesNotMatch(r.io.out.text, /fyi|side note/, "another role's note and the same role's unsolicited note are not printed");
assert.deepEqual(await s.transport("demo")("message.receive"), [], "both were marked read"); assert.equal(s.calls.filter((c) => c.verb === "message.read").length, 1, "only the reply was marked read");
// receive delivers a message once, so what talk skipped is no longer receivable; mosaic inbox lists it as unread.
const inbox = io();
await main(["inbox", "--business", "demo"], inbox, { system: { dataRoot: s.dataRoot }, transport: s.transport });
assert.match(inbox.out.text, /messages: 2 unread\n/, "the rest stays unread for mosaic inbox");
assert.match(inbox.out.text, new RegExp(`cto \\(INFO\\) ${note.id.slice(0, 8)}:\nfyi\n`));
assert.match(inbox.out.text, new RegExp(`coder \\(INFO\\) ${unsolicited.id.slice(0, 8)}:\nside note\n`));
}); });
test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show", async (t) => { test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show", async (t) => {
@@ -63,11 +73,11 @@ test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it
assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]); assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]);
const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]); const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
assert.equal(slow.code, 1); assert.equal(slow.code, 1);
// Row 81 (#1575): only mosaic talk shows a late reply; mosaic inbox lists decisions, never messages. // Row 142 (#1636): a late reply stays unread, so mosaic inbox lists it.
assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and the next mosaic talk shows it"); assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and mosaic inbox shows it");
}); });
test("a late reply the Console has listed still reaches the next talk", async (t) => { test("a late reply the Console has listed stays unread for mosaic inbox, and the next talk skips it", async (t) => {
const s = setup(t); const s = setup(t);
assert.equal((await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"])).code, 1); assert.equal((await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"])).code, 1);
const { id } = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1"); const { id } = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1");
@@ -77,8 +87,12 @@ test("a late reply the Console has listed still reaches the next talk", async (t
assert.deepEqual(s.read("replies", { id }).map((m) => m.id), [answer.id]); assert.deepEqual(s.read("replies", { id }).map((m) => m.id), [answer.id]);
const next = await s.run(["talk", "coder", "again", "--wait", "1", "--business", "demo"]); const next = await s.run(["talk", "coder", "again", "--wait", "1", "--business", "demo"]);
assert.equal(next.code, 1, "no answer to the second one"); assert.equal(next.code, 1, "no answer to the second one");
assert.match(next.io.out.text, new RegExp(`coder \\(RESULT, re ${id.slice(0, 8)}\\) ${answer.id.slice(0, 8)}:\nlate answer\n`)); assert.doesNotMatch(next.io.out.text, /late answer/, "the late reply answers the first message, not this one");
assert.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [answer.id]); assert.deepEqual(s.calls.filter((c) => c.verb === "message.read"), [], "nothing was marked read");
const inbox = io();
await main(["inbox", "--business", "demo"], inbox, { system: { dataRoot: s.dataRoot }, transport: s.transport });
assert.match(inbox.out.text, /messages: 1 unread\n/);
assert.match(inbox.out.text, new RegExp(`coder \\(RESULT, re ${id.slice(0, 8)}\\) ${answer.id.slice(0, 8)}:\nlate answer\n`));
}); });
// Row 146 (#1640): the lines after "sent" when the role has no running session. // Row 146 (#1640): the lines after "sent" when the role has no running session.