Files
roboco/tests/unit/gateway/test_choreographer_reassignment.py
T
Renn FandClaude Opus 4.7 4cb47afdb2 feat(gateway): restore Gate Set E submit-qa field-level gates
i_am_done now strictly enforces the four pre-gateway field-level gates
restored from roboco/api/routes/tasks.py:903-940 at commit 254cc93:

- NOT_SELF_VERIFIED: task.self_verified must be true.
- NO_COMMITS: task.commits must be non-empty.
- NO_PR: task.pr_number must be set.
- NO_PROGRESS: task.progress_updates must have at least one entry.

Each missing field surfaces as a tracing_gap with the matching pre-
gateway error code in the missing list, and a remediation hint that
tells the dev exactly what to do.

The previous silent-catch-up behavior is preserved as a separate
opt-in verb i_am_done_with_catchup. Existing tests that asserted the
catch-up behavior have been migrated to the new verb.

This fixes the failure mode where a dev could call i_am_done with no
commits and the gateway would silently try to push nothing, open an
empty PR, etc. — now the dev sees an explicit error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:34:57 +02:00

475 lines
16 KiB
Python

"""Tests covering ``task.assigned_to`` reassignment on each Choreographer
lifecycle transition.
The orchestrator polls per-agent for actionable tasks. If ``assigned_to``
is not updated when the lifecycle moves to a new stage (qa, doc, pm,
ceo), the orchestrator keeps respawning the previous-stage agent — an
infinite retry loop the role permission gates correctly reject. The
choreographer must hand the task off to the right agent for the new
stage as part of every transition.
"""
from __future__ import annotations
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 = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
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 = []
return ChoreographerDeps(**base)
# ---------------------------------------------------------------------------
# i_am_done: dev → qa
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_am_done_reassigns_task_to_qa_agent() -> None:
"""After dev submits, ``assigned_to`` must point at the team's QA."""
dev_id = uuid4()
task_id = uuid4()
qa_id = uuid4()
branch = "feature/backend/abc--def"
ws_id = uuid4()
initial = MagicMock(
id=task_id,
status="in_progress",
assigned_to=dev_id,
plan={"x": 1},
branch_name=branch,
work_session_id=ws_id,
self_verified=True,
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
progress_updates=[{"message": "did x"}],
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
# Gate Set E requires non-empty commits before submit_qa.
commits=[{"sha": "abc"}],
documents=[],
dev_notes="",
)
after_submit = MagicMock(
**{**initial.__dict__, "status": "awaiting_qa", "assigned_to": None},
)
qa_agent = MagicMock(id=qa_id, skills=[{"id": "code_review"}])
task_svc = AsyncMock()
task_svc.get.return_value = initial
task_svc.submit_qa.return_value = after_submit
task_svc.qa_agent_for_team.return_value = qa_agent
work_svc = AsyncMock()
work_svc.has_unpushed_commits.return_value = False
work_svc.files_changed.return_value = []
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, work_session=work_svc, journal=journal_svc)
deps.evidence_repo.journal_highlights_for_task.return_value = []
c = Choreographer(deps)
env = await c.i_am_done(dev_id, task_id, "all done")
assert env.error is None
task_svc.reassign.assert_awaited_once_with(task_id, qa_id)
@pytest.mark.asyncio
async def test_i_am_done_skips_reassign_when_no_qa_agent() -> None:
"""No QA configured for the team -> no reassign call (and no spawn)."""
dev_id = uuid4()
task_id = uuid4()
branch = "feature/backend/abc"
ws_id = uuid4()
initial = MagicMock(
id=task_id,
status="in_progress",
assigned_to=dev_id,
plan={"x": 1},
branch_name=branch,
work_session_id=ws_id,
self_verified=True,
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
progress_updates=[{"message": "p"}],
acceptance_criteria=[],
acceptance_criteria_status=[],
# Gate Set E requires non-empty commits before submit_qa.
commits=[{"sha": "abc"}],
documents=[],
dev_notes="",
)
after_submit = MagicMock(
**{**initial.__dict__, "status": "awaiting_qa", "assigned_to": None},
)
task_svc = AsyncMock()
task_svc.get.return_value = initial
task_svc.submit_qa.return_value = after_submit
task_svc.qa_agent_for_team.return_value = None # no QA found
work_svc = AsyncMock()
work_svc.has_unpushed_commits.return_value = False
work_svc.files_changed.return_value = []
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, work_session=work_svc, journal=journal_svc)
deps.evidence_repo.journal_highlights_for_task.return_value = []
c = Choreographer(deps)
await c.i_am_done(dev_id, task_id, "done")
task_svc.reassign.assert_not_awaited()
# ---------------------------------------------------------------------------
# pass_review: qa → documenter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pass_review_reassigns_task_to_documenter() -> None:
qa_id = uuid4()
doc_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="claimed",
assigned_to=qa_id,
qa_evidence_inspected=True,
)
after = MagicMock(
**{
**t.__dict__,
"status": "awaiting_documentation",
"team": "backend",
"pr_url": "https://x/pr/8",
"assigned_to": None,
},
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.qa_pass.return_value = after
task_svc.documenter_for_team.return_value = MagicMock(id=doc_id)
journal_svc = AsyncMock()
journal_svc.has_learning_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
notes = (
"Reviewed PR carefully. Branch convention correct. Commit prefix "
"verified. README diff matches spec. All acceptance criteria met."
)
env = await c.pass_review(qa_id, task_id, notes=notes)
assert env.error is None
task_svc.reassign.assert_awaited_once_with(task_id, doc_id)
@pytest.mark.asyncio
async def test_pass_review_skips_reassign_when_no_documenter() -> None:
qa_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="claimed",
assigned_to=qa_id,
qa_evidence_inspected=True,
)
after = MagicMock(
**{
**t.__dict__,
"status": "awaiting_documentation",
"team": "backend",
"pr_url": "x",
"assigned_to": None,
},
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.qa_pass.return_value = after
task_svc.documenter_for_team.return_value = None
journal_svc = AsyncMock()
journal_svc.has_learning_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
notes = "x" * 100
await c.pass_review(qa_id, task_id, notes=notes)
task_svc.reassign.assert_not_awaited()
# ---------------------------------------------------------------------------
# i_documented: documenter → cell_pm
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_documented_reassigns_task_to_cell_pm() -> None:
doc_id = uuid4()
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="claimed", assigned_to=doc_id, team="backend")
after = MagicMock(**{**t.__dict__, "status": "awaiting_pm_review"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.docs_complete.return_value = after
task_svc.cell_pm_for_team.return_value = MagicMock(id=pm_id)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
notes = "Wrote backend/guides/feature-x.md with usage examples and config notes."
files = ["backend/guides/feature-x.md"]
env = await c.i_documented(doc_id, task_id, notes=notes, files=files)
assert env.error is None
task_svc.reassign.assert_awaited_once_with(task_id, pm_id)
# ---------------------------------------------------------------------------
# main_pm_complete: PM → CEO (assigned_to cleared so no agent respawns)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_main_pm_complete_clears_assignment_for_ceo() -> None:
main_pm_id = uuid4()
root_task_id = uuid4()
t = MagicMock(
id=root_task_id,
status="awaiting_pm_review",
assigned_to=main_pm_id,
pr_number=42,
branch_name="feature/backend/root123",
parent_task_id=None,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.escalate_to_ceo.return_value = after
task_svc.all_subtasks_terminal.return_value = True
git_svc = AsyncMock()
git_svc.pr_target.return_value = "master"
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(main_pm_id, root_task_id, notes="ready")
assert env.error is None
task_svc.reassign.assert_awaited_once_with(root_task_id, None)
# ---------------------------------------------------------------------------
# escalate_to_ceo (board): clears assignment so CEO acts via UI
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_board_escalate_to_ceo_clears_assignment() -> None:
board_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
parent_task_id=None,
)
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="product_owner")
task_svc.escalate_to_ceo.return_value = after
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_to_ceo(board_id, task_id, reason="cross-cell rollout")
assert env.error is None
task_svc.reassign.assert_awaited_once_with(task_id, None)
# ---------------------------------------------------------------------------
# cell_pm_complete: leaf done → walk up + reassign parent if all subtasks done
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_reassigns_parent_when_all_subtasks_done() -> None:
pm_id = uuid4()
leaf_id = uuid4()
parent_id = uuid4()
new_pm_id = uuid4()
leaf = MagicMock(
id=leaf_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/abc--def",
parent_task_id=parent_id,
team="backend",
)
after = MagicMock(
**{**leaf.__dict__, "status": "completed"},
)
parent = MagicMock(
id=parent_id,
team="backend",
parent_task_id=None,
)
task_svc = AsyncMock()
# First .get is for the leaf (status check), then for the parent walk-up.
task_svc.get.side_effect = [leaf, parent]
task_svc.all_subtasks_terminal.side_effect = [True, True]
task_svc.cell_pm_complete.return_value = after
task_svc.cell_pm_for_team.return_value = MagicMock(id=new_pm_id)
git_svc = AsyncMock()
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, leaf_id, notes="reviewed and merged")
assert env.error is None
# Parent reassignment should have been issued, and only for the parent.
task_svc.reassign.assert_awaited_once_with(parent_id, new_pm_id)
@pytest.mark.asyncio
async def test_cell_pm_complete_skips_parent_reassign_when_subtasks_pending() -> None:
pm_id = uuid4()
leaf_id = uuid4()
parent_id = uuid4()
leaf = MagicMock(
id=leaf_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/abc--def",
parent_task_id=parent_id,
team="backend",
)
after = MagicMock(**{**leaf.__dict__, "status": "completed"})
parent = MagicMock(id=parent_id, team="backend")
task_svc = AsyncMock()
# The leaf's own all_subtasks_terminal in the guard returns True (no
# children); after completion, the parent walk-up's call returns False
# because some sibling is still active.
task_svc.get.side_effect = [leaf, parent]
task_svc.all_subtasks_terminal.side_effect = [True, False]
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
await c.cell_pm_complete(pm_id, leaf_id, notes="reviewed")
task_svc.reassign.assert_not_awaited()
@pytest.mark.asyncio
async def test_cell_pm_complete_skips_parent_walk_up_for_root_task() -> None:
"""Root tasks have parent_task_id=None — no walk-up should occur."""
pm_id = uuid4()
root_id = uuid4()
leaf = MagicMock(
id=root_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/root123",
parent_task_id=None,
team="backend",
)
after = MagicMock(**{**leaf.__dict__, "status": "completed"})
task_svc = AsyncMock()
task_svc.get.return_value = leaf
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
await c.cell_pm_complete(pm_id, root_id, notes="reviewed")
task_svc.reassign.assert_not_awaited()
# ---------------------------------------------------------------------------
# fail_review: existing path leans on qa_fail's quick_context recovery; the
# choreographer should NOT issue an explicit reassign of its own (qa_fail
# already moves the task back to the original developer).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fail_review_does_not_double_reassign() -> None:
qa_id = uuid4()
dev_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="claimed",
assigned_to=qa_id,
qa_evidence_inspected=True,
)
after = MagicMock(
**{**t.__dict__, "status": "needs_revision", "assigned_to": dev_id},
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.qa_fail.return_value = after
journal_svc = AsyncMock()
journal_svc.has_learning_for_task.return_value = True
a2a_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc, a2a=a2a_svc)
c = Choreographer(deps)
issues = [
"Missing unit test coverage for /healthz endpoint",
"Lint errors in /api/foo.py: unused import",
]
env = await c.fail_review(qa_id, task_id, issues)
assert env.error is None
# qa_fail itself reassigns back to the original developer via quick_context;
# the choreographer should not stack a second reassign on top.
task_svc.reassign.assert_not_awaited()