Files
roboco/tests/unit/services/test_task_assignment_invariants.py
T

309 lines
11 KiB
Python

"""Cell-team PM-owned children must be assigned to their cell PM.
A descendant task whose ``team`` is a cell team and whose ``task_type`` is a
non-code work type (``planning`` / ``research`` / ``administrative`` /
``documentation`` / ``design``) belongs to that cell's PM. Assigning it to
``main-pm`` (or any other mismatched owner) deadlocks the escalation chain,
because the Main PM escalates up to the board, which cannot own cell
coordination work. The service-layer invariant redirects such assignments to
the correct cell PM at reassign / reassign-active-claim time and writes an
``[ASSIGNMENT REDIRECTED]`` line to ``dev_notes`` so the audit is visible in
the task body, not only in logs. The create path does NOT redirect (per the
decision that reassign is the single backstop) and is regression-tested as
such. Direct ORM writes from the escalation chain are out of scope.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from roboco.models.base import TaskStatus, TaskType, Team
from roboco.services.task import TaskService, _is_cell_pm_owned_task
def _bind(svc: TaskService, name: str, value: object) -> None:
object.__setattr__(svc, name, value)
def _service() -> TaskService:
session = MagicMock()
session.flush = AsyncMock()
return TaskService(session)
def _task(
*,
team: Team,
task_type: TaskType,
assigned_to: UUID | None = None,
status: TaskStatus = TaskStatus.CLAIMED,
parent_task_id: object = "USE_DEFAULT",
) -> MagicMock:
"""Build a task-shaped mock. ``parent_task_id`` defaults to a fresh UUID
(a descendant); pass an explicit ``None`` for a root task. The sentinel
avoids the truthiness collision where ``None`` and "use a default" both
look the same to ``or`` / ternary ``if-else``.
"""
resolved_parent = uuid4() if parent_task_id == "USE_DEFAULT" else parent_task_id
return MagicMock(
id=uuid4(),
parent_task_id=resolved_parent,
team=team,
task_type=task_type,
assigned_to=assigned_to,
claimed_by=assigned_to,
active_claimant_id=assigned_to,
dev_notes="",
status=status,
)
# ---------------------------------------------------------------------------
# Pure predicate — every non-code cell-team child type is PM-owned
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"task_type",
[
TaskType.PLANNING,
TaskType.RESEARCH,
TaskType.ADMINISTRATIVE,
TaskType.DOCUMENTATION,
TaskType.DESIGN,
],
)
def test_cell_non_code_child_is_pm_owned(task_type: TaskType) -> None:
"""Every non-code cell-team child task is PM-owned by its cell PM."""
task = _task(team=Team.BACKEND, task_type=task_type)
assert _is_cell_pm_owned_task(task) is True
def test_cell_code_child_is_not_pm_owned() -> None:
"""Leaf code work is delegated by the cell PM to a developer, not owned
by the cell PM — it is NOT subject to the redirect."""
task = _task(team=Team.BACKEND, task_type=TaskType.CODE)
assert _is_cell_pm_owned_task(task) is False
def test_root_cell_planning_is_not_pm_owned() -> None:
"""Roots are Main-PM coordination work; only descendants are PM-owned."""
task = _task(
team=Team.BACKEND,
task_type=TaskType.PLANNING,
parent_task_id=None,
)
assert _is_cell_pm_owned_task(task) is False
def test_main_pm_team_planning_is_not_pm_owned() -> None:
"""The invariant is cell-team-specific; main-pm team tasks skip it."""
task = _task(team=Team.MAIN_PM, task_type=TaskType.PLANNING)
assert _is_cell_pm_owned_task(task) is False
# ---------------------------------------------------------------------------
# Service-layer redirect at reassign — happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reassign_redirects_main_pm_to_cell_pm_for_planning_child() -> None:
svc = _service()
be_pm_id = uuid4()
main_pm_id = uuid4()
task = _task(
team=Team.BACKEND,
task_type=TaskType.PLANNING,
assigned_to=main_pm_id,
)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
_bind(
svc,
"cell_pm_for_team",
AsyncMock(return_value=MagicMock(id=be_pm_id, slug="be-pm")),
)
result = await svc.reassign(task.id, main_pm_id)
assert result is task
assert task.assigned_to == be_pm_id
assert task.claimed_by == be_pm_id
# Reassign now writes the audit line so the redirect is visible in the
# task body, not just in logs.
assert "[ASSIGNMENT REDIRECTED]" in task.dev_notes
assert "be-pm" in task.dev_notes
@pytest.mark.asyncio
async def test_reassign_keeps_cell_pm_for_planning_child() -> None:
"""A cell PM request on a cell-team PM-owned task is a no-op redirect."""
svc = _service()
be_pm_id = uuid4()
task = _task(
team=Team.BACKEND,
task_type=TaskType.PLANNING,
assigned_to=None,
)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
_bind(
svc,
"cell_pm_for_team",
AsyncMock(return_value=MagicMock(id=be_pm_id, slug="be-pm")),
)
result = await svc.reassign(task.id, be_pm_id)
assert result is task
assert task.assigned_to == be_pm_id
# No redirect → no audit line.
assert "[ASSIGNMENT REDIRECTED]" not in task.dev_notes
@pytest.mark.asyncio
async def test_reassign_active_claim_redirects_main_pm_to_cell_pm() -> None:
svc = _service()
be_pm_id = uuid4()
main_pm_id = uuid4()
task = _task(
team=Team.BACKEND,
task_type=TaskType.RESEARCH,
assigned_to=main_pm_id,
)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
_bind(
svc,
"cell_pm_for_team",
AsyncMock(return_value=MagicMock(id=be_pm_id, slug="be-pm")),
)
result = await svc.reassign_active_claim(task.id, main_pm_id)
assert result is task
assert task.assigned_to == be_pm_id
assert task.claimed_by == be_pm_id
assert task.active_claimant_id == be_pm_id
assert "[ASSIGNMENT REDIRECTED]" in task.dev_notes
# ---------------------------------------------------------------------------
# Service-layer redirect — failure modes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reassign_queues_task_when_no_cell_pm_exists() -> None:
"""When no cell PM agent row exists for the team, queue the task (no
assignee) and log an error rather than silently routing to the caller's
requested assignee. The task stays PENDING until the agents table is
repaired; this is observable in logs and dev_notes.
"""
svc = _service()
main_pm_id = uuid4()
task = _task(
team=Team.BACKEND,
task_type=TaskType.PLANNING,
assigned_to=main_pm_id,
)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
_bind(svc, "cell_pm_for_team", AsyncMock(return_value=None))
result = await svc.reassign(task.id, main_pm_id)
assert result is task
# Queued: assignee cleared so the orchestrator does not respawn into a
# misrouted hand-off.
assert task.assigned_to is None
assert task.claimed_by is None
assert "[ASSIGNMENT REDIRECTED]" in task.dev_notes
assert "queued" in task.dev_notes
@pytest.mark.asyncio
async def test_reassign_active_claim_queues_when_no_cell_pm_exists() -> None:
"""Same missing-cell-PM fallback for the active-claim path."""
svc = _service()
main_pm_id = uuid4()
task = _task(
team=Team.BACKEND,
task_type=TaskType.DESIGN,
assigned_to=main_pm_id,
)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
_bind(svc, "cell_pm_for_team", AsyncMock(return_value=None))
result = await svc.reassign_active_claim(task.id, main_pm_id)
assert result is task
assert task.assigned_to is None
assert task.claimed_by is None
assert task.active_claimant_id is None
assert "[ASSIGNMENT REDIRECTED]" in task.dev_notes
@pytest.mark.asyncio
async def test_reassign_board_divert_fires_before_cell_pm_redirect() -> None:
"""The board-divert guard must run BEFORE the cell-PM redirect. If a
board agent is asked to own a cell task, the divert-to-pool path is the
one that fires — not a cell-PM redirect that never matches because the
board agent is not the cell PM. This test exercises the ordering so a
future refactor that reorders the guards fails loudly.
"""
svc = _service()
board_agent_id = uuid4()
task = _task(
team=Team.BACKEND,
task_type=TaskType.PLANNING,
assigned_to=board_agent_id,
)
_bind(svc, "get", AsyncMock(return_value=task))
# Board guard says YES, this is a board agent.
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=True))
# Cell PM lookup must NOT be reached — if it is, the test fails because
# the AsyncMock has no return_value configured.
_bind(
svc,
"cell_pm_for_team",
AsyncMock(
side_effect=AssertionError(
"cell_pm_for_team must not run when board-divert fires first"
)
),
)
diverted = AsyncMock()
_bind(svc, "_divert_owned_task_to_pool", diverted)
result = await svc.reassign(task.id, board_agent_id)
assert result is task
diverted.assert_awaited_once()
# Board-divert path returned before the cell-PM redirect mutated fields.
assert task.assigned_to == board_agent_id
# ---------------------------------------------------------------------------
# Regression — the create path does NOT redirect
# ---------------------------------------------------------------------------
def test_create_does_not_redirect_misassigned_cell_planning_child() -> None:
"""Regression for the create-path backstop decision. The decision (see
audit, Gap 1) was: the create path no longer redirects to the cell PM,
because the reassign path is the single backstop and a second redirect
on create is duplicate coverage. If this test ever fails, someone has
re-added the create-path redirect and should be challenged on why.
"""
svc = _service()
# If a `_redirect_cell_team_pm_task` method ever reappears, this assert
# should make the failure obvious in the diff.
assert not hasattr(svc, "_redirect_cell_team_pm_task"), (
"create-path redirect was re-added; remove it (see audit Gap 1)."
)