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, packetdcc2b2936), applied unchanged ondcc2b2936. Candidate manifest c847d8fe...dd81, 4 paths, rebased onto277d1fa21after 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:
+5
-3
@@ -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
|
||||
`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
|
||||
`sessions.json` it can't read. `talk` sends a REQUEST and prints what
|
||||
arrives until the reply (polls every 2 s, `--wait` default 900; `0` only
|
||||
sends). `stop` sends SIGTERM to a run's runner after checking it in
|
||||
`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
|
||||
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
|
||||
`sessions.json` and needs no broker. `talk`, `stop` and `launches off|on`
|
||||
|
||||
@@ -62,10 +62,11 @@ mosaic launches list [--json]
|
||||
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
|
||||
does not pull in the task's rows.
|
||||
- `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
|
||||
message that arrives and marks it read, and stops at the reply to its
|
||||
REQUEST. No reply in time exits 1; the reply may still come, and the next
|
||||
`mosaic talk` prints it. When no run holds the role (row 146, #1640),
|
||||
inbox every 2 s for up to `--wait` seconds (default 900). It prints the
|
||||
reply to its REQUEST, marks only that read, and stops. Any other message
|
||||
that arrives meanwhile is not printed and stays unread; `mosaic inbox`
|
||||
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`
|
||||
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
|
||||
@@ -77,8 +78,8 @@ mosaic launches list [--json]
|
||||
shows `pm: on`) and stops running sessions. A hand-started host takes
|
||||
`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
|
||||
printing and marking messages read until Ctrl-C, including messages with
|
||||
no `in_reply_to`. `follow` runs that same loop without sending. `--wait`
|
||||
printing and marking every message read until Ctrl-C, including messages
|
||||
with no `in_reply_to`. `follow` runs that same loop without sending. `--wait`
|
||||
and `--follow` together refuse. These commands consume the same human
|
||||
inbox; run one receiving CLI at a time. Console only reads history.
|
||||
- `launches off` and `on` are the broker's `launch.revoke` and
|
||||
|
||||
@@ -225,8 +225,9 @@ function waitingLines(w, business) {
|
||||
}
|
||||
|
||||
// `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
|
||||
// that reply; follow starts the same receive loop without sending.
|
||||
// until the reply to it arrives or --wait runs out; other messages stay unread.
|
||||
// --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) {
|
||||
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");
|
||||
@@ -258,12 +259,14 @@ async function talk(rest, io, deps, receiveOnly = false) {
|
||||
while (!controller.signal.aborted) {
|
||||
let answered = false;
|
||||
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));
|
||||
await call("message.read", { id: m.id });
|
||||
if (m.in_reply_to === sent?.id) answered = true;
|
||||
}
|
||||
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;
|
||||
await new Promise(resolve => {
|
||||
const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); };
|
||||
|
||||
@@ -28,32 +28,42 @@ function setup(t, deps = {}, businesses) {
|
||||
|
||||
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 cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" });
|
||||
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 note;
|
||||
let unsolicited;
|
||||
const transport = (business) => {
|
||||
const inner = s.transport(business);
|
||||
return async (verb, args) => {
|
||||
if (verb === "message.receive" && ++polls === 2) {
|
||||
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 });
|
||||
}
|
||||
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);
|
||||
const sent = s.calls.find((c) => c.verb === "message.send");
|
||||
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, /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.equal(s.calls.filter((c) => c.verb === "message.read").length, 2);
|
||||
assert.deepEqual(await s.transport("demo")("message.receive"), [], "both were marked read");
|
||||
assert.doesNotMatch(r.io.out.text, /fyi|side note/, "another role's note and the same role's unsolicited note are not printed");
|
||||
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) => {
|
||||
@@ -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"]);
|
||||
const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
|
||||
assert.equal(slow.code, 1);
|
||||
// Row 81 (#1575): only mosaic talk shows a late reply; mosaic inbox lists decisions, never messages.
|
||||
assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and the next mosaic talk shows it");
|
||||
// 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 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);
|
||||
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");
|
||||
@@ -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]);
|
||||
const next = await s.run(["talk", "coder", "again", "--wait", "1", "--business", "demo"]);
|
||||
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.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [answer.id]);
|
||||
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"), [], "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.
|
||||
|
||||
Reference in New Issue
Block a user