mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""Coroner (Board Program) event hooks fired through the REAL service
|
|||
|
|
transition paths — bounce >=3x (TaskService._emit_status_transition_audit,
|
||
|
|
via a real _validate_and_set_status transition) and cancel-after-work-started
|
||
|
|
(TaskService.cancel). Every hook must be best-effort: a transition succeeds
|
||
|
|
even when the hook itself raises."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import contextlib
|
||
|
|
import types
|
||
|
|
from typing import TYPE_CHECKING, Any, cast
|
||
|
|
from unittest.mock import AsyncMock
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from roboco.db.tables import (
|
||
|
|
AgentTable,
|
||
|
|
BoardProgramCycleTable,
|
||
|
|
ProjectTable,
|
||
|
|
SystemSettingTable,
|
||
|
|
TaskTable,
|
||
|
|
)
|
||
|
|
from roboco.foundation import identity as _foundation
|
||
|
|
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 coroner_engine as coroner_engine_module
|
||
|
|
from roboco.services import task as task_module
|
||
|
|
from roboco.services.task import (
|
||
|
|
CORONER_SOURCE,
|
||
|
|
PEST_CONTROL_SOURCE,
|
||
|
|
ROADMAP_SOURCE,
|
||
|
|
X_FEATURE_EXPLORATION_SOURCE,
|
||
|
|
TaskCreateRequest,
|
||
|
|
TaskService,
|
||
|
|
_fire_coroner_bounce_hook,
|
||
|
|
get_task_service,
|
||
|
|
)
|
||
|
|
from sqlalchemy import delete, select, update
|
||
|
|
from sqlalchemy.orm import selectinload
|
||
|
|
|
||
|
|
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
|
||
|
|
ONE = 1
|
||
|
|
THIRD_BOUNCE = 3
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture(autouse=True)
|
||
|
|
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
|
||
|
|
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=f"backend-svc-{uuid4().hex[:6]}",
|
||
|
|
git_url=f"https://github.com/x/{uuid4().hex[:6]}.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
|
||
|
|
|
||
|
|
|
||
|
|
def _arm(session: AsyncSession) -> None:
|
||
|
|
session.add(SystemSettingTable(key="board_program.coroner.enabled", value="true"))
|
||
|
|
|
||
|
|
|
||
|
|
async def _make_task(
|
||
|
|
session: AsyncSession, project: ProjectTable, *, status: TS = TS.PENDING
|
||
|
|
) -> TaskTable:
|
||
|
|
"""A real dev task, with its ``cell_projects`` relationship eagerly
|
||
|
|
(re)loaded before returning: ``_validate_and_set_status`` reads it
|
||
|
|
synchronously (``bool(task.cell_projects)``), and a task fresh off
|
||
|
|
``create()`` can otherwise leave it in a state that needs a lazy DB
|
||
|
|
load outside any active greenlet the next time it's touched — a
|
||
|
|
``MissingGreenlet`` error, not a real business-logic failure."""
|
||
|
|
task = await get_task_service(session).create(
|
||
|
|
TaskCreateRequest(
|
||
|
|
title="A real dev task",
|
||
|
|
description="Something a dev worked on",
|
||
|
|
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,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if status != TS.PENDING:
|
||
|
|
task.status = status
|
||
|
|
await session.flush()
|
||
|
|
return (
|
||
|
|
await session.execute(
|
||
|
|
select(TaskTable)
|
||
|
|
.options(selectinload(TaskTable.cell_projects))
|
||
|
|
.where(TaskTable.id == task.id)
|
||
|
|
)
|
||
|
|
).scalar_one()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bounce_hook_schedules_only_at_the_third_bounce(
|
||
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
"""The chokepoint (_emit_status_transition_audit) schedules
|
||
|
|
``_fire_coroner_bounce_hook`` via ``asyncio.create_task`` — mock the
|
||
|
|
module-level hook function itself (a narrow, single-name patch that
|
||
|
|
leaves the real ``asyncio`` module and every other caller of
|
||
|
|
``create_task`` — including session-teardown internals — untouched) and
|
||
|
|
let the REAL ``create_task`` schedule the mock's coroutine normally."""
|
||
|
|
calls: list[Any] = []
|
||
|
|
|
||
|
|
async def _fake_hook(task_id: Any) -> None:
|
||
|
|
calls.append(task_id)
|
||
|
|
|
||
|
|
monkeypatch.setattr(task_module, "_fire_coroner_bounce_hook", _fake_hook)
|
||
|
|
|
||
|
|
project = await _seed(db_session)
|
||
|
|
_arm(db_session)
|
||
|
|
task = await _make_task(db_session, project, status=TS.IN_PROGRESS)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
svc = TaskService(db_session)
|
||
|
|
# 1st and 2nd bounce: no autopsy yet.
|
||
|
|
svc._validate_and_set_status(task, TS.NEEDS_REVISION, "qa")
|
||
|
|
task.status = TS.IN_PROGRESS # re-enter from a different status
|
||
|
|
svc._validate_and_set_status(task, TS.NEEDS_REVISION, "qa")
|
||
|
|
task.status = TS.IN_PROGRESS
|
||
|
|
await asyncio.sleep(0)
|
||
|
|
assert calls == []
|
||
|
|
|
||
|
|
# 3rd bounce: schedules the hook.
|
||
|
|
svc._validate_and_set_status(task, TS.NEEDS_REVISION, "qa")
|
||
|
|
await db_session.flush()
|
||
|
|
assert task.revision_count == THIRD_BOUNCE
|
||
|
|
await asyncio.sleep(0) # let the scheduled task actually run
|
||
|
|
|
||
|
|
assert calls == [task.id]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bounce_hook_opens_its_own_session_and_commits(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""``_fire_coroner_bounce_hook``'s own responsibility — open a FRESH
|
||
|
|
session via ``get_db_context()``, delegate to ``CoronerEngine.
|
||
|
|
open_for_incident``, commit. Verified with mocks (never a real
|
||
|
|
``get_db_context`` redirect to the shared ``db_session``): the hook's own
|
||
|
|
``await db.commit()`` would otherwise durably commit whatever this test
|
||
|
|
session had pending — including the OTHER real rows this file's other
|
||
|
|
tests create — permanently polluting the shared, cross-test-persistent
|
||
|
|
test DB for every test that runs after this one in the same session.
|
||
|
|
``CoronerEngine.open_for_incident``'s actual behavior is covered by
|
||
|
|
test_coroner_engine.py's dedicated real-DB tests; this is pure wiring."""
|
||
|
|
fake_session = AsyncMock()
|
||
|
|
|
||
|
|
@contextlib.asynccontextmanager
|
||
|
|
async def _fake_db_context() -> Any:
|
||
|
|
yield fake_session
|
||
|
|
|
||
|
|
monkeypatch.setattr("roboco.db.base.get_db_context", _fake_db_context)
|
||
|
|
|
||
|
|
engine = AsyncMock()
|
||
|
|
engine.open_for_incident = AsyncMock(return_value=None)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
coroner_engine_module, "get_coroner_engine", lambda _session: engine
|
||
|
|
)
|
||
|
|
|
||
|
|
task_id = uuid4()
|
||
|
|
await _fire_coroner_bounce_hook(task_id)
|
||
|
|
|
||
|
|
engine.open_for_incident.assert_awaited_once_with(task_id, kind="bounced")
|
||
|
|
fake_session.commit.assert_awaited_once()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bounce_hook_never_fails_the_transition_even_if_it_raises(
|
||
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
"""Best-effort: a hook scheduling failure never breaks the real bounce
|
||
|
|
transition that triggered it. Swaps out the ``asyncio`` NAME BINDING
|
||
|
|
``roboco.services.task`` sees (not the shared ``asyncio`` module's own
|
||
|
|
``create_task`` attribute) so this stays scoped to that one module and
|
||
|
|
can't break session-teardown machinery elsewhere in the same process."""
|
||
|
|
|
||
|
|
def _raise_create_task(coro: Any, *_a: object, **_kw: object) -> object:
|
||
|
|
coro.close() # avoid a "coroutine was never awaited" warning
|
||
|
|
raise RuntimeError("event loop trouble")
|
||
|
|
|
||
|
|
fake_asyncio = types.SimpleNamespace(create_task=_raise_create_task)
|
||
|
|
monkeypatch.setattr(task_module, "asyncio", fake_asyncio)
|
||
|
|
|
||
|
|
project = await _seed(db_session)
|
||
|
|
_arm(db_session)
|
||
|
|
task = await _make_task(db_session, project, status=TS.IN_PROGRESS)
|
||
|
|
task.revision_count = 2
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
svc = TaskService(db_session)
|
||
|
|
# Must NOT raise even though scheduling the hook blows up — the real
|
||
|
|
# transition (status + revision_count bump + audit row) still lands.
|
||
|
|
svc._validate_and_set_status(task, TS.NEEDS_REVISION, "qa")
|
||
|
|
assert task.status == TS.NEEDS_REVISION
|
||
|
|
assert task.revision_count == THIRD_BOUNCE
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_cancel_after_work_started_opens_autopsy(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
_arm(db_session)
|
||
|
|
task = await _make_task(db_session, project, status=TS.IN_PROGRESS)
|
||
|
|
task.commits = [{"sha": "abc123", "message": "did work"}]
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
svc = TaskService(db_session)
|
||
|
|
await svc.cancel(cast("UUID", task.id), agent_role="cell_pm")
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
cycles = (
|
||
|
|
(
|
||
|
|
await db_session.execute(
|
||
|
|
select(TaskTable).where(
|
||
|
|
TaskTable.source == CORONER_SOURCE, TaskTable.status == TS.PENDING
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
.scalars()
|
||
|
|
.all()
|
||
|
|
)
|
||
|
|
assert len(cycles) == ONE
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_cancel_with_no_work_started_opens_no_autopsy(
|
||
|
|
db_session: AsyncSession,
|
||
|
|
) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
_arm(db_session)
|
||
|
|
task = await _make_task(db_session, project, status=TS.PENDING)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
svc = TaskService(db_session)
|
||
|
|
await svc.cancel(cast("UUID", task.id), agent_role="cell_pm")
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
cycles = (
|
||
|
|
(
|
||
|
|
await db_session.execute(
|
||
|
|
select(TaskTable).where(
|
||
|
|
TaskTable.source == CORONER_SOURCE, TaskTable.status == TS.PENDING
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
.scalars()
|
||
|
|
.all()
|
||
|
|
)
|
||
|
|
assert cycles == []
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_cancel_hook_never_fails_the_cancel_when_coroner_engine_raises(
|
||
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
project = await _seed(db_session)
|
||
|
|
_arm(db_session)
|
||
|
|
task = await _make_task(db_session, project, status=TS.IN_PROGRESS)
|
||
|
|
task.commits = [{"sha": "abc123", "message": "did work"}]
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
def _boom(_session: AsyncSession) -> object:
|
||
|
|
raise RuntimeError("coroner engine trouble")
|
||
|
|
|
||
|
|
monkeypatch.setattr(coroner_engine_module, "get_coroner_engine", _boom)
|
||
|
|
|
||
|
|
svc = TaskService(db_session)
|
||
|
|
result = await svc.cancel(cast("UUID", task.id), agent_role="cell_pm")
|
||
|
|
assert result is not None
|
||
|
|
assert result.status == TS.CANCELLED
|