Files
roboco/tests/unit/gateway/test_claim_doc_task_checkout.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

204 lines
7.1 KiB
Python

"""Task #162: claim_doc_task checks out the task branch + verb hints.
Smoke-11: be-doc claimed an awaiting_documentation task but its clone
stayed on the default branch (the branch was created in the dev's
separate clone). roboco_docs_write / commit failed BRANCH_MISMATCH and
the doc looped (i_am_blocked Not Found, give_me_work pointed at a dev
verb). Primary fix: claim_doc_task checks out the task branch into the
documenter's workspace. Facet (d): give_me_work's next-hint is now
role + status aware.
"""
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
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)
# Findings-ledger reads (ReviewFindingsRepository.list_for_task) go
# through session.execute — an unconfigured AsyncMock's awaited result
# is itself an AsyncMock, so a plain sync `.scalars()` call on it leaks
# an unawaited coroutine. Empty scalars result (no findings).
base["task"].session.execute = AsyncMock(
return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
)
)
repo = base["evidence_repo"]
for m 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, m).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)
# ---------------------------------------------------------------------------
# Facet (d): give_me_work next-hint is role + status aware
# ---------------------------------------------------------------------------
def _task(status: str) -> MagicMock:
t = MagicMock()
t.id = uuid4()
t.status = status
return t
def test_claim_verb_hint_doc_for_awaiting_documentation() -> None:
hint = Choreographer._claim_verb_hint("documenter", _task("awaiting_documentation"))
assert "claim_doc_task" in hint
assert "i_will_work_on" not in hint
def test_claim_verb_hint_qa_for_awaiting_qa() -> None:
hint = Choreographer._claim_verb_hint("qa", _task("awaiting_qa"))
assert "claim_review" in hint
assert "i_will_work_on" not in hint
def test_claim_verb_hint_pm_for_planning() -> None:
hint = Choreographer._claim_verb_hint("cell_pm", _task("pending"))
assert "i_will_plan" in hint
def test_claim_verb_hint_pm_for_awaiting_pm_review_steers_to_complete() -> None:
"""A PM's own review-queue task must never hint i_will_plan — that verb
used to legally re-claim and reset it, looping submit_up -> pr_pass ->
awaiting_pm_review forever."""
for role in ("cell_pm", "main_pm"):
hint = Choreographer._claim_verb_hint(role, _task("awaiting_pm_review"))
assert "complete" in hint
assert "call i_will_plan(" not in hint
def test_claim_verb_hint_dev_default() -> None:
hint = Choreographer._claim_verb_hint("developer", _task("pending"))
assert "i_will_work_on" in hint
# ---------------------------------------------------------------------------
# Primary: claim_doc_task checks out the task branch into doc workspace
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_doc_task_checks_out_branch() -> None:
"""After doc_claim, the task branch is checked out into the
documenter's own clone so roboco_docs_write / commit don't
BRANCH_MISMATCH."""
doc_id = uuid4()
task_id = uuid4()
branch = "feature/backend/root1234--cellpm56--dev78901"
t_initial = MagicMock(
id=task_id,
status="awaiting_documentation",
assigned_to=None,
task_type="documentation",
team="backend",
branch_name=branch,
quick_context=None,
documents=[],
commits=[{"sha": "abc123", "message": "[x] work"}],
pr_number=7,
pr_url="https://github.com/x/y/pull/7",
dev_notes="done",
acceptance_criteria_status=[],
work_session_id=uuid4(),
)
t_claimed = MagicMock(
**{
**t_initial.__dict__,
"assigned_to": doc_id,
"status": "awaiting_documentation",
}
)
task_svc = AsyncMock()
task_svc.get.return_value = t_initial
task_svc.agent_for.return_value = MagicMock(role="documenter", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.doc_claim.return_value = t_claimed
git_svc = AsyncMock()
git_svc.diff.return_value = "diff"
git_svc.list_changed_files.return_value = ["README.md"]
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
env = await c.claim_doc_task(doc_id, task_id)
body = env.as_dict()
assert body["error"] is None, body
git_svc.checkout_branch_in_agent_workspace.assert_awaited_once_with(
branch, actor_agent_id=doc_id
)
@pytest.mark.asyncio
async def test_claim_doc_task_checkout_failure_does_not_break_claim() -> None:
"""A checkout hiccup must not fail the claim — the doc still gets
an ok envelope and can retry / escalate from a claimed state."""
doc_id = uuid4()
task_id = uuid4()
branch = "feature/backend/root1234--cellpm56--dev78901"
t_initial = MagicMock(
id=task_id,
status="awaiting_documentation",
assigned_to=None,
task_type="documentation",
team="backend",
branch_name=branch,
quick_context=None,
documents=[],
commits=[{"sha": "abc", "message": "[x] w"}],
pr_number=7,
pr_url="u",
dev_notes="d",
acceptance_criteria_status=[],
work_session_id=uuid4(),
)
t_claimed = MagicMock(**{**t_initial.__dict__, "assigned_to": doc_id})
task_svc = AsyncMock()
task_svc.get.return_value = t_initial
task_svc.agent_for.return_value = MagicMock(role="documenter", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.doc_claim.return_value = t_claimed
git_svc = AsyncMock()
git_svc.checkout_branch_in_agent_workspace.side_effect = RuntimeError("fetch fail")
git_svc.diff.return_value = ""
git_svc.list_changed_files.return_value = []
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
env = await c.claim_doc_task(doc_id, task_id)
# Claim still succeeds despite checkout raising.
assert env.as_dict()["error"] is None