Files
roboco/tests/unit/services/test_pm_claim_needs_revision.py
T
Renn F d87e2d9b4e 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.
2026-07-30 23:37:27 +02:00

82 lines
3.7 KiB
Python

"""Runtime claim-status parity: PMs may re-claim a NEEDS_REVISION coordination task.
The lifecycle spec (``lifecycle.CLAIM_RULES``) lets ``CELL_PM`` / ``MAIN_PM``
claim ``NEEDS_REVISION`` so a rejected coordination / assembled task (pr_fail,
qa_fail, ceo_reject) can be re-claimed via ``i_will_plan`` and re-delegated.
The runtime claim path (``TaskService.claim`` ->
``_get_valid_claim_statuses`` -> ``_ROLE_CLAIM_STATUSES``) must honour the same
authority. Otherwise the spec gate *allows* ``i_will_plan`` on a
``needs_revision`` root, but the composed ``claim()`` inside the verb returns
``None`` (source status not in the runtime mapping) -> the verb runner raises
``INVALID_STATE`` -> the PM can neither plan nor idle its own rejected root and
respawn-loops on it (observed live 2026-06-25 on the ``0e49e04e`` cell root,
~143 INVALID_STATE rejections across 11 PM sessions).
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
import pytest
from roboco.foundation.policy import lifecycle as spec
from roboco.models.base import TaskStatus
from roboco.services.task import (
_ROLE_CLAIM_STATUSES,
_default_claim_statuses,
_get_valid_claim_statuses,
)
if TYPE_CHECKING:
from roboco.db.tables import AgentTable
@pytest.mark.parametrize("role", ["cell_pm", "main_pm"])
def test_pm_runtime_claim_statuses_include_needs_revision(role: str) -> None:
# _get_valid_claim_statuses only reads ``agent.role`` — a lightweight
# role-bearing stand-in is enough; cast keeps it type-clean (no AgentTable row).
agent = cast("AgentTable", SimpleNamespace(role=role))
assert TaskStatus.NEEDS_REVISION in _get_valid_claim_statuses(
agent, allow_reassign=False
)
assert TaskStatus.NEEDS_REVISION in _default_claim_statuses(role)
@pytest.mark.parametrize("role", [spec.Role.CELL_PM, spec.Role.MAIN_PM])
def test_runtime_pm_claim_mapping_covers_spec_claim_rules(role: spec.Role) -> None:
"""The runtime mapping must cover every status the spec grants the role.
Guards against the spec (CLAIM_RULES) and the runtime mapping
(_ROLE_CLAIM_STATUSES) drifting apart again — the parity invariant.
"""
runtime_values = {s.value for s in _default_claim_statuses(role.value)}
for status in spec.CLAIM_RULES[role]:
assert status.value in runtime_values, (
f"runtime claim mapping for {role.value} is missing spec-allowed "
f"status '{status.value}'"
)
@pytest.mark.parametrize("role", [spec.Role.CELL_PM, spec.Role.MAIN_PM])
def test_claim_rules_and_role_statuses_are_identical(role: spec.Role) -> None:
"""Genuine bidirectional cross-check between the two claim tables.
``test_runtime_pm_claim_mapping_covers_spec_claim_rules`` above only
checks spec ⊆ runtime — it would still pass if ``_ROLE_CLAIM_STATUSES``
carried an EXTRA status the spec doesn't grant (e.g. AWAITING_PM_REVIEW
re-added to the runtime table alone, with lifecycle.CLAIM_RULES left
untouched). That silent one-sided drift — "the service table already
granted this on the belief that the spec granted it too" — is exactly the
shape that caused the awaiting_pm_review re-claim loop
(``lifecycle.py``'s ``CLAIM_RULES`` comment covers the incident). This
test imports both tables directly and asserts the per-role sets are
IDENTICAL, not just one-way-covering.
"""
spec_values = {s.value for s in spec.CLAIM_RULES[role]}
runtime_values = {s.value for s in _ROLE_CLAIM_STATUSES[role.value]}
assert spec_values == runtime_values, (
f"spec.CLAIM_RULES[{role.value}]={spec_values} != "
f"task._ROLE_CLAIM_STATUSES[{role.value!r}]={runtime_values}"
)