Files
roboco/tests/integration/test_metrics_scorecards.py
T

317 lines
10 KiB
Python
Raw Normal View History

"""Member / org rollup scorecards + the live in-flight overlay (real PG).
Seeds member_performance_daily rows (the rollup source) and asserts the derived
rates (FPY, effort-throughput, turns/task, qa pass-rate, utilization), the live
in-flight overlay (enriches effort but not completion counts — disjoint by
status), and the division guards.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import (
AgentSpawnSessionTable,
AgentTable,
MemberPerformanceDailyTable,
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 uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
_TODAY = datetime.now(UTC).date()
_TOTAL_COMPLETED = 3
_ROLLUP_COMPLETED = 2
_OVERLAY_TURNS = 3
_ROLLUP_ONLY_TURNS = 5 # closed session already in the rollup, not re-added
_ORG_MEMBERS = 2
_ORG_COMPLETED = 3
def _daily(slug: str, **over: Any) -> MemberPerformanceDailyTable:
base: dict[str, Any] = {
"id": uuid4(),
"date": _TODAY,
"member_kind": "agent",
"agent_slug": slug,
"team": Team.BACKEND.value,
"role": "developer",
}
base.update(over)
return MemberPerformanceDailyTable(**base)
def _agent(role: AgentRole, slug: str) -> AgentTable:
return AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
@pytest_asyncio.fixture
async def svc(db_session: AsyncSession) -> AsyncIterator[MetricsService]:
yield MetricsService(db_session)
@pytest.mark.asyncio
async def test_member_scorecard_rollup_and_derived(
svc: MetricsService, db_session: AsyncSession
) -> None:
dev = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
db_session.add(dev)
await db_session.flush()
db_session.add_all(
[
_daily(
dev.slug,
tasks_completed=2,
tasks_first_pass=1,
active_runtime_seconds=1800,
turns=6,
tool_calls=12,
tokens=100,
cost_usd=1.0,
qa_reviews_total=3,
qa_reviews_passed=2,
escalations=1,
blocked_others=1,
idle_seconds=600,
revisions_caused=1,
revisions_received=1,
),
_daily(
dev.slug,
date=_TODAY - timedelta(days=1),
tasks_completed=1,
tasks_first_pass=1,
active_runtime_seconds=1800,
turns=4,
tool_calls=8,
tokens=50,
cost_usd=0.5,
qa_reviews_total=2,
qa_reviews_passed=2,
idle_seconds=1200,
),
]
)
await db_session.flush()
card = await svc.get_member_scorecard(cast("UUID", dev.id), days=30)
assert card is not None
assert card.tasks_completed == _TOTAL_COMPLETED
assert card.first_pass_yield == pytest.approx(2 / 3, abs=1e-4) # 2 of 3
# 3 tasks over 3600s = 1h -> 3.0/hr.
assert card.effort_throughput_per_hour == pytest.approx(3.0)
assert (card.turns, card.tool_calls) == (10, 20)
assert card.turns_per_task == pytest.approx(10 / 3, abs=1e-4)
assert card.qa_pass_rate == pytest.approx(4 / 5, abs=1e-4) # 4 of 5
assert card.escalations == 1
assert card.blocked_others == 1
# util = 3600 active / (3600 + 1800 idle) = 0.6667.
assert card.utilization == pytest.approx(3600 / 5400, abs=1e-4)
assert card.includes_live_inflight is False
@pytest.mark.asyncio
async def test_live_overlay_enriches_effort_not_completion(
svc: MetricsService, db_session: AsyncSession
) -> None:
dev = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
db_session.add(dev)
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()
db_session.add(_daily(dev.slug, tasks_completed=2, active_runtime_seconds=100))
# A non-terminal (in-flight) task with a spawn stint -> overlay effort.
inflight = TaskTable(
id=uuid4(),
title="t",
description="d",
acceptance_criteria=["ac"],
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
status=TaskStatus.IN_PROGRESS,
team=Team.BACKEND,
project_id=project.id,
created_by=dev.id,
assigned_to=dev.id,
estimated_complexity=Complexity.MEDIUM,
started_at=datetime.now(UTC) - timedelta(hours=1),
)
db_session.add(inflight)
await db_session.flush()
now = datetime.now(UTC)
# OPEN (still-running) session — the live delta the rollup cannot hold yet.
db_session.add(
AgentSpawnSessionTable(
id=uuid4(),
agent_slug=dev.slug,
team="backend",
role="developer",
model="claude",
task_id=str(inflight.id),
started_at=now - timedelta(seconds=200),
ended_at=None,
turns=3,
tool_calls=4,
tokens_input=10,
tokens_output=5,
estimated_cost_usd=0.2,
)
)
await db_session.flush()
card = await svc.get_member_scorecard(cast("UUID", dev.id), days=30)
assert card is not None
assert card.tasks_completed == _ROLLUP_COMPLETED # in-flight NOT completed
assert card.includes_live_inflight is True
assert card.active_runtime_hours > 100 / 3600 # rollup + overlay effort
assert card.turns == _OVERLAY_TURNS # from the overlay (rollup row had 0)
@pytest.mark.asyncio
async def test_live_overlay_excludes_closed_session_no_double_count(
svc: MetricsService, db_session: AsyncSession
) -> None:
"""A CLOSED session on a non-terminal task is already in the daily rollup
(via _msweep_spawn, which counts ended_at IS NOT NULL). The overlay must NOT
re-add it, or the member's effort/turns double-count on the common
reap/respawn path."""
dev = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
db_session.add(dev)
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()
# Rollup row already reflects the closed session (turns=5, active=300s).
db_session.add(
_daily(dev.slug, tasks_completed=0, active_runtime_seconds=300, turns=5)
)
inflight = TaskTable(
id=uuid4(),
title="t",
description="d",
acceptance_criteria=["ac"],
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
status=TaskStatus.IN_PROGRESS,
team=Team.BACKEND,
project_id=project.id,
created_by=dev.id,
assigned_to=dev.id,
estimated_complexity=Complexity.MEDIUM,
started_at=datetime.now(UTC) - timedelta(hours=1),
)
db_session.add(inflight)
await db_session.flush()
now = datetime.now(UTC)
db_session.add(
AgentSpawnSessionTable(
id=uuid4(),
agent_slug=dev.slug,
team="backend",
role="developer",
model="claude",
task_id=str(inflight.id),
started_at=now - timedelta(seconds=300),
ended_at=now, # CLOSED — already counted by the rollup
turns=5,
tool_calls=4,
tokens_input=10,
tokens_output=5,
estimated_cost_usd=0.2,
)
)
await db_session.flush()
card = await svc.get_member_scorecard(cast("UUID", dev.id), days=30)
assert card is not None
assert card.turns == _ROLLUP_ONLY_TURNS # closed session is NOT re-added
assert card.active_runtime_hours == pytest.approx(300 / 3600, abs=1e-4)
assert card.includes_live_inflight is False # no OPEN session
@pytest.mark.asyncio
async def test_member_scorecard_404_and_guards(
svc: MetricsService, db_session: AsyncSession
) -> None:
assert await svc.get_member_scorecard(uuid4()) is None
# An agent with no rollup rows: division guards -> None, no crash.
dev = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
db_session.add(dev)
await db_session.flush()
card = await svc.get_member_scorecard(cast("UUID", dev.id), days=30)
assert card is not None
assert card.tasks_completed == 0
assert card.first_pass_yield is None
assert card.effort_throughput_per_hour is None
assert card.qa_pass_rate is None
assert card.utilization is None
@pytest.mark.asyncio
async def test_org_scorecard_aggregates_members(
svc: MetricsService, db_session: AsyncSession
) -> None:
s1, s2 = f"be-dev-{uuid4().hex[:6]}", f"be-dev-{uuid4().hex[:6]}"
db_session.add_all(
[
_daily(
s1, tasks_completed=2, tasks_first_pass=2, active_runtime_seconds=3600
),
_daily(
s2, tasks_completed=1, tasks_first_pass=0, active_runtime_seconds=3600
),
]
)
await db_session.flush()
org = await svc.get_org_scorecard(team=Team.BACKEND, days=30)
assert org.scope == "team"
assert org.member_count == _ORG_MEMBERS
assert org.tasks_completed == _ORG_COMPLETED
assert org.first_pass_yield == pytest.approx(2 / 3, abs=1e-4)