diff --git a/agents/dewey/work/queue-81/r2/build.md b/agents/dewey/work/queue-81/r2/build.md new file mode 100644 index 000000000..85e1b1c61 --- /dev/null +++ b/agents/dewey/work/queue-81/r2/build.md @@ -0,0 +1,64 @@ +# Row 81 (#1575): Console shows the reply to a web send, round 2 + +Owner: Dewey. Reviewer: Darkwing (lead decision 97), then Sage's gate. +Round 1 is `../build.md` (base `ff11aa4b`, manifest `2263ff5f`), approved by +Darkwing in comment 27783. Round 2 is a rebase after row 61 (#1550) landed +as `6c42344d`, with the two follow-ups from round 1 folded in, as Sage asked. + +Base `1e5f5958` (queue rev 689, row 61 done). `build.patch` is +`git diff --cached --binary 1e5f5958 --` the thirteen paths in +`candidate-manifest.sha256`. Apply it with `git apply --index` to a detached +worktree of the base; the manifest then checks 13/13 OK. + +## What changed from round 1 + +- **Rebase.** Round 1's `build.patch` applied to `1e5f5958` with + `git apply --3way --index`, every file clean, no conflict markers. Row 61 + changed three of the twelve paths between `ff11aa4b` and `1e5f5958`: + `packages/webui/src/serve.mjs` (`GET /api/task-fields`), + `src/public/bus.js` (the "Who may write" task section) and + `packages/webui/README.md`. Row 81's hunks in those files are in different + places. Checks: + - The added and removed lines of round 2's patch equal round 1's, except + the two follow-ups below. + - Every line row 61 added to the three shared files is still in the + candidate. + - Row 61's route is served by its own branch in `serve.mjs`, not + `busRead`, so `BUS_VERBS` and the new `id` check don't reach it. Row 61's + section in `bus.js` reads through `get.api`; row 81 changes only + `fetchRead`'s query name for `replies`, which `get.api` doesn't use. + No behaviour changes beyond merging the two rows. +- **Follow-up 1: `packages/cli/README.md`**, a thirteenth path. The `talk` + entry said "No reply in time exits 1; the reply still lands in the inbox", + which reads like the timeout wording round 1 removed. It now says "the + reply may still come, and the next `mosaic talk` prints it", matching + the CLI's timeout message. +- **Follow-up 2: `packages/webui/src/bus.mjs`.** The file's opening + comment had a 103-character line. Rewrapped at the block's width (76 + at most); no words changed. + +## Gate (my run on the candidate) + +Sequential, in a worktree of `1e5f5958` with `build.patch` applied +(`gate-summary.txt`): node suites for bus, webui, control-board and cli, +plus tasks because row 61 made webui read `packages/tasks/fields.json`; then +every `scripts/test-*.sh`. + +2026-10-12T19:05:31Z to 19:10:13Z, all rc=0: + +- `node --test 'packages/
/tests/*.test.mjs'`: bus 102/102, webui 84/84
+ (round 1's 83 plus row 61's one), control-board 124/124, cli 115/115,
+ tasks 74/74.
+- `scripts/test-auth.sh`, `test-conductor.sh`, `test-config.sh`,
+ `test-discord.sh`, `test-extension-package.sh`, `test-foundation.sh`,
+ `test-queue.sh`, `test-release.sh`, `test-task.sh`.
+
+`build.patch` applied to a second fresh worktree of `1e5f5958` also checks
+13/13; that copy is the one the mutants ran in.
+
+## Mutants
+
+Round 1's 17 mutants (`mutants.py`, its path pointed at a scratch copy of
+this candidate), rerun after the gate (`mutants.txt`): all 17 killed, and the copy checked 13/13 again afterwards. The follow-ups
+change no code, so no new mutant: the README is prose, and the `bus.mjs`
+edit is inside a comment.
diff --git a/agents/dewey/work/queue-81/r2/build.patch b/agents/dewey/work/queue-81/r2/build.patch
new file mode 100644
index 000000000..4e6206ac6
--- /dev/null
+++ b/agents/dewey/work/queue-81/r2/build.patch
@@ -0,0 +1,749 @@
+diff --git a/packages/bus/README.md b/packages/bus/README.md
+index 11489360..69824a30 100644
+--- a/packages/bus/README.md
++++ b/packages/bus/README.md
+@@ -130,6 +130,7 @@ an operator inspect content and delivery evidence after an uncertain result.
+ | `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
+ | `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
+ | `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
++| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
+
+ ### Agent state (row 59, #1548)
+
+@@ -226,7 +227,7 @@ must not invoke the human CLI or work around its proof.
+
+ Human resolutions and launch toggles append `human.input`. Agent capabilities
+ and read-only WebUI capabilities cannot reach these operations. Reader
+-capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials for one
++capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies for one
+ business; they do not confer mutation or resolution authority.
+
+ `decisions` (row 57, #1546) is for human and reader sessions only; a role
+@@ -264,6 +265,18 @@ business file changes after startup: a date, an account, or a credential
+ added or removed. Rotating a token file without editing its date changes
+ neither.
+
++`replies` (row 81, #1575) is also for human and reader sessions only; a role
++session gets `human-required`. It takes `{id}`, nothing else, and returns
++the business's messages whose `in_reply_to` is that id, in broker write
++order, at most `REPLIES` (50). Each row is the `messages` row `trail` gives
++the same session for the reply's id. An id that names no message in the
++session's business refuses `message-not-found`, as `message.send` does for
++`in_reply_to`; `trail` returns an empty list for such an id instead. The
++read writes no `deliveries` row, so a reply the WebUI's Sent receipt lists
++still reaches the human's next `message.receive` (`mosaic talk`). It is a
++separate verb rather than a `trail` field so `trail`'s shape and its
++empty-list answer for unknown subjects stay as they are.
++
+ ## Shared reads and adapter boundary
+
+ ```js
+@@ -273,6 +286,7 @@ const read = views(new Client({path,cap}));
+ await read.inbox(); await read.tasks(); await read.agents();
+ await read.trail('vikunja:3/41');
+ await read.decisions('roles'); await read.decisions('recent');
++await read.replies(messageId);
+ ```
+
+ S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
+diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
+index 5acc8329..4389b353 100644
+--- a/packages/bus/src/broker.mjs
++++ b/packages/bus/src/broker.mjs
+@@ -40,6 +40,8 @@ const GATED = new Set([
+ const CLOSED = "('resolved','withdrawn','expired')";
+ // How many closed decisions `decisions` with list 'recent' returns, newest close first.
+ export const RECENT = 50;
++// How many replies `replies` returns, oldest first.
++export const REPLIES = 50;
+ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
+ const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
+ const POLL_KINDS = new Set([
+@@ -492,7 +494,7 @@ export class Broker {
+ if (!object(args)) fail('invalid-request');
+ this.#secretCheck(args);
+ return this.#store.transaction(() => {
+- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials'].includes(request.verb))
++ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies'].includes(request.verb))
+ fail('read-only');
+ const result = this.#dispatch(s, request.verb, args);
+ this.#secretCheck(result);
+@@ -906,6 +908,22 @@ export class Broker {
+ };
+ });
+ }
++ // Row 81 (#1575): the replies to a message, for the Console's web-send receipt. Human and
++ // reader sessions only, like decisions. It writes no delivery or read row, so a reply it
++ // lists still reaches the next `mosaic talk`. An id naming no message in the business
++ // refuses message-not-found, as message.send does for in_reply_to; trail would return [].
++ case 'replies': {
++ if (!s.human && !s.reader) fail('human-required');
++ keys(a, ['id'], ['id']);
++ identifier(a.id);
++ if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
++ fail('message-not-found');
++ return this.#store.all(
++ `SELECT * FROM messages WHERE business=? AND in_reply_to=? ORDER BY seq LIMIT ${REPLIES}`,
++ s.business,
++ a.id,
++ );
++ }
+ default:
+ fail('unknown-verb');
+ }
+diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs
+index a33c0faf..27ca798f 100644
+--- a/packages/bus/src/views.mjs
++++ b/packages/bus/src/views.mjs
+@@ -7,5 +7,6 @@ export function views(client) {
+ trail: (subject) => client.call('trail', { subject }),
+ decisions: (list) => client.call('decisions', { list }),
+ credentials: () => client.call('credentials'),
++ replies: (id) => client.call('replies', { id }),
+ });
+ }
+diff --git a/packages/bus/tests/replies.test.mjs b/packages/bus/tests/replies.test.mjs
+new file mode 100644
+index 00000000..600e1daf
+--- /dev/null
++++ b/packages/bus/tests/replies.test.mjs
+@@ -0,0 +1,162 @@
++// Row 81 (#1575): `replies`, the read behind the Console's web-send receipt. The messages
++// answering one message, oldest first, at most REPLIES, for human and reader sessions. It
++// writes no delivery or read row, so the human's next message.receive (mosaic talk) still
++// gets each reply.
++import test from 'node:test';
++import assert from 'node:assert/strict';
++import { mkdtempSync, rmSync } from 'node:fs';
++import { tmpdir } from 'node:os';
++import { join } from 'node:path';
++import { Store } from '../src/store.mjs';
++import { Broker, REPLIES } from '../src/broker.mjs';
++import { views } from '../src/views.mjs';
++
++const roles = {
++ pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
++ coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
++};
++const businesses = {
++ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
++ other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
++};
++function setup(t) {
++ const root = mkdtempSync(join(tmpdir(), 'bus-replies-'));
++ const store = new Store(root);
++ const b = new Broker({ store, businesses });
++ t.after(() => {
++ store.close();
++ rmSync(root, { recursive: true, force: true });
++ });
++ const agent = (role, business = 'demo') => {
++ const cap = b.bindLaunch({ business, role, run: `${business}-${role}-run`, harness: 'pi', address: `${business}-${role}-run` });
++ b.request(cap, { verb: 'role.claim' });
++ return cap;
++ };
++ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
++ const reader = b.bindReader({ business: 'demo' });
++ const coder = agent('coder');
++ const pm = agent('pm');
++ // The human's web send, as row 76's Console makes it.
++ const sent = call(b, human, 'message.send', { to: 'coder', body: 'Please look at row 81.' });
++ const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq');
++ const count = () =>
++ ['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
++ (table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
++ );
++ return { b, store, agent, human, reader, coder, pm, sent, deliveries, count };
++}
++const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
++const reply = (b, cap, to, body, extra = {}) =>
++ call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra });
++
++test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => {
++ const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t);
++ call(b, coder, 'message.receive');
++ const answer = reply(b, coder, sent.id, 'Done: 3 files.');
++ // Not replies to the send: a message to the human with no in_reply_to, and a reply to another message.
++ call(b, pm, 'message.send', { to: 'human', body: 'fyi' });
++ const other = call(b, human, 'message.send', { to: 'pm', body: 'Another question' });
++ reply(b, pm, other.id, 'Another answer');
++
++ const before = deliveries();
++ const counts = count();
++ for (const cap of [human, reader]) {
++ const rows = call(b, cap, 'replies', { id: sent.id });
++ assert.deepEqual(
++ rows.map((m) => [m.id, m.from_role, m.to_role, m.class, m.in_reply_to, m.body]),
++ [[answer.id, 'coder', 'human', 'RESULT', sent.id, 'Done: 3 files.']],
++ );
++ // The whole row is the messages row trail gives the same session for that id.
++ const { table, ...m } = call(b, cap, 'trail', { subject: answer.id }).find((r) => r.table === 'messages');
++ assert.equal(table, 'messages');
++ assert.deepEqual({ ...rows[0] }, m);
++ }
++ assert.deepEqual(deliveries(), before, 'no delivered or read row, nothing else either');
++ assert.deepEqual(count(), counts, 'a successful read writes nothing');
++ assert.deepEqual(
++ store.all("SELECT op FROM deliveries WHERE message=? ORDER BY seq", answer.id).map((d) => d.op),
++ ['routed'],
++ );
++});
++
++test('the reply still reaches the human message.receive (mosaic talk) after the Console showed it', (t) => {
++ const { b, human, reader, coder, sent } = setup(t);
++ const answer = reply(b, coder, sent.id, 'Done.');
++ assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
++ assert.deepEqual(call(b, human, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
++ const got = call(b, human, 'message.receive');
++ assert.deepEqual(got.map((m) => [m.id, m.in_reply_to, m.body]), [[answer.id, sent.id, 'Done.']]);
++ assert.deepEqual(call(b, human, 'message.read', { id: answer.id }), { read: true });
++ assert.deepEqual(call(b, human, 'message.receive'), []);
++ // Once received and read it is still listed: the read is history, not an inbox.
++ assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
++});
++
++test('a message with no reply gives an empty list', (t) => {
++ const { b, human, reader, sent } = setup(t);
++ for (const cap of [human, reader]) assert.deepEqual(call(b, cap, 'replies', { id: sent.id }), []);
++});
++
++test('replies come oldest first and stop at REPLIES', (t) => {
++ const { b, reader, coder, pm, sent } = setup(t);
++ assert.equal(REPLIES, 50);
++ const ids = [];
++ for (let i = 0; i <= REPLIES; i++) ids.push(reply(b, i % 2 ? pm : coder, sent.id, `reply ${i}`).id);
++ const rows = call(b, reader, 'replies', { id: sent.id });
++ assert.equal(rows.length, REPLIES);
++ assert.deepEqual(
++ rows.map((m) => m.id),
++ ids.slice(0, REPLIES),
++ );
++ assert.deepEqual(rows.slice(0, 3).map((m) => [m.from_role, m.body]), [
++ ['coder', 'reply 0'],
++ ['pm', 'reply 1'],
++ ['coder', 'reply 2'],
++ ]);
++});
++
++test('an id that names no message in the business refuses message-not-found, and the refusal is recorded', (t) => {
++ const { b, store, agent, human, reader, pm, sent } = setup(t);
++ const elsewhere = call(b, agent('coder', 'other'), 'message.send', { to: 'human', body: 'not demo' });
++ const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
++ for (const cap of [human, reader])
++ for (const id of ['00000000-0000-4000-8000-000000000000', elsewhere.id, 'vikunja:3/1']) {
++ const before = refusals();
++ assert.throws(() => call(b, cap, 'replies', { id }), { code: 'message-not-found' }, id);
++ assert.equal(refusals(), before + 1, 'the refusal is recorded');
++ }
++ // A decision id is not a message id either.
++ const d = call(b, pm, 'decision.raise', {
++ action: 'deploy',
++ target: 'release-1',
++ question: 'Deploy?',
++ options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }],
++ recommendation: 'yes',
++ blocking: false,
++ });
++ assert.throws(() => call(b, reader, 'replies', { id: d.id }), { code: 'message-not-found' });
++ // The other business's reader finds its own message, not demo's.
++ const otherReader = b.bindReader({ business: 'other' });
++ assert.deepEqual(call(b, otherReader, 'replies', { id: elsewhere.id }), []);
++ assert.throws(() => call(b, otherReader, 'replies', { id: sent.id }), { code: 'message-not-found' });
++});
++
++test('strict arguments; a role session gets human-required', (t) => {
++ const { b, human, reader, coder, pm, sent } = setup(t);
++ for (const cap of [human, reader])
++ for (const args of [{}, { id: '' }, { id: 7 }, { id: [sent.id] }, { id: '' }, { id: sent.id, limit: 5 }, { subject: sent.id }])
++ assert.throws(() => call(b, cap, 'replies', args), { code: 'invalid-request' }, JSON.stringify(args));
++ // A role can't read the human's replies, even to a message it sent itself.
++ const own = call(b, pm, 'message.send', { to: 'coder', body: 'hi' });
++ for (const [cap, id] of [[coder, sent.id], [pm, own.id]])
++ assert.throws(() => call(b, cap, 'replies', { id }), { code: 'human-required' });
++ // The reader's other limits stand.
++ assert.throws(() => call(b, reader, 'message.receive'), { code: 'read-only' });
++});
++
++test('views passes replies through as one call with the id', async () => {
++ const calls = [];
++ const read = views({ call: async (verb, args) => (calls.push([verb, args]), []) });
++ assert.deepEqual(await read.replies('m-1'), []);
++ assert.deepEqual(calls, [['replies', { id: 'm-1' }]]);
++});
+diff --git a/packages/cli/README.md b/packages/cli/README.md
+index 72fe2669..c90fc3d7 100644
+--- a/packages/cli/README.md
++++ b/packages/cli/README.md
+@@ -61,8 +61,8 @@ mosaic launches list [--json]
+ - `talk` sends ` The replies to it could not be read. Read again to retry; No reply yet. One shows here when the role answers, as the page refreshes. ${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · trail ${txt(m.body)} The first ${REPLIES} replies show here. Sent to ${txt(r.to)} ${when(r.at)}, message Sent to ${txt(r.to)} ${when(r.at)}, message Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` ( Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal. Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. A reply comes back to you as a message, which this Console does not show yet. The next Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. Replies to it show under the receipt. The Console only shows them and never marks one delivered or read, so the next The held roles could not be read, so there is nothing to pick. Read again to retry. No role is held right now, so there is nothing to pick.mosaic talk in a terminal prints them.Replies ${replies.length}
${replies.map(m => `
${replies.length >= REPLIES ? `${txt(short(r.id))}. Its trail.${txt(short(r.id))}. Its trail.${txt(r.code)})`}. The draft is kept.Message a role
+- mosaic talk in a terminal prints it, after sending its own message.mosaic talk in a terminal still prints each one.