Files
roboco/tests/unit/gateway/test_choreographer_impl_branches.py
T
Renn F fd344511a5 feat(gateway): add Envelope introspection (current_state + valid_next_verbs)
Pre-fix, agents had no way to introspect what verbs were valid from a
task's current state — the 2026-05-08 trace showed them spamming
escalate_to_ceo/complete/unblock/resume against a `claimed` task and
racking up rejections. Pre-gateway agents could ground reasoning in
VALID_TRANSITIONS[status] from a doc; the gateway hid that.

Now every Envelope carries:
  - current_state: the task's status string (or None for tool-discovery
    envelopes that aren't task-bound)
  - valid_next_verbs: the verbs the caller can usefully call right now,
    sourced from verb_gates.valid_next_verbs(role, task)

Wired into i_will_work_on (highest-traffic verb) for both the OK path
and the wrong-state rejection path. Remaining lifecycle verbs to be
wired in subsequent commits (Task 3.9).
2026-05-08 08:03:32 +02:00

1052 lines
36 KiB
Python

"""Targeted coverage for branches in roboco.services.gateway.choreographer._impl.
Each test pins one rejection-envelope branch so the larger Choreographer
verb continues to surface remediation hints rather than crash on edge
states (claim failures, start failures, missing parents, etc.).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
import structlog
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.choreographer._impl import DelegateInputs
from roboco.services.gateway.claim_guards import pm_cannot_execute_code_guard
from roboco.services.gateway.envelope import Envelope
def _wire_dev_task_svc(
task_id, *, status: str, assigned_to=None, plan=None, parent_task_id=None
):
"""Build a TaskService AsyncMock pre-wired with claim-guard side effects.
Defaults `agent_for` → developer/backend and the three list-* methods to
empty lists so claim-guard short-circuits never fire unintentionally.
"""
task_svc = AsyncMock()
task_svc.get.return_value = MagicMock(
status=status,
assigned_to=assigned_to,
plan=plan,
id=task_id,
title="t",
task_type="code",
parent_task_id=parent_task_id,
team="backend",
)
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.get_subtasks.return_value = []
return task_svc
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)
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)
# ---------------------------------------------------------------------------
# _emit_rejection: ok envelope passes through unchanged (line 158)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_emit_rejection_passes_through_ok_envelope() -> None:
deps = _make_deps()
c = Choreographer(deps)
ok = Envelope.ok(status="x", task_id=None, next="n", context_briefing={})
result = await c._emit_rejection(ok, agent_id=uuid4(), task_id=None, verb="x")
assert result is ok
deps.audit.log_event.assert_not_called()
# ---------------------------------------------------------------------------
# i_will_work_on: claim() raises Exception → invalid_state (lines 369-378)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_will_work_on_pending_claim_raises_returns_invalid_state() -> None:
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(task_id, status="pending")
task_svc.claim.side_effect = RuntimeError("workspace down")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="plan")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "claim failed during finalization" in body["message"]
@pytest.mark.asyncio
async def test_i_will_work_on_pending_claim_returns_none_invalid_state() -> None:
"""Lines 379-384: claim returns None → invalid_state."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(task_id, status="pending")
task_svc.claim.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="plan")
body = env.as_dict()
assert body["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_i_will_work_on_pending_no_plan_tracing_gap() -> None:
"""Lines 385-393: pending task, no plan → tracing_gap."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(task_id, status="pending", assigned_to=agent_id)
claimed_task = MagicMock(
status="pending",
assigned_to=agent_id,
plan=None,
id=task_id,
title="t",
task_type="code",
)
task_svc.claim.return_value = claimed_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan=None)
body = env.as_dict()
assert body["error"] == "tracing_gap"
@pytest.mark.asyncio
async def test_i_will_work_on_start_returns_none_invalid_state() -> None:
"""Lines 396-398: start returns None → start_failed_envelope."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(task_id, status="pending", assigned_to=agent_id)
claimed_task = MagicMock(
status="pending",
assigned_to=agent_id,
plan="some plan",
id=task_id,
title="t",
task_type="code",
)
task_svc.claim.return_value = claimed_task
task_svc.set_plan.return_value = claimed_task
task_svc.start.return_value = None # start fails
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok plan")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "start failed" in body["message"]
# ---------------------------------------------------------------------------
# _i_will_work_on_needs_revision: claim returns None → invalid_state (lines 427-433)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_needs_revision_branch_claim_fails_invalid_state() -> None:
"""Lines 427-433: needs_revision, not assigned, claim fails → invalid_state."""
agent_id = uuid4()
task_id = uuid4()
other_id = uuid4()
task_svc = _wire_dev_task_svc(
task_id, status="needs_revision", assigned_to=other_id, plan="p"
)
task_svc.claim.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok")
body = env.as_dict()
assert body["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_needs_revision_branch_start_fails() -> None:
"""Line 436: start returns None in needs_revision branch."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(
task_id, status="needs_revision", assigned_to=agent_id, plan="p"
)
task_svc.start.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok")
body = env.as_dict()
assert body["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# _i_will_work_on_claimed: start fails → start_failed_envelope (line 454)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claimed_branch_returns_start_failed() -> None:
"""Line 454: start fails in claimed branch."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(
task_id, status="claimed", assigned_to=agent_id, plan="p"
)
task_svc.start.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok")
body = env.as_dict()
assert body["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# i_will_work_on with in_progress assigned to self → idempotent (line 491)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_will_work_on_in_progress_assigned_to_self_idempotent() -> None:
"""Line 491: in_progress assigned_to=agent → idempotent re-entry pass through."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(
task_id, status="in_progress", assigned_to=agent_id, plan="p"
)
task_svc.heartbeat = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok")
body = env.as_dict()
# No error — re-entry pass.
assert "error" not in body or body.get("error") is None
# ---------------------------------------------------------------------------
# i_will_plan: pending claim returns None (line 1155)
# ---------------------------------------------------------------------------
def test_pm_cannot_execute_code_guard_passes_for_non_code_task() -> None:
"""Direct guard unit test: PM + non-code task → no rejection.
Covers claim_guards.py:98 (the early-return for non-code task_type).
"""
assert pm_cannot_execute_code_guard("cell_pm", "planning") is None
assert pm_cannot_execute_code_guard("main_pm", "documentation") is None
@pytest.mark.asyncio
async def test_i_will_plan_pm_with_already_active_task_rejects() -> None:
"""The already_active_guard still fires on i_will_plan even though
pm_cannot_execute_code is skipped. Covers _impl.py:1106-1108
(with-briefing wrap of the guard rejection).
"""
pm_id = uuid4()
task_id = uuid4()
other_task_id = uuid4()
target = MagicMock(
status="pending",
assigned_to=pm_id,
plan=None,
id=task_id,
title="t",
team="backend",
parent_task_id=None,
task_type="planning",
)
busy_task = MagicMock(id=other_task_id, status="in_progress")
task_svc = AsyncMock()
task_svc.get.return_value = target
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.list_in_progress_for_agent.return_value = [busy_task]
task_svc.list_paused_for_agent.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(pm_id, task_id, plan="x" * 30)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "in_progress task" in body["message"]
@pytest.mark.asyncio
async def test_i_will_plan_cell_pm_on_code_typed_parent_succeeds() -> None:
"""Regression for the smoke-test deadlock (2026-05-08 trace).
When a cell PM tries to plan a code-typed parent task, the verb must
succeed — PMs PLAN code work and DELEGATE the execution; they don't
execute. The pre-fix `pm_cannot_execute_code_guard` was wrongly fired
on `i_will_plan` (the planning verb) instead of being scoped to
`i_will_work_on` (the execution verb), causing a deadlock: cell PM
couldn't plan → couldn't transition parent to in_progress → couldn't
delegate (delegate requires parent in_progress).
"""
pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="pending",
assigned_to=pm_id,
plan=None,
id=task_id,
title="Backend slice: Git workflow smoke test",
team="backend",
parent_task_id=uuid4(), # subtask of the main_pm root
task_type="code", # ← the trigger; pre-fix this rejected with
# "Cell Pm cannot claim code tasks"
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.get_subtasks.return_value = []
# Claim + start succeed so we can verify the verb runs end-to-end.
started_task = MagicMock(
status="in_progress",
assigned_to=pm_id,
id=task_id,
title=task.title,
team="backend",
task_type="code",
)
task_svc.claim.return_value = task
task_svc.start.return_value = started_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(pm_id, task_id, plan="Decompose into 2 dev subtasks.")
body = env.as_dict()
# The PM-cannot-execute-code rejection must NOT fire on i_will_plan.
assert body.get("error") != "not_authorized", (
f"i_will_plan was rejected for a code-typed parent; envelope: {body}"
)
@pytest.mark.asyncio
async def test_i_will_plan_pending_claim_fails() -> None:
pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="pending",
assigned_to=pm_id,
plan=None,
id=task_id,
title="t",
team="backend",
parent_task_id=None,
task_type="planning",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
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.claim.return_value = None # claim fails
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(pm_id, task_id, plan="my plan that is long enough")
body = env.as_dict()
assert body["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# delegate: parent not found (line 1208)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delegate_parent_not_found() -> None:
pm_id = uuid4()
parent_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
pm_id,
parent_id,
DelegateInputs(
title="x",
description="y",
assigned_to="be-dev-1",
team="backend",
task_type="code",
),
)
body = env.as_dict()
assert body["error"] == "not_found"
# ---------------------------------------------------------------------------
# _delegate_role_guards: unknown role rejection (line 1271)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delegate_unknown_role_rejected() -> None:
pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
status="in_progress",
assigned_to=pm_id,
project_id=uuid4(),
title="p",
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
pm_id,
parent_id,
DelegateInputs(
title="x",
description="y",
assigned_to="be-dev-1",
team="backend",
task_type="code",
),
)
body = env.as_dict()
assert body["error"] == "not_authorized"
# ---------------------------------------------------------------------------
# _delegate_static_guards: unknown agent slug → invalid_state (line 1300)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delegate_parent_no_project_rejected() -> None:
"""Line 1306: parent.project_id is None → invalid_state."""
pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
status="in_progress",
assigned_to=pm_id,
project_id=None,
title="p",
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
pm_id,
parent_id,
DelegateInputs(
title="x",
description="y",
assigned_to="be-dev-1",
team="backend",
task_type="code",
),
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "no project_id" in body["message"]
# ---------------------------------------------------------------------------
# _validate_delegation_chain: unknown role → "role X cannot delegate" (line 1446)
# ---------------------------------------------------------------------------
def test_validate_delegation_chain_unknown_role() -> None:
deps = _make_deps()
c = Choreographer(deps)
err = c._validate_delegation_chain("auditor", "be-dev-1")
assert err is not None
assert "auditor" in err
# ---------------------------------------------------------------------------
# submit_up: task not found (line 1458)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_up_task_not_found() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.submit_up(pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "not_found"
# ---------------------------------------------------------------------------
# submit_up: submit_pm_review returns None (line 1477)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_up_submit_pm_review_fails() -> None:
pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="in_progress",
assigned_to=pm_id,
branch_name="feature/backend/abc",
pr_number=None,
title="t",
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.all_subtasks_terminal.return_value = True
task_svc.submit_pm_review.return_value = None # service returns None
journal = AsyncMock()
journal.has_decision_for_task.return_value = True
git = AsyncMock()
git.create_pr = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal, git=git)
c = Choreographer(deps)
env = await c.submit_up(pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# _submit_up_ownership_guard wrong role (line 1520)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_up_wrong_role_rejected() -> None:
pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="in_progress",
assigned_to=pm_id,
title="t",
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="main_pm", team=None)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.submit_up(pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "not_authorized"
# ---------------------------------------------------------------------------
# _submit_up_state_guard: no branch_name (line 1556)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_up_no_branch_rejected() -> None:
pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="in_progress",
assigned_to=pm_id,
branch_name=None,
pr_number=None,
title="t",
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.all_subtasks_terminal.return_value = True
journal = AsyncMock()
journal.has_decision_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal)
c = Choreographer(deps)
env = await c.submit_up(pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "no branch" in body["message"]
# ---------------------------------------------------------------------------
# _handoff_to_main_pm: main_pm_agent returns None (line 1567)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_handoff_to_main_pm_no_main_pm_returns_silently() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.main_pm_agent.return_value = None # No main PM in DB
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
# Should silently return without error.
await c._handoff_to_main_pm(pm_id, task_id)
# reassign + a2a never called.
task_svc.reassign.assert_not_called()
# ---------------------------------------------------------------------------
# _pm_next_hint: each branch (lines 1612-1616)
# ---------------------------------------------------------------------------
def test_pm_next_hint_pending() -> None:
deps = _make_deps()
c = Choreographer(deps)
hint = c._pm_next_hint("pending", "tid")
assert "i_will_plan" in hint
def test_pm_next_hint_paused() -> None:
deps = _make_deps()
c = Choreographer(deps)
hint = c._pm_next_hint("paused", "tid")
assert "subtasks" in hint or "complete" in hint
def test_pm_next_hint_blocked() -> None:
deps = _make_deps()
c = Choreographer(deps)
hint = c._pm_next_hint("blocked", "tid")
assert "unblock" in hint
def test_pm_next_hint_awaiting_pm_review() -> None:
deps = _make_deps()
c = Choreographer(deps)
hint = c._pm_next_hint("awaiting_pm_review", "tid")
assert "complete" in hint
def test_pm_next_hint_unknown_status() -> None:
deps = _make_deps()
c = Choreographer(deps)
hint = c._pm_next_hint("unknown_status", "tid")
assert "unknown_status" in hint
# ---------------------------------------------------------------------------
# triage_all main_pm — awaiting Main PM tasks branch (lines 1662-1663)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_triage_all_returns_awaiting_main_pm_when_no_blocked() -> None:
pm_id = uuid4()
awaiting_task = MagicMock(
id=uuid4(), status="awaiting_pm_review", title="x", team="backend"
)
task_svc = AsyncMock()
task_svc.list_blocked_all_teams.return_value = []
task_svc.list_awaiting_main_pm_all.return_value = [awaiting_task]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.triage_all(pm_id)
body = env.as_dict()
assert body["task_id"] == str(awaiting_task.id)
assert "complete" in body["next"]
# ---------------------------------------------------------------------------
# unblock: task not found (line 1682)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unblock_task_not_found() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.unblock(pm_id, task_id)
body = env.as_dict()
assert body["error"] == "not_found"
# ---------------------------------------------------------------------------
# _cell_pm_complete_guard: wrong status (line 1743)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_wrong_status() -> None:
"""Line 1743: status not awaiting_pm_review."""
pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="in_progress",
assigned_to=pm_id,
title="t",
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# cell_pm_complete: task not found (line 1782)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_not_found() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "not_found"
# ---------------------------------------------------------------------------
# _maybe_advance_parent_to_pm_review: silent skips (lines 1834, 1840, 1843)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_maybe_advance_parent_skips_when_parent_missing() -> None:
parent_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c._maybe_advance_parent_to_pm_review(parent_id, "backend")
task_svc.reassign.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_advance_parent_skips_when_subtasks_not_terminal() -> None:
parent_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = MagicMock(team="backend")
task_svc.all_subtasks_terminal.return_value = False
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c._maybe_advance_parent_to_pm_review(parent_id, "backend")
task_svc.reassign.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_advance_parent_skips_when_no_team() -> None:
parent_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = MagicMock(team=None)
task_svc.all_subtasks_terminal.return_value = True
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
# leaf_team also None → triggers line 1840 short-circuit.
await c._maybe_advance_parent_to_pm_review(parent_id, None)
task_svc.reassign.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_advance_parent_skips_when_no_pm_for_team() -> None:
parent_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = MagicMock(team="backend")
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_for_team.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c._maybe_advance_parent_to_pm_review(parent_id, "backend")
task_svc.reassign.assert_not_called()
# ---------------------------------------------------------------------------
# _main_pm_complete_guard: not assigned (1851), wrong status (1859)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_main_pm_complete_not_assigned() -> None:
main_pm_id = uuid4()
other_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="awaiting_pm_review",
assigned_to=other_id,
parent_task_id=None,
title="t",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(main_pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "not_authorized"
@pytest.mark.asyncio
async def test_main_pm_complete_wrong_status() -> None:
main_pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="in_progress",
assigned_to=main_pm_id,
parent_task_id=None,
title="t",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(main_pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# _main_pm_complete_guard: missing decision journal (lines 1885-1889)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_main_pm_complete_missing_journal_decision() -> None:
main_pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="awaiting_pm_review",
assigned_to=main_pm_id,
parent_task_id=None,
title="t",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
journal = AsyncMock()
journal.has_decision_for_task.return_value = False
deps = _make_deps(task=task_svc, journal=journal)
c = Choreographer(deps)
env = await c.main_pm_complete(main_pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "tracing_gap"
# ---------------------------------------------------------------------------
# main_pm_complete: not_found (line 1908)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_main_pm_complete_not_found() -> None:
main_pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(main_pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "not_found"
# ---------------------------------------------------------------------------
# _emit_rejection: correlation_id from contextvars (line 170)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_emit_rejection_includes_correlation_id() -> None:
"""When structlog contextvars holds a correlation_id, it gets stamped."""
deps = _make_deps()
c = Choreographer(deps)
rejection = Envelope.invalid_state(message="m", remediate="r", context_briefing={})
structlog.contextvars.bind_contextvars(correlation_id="cid-123")
try:
await c._emit_rejection(rejection, agent_id=uuid4(), task_id=None, verb="x")
finally:
structlog.contextvars.unbind_contextvars("correlation_id")
deps.audit.log_event.assert_awaited()
call_kwargs = deps.audit.log_event.await_args.kwargs
assert call_kwargs["details"]["correlation_id"] == "cid-123"
# ---------------------------------------------------------------------------
# _i_will_work_on_claimed: guard returns (line 451) — already-active blocker
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claimed_branch_already_active_guard() -> None:
"""Line 451: in_progress task elsewhere blocks claim of another."""
agent_id = uuid4()
task_id = uuid4()
other_id = uuid4()
in_prog = MagicMock(id=other_id, status="in_progress", title="other")
task_svc = _wire_dev_task_svc(
task_id, status="claimed", assigned_to=agent_id, plan="p"
)
task_svc.list_in_progress_for_agent.return_value = [in_prog]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok")
body = env.as_dict()
assert body["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# Pure helper: skill matching string entries (lines 802-803)
# ---------------------------------------------------------------------------
def test_resolve_skill_string_entries() -> None:
deps = _make_deps()
c = Choreographer(deps)
agent = MagicMock(skills=["python", "rust"], capabilities=None)
result = c._resolve_skill(agent, ["go", "python"])
assert result == "python"
# ---------------------------------------------------------------------------
# i_will_plan: claim returns None for pending task (line 1155 — emit_rejection)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_will_plan_pending_claim_returns_none_emit_rejection() -> None:
"""Forces the await self._emit_rejection in the failed-claim branch."""
pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="pending",
assigned_to=pm_id,
plan=None,
id=task_id,
title="t",
team="backend",
parent_task_id=None,
task_type="planning",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
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.claim.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(pm_id, task_id, plan="my plan that is long enough")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "claim failed" in body["message"]
# ---------------------------------------------------------------------------
# _submit_up_ownership_guard: not_assigned (line 1520)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_up_not_assigned_rejected() -> None:
"""Line 1520: cell_pm calling submit_up but task assigned to another agent."""
pm_id = uuid4()
task_id = uuid4()
other_id = uuid4()
task = MagicMock(
status="in_progress",
assigned_to=other_id,
title="t",
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.submit_up(pm_id, task_id, notes="x" * 30)
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "not assigned" in body["message"]
# ---------------------------------------------------------------------------
# Task 3: Envelope introspection — verb returns carry current_state +
# valid_next_verbs so agents stop trial-and-erroring.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_will_work_on_envelope_carries_introspection_on_success() -> None:
"""Successful claim+start path stamps current_state + valid_next_verbs."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(task_id, status="pending", assigned_to=agent_id)
claimed_task = MagicMock(
status="in_progress",
assigned_to=agent_id,
plan="ok plan",
id=task_id,
title="t",
task_type="code",
)
task_svc.claim.return_value = claimed_task
task_svc.set_plan.return_value = claimed_task
task_svc.start.return_value = claimed_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok plan")
body = env.as_dict()
assert body["error"] is None
assert body["current_state"] == "in_progress"
assert isinstance(body["valid_next_verbs"], list)
assert "commit" in body["valid_next_verbs"]
assert "i_am_done" in body["valid_next_verbs"]
@pytest.mark.asyncio
async def test_i_will_work_on_envelope_carries_introspection_on_rejection() -> None:
"""A wrong-state rejection still stamps current_state + valid_next_verbs
so the agent learns what verbs are actually valid right now."""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(
task_id, status="completed", assigned_to=agent_id
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="x")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert body["current_state"] == "completed"
assert isinstance(body["valid_next_verbs"], list)
# Lifecycle verbs are NOT in the list for a completed task.
assert "i_will_work_on" not in body["valid_next_verbs"]