fix(lifecycle): stop PMs re-claiming tasks out of the closure queue — kills the i_will_plan review loop

A cell/main PM's i_will_plan could legally re-claim its own task from
awaiting_pm_review (a CLAIM_RULES edge added for post-respawn recovery),
resetting the task to in_progress and re-running submit_up -> pr_pass ->
awaiting_pm_review forever: one Sentinel conventions child looped eleven
full laps in four hours (14 reviews on one PR, 37 agent spawns) while
its root's closure check fired eighteen times and the Main PM could
never close anything.

The claim edge is gone from every table that carried it — CLAIM_RULES,
the claim ActionSpec's source statuses, the StatusTransition row, the
service-layer _ROLE_CLAIM_STATUSES twin, and the legacy enforcement
shim's operational-edge/role-gate entries (left divergent, it would be
the same silent two-table drift that produced this bug). The respawn
case the edge existed for is now served properly: _handle_pm_reentry
gained a third contract — a PM calling i_will_plan on its own
awaiting_pm_review task gets a steering envelope (no claim, no state
change) pointing at complete/request_changes, and give_me_work's next
hint for that status says the same instead of steering back into
i_will_plan. The no-transition review-claim path and the pm-review
dispatch prompt were already correct and are untouched, so closure
still converges through them.

A new bidirectional test asserts CLAIM_RULES and _ROLE_CLAIM_STATUSES
stay identical per PM role (the old comment claimed a sync test existed;
it checked one direction only). Lifecycle artifacts regenerated; the
parity suite's three unshaped session mocks fixed, zero AsyncMock
warnings remain.
This commit is contained in:
Renn F
2026-07-30 23:37:27 +02:00
parent 27e58f469f
commit d87e2d9b4e
12 changed files with 373 additions and 71 deletions
@@ -277,6 +277,40 @@ def test_status_classification_is_mutually_disjoint() -> None:
assert waiting & terminal == set()
# ---------------------------------------------------------------------------
# awaiting_pm_review -> claimed is closed (the i_will_plan re-claim loop):
# a PM re-entering its own review-queue task is steered by the choreographer
# straight to complete/request_changes, never via a claim that resets the
# task and re-runs submit_up -> pr_pass -> awaiting_pm_review forever. This
# legacy shim (_LEGACY_OPERATIONAL_EDGES / _LEGACY_ROLE_GATES) used to grant
# the same edge lifecycle.CLAIM_RULES had already closed — the identical
# two-tables-drift shape that caused the incident.
# ---------------------------------------------------------------------------
def test_awaiting_pm_review_claim_no_longer_allowed_for_pm_roles() -> None:
assert can_agent_transition("awaiting_pm_review", "claimed", "cell_pm") is False
assert can_agent_transition("awaiting_pm_review", "claimed", "main_pm") is False
with pytest.raises(TaskLifecycleError):
validate_task_transition("awaiting_pm_review", "claimed", "cell_pm")
with pytest.raises(TaskLifecycleError):
validate_task_transition("awaiting_pm_review", "claimed", "main_pm")
def test_awaiting_pm_review_needs_revision_still_allowed() -> None:
"""The PM reject-back-to-dev path (request_changes) is untouched."""
assert (
can_agent_transition("awaiting_pm_review", "needs_revision", "cell_pm") is True
)
assert (
can_agent_transition("awaiting_pm_review", "needs_revision", "main_pm") is True
)
assert (
validate_task_transition("awaiting_pm_review", "needs_revision", "cell_pm")
is True
)
def test_status_classification_covers_every_enum_member() -> None:
"""Every Status enum member must be classified by EXACTLY one of
is_terminal_state / is_active_state / is_waiting_state — the coverage