Files
stack/agents/filbert/work/queue-59/build.patch
T
jason.woltjeandClaude Opus 5.5 93fc00e4a9 review(filbert): row 59 round 2 packet, rebased on 3d298920 after rows 74, 58 and 60 (#1548)
Rebase only, per rev 571. Two conflicts resolved: the agents branch in
broker.mjs next to row 58's tasks line, and the bus.css widths line.
Merged hunks in broker.mjs and the bus README are named in build.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:46:40 -05:00

394 lines
28 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 82597eeedf85222d2abe8e4902321c41a85c064d..08be63070f3ad4e6367d1dcc4670a3db97ef377a 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -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. |
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations 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. |
| `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. |
+### 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
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index d731988331dcfa37a0f02c6c108595f10638fd08..5e86c60c75b9b92ad5467aa27b5a6bee2ab713ea 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -849,11 +849,14 @@ export class Broker {
s.human || s.reader ? 'human' : s.role,
)
.map((d) => this.#decisionView(d));
- if (verb === 'agents')
- return this.#store.all(
+ if (verb === 'agents') {
+ 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",
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);
return this.#trail(s, identifier(a.subject));
}
@@ -904,6 +907,28 @@ export class Broker {
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) {
this.#store.run(
'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)',
diff --git a/packages/bus/tests/agents.test.mjs b/packages/bus/tests/agents.test.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..23b5126b32bd3964c30a52fbb48b50ce9a699553
--- /dev/null
+++ b/packages/bus/tests/agents.test.mjs
@@ -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]);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index e2ffcb3bb5b96a45d22fe7ad4c10fbc7990e07a6..c866804c61aab892a5d660436edf7b6f51a59bd8 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -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
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)
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
diff --git a/packages/webui/src/public/bus.css b/packages/webui/src/public/bus.css
index acf496a53a486ff7df8cdd33fa42ac19cf5d6d2a..fc8507e370868d3fcb9f69b428fbd744361e98ab 100644
--- a/packages/webui/src/public/bus.css
+++ b/packages/webui/src/public/bus.css
@@ -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}
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
/* 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}
.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}
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 3be22af5695511ce6f34948c77e49939bf5d95e9..b22bcf009cf8c93c2811e095ac84b5d83efad8df 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -328,12 +328,23 @@
}
// 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) {
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())}
- <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>
- ${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>
+ <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', '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">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
}
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
index 9faa15eb47f10a804f766f7a9d7d1bfdba6b022b..9369de62f6d2fad6a9d991b60a7f32f9917bac61 100644
--- a/packages/webui/tests/bus-browser.test.mjs
+++ b/packages/webui/tests/bus-browser.test.mjs
@@ -36,7 +36,10 @@ function seed(root) {
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 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 });
// 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}` } });
@@ -62,7 +65,9 @@ function seed(root) {
const calls = [];
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 })); } });
- 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 () => {
@@ -222,6 +227,26 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
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.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'])
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
await noOverflow('agents');
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
index be677a5b2ef4d08d83cb918880a0c39efd3497a5..46d8016c3a2ef855a6041ba187f3ae4c9174c304 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -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');
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 trail = await read('/api/bus/trail?subject=' + encodeURIComponent(id));