fix(gateway): unblock PM planning + drop magic delegate task_type

Two coupled fixes from the 2026-05-08 smoke-test trace:

1. pm_cannot_execute_code is now scoped to i_will_work_on (the
   EXECUTION verb) only. Pre-fix it also fired on i_will_plan, which
   deadlocked any code-typed parent: cell_pm couldn't plan, so couldn't
   transition parent to in_progress, so couldn't delegate. PMs PLAN
   code-typed parents and DELEGATE the work — that's exactly the verb
   we were blocking.

2. delegate.task_type is now REQUIRED at both the HTTP boundary
   (DelegateRequest) and the choreographer dataclass (DelegateInputs).
   The pre-fix default of 'code' silently changed semantics whenever a
   caller forgot the field — main-pm's call in the smoke trace omitted
   it, schema defaulted to 'code', and the cell PM downstream was
   wedged. Also drops the choreographer's task.task_type fallback
   (the DB column is NOT NULL anyway).

Plus middleware coverage tests for the parallel ServiceError →
4xx handler hierarchy added in the prior session, restoring 100%
coverage across the touched files.

Tests: 3101 passing, 100% coverage, ruff clean.
This commit is contained in:
Renn F
2026-05-08 07:45:18 +02:00
parent f0eec854d1
commit 01ff44b83f
11 changed files with 350 additions and 17 deletions
@@ -336,7 +336,15 @@ async def test_main_pm_cannot_claim_code_task_via_i_will_work_on() -> None:
@pytest.mark.asyncio
async def test_cell_pm_cannot_claim_code_task_via_i_will_plan() -> None:
async def test_cell_pm_can_plan_code_typed_parent_via_i_will_plan() -> None:
"""Rule change (2026-05-08): the PM-cannot-execute-code guard belongs
on `i_will_work_on` (the EXECUTION verb), not on `i_will_plan` (the
PLANNING verb). PMs decompose code-typed parent tasks into
developer-claimable subtasks all the time; that's planning, not
executing. Pre-fix this rejection deadlocked every code-typed parent
in the smoke test (see PRE_GATEWAY_LIFECYCLE.md and the 2026-05-08
audit-log analysis).
"""
pm_id = uuid4()
task_id = uuid4()
target = MagicMock(
@@ -353,10 +361,12 @@ async def test_cell_pm_cannot_claim_code_task_via_i_will_plan() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(pm_id, task_id, plan="x")
env = await c.i_will_plan(pm_id, task_id, plan="Decompose into 2 dev subtasks.")
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "code" in body["message"].lower() or "execute" in body["message"].lower()
# The PM-cannot-execute-code rejection must NOT fire on i_will_plan.
assert body.get("error") != "not_authorized", (
f"i_will_plan was rejected for a code-typed parent; envelope: {body}"
)
@pytest.mark.asyncio