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) diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md index 438cc6c66..6e040b6c8 100644 --- a/docs/memory/requirements/core-agent.md +++ b/docs/memory/requirements/core-agent.md @@ -3317,6 +3317,22 @@ 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. 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 28c7aa41c..fe99e37f2 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1744,6 +1744,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 @@ -2736,6 +2739,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/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 1b994d2a1..866ce38fa 100644 --- a/src/backend/routers/agents.py +++ b/src/backend/routers/agents.py @@ -168,10 +168,27 @@ 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`. 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). + 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 756b5b135..376de2d06 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" > - + {{ readinessFor(agent).label }} {{ agentNameParts(agent).secondary }} + {{ 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 b0d292e05..0d030c278 100644 --- a/src/frontend/src/components/AgentTile.vue +++ b/src/frontend/src/components/AgentTile.vue @@ -29,6 +29,17 @@ @click="viewDetails" >{{ agentNameParts(agent).primary }} + + {{ readiness.label }} props.agent.name) const isSystemAgent = computed(() => props.agent.is_system === true) +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/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/src/utils/readinessBadge.js b/src/frontend/src/utils/readinessBadge.js new file mode 100644 index 000000000..ea3ab4de0 --- /dev/null +++ b/src/frontend/src/utils/readinessBadge.js @@ -0,0 +1,44 @@ +/** + * 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. + * + * `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, 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) + ? readiness.changed_at.slice(0, 10) + : 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${since} — carried over when the readiness gate shipped` + : `Ready${since} — marked ready by its owner` + } else { + 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 new file mode 100644 index 000000000..692067d30 --- /dev/null +++ b/src/frontend/tests/unit/readinessBadge.spec.js @@ -0,0 +1,53 @@ +/** + * 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 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 since 2026-09-24 (UTC) — carried over when the readiness gate shipped') + }) + + 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', () => { + 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..c4e6f9518 --- /dev/null +++ b/src/frontend/tests/unit/readinessOnGrid.spec.js @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +/** + * 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' +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' +import AgentListPanel from '../../src/components/AgentListPanel.vue' + +const STUBS = { + AgentAvatar: true, RuntimeBadge: true, RunningStateToggle: true, AutonomyToggle: true, + ScanlineReveal: { template: '
' }, +} + +function tile(readiness, briefHeld = false) { + return mount(AgentTile, { + props: { + 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 }, + }) +} + +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' }, true) + .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 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"]') + 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) + }) +}) + +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/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() + }) +}) diff --git a/tests/registry.json b/tests/registry.json index be46ca00f..10d66b4ea 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -3443,7 +3443,7 @@ "access-control", "transport" ], - "description": "The loop-tool gate is WIRED where server.ts registers it, proved the way an agent reaches it: a real createServer in key mode, a real MCP client presenting an agent-scoped key over streamable HTTP, and a stub backend that answers key validation + the permission-edge read and COUNTS every POST /api/agents//loops (ent#628 -- no edge: refusal and zero loop starts; edge: the loop starts; self loop: no edge and no permission read). The stub also RECORDS every fire-and-forget POST /api/internal/audit, so the two #2807 cases prove the denial label over the wire: a refused start's row says success:false/denied:true with the reason and target, and deny-then-allow on ONE session leaves no stale marker on the permitted call. #2952: every call through asAgent().call drains ITS OWN audit row before returning and checks it for position (a call counter) AND identity (a per-session bearer the stub echoes back as key_name) -- the previous finder matched `(tool, index-after-length)` and read the prior test's late row on a busy host. A change to withAudit's one-row-per-call breaks this file on purpose (loud precondition); subtests must stay sequential (node:test default). trinity-enterprise#611: a second suite drives get_my_ask over the same real transport against a stub that answers key validation by bearer and records every readback — an agent key is served its OWN agent and no other, a person's user-scoped key is refused before the backend is asked anything, and the orchestrator's system key reads as trinity-system. trinity-enterprise#611 PR B: ask_operator over the same real transport \u2014 an agent key raises as its own agent with exactly the declared fields in the body, an agent_name argument neither aims the raise at a sibling nor reaches the backend, a person's user-scoped key is refused before any backend call, the system key raises as trinity-system, and a named 422 reaches the caller with its code and extras." + "description": "The loop-tool gate is WIRED where server.ts registers it, proved the way an agent reaches it: a real createServer in key mode, a real MCP client presenting an agent-scoped key over streamable HTTP, and a stub backend that answers key validation + the permission-edge read and COUNTS every POST /api/agents//loops (ent#628 -- no edge: refusal and zero loop starts; edge: the loop starts; self loop: no edge and no permission read). The stub also RECORDS every fire-and-forget POST /api/internal/audit, so the two #2807 cases prove the denial label over the wire: a refused start's row says success:false/denied:true with the reason and target, and deny-then-allow on ONE session leaves no stale marker on the permitted call. #2952: every call through asAgent().call drains ITS OWN audit row before returning and checks it for position (a call counter) AND identity (a per-session bearer the stub echoes back as key_name) -- the previous finder matched `(tool, index-after-length)` and read the prior test's late row on a busy host. A change to withAudit's one-row-per-call breaks this file on purpose (loud precondition); subtests must stay sequential (node:test default). trinity-enterprise#611: a second suite drives get_my_ask over the same real transport against a stub that answers key validation by bearer and records every readback — an agent key is served its OWN agent and no other, a person's user-scoped key is refused before the backend is asked anything, and the orchestrator's system key reads as trinity-system. trinity-enterprise#611 PR B: ask_operator over the same real transport — an agent key raises as its own agent with exactly the declared fields in the body, an agent_name argument neither aims the raise at a sibling nor reaches the backend, a person's user-scoped key is refused before any backend call, the system key raises as trinity-system, and a named 422 reaches the caller with its code and extras." }, { "file": "unit/test_2915_operator_queue_sync_honesty.py", @@ -3485,7 +3485,7 @@ "typescript", "access-control" ], - "description": "Operator-queue MCP tools: the #1101 agent-scope post-filter (pure), the #1104 respond gate and proxy (stub client), and trinity-enterprise#611 get_my_ask — identity from the key (resolveActingAgent), no agent-target parameter, the system key reads as trinity-system, user / connector / agent-less system keys refused before any backend call, backend refusals returned as structured errors, the description teaching the rider — plus the person-only wording on respond_to_operator_queue. node:test. PR B: ask_operator \u2014 raises as the key's agent (the system key as trinity-system; user / connector / agent-less system keys refused before any backend call), declares no agent-shaped parameter, forwards ONLY the declared fields whatever else the arguments carry, returns a 422 / 429 refusal as its named code and extras, keeps an unnamed validation error or a transport failure structured, and its description teaches idempotency, the re-ask link and the readback without the mcp__trinity__ prefix." + "description": "Operator-queue MCP tools: the #1101 agent-scope post-filter (pure), the #1104 respond gate and proxy (stub client), and trinity-enterprise#611 get_my_ask — identity from the key (resolveActingAgent), no agent-target parameter, the system key reads as trinity-system, user / connector / agent-less system keys refused before any backend call, backend refusals returned as structured errors, the description teaching the rider — plus the person-only wording on respond_to_operator_queue. node:test. PR B: ask_operator — raises as the key's agent (the system key as trinity-system; user / connector / agent-less system keys refused before any backend call), declares no agent-shaped parameter, forwards ONLY the declared fields whatever else the arguments carry, returns a 422 / 429 refusal as its named code and extras, keeps an unnamed validation error or a transport failure structured, and its description teaches idempotency, the re-ask link and the readback without the mcp__trinity__ prefix." }, { "file": "unit/test_ent611_native_ask.py", @@ -3689,6 +3689,16 @@ "workspace" ], "description": "Workspace asks read fails loud (ent#610 PR A0): a fault in any of the three queue reads (agent set, count, page — #3059) and an unreadable roster each raise AsksUnavailable and the route answers 503 asks_unavailable with Retry-After: 20 (never []); a genuinely off-roster agent is still dropped with a 200; answer_ask stays a uniform 404 on a roster outage (Invariant #8); the suggestions build still degrades to no ask ids through its own attempt() (a guard for the new raise path; it also passes pre-fix). The app is a module-scoped fixture. Mutation: revert the raise to return an empty page (9 red across this file and test_ent428)." + }, + { + "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. 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 new file mode 100644 index 000000000..1ead22a69 --- /dev/null +++ b/tests/unit/test_ent527_readiness_on_lists.py @@ -0,0 +1,286 @@ +"""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, seed_user # 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"]) + + @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 +# 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