fix(webui): sp11 item 1, agent counts say running and working and count the same rows: the tiles say how many run beside how many work, the Dashboard organization rows count the tile's rows, the sessions heading lists the running copies too, and the Agents page names a running agent with no reported state (#1873, lane sp11-fixes)

This commit is contained in:
jason.woltje committed 2026-11-15 10:27:20 -06:00
1 parent bd29e756a5
commit 13615e12b9
3 files changed
+68 -16

No files matched your search

+26 -13
View File
@@ -68,6 +68,12 @@
// An agent's activity is the state the bus derives for the run holding its role. A live process
// alone says only running, never working or idle.
const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] };
// SP11 (#1873): the agent counts share two words. Running is an agent row with a live process,
// whatever it is doing; working is a running one whose role session is working.
const LIVE_STATES = new Set(['working', 'waiting', 'idle', 'running']);
const runningOf = rows => rows.filter(a => LIVE_STATES.has(a.state)).length;
const workingOf = rows => rows.filter(a => a.state === 'working').length;
const agentNote = (run, waiting) => `${run} running${waiting ? `, ${waiting} waiting on you` : ''}`;
const activity = (name, item, agents) => {
const on = running(item) > 0;
const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null;
@@ -297,18 +303,22 @@
const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer');
const kpi = orgsOf(ws), oq = `org=${enc(id)}`;
const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) });
const working = orgAgents?.filter(a => a.state === 'working').length ?? 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0;
const working = orgAgents ? workingOf(orgAgents) : 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0;
const orgTiles = `<div class="ws-tiles" data-org-tiles role="group" aria-label="Key figures">
${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: waiting ? `${waiting} waiting on you` : '' })}
${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })}
${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })}
${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })}
${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
</div>`;
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null;
// SP11: the sessions are the running rows the tile counts: the organization's instances and the
// root and project copies its bus booted with. A copy shows its own name.
const copyLive = Object.entries(copiesOf(value(2))).filter(([, c]) => c && typeof c.name === 'string' && !copyBlocked(c) && running(c)).map(([key, c]) => [key, c, c.name]);
const live = instances ? [...Object.entries(instances).filter(([, item]) => running(item)).map(([key, item]) => [key, item, key]), ...copyLive] : null;
const liveWorking = live ? live.filter(([key, item]) => activity(key, item, agents)[2] === 'working').length : 0;
// A session's project and task is the thread its live run holds a claim on (thread.claims), not
// any older conversation of the same agent. A failed read says unavailable, never "none".
const sessionRows = (live || []).map(([agent, item]) => {
const sessionRows = (live || []).map(([agent, item, shown]) => {
const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null;
const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null;
const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents);
@@ -317,7 +327,7 @@
: !first ? '<span class="muted">No conversation held right now</span>'
: thread ? `${esc(projectName(thread.project))}<span class="t2">${esc(thread.name || thread.id)}${more}</span>`
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
return `<tr><td>${esc(agent)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
return `<tr><td>${esc(shown)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
}).join('');
const sessions = live === null ? (stopped ? '<p class="ws-panel tight note" data-bus-stopped>Sessions not read: the bus is stopped.</p>' : '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>') : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
@@ -338,7 +348,7 @@
${docrow}
<div class="ws-split">
<section aria-label="Measures and sessions">
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
${secH('Active agent sessions', live ? (live.length ? `${live.length} running, ${liveWorking} working` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
${sessions}
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
${queueWhy}
@@ -669,7 +679,7 @@
const needsN = fig('needs', ps.needs);
const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0);
const tiles = !o.rows.length ? '<p class="ws-panel tight note" data-ws-tiles>No organizations yet, so there is nothing to count.</p>' : `<div class="ws-tiles six" data-ws-tiles role="group" aria-label="Key figures">
${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })}
${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })}
${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })}
@@ -679,15 +689,17 @@
// SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an
// organization that did not answer shows its state and reason instead of figures.
const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions);
const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs);
const none = '<span class="muted" title="not read">–</span>';
const orgTable = o.rows.map(r => {
const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id);
const [cls, glyph, word] = rowState(r, fromList);
const lv = levels?.orgs?.find(v => v?.id === id);
const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null;
const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` <small class="muted">(${working} working)</small>`}`;
// SP11: an organization whose agents were read counts the tile's rows, running and working; one
// that was not (a stopped bus) counts the agents in its file and says nothing of what runs.
const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null;
const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` <small class="muted">(${runningOf(mineRows)} running, ${workingOf(mineRows)} working)</small>` : ''}`;
const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none;
const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none;
// #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once.
@@ -950,7 +962,7 @@
// `opts.scope` names one organization (#/orgs/<id>/agents): the same table, that organization only.
// `opts.fireIn(org)` says from that organization's own config whether this Console decides there;
// Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js).
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' };
// Each named organization's instances read, and whether this Console decides there when `fireIn` is given.
const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([
@@ -1044,7 +1056,8 @@
const notRead = new Set(reads.filter(r => !r.inst).map(r => r.id));
const chipOrgs = { ...every, rows: every.rows.map(r => (notRead.has(r.org) ? { ...r, errors: { ...(r.errors || {}), instances: 'not read' } } : r)) };
const orgsWith = new Set(rows.map(a => a.org)).size;
const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}.` : '';
// SP11: running and working in the Dashboard tiles' words, over the rows shown.
const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}: ${runningOf(rows)} running, ${workingOf(rows)} working.` : '';
const sub = scope
? `Agents in ${esc(o.name(scope))} only.${tally} <a href="#/agents" data-focus-key="agents-all">Every organization</a>`
: `Every agent on the system, by organization.${tally} Open one to see it in its organization.`;
@@ -200,6 +200,30 @@ test('An organization page leads with its own tiles, each linking to the system
await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
});
// SP11 (#1873): "working" and "running" each name what they count. Running is an agent with a live
// process, whatever it is doing; working is a running agent whose role session is working. The tile,
// the Dashboard organization rows and the sessions heading count the same agent rows.
const delta = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-delta:not(.ws-miss)`)})?.textContent.trim() ?? null`;
const rowAgents = org => `document.querySelector("[data-ws-orgrow=${org}] td:nth-child(2)")?.innerText.replace(/\\s+/g, " ").trim() ?? null`;
test('Agent counts say running and working, and count the same agents on the tiles, the organization rows and the sessions heading', async t => {
const { b, wait, text } = await setup(t);
await wait(homeReady);
// acme's coder runs and works; beta's pm runs and waits on you; chief and writer run nothing.
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
assert.equal(await b.evaluate(delta('[data-ws-home]', 'agents')), '2 running, 1 waiting on you');
assert.equal(await b.evaluate(rowAgents('acme')), '2 agents (1 running, 1 working)');
assert.equal(await b.evaluate(rowAgents('beta')), '2 agents (1 running, 0 working)');
await b.evaluate('location.hash = "#/orgs/beta"');
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running, 1 waiting on you');
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 0 working/);
await b.evaluate('location.hash = "#/orgs/acme"');
await wait(`location.hash.startsWith("#/orgs/acme") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`);
assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running');
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/);
});
test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' });
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")');
@@ -17,7 +17,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++)
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
function fixture({ empty = false } = {}) {
function fixture({ empty = false, unheld = false } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
@@ -38,6 +38,8 @@ function fixture({ empty = false } = {}) {
},
};
if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; }
// `unheld`: a live designer run that holds no role session, so the bus reports no state for it.
if (unheld) DATA.acme.instances.designer = { definition: 'designer', running: ['run-5'] };
const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
const opts = id => ({
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
@@ -90,8 +92,8 @@ const STUB = `(() => {
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, stub = false, noDecide = [] } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} });
async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, unheld = false, stub = false, noDecide = [] } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty, unheld }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
@@ -132,6 +134,19 @@ test('#/agents lists both organizations\' agents in one table, each row with its
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm');
});
// SP11 (#1873): the page says how many of its rows run and how many work, in the words the Dashboard
// tiles use. A live agent with no reported state is "Running, state unknown", not a bare "Running"
// that reads as every running agent.
test('#/agents counts its running and working agents, and names a running agent with no reported state', async t => {
const { b, wait, text } = await setup(t, { unheld: true });
await wait(are(['acme:chief', 'acme:coder', 'acme:designer', 'beta:pm', 'beta:research-2']));
assert.match(await text('[data-ws-agents] .sub'), /^Every agent on the system, by organization\. 5 agents in 2 organizations: 3 running, 1 working\./);
assert.equal(await b.evaluate('document.querySelector("[data-ws-astate=running]")?.innerText.replace(/\\s+/g, " ").trim()'), 'Running, state unknown 1');
await b.evaluate('document.querySelector("[data-ws-astate=working]").click()');
await wait(are(['acme:coder']));
assert.match(await text('[data-ws-agents] .sub'), /1 agent in 1 organization: 1 running, 1 working\./);
});
test('the organization and state filters live in the hash and survive a reload', async t => {
const { b, base, wait } = await setup(t);
await wait(are(ALL));