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.
204 lines
7.1 KiB
Python
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
|