"""``main_pm`` + ``code`` must never coexist — the impossibility guard. The 2026-06-27 MegaTask meltdown traced to a Main-PM-owned root-subtask that was ``task_type=code``: the git/PR/review layer treated it as a code root (branch + PR + ``submit_root`` + the in-path ``pr_review`` gate + complete), while the ownership/dispatch layer treated it as coordination (owned / claimed / submitted / completed by the Main PM). The two layers never reconciled — a ``pr_fail`` on the assembled code landed on a coordinator with no code verb, who re-submitted the unchanged root → infinite loop. The CEO's fix: *"It should be impossible to draft a task where main pm and task type code COEXIST."* This suite pins the layered backstop that closes the invariant at every site where the combo can be created or re-handed: * ``TaskService.create`` rejects ``main_pm`` + ``code`` (the HTTP-route / direct-create backstop; intake coerces ``code``→``planning``). * ``approve_and_start`` retypes a board-routed code task to ``planning`` when it hands it to Main PM (the board→main-pm handoff is where a project code task would otherwise become ``main_pm`` + ``code``). * ``apply_escalation`` / ``reassign`` / ``reassign_active_claim`` divert a ``main_pm`` + ``code`` task handed to a Main-PM target to the pool — but STILL allow reassigning it to a cell dev (the correct remediation). * ``claim_task_for_agent`` rejects a Main-PM agent claiming a CODE task in an execution state (claiming = owning through the lifecycle, not delegating), while leaving the ``awaiting_pm_review`` review-claim path untouched (C8). The single predicate every layer consults is ``roboco.foundation.policy.batch.main_pm_cannot_own_code`` (accepts ORM enums or their ``.value`` strings); these tests exercise the service-layer sites that call it. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest from roboco.foundation.identity import AGENTS from roboco.models.base import ( AgentRole, Complexity, TaskNature, TaskStatus, TaskType, Team, ) from roboco.models.task import TaskCreateRequest from roboco.services.base import UnauthorizedError, ValidationError from roboco.services.task import TaskService def _bind(svc: TaskService, name: str, value: object) -> None: object.__setattr__(svc, name, value) def _service() -> TaskService: session = MagicMock() session.flush = AsyncMock() session.commit = AsyncMock() return TaskService(session) def _task( *, team: Team, task_type: TaskType, status: TaskStatus = TaskStatus.PENDING, assigned_to: object = None, board_review_complete: bool = True, ) -> MagicMock: return MagicMock( id=uuid4(), team=team, task_type=task_type, status=status, assigned_to=assigned_to, claimed_by=assigned_to, active_claimant_id=assigned_to, blocker_raised_by=None, board_review_complete=board_review_complete, dev_notes="", ) def _agent(role: AgentRole = AgentRole.DEVELOPER) -> MagicMock: return MagicMock(role=role, agent_id=uuid4()) def _perms() -> MagicMock: p = MagicMock() p.can_perform_task_action = MagicMock(return_value=True) return p # --------------------------------------------------------------------------- # Site 1 — TaskService.create backstop (invariant #1: the combo on the task) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_create_rejects_main_pm_plus_code() -> None: svc = TaskService( MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock()) ) req = TaskCreateRequest( title="rogue main-pm code root", description="should not persist", acceptance_criteria=["ship it"], team=Team.MAIN_PM, created_by=uuid4(), task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, estimated_complexity=Complexity.MEDIUM, project_id=uuid4(), ) with pytest.raises(ValidationError, match="MAIN_PM"): await svc.create(req) @pytest.mark.asyncio async def test_create_allows_main_pm_plus_planning() -> None: # The backstop is the team+type combo, not main_pm alone — a Main-PM # coordination root typed planning is the canonical shape, not a mismatch. svc = TaskService( MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock()) ) req = TaskCreateRequest( title="main-pm coordination root", description="fine", acceptance_criteria=["coordinate the cells"], team=Team.MAIN_PM, created_by=uuid4(), task_type=TaskType.PLANNING, nature=TaskNature.TECHNICAL, estimated_complexity=Complexity.MEDIUM, project_id=uuid4(), ) task = await svc.create(req) assert task.task_type == TaskType.PLANNING @pytest.mark.asyncio async def test_create_rejects_cell_pm_assignee_plus_code() -> None: # The team-based backstop above only catches main_pm+code. A code task on a # cell team (backend) assigned TO the cell PM (be-pm) is the same mismatch # — the role-based guard closes it at create time. be_pm_uuid = AGENTS["be-pm"].uuid svc = TaskService( MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock()) ) req = TaskCreateRequest( title="rogue cell-pm code task", description="should not persist", acceptance_criteria=["ship it"], team=Team.BACKEND, created_by=uuid4(), assigned_to=be_pm_uuid, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, estimated_complexity=Complexity.MEDIUM, project_id=uuid4(), ) with pytest.raises(ValidationError, match="PM_NO_CODE"): await svc.create(req) @pytest.mark.asyncio async def test_create_allows_cell_pm_assignee_plus_planning() -> None: be_pm_uuid = AGENTS["be-pm"].uuid svc = TaskService( MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock()) ) req = TaskCreateRequest( title="cell-pm planning task", description="fine", acceptance_criteria=["decompose the slice"], team=Team.BACKEND, created_by=uuid4(), assigned_to=be_pm_uuid, task_type=TaskType.PLANNING, nature=TaskNature.TECHNICAL, estimated_complexity=Complexity.MEDIUM, project_id=uuid4(), ) task = await svc.create(req) assert task.task_type == TaskType.PLANNING @pytest.mark.asyncio async def test_create_allows_dev_assignee_plus_code() -> None: # The role-based guard is PM-scoped — a developer assignee + code is fine. dev_uuid = AGENTS["be-dev-1"].uuid svc = TaskService( MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock()) ) req = TaskCreateRequest( title="dev code task", description="fine", acceptance_criteria=["implement it"], team=Team.BACKEND, created_by=uuid4(), assigned_to=dev_uuid, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, estimated_complexity=Complexity.MEDIUM, project_id=uuid4(), ) task = await svc.create(req) assert task.task_type == TaskType.CODE # --------------------------------------------------------------------------- # Site 2 — approve_and_start retype (board→main-pm handoff) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_approve_and_start_retypes_code_to_planning_for_main_pm( monkeypatch: pytest.MonkeyPatch, ) -> None: svc = _service() main_pm_agent = MagicMock(id=uuid4(), slug="main-pm", role=AgentRole.MAIN_PM) agent_svc = MagicMock() agent_svc.get_by_slug = AsyncMock(return_value=main_pm_agent) monkeypatch.setattr( "roboco.services.agent.get_agent_service", lambda _session: agent_svc ) task = _task( team=Team.BOARD, task_type=TaskType.CODE, status=TaskStatus.PENDING, board_review_complete=True, ) _bind(svc, "get", AsyncMock(return_value=task)) _bind(svc, "_activate_batch_root_subtasks", AsyncMock()) _bind(svc, "_emit_task_event", AsyncMock()) await svc.approve_and_start(task.id) # Handed to Main PM (team set) AND retyped off code in the same write. assert task.team == Team.MAIN_PM assert task.task_type == TaskType.PLANNING @pytest.mark.asyncio async def test_approve_and_start_leaves_planning_untouched( monkeypatch: pytest.MonkeyPatch, ) -> None: svc = _service() main_pm_agent = MagicMock(id=uuid4(), slug="main-pm", role=AgentRole.MAIN_PM) agent_svc = MagicMock() agent_svc.get_by_slug = AsyncMock(return_value=main_pm_agent) monkeypatch.setattr( "roboco.services.agent.get_agent_service", lambda _session: agent_svc ) task = _task( team=Team.BOARD, task_type=TaskType.PLANNING, status=TaskStatus.PENDING, board_review_complete=True, ) _bind(svc, "get", AsyncMock(return_value=task)) _bind(svc, "_activate_batch_root_subtasks", AsyncMock()) _bind(svc, "_emit_task_event", AsyncMock()) await svc.approve_and_start(task.id) assert task.team == Team.MAIN_PM assert task.task_type == TaskType.PLANNING # --------------------------------------------------------------------------- # Site 3 — apply_escalation: divert a main_pm+code task → main-pm target to pool # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_apply_escalation_diverts_main_pm_code_to_main_pm_target() -> None: svc = _service() main_pm_target = uuid4() task = _task( team=Team.MAIN_PM, task_type=TaskType.CODE, status=TaskStatus.IN_PROGRESS, assigned_to=uuid4(), ) _bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False)) _bind(svc, "_is_main_pm_agent", AsyncMock(return_value=True)) release_mock = AsyncMock() _bind(svc, "_release_code_task_to_pool", release_mock) await svc.apply_escalation( task=task, target_agent_id=main_pm_target, escalator_slug="cell-pm", target_slug="main-pm", reason="blocked", ) release_mock.assert_awaited_once() assert task.assigned_to != main_pm_target @pytest.mark.asyncio async def test_apply_escalation_does_not_divert_main_pm_code_to_cell_dev() -> None: # The correct remediation for a legacy main_pm+code task is to reassign it # to a cell dev who can actually fix the code — the guard must NOT block # that, only block re-handing it BACK to a Main-PM target. svc = _service() cell_dev_target = uuid4() task = _task( team=Team.MAIN_PM, task_type=TaskType.CODE, status=TaskStatus.IN_PROGRESS, assigned_to=uuid4(), ) _bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False)) _bind(svc, "_is_main_pm_agent", AsyncMock(return_value=False)) release_mock = AsyncMock() _bind(svc, "_release_code_task_to_pool", release_mock) _bind(svc, "_emit_task_event", AsyncMock()) await svc.apply_escalation( task=task, target_agent_id=cell_dev_target, escalator_slug="main-pm", target_slug="be-dev-1", reason="reassign to a dev to fix", ) # Not diverted — proceeds to the normal escalation path. release_mock.assert_not_awaited() # --------------------------------------------------------------------------- # Site 4 — reassign / reassign_active_claim diversion # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_reassign_diverts_main_pm_code_to_main_pm_target() -> None: svc = _service() main_pm_target = uuid4() task = _task(team=Team.MAIN_PM, task_type=TaskType.CODE, assigned_to=uuid4()) _bind(svc, "get", AsyncMock(return_value=task)) _bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False)) _bind(svc, "_is_main_pm_agent", AsyncMock(return_value=True)) diverted = AsyncMock() _bind(svc, "_divert_owned_task_to_pool", diverted) # The cell-PM redirect must NOT be reached when the main-pm-code divert fires. _bind( svc, "_resolve_cell_pm_redirect", AsyncMock( side_effect=AssertionError( "cell-PM redirect must not run when main-pm-code divert fires" ) ), ) result = await svc.reassign(task.id, main_pm_target) assert result is task diverted.assert_awaited_once() @pytest.mark.asyncio async def test_reassign_active_claim_diverts_main_pm_code_to_main_pm_target() -> None: svc = _service() main_pm_target = uuid4() task = _task( team=Team.MAIN_PM, task_type=TaskType.CODE, status=TaskStatus.IN_PROGRESS, assigned_to=uuid4(), ) _bind(svc, "get", AsyncMock(return_value=task)) _bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False)) _bind(svc, "_is_main_pm_agent", AsyncMock(return_value=True)) diverted = AsyncMock() _bind(svc, "_divert_owned_task_to_pool", diverted) _bind( svc, "_resolve_cell_pm_redirect", AsyncMock( side_effect=AssertionError( "cell-PM redirect must not run when main-pm-code divert fires" ) ), ) result = await svc.reassign_active_claim(task.id, main_pm_target) assert result is task diverted.assert_awaited_once() # --------------------------------------------------------------------------- # Site 5 — claim_task_for_agent: main-pm claiming code (execution state only) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_claim_rejects_main_pm_claiming_code_in_execution_state( monkeypatch: pytest.MonkeyPatch, ) -> None: svc = TaskService.__new__(TaskService) svc.session = AsyncMock() task = _task(team=Team.BACKEND, task_type=TaskType.CODE, status=TaskStatus.PENDING) monkeypatch.setattr(svc, "_load_task_or_raise", AsyncMock(return_value=task)) plain_claim = AsyncMock(return_value=task) monkeypatch.setattr(svc, "claim", plain_claim) with pytest.raises(UnauthorizedError, match="MAIN_PM_NO_CODE"): await svc.claim_task_for_agent( task.id, _agent(role=AgentRole.MAIN_PM), _perms(), claim_target_slug=None ) plain_claim.assert_not_awaited() @pytest.mark.asyncio async def test_claim_allows_main_pm_recovery_of_code_root_from_needs_revision( monkeypatch: pytest.MonkeyPatch, ) -> None: # The coordination-recovery path: after a pr_fail / qa_fail / ceo_reject the # owning PM re-claims the NEEDS_REVISION root to re-delegate the fixes # (lifecycle CLAIM_RULES). A legacy coordination root still typed ``code`` # (the 2026-06-27 c80e19ff root until the Phase 3b deploy retype) MUST pass # through this claim — blocking it wedges the Main PM in needs_revision with # no actor and no exit (the loop this bundle closes). The recovery claim # re-delegates; it does not execute code. svc = TaskService.__new__(TaskService) svc.session = AsyncMock() task = MagicMock( team=Team.MAIN_PM, task_type=TaskType.CODE, status=TaskStatus.NEEDS_REVISION, ) monkeypatch.setattr(svc, "_load_task_or_raise", AsyncMock(return_value=task)) claimed = MagicMock() plain_claim = AsyncMock(return_value=claimed) monkeypatch.setattr(svc, "claim", plain_claim) result = await svc.claim_task_for_agent( task.id, _agent(role=AgentRole.MAIN_PM), _perms(), claim_target_slug=None ) assert result is claimed plain_claim.assert_awaited_once() @pytest.mark.asyncio async def test_claim_allows_main_pm_claiming_planning_in_execution_state( monkeypatch: pytest.MonkeyPatch, ) -> None: # A Main PM coordinates planning roots — claiming a planning task is the # legitimate coordination path, not a mismatch. svc = TaskService.__new__(TaskService) svc.session = AsyncMock() task = MagicMock( team=Team.MAIN_PM, task_type=TaskType.PLANNING, status=TaskStatus.PENDING, ) monkeypatch.setattr(svc, "_load_task_or_raise", AsyncMock(return_value=task)) claimed = MagicMock() plain_claim = AsyncMock(return_value=claimed) monkeypatch.setattr(svc, "claim", plain_claim) result = await svc.claim_task_for_agent( task.id, _agent(role=AgentRole.MAIN_PM), _perms(), claim_target_slug=None ) assert result is claimed plain_claim.assert_awaited_once() @pytest.mark.asyncio async def test_claim_does_not_reject_main_pm_code_in_awaiting_pm_review( monkeypatch: pytest.MonkeyPatch, ) -> None: # C8: awaiting_pm_review is a REVIEW state, not an execution state. A # Main PM legitimately claims it to complete/merge — the code guard must # not fire there (it runs only after the review-state early return). svc = TaskService.__new__(TaskService) svc.session = AsyncMock() task = MagicMock( team=Team.MAIN_PM, task_type=TaskType.CODE, status=TaskStatus.AWAITING_PM_REVIEW, ) monkeypatch.setattr(svc, "_load_task_or_raise", AsyncMock(return_value=task)) review_claim = AsyncMock(return_value=task) monkeypatch.setattr(svc, "_claim_review_state", review_claim) plain_claim = AsyncMock( side_effect=AssertionError("transitioning claim must not run for review state") ) monkeypatch.setattr(svc, "claim", plain_claim) result = await svc.claim_task_for_agent( task.id, _agent(role=AgentRole.MAIN_PM), _perms(), claim_target_slug=None ) assert result is task review_claim.assert_awaited_once()