bus: agent state per role, and the Console shows it (#1548)
Row 59 round 2, owner Filbert, candidate manifest 9acfac19 on 3d298920.
Approved by Darkwing (27591) and Dewey (27595); Sage's gate green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user