Files
stack/packages/bus/tests/agents.test.mjs
T
jason.woltjeandClaude Opus 5.5 8e0fbca420 bus: list messages stranded by a holder change in agents, row 80 (#1574)
A holder that releases a role without reading what it was delivered leaves
those messages where no later holder receives or reads them. The agents
view now lists them for human and reader sessions under `stranded`, as
{id, from, run, at}, oldest first, at most 50. The bus doesn't redeliver,
route or mark them read; a person who still needs one sends it again.

Round 2 is a mechanical rebase of round 1 onto row 93. Reviewed by Filbert
(28206, approve); Sage gate record 28207. Gate on 7496251c: 14 node suites
and 9 scripts green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:01:50 -05:00

241 lines
13 KiB
JavaScript

// Row 59 (#1548): `agents` gives human and reader sessions each held role's state,
// derived from records the bus already keeps. A role session gets the claims only.
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 } from '../src/broker.mjs';
const options = [
{ key: 'yes', text: 'Allow' },
{ key: 'no', text: 'Decline' },
];
const roles = {
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
other: { id: 'other', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
};
const CLAIM = ['seq', 'at', 'business', 'role', 'op', 'holder_run', 'harness', 'address', 'by', 'reason', 'decision'];
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-agents-'));
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', run = `${business}-${role}-run`) => {
const cap = b.bindLaunch({ business, role, run, harness: 'pi', address: 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' });
return { b, store, agent, human, reader };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
const raise = (b, cap, action, extra = {}) =>
call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
const byRole = (rows) => Object.fromEntries(rows.map((r) => [r.role, r]));
const states = (b, cap) => Object.fromEntries(call(b, cap, 'agents').map((r) => [r.role, r.state]));
const delivered = (store, id) => store.get("SELECT at FROM deliveries WHERE message=? AND op='delivered'", id).at;
test('agents: a held role with nothing on the bus is idle; a role session gets the claim rows unchanged', (t) => {
const { b, agent, human, reader } = setup(t);
const pm = agent('pm');
agent('coder');
const rows = call(b, human, 'agents');
assert.deepEqual(rows.map((r) => [r.role, r.state, r.working, r.inputNeeded]), [
['coder', 'idle', null, []],
['pm', 'idle', null, []],
]);
for (const r of rows) assert.deepEqual(Object.keys(r), [...CLAIM, 'state', 'working', 'inputNeeded', 'stranded']);
assert.deepEqual(rows.map((r) => r.stranded), [[], []]);
assert.deepEqual(call(b, reader, 'agents'), rows);
const plain = call(b, pm, 'agents');
for (const r of plain) assert.deepEqual(Object.keys(r), CLAIM);
assert.deepEqual(plain.map((r) => ({ ...r })), rows.map((r) => Object.fromEntries(CLAIM.map((k) => [k, r[k]]))));
});
test('agents: working counts what the holder received and has not read; routed alone is not working', (t) => {
const { b, store, agent, human } = setup(t);
const pm = agent('pm');
const one = call(b, human, 'message.send', { to: 'pm', body: 'first' });
assert.equal(states(b, human).pm, 'idle', 'routed, not received');
assert.deepEqual(call(b, pm, 'message.receive').map((m) => m.id), [one.id]);
const two = call(b, human, 'message.send', { to: 'pm', body: 'second' });
let row = byRole(call(b, human, 'agents')).pm;
assert.equal(row.state, 'working');
assert.deepEqual(row.working, { messages: 1, since: delivered(store, one.id) });
call(b, pm, 'message.receive');
row = byRole(call(b, human, 'agents')).pm;
assert.deepEqual(row.working, { messages: 2, since: delivered(store, one.id) });
call(b, pm, 'message.read', { id: one.id });
row = byRole(call(b, human, 'agents')).pm;
assert.deepEqual(row.working, { messages: 1, since: delivered(store, two.id) });
call(b, pm, 'message.read', { id: two.id });
row = byRole(call(b, human, 'agents')).pm;
assert.deepEqual([row.state, row.working], ['idle', null]);
});
test("agents: input needed is the role's open decisions routed to the human, gated first, then oldest", (t) => {
const { b, agent, human } = setup(t);
const cto = agent('cto');
const coder = agent('coder');
agent('pm');
// The arbiter raising a cross-role decision in its own domain routes it to the human.
const own = raise(b, cto, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' });
const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/2' });
const first = raise(b, cto, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true });
const second = raise(b, cto, 'deploy', { target: 'release-2' });
const replaced = raise(b, cto, 'deploy', { target: 'release-3' });
raise(b, cto, 'deploy', { target: 'release-3b', supersedes: replaced.id, question: 'Ship 3b?' });
const resolved = raise(b, cto, 'deploy', { target: 'release-4' });
call(b, human, 'decision.resolve', { id: resolved.id, choice: 'no' });
call(b, human, 'decision.seen', { id: second.id });
assert.deepEqual([own.route_to, own.class, toCto.route_to, first.route_to, first.class], ['human', 'cross-role', 'cto', 'human', 'gated']);
const rows = byRole(call(b, human, 'agents'));
assert.equal(rows.cto.state, 'waiting');
assert.equal(rows.cto.working, null);
const latest = call(b, human, 'inbox').find((d) => d.question === 'Ship 3b?');
assert.deepEqual(rows.cto.inputNeeded, [
{ id: first.id, question: 'deploy?', class: 'gated', blocking: true, task_ref: 'vikunja:3/3', at: first.at },
{ id: second.id, question: 'deploy?', class: 'gated', blocking: false, task_ref: null, at: second.at },
{ id: latest.id, question: 'Ship 3b?', class: 'gated', blocking: false, task_ref: null, at: latest.at },
{ id: own.id, question: 'task.scope.change?', class: 'cross-role', blocking: false, task_ref: null, at: own.at },
]);
// The inbox shows the same open decisions; a decision routed to a role is no one's Input needed.
assert.deepEqual(call(b, human, 'inbox').map((d) => d.id), rows.cto.inputNeeded.map((d) => d.id));
assert.deepEqual([rows.coder.state, rows.coder.inputNeeded, rows.pm.state], ['idle', [], 'idle']);
for (const d of rows.cto.inputNeeded) call(b, human, 'decision.resolve', { id: d.id, choice: 'no' });
assert.deepEqual([byRole(call(b, human, 'agents')).cto.state, byRole(call(b, human, 'agents')).cto.inputNeeded], ['idle', []]);
});
test('agents: working wins over waiting, and Input needed still shows', (t) => {
const { b, agent, human } = setup(t);
const cto = agent('cto');
const asked = raise(b, cto, 'deploy', { target: 'release-1' });
call(b, human, 'message.send', { to: 'cto', body: 'status?' });
call(b, cto, 'message.receive');
const row = byRole(call(b, human, 'agents')).cto;
assert.equal(row.state, 'working');
assert.equal(row.working.messages, 1);
assert.deepEqual(row.inputNeeded.map((d) => d.id), [asked.id]);
});
test("agents: a new holder doesn't inherit what the last one left unread; decisions stay with the role", (t) => {
const { b, agent, human } = setup(t);
const first = agent('cto', 'demo', 'cto-one');
const asked = raise(b, first, 'deploy', { target: 'release-1' });
call(b, human, 'message.send', { to: 'cto', body: 'left unread' });
call(b, first, 'message.receive');
assert.equal(states(b, human).cto, 'working');
call(b, first, 'role.release');
assert.deepEqual(call(b, human, 'agents'), [], 'a released role is not listed');
agent('cto', 'demo', 'cto-two');
const row = byRole(call(b, human, 'agents')).cto;
assert.deepEqual([row.holder_run, row.state, row.working], ['cto-two', 'waiting', null]);
assert.deepEqual(row.inputNeeded.map((d) => d.id), [asked.id]);
});
test('agents: state reads only its own business, even for a run name both use', (t) => {
const { b, agent, human } = setup(t);
// A run name is unique per business only, so the holder alone doesn't separate them.
agent('cto', 'demo', 'cto-run');
const away = agent('cto', 'other', 'cto-run');
raise(b, away, 'deploy', { target: 'release-1' });
const otherHuman = b.bindHuman({ business: 'other', human: 'jason', via: 'cli', outsideAgent: true });
call(b, otherHuman, 'message.send', { to: 'cto', body: 'elsewhere' });
call(b, away, 'message.receive');
const here = byRole(call(b, human, 'agents')).cto;
assert.deepEqual([here.state, here.working, here.inputNeeded], ['idle', null, []]);
const there = byRole(call(b, otherHuman, 'agents')).cto;
assert.deepEqual([there.state, there.working.messages, there.inputNeeded.length], ['working', 1, 1]);
});
// Row 80 (#1574): a message delivered to a run that released the role unread is stranded for the
// role. No later holder receives or reads it, and listing it writes no delivery row.
test('agents: a message the holder released unread is stranded; the next holder gets nothing and cannot read it', (t) => {
const { b, store, agent, human } = setup(t);
const first = agent('pm', 'demo', 'pm-one');
const sent = call(b, human, 'message.send', { to: 'pm', body: 'left behind' });
assert.deepEqual(call(b, first, 'message.receive').map((m) => m.id), [sent.id]);
const before = store.all('SELECT * FROM deliveries');
call(b, first, 'role.release');
assert.deepEqual(call(b, human, 'agents'), [], 'a released role is not listed');
const second = agent('pm', 'demo', 'pm-two');
const row = byRole(call(b, human, 'agents')).pm;
assert.deepEqual(row.stranded, [{ id: sent.id, from: 'human', run: 'pm-one', at: delivered(store, sent.id) }]);
assert.deepEqual(call(b, second, 'message.receive'), []);
assert.throws(() => call(b, second, 'message.read', { id: sent.id }), { code: 'not-recipient' });
assert.throws(() => call(b, first, 'message.read', { id: sent.id }), { code: 'not-holder' });
assert.deepEqual(byRole(call(b, human, 'agents')).pm.stranded, row.stranded);
assert.deepEqual(store.all('SELECT * FROM deliveries'), before, 'the claim and the listing write no delivery row');
});
test('agents: a message the holder read is not stranded; one the current holder has not read is not listed', (t) => {
const { b, agent, human } = setup(t);
const first = agent('pm', 'demo', 'pm-one');
const read = call(b, human, 'message.send', { to: 'pm', body: 'read first' });
const unread = call(b, human, 'message.send', { to: 'pm', body: 'still held' });
call(b, first, 'message.receive');
call(b, first, 'message.read', { id: read.id });
assert.deepEqual(byRole(call(b, human, 'agents')).pm.stranded, [], 'the current holder still holds the unread one');
call(b, first, 'role.release');
agent('pm', 'demo', 'pm-two');
assert.deepEqual(byRole(call(b, human, 'agents')).pm.stranded.map((m) => m.id), [unread.id]);
});
test('agents: stranded is oldest first and capped at 50 for a role', (t) => {
const { b, agent, human } = setup(t);
const first = agent('pm', 'demo', 'pm-one');
const ids = Array.from({ length: 60 }, (_, i) => call(b, human, 'message.send', { to: 'pm', body: `m${i}` }).id);
assert.equal(call(b, first, 'message.receive').length, 60);
call(b, first, 'role.release');
agent('pm', 'demo', 'pm-two');
const stranded = byRole(call(b, human, 'agents')).pm.stranded;
assert.equal(stranded.length, 50);
assert.deepEqual(stranded.map((m) => m.id), ids.slice(0, 50));
assert.ok(stranded.every((m) => m.run === 'pm-one' && m.from === 'human'));
});
test('agents: stranded is per role and per business', (t) => {
const { b, agent, human } = setup(t);
const cto = agent('cto', 'demo', 'cto-one');
const pm = agent('pm', 'demo', 'pm-one');
const toCto = call(b, human, 'message.send', { to: 'cto', body: 'for cto' });
const toPm = call(b, human, 'message.send', { to: 'pm', body: 'for pm' });
call(b, cto, 'message.receive');
call(b, pm, 'message.receive');
call(b, cto, 'role.release');
call(b, pm, 'role.release');
agent('cto', 'demo', 'cto-two');
agent('pm', 'demo', 'pm-two');
const rows = byRole(call(b, human, 'agents'));
assert.deepEqual(rows.cto.stranded.map((m) => m.id), [toCto.id]);
assert.deepEqual(rows.pm.stranded.map((m) => m.id), [toPm.id]);
// The same role and run name in another business stays there.
const otherHuman = b.bindHuman({ business: 'other', human: 'jason', via: 'cli', outsideAgent: true });
const otherPm = agent('pm', 'other', 'pm-one');
const elsewhere = call(b, otherHuman, 'message.send', { to: 'pm', body: 'elsewhere' });
call(b, otherPm, 'message.receive');
call(b, otherPm, 'role.release');
agent('pm', 'other', 'pm-two');
assert.deepEqual(byRole(call(b, human, 'agents')).pm.stranded.map((m) => m.id), [toPm.id]);
assert.deepEqual(byRole(call(b, otherHuman, 'agents')).pm.stranded.map((m) => m.id), [elsewhere.id]);
});
// Row 80 (#1574), Sage ruling: a message routed to a held role and not yet received is not stranded.
test('agents: a message routed to a held role and not yet received is not stranded', (t) => {
const { b, agent, human } = setup(t);
agent('pm', 'demo', 'pm-one');
call(b, human, 'message.send', { to: 'pm', body: 'routed, not received' });
assert.deepEqual(byRole(call(b, human, 'agents')).pm.stranded, []);
});