fix(gateway): reject Cell-PM-assigned subtasks that aren't task_type=planning

Bug B from the 2026-05-09 smoke run. main-pm called
delegate(assigned_to='be-pm', task_type='code'). The chain validator
let it through (be-pm IS in main-pm's allowed targets), the schema
let it through (task_type='code' is a valid enum value), and the
subtask got created mis-typed. Task 0 made it cosmetically work
because PMs can now plan code-typed parents — but the model is
wrong: a Cell PM owns the PLANNING of the slice; the code execution
is what they delegate to devs.

New gate in _delegate_static_guards: when assignee is a Cell PM
(be-pm/fe-pm/ux-pm), task_type MUST be 'planning'. Returns
invalid_state with a remediate hint pointing at the right type.
Devs are unrestricted (could be code OR documentation, depending
on the slice).

Tests: 3139 passing (+ 2 regression tests pinning the rule), 100%
coverage, ruff clean.
This commit is contained in:
Renn F
2026-05-09 03:29:04 +02:00
parent 73e1e96851
commit 091e4076a2
2 changed files with 112 additions and 1 deletions
+35 -1
View File
@@ -12,7 +12,7 @@ injection so later phases just fill in the bodies.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, ClassVar
from uuid import UUID
import structlog
@@ -1430,6 +1430,40 @@ class Choreographer:
remediate="check team/task_type/estimated_complexity",
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
if type_error := self._validate_assignee_task_type(
inputs.assigned_to, inputs.task_type
):
return Envelope.invalid_state(
message=type_error,
remediate=(
"Cell PMs (be-pm/fe-pm/ux-pm) own PLANNING tasks — they "
"decompose the slice and delegate code work to devs. Pass "
"task_type='planning' when delegating to a Cell PM."
),
context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
)
return None
_CELL_PM_SLUGS: ClassVar[frozenset[str]] = frozenset({"be-pm", "fe-pm", "ux-pm"})
@staticmethod
def _validate_assignee_task_type(
assigned_to: str, task_type: str
) -> str | None:
"""Reject role-vs-type misclassifications.
Rule (2026-05-09 smoke Bug B): when delegating to a Cell PM, the
subtask must be `planning`-typed. The Cell PM owns the planning
of the slice and delegates code execution to devs; a code-typed
task assigned to a Cell PM conflates the two layers and made
the lifecycle harder to reason about (a code task that nobody
will execute, just plan).
"""
if assigned_to in Choreographer._CELL_PM_SLUGS and task_type != "planning":
return (
f"task_type={task_type!r} is invalid for assignee {assigned_to!r}: "
f"Cell PMs own planning tasks, not code/documentation/etc."
)
return None
async def _delegate_lifecycle_guards(
@@ -749,3 +749,80 @@ async def test_i_am_idle_with_unread_skips_pause_and_idle() -> None:
assert env.status == "idle_with_unread"
task_svc.list_in_progress_for_agent.assert_not_awaited()
task_svc.mark_agent_idle.assert_not_awaited()
# ---------------------------------------------------------------------------
# Bug B from 2026-05-09 smoke: main-pm sent task_type="code" when delegating
# to be-pm. A Cell PM PLANS the slice; it does not execute code. The
# delegate verb must reject this misclassification at the gateway so future
# spawns can't recreate the wrong-type-task condition.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_rejects_code_typed_subtask() -> None:
main_pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
status="in_progress",
assigned_to=main_pm_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.get_subtasks.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
main_pm_id,
parent_id,
DelegateInputs(
title="Backend slice",
description="Plan + drive backend work",
assigned_to="be-pm",
team="backend",
task_type="code", # WRONG — Cell PM should get planning
),
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "planning" in body["message"].lower()
assert "be-pm" in body["message"] or "cell_pm" in body["message"].lower()
@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_accepts_planning_subtask() -> None:
"""The contract from the test above: planning IS the right type."""
main_pm_id = uuid4()
parent_id = uuid4()
project_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=project_id,
status="in_progress",
assigned_to=main_pm_id,
)
new_task = MagicMock(id=uuid4())
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.get_subtasks.return_value = []
task_svc.create_subtask.return_value = new_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
main_pm_id,
parent_id,
DelegateInputs(
title="Backend slice",
description="Plan + drive backend work",
assigned_to="be-pm",
team="backend",
task_type="planning",
),
)
assert env.error is None