2026-07-01 05:18:45 +02:00
|
|
|
"""get_task_metrics — granular per-task effort against a real Postgres.
|
|
|
|
|
|
|
|
|
|
Seeds a task's audit-log journey + agent spawn stints (with turns/tool_calls/
|
|
|
|
|
tokens/cost) + named qa/pr fail events, then asserts the composed metrics:
|
|
|
|
|
summed effort vs wall-clock, turns/tool_calls/tokens/cost, per-stage
|
|
|
|
|
active-vs-wait, and who-caused-rework.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from roboco.db.tables import (
|
|
|
|
|
AgentSpawnSessionTable,
|
|
|
|
|
AgentTable,
|
|
|
|
|
AuditLogTable,
|
|
|
|
|
ProjectTable,
|
2026-07-11 22:54:42 +02:00
|
|
|
TaskReviewFindingTable,
|
2026-07-01 05:18:45 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sec(n: int) -> datetime:
|
|
|
|
|
return _T0 + timedelta(seconds=n)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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={},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Usage(NamedTuple):
|
|
|
|
|
turns: int
|
|
|
|
|
tool_calls: int
|
|
|
|
|
tokens_in: int
|
|
|
|
|
tokens_out: int
|
|
|
|
|
cost: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _spawn(
|
|
|
|
|
task_id: str,
|
|
|
|
|
started: datetime,
|
|
|
|
|
ended: datetime | None,
|
|
|
|
|
usage: _Usage,
|
|
|
|
|
) -> AgentSpawnSessionTable:
|
|
|
|
|
return AgentSpawnSessionTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
agent_slug="be-dev-1",
|
|
|
|
|
team="backend",
|
|
|
|
|
role="developer",
|
|
|
|
|
model="claude",
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
started_at=started,
|
|
|
|
|
ended_at=ended,
|
|
|
|
|
turns=usage.turns,
|
|
|
|
|
tool_calls=usage.tool_calls,
|
|
|
|
|
tokens_input=usage.tokens_in,
|
|
|
|
|
tokens_output=usage.tokens_out,
|
|
|
|
|
estimated_cost_usd=usage.cost,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def setup(db_session: AsyncSession) -> AsyncIterator[dict]:
|
|
|
|
|
dev = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
|
|
|
|
|
qa = _agent(AgentRole.QA, 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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_returns_none_for_missing_task(setup: dict) -> None:
|
|
|
|
|
assert await setup["svc"].get_task_metrics(uuid4()) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_composes_effort_turns_stages_and_rework(setup: dict) -> None:
|
|
|
|
|
db = setup["db"]
|
|
|
|
|
tid = uuid4()
|
|
|
|
|
db.add(
|
|
|
|
|
TaskTable(
|
|
|
|
|
id=tid,
|
|
|
|
|
title="t",
|
|
|
|
|
description="d",
|
|
|
|
|
acceptance_criteria=["ac"],
|
|
|
|
|
task_type=TaskType.CODE,
|
|
|
|
|
nature=TaskNature.TECHNICAL,
|
|
|
|
|
status=TaskStatus.COMPLETED,
|
|
|
|
|
team=Team.BACKEND,
|
|
|
|
|
project_id=setup["project_id"],
|
|
|
|
|
created_by=setup["dev_id"],
|
|
|
|
|
assigned_to=setup["dev_id"],
|
|
|
|
|
revision_count=2,
|
|
|
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
|
|
|
started_at=_T0,
|
|
|
|
|
completed_at=_sec(7200),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
db.add_all(
|
|
|
|
|
[
|
|
|
|
|
_audit(tid, "claimed", _T0),
|
|
|
|
|
_audit(tid, "in_progress", _sec(60)),
|
|
|
|
|
_audit(tid, "awaiting_qa", _sec(3660)),
|
|
|
|
|
_audit(tid, "completed", _sec(7200)),
|
|
|
|
|
_audit(tid, "needs_revision", _sec(3660), event_type="task.qa_fail"),
|
|
|
|
|
_audit(tid, "needs_revision", _sec(3000), event_type="task.pr_fail"),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
db.add_all(
|
|
|
|
|
[
|
|
|
|
|
_spawn(str(tid), _T0, _sec(600), _Usage(5, 10, 100, 50, 1.0)),
|
|
|
|
|
_spawn(str(tid), _sec(3600), _sec(3660), _Usage(3, 4, 20, 10, 0.5)),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
m = await setup["svc"].get_task_metrics(tid)
|
|
|
|
|
assert m is not None
|
|
|
|
|
# summed effort (600 + 60) vs wall-clock (2h).
|
|
|
|
|
expected_active_s = 660
|
|
|
|
|
expected_wall_s = 7200
|
|
|
|
|
assert m.active_runtime_seconds == expected_active_s
|
|
|
|
|
assert m.wall_clock_seconds == expected_wall_s
|
|
|
|
|
assert (m.turns, m.tool_calls, m.tokens) == (8, 14, 180)
|
|
|
|
|
assert m.cost_usd == pytest.approx(1.5)
|
|
|
|
|
assert (m.revision_count, m.qa_fails, m.pr_fails, m.stints) == (2, 1, 1, 2)
|
|
|
|
|
|
|
|
|
|
stages = {s.status: s for s in m.stages}
|
|
|
|
|
# claimed [0,60): stint1 covers it fully.
|
|
|
|
|
assert (stages["claimed"].active_seconds, stages["claimed"].wait_seconds) == (60, 0)
|
|
|
|
|
# in_progress [60,3660): stint1 60..600 (540) + stint2 3600..3660 (60) = 600 active.
|
|
|
|
|
assert (
|
|
|
|
|
stages["in_progress"].active_seconds,
|
|
|
|
|
stages["in_progress"].wait_seconds,
|
|
|
|
|
) == (600, 3000)
|
|
|
|
|
# awaiting_qa [3660,7200): no stint running -> all wait.
|
|
|
|
|
assert (
|
|
|
|
|
stages["awaiting_qa"].active_seconds,
|
|
|
|
|
stages["awaiting_qa"].wait_seconds,
|
|
|
|
|
) == (0, 3540)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_in_flight_open_stint_and_open_window_decompose(setup: dict) -> None:
|
|
|
|
|
db = setup["db"]
|
|
|
|
|
tid = uuid4()
|
|
|
|
|
db.add(
|
|
|
|
|
TaskTable(
|
|
|
|
|
id=tid,
|
|
|
|
|
title="t",
|
|
|
|
|
description="d",
|
|
|
|
|
acceptance_criteria=["ac"],
|
|
|
|
|
task_type=TaskType.CODE,
|
|
|
|
|
nature=TaskNature.TECHNICAL,
|
|
|
|
|
status=TaskStatus.IN_PROGRESS,
|
|
|
|
|
team=Team.BACKEND,
|
|
|
|
|
project_id=setup["project_id"],
|
|
|
|
|
created_by=setup["dev_id"],
|
|
|
|
|
assigned_to=setup["dev_id"],
|
|
|
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
|
|
|
started_at=_T0,
|
|
|
|
|
completed_at=None,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
db.add_all([_audit(tid, "claimed", _T0), _audit(tid, "in_progress", _sec(60))])
|
|
|
|
|
# An OPEN stint (ended_at=None) -> runs to now.
|
|
|
|
|
db.add(_spawn(str(tid), _T0, None, _Usage(2, 3, 1, 1, 0.1)))
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
m = await setup["svc"].get_task_metrics(tid)
|
|
|
|
|
assert m is not None
|
|
|
|
|
assert m.stints == 1
|
|
|
|
|
assert m.active_runtime_seconds > 0 # open stint ran to now
|
|
|
|
|
assert m.wall_clock_seconds > 0 # open task -> now
|
|
|
|
|
# The open final window (in_progress) still decomposes.
|
|
|
|
|
assert "in_progress" in {s.status for s in m.stages}
|
2026-07-11 22:54:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_task_metrics_includes_pm_ceo_rejects_and_findings_counts(
|
|
|
|
|
setup: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""pm_rejects/ceo_rejects mirror qa_fails/pr_fails for the other two named
|
|
|
|
|
bounce events; findings_open/findings_total read the revision-findings
|
|
|
|
|
ledger (open vs total rows) for this task."""
|
|
|
|
|
db = setup["db"]
|
|
|
|
|
tid = uuid4()
|
|
|
|
|
db.add(
|
|
|
|
|
TaskTable(
|
|
|
|
|
id=tid,
|
|
|
|
|
title="t",
|
|
|
|
|
description="d",
|
|
|
|
|
acceptance_criteria=["ac"],
|
|
|
|
|
task_type=TaskType.CODE,
|
|
|
|
|
nature=TaskNature.TECHNICAL,
|
|
|
|
|
status=TaskStatus.NEEDS_REVISION,
|
|
|
|
|
team=Team.BACKEND,
|
|
|
|
|
project_id=setup["project_id"],
|
|
|
|
|
created_by=setup["dev_id"],
|
|
|
|
|
assigned_to=setup["dev_id"],
|
|
|
|
|
revision_count=2,
|
|
|
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
|
|
|
started_at=_T0,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
db.add_all(
|
|
|
|
|
[
|
|
|
|
|
_audit(tid, "claimed", _T0),
|
|
|
|
|
_audit(tid, "needs_revision", _sec(60), event_type="task.request_changes"),
|
|
|
|
|
_audit(tid, "needs_revision", _sec(120), event_type="task.ceo_reject"),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
# The ledger's task_id carries a real FK (unlike audit_log.target_id /
|
|
|
|
|
# spawn_session.task_id, both plain columns) — the referenced task must
|
|
|
|
|
# be flushed first.
|
|
|
|
|
await db.flush()
|
|
|
|
|
db.add_all(
|
|
|
|
|
[
|
|
|
|
|
TaskReviewFindingTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
task_id=tid,
|
|
|
|
|
origin="qa",
|
|
|
|
|
round=1,
|
|
|
|
|
author_slug="be-qa",
|
|
|
|
|
severity="major",
|
|
|
|
|
expected="x",
|
|
|
|
|
actual="y",
|
|
|
|
|
status="verified",
|
|
|
|
|
),
|
|
|
|
|
TaskReviewFindingTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
task_id=tid,
|
|
|
|
|
origin="pm",
|
|
|
|
|
round=2,
|
|
|
|
|
author_slug="be-pm",
|
|
|
|
|
severity="blocker",
|
|
|
|
|
expected="x",
|
|
|
|
|
actual="y",
|
|
|
|
|
status="open",
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
m = await setup["svc"].get_task_metrics(tid)
|
|
|
|
|
assert m is not None
|
|
|
|
|
assert (m.pm_rejects, m.ceo_rejects) == (1, 1)
|
|
|
|
|
assert (m.findings_open, m.findings_total) == (1, 2)
|