bus: agent state per role, and the Console shows it (#1548)

Row 59 round 2, owner Filbert, candidate manifest 9acfac19 on 3d298920.
Approved by Darkwing (27591) and Dewey (27595); Sage's gate green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 20:13:43 -05:00
co-authored by Claude Opus 5.5
parent cb8ca8574f
commit a85120070b
8 changed files with 270 additions and 9 deletions
+29 -1
View File
@@ -125,11 +125,39 @@ an operator inspect content and delivery evidence after an uncertain result.
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. | | `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. | | `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. | | `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
| `inbox`, `tasks`, `agents` | No args; plain JSON projections scoped to capability's business. | | `inbox`, `tasks` | No args; plain JSON projections scoped to capability's business. |
| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working` and `inputNeeded` (Agent state, below); a role session gets the claim rows only. |
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. | | `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. | | `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. | | `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. |
### Agent state (row 59, #1548)
For a human or reader session, `agents` derives each held role's state from
records the bus already keeps. Nothing new is written and no schema changed.
- `working` is `{messages, since}` or null: messages to the role that the
current holder received and hasn't marked read, and when the oldest was
received. The runner receives a batch, takes one turn per message, replies
and marks it read, so working means a turn is under way or queued.
- `inputNeeded` lists the role's open decisions routed to the human, gated
first and then oldest, as the Inbox orders them. Each item is
`{id, question, class, blocking, task_ref, at}`. Seen is still open.
- `state` is `working` when `working` is set, else `waiting` when
`inputNeeded` is nonempty, else `idle`. Working wins over waiting, as on
the control board.
Why the bus and not a seat-to-role map: the business file's `holder` is a
display name, and the claim's holder is a run, not a seat, so no committed
data joins a seat to a role. The bus already has the claim, the deliveries
and the decisions.
Limits: a message routed but not yet received shows idle. A reply that fails
for a reason other than a gone session leaves its message unread, so the role
stays working; `since` shows how long. No error or offline state is derived.
A new holder doesn't inherit the previous holder's unread deliveries;
decisions stay with the role. Unheld and released roles aren't listed.
Unknown keys, roles, references, actions and verbs refuse. If writing refusal 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 evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT, recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
+27 -2
View File
@@ -849,11 +849,14 @@ export class Broker {
s.human || s.reader ? 'human' : s.role, s.human || s.reader ? 'human' : s.role,
) )
.map((d) => this.#decisionView(d)); .map((d) => this.#decisionView(d));
if (verb === 'agents') if (verb === 'agents') {
return this.#store.all( const claims = this.#store.all(
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role", "SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
s.business, s.business,
); );
// A role session gets the claims only: another role's decisions aren't its to read.
return s.human || s.reader ? claims.map((c) => this.#agentState(c)) : claims;
}
if (verb === 'tasks') return this.#taskRows(s.business); if (verb === 'tasks') return this.#taskRows(s.business);
return this.#trail(s, identifier(a.subject)); return this.#trail(s, identifier(a.subject));
} }
@@ -904,6 +907,28 @@ export class Broker {
fail('unknown-verb'); fail('unknown-verb');
} }
} }
// Row 59 (#1548): a held role's state for the Agents view, derived from what the bus
// already records. Working: messages the current holder received and hasn't marked read;
// the runner marks each one read after its turn. Input needed: the role's open decisions
// routed to the human, in Inbox order. Working wins over waiting, as on the control board.
#agentState(c) {
const busy = this.#store.get(
"SELECT count(*) AS messages, min(d.at) AS since FROM messages m JOIN deliveries d ON d.message=m.id AND d.op='delivered' WHERE m.business=? AND m.to_role=? AND d.holder_run=? AND NOT EXISTS(SELECT 1 FROM deliveries r WHERE r.message=m.id AND r.op='read')",
c.business,
c.role,
c.holder_run,
);
const inputNeeded = this.#store
.all(
`SELECT d.* FROM decisions d WHERE business=? AND raised_by_role=? AND route_to='human' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY CASE class WHEN 'gated' THEN 0 ELSE 1 END,seq`,
c.business,
c.role,
)
.map((d) => ({ id: d.id, question: d.question, class: d.class, blocking: !!d.blocking, task_ref: d.task_ref, at: d.at }));
const working = busy.messages ? { messages: busy.messages, since: busy.since } : null;
const state = working ? 'working' : inputNeeded.length ? 'waiting' : 'idle';
return { ...c, state, working, inputNeeded };
}
#claimEnd(s, h, op, decision) { #claimEnd(s, h, op, decision) {
this.#store.run( this.#store.run(
'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)', 'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)',
+159
View File
@@ -0,0 +1,159 @@
// 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']);
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]);
});
+11
View File
@@ -171,6 +171,17 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
example `human-required` from a Console started inside an agent run), "No bus to read" (no system example `human-required` from a Console started inside an agent run), "No bus to read" (no system
config, or no bus host and no `--business`) or "The read failed". config, or no bus host and no `--business`) or "The read failed".
## Agent state (row 59, #1548)
`#/agents` adds State and Attention columns. State is the board's mark
(● working, ◐ waiting, – idle) from the bus `agents` verb, with the number
of messages and since when for a working role. Attention shows
"Input needed:" with the first line of the role's first open decision,
linked to `#/inbox/<id>`, a blocking chip, and "+N more" to the Inbox when
there are others. The page says what each state means. It writes nothing;
the server passes the rows through unchanged. How the bus derives the state
and its limits: `packages/bus/README.md`, "Agent state".
## Shell (row 53, #1542) ## Shell (row 53, #1542)
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
+1 -1
View File
@@ -25,7 +25,7 @@ body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere} .s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px} .chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
/* Tables */ /* Tables */
table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:1020px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px} table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:1020px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:1000px}
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal} table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px} .s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap} .s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
+14 -3
View File
@@ -328,12 +328,23 @@
} }
// Agents // Agents
// Row 59 (#1548): the state mark is the control board's (shell.css), glyph and word. The
// broker derives the state from its own records: working while the holder has messages it
// received and hasn't answered, waiting while a decision the role raised waits for the
// human, otherwise idle.
const stateMark = state => `<span class="badge st st-${esc(state || 'unknown')} badge-${{ working: 'ok', waiting: 'attn' }[state] || 'muted'}">${esc(state || 'unknown')}</span>`;
function attention(c) {
const asks = Array.isArray(c.inputNeeded) ? c.inputNeeded : [];
if (!asks.length) return none;
const d = asks[0];
return `Input needed: <a href="#/inbox/${enc(d.id)}">${txt(first(d.question))}</a> <span class="source">${txt(short(d.id))}</span>${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` <a href="#/inbox">+${esc(asks.length - 1)} more</a>` : ''}`;
}
async function agentsView(get) { async function agentsView(get) {
const claims = await get('agents'); const claims = await get('agents');
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join(''); const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
return `${head('Agents', readNote())} return `${head('Agents', readNote())}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear.</p> <section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section> ${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p> <p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`; <p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
} }
+27 -2
View File
@@ -36,7 +36,10 @@ function seed(root) {
const store = new Store(root), b = new Broker({ store, businesses }); const store = new Store(root), b = new Broker({ store, businesses });
const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; }; const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; };
const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run'); const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run');
launch('cto', 'claude-code', 'cto-thread'); const cto = launch('cto', 'claude-code', 'cto-thread');
// Row 59 (#1548): CTO has a message it received and hasn't answered, so it is working.
b.request(pm, { verb: 'message.send', args: { to: 'cto', body: 'Review the inbox design' } });
b.request(cto, { verb: 'message.receive' });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// The human's request, the task PM created for it, a later poll that moved it, and a refused write. // The human's request, the task PM created for it, a later poll that moved it, and a refused write.
const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } }); const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } });
@@ -62,7 +65,9 @@ function seed(root) {
const calls = []; const calls = [];
let fail = null; let fail = null;
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } }); const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } });
return { store, bus, calls, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } }; // Row 59 (#1548): a second open decision for Coder, raised and withdrawn on the Agents page.
const second = { raise: () => raise({ ...push, question: 'Tag the release?', target: 'tag', blocking: false }).id, withdraw: id => b.request(coder, { verb: 'decision.withdraw', args: { id } }) };
return { store, bus, calls, second, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
} }
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => { test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
@@ -222,6 +227,26 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
await go('#/agents', 'Agents'); await go('#/agents', 'Agents');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/); assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
// Row 59 (#1548): the state mark and Input needed per role. Coder's open push decision waits on the human.
const agentRow = role => b.evaluate(`(() => { const r = [...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r => r.querySelector("th").textContent === ${JSON.stringify(role)}); return [...r.querySelectorAll("td")].map(td => td.textContent); })()`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents thead th")].map(e=>e.textContent)'), ['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since']);
const [coderRow, ctoRow, pmRow] = [await agentRow('coder'), await agentRow('cto'), await agentRow('pm')];
assert.equal(coderRow[0], 'waiting'); assert.match(coderRow[1], /^Input needed: Push the release\? .*blocking$/);
assert.ok(!coderRow[1].includes('Second line'), 'only the first line of the question');
assert.match(ctoRow[0], /^working1 message since /); assert.equal(ctoRow[1], 'none');
assert.deepEqual([pmRow[0], pmRow[1]], ['idle', 'none']);
assert.equal(await b.evaluate('document.querySelector("#bus-view table.s1-agents td a[href^=\\"#/inbox/\\"]")?.getAttribute("href")'), `#/inbox/${s.ids.open}`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents .st")].map(e=>[e.className.split(" ").find(c=>c.startsWith("st-")), getComputedStyle(e,"::before").content])').then(r => r.sort()), [['st-idle', '"–" / ""'], ['st-waiting', '"◐" / ""'], ['st-working', '"●" / ""']]);
// Gated first, then oldest: the push decision stays first and the new one is counted.
const extra = s.second.raise();
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("#bus-view table.s1-agents")?.textContent.includes("+1 more")');
assert.match((await agentRow('coder'))[1], /^Input needed: Push the release\? .*blocking \+1 more$/);
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents td a")].find(a=>a.textContent==="+1 more").getAttribute("href")'), '#/inbox');
s.second.withdraw(extra);
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#bus-view table.s1-agents")?.textContent.includes("more")');
await inertCheck('agents');
for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices']) for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices'])
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label); assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
await noOverflow('agents'); await noOverflow('agents');
+2
View File
@@ -73,6 +73,8 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t
const agents = await read('/api/bus/agents'); const agents = await read('/api/bus/agents');
assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]); assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]);
// Row 59 (#1548): the reader view carries the state the broker derives.
assert.deepEqual([agents.body.rows[0].state, agents.body.rows[0].working, agents.body.rows[0].inputNeeded.map(d => [d.id, d.blocking, d.class])], ['waiting', null, [[decision.id, true, 'gated']]]);
const id = inbox.body.rows[0].id; const id = inbox.body.rows[0].id;
const trail = await read('/api/bus/trail?subject=' + encodeURIComponent(id)); const trail = await read('/api/bus/trail?subject=' + encodeURIComponent(id));