Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/memory/feature-flows/dashboard-grid-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** ←
Expand Down
13 changes: 10 additions & 3 deletions docs/memory/feature-flows/dashboard-list-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 16 additions & 0 deletions docs/memory/requirements/core-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -3317,6 +3317,22 @@ to localStorage in the clear.
shipped", never "by <owner>"; 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).
Expand Down
15 changes: 10 additions & 5 deletions src/backend/client_portal/role_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 33 additions & 1 deletion src/backend/db/role_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions src/backend/db/schedules/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
17 changes: 17 additions & 0 deletions src/backend/routers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions src/backend/services/role_readiness_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
25 changes: 24 additions & 1 deletion src/frontend/src/components/AgentListPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<!-- Fixed order: slug · pressure · runtime · tags · +N. The line is
<!-- Fixed order: slug · pressure · readiness · runtime · tags · +N. The line is
the row's one meta strip and it stays legible only while it has
an order and a shrink policy — a future badge goes here, in
this order, or it does not go in the row at all. The slug is
Expand All @@ -501,6 +501,15 @@
>
{{ pressureBadgeFor(agent.name).text }}
</span>
<!-- ent#527 rider: the owner's readiness stamp (role companions only). -->
<BaseBadge
v-if="readinessFor(agent)"
:variant="readinessFor(agent).variant"
dot
class="flex-shrink-0"
:title="readinessFor(agent).title"
data-testid="readiness-badge"
>{{ readinessFor(agent).label }}</BaseBadge>
<RuntimeBadge
v-if="showsRuntimeBadgeInList(agent)"
data-testid="runtime-badge"
Expand Down Expand Up @@ -694,6 +703,14 @@
data-testid="agent-slug-md"
class="font-mono text-[11px] min-w-0 max-w-[50%] truncate select-all"
>{{ agentNameParts(agent).secondary }}</code>
<BaseBadge
v-if="readinessFor(agent)"
:variant="readinessFor(agent).variant"
dot
class="flex-shrink-0"
:title="readinessFor(agent).title"
data-testid="readiness-badge"
>{{ readinessFor(agent).label }}</BaseBadge>
<RuntimeBadge
v-if="showsRuntimeBadgeInList(agent)"
data-testid="runtime-badge"
Expand Down Expand Up @@ -855,6 +872,8 @@ import { isOrgTag } from '../utils/gridOrg'
import { sortAgents } from '../utils/agentSort'
import AgentAvatar from './AgentAvatar.vue'
import RuntimeBadge from './RuntimeBadge.vue'
import BaseBadge from './base/BaseBadge.vue'
import { readinessBadge } from '../utils/readinessBadge'
import RunningStateToggle from './RunningStateToggle.vue'
import AutonomyToggle from './AutonomyToggle.vue'
import ReadOnlyToggle from './ReadOnlyToggle.vue'
Expand Down Expand Up @@ -892,6 +911,10 @@ const emit = defineEmits(['tags-changed', 'clear-chassis-filters'])
const agentsStore = useAgentsStore()
const networkStore = useNetworkStore()

// ent#527 rider: the row's stamp plus whether it holds a brief (both ride the
// GET /api/agents row), through the one predicate the grid tile uses.
const readinessFor = (agent) => readinessBadge(agent.readiness, agent.brief_held)

const autonomyLoading = ref(null)
const readOnlyLoading = ref(null)

Expand Down
14 changes: 14 additions & 0 deletions src/frontend/src/components/AgentTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@
@click="viewDetails"
>{{ agentNameParts(agent).primary }}</span>
<RuntimeBadge :runtime="agent.runtime || 'claude-code'" :show-label="false" class="flex-none" />
<!-- ent#527 rider (ruling 2026-09-24): the owner's readiness stamp on a
role companion, same words and variants as the role card. Only a
stamp renders — no stamp, no badge (see utils/readinessBadge.js). -->
<BaseBadge
v-if="readiness"
:variant="readiness.variant"
dot
class="flex-none"
:title="readiness.title"
data-testid="readiness-badge"
>{{ readiness.label }}</BaseBadge>
<span
v-if="isSystemAgent"
class="sys-badge"
Expand Down Expand Up @@ -195,6 +206,8 @@ import { useRouter } from 'vue-router'
import { formatCostCompact } from '../composables/useFormatters'
import AgentAvatar from './AgentAvatar.vue'
import RuntimeBadge from './RuntimeBadge.vue'
import BaseBadge from './base/BaseBadge.vue'
import { readinessBadge } from '../utils/readinessBadge'
import { agentNameParts, agentNameTooltip } from '../utils/agentName'
import { pressureBadge, isSubscriptionFunded } from '../utils/subscriptionPressure'
import RunningStateToggle from './RunningStateToggle.vue'
Expand Down Expand Up @@ -230,6 +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, 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
Expand Down
Loading
Loading