morgana: row 277 (#1782) build: thread.claims bus verb for human and reader sessions (D1), mosaic agents adds thread lines without --tester or --json (C1/C2), message-filter pin moves to thread.claims (C3), tests T1-T8

This commit is contained in:
jason.woltje committed 2026-10-22 00:25:18 -05:00
1 parent ba2ecb65b0
commit c050bb90b6
9 files changed
+216 -10

No files matched your search

+38
View File
@@ -185,3 +185,41 @@ Notes taken:
c27207f6e). The :2370 reader list is the one list that gains the verb.
Mutants are now M1-M7. Building starts once Sage creates the row.
## r3: build (sage-2486, on 23c3d6218)
Changes from the design, each a placement choice:
- **Bus tests file.** T1-T5 go in a new file, packages/bus/tests/thread-claims.test.mjs, not
agents.test.mjs. agents.test.mjs's businesses have no `assistant` instance, so a thread run would
need a new fixture there. The new file copies message-filter.test.mjs's setup (chief plus an
`assistant` thread instance, human, reader and tester sessions). Its threads are owned by the
business human (`jason`), so the human's `assistant.stop` reaches the run for T2's revoke.
- **T3** covers the chief's role session and the thread run itself (both `human-required`) and the
tester (`tester-refused`, N2).
- **T4** covers human, reader, tester and chief sessions (N1).
- **T5** writes the thread's message to the store, as message-filter.test.mjs's put() does. The run's
own message.send would also pass through the messaging graph, which T5 doesn't test.
- **T6/T8 split.** format.test.mjs holds T6's text cases: mixed, empty roles with threads, and no
threads argument. The `--json` pin needs main(), so it sits with T8 in tester.test.mjs, whose
setup has both stand-in transports. That test also pins the call order: tester `["agents"]`; human
`["agents", "thread.claims"]`; human `--json` adds only `agents`. Its JSON deepEquals the broker's
own `agents` result.
- **T7.** The message-filter.test.mjs pin now reads `holder_run` from the owner's `thread.claims`.
The old pin read `holder` from `threadsState`.
Manifest (actual):
- packages/bus/src/broker.mjs
- packages/bus/README.md (a "Thread claims" paragraph at the end of "Agent state")
- packages/bus/tests/thread-claims.test.mjs (new)
- packages/bus/tests/message-filter.test.mjs
- packages/cli/src/cli.mjs
- packages/cli/src/format.mjs
- packages/cli/tests/format.test.mjs
- packages/cli/tests/tester.test.mjs
- agents/morgana/work/queue-277/BUILD.md
Mutants: M1 (T1, T7), M2 (T2), M3 (T3), M4 (T1, T4), M5 (T5), M6 (T6), M7 (T8). Gate: the touched test
files run whole, plus other-tree.test.mjs. Then the bus and cli suites and every scripts/test-*.sh.
Nothing runs before Sage's CLEAR.
+11
View File
@@ -340,6 +340,17 @@ A new holder doesn't inherit the previous holder's unread deliveries;
decisions stay with the role. Unheld and released roles aren't listed, so a
message stranded for a role nobody holds shows once a run claims the role.
Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
`thread.claims` takes no arguments and lists the held thread claims in the
business, oldest thread first, as `{thread, instance, holder_run, harness,
at}`. A claim is held while its thread's latest `thread_claims` row is a
claim, so a released or revoked thread drops out. Human and reader sessions
only: a role session refuses `human-required` and the tester
`tester-refused`. The result carries the thread id but never its name, which
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
after the roles; `mosaic agents --json` and `--tester` print `agents` alone.
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
+13 -1
View File
@@ -2367,7 +2367,7 @@ export class Broker {
return result;
}
return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
if (s.reader && !['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
fail('read-only');
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
// A tester reads the human's views, as a reader does, and its message verbs are its own.
@@ -3418,6 +3418,18 @@ export class Broker {
if (verb === 'tasks') return this.#tasks(s.business, a);
return this.#trail(s, identifier(a.subject));
}
// Row 277 (#1782, design D1): the thread claims held in the business, for human and reader
// sessions only. A thread run's role.claim writes thread_claims, which `agents` doesn't read,
// so this is where the owner sees it (PRD :1083-1084). A claim is held when its thread's latest
// thread_claims row is a claim, as #threadHolders reads it. No thread name: a user wrote that.
case 'thread.claims': {
if (!s.human && !s.reader) fail('human-required');
keys(a, [], []);
return this.#store.all(
"SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND c.op='claim' ORDER BY t.seq",
s.business,
);
}
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
// reader sessions only. Each row is inbox's view of the decision: the `decisions` row trail
// gives the same session for that id, with blocking as a boolean, plus `authorization` from
+3 -5
View File
@@ -172,11 +172,9 @@ test('governance pin: the owner sees decisions, inbox entries, claims, tasks and
const inbox = await f.call(f.human, 'inbox');
assert.ok(inbox.some((x) => x.id === d), 'inbox entries: the run raise is in the owner inbox');
// A thread run's claim is in thread_claims, and `agents` reads role_claims only, so no owner verb
// shows it yet (sage-2430). This is the launcher view: the claim is recorded and the filter leaves it
// alone. The follow-up row Sage adds for an owner-visible thread-claim read moves this to an owner verb.
const claim = f.broker.threadsState('demo').find((x) => x.thread === U);
assert.equal(claim?.holder, 'assistant-run', 'claims: the run claim is recorded');
// Row 277 (#1782): the owner's thread.claims lists the run's held thread claim.
const claim = (await f.call(f.human, 'thread.claims')).find((x) => x.thread === U);
assert.equal(claim?.holder_run, 'assistant-run', 'claims: the run claim shows to the owner');
const tasks = await f.call(f.human, 'tasks');
assert.ok(tasks.some((x) => x.task_ref === 'vikunja:3/47' && x.run === 'assistant-run'), 'tasks: the run task write shows');
const file = await f.call(f.human, 'project.file', { project: 'stack', path: 'docs/alice-plan.md' });
+113
View File
@@ -0,0 +1,113 @@
// Row 277 (#1782, design D1, duckworth-0118): `thread.claims` gives human and reader sessions the
// thread claims held in the business. A thread run's role.claim writes thread_claims, which `agents`
// doesn't read, so this is where the owner sees it. `agents` is unchanged for every session.
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 { randomUUID } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
const ROW = ['thread', 'instance', 'holder_run', 'harness', 'at'];
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-claims-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: { chief: { definition: 'chief-of-staff', ...within(['message.send']) }, assistant: { definition: 'assistant', ...within(['message.send']) } },
},
},
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
b.request(chief, { verb: 'role.claim' });
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
chief,
};
const call = (who, verb, args = {}) => b.request(typeof who === 'string' ? caps[who] : who, { verb, args });
const project = call('human', 'project.create', { name: 'shared work' }).id;
// The human's own thread, so the human's assistant.stop reaches its run.
const thread = (name) => {
const id = randomUUID();
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', project, 'assistant', 'jason', name);
return id;
};
const claim = (H, run) => {
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H });
call(cap, 'role.claim');
return cap;
};
return { b, store, caps, call, thread, claim };
}
test('T1: the human and the reader see a held thread claim; agents still lists the role only', (t) => {
const { store, call, thread, claim } = setup(t);
const H = thread('plan');
claim(H, 'assistant-run');
const at = store.get("SELECT at FROM thread_claims WHERE thread=? AND op='claim'", H).at;
const rows = call('human', 'thread.claims');
assert.deepEqual(rows, [{ thread: H, instance: 'assistant', holder_run: 'assistant-run', harness: 'pi', at }]);
for (const r of rows) assert.deepEqual(Object.keys(r), ROW);
assert.deepEqual(call('reader', 'thread.claims'), rows);
assert.deepEqual(call('human', 'agents').map((r) => r.role), ['chief']);
});
test('T2: a released or revoked thread claim is not listed, though its thread has an earlier claim row', (t) => {
const { store, call, thread, claim } = setup(t);
const H = thread('plan');
const first = claim(H, 'run-1');
call(first, 'role.release');
assert.deepEqual(call('human', 'thread.claims'), [], 'released');
claim(H, 'run-2');
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-2'], 'claimed again');
call('human', 'assistant.stop', { instance: 'assistant' });
assert.deepEqual(call('human', 'thread.claims'), [], 'revoked');
// The thread's first row is still a claim, so only the latest row decides.
assert.deepEqual(store.all('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq', H).map((r) => r.op), ['claim', 'release', 'claim', 'revoke']);
});
test('T3: a role session refuses human-required and the tester refuses tester-refused', (t) => {
const { call, thread, claim } = setup(t);
const run = claim(thread('plan'), 'assistant-run');
for (const who of ['chief', run]) assert.throws(() => call(who, 'thread.claims'), { code: 'human-required' });
assert.throws(() => call('tester', 'thread.claims'), { code: 'tester-refused' });
});
test('T4: agents answers each session the same before and after a thread run claims', (t) => {
const { call, thread, claim } = setup(t);
const sessions = ['human', 'reader', 'tester', 'chief'];
const before = Object.fromEntries(sessions.map((who) => [who, call(who, 'agents')]));
claim(thread('plan'), 'assistant-run');
assert.equal(call('human', 'thread.claims').length, 1);
for (const who of sessions) assert.deepEqual(call(who, 'agents'), before[who], who);
});
test('T5: the result carries neither the thread name a user wrote nor a message body', (t) => {
const { store, call, thread, claim } = setup(t);
const H = thread('alice secret thread name');
claim(H, 'assistant-run');
// The thread's message, written to the store as message-filter.test.mjs's put() writes one.
const m = randomUUID();
store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
m, AT, 'demo', 'assistant', 'assistant-run', 'human', 'INFO', null, null, null, 'alice secret message body',
);
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', m, H);
const text = JSON.stringify(call('human', 'thread.claims'));
assert.ok(text.includes(H), 'the thread row is there');
assert.doesNotMatch(text, /secret/);
});
+6 -1
View File
@@ -311,7 +311,12 @@ async function human(verb, rest, io, deps) {
`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
}
if (verb === "tasks") return print(await call("tasks"), formatTasks);
return print(await call("agents"), formatAgents);
const agents = await call("agents");
// Row 277 (#1782, C1/C2): the human's text output adds the held thread claims. A tester can't read
// thread.claims, so --tester makes the one call, and --json stays the agents array.
if (json || parsed.flags.has("--tester")) return print(agents, formatAgents);
const threads = await call("thread.claims");
return print(agents, (list) => formatAgents(list, threads));
}
// Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an
+4 -3
View File
@@ -49,9 +49,10 @@ export function formatDecision(d) {
return `${out.join("\n")}\n`;
}
export function formatAgents(list) {
if (list.length === 0) return "agents: no role is claimed\n";
return `${list.map((c) => `${c.role} ${c.holder_run} ${c.harness ?? "-"} since ${c.at}`).join("\n")}\n`;
// Row 277 (#1782): the held thread claims follow the role lines, each marked `thread`.
export function formatAgents(list, threads = []) {
const roles = list.length === 0 ? "agents: no role is claimed\n" : `${list.map((c) => `${c.role} ${c.holder_run} ${c.harness ?? "-"} since ${c.at}`).join("\n")}\n`;
return roles + threads.map((c) => `thread ${c.thread} ${c.instance} ${c.holder_run} ${c.harness} since ${c.at}\n`).join("");
}
export function formatTasks(list) {
+12
View File
@@ -8,6 +8,18 @@ test("empty views say so", () => {
assert.equal(formatTasks([]), "tasks: none\n");
});
// Row 277 (#1782, C2): the held thread claims follow the role lines, each marked `thread`.
test("agents adds a thread line for each held thread claim, after the roles", () => {
const roles = [{ role: "coder", holder_run: "coder-run", harness: "pi", at: "2026-10-22T05:00:00Z" }];
const threads = [{ thread: "t-1", instance: "assistant", holder_run: "assistant-run", harness: "pi", at: "2026-10-22T05:01:00Z" }];
assert.equal(
formatAgents(roles, threads),
"coder coder-run pi since 2026-10-22T05:00:00Z\nthread t-1 assistant assistant-run pi since 2026-10-22T05:01:00Z\n",
);
assert.equal(formatAgents([], threads), "agents: no role is claimed\nthread t-1 assistant assistant-run pi since 2026-10-22T05:01:00Z\n");
assert.equal(formatAgents(roles), formatAgents(roles, []));
});
test("the trail keeps the broker's order and names a decision's task without its rows", () => {
const rows = [
{ at: "2026-10-08T10:00:00Z", table: "decisions", seq: 4, id: "d-1", class: "gated", action: "deploy", route_to: "human", blocking: 1, question: "Ship?", task_ref: "vikunja:1/7" },
+16
View File
@@ -128,6 +128,22 @@ test("with no stand-in transport, --tester runs tester-cli.mjs on the data root'
assert.deepEqual(seen, [{ argv: [TESTER_CLI, join(dataRoot, "bus", "broker.sock")], input: { business: "demo", verb: "agents", args: {} } }]);
});
// Row 277 (#1782, C1/C2): the human's text adds thread.claims, which a tester can't read, so
// `agents --tester` stays one call. --json prints the agents array alone, as `inbox --json` does.
test("agents --tester makes one call; the human's agents also reads thread.claims, and --json is the agents array", async (t) => {
const s = setup(t);
await main(["agents", "--tester", "--business", "demo"], inAgent(), s.deps);
assert.deepEqual(s.calls.tester.map((c) => c.verb), ["agents"]);
const x = io();
await main(["agents", "--business", "demo"], x, s.deps);
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents", "thread.claims"]);
assert.match(x.out.text, /^coder coder-run pi since /);
const j = io();
await main(["agents", "--json", "--business", "demo"], j, s.deps);
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents", "thread.claims", "agents"]);
assert.deepEqual(JSON.parse(j.out.text), s.b.request(s.human, { verb: "agents", args: {} }));
});
// Row 170 (#1667): the bus refuses a tester's message to a stopped role and stores nothing, and
// talk says so and exits 3.
test("talk --tester to a stopped role exits 3, prints no sent line and stores nothing", async (t) => {