Files
roboco/tests/unit/gateway/test_choreographer_impl_branches.py
T

901 lines
30 KiB
Python
Raw Normal View History

2026-05-06 21:02:31 +02:00
"""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.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)
# ---------------------------------------------------------------------------
@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",
),
)
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",
),
)
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",
),
)
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"]