Files
roboco/tests/unit/services/test_coroner_service.py
a7b970a3b2 feat(board): materialize program items as Main-PM roots, make reports actionable (#711)
Two coupled gaps in the Board Program output path.

Approved items were created unowned and in BACKLOG. Nothing dispatches
BACKLOG, and once activated a cell PM claimed the parentless task as a root,
where _cell_pm_complete resolves its merge target through
resolve_parent_branch — which for a parentless task falls through to the
project head rung. The result was a cell branch merging straight into the
trunk, bypassing the Main-PM root, the root->master PR and the CEO gate
(live: PRs #703 and #704 both targeted slave directly).

All eight materializers now create a PENDING, main-pm-assigned root with
team=Team.MAIN_PM, matching what approve_and_start does for an intake draft.
The team is load-bearing, not cosmetic: _next_hint_pr_fail,
_deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer
label all key on it, and a cell-teamed root drops the 'do NOT re-submit the
root' steer that exists because of PR #138's infinite pr_fail loop. The
item's own cell survives as a delegation hint in the description, which is
what the Main PM's briefing renders.

Periscope, Sentinel and Coroner produced artifacts with no way to act on
them — three panel surfaces carried explicit 'no approve/reject UI' comments
while each item already held a machine-readable suggested action. They now
have per-item approve and dismiss, modelled on the roadmap queue: idempotent
per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check
still fires, and every decision recorded through record_decision so it
reaches the next cycle's prompt. Approving materializes through the same
corrected Main-PM-owned path.

Target project resolves to each engine's own existing anchor — RoboCo's
project for Periscope and Sentinel, the incident's project for Coroner — and
fails with a clean invalid_state naming what is unresolvable rather than
guessing at a repo.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-26 20:01:24 +02:00

517 lines
18 KiB
Python

"""CoronerService coverage: approve materializes the postmortem's ONE
process change as a Main-PM-owned root task (idempotent), reject records a
reason (idempotent). Unlike Periscope/Sentinel there is no item id — a
postmortem is one process change, not a list — and the target project
resolves against the INCIDENT task's own project (falling back to RoboCo's),
not a per-item project_slug.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.config import settings as cfg
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
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 import board_programs as bp_module
from roboco.services.coroner_service import CoronerService, get_coroner_service
from roboco.services.task import CORONER_ITEM_SOURCE, CORONER_SOURCE
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
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ROBOCO_SLUG = "roboco-standin"
INCIDENT_SLUG = "customer-app"
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."""
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 == CORONER_SOURCE,
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
)
.values(status=TS.CANCELLED)
)
await db_session.commit()
def _process_change(
*, kind: str = "conventions_rule", status: str | None = "proposed"
) -> dict:
change: dict[str, Any] = {
"kind": kind,
"description": "Add a venv-freshness check to make quality",
}
if status is not None:
change["status"] = status
change["reject_reason"] = None
change["materialized_task_id"] = None
return change
async def _seed_agents(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(AUDITOR_UUID, "auditor", AgentRole.AUDITOR, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
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()
async def _seed_roboco_project(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> ProjectTable:
await _seed_agents(session)
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=ROBOCO_SLUG,
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
monkeypatch.setattr(cfg, "self_heal_project_slug", ROBOCO_SLUG)
return project
async def _seed_incident(session: AsyncSession, *, project: ProjectTable) -> TaskTable:
"""A real incident task on its OWN project/team — distinct from any
RoboCo fallback project, so a test asserting the incident's project/team
wins can't accidentally pass via the fallback instead."""
await _seed_agents(session)
incident = TaskTable(
id=uuid4(),
title="Fix worktree venv rot",
description="x",
acceptance_criteria=["x"],
status=TS.NEEDS_REVISION,
priority=2,
task_type=TT.CODE,
nature=TN.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
source="manual",
confirmed_by_human=True,
project_id=project.id,
revision_count=3,
)
session.add(incident)
await session.flush()
return incident
async def _seed_incident_project(session: AsyncSession) -> ProjectTable:
await _seed_agents(session)
project = ProjectTable(
id=uuid4(),
name="Customer App",
slug=INCIDENT_SLUG,
git_url="https://example.com/customer-app.git",
assigned_cell=Team.FRONTEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
return project
async def _seed_postmortem(
session: AsyncSession,
*,
incident: TaskTable | None,
process_change: dict | None = None,
postmortem_project_id: object | None = None,
) -> TaskTable:
await _seed_agents(session)
task = TaskTable(
id=uuid4(),
title="Coroner postmortem",
description="Autopsy the chronic task.",
acceptance_criteria=["propose_postmortem() called once"],
status=TS.COMPLETED,
priority=2,
task_type=TT.ADMINISTRATIVE,
nature=TN.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
created_by=SYSTEM_UUID,
assigned_to=AUDITOR_UUID,
team=Team.BOARD,
source=CORONER_SOURCE,
confirmed_by_human=False,
project_id=postmortem_project_id,
)
session.add(task)
await session.flush()
if incident is not None:
markers.set_coroner_incident(
task,
{
"incident_task_id": str(incident.id),
"kind": "bounced",
"revision_count": incident.revision_count or 0,
"title": incident.title,
},
)
markers.set_coroner_postmortem(
task,
{
"incident_summary": "the task bounced 3 times over a stale venv",
"root_cause": "the gate never verified the venv's dev extras",
"failed_stage": "awaiting_qa",
"process_change": process_change or _process_change(),
"playbook_id": None,
},
)
await session.flush()
return task
def _svc(session: AsyncSession) -> CoronerService:
return get_coroner_service(session)
def _id(task: TaskTable) -> UUID:
return cast("UUID", task.id)
@pytest.mark.asyncio
async def test_approve_materializes_main_pm_owned_task_on_incident_project(
db_session: AsyncSession,
) -> None:
"""The target project/team is the INCIDENT's own — not the postmortem
task's own project_id (left None here) and not a RoboCo fallback."""
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
assert result.materialized_task_id is not None
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == CORONER_ITEM_SOURCE
assert materialized.project_id == incident_project.id
# team is forced to Team.MAIN_PM (not the incident's own cell) — see
# test_roadmap_service.py's identical assertion for why: every "is this
# a coordination root" consumer keys on team, not assigned_to.
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task — see test_roadmap_service.py's
# identical assertion for the coercion rationale.
assert materialized.task_type == TT.PLANNING
# The incident's own cell (Team.FRONTEND) survives as a Notes delegation
# hint instead of the materialized task's team column.
assert "frontend cell" in (materialized.description or "")
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_coroner_postmortem(task)
assert payload is not None
assert payload["process_change"]["status"] == "approved"
assert payload["process_change"]["materialized_task_id"] == str(
result.materialized_task_id
)
assert task.status == TS.COMPLETED
@pytest.mark.asyncio
async def test_approve_falls_back_to_roboco_project_when_incident_gone(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
roboco_project = await _seed_roboco_project(db_session, monkeypatch)
# coroner_incident references an incident id that no longer resolves.
task = await _seed_postmortem(db_session, incident=None)
markers.set_coroner_incident(
task,
{
"incident_task_id": str(uuid4()),
"kind": "cancelled",
"revision_count": 0,
"title": "gone",
},
)
await db_session.flush()
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.project_id == roboco_project.id
# team is forced to Team.MAIN_PM regardless of the fallback team
# (Team.BACKEND) _resolve_target reports when the incident is gone.
assert materialized.team == Team.MAIN_PM
assert "backend cell" in (materialized.description or "")
@pytest.mark.asyncio
async def test_approve_is_idempotent(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
first = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
second = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
assert first is not None
assert second is not None
assert second.status == "already_approved"
assert second.materialized_task_id == first.materialized_task_id
result = await db_session.execute(
select(TaskTable).where(TaskTable.source == CORONER_ITEM_SOURCE)
)
assert len(result.scalars().all()) == ONE
@pytest.mark.asyncio
async def test_reject_records_reason(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
result = await _svc(db_session).reject_process_change(
_id(task), "one-off incident, not worth a standing rule"
)
assert result is not None
assert result.status == "rejected"
await db_session.refresh(task)
payload = markers.get_coroner_postmortem(task)
assert payload is not None
assert payload["process_change"]["status"] == "rejected"
assert (
payload["process_change"]["reject_reason"]
== "one-off incident, not worth a standing rule"
)
@pytest.mark.asyncio
async def test_reject_is_idempotent(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
await svc.reject_process_change(_id(task), "reason one")
second = await svc.reject_process_change(_id(task), "reason two")
assert second is not None
assert second.status == "already_rejected"
@pytest.mark.asyncio
async def test_cannot_reject_an_approved_change(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
await svc.approve_process_change(_id(task), created_by=CEO_UUID)
result = await svc.reject_process_change(_id(task), "changed my mind")
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_cannot_approve_a_rejected_change(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
await svc.reject_process_change(_id(task), "not now")
result = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_playbook_kind_refuses_approve(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(
db_session,
incident=incident,
process_change=_process_change(kind="playbook", status="not_applicable"),
)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "invalid_state"
assert "already drafted as a playbook" in result.detail
@pytest.mark.asyncio
async def test_playbook_kind_refuses_reject(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(
db_session,
incident=incident,
process_change=_process_change(kind="playbook", status="not_applicable"),
)
result = await _svc(db_session).reject_process_change(_id(task), "no thanks")
assert result is not None
assert result.status == "invalid_state"
assert "already drafted as a playbook" in result.detail
@pytest.mark.asyncio
async def test_process_change_with_no_status_key_defaults_to_proposed(
db_session: AsyncSession,
) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
legacy_change = _process_change(status=None)
assert "status" not in legacy_change
task = await _seed_postmortem(
db_session, incident=incident, process_change=legacy_change
)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
@pytest.mark.asyncio
async def test_approve_unresolvable_project_is_invalid_state(
db_session: AsyncSession,
) -> None:
"""Incident gone AND no RoboCo project seeded — fails cleanly instead of
guessing a project."""
task = await _seed_postmortem(db_session, incident=None)
markers.set_coroner_incident(
task,
{
"incident_task_id": str(uuid4()),
"kind": "cancelled",
"revision_count": 0,
"title": "gone",
},
)
await db_session.flush()
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "invalid_state"
assert "cannot anchor a materialized task" in result.detail
@pytest.mark.asyncio
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
result = await _svc(db_session).approve_process_change(uuid4(), created_by=CEO_UUID)
assert result is None
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
session.add(
BoardProgramCycleTable(
program_key="coroner",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await session.flush()
@pytest.mark.asyncio
async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
await _seed_cycle_ledger_row(db_session, task)
await _svc(db_session).approve_process_change(_id(task), created_by=CEO_UUID)
row = (
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "coroner"
)
)
).scalar_one()
assert row.items_approved == ONE
decision = row.decisions[0]
assert decision["verdict"] == "approved"
assert decision["item_ref"] == "Add a venv-freshness check to make quality"
@pytest.mark.asyncio
async def test_approve_survives_learn_recording_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
await _seed_cycle_ledger_row(db_session, task)
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("learn boom")
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"