mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
149 lines
5.2 KiB
Python
149 lines
5.2 KiB
Python
"""PM re-entry on an awaiting_pm_review task must steer, never re-claim.
|
|
|
|
Live incident: an awaiting_pm_review task (already past the in-path PR gate)
|
|
kept getting re-offered to its owning PM by give_me_work. The respawned PM
|
|
called i_will_plan, and CLAIM_RULES used to grant CELL_PM/MAIN_PM a claim from
|
|
AWAITING_PM_REVIEW — so the composed (claim, set_plan, start) sequence legally
|
|
reset the task to in_progress and re-ran submit_up -> pr_pass ->
|
|
awaiting_pm_review forever (one production task looped 11 cycles across 37
|
|
spawns in 4h). ``_handle_pm_reentry`` now recognizes this status for the
|
|
owning PM and returns a steering-only OK envelope (complete / request_changes)
|
|
with no claim and no state change; CLAIM_RULES no longer permits the claim at
|
|
all, so a non-owner (or any other caller) falls through to a normal spec
|
|
rejection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixture helpers — same pattern as test_i_will_plan_sub_tasks_gate.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|
base: dict[str, Any] = {
|
|
"task": AsyncMock(),
|
|
"work_session": AsyncMock(),
|
|
"git": AsyncMock(),
|
|
"a2a": AsyncMock(),
|
|
"journal": AsyncMock(),
|
|
"audit": AsyncMock(),
|
|
"evidence_repo": AsyncMock(),
|
|
}
|
|
base.update(overrides)
|
|
task = base["task"]
|
|
task.session = MagicMock()
|
|
task.session.begin_nested = MagicMock(
|
|
return_value=MagicMock(
|
|
__aenter__=AsyncMock(return_value=None),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
)
|
|
repo = base["evidence_repo"]
|
|
for method in (
|
|
"list_unread_a2a",
|
|
"list_unread_mentions",
|
|
"list_pending_notifications",
|
|
"task_metadata_gaps",
|
|
"recent_team_activity",
|
|
"blockers_in_lane",
|
|
"journal_highlights_for_task",
|
|
):
|
|
getattr(repo, method).return_value = []
|
|
_ldef = base["journal"].latest_decision_at.return_value
|
|
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
|
|
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
|
return ChoreographerDeps(**base)
|
|
|
|
|
|
def _review_task_svc(task_id: object, pm_id: object, *, role: str) -> AsyncMock:
|
|
"""TaskService mock for a PM re-entering its own awaiting_pm_review task."""
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_pm_review",
|
|
plan={"text": "already planned"},
|
|
assigned_to=pm_id,
|
|
task_type="planning",
|
|
parent_task_id=None,
|
|
sequence=0,
|
|
team="backend",
|
|
commits=["abc123"],
|
|
pr_number=42,
|
|
branch_name="feature/backend/abc",
|
|
quick_context=None,
|
|
)
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
id=pm_id, role=role, team="backend", slug=None
|
|
)
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
task_svc.get_subtasks.return_value = []
|
|
task_svc.session = MagicMock()
|
|
task_svc.session.begin_nested = MagicMock(
|
|
return_value=MagicMock(
|
|
__aenter__=AsyncMock(return_value=None),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
)
|
|
return task_svc
|
|
|
|
|
|
async def _assert_steers_without_reclaiming(role: str) -> None:
|
|
pm_id = uuid4()
|
|
task_id = uuid4()
|
|
task_svc = _review_task_svc(task_id, pm_id, role=role)
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_will_plan(pm_id, task_id, plan="resume")
|
|
body = env.as_dict()
|
|
|
|
assert body.get("error") is None, body
|
|
assert body["status"] == "awaiting_pm_review", body
|
|
assert "complete" in body["next"], body
|
|
|
|
task_svc.claim.assert_not_awaited()
|
|
task_svc.set_plan.assert_not_awaited()
|
|
task_svc.start.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_pm_reentry_awaiting_pm_review_steers_to_complete() -> None:
|
|
await _assert_steers_without_reclaiming("cell_pm")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_reentry_awaiting_pm_review_steers_to_complete() -> None:
|
|
await _assert_steers_without_reclaiming("main_pm")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_owner_awaiting_pm_review_is_rejected_not_reclaimed() -> None:
|
|
"""A PM that does NOT own the review task gets the normal spec rejection —
|
|
CLAIM_RULES no longer grants a claim from awaiting_pm_review to anyone, so
|
|
this falls straight through to invalid_state instead of resetting the task.
|
|
"""
|
|
pm_id = uuid4()
|
|
other_pm_id = uuid4()
|
|
task_id = uuid4()
|
|
task_svc = _review_task_svc(task_id, other_pm_id, role="cell_pm")
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_will_plan(pm_id, task_id, plan="resume")
|
|
body = env.as_dict()
|
|
|
|
assert body.get("error") == "invalid_state", body
|
|
task_svc.claim.assert_not_awaited()
|
|
task_svc.set_plan.assert_not_awaited()
|
|
task_svc.start.assert_not_awaited()
|