From 13615e12b9ba87108191408070f5d1dca3191271 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:08:39 -0600 Subject: [PATCH] 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) --- packages/webui/src/public/workspace.js | 39 ++++++++++++------- .../webui/tests/kpi-tiles-browser.test.mjs | 24 ++++++++++++ .../tests/system-agents-browser.test.mjs | 21 ++++++++-- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 69c5b4212..851de9047 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -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 = `
- ${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) })}
`; 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 ? 'No conversation held right now' : thread ? `${esc(projectName(thread.project))}${esc(thread.name || thread.id)}${more}` : threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable'; - return `${esc(agent)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; + return `${esc(shown)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); const sessions = live === null ? (stopped ? '

Sessions not read: the bus is stopped.

' : '

Sessions unavailable. The bus did not answer the instance read.

') : live.length ? `
${sessionRows}
AgentProject and taskModelStatus
` : '

No active agent sessions

'; const open = queue ? queue.filter(r => r?.state !== 'done').length : null; @@ -338,7 +348,7 @@ ${docrow}
- ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `This organization's agents`)} + ${secH('Active agent sessions', live ? (live.length ? `${live.length} running, ${liveWorking} working` : 'none running') : '', `This organization's agents`)} ${sessions} ${secH('Queue', queueNote, 'Open queue')} ${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 ? '

No organizations yet, so there is nothing to count.

' : `
- ${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 = '–'; 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 ? '' : ` (${working} working)`}`; + // 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 ? ` (${runningOf(mineRows)} running, ${workingOf(mineRows)} working)` : ''}`; 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//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} Every organization` : `Every agent on the system, by organization.${tally} Open one to see it in its organization.`; diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index 16adcf8ad..63d06cac0 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -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]")'); diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs index 0b153b08d..914c31f3c 100644 --- a/packages/webui/tests/system-agents-browser.test.mjs +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -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));