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:
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user