Feature/observability gateway health (#247)

* feat(observability): revision_count + audit_log query index (migration 045)

Adds tasks.revision_count (the O(1) rework counter — forward-only, existing
rows default 0) and the composite index audit_log(target_id, event_type,
timestamp) that powers the cycle-time and rework reconstruction queries.
Verified the real upgrade/downgrade/upgrade chain on a throwaway pgvector PG.
First task of the 0.10.0 observability dashboards.

* feat(observability): count reworks + attribute qa_fail/pr_fail to the rejector

Every transition into needs_revision increments tasks.revision_count at the
single audit chokepoint (exactly once per bounce, across all paths incl. pr_fail
and ceo_reject), so the rework rate is an O(1) read. A QA or PR-review bounce
also emits a named task.qa_fail / task.pr_fail audit event carrying the
rejector's agent_id, so the per-agent rework scorecard charges the rejection to
the reviewer who made it, not the developer who owns the task.

* feat(observability): cycle-time, bottleneck, rework, and scorecard metrics

MetricsService gains four read methods on the audit_log + tasks data: per-stage
cycle time reconstructed from the transition journey (excluding the named
qa_fail/pr_fail events), bottleneck distribution (cumulative dwell + live parked
counts), rework rate (overall/by-team/by-agent with rejector attribution + cost
via spawn-session task_id), and a fused per-agent/per-cell scorecard. Dataclass
models with to_dict(). Verified against a real Postgres journey.

* feat(observability): cycle-time/bottleneck/rework/scorecard read endpoints

Thin read-only routes on the dashboard router delegating to MetricsService:
/metrics/cycle-time, /metrics/bottlenecks, /metrics/rework, and
/metrics/scorecard/{agent,team}. 404 when an agent scorecard target is absent.
5 route tests (200 + shape + the agent-404 case).

* feat(panel): Delivery observability tab (cycle-time, bottlenecks, rework, scorecards)

A third Metrics tab built on the observability endpoints: a per-stage
cycle-time bar chart, a bottleneck panel (worst stage + cumulative dwell +
live parked counts), a rework panel (rate + by-team + by-agent attribution +
cost), and per-cell scorecards. Reuses Recharts + Card/Badge/Skeleton and the
React-Query hook pattern; observabilityApi mirrors usageApi with mock-mode
fallbacks. tsc + eslint clean; 113 panel tests pass.

* docs(observability): changelog + CLAUDE.md for the delivery dashboards

* feat(gateway-health): recover a broken-but-alive agent instead of protecting it

The verb-heartbeat cannot tell a quiet-healthy agent from one whose MCP gateway
is broken (a corrupted /app/.venv firing no verb) yet whose container is up — the
reaper's live-skip would shield it forever. The reaper now probes the gateway
out-of-band (docker exec: does the gateway venv import its deps?) and, once it
has been broken past gateway_health_grace_seconds (tolerating a transient probe
miss), kills + evicts the container so it falls through to release + respawn.
Probe-inconclusive or healthy spares the container. Gated by
gateway_health_enabled (default-on reliability fix; in the panel Feature Flags).
Defers the optional agent-side self-check + full registry re-adoption — the
reaper's docker-liveness fallback already recovers a broken-after-restart agent.

* docs(gateway-health): changelog + CLAUDE.md for broken-but-alive recovery

* docs(observability): user-facing docs for the Delivery dashboards + gateway-health

Documents the new Metrics -> Delivery tab (cycle-time, bottlenecks, rework with
rejector attribution, cell scorecards) in the panel guide and the operations
health-and-metrics guide, and adds the gateway-health env vars + an agent-gateway
recovery note. Published MkDocs site only; settings.md's default-off flag table
intentionally omits the default-on gateway-health flag (same as overload-break).

* chore(release): cut 0.10.0 (changelog section + version refs)

* fix(gateway): exempt PM coordinators from single-task claim guards

A Main/Cell PM plans and delegates many root tasks in parallel; the work
then runs in the delegated cells, not in the PM's own hands. But the
claim-time concurrency guards meant for developers — already_active and
paused (the latter firing after i_am_idle auto-pauses the PM's own
umbrella) — were applied to the PM too, so once it held one root it could
never plan a second: it thrashed between its claimed roots and respawned
forever, burning tokens for zero progress.

_run_claim_guards now skips already_active/paused for the coordinator PM
roles (_COORDINATOR_ROLES = {main_pm, cell_pm}); only unmet_dependency — a
real upstream sequence constraint, which parks the root back to pending —
still gates a PM. paused_tasks_guard also excludes the target task itself,
so a PM re-entering its own paused umbrella never self-blocks.

Tests: a coordinator plans a second root with one in_progress + one paused
sibling (full path + claimed-recovery path), the paused target exclusion,
and the developer guards still fire. Repurposed the pre-fix test that
asserted the now-removed PM block.

* fix(metrics): coerce SQL avg/extract hours aggregates to float (panel toFixed crash)

EXTRACT(epoch ...) returns numeric on PostgreSQL 14+, which asyncpg surfaces
as a Decimal; a Decimal serializes to a quoted JSON string, so the panel's
avg_cycle_hours.toFixed(1) (and the other hours fields) threw 'toFixed is not
a function' and blanked the Delivery tab.

A single _as_hours helper now rounds every SQL-averaged hours field to a real
float — avg_cycle_hours on the new scorecards plus the pre-existing
avg_completion_hours / avg_blocked_hours / longest_blocked_hours. Token and
cost fields were already float()-cast and are unaffected.

Regression test asserts _as_hours coerces Decimal -> float and preserves the
None/zero behavior.

* feat(panel): edit a task's sequence from the details page

A task's sequence (order within siblings, lower runs first) was display-only
with no way to change it from the UI, and TaskUpdate didn't carry the field
so PATCH couldn't set it either. The details page's Dependencies tab now has
an inline sequence editor mirroring the parent / dependency editors, and
PATCH /tasks/{id} accepts a sequence field (owner or privileged role) through
the existing generic update path.

* fix(mypy): green the full make-quality type gate

make quality runs 'mypy roboco/ tests/', which the per-module checks on the
0.10.0 branch never exercised. Two issues surfaced:

- The coordinator-exemption change added role_str to
  Choreographer._run_claim_guards but not to the ChoreographerHelpers
  protocol base, so the composed Choreographer had incompatible base-class
  signatures. Sync the protocol signature.

- The gateway-health / stale-reaper tests stubbed methods by direct
  assignment (orch._m = AsyncMock()) and typed their duck-typed task doubles
  as object, tripping method-assign / assignment / attr-defined. Switch to
  monkeypatch.setattr (keeping a local mock ref for the assertions) and type
  the doubles as Any — no type: ignore.

Full mypy roboco/ tests/ clean (785 files); the 21 runtime tests pass.

* fix(metrics): static cycle-time SQL — clear bandit B608 (CI gate)

The cycle-time query interpolated an optional team clause into the text() SQL
via an f-string, which bandit flags as B608 (hardcoded SQL) and turned the
merge gate red. The team value was always a bound parameter, so it was a false
positive — but the f-string is the trigger. Rebuilt as one static query with
(CAST(:team AS text) IS NULL OR a.details->>'team' = :team) and an always-bound
team param (CAST, not ::text — SQLAlchemy's :param parser collides with
PostgreSQL's :: cast operator, which broke the query as a stray param).

Full make quality green vs a real pgvector PG (all 21 gate steps).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-23 07:26:41 +02:00
committed by GitHub
co-authored by Renn F
parent 07008baaf5
commit c09cf80b40
42 changed files with 2419 additions and 63 deletions
@@ -93,6 +93,55 @@ async def test_get_auditor_flags(dashboard_client: AsyncClient) -> None:
assert isinstance(response.json(), list)
# ---------------------------------------------------------------------------
# Observability endpoints (0.10.0)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cycle_time_endpoint(dashboard_client: AsyncClient) -> None:
resp = await dashboard_client.get(
"/api/dashboard/metrics/cycle-time?days=30", headers=_HDR
)
assert resp.status_code == HTTPStatus.OK
assert isinstance(resp.json(), list)
@pytest.mark.asyncio
async def test_bottlenecks_endpoint(dashboard_client: AsyncClient) -> None:
resp = await dashboard_client.get(
"/api/dashboard/metrics/bottlenecks", headers=_HDR
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert "by_stage" in body and "worst_stage" in body and "active_blockers" in body
@pytest.mark.asyncio
async def test_rework_endpoint(dashboard_client: AsyncClient) -> None:
resp = await dashboard_client.get("/api/dashboard/metrics/rework", headers=_HDR)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert "rate" in body and "by_team" in body and "by_agent" in body
@pytest.mark.asyncio
async def test_agent_scorecard_404_when_absent(dashboard_client: AsyncClient) -> None:
resp = await dashboard_client.get(
f"/api/dashboard/metrics/scorecard/agent/{uuid4()}", headers=_HDR
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_team_scorecard_endpoint(dashboard_client: AsyncClient) -> None:
resp = await dashboard_client.get(
"/api/dashboard/metrics/scorecard/team/backend", headers=_HDR
)
assert resp.status_code == HTTPStatus.OK
assert resp.json()["scope"] == "cell"
@pytest.mark.asyncio
async def test_resolve_auditor_flag(dashboard_client: AsyncClient) -> None:
create = await dashboard_client.post(
@@ -0,0 +1,283 @@
"""0.10.0 observability metric layer: cycle-time, bottleneck, rework, scorecard.
Seeds an audit_log journey + reworked tasks + rejector-attributed fail events +
spawn-session costs against a real Postgres and asserts the reconstructed
metrics. The named task.qa_fail / task.pr_fail events must NOT pollute the
cycle-time reconstruction (they share a timestamp with the needs_revision row).
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import (
AgentSpawnSessionTable,
AgentTable,
AuditLogTable,
ProjectTable,
TaskTable,
)
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.services.metrics import MetricsService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_T0 = datetime(2026, 6, 20, 12, 0, 0, tzinfo=UTC)
_EXPECTED_TASKS = 2 # completed tasks seeded per rework / scorecard test
_EXPECTED_TOKENS = 1500 # 1000 input + 500 output in the scorecard spawn session
def _agent(role: AgentRole, team: Team, slug: str) -> AgentTable:
return AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
def _task(
project_id: Any,
created_by: Any,
*,
assigned_to: Any = None,
revision_count: int = 0,
started_hours_ago: int | None = None,
) -> TaskTable:
"""A COMPLETED backend task completed `now` (in-window), optionally started."""
started = (
datetime.now(UTC) - timedelta(hours=started_hours_ago)
if started_hours_ago is not None
else None
)
return TaskTable(
id=uuid4(),
title="t",
description="d",
acceptance_criteria=["ac"],
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
status=TaskStatus.COMPLETED,
team=Team.BACKEND,
project_id=project_id,
created_by=created_by,
assigned_to=assigned_to,
revision_count=revision_count,
estimated_complexity=Complexity.MEDIUM,
completed_at=datetime.now(UTC),
started_at=started,
)
def _audit(
task_id: Any,
status: str,
ts: datetime,
*,
agent_id: Any = None,
event_type: str | None = None,
) -> AuditLogTable:
return AuditLogTable(
id=uuid4(),
event_type=event_type or f"task.{status}",
agent_id=agent_id,
target_type="task",
target_id=task_id,
severity="info",
details={"to_status": status, "from_status": "prev", "team": "backend"},
timestamp=ts,
)
def _spawn(
slug: str, *, task_id: str | None, cost: float, tokens_in: int, tokens_out: int
) -> AgentSpawnSessionTable:
return AgentSpawnSessionTable(
id=uuid4(),
agent_slug=slug,
team="backend",
role="developer",
model="claude",
task_id=task_id,
started_at=datetime.now(UTC) - timedelta(hours=1),
tokens_input=tokens_in,
tokens_output=tokens_out,
estimated_cost_usd=cost,
)
@pytest_asyncio.fixture
async def obs_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
dev = _agent(AgentRole.DEVELOPER, Team.BACKEND, f"be-dev-{uuid4().hex[:6]}")
qa = _agent(AgentRole.QA, Team.BACKEND, f"be-qa-{uuid4().hex[:6]}")
db_session.add_all([dev, qa])
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=dev.id,
)
db_session.add(project)
await db_session.flush()
yield {
"svc": MetricsService(db_session),
"db": db_session,
"project_id": project.id,
"dev_id": dev.id,
"qa_id": qa.id,
"dev_slug": dev.slug,
}
@pytest.mark.asyncio
async def test_cycle_time_reconstructs_per_stage_dwell(obs_setup: dict) -> None:
db = obs_setup["db"]
tid = uuid4()
# claimed (60s) -> in_progress (3600s) -> awaiting_qa (120s) -> completed
db.add_all(
[
_audit(tid, "claimed", _T0),
_audit(tid, "in_progress", _T0 + timedelta(seconds=60)),
_audit(tid, "awaiting_qa", _T0 + timedelta(seconds=60 + 3600)),
_audit(tid, "completed", _T0 + timedelta(seconds=60 + 3600 + 120)),
# A named fail event sharing the awaiting_qa timestamp must be ignored.
_audit(
tid,
"needs_revision",
_T0 + timedelta(seconds=60 + 3600),
event_type="task.qa_fail",
),
]
)
await db.flush()
stages = {s.status: s for s in await obs_setup["svc"].get_cycle_time_by_stage()}
assert stages["claimed"].avg_seconds == pytest.approx(60.0)
assert stages["in_progress"].avg_seconds == pytest.approx(3600.0)
assert stages["awaiting_qa"].avg_seconds == pytest.approx(120.0)
# The named qa_fail event did not create a zero-length needs_revision stage.
assert "needs_revision" not in stages
@pytest.mark.asyncio
async def test_bottleneck_ranks_longest_cumulative_stage(obs_setup: dict) -> None:
db = obs_setup["db"]
tid = uuid4()
db.add_all(
[
_audit(tid, "claimed", _T0),
_audit(tid, "in_progress", _T0 + timedelta(seconds=60)),
_audit(tid, "awaiting_qa", _T0 + timedelta(seconds=60 + 7200)),
_audit(tid, "completed", _T0 + timedelta(seconds=60 + 7200 + 30)),
]
)
await db.flush()
report = await obs_setup["svc"].get_bottleneck_distribution()
assert report.worst_stage == "in_progress"
assert report.by_stage[0].status == "in_progress"
@pytest.mark.asyncio
async def test_rework_rate_and_attribution(obs_setup: dict) -> None:
db = obs_setup["db"]
pid, dev_id, qa_id = (
obs_setup["project_id"],
obs_setup["dev_id"],
obs_setup["qa_id"],
)
clean = _task(pid, dev_id, assigned_to=dev_id)
reworked = _task(pid, dev_id, assigned_to=dev_id, revision_count=2)
db.add_all([clean, reworked])
await db.flush()
# The QA agent bounced the reworked task once (rejector attribution).
db.add(
_audit(
reworked.id,
"needs_revision",
datetime.now(UTC) - timedelta(hours=1),
agent_id=qa_id,
event_type="task.qa_fail",
)
)
# A spawn session attributes the rework's cost.
db.add(
_spawn(
obs_setup["dev_slug"],
task_id=str(reworked.id),
cost=0.42,
tokens_in=100,
tokens_out=50,
)
)
await db.flush()
report = await obs_setup["svc"].get_rework_metrics(days=30)
assert report.total_completed == _EXPECTED_TASKS
assert report.total_reworked == 1
assert report.rate == pytest.approx(0.5)
assert report.rework_cost_usd == pytest.approx(0.42)
qa_row = next(a for a in report.by_agent if a.qa_fails > 0)
assert qa_row.qa_fails == 1
@pytest.mark.asyncio
async def test_scorecard_agent_and_cell(obs_setup: dict) -> None:
db = obs_setup["db"]
pid, dev_id = obs_setup["project_id"], obs_setup["dev_id"]
db.add_all(
[
_task(pid, dev_id, assigned_to=dev_id, started_hours_ago=2),
_task(
pid, dev_id, assigned_to=dev_id, revision_count=1, started_hours_ago=4
),
]
)
db.add(
_spawn(
obs_setup["dev_slug"],
task_id=None,
cost=1.25,
tokens_in=1000,
tokens_out=500,
)
)
await db.flush()
card = await obs_setup["svc"].get_scorecard(agent_id=dev_id, days=7)
assert card is not None
assert card.scope == "agent"
assert card.tasks_completed == _EXPECTED_TASKS
assert card.rework_rate == pytest.approx(0.5)
assert card.tokens == _EXPECTED_TOKENS
assert card.cost_usd == pytest.approx(1.25)
cell = await obs_setup["svc"].get_scorecard(team=Team.BACKEND, days=7)
assert cell is not None
assert cell.scope == "cell"
assert cell.tasks_completed == _EXPECTED_TASKS
assert await obs_setup["svc"].get_scorecard(agent_id=uuid4()) is None
@@ -0,0 +1,43 @@
"""0.10.0 observability: tasks.revision_count + the audit_log query index.
Migration 045 adds ``tasks.revision_count`` (the O(1) rework counter —
forward-only, existing rows default to 0) and the composite index
``audit_log(target_id, event_type, timestamp)`` that powers the cycle-time and
rework reconstruction queries. The real upgrade/downgrade chain is verified
separately against a throwaway Postgres (see project migration-verification
discipline); these assertions guard the resulting schema shape.
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
@pytest.mark.asyncio
async def test_revision_count_defaults_to_zero(db_session) -> None: # type: ignore[no-untyped-def]
result = await db_session.execute(
text(
"SELECT column_default, is_nullable "
"FROM information_schema.columns "
"WHERE table_name = 'tasks' AND column_name = 'revision_count'"
)
)
row = result.first()
assert row is not None, "tasks.revision_count column must exist"
assert row[1] == "NO", "revision_count must be NOT NULL"
assert "0" in (row[0] or ""), "revision_count must default to 0"
@pytest.mark.asyncio
async def test_audit_log_query_index_exists(db_session) -> None: # type: ignore[no-untyped-def]
result = await db_session.execute(
text(
"SELECT indexname FROM pg_indexes "
"WHERE tablename = 'audit_log' "
"AND indexname = 'ix_audit_log_target_event_ts'"
)
)
assert result.first() is not None, (
"composite index ix_audit_log_target_event_ts must exist on audit_log"
)
@@ -536,6 +536,32 @@ async def test_fail_qa_reassigns_to_original_developer(
assert failed.assigned_to == dev_id
@pytest.mark.asyncio
async def test_fail_qa_increments_revision_count(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Each QA bounce to needs_revision increments the O(1) rework counter."""
svc = task_setup["svc"]
dev_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.orchestration_markers = {"original_developer": str(dev_id)}
await db_session.flush()
assert task.revision_count == 0
failed = await svc.fail_qa(task.id, notes="missing tests")
assert failed is not None
assert failed.revision_count == 1
count_after_first = failed.revision_count
# A second QA cycle bumps it again (once per transition into needs_revision).
failed.status = TaskStatus.AWAITING_QA
await db_session.flush()
again = await svc.fail_qa(task.id, notes="still missing")
assert again is not None
assert again.revision_count == count_after_first + 1
@pytest.mark.asyncio
async def test_fail_qa_with_no_original_dev_unassigns(
task_setup: dict, db_session: AsyncSession