mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
283 lines
9.2 KiB
Python
283 lines
9.2 KiB
Python
"""Coroner engine: EVENT-triggered autopsy origination, deduped, never
|
|||
|
|
authors content itself, and (unlike Pest Control) org-scoped — no
|
||
|
|
per-project opt-in gate. Mirrors test_pest_control_engine.py's shape.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import TYPE_CHECKING, cast
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from roboco.db.tables import (
|
||
|
|
AgentTable,
|
||
|
|
AuditLogTable,
|
||
|
|
BoardProgramCycleTable,
|
||
|
|
ProjectTable,
|
||
|
|
SystemSettingTable,
|
||
|
|
TaskReviewFindingTable,
|
||
|
|
TaskTable,
|
||
|
|
)
|
||
|
|
from roboco.foundation import identity as _foundation
|
||
|
|
from roboco.foundation.policy.content import markers
|
||
|
|
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
|
||
|
|
from roboco.models.base import TaskNature as TN
|
||
|
|
from roboco.models.base import TaskStatus as TS
|
||
|
|
from roboco.models.base import TaskType as TT
|
||
|
|
from roboco.services.coroner_engine import CoronerEngine
|
||
|
|
from roboco.services.task import (
|
||
|
|
CORONER_SOURCE,
|
||
|
|
PEST_CONTROL_SOURCE,
|
||
|
|
ROADMAP_SOURCE,
|
||
|
|
X_FEATURE_EXPLORATION_SOURCE,
|
||
|
|
TaskCreateRequest,
|
||
|
|
get_task_service,
|
||
|
|
)
|
||
|
|
from sqlalchemy import delete, select, update
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||
|
|
AUDITOR_UUID = _foundation.AGENTS["auditor"].uuid
|
||
|
|
SLUG = "backend-svc"
|
||
|
|
ONE = 1
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture(autouse=True)
|
||
|
|
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
|
||
|
|
"""See test_board_program_engine.py's identical fixture: Board Program
|
||
|
|
settings-store rows / ledger rows / open exploration tasks are shared,
|
||
|
|
cross-test-persistent DB state. Purge before every test in this file."""
|
||
|
|
await db_session.execute(
|
||
|
|
delete(SystemSettingTable).where(SystemSettingTable.key.like("board_program.%"))
|
||
|
|
)
|
||
|
|
await db_session.execute(delete(BoardProgramCycleTable))
|
||
|
|
await db_session.execute(
|
||
|
|
update(TaskTable)
|
||
|
|
.where(
|
||
|
|
TaskTable.source.in_(
|
||
|
|
[
|
||
|
|
ROADMAP_SOURCE,
|
||
|
|
X_FEATURE_EXPLORATION_SOURCE,
|
||
|
|
PEST_CONTROL_SOURCE,
|
||
|
|
CORONER_SOURCE,
|
||
|
|
]
|
||
|
|
),
|
||
|
|
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
|
||
|
|
)
|
||
|
|
.values(status=TS.CANCELLED)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
|
||
|
|
async def _seed(session: AsyncSession) -> ProjectTable:
|
||
|
|
for uuid, slug, role, team in (
|
||
|
|
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||
|
|
(AUDITOR_UUID, "auditor", AgentRole.AUDITOR, Team.BOARD),
|
||
|
|
):
|
||
|
|
if await session.get(AgentTable, uuid) is None:
|
||
|
|
session.add(
|
||
|
|
AgentTable(
|
||
|
|
id=uuid,
|
||
|
|
name=slug,
|
||
|
|
slug=slug,
|
||
|
|
role=role,
|
||
|
|
team=team,
|
||
|
|
status=AgentStatus.ACTIVE,
|
||
|
|
model_config={},
|
||
|
|
system_prompt="x",
|
||
|
|
capabilities=[],
|
||
|
|
permissions={},
|
||
|
|
metrics={},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await session.flush()
|
||
|
|
project = ProjectTable(
|
||
|
|
name="Backend Service",
|
||
|
|
slug=SLUG,
|
||
|
|
git_url="https://github.com/x/backend-svc.git",
|
||
|
|
default_branch="master",
|
||
|
|
protected_branches=["master"],
|
||
|
|
assigned_cell=Team.BACKEND,
|
||
|
|
created_by=SYSTEM_UUID,
|
||
|
|
is_active=True,
|
||
|
|
)
|
||
|
|
session.add(project)
|
||
|
|
await session.flush()
|
||
|
|
return project
|
||
|
|
|
||
|
|
|
||
|
|
async def _incident(session: AsyncSession, project: ProjectTable) -> TaskTable:
|
||
|
|
"""A raw delivery task standing in for a bounced/cancelled/budget-blocked
|
||
|
|
incident — created PENDING, then raw-set to a terminal-ish status (never
|
||
|
|
routed through the transition validator, mirroring test_board_program_
|
||
|
|
engine.py's ``_make_exploration`` helper), since CoronerEngine only reads
|
||
|
|
the task, never transitions it."""
|
||
|
|
task = await get_task_service(session).create(
|
||
|
|
TaskCreateRequest(
|
||
|
|
title="Chronic task",
|
||
|
|
description="Bounced repeatedly",
|
||
|
|
acceptance_criteria=["it works"],
|
||
|
|
team=Team.BACKEND,
|
||
|
|
created_by=SYSTEM_UUID,
|
||
|
|
task_type=TT.CODE,
|
||
|
|
nature=TN.TECHNICAL,
|
||
|
|
estimated_complexity=Complexity.LOW,
|
||
|
|
project_id=cast("UUID", project.id),
|
||
|
|
status=TS.PENDING,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
task.status = TS.NEEDS_REVISION
|
||
|
|
task.revision_count = 3
|
||
|
|
await session.flush()
|
||
|
|
return task
|
||
|
|
|
||
|
|
|
||
|
|
def _arm(session: AsyncSession) -> None:
|
||
|
|
session.add(SystemSettingTable(key="board_program.coroner.enabled", value="true"))
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_disabled_creates_no_cycle(db_session: AsyncSession) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
incident = await _incident(db_session, project)
|
||
|
|
await db_session.flush()
|
||
|
|
engine = CoronerEngine(db_session)
|
||
|
|
assert (
|
||
|
|
await engine.open_for_incident(cast("UUID", incident.id), kind="bounced")
|
||
|
|
is None
|
||
|
|
)
|
||
|
|
assert await get_task_service(db_session).list_open_coroner_cycles() == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_enabled_originates_held_autopsy_task(db_session: AsyncSession) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
incident = await _incident(db_session, project)
|
||
|
|
_arm(db_session)
|
||
|
|
await db_session.flush()
|
||
|
|
engine = CoronerEngine(db_session)
|
||
|
|
task = await engine.open_for_incident(cast("UUID", incident.id), kind="bounced")
|
||
|
|
assert task is not None
|
||
|
|
assert task.source == CORONER_SOURCE
|
||
|
|
assert task.status == TS.PENDING
|
||
|
|
assert task.assigned_to == AUDITOR_UUID
|
||
|
|
assert task.confirmed_by_human is False
|
||
|
|
ref = markers.get_coroner_incident(task)
|
||
|
|
assert ref is not None
|
||
|
|
assert ref["incident_task_id"] == str(incident.id)
|
||
|
|
assert ref["kind"] == "bounced"
|
||
|
|
|
||
|
|
rows = (await db_session.execute(select(BoardProgramCycleTable))).scalars().all()
|
||
|
|
assert len(rows) == ONE
|
||
|
|
assert rows[0].program_key == "coroner"
|
||
|
|
assert rows[0].exploration_task_id == task.id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_open_autopsy_blocks_a_second_incident(db_session: AsyncSession) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
first_incident = await _incident(db_session, project)
|
||
|
|
second_incident = await _incident(db_session, project)
|
||
|
|
_arm(db_session)
|
||
|
|
await db_session.flush()
|
||
|
|
engine = CoronerEngine(db_session)
|
||
|
|
first_task = await engine.open_for_incident(
|
||
|
|
cast("UUID", first_incident.id), kind="bounced"
|
||
|
|
)
|
||
|
|
assert first_task is not None
|
||
|
|
|
||
|
|
second_task = await engine.open_for_incident(
|
||
|
|
cast("UUID", second_incident.id), kind="cancelled"
|
||
|
|
)
|
||
|
|
assert second_task is None
|
||
|
|
cycles = await get_task_service(db_session).list_open_coroner_cycles()
|
||
|
|
assert len(cycles) == ONE
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unresolvable_incident_returns_none(db_session: AsyncSession) -> None:
|
||
|
|
await _seed(db_session)
|
||
|
|
_arm(db_session)
|
||
|
|
await db_session.flush()
|
||
|
|
engine = CoronerEngine(db_session)
|
||
|
|
assert await engine.open_for_incident(uuid4(), kind="bounced") is None
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_incident_context_renders_findings_and_transitions(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
incident = await _incident(db_session, project)
|
||
|
|
await db_session.flush()
|
||
|
|
db_session.add(
|
||
|
|
TaskReviewFindingTable(
|
||
|
|
task_id=incident.id,
|
||
|
|
origin="qa",
|
||
|
|
round=1,
|
||
|
|
author_slug="be-qa",
|
||
|
|
file="roboco/app.py",
|
||
|
|
line=42,
|
||
|
|
severity="blocker",
|
||
|
|
criterion="AC 1",
|
||
|
|
expected="handles the edge case",
|
||
|
|
actual="raises",
|
||
|
|
fix="add a guard",
|
||
|
|
evidence="repro steps",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db_session.add(
|
||
|
|
AuditLogTable(
|
||
|
|
event_type="task.needs_revision",
|
||
|
|
target_type="task",
|
||
|
|
target_id=incident.id,
|
||
|
|
severity="info",
|
||
|
|
details={},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
engine = CoronerEngine(db_session)
|
||
|
|
context = await engine.incident_context(cast("UUID", incident.id))
|
||
|
|
assert "roboco/app.py:42" in context
|
||
|
|
assert "AC 1" in context
|
||
|
|
assert "task.needs_revision" in context
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_incident_context_empty_with_no_history(db_session: AsyncSession) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
incident = await _incident(db_session, project)
|
||
|
|
await db_session.flush()
|
||
|
|
engine = CoronerEngine(db_session)
|
||
|
|
assert await engine.incident_context(cast("UUID", incident.id)) == ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_complete_with_postmortem_completes_the_task(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
incident = await _incident(db_session, project)
|
||
|
|
_arm(db_session)
|
||
|
|
await db_session.flush()
|
||
|
|
engine = CoronerEngine(db_session)
|
||
|
|
task = await engine.open_for_incident(cast("UUID", incident.id), kind="bounced")
|
||
|
|
assert task is not None
|
||
|
|
|
||
|
|
payload = {
|
||
|
|
"incident_summary": "it bounced repeatedly",
|
||
|
|
"root_cause": "no venv-freshness check",
|
||
|
|
"failed_stage": "awaiting_qa",
|
||
|
|
"process_change": {"kind": "conventions_rule", "description": "add a check"},
|
||
|
|
"playbook_id": None,
|
||
|
|
}
|
||
|
|
await engine.complete_with_postmortem(task, payload)
|
||
|
|
assert task.status == TS.COMPLETED
|
||
|
|
assert markers.get_coroner_postmortem(task) == payload
|