row 59 (#1548): round 1 packet, agent state per role (filbert)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 16:39:10 -05:00
co-authored by Claude Opus 5.5
parent 16c49998f7
commit e82f7c32e2
3 changed files with 573 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
# Row 59 (#1548): bus read, agent state per role, round 1
Filbert, 2026-10-11. Brief: `docs/plans/2026-10-10_design-implementation.md`,
"Bus read: agent state per role" (blob a360554c). Darkwing and Dewey
review. Sage commits after the gate; nothing is committed, staged or
pushed yet.
Base f2f2861b. `build.patch` applies there with `git apply --index`;
`candidate-manifest.sha256` pins all 8 files after the patch.
## Which: state on the bus
The brief allows either a seat-to-role mapping read from committed data or
state on the bus. I chose state on the bus, because no committed data joins
a seat to a role:
- The business file's `holder` for a role instance is a display name. It
isn't a seat id the control board knows.
- The bus's claim names a run (`holder_run`), not a seat. A run is one
launch; the next launch of the same role is a different run.
So a mapping would be a new hand-kept file that goes stale on each launch.
The bus already records everything the state needs: the claim, message
deliveries and decisions. The state is derived at read time. Nothing new is
written and the schema is unchanged.
## The derivation
`agents` for a human or reader session returns each held claim row plus:
- `working`: `{messages, since}` or null. Messages to the role, in the
business, that the current holder received (`delivered`) and hasn't
marked `read`; `since` is the oldest delivery. The runner
(`packages/harness/src/runner.mjs`) receives a batch, takes one turn per
message, replies, then marks each read, so this is "a turn is under way
or queued".
- `inputNeeded`: the role's open decisions routed to the human (`raised_by_role`
is the role, `route_to` is `human`, no resolved, withdrawn or expired
event), gated first and then oldest, the Inbox's order. Each item is
`{id, question, class, blocking, task_ref, at}`; options and the rest of
the decision stay in the Inbox. Seen is still open.
- `state`: `working` if `working` is set, else `waiting` if `inputNeeded`
is nonempty, else `idle`. Working wins, as on the control board
(`scan.mjs`).
A role session gets the claim rows unchanged, so a role can't read another
role's decisions through `agents`. The CLI's `formatAgents` prints only
role, holder, harness and time, so `mosaic agents` is unchanged;
`--json` carries the new fields. The webui server passes `agents` rows
through, so it has no change.
Limits, in the bus README:
- 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 for how long.
- No error or offline state is derived. The control board has both; the
bus has no record to derive them from.
- A new holder doesn't inherit the last holder's unread deliveries.
Decisions stay with the role across holders.
- Unheld and released roles aren't listed, as before.
## The Agents view
`#/agents` gets State and Attention columns, between Role and Holder:
- State: the board's mark (`.st-*` from `shell.css`: ● working, ◐ waiting,
– idle) with the word, plus "N messages since <time>" when working.
- Attention: "Input needed:", then the first line of the first open
decision, linked to `#/inbox/<id>`, then its short id and a blocking chip,
plus "+N more" linking to the Inbox. Otherwise "none".
- The page says what each state means. The table's minimum width goes from
820 to 1000px inside its scroll region; the page doesn't scroll sideways
at 320 or 1440px.
Read-only: no new route, verb, write or button.
## Files
- `packages/bus/src/broker.mjs`: `#agentState`, and the `agents` branch
maps it for human and reader sessions.
- `packages/bus/tests/agents.test.mjs` (new, 6 tests): idle and the role
session's plain rows; working counts received and unread, not routed,
and `since` moves as messages are read; Input needed order, the arbiter's
own cross-role decision routed to the human, supersede, resolve and seen;
working wins over waiting; a new holder; business isolation with one
run name in both businesses.
- `packages/bus/README.md`: the `agents` row of the verb table and an
"Agent state" section with the reasoning and limits.
- `packages/webui/src/public/bus.js`: the Agents view only. Row 58 edits
the Tasks view in the same file and the `tasks` branch next to the
`agents` branch in `broker.mjs`; the hunks touch, so whichever lands
second rebases.
- `packages/webui/src/public/bus.css`: the table's minimum width.
- `packages/webui/tests/bus.test.mjs`: the reader route carries `state`,
`working` and `inputNeeded`.
- `packages/webui/tests/bus-browser.test.mjs`: the seed makes CTO working
(PM sends, CTO receives); the page shows the headers, each role's state
and mark glyph, Coder's Input needed with only the first line, the link
and the blocking chip, "+1 more" while a second decision is open, and
stays inert.
- `packages/webui/README.md`: an "Agent state" section.
`packages/control-board/` is unchanged: the view reuses its mark through
`shell.css`, which is already served.
## Mutants
29 mutants (`run.mjs`, kept in my scratch). Each edits one file, runs the
tests that own it, and restores the file:
- broker: the bus suite;
- bus.js: `bus.test.mjs` and `bus-browser.test.mjs`.
The diff is unchanged afterwards. 28 are killed:
- broker, 18 mutants, 17 killed. The killed ones:
- working: any holder, no read check, any delivery op, `max` for
`since`, any business, working when nothing is unread.
- Input needed: any business, any raiser, any route, closed kept,
`seq` order only, newest first, `blocking` left as 0/1, the whole
decision row.
- state and access: waiting wins, a role session widened, a reader
getting the plain rows.
- bus.js, 11 mutants, all killed: no mark, a fixed mark class, no working
note, no Attention, the whole question, the question unescaped, the link
to the Inbox instead of the decision, no blocking chip, "+N more"
always, the count off by one, and the old column order.
The survivor is `b-msg-any-role` (`m.to_role` dropped from the working
count). It's equivalent: a run is bound to one role in a business
(`bindLaunch` refuses a second record for the run), and deliveries carry
the holder's run, so the business and holder already pick the role. I kept
the filter because it states what is counted.
The first pass had four more survivors, and one mutant killed only by
unrelated tests. I fixed each and reran it:
- `b-msg-any-business`: test 6 used different run names per business, so
the holder alone separated them. Run names are unique per business only,
so test 6 now uses one run name in both.
- `w-more-count-off`: nothing showed a second decision. The browser test
now raises one on the Agents page, checks "+1 more" and its link,
withdraws it, and checks the count goes.
- `w-link-inbox` and `w-no-blocking` first edited the same text in the
Tasks view, and `b-blocking-raw` the same line in `#decisionView`, where
other tests caught it. I narrowed their targets.
## Gate (my run on the candidate)
Worktree at f2f2861b with the patch, run in sequence with `TMPDIR` in my
scratch and `DOCKER_HOST` pointed at a missing socket:
| Suite | Result |
|---|---|
| packages/bus | 86/0 (80 before; 6 new) |
| packages/control-board | 124/0 |
| packages/webui | 40/0, the browser tests included |
| packages/cli | 110/0, since it reads `agents` |
| scripts/test-*.sh | all nine exit 0: auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 27, release 4, task 26 |
`test-release` and `test-task` skipped their Docker cases without a daemon;
Sage's gate rerun covers those. No test calls a model. Logs are in my
scratch (`r59/gate/`), not kept in the repository.
HEAD moved to b31cc8a9 while I packaged, with docs and Sage's row 68 packet
only. None of the eight files changed, so the patch applies there too.
## Questions for reviewers
1. Is "received and not read" the right working signal? The other choice is
the claim's own activity, which the bus doesn't record per turn.
2. Should Attention list every open decision instead of the first and a
count? The Inbox already lists them all; I kept one line per role.
+391
View File
@@ -0,0 +1,391 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 5b57b7fc..30ed0ab5 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -125,9 +125,37 @@ 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. |
+### 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 84db8799..d471d600 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -825,11 +825,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.#store
.all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', s.business)
@@ -861,6 +864,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 00000000..23b5126b
--- /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 e43118d5..092b6b0e 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -101,6 +101,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 39cf2991..54d2c736 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:900px}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:900px}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 affb9b7b..8422a87a 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -325,12 +325,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 aebd3c30..200f9428 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 () => {
@@ -212,6 +217,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 2d9ca6ba..5b078168 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -70,6 +70,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));
@@ -0,0 +1,8 @@
bc354048f0df93aefd8a58cae1f2421df066efce48beefc7f23fe46763fe43bd packages/bus/README.md
4701efe9d7edf5869ed011395d114b8156b99f735b841f6cab0255a635ae5bca packages/bus/src/broker.mjs
4e5c3fe7793b9a026f274f0645c440907c467c9fdc11324f167bcb9bdd1fd31b packages/bus/tests/agents.test.mjs
f2b91ca5ffab6ae5ad19f66bc4d11cb6cf99777f1cf18a3c44449a0998ad664f packages/webui/README.md
cd0fa9e4a7fa00275aacf89eb73007baa193c6a08ea135b9bf48f596d32e418a packages/webui/src/public/bus.css
899ef3e3a27ba14100135dd92a71110556113feb2585db99648624e50400aab2 packages/webui/src/public/bus.js
acefab3ff732fa255b207cd3105589ee30f3f2c94a7ba651528f0f73fe4b0e53 packages/webui/tests/bus-browser.test.mjs
a3d33ee916598525d5e8ea86202b96fda9416dd798990ac39ee7b786cb68ee3a packages/webui/tests/bus.test.mjs