From 04872ee4aa77d7ca4835951a1a11efefdba833c1 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Mon, 28 Sep 2026 13:48:43 +0300 Subject: [PATCH 1/5] feat(dashboard): readiness stamp on the agents list and the fleet grid (trinity-enterprise#527 rider) Operator ruling 2026-09-24 (ent#560's closure): readiness is a role-companion property, and the owner's stamp should be visible on the agents list and the fleet grid, not only on the role card. - GET /api/agents attaches readiness {status, changed_at, source} from one batched read (get_role_readiness_for_agents, the display-label pattern, no N+1). An unstamped agent carries null and shows nothing: whether it is a companion is in its template.yaml, which a list must not read, so there is no guessed calibrating. The list says what and when, never who; the owner-scoped role card keeps the person. - One predicate for both surfaces (utils/readinessBadge.js), with the role card's words and variants (ready = success, calibrating = warning, dot). The tooltip names a rollout stamp and what calibrating holds back. - The list places it after the pressure badge (documented order updated) on both layouts; the grid tile places it after the runtime badge. Tests: 7 backend (real DB + the endpoint), 6 helper, 3 mounted tile (2 red without the tile change). Full vitest and ratchets green. Co-Authored-By: Claude Opus 5.5 --- docs/memory/requirements/core-agent.md | 11 ++ src/backend/database.py | 3 + src/backend/db/role_readiness.py | 34 +++++- src/backend/routers/agents.py | 5 + .../src/components/AgentListPanel.vue | 21 +++- src/frontend/src/components/AgentTile.vue | 14 +++ src/frontend/src/utils/readinessBadge.js | 34 ++++++ .../tests/unit/readinessBadge.spec.js | 42 +++++++ .../tests/unit/readinessOnGrid.spec.js | 62 ++++++++++ tests/registry.json | 10 ++ tests/unit/test_ent527_readiness_on_lists.py | 110 ++++++++++++++++++ 11 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/utils/readinessBadge.js create mode 100644 src/frontend/tests/unit/readinessBadge.spec.js create mode 100644 src/frontend/tests/unit/readinessOnGrid.spec.js create mode 100644 tests/unit/test_ent527_readiness_on_lists.py diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md index 3d8b1c036..2ed84ad01 100644 --- a/docs/memory/requirements/core-agent.md +++ b/docs/memory/requirements/core-agent.md @@ -3312,6 +3312,17 @@ to localStorage in the clear. shipped", never "by "; when a seat-delivery schedule exists and readiness is not `ready`, the card adds "its scheduled brief is paused until you mark it ready" — for platform viewers only, and not when autonomy is off (the autonomy gate stops it first). +- **The stamp on the agents list and the fleet grid (rider, operator ruling 2026-09-24 — + ent#560's closure)**: readiness is a role-companion property, and the owner's stamp is + shown beside the agent wherever the operator scans the fleet, not only on the role card. + `GET /api/agents` attaches `readiness: {status, changed_at, source}` from ONE batched + read (`get_role_readiness_for_agents`, the display-label pattern); an agent with no stamp + carries `null` and shows nothing — never a guessed `calibrating`, because whether it is a + companion at all is in its template.yaml, which a list never reads. The list says what and + when, never who (the role card, owner-scoped, keeps the person). Both surfaces render one + predicate (`utils/readinessBadge.js`): the role card's words and variants (`ready` = + success, `calibrating` = warning, with a dot), and a tooltip that names a rollout stamp + and what calibrating holds back. ### 5.37 Workspace — the seat-level decision record: why a thing was approved, deferred or killed (trinity-enterprise#638) - **Status**: ✅ Implemented (2026-09-22). OSS-core (Workspace). diff --git a/src/backend/database.py b/src/backend/database.py index 48779ea93..4fc281a5e 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -2761,6 +2761,9 @@ def get_agent_role_readiness(self, agent_name: str): def set_agent_role_readiness(self, agent_name: str, status: str, changed_by: str): return self._role_readiness_ops.set_role_readiness(agent_name, status, changed_by) + def get_role_readiness_for_agents(self, agent_names): + return self._role_readiness_ops.get_role_readiness_for_agents(agent_names) + # Seat decisions (delegated to db/seat_decisions.py) — ent#638 / R25. # Explicit signatures on purpose (learnings 2026-09-01: a kwarg the mixin # gains must land here too); parity pinned by test_ent638_seat_decisions. diff --git a/src/backend/db/role_readiness.py b/src/backend/db/role_readiness.py index 9f30b119e..02ccc1424 100644 --- a/src/backend/db/role_readiness.py +++ b/src/backend/db/role_readiness.py @@ -5,7 +5,7 @@ the rule is that only the agent OWNER flips a companion and the agent never can. SQLAlchemy Core so it runs unchanged on SQLite and PostgreSQL. """ -from typing import Optional +from typing import Dict, Iterable, Optional from sqlalchemy import select, delete, insert, update @@ -30,6 +30,38 @@ def get_role_readiness(self, agent_name: str) -> Optional[dict]: row = conn.execute(stmt).mappings().first() return dict(row) if row else None + def get_role_readiness_for_agents(self, agent_names: Iterable[str]) -> Dict[str, dict]: + """The stamps of many agents in ONE query, for the agents list and the fleet + grid (ent#527 rider, ruling 2026-09-24). + + `{name: {status, changed_at, source}}` for stamped agents only — an agent + with no stamp is absent, never a guessed `calibrating` (whether it is a + companion at all is in its template.yaml, which a list must not read). + WHO flipped it is deliberately left out: the list is visible to every + viewer of the agent, the role card (owner-scoped) keeps the person. + `source` is `rollout` for the ent#689 seed, `owner` otherwise. + """ + names = list(dict.fromkeys(n for n in agent_names if n)) + if not names: + return {} + stmt = select( + agent_role_readiness.c.agent_name, + agent_role_readiness.c.status, + agent_role_readiness.c.changed_at, + agent_role_readiness.c.changed_by, + ).where(agent_role_readiness.c.agent_name.in_(names)) + with get_engine().connect() as conn: + rows = conn.execute(stmt).mappings().all() + return { + r["agent_name"]: { + "status": r["status"], + "changed_at": r["changed_at"], + "source": "rollout" if str(r["changed_by"] or "").startswith("rollout:") else "owner", + } + for r in rows + if r["status"] in READINESS_STATES + } + def set_role_readiness(self, agent_name: str, status: str, changed_by: str) -> dict: """Write the stamp (upsert). The caller has already decided WHO may.""" if status not in READINESS_STATES: diff --git a/src/backend/routers/agents.py b/src/backend/routers/agents.py index 1b994d2a1..487487cb8 100644 --- a/src/backend/routers/agents.py +++ b/src/backend/routers/agents.py @@ -168,10 +168,15 @@ async def list_agents_endpoint( # would be an N+1 on the fleet's hottest endpoint. Agents without a label # are absent from the map and render under their slug, as they do today. all_labels = db.get_display_labels_for_agents(agent_names) + # ent#527 rider (ruling 2026-09-24): the owner's readiness stamp on the list + # and the fleet grid — batched for the same reason. Only stamped agents carry + # one; `None` means "no stamp", never a guessed `calibrating`. + all_readiness = db.get_role_readiness_for_agents(agent_names) for agent in agents: agent["tags"] = all_tags.get(agent.get("name"), []) agent["display_label"] = all_labels.get(agent.get("name")) + agent["readiness"] = all_readiness.get(agent.get("name")) return agents diff --git a/src/frontend/src/components/AgentListPanel.vue b/src/frontend/src/components/AgentListPanel.vue index 756b5b135..bebc496ef 100644 --- a/src/frontend/src/components/AgentListPanel.vue +++ b/src/frontend/src/components/AgentListPanel.vue @@ -474,7 +474,7 @@ data-testid="row-secondary-lg" class="flex flex-nowrap items-center gap-1 min-w-0 overflow-hidden min-h-[1.375rem] text-gray-500 dark:text-gray-400 list-wide:row-start-2 list-wide:col-start-4 list-wide:col-end-10" > - + {{ readinessBadge(agent.readiness).label }} {{ agentNameParts(agent).secondary }} + {{ readinessBadge(agent.readiness).label }} {{ agentNameParts(agent).primary }} + + {{ readiness.label }} props.agent.name) const isSystemAgent = computed(() => props.agent.is_system === true) +const readiness = computed(() => readinessBadge(props.agent.readiness)) // ent#139/#2104 — agent-class variant. Keyed off the runner's FIXED NAME // (`trinity-skill-runner` is a fixed-name singleton, RUNNER_AGENT_NAME in the diff --git a/src/frontend/src/utils/readinessBadge.js b/src/frontend/src/utils/readinessBadge.js new file mode 100644 index 000000000..59d7db7fc --- /dev/null +++ b/src/frontend/src/utils/readinessBadge.js @@ -0,0 +1,34 @@ +/** + * The owner's readiness stamp as a badge on the agents list and the fleet grid + * (trinity-enterprise#527 rider, operator ruling 2026-09-24). + * + * One predicate for both surfaces, like `pressureBadge`: a second copy is how a + * list and a grid end up disagreeing about the same agent. + * + * `readiness` is what `GET /api/agents` attaches: `{status, changed_at, source}` + * for a stamped companion, `null` otherwise. No stamp → no badge — whether an + * unstamped agent is a companion at all is in its template.yaml, which a list + * never reads, so the badge is never a guessed "calibrating". The words and the + * variants match the role card (`PortalAgentRole.vue`): ready = success, + * calibrating = warning, both with a dot. + * + * @param {{status?: string, changed_at?: string|null, source?: string}|null|undefined} readiness + * @returns {{label: string, variant: 'success'|'warning', title: string}|null} + */ +export function readinessBadge(readiness) { + const status = readiness && readiness.status + if (status !== 'ready' && status !== 'calibrating') return null + const day = typeof readiness.changed_at === 'string' && /^\d{4}-\d{2}-\d{2}/.test(readiness.changed_at) + ? readiness.changed_at.slice(0, 10) + : null + const since = day ? ` since ${day} (UTC)` : '' + let title + if (status === 'ready') { + title = readiness.source === 'rollout' + ? `Ready — carried over when the readiness gate shipped${since}` + : `Ready — marked ready by its owner${since}` + } else { + title = `Calibrating${since} — its scheduled brief is paused until its owner marks it ready` + } + return { label: status, variant: status === 'ready' ? 'success' : 'warning', title } +} diff --git a/src/frontend/tests/unit/readinessBadge.spec.js b/src/frontend/tests/unit/readinessBadge.spec.js new file mode 100644 index 000000000..eef58edcf --- /dev/null +++ b/src/frontend/tests/unit/readinessBadge.spec.js @@ -0,0 +1,42 @@ +/** + * trinity-enterprise#527 rider — the readiness stamp on the agents list and the + * fleet grid. The shared predicate both surfaces render from. + */ +import { describe, it, expect } from 'vitest' +import { readinessBadge } from '../../src/utils/readinessBadge.js' + +describe('readinessBadge', () => { + it('no stamp is no badge — never a guessed calibrating', () => { + expect(readinessBadge(null)).toBeNull() + expect(readinessBadge(undefined)).toBeNull() + expect(readinessBadge({})).toBeNull() + }) + + it('an unknown state is not rendered as either badge', () => { + expect(readinessBadge({ status: 'maybe' })).toBeNull() + }) + + it('ready by the owner', () => { + expect(readinessBadge({ status: 'ready', changed_at: '2026-09-28T10:00:00Z', source: 'owner' })).toEqual({ + label: 'ready', + variant: 'success', + title: 'Ready — marked ready by its owner since 2026-09-28 (UTC)', + }) + }) + + it('ready from the ent#689 rollout says so', () => { + expect(readinessBadge({ status: 'ready', changed_at: '2026-09-24T08:00:00Z', source: 'rollout' }).title) + .toBe('Ready — carried over when the readiness gate shipped since 2026-09-24 (UTC)') + }) + + it('calibrating names what it holds back', () => { + const b = readinessBadge({ status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }) + expect(b.variant).toBe('warning') + expect(b.title).toBe('Calibrating since 2026-09-28 (UTC) — its scheduled brief is paused until its owner marks it ready') + }) + + it('a missing or malformed date is left out, not printed', () => { + expect(readinessBadge({ status: 'ready', changed_at: null }).title).toBe('Ready — marked ready by its owner') + expect(readinessBadge({ status: 'ready', changed_at: 'yesterday' }).title).toBe('Ready — marked ready by its owner') + }) +}) diff --git a/src/frontend/tests/unit/readinessOnGrid.spec.js b/src/frontend/tests/unit/readinessOnGrid.spec.js new file mode 100644 index 000000000..4b1509c08 --- /dev/null +++ b/src/frontend/tests/unit/readinessOnGrid.spec.js @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +/** + * trinity-enterprise#527 rider — the fleet grid tile shows the owner's readiness + * stamp, mounted (not a regex over the template). The agents list renders the + * same `readinessBadge` predicate; its rows are covered by the shared helper's + * spec (readinessBadge.spec.js). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('axios', () => { + const inst = { + get: vi.fn(() => Promise.resolve({ data: {} })), post: vi.fn(), put: vi.fn(), delete: vi.fn(), + interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } }, + defaults: { headers: { common: {} } }, + } + return { default: Object.assign(inst, { create: () => inst }) } +}) + +import AgentTile from '../../src/components/AgentTile.vue' + +const STUBS = { + AgentAvatar: true, RuntimeBadge: true, RunningStateToggle: true, AutonomyToggle: true, + ScanlineReveal: { template: '
' }, +} + +function tile(readiness) { + return mount(AgentTile, { + props: { + agent: { name: 'sales-companion', status: 'running', runtime: 'claude-code', tags: [], readiness }, + now: Date.parse('2026-09-28T12:00:00Z'), + }, + global: { stubs: STUBS }, + }) +} + +beforeEach(() => setActivePinia(createPinia())) + +describe('AgentTile — readiness stamp (ent#527 rider)', () => { + it('a calibrating companion shows a warning badge that says what it holds back', () => { + const b = tile({ status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }) + .find('[data-testid="readiness-badge"]') + expect(b.exists()).toBe(true) + expect(b.text()).toBe('calibrating') + expect(b.attributes('title')).toContain('scheduled brief is paused') + expect(b.classes().join(' ')).toContain('status-warning') + }) + + it('a ready companion shows a success badge', () => { + const b = tile({ status: 'ready', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }) + .find('[data-testid="readiness-badge"]') + expect(b.text()).toBe('ready') + expect(b.classes().join(' ')).toContain('status-success') + }) + + it('no stamp, no badge — never a guessed state', () => { + expect(tile(null).find('[data-testid="readiness-badge"]').exists()).toBe(false) + expect(tile(undefined).find('[data-testid="readiness-badge"]').exists()).toBe(false) + }) +}) diff --git a/tests/registry.json b/tests/registry.json index a4048ca87..918a4e6af 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -3558,6 +3558,16 @@ "integration" ], "description": "Journey J13 (suggestions): my agent tells me what I can do with it and what is waiting on me, and only things that are true. A strict=True xfail skeleton — the rules, SQL and doors are unit-proven and the verbs mount-proven; the walk needs a real failing schedule, a shared platform user and a magic-link client on one instance." + }, + { + "file": "unit/test_ent527_readiness_on_lists.py", + "feature": "abilityai/trinity-enterprise#527", + "added": "2026-09-28", + "categories": [ + "backend", + "database" + ], + "description": "ent#527 rider (ruling 2026-09-24): the owner's readiness stamp on the agents list and the fleet grid. One batched read on a real database returns only stamped agents, names a rollout seed as such, never carries who flipped it, skips an unknown state and does not query for an empty list; GET /api/agents attaches it per agent from that one read, None when unstamped." } ] } diff --git a/tests/unit/test_ent527_readiness_on_lists.py b/tests/unit/test_ent527_readiness_on_lists.py new file mode 100644 index 000000000..84ceb4fb6 --- /dev/null +++ b/tests/unit/test_ent527_readiness_on_lists.py @@ -0,0 +1,110 @@ +"""trinity-enterprise#527 rider — the readiness stamp on the agents list and the fleet grid. + +Operator ruling 2026-09-24 (ent#560's closure): readiness is a role-companion +property, and the owner's stamp (`calibrating | ready`, ent#663) should be +visible on the agents list and the fleet grid, not only on the role card. + +What is pinned here: +- ONE batched read for the whole list (the display-label pattern — a per-agent + read on the fleet's hottest endpoint is an N+1), on a real database. +- Only a STAMP is shown. An agent without one is either not a companion or an + unstamped one, and telling those apart needs the container's template.yaml, + which a list must never read — so it carries no readiness at all rather than + a guessed `calibrating`. +- The list says WHAT and WHEN, never WHO: `changed_by` is an email (or the + ent#689 rollout sentinel) and the list is visible to every agent viewer; the + role card, which is owner-scoped, keeps the person. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parents[2] / "src" / "backend" +_BACKEND_STR = str(_BACKEND) +while _BACKEND_STR in sys.path: + sys.path.remove(_BACKEND_STR) +sys.path.insert(0, _BACKEND_STR) + +from db_harness import db_backend, run as _hrun # noqa: E402,F401 + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def readiness_ops(db_backend): + try: + from db.role_readiness import RoleReadinessOperations + except ImportError: # pragma: no cover - env guard + pytest.skip("backend venv required") + return RoleReadinessOperations() + + +class TestBatchedRead: + def test_returns_only_stamped_agents(self, readiness_ops): + readiness_ops.set_role_readiness("companion-ready", "ready", "owner@example.com") + readiness_ops.set_role_readiness("companion-cal", "calibrating", "owner@example.com") + got = readiness_ops.get_role_readiness_for_agents( + ["companion-ready", "companion-cal", "plain-agent"]) + assert set(got) == {"companion-ready", "companion-cal"} + assert got["companion-ready"]["status"] == "ready" + assert got["companion-cal"]["status"] == "calibrating" + assert got["companion-ready"]["changed_at"] + + def test_the_rollout_seed_is_named_as_such(self, readiness_ops): + readiness_ops.set_role_readiness("grandfathered", "ready", "rollout:ent#689") + assert readiness_ops.get_role_readiness_for_agents(["grandfathered"])["grandfathered"] == { + "status": "ready", + "changed_at": readiness_ops.get_role_readiness("grandfathered")["changed_at"], + "source": "rollout", + } + + def test_an_owners_flip_is_source_owner(self, readiness_ops): + readiness_ops.set_role_readiness("a", "ready", "owner@example.com") + assert readiness_ops.get_role_readiness_for_agents(["a"])["a"]["source"] == "owner" + + def test_never_carries_who(self, readiness_ops): + readiness_ops.set_role_readiness("a", "ready", "owner@example.com") + assert "changed_by" not in readiness_ops.get_role_readiness_for_agents(["a"])["a"] + + def test_empty_input_does_not_query(self, readiness_ops, monkeypatch): + import db.role_readiness as mod + monkeypatch.setattr(mod, "get_engine", lambda: (_ for _ in ()).throw(AssertionError("queried"))) + assert readiness_ops.get_role_readiness_for_agents([]) == {} + + def test_an_unknown_state_in_the_row_is_not_shown(self, readiness_ops): + # A row written outside the one writer (which validates) must not reach + # the UI as a third state it has no badge for. + _hrun("INSERT INTO agent_role_readiness (agent_name, status, changed_at, changed_by) " + "VALUES ('odd', 'maybe', '2026-09-28T00:00:00Z', 'x')") + assert readiness_ops.get_role_readiness_for_agents(["odd"]) == {} + + +class TestListEndpoint: + @pytest.mark.asyncio + async def test_attaches_readiness_from_one_batched_read(self, monkeypatch): + from unittest.mock import MagicMock + import importlib + mod = importlib.import_module("routers.agents") + import database + + agents = [{"name": "companion"}, {"name": "plain"}] + monkeypatch.setattr(mod, "get_accessible_agents", lambda user: [dict(a) for a in agents]) + db = MagicMock() + db.get_tags_for_agents.return_value = {} + db.get_display_labels_for_agents.return_value = {} + db.get_role_readiness_for_agents.return_value = { + "companion": {"status": "calibrating", "changed_at": "2026-09-28T10:00:00Z", "source": "owner"}, + } + monkeypatch.setattr(database, "db", db) + + out = await mod.list_agents_endpoint(MagicMock(), tags=None, current_user=MagicMock()) + + by_name = {a["name"]: a for a in out} + assert by_name["companion"]["readiness"] == { + "status": "calibrating", "changed_at": "2026-09-28T10:00:00Z", "source": "owner"} + # Not a companion (or not stamped): no field value, never a guessed state. + assert by_name["plain"]["readiness"] is None + db.get_role_readiness_for_agents.assert_called_once_with(["companion", "plain"]) From 0d73c64c4400b022390ab844dcfbe1337ddec706 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 29 Sep 2026 10:05:38 +0300 Subject: [PATCH 2/5] fix(dashboard): calibrating tooltip claims a paused brief only when one is held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3038 review item 1: the list/grid calibrating tooltip always said "its scheduled brief is paused until its owner marks it ready", while the role card says so only when _brief_held() is true (an enabled seat-delivery schedule AND autonomy on). With autonomy off or no Workspace-delivery schedule the owner would mark it ready and nothing would start. - services/role_readiness_gate: is_seat_delivery_schedule + brief_is_held, the one predicate; role_card._brief_held now uses it. - GET /api/agents attaches brief_held next to readiness: one batched, projected read over agent_schedules (get_workspace_delivery_schedules_for_agents), only for calibrating stamps with autonomy on; fail-soft to no claim. - readinessBadge(readiness, briefHeld): the pause clause only when held, otherwise "not yet marked ready by its owner". Tile and list pass agent.brief_held. - Nit: ready tooltips put the date first, as the role card does ("Ready since … (UTC) — carried over when the readiness gate shipped"). - Nit: AgentListPanel is now mounted in the spec — one badge per stamped row on both the lg and md secondary lines, none on the unstamped row. Co-Authored-By: Claude Opus 5.5 --- docs/memory/requirements/core-agent.md | 7 +- src/backend/client_portal/role_card.py | 15 +- src/backend/database.py | 3 + src/backend/db/schedules/crud.py | 26 +++ src/backend/routers/agents.py | 6 + src/backend/services/role_readiness_gate.py | 52 ++++++ .../src/components/AgentListPanel.vue | 20 ++- src/frontend/src/components/AgentTile.vue | 2 +- src/frontend/src/utils/readinessBadge.js | 18 +- .../tests/unit/readinessBadge.spec.js | 23 ++- .../tests/unit/readinessOnGrid.spec.js | 62 ++++++- tests/registry.json | 2 +- tests/unit/test_ent527_readiness_on_lists.py | 155 +++++++++++++++++- 13 files changed, 357 insertions(+), 34 deletions(-) diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md index 2ed84ad01..4acdfa092 100644 --- a/docs/memory/requirements/core-agent.md +++ b/docs/memory/requirements/core-agent.md @@ -3322,7 +3322,12 @@ to localStorage in the clear. when, never who (the role card, owner-scoped, keeps the person). Both surfaces render one predicate (`utils/readinessBadge.js`): the role card's words and variants (`ready` = success, `calibrating` = warning, with a dot), and a tooltip that names a rollout stamp - and what calibrating holds back. + and what calibrating holds back. The row also carries `brief_held` (one batched schedule + read, the role card's own predicate — `role_readiness_gate.brief_is_held`: a calibrating + stamp, an enabled seat-delivery schedule, autonomy on), and the tooltip says the brief is + paused only when it is true, so the list never claims a pause the card does not. The + dashboard's 30 s agent poll patches `readiness`/`brief_held` in place, so a flip reaches an + open tab without a reload. ### 5.37 Workspace — the seat-level decision record: why a thing was approved, deferred or killed (trinity-enterprise#638) - **Status**: ✅ Implemented (2026-09-22). OSS-core (Workspace). diff --git a/src/backend/client_portal/role_card.py b/src/backend/client_portal/role_card.py index 9aa93dd14..7368114af 100644 --- a/src/backend/client_portal/role_card.py +++ b/src/backend/client_portal/role_card.py @@ -37,6 +37,7 @@ from typing import Any, Optional from database import db +from services.role_readiness_gate import brief_is_held, is_seat_delivery_schedule from . import db as portal_db @@ -336,18 +337,22 @@ def _brief_held(agent_name: str, stamp: Optional[dict]) -> bool: seat-delivery schedule and its stamp is not `ready`. Reads the stamp the gate reads, never the template. Fail-soft: an unreadable schedule list says nothing rather than a claim about a pause.""" - if stamp and stamp.get("status") == "ready": + status = stamp.get("status") if stamp else None + if status == "ready": return False try: # Autonomy off stops every schedule before readiness is asked; saying # "paused until you mark it ready" then would promise a flip that - # starts nothing. - if not db.get_autonomy_enabled(agent_name): + # starts nothing. The rule itself is shared with the agents list + # (services/role_readiness_gate.brief_is_held) so the two never disagree. + autonomy = db.get_autonomy_enabled(agent_name) + if not autonomy: return False - return any( - s.enabled and (s.deliver_to_workspace_email or "").strip() + seated = any( + is_seat_delivery_schedule(s.enabled, s.deliver_to_workspace_email) for s in db.list_agent_schedules(agent_name) ) + return brief_is_held(status, autonomy, seated) except Exception as e: # noqa: BLE001 logger.warning("role card: schedule read failed for %s: %s", agent_name, e) return False diff --git a/src/backend/database.py b/src/backend/database.py index 4fc281a5e..b62b7a720 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1741,6 +1741,9 @@ def list_agent_schedules(self, agent_name: str): def get_agent_schedule_names(self, agent_name: str): return self._schedule_ops.get_agent_schedule_names(agent_name) + def get_workspace_delivery_schedules_for_agents(self, agent_names): + return self._schedule_ops.get_workspace_delivery_schedules_for_agents(agent_names) + def find_active_schedules_exceeding_timeout(self, agent_name: str, ceiling_seconds: int): return self._schedule_ops.find_active_schedules_exceeding_timeout( agent_name, ceiling_seconds diff --git a/src/backend/db/schedules/crud.py b/src/backend/db/schedules/crud.py index 837f83234..11bf6140b 100644 --- a/src/backend/db/schedules/crud.py +++ b/src/backend/db/schedules/crud.py @@ -237,6 +237,32 @@ def list_agent_schedules(self, agent_name: str) -> List[Schedule]: with get_engine().connect() as conn: return [self._row_to_schedule(row) for row in conn.execute(stmt).mappings()] + def get_workspace_delivery_schedules_for_agents(self, agent_names: List[str]) -> List[dict]: + """Live schedules that carry a Workspace delivery address, for many agents + in ONE query (the agents list's `brief_held`, ent#527 rider / PR #3038). + + Rows of ``{agent_name, enabled, deliver_to_workspace_email}`` — a projected + SELECT, never the prompts. Whether a row is a seat brief is the caller's + shared predicate (``role_readiness_gate.is_seat_delivery_schedule``), not + SQL, so the list and the role card apply the same rule. + """ + names = list(dict.fromkeys(n for n in agent_names if n)) + if not names: + return [] + stmt = select( + agent_schedules.c.agent_name, + agent_schedules.c.enabled, + agent_schedules.c.deliver_to_workspace_email, + ).where( + and_( + agent_schedules.c.agent_name.in_(names), + agent_schedules.c.deliver_to_workspace_email.isnot(None), + agent_schedules.c.deleted_at.is_(None), + ) + ) + with get_engine().connect() as conn: + return [dict(r) for r in conn.execute(stmt).mappings()] + def get_agent_schedule_names(self, agent_name: str) -> Dict[str, str]: """``{schedule_id: name}`` for one agent's live schedules (#2161). diff --git a/src/backend/routers/agents.py b/src/backend/routers/agents.py index 487487cb8..e615fc607 100644 --- a/src/backend/routers/agents.py +++ b/src/backend/routers/agents.py @@ -172,11 +172,17 @@ async def list_agents_endpoint( # and the fleet grid — batched for the same reason. Only stamped agents carry # one; `None` means "no stamp", never a guessed `calibrating`. all_readiness = db.get_role_readiness_for_agents(agent_names) + # Whether a calibrating stamp is actually holding a scheduled brief — the + # role card's own predicate, one batched schedule read, so the list's + # tooltip never claims a pause the card does not (PR #3038 review). + from services.role_readiness_gate import briefs_held_for_list + held = briefs_held_for_list(agents, all_readiness) for agent in agents: agent["tags"] = all_tags.get(agent.get("name"), []) agent["display_label"] = all_labels.get(agent.get("name")) agent["readiness"] = all_readiness.get(agent.get("name")) + agent["brief_held"] = agent.get("name") in held return agents diff --git a/src/backend/services/role_readiness_gate.py b/src/backend/services/role_readiness_gate.py index 55f3115b1..406ae04a3 100644 --- a/src/backend/services/role_readiness_gate.py +++ b/src/backend/services/role_readiness_gate.py @@ -70,6 +70,58 @@ def decide(agent_name: str, stamp: Optional[dict], companion: Optional[bool]) -> return Verdict(False, held_reason(agent_name), "unstamped_companion") +def is_seat_delivery_schedule(enabled, deliver_to_workspace_email) -> bool: + """A live schedule that delivers into someone's Workspace — the seat brief the + gate holds. Shared by the role card and the agents list (PR #3038).""" + return bool(enabled) and bool((deliver_to_workspace_email or "").strip()) + + +def brief_is_held(stamp_status: Optional[str], autonomy_enabled, has_seat_delivery_schedule) -> bool: + """Whether "its scheduled brief is paused until it is marked ready" is TRUE. + + Not `ready`, a seat-delivery schedule to hold, and autonomy on — with + autonomy off every schedule is stopped before readiness is asked, so the + sentence would promise a flip that starts nothing. The role card and the + agents list both say it from this one predicate, so they cannot disagree. + """ + if stamp_status == "ready": + return False + return bool(autonomy_enabled) and bool(has_seat_delivery_schedule) + + +def briefs_held_for_list(agents: list, readiness_by_name: dict) -> set: + """The names on `GET /api/agents` whose brief is held — one batched read. + + Only a `calibrating` STAMP can be held here: the list never reads a template, + so an unstamped agent (companion or not) carries no badge and no claim. + Autonomy comes from the rows themselves. Fail-soft like the role card: an + unreadable schedule list claims no pause. + """ + from database import db + + candidates = [ + a.get("name") for a in agents + if a.get("name") + and (readiness_by_name.get(a.get("name")) or {}).get("status") == "calibrating" + and a.get("autonomy_enabled") + ] + if not candidates: + return set() + try: + rows = db.get_workspace_delivery_schedules_for_agents(candidates) + except Exception as e: # noqa: BLE001 + logger.warning("[ent#527] schedule read for the agents list failed: %s", e) + return set() + seated = { + r["agent_name"] for r in rows + if is_seat_delivery_schedule(r.get("enabled"), r.get("deliver_to_workspace_email")) + } + return { + n for n in candidates + if brief_is_held(readiness_by_name[n]["status"], True, n in seated) + } + + async def _is_companion(agent_name: str) -> Optional[bool]: """Whether template.yaml declares `x-role` — True / False, or None when that cannot be read right now (not running, Docker unreadable, slow, unparsable).""" diff --git a/src/frontend/src/components/AgentListPanel.vue b/src/frontend/src/components/AgentListPanel.vue index bebc496ef..376de2d06 100644 --- a/src/frontend/src/components/AgentListPanel.vue +++ b/src/frontend/src/components/AgentListPanel.vue @@ -503,13 +503,13 @@
{{ readinessBadge(agent.readiness).label }} + >{{ readinessFor(agent).label }} {{ agentNameParts(agent).secondary }} {{ readinessBadge(agent.readiness).label }} + >{{ readinessFor(agent).label }} readinessBadge(agent.readiness, agent.brief_held) + const autonomyLoading = ref(null) const readOnlyLoading = ref(null) diff --git a/src/frontend/src/components/AgentTile.vue b/src/frontend/src/components/AgentTile.vue index 7d4a52508..0d030c278 100644 --- a/src/frontend/src/components/AgentTile.vue +++ b/src/frontend/src/components/AgentTile.vue @@ -243,7 +243,7 @@ const gridStore = useFleetGridStore() const name = computed(() => props.agent.name) const isSystemAgent = computed(() => props.agent.is_system === true) -const readiness = computed(() => readinessBadge(props.agent.readiness)) +const readiness = computed(() => readinessBadge(props.agent.readiness, props.agent.brief_held)) // ent#139/#2104 — agent-class variant. Keyed off the runner's FIXED NAME // (`trinity-skill-runner` is a fixed-name singleton, RUNNER_AGENT_NAME in the diff --git a/src/frontend/src/utils/readinessBadge.js b/src/frontend/src/utils/readinessBadge.js index 59d7db7fc..ea3ab4de0 100644 --- a/src/frontend/src/utils/readinessBadge.js +++ b/src/frontend/src/utils/readinessBadge.js @@ -12,10 +12,17 @@ * variants match the role card (`PortalAgentRole.vue`): ready = success, * calibrating = warning, both with a dot. * + * `briefHeld` is the row's `brief_held` (same list payload): the role card's own + * predicate — an enabled seat-delivery schedule AND autonomy on. Only then does + * the calibrating tooltip say the brief is paused; otherwise "until its owner + * marks it ready" would promise a flip that starts nothing, and the list would + * claim a pause the card does not. + * * @param {{status?: string, changed_at?: string|null, source?: string}|null|undefined} readiness + * @param {boolean} [briefHeld] * @returns {{label: string, variant: 'success'|'warning', title: string}|null} */ -export function readinessBadge(readiness) { +export function readinessBadge(readiness, briefHeld = false) { const status = readiness && readiness.status if (status !== 'ready' && status !== 'calibrating') return null const day = typeof readiness.changed_at === 'string' && /^\d{4}-\d{2}-\d{2}/.test(readiness.changed_at) @@ -23,12 +30,15 @@ export function readinessBadge(readiness) { : null const since = day ? ` since ${day} (UTC)` : '' let title + // Date first, then the why — the role card's order ("since … · carried over"). if (status === 'ready') { title = readiness.source === 'rollout' - ? `Ready — carried over when the readiness gate shipped${since}` - : `Ready — marked ready by its owner${since}` + ? `Ready${since} — carried over when the readiness gate shipped` + : `Ready${since} — marked ready by its owner` } else { - title = `Calibrating${since} — its scheduled brief is paused until its owner marks it ready` + title = briefHeld === true + ? `Calibrating${since} — its scheduled brief is paused until its owner marks it ready` + : `Calibrating${since} — not yet marked ready by its owner` } return { label: status, variant: status === 'ready' ? 'success' : 'warning', title } } diff --git a/src/frontend/tests/unit/readinessBadge.spec.js b/src/frontend/tests/unit/readinessBadge.spec.js index eef58edcf..692067d30 100644 --- a/src/frontend/tests/unit/readinessBadge.spec.js +++ b/src/frontend/tests/unit/readinessBadge.spec.js @@ -20,19 +20,30 @@ describe('readinessBadge', () => { expect(readinessBadge({ status: 'ready', changed_at: '2026-09-28T10:00:00Z', source: 'owner' })).toEqual({ label: 'ready', variant: 'success', - title: 'Ready — marked ready by its owner since 2026-09-28 (UTC)', + title: 'Ready since 2026-09-28 (UTC) — marked ready by its owner', }) }) it('ready from the ent#689 rollout says so', () => { expect(readinessBadge({ status: 'ready', changed_at: '2026-09-24T08:00:00Z', source: 'rollout' }).title) - .toBe('Ready — carried over when the readiness gate shipped since 2026-09-24 (UTC)') + .toBe('Ready since 2026-09-24 (UTC) — carried over when the readiness gate shipped') }) - it('calibrating names what it holds back', () => { - const b = readinessBadge({ status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }) - expect(b.variant).toBe('warning') - expect(b.title).toBe('Calibrating since 2026-09-28 (UTC) — its scheduled brief is paused until its owner marks it ready') + it('calibrating names what it holds back — only when a brief is actually held', () => { + const cal = { status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' } + const held = readinessBadge(cal, true) + expect(held.variant).toBe('warning') + expect(held.title).toBe('Calibrating since 2026-09-28 (UTC) — its scheduled brief is paused until its owner marks it ready') + }) + + it('calibrating with no held brief claims no pause (the role card says none either)', () => { + // No seat-delivery schedule, or autonomy off: "marks it ready" would start nothing. + const cal = { status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' } + for (const b of [readinessBadge(cal), readinessBadge(cal, false), readinessBadge(cal, undefined)]) { + expect(b.label).toBe('calibrating') + expect(b.title).toBe('Calibrating since 2026-09-28 (UTC) — not yet marked ready by its owner') + expect(b.title).not.toContain('paused') + } }) it('a missing or malformed date is left out, not printed', () => { diff --git a/src/frontend/tests/unit/readinessOnGrid.spec.js b/src/frontend/tests/unit/readinessOnGrid.spec.js index 4b1509c08..c4e6f9518 100644 --- a/src/frontend/tests/unit/readinessOnGrid.spec.js +++ b/src/frontend/tests/unit/readinessOnGrid.spec.js @@ -1,9 +1,10 @@ // @vitest-environment jsdom /** - * trinity-enterprise#527 rider — the fleet grid tile shows the owner's readiness - * stamp, mounted (not a regex over the template). The agents list renders the - * same `readinessBadge` predicate; its rows are covered by the shared helper's - * spec (readinessBadge.spec.js). + * trinity-enterprise#527 rider — the owner's readiness stamp, mounted (not a + * regex over the template) on BOTH surfaces: the fleet grid tile and the agents + * list rows (the `lg` and `md` secondary lines). The words come from the shared + * `readinessBadge` predicate (readinessBadge.spec.js); this pins the placement + * and that each surface passes the row's `brief_held` through. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' @@ -20,16 +21,17 @@ vi.mock('axios', () => { }) import AgentTile from '../../src/components/AgentTile.vue' +import AgentListPanel from '../../src/components/AgentListPanel.vue' const STUBS = { AgentAvatar: true, RuntimeBadge: true, RunningStateToggle: true, AutonomyToggle: true, ScanlineReveal: { template: '
' }, } -function tile(readiness) { +function tile(readiness, briefHeld = false) { return mount(AgentTile, { props: { - agent: { name: 'sales-companion', status: 'running', runtime: 'claude-code', tags: [], readiness }, + agent: { name: 'sales-companion', status: 'running', runtime: 'claude-code', tags: [], readiness, brief_held: briefHeld }, now: Date.parse('2026-09-28T12:00:00Z'), }, global: { stubs: STUBS }, @@ -40,7 +42,7 @@ beforeEach(() => setActivePinia(createPinia())) describe('AgentTile — readiness stamp (ent#527 rider)', () => { it('a calibrating companion shows a warning badge that says what it holds back', () => { - const b = tile({ status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }) + const b = tile({ status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }, true) .find('[data-testid="readiness-badge"]') expect(b.exists()).toBe(true) expect(b.text()).toBe('calibrating') @@ -48,6 +50,13 @@ describe('AgentTile — readiness stamp (ent#527 rider)', () => { expect(b.classes().join(' ')).toContain('status-warning') }) + it('a calibrating companion with no held brief claims no pause', () => { + const b = tile({ status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }, false) + .find('[data-testid="readiness-badge"]') + expect(b.text()).toBe('calibrating') + expect(b.attributes('title')).not.toContain('paused') + }) + it('a ready companion shows a success badge', () => { const b = tile({ status: 'ready', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }) .find('[data-testid="readiness-badge"]') @@ -60,3 +69,42 @@ describe('AgentTile — readiness stamp (ent#527 rider)', () => { expect(tile(undefined).find('[data-testid="readiness-badge"]').exists()).toBe(false) }) }) + +describe('AgentListPanel — readiness stamp on both secondary lines (ent#527 rider)', () => { + function panel() { + const row = (name, extra) => ({ + name, status: 'running', runtime: 'claude-code', tags: [], is_owner: true, ...extra, + }) + return mount(AgentListPanel, { + props: { + agents: [ + row('held-companion', { + readiness: { status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' }, + brief_held: true, + }), + row('ready-companion', { + readiness: { status: 'ready', changed_at: '2026-09-27T10:00:00Z', source: 'owner' }, + brief_held: false, + }), + row('plain-agent', { readiness: null, brief_held: false }), + ], + }, + global: { stubs: { ...STUBS, ReadOnlyToggle: true, CapacityMeter: true, RouterLink: true } }, + }) + } + + for (const line of ['row-secondary-lg', 'row-secondary-md']) { + it(`${line}: one badge per stamped row, none on the unstamped one`, () => { + const lines = panel().findAll(`[data-testid="${line}"]`) + expect(lines.length).toBe(3) + const badges = lines.map(l => l.findAll('[data-testid="readiness-badge"]')) + expect(badges.map(b => b.length).sort()).toEqual([0, 1, 1]) + const all = badges.flat() + const cal = all.find(b => b.text() === 'calibrating') + const ready = all.find(b => b.text() === 'ready') + expect(cal.attributes('title')).toContain('scheduled brief is paused') + expect(cal.classes().join(' ')).toContain('status-warning') + expect(ready.classes().join(' ')).toContain('status-success') + }) + } +}) diff --git a/tests/registry.json b/tests/registry.json index 918a4e6af..7d3c45ad5 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -3567,7 +3567,7 @@ "backend", "database" ], - "description": "ent#527 rider (ruling 2026-09-24): the owner's readiness stamp on the agents list and the fleet grid. One batched read on a real database returns only stamped agents, names a rollout seed as such, never carries who flipped it, skips an unknown state and does not query for an empty list; GET /api/agents attaches it per agent from that one read, None when unstamped." + "description": "ent#527 rider (ruling 2026-09-24): the owner's readiness stamp on the agents list and the fleet grid. One batched read on a real database returns only stamped agents, names a rollout seed as such, never carries who flipped it, skips an unknown state and does not query for an empty list; GET /api/agents attaches it per agent from that one read, None when unstamped. brief_held rides next to it: the role card's shared predicates (is_seat_delivery_schedule, brief_is_held), one batched schedule read over the calibrating+autonomy candidates only, fail-soft to no claim, so the list's calibrating tooltip never says a brief is paused when the card would not." } ] } diff --git a/tests/unit/test_ent527_readiness_on_lists.py b/tests/unit/test_ent527_readiness_on_lists.py index 84ceb4fb6..66df4c959 100644 --- a/tests/unit/test_ent527_readiness_on_lists.py +++ b/tests/unit/test_ent527_readiness_on_lists.py @@ -28,7 +28,7 @@ sys.path.remove(_BACKEND_STR) sys.path.insert(0, _BACKEND_STR) -from db_harness import db_backend, run as _hrun # noqa: E402,F401 +from db_harness import db_backend, run as _hrun, seed_user # noqa: E402,F401 pytestmark = pytest.mark.unit @@ -108,3 +108,156 @@ async def test_attaches_readiness_from_one_batched_read(self, monkeypatch): # Not a companion (or not stamped): no field value, never a guessed state. assert by_name["plain"]["readiness"] is None db.get_role_readiness_for_agents.assert_called_once_with(["companion", "plain"]) + + +# --------------------------------------------------------------------------- +# brief_held on the list (PR #3038 review, item 1): the calibrating tooltip may +# say "its scheduled brief is paused" only when the role card would — an +# enabled seat-delivery schedule AND autonomy on. One predicate for both. +# --------------------------------------------------------------------------- + +def _gate(): + try: + from services import role_readiness_gate + except ImportError: # pragma: no cover - env guard + pytest.skip("backend venv required") + return role_readiness_gate + + +class TestSharedPredicate: + @pytest.mark.parametrize("enabled,email,seat", [ + (1, "s@example.com", True), + (True, "s@example.com", True), + (0, "s@example.com", False), + (1, None, False), + (1, " ", False), + (None, "s@example.com", False), + ]) + def test_seat_delivery_schedule(self, enabled, email, seat): + assert _gate().is_seat_delivery_schedule(enabled, email) is seat + + @pytest.mark.parametrize("status,autonomy,has_seat,held", [ + ("calibrating", True, True, True), + (None, True, True, True), # the card's unstamped companion + ("ready", True, True, False), + ("calibrating", False, True, False), # autonomy off: a flip starts nothing + ("calibrating", True, False, False), # nothing scheduled to hold + ]) + def test_brief_is_held(self, status, autonomy, has_seat, held): + assert _gate().brief_is_held(status, autonomy, has_seat) is held + + def test_the_role_card_uses_the_same_predicates(self): + from client_portal import role_card + gate = _gate() + assert role_card.is_seat_delivery_schedule is gate.is_seat_delivery_schedule + assert role_card.brief_is_held is gate.brief_is_held + + +def _seed_schedule(sid, agent, enabled, email, deleted_at=None): + _hrun( + "INSERT INTO agent_schedules (id, agent_name, name, cron_expression, message, " + "enabled, timezone, owner_id, created_at, updated_at, deliver_to_workspace_email, deleted_at) " + "VALUES (:id, :a, 'brief', '0 8 * * *', 'brief', :en, 'UTC', 1, " + "'2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', :em, :dl)", + id=sid, a=agent, en=enabled, em=email, dl=deleted_at, + ) + + +class TestBatchedScheduleRead: + def test_one_read_returns_only_live_rows_with_a_delivery_address(self, db_backend): + from db.schedules import ScheduleOperations + ops = ScheduleOperations(None, None) + seed_user(1) + _seed_schedule("s1", "held", 1, "seat@example.com") + _seed_schedule("s2", "disabled", 0, "seat@example.com") + _seed_schedule("s3", "plain", 1, None) + _seed_schedule("s4", "gone", 1, "seat@example.com", deleted_at="2026-09-02T00:00:00Z") + _seed_schedule("s5", "other", 1, "seat@example.com") + rows = ops.get_workspace_delivery_schedules_for_agents( + ["held", "disabled", "plain", "gone"]) + got = {(r["agent_name"], bool(r["enabled"])) for r in rows} + # `other` is not asked for; `plain` has no address; `gone` is soft-deleted. + assert got == {("held", True), ("disabled", False)} + + def test_empty_input_does_not_query(self, db_backend, monkeypatch): + from db.schedules import ScheduleOperations + import db.schedules.crud as crud + monkeypatch.setattr(crud, "get_engine", lambda: (_ for _ in ()).throw(AssertionError("queried"))) + assert ScheduleOperations(None, None).get_workspace_delivery_schedules_for_agents([]) == [] + + +class TestBriefsHeldForList: + def _db(self, monkeypatch, rows): + from unittest.mock import MagicMock + import database + db = MagicMock() + db.get_workspace_delivery_schedules_for_agents.return_value = rows + monkeypatch.setattr(database, "db", db) + return db + + def test_only_a_calibrating_stamp_with_autonomy_and_a_seat_schedule_is_held(self, monkeypatch): + seat = lambda a: {"agent_name": a, "enabled": 1, "deliver_to_workspace_email": "s@example.com"} + db = self._db(monkeypatch, [seat("held"), seat("auto-off"), seat("ready"), + {"agent_name": "no-seat", "enabled": 0, + "deliver_to_workspace_email": "s@example.com"}]) + agents = [ + {"name": "held", "autonomy_enabled": True}, + {"name": "auto-off", "autonomy_enabled": False}, + {"name": "ready", "autonomy_enabled": True}, + {"name": "no-seat", "autonomy_enabled": True}, + {"name": "unstamped", "autonomy_enabled": True}, + ] + readiness = { + "held": {"status": "calibrating"}, "auto-off": {"status": "calibrating"}, + "ready": {"status": "ready"}, "no-seat": {"status": "calibrating"}, + } + assert _gate().briefs_held_for_list(agents, readiness) == {"held"} + # One read, and only over the agents that could be held. + db.get_workspace_delivery_schedules_for_agents.assert_called_once() + asked = set(db.get_workspace_delivery_schedules_for_agents.call_args.args[0]) + assert asked == {"held", "no-seat"} + + def test_nothing_calibrating_reads_nothing(self, monkeypatch): + db = self._db(monkeypatch, []) + assert _gate().briefs_held_for_list( + [{"name": "a", "autonomy_enabled": True}], {"a": {"status": "ready"}}) == set() + db.get_workspace_delivery_schedules_for_agents.assert_not_called() + + def test_an_unreadable_schedule_list_claims_no_pause(self, monkeypatch): + db = self._db(monkeypatch, []) + db.get_workspace_delivery_schedules_for_agents.side_effect = RuntimeError("db") + assert _gate().briefs_held_for_list( + [{"name": "a", "autonomy_enabled": True}], {"a": {"status": "calibrating"}}) == set() + + +class TestListEndpointBriefHeld: + @pytest.mark.asyncio + async def test_attaches_brief_held_next_to_readiness(self, monkeypatch): + from unittest.mock import MagicMock + import importlib + mod = importlib.import_module("routers.agents") + import database + + agents = [{"name": "held", "autonomy_enabled": True}, + {"name": "auto-off", "autonomy_enabled": False}, + {"name": "plain", "autonomy_enabled": True}] + monkeypatch.setattr(mod, "get_accessible_agents", lambda user: [dict(a) for a in agents]) + db = MagicMock() + db.get_tags_for_agents.return_value = {} + db.get_display_labels_for_agents.return_value = {} + db.get_role_readiness_for_agents.return_value = { + "held": {"status": "calibrating", "changed_at": "2026-09-28T10:00:00Z", "source": "owner"}, + "auto-off": {"status": "calibrating", "changed_at": "2026-09-28T10:00:00Z", "source": "owner"}, + } + db.get_workspace_delivery_schedules_for_agents.return_value = [ + {"agent_name": "held", "enabled": 1, "deliver_to_workspace_email": "s@example.com"}, + {"agent_name": "auto-off", "enabled": 1, "deliver_to_workspace_email": "s@example.com"}, + ] + monkeypatch.setattr(database, "db", db) + + out = await mod.list_agents_endpoint(MagicMock(), tags=None, current_user=MagicMock()) + + by_name = {a["name"]: a for a in out} + assert by_name["held"]["brief_held"] is True + assert by_name["auto-off"]["brief_held"] is False + assert by_name["plain"]["brief_held"] is False From 687fc2295805ebaaddafdba5cbb870bdd7ae99b6 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 29 Sep 2026 10:05:38 +0300 Subject: [PATCH 3/5] fix(dashboard): agent poll patches readiness in place on an open dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3038 review item 3: the 30s poll replaced agents only when the set of names changed, and a readiness flip emits no WS event, so an open tab kept showing calibrating until a reload. On an unchanged name set the poll now patches readiness and brief_held on the rows already present (only when the value changed) — no row replacement, no node rebuild. Pinned by a vitest over the real poll body (fake timers, same row objects, same nodes). Co-Authored-By: Claude Opus 5.5 --- src/frontend/src/stores/network.js | 22 +++++ src/frontend/tests/unit/readinessPoll.spec.js | 81 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/frontend/tests/unit/readinessPoll.spec.js diff --git a/src/frontend/src/stores/network.js b/src/frontend/src/stores/network.js index e1332425c..efd2134e7 100644 --- a/src/frontend/src/stores/network.js +++ b/src/frontend/src/stores/network.js @@ -1363,6 +1363,23 @@ export const useNetworkStore = defineStore('network', () => { } } + // The readiness stamp and whether it holds a brief (ent#527 rider) are the + // two per-row fields expected to change while a dashboard stays open. Only + // a changed value is written, so an unchanged poll triggers nothing. + function patchReadinessInPlace(freshAgents) { + const byName = new Map(freshAgents.map(a => [a.name, a])) + for (const row of agents.value) { + const fresh = byName.get(row.name) + if (!fresh) continue + const readiness = fresh.readiness ?? null + if (JSON.stringify(row.readiness ?? null) !== JSON.stringify(readiness)) { + row.readiness = readiness + } + const briefHeld = fresh.brief_held === true + if (row.brief_held !== briefHeld) row.brief_held = briefHeld + } + } + // Start polling agent list every 30 seconds (PERF-269: was 10s) function startAgentRefresh() { if (agentRefreshInterval.value) { @@ -1395,6 +1412,11 @@ export const useNetworkStore = defineStore('network', () => { // Pre-query, owner-filtered rebuild (ent#261) — this poll previously // rebuilt nodes from the RAW list, ignoring even the owner filter. convertAgentsToNodes(ownerFilteredAgents.value) + } else { + // ent#527 rider (PR #3038 review): a readiness flip changes no name + // and emits no WS event, so patch the stamp in place on the rows + // already present — no node rebuild, no row replacement. + patchReadinessInPlace(newAgents) } } catch (error) { console.error('[Collaboration] Failed to refresh agents:', error) diff --git a/src/frontend/tests/unit/readinessPoll.spec.js b/src/frontend/tests/unit/readinessPoll.spec.js new file mode 100644 index 000000000..15509ff00 --- /dev/null +++ b/src/frontend/tests/unit/readinessPoll.spec.js @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +/** + * trinity-enterprise#527 rider (PR #3038 review, item 3) — the 30 s agent poll + * carries a readiness flip to an already-open dashboard. + * + * The poll replaces `agents` only when the SET of names changes, and a + * readiness flip emits no WS event, so without an in-place patch an open tab + * showed `calibrating` until a reload. Pinned over the real poll body: the + * rows already present get `readiness` / `brief_held` patched in place, and + * nothing is rebuilt (same row objects, same `nodes`). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const get = vi.fn() +vi.mock('axios', () => { + const inst = { + get: (...a) => get(...a), post: vi.fn(), put: vi.fn(), delete: vi.fn(), + interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } }, + defaults: { headers: { common: {} } }, + } + return { default: Object.assign(inst, { create: () => inst }) } +}) + +import { useNetworkStore } from '../../src/stores/network.js' + +const CAL = { status: 'calibrating', changed_at: '2026-09-28T10:00:00Z', source: 'owner' } +const READY = { status: 'ready', changed_at: '2026-09-29T09:00:00Z', source: 'owner' } + +beforeEach(() => { + setActivePinia(createPinia()) + vi.useFakeTimers() + get.mockReset() +}) +afterEach(() => vi.useRealTimers()) + +describe('agent poll — readiness patched in place (ent#527 rider)', () => { + it('a flip reaches the row without rebuilding it or the nodes', async () => { + const store = useNetworkStore() + store.agents = [ + { name: 'companion', readiness: CAL, brief_held: true }, + { name: 'plain', readiness: null, brief_held: false }, + ] + const rowBefore = store.agents[0] + const nodesBefore = store.nodes + get.mockResolvedValue({ data: [ + { name: 'companion', readiness: READY, brief_held: false }, + { name: 'plain', readiness: null, brief_held: false }, + ] }) + + store.startAgentRefresh() + await vi.advanceTimersByTimeAsync(30000) + store.stopAgentRefresh() + + expect(get).toHaveBeenCalledWith('/api/agents', expect.anything()) + expect(store.agents[0]).toBe(rowBefore) + expect(store.agents[0].readiness).toEqual(READY) + expect(store.agents[0].brief_held).toBe(false) + expect(store.nodes).toBe(nodesBefore) + }) + + it('a newly stamped agent gains its badge, a cleared stamp loses it', async () => { + const store = useNetworkStore() + store.agents = [ + { name: 'a', readiness: null, brief_held: false }, + { name: 'b', readiness: READY, brief_held: false }, + ] + get.mockResolvedValue({ data: [ + { name: 'a', readiness: CAL, brief_held: true }, + { name: 'b', readiness: null, brief_held: false }, + ] }) + + store.startAgentRefresh() + await vi.advanceTimersByTimeAsync(30000) + store.stopAgentRefresh() + + expect(store.agents.find(x => x.name === 'a').readiness).toEqual(CAL) + expect(store.agents.find(x => x.name === 'a').brief_held).toBe(true) + expect(store.agents.find(x => x.name === 'b').readiness).toBeNull() + }) +}) From 592899dc211ee75c0949ffe85607a0f4322691f2 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 29 Sep 2026 10:05:38 +0300 Subject: [PATCH 4/5] docs(feature-flows): readiness in the list meta-strip order and the grid nameline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3038 review item 2: dashboard-list-view.md (diagram and the D14 secondary-line contract) now reads slug · pressure · readiness · runtime · tags · +N; dashboard-grid-view.md gains the nameline badge order (runtime · readiness · SYSTEM / SKILL RUNNER) and the brief_held tooltip rule. Co-Authored-By: Claude Opus 5.5 --- docs/memory/feature-flows/dashboard-grid-view.md | 6 ++++++ docs/memory/feature-flows/dashboard-list-view.md | 13 ++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/memory/feature-flows/dashboard-grid-view.md b/docs/memory/feature-flows/dashboard-grid-view.md index d65b85be3..539c19755 100644 --- a/docs/memory/feature-flows/dashboard-grid-view.md +++ b/docs/memory/feature-flows/dashboard-grid-view.md @@ -92,6 +92,12 @@ not navigate**: it is a copy affordance; navigation stays on `.t-name` and the Details button. Ink is `var(--gv-muted)`, the same token as the repo text it sits beside — no new `--gv-*` var. +**Nameline badge order:** `.t-name` · runtime icon · **readiness** · SYSTEM / +SKILL RUNNER. The readiness badge (trinity-enterprise#527 rider) is a `BaseBadge` +(`flex-none`) that renders only for a stamped role companion, from the same +`utils/readinessBadge.js` predicate as the agents list; its calibrating tooltip +names a paused brief only when the row's `brief_held` is true. + ### Trigger-bucket collapse (tile scale) The backend's #1107 buckets collapse to three groups: **Scheduled** ← diff --git a/docs/memory/feature-flows/dashboard-list-view.md b/docs/memory/feature-flows/dashboard-list-view.md index 196a9140e..dc486613e 100644 --- a/docs/memory/feature-flows/dashboard-list-view.md +++ b/docs/memory/feature-flows/dashboard-list-view.md @@ -97,7 +97,7 @@ lg row anatomy (#2358) — ONE sizing context: │ 9 row-1 cells (same data-col hooks as the header) │ + secondary line lg:row-start-2 lg:col-start-4 lg:col-end-10 │ flex-nowrap min-w-0 overflow-hidden, meta ink on the container - │ slug(code, select-all, truncate max-w-1/2) · pressure · runtime · tags · +N + │ slug(code, select-all, truncate max-w-1/2) · pressure · readiness · runtime · tags · +N │ + CapacityMeter lg:col-start-10 lg:row-start-1 lg:row-span-2 lg:mr-4 ├─ md layout (display:none at lg — never a grid item) └─ base layout (display:none at lg) @@ -222,10 +222,17 @@ NavBar: Agents entry removed; Dashboard active on '/' || isAgentSection entirely and on a mixed fleet it marks the exceptions. The rule is platform-anchored in a pure util rather than derived from fleet majority, which would silently flip badges as the fleet changes. **Secondary-line - contract** (`lg`/`md`): fixed order `slug · pressure · runtime · tags · +N`; + contract** (`lg`/`md`): fixed order `slug · pressure · readiness · runtime · tags · +N`; `flex-nowrap min-w-0 overflow-hidden`; slug `truncate max-w-[50%]`; badges `flex-shrink-0`; tags keep their counted `+N`. A future badge goes here in - that order or it does not go in the row. + that order or it does not go in the row. The **readiness** badge + (trinity-enterprise#527 rider) renders only for a stamped role companion — + `utils/readinessBadge.js`, the one predicate the grid tile shares — and its + calibrating tooltip says the scheduled brief is paused only when the row's + `brief_held` is true (the role card's rule: an enabled seat-delivery + schedule and autonomy on). The 30 s agent poll patches `readiness` / + `brief_held` in place on rows already present, so a flip reaches an open + dashboard without a reload. ## Teardown state loss (by design) From 0c3f81abc6e9ebe3b0d9afb56172ac2cfde98f04 Mon Sep 17 00:00:00 2001 From: Eugene Vyborov <1073874+vybe@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:53:12 +0100 Subject: [PATCH 5/5] =?UTF-8?q?merge-train:=20a=20failed=20readiness=20rea?= =?UTF-8?q?d=20lists=20without=20stamps=20(#3038)=20=E2=80=94=20mechanical?= =?UTF-8?q?,=20per=20the=20merge-train=20note=20on=20the=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_role_readiness_for_agents was the one unguarded decoration read on GET /api/agents: a DB fault 500'd the whole list. Degrade to no stamp (briefs_held_for_list already degrades to no pause), plus a test that drives the endpoint with the read raising. Co-Authored-By: Claude Opus 5.5 --- src/backend/routers/agents.py | 10 +++++++-- tests/unit/test_ent527_readiness_on_lists.py | 23 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/backend/routers/agents.py b/src/backend/routers/agents.py index e615fc607..866ce38fa 100644 --- a/src/backend/routers/agents.py +++ b/src/backend/routers/agents.py @@ -170,8 +170,14 @@ async def list_agents_endpoint( all_labels = db.get_display_labels_for_agents(agent_names) # ent#527 rider (ruling 2026-09-24): the owner's readiness stamp on the list # and the fleet grid — batched for the same reason. Only stamped agents carry - # one; `None` means "no stamp", never a guessed `calibrating`. - all_readiness = db.get_role_readiness_for_agents(agent_names) + # one; `None` means "no stamp", never a guessed `calibrating`. The stamp is + # decoration: a failed read degrades to "no stamp" rather than failing the + # whole list. + try: + all_readiness = db.get_role_readiness_for_agents(agent_names) + except Exception: + logger.warning("[ent#527] readiness read failed; listing without stamps", exc_info=True) + all_readiness = {} # Whether a calibrating stamp is actually holding a scheduled brief — the # role card's own predicate, one batched schedule read, so the list's # tooltip never claims a pause the card does not (PR #3038 review). diff --git a/tests/unit/test_ent527_readiness_on_lists.py b/tests/unit/test_ent527_readiness_on_lists.py index 66df4c959..1ead22a69 100644 --- a/tests/unit/test_ent527_readiness_on_lists.py +++ b/tests/unit/test_ent527_readiness_on_lists.py @@ -109,6 +109,29 @@ async def test_attaches_readiness_from_one_batched_read(self, monkeypatch): assert by_name["plain"]["readiness"] is None db.get_role_readiness_for_agents.assert_called_once_with(["companion", "plain"]) + @pytest.mark.asyncio + async def test_a_failed_readiness_read_lists_without_stamps(self, monkeypatch): + # The stamp is decoration on the fleet's hottest endpoint: a DB fault in + # its read degrades every row to "no stamp", never a 500 for the list. + from unittest.mock import MagicMock + import importlib + mod = importlib.import_module("routers.agents") + import database + + agents = [{"name": "companion", "autonomy_enabled": True}] + monkeypatch.setattr(mod, "get_accessible_agents", lambda user: [dict(a) for a in agents]) + db = MagicMock() + db.get_tags_for_agents.return_value = {} + db.get_display_labels_for_agents.return_value = {} + db.get_role_readiness_for_agents.side_effect = RuntimeError("database is locked") + monkeypatch.setattr(database, "db", db) + + out = await mod.list_agents_endpoint(MagicMock(), tags=None, current_user=MagicMock()) + + assert [a["name"] for a in out] == ["companion"] + assert out[0]["readiness"] is None + assert out[0]["brief_held"] is False + # --------------------------------------------------------------------------- # brief_held on the list (PR #3038 review, item 1): the calibrating tooltip may