mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
309 lines
11 KiB
Python
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)."
|
||
|
|
)
|