Files
roboco/tests/unit/gateway/test_choreographer_impl_branches.py
T
cfde4369b1 Token optimization levers — claim-scoped briefing, payload caps, role-scoped optimal, notification-spawn cooldown (#292)
* feat(gateway): claim-scoped context briefing — heavy sections only on context-acquisition verbs

* feat(gateway): cap unbounded LLM-facing payloads — embedded diffs, notification bodies, handoff journal content, north star

* feat(mcp): role-scope the optimal server's tool groups; index management becomes dev/test-only

* feat(mcp): cap per-result content on kb/error/learning search, mentor sources, rag citations

* refactor(gateway): extract heavy-briefing sections + clip helper to keep xenon ranks

* feat(orchestrator): cross-tick cooldown for notification-triggered spawns

* feat(usage,orchestrator): scope spawn-waste to anthropic sessions; cap agent Bash output via settings env

* docs: claim-scoped briefing, payload caps, optimal role-scoping, notification-spawn cooldown

* test(mcp): type the mixed-item cap fixture explicitly

* fix(orchestrator): lazy-init the notification-spawn cooldown store

* fix(lifecycle): admin-override claim reconciliation + PM request_changes verb (S6 postmortem B3+B4)

B3 — admin_set_status now reconciles claim ownership when leaving BLOCKED:
review/queue targets clear claimed_by/claimed_at/active_claimant_id and
consume the pre-block snapshot (a stale escalation claim was stranding the
next claimant: give_me_work handed the task out while note() bounced
not_authorized — the live b8fe0494 wedge). The pending/in_progress restore
path also syncs active_claimant_id, and a REST PATCH unassign releases the
claim with it.

B4 — new PM verb request_changes: awaiting_pm_review -> needs_revision with
concrete issues. The PM previously had no reject at merge review (only
complete/escalate), so an AC/scope violation looped i_am_blocked->escalate
4x live. Full vertical: lifecycle transition + ActionSpec + IntentSpec,
TaskService.request_changes (routes like a QA fail — original dev for a
leaf, revision PM for assembled; issues appended to dev_notes), verb-runner
compose, choreographer verb (spec gate + non-empty issues + soup check +
a2a delivery of the reject reason), HTTP routes on both PM flows, MCP tool,
journal:decision tracing, PM prompts, regenerated lifecycle artifacts.

* fix(panel): stop scorecard fetches for fallback-roster placeholder ids

useAgents() serves the static AGENT_ROSTER (ids "1".."22") while agent
definitions load; the Scorecards tab fetched a member scorecard per row
immediately, firing 22 guaranteed-422 requests per refetch cycle. Through
the browser's per-origin connection limit those queued every metrics-page
query behind them (~10s of skeletons on every tab). Gate the fetch on a
real member id (agent UUID or the "ceo" alias).

* Upgraded uv.lock

* fix(sequencing): declared deps become real edges + full loop-breaker coverage + assembled-branch freshness (S6 postmortem B1/B2/B6 + breaker)

B1a — code delegations REQUIRE a collision surface: new TASK_AT_DELEGATE
completeness spec (conditional FieldRequirement, when=('task_type','code'))
enforced at the gateway delegate gate. A no-surface code sibling is
'parallel to everything' by analyzer design, which is how two devs ran the
CEO's explicitly-ordered work out of order (f3e1afc5: seq#1 started before
seq#0, zero dependency edges). PM prompts updated; REST/manual creation
(TASK_AT_CREATE) unchanged.

B1b — the CEO's declared 'Depends on' lists become real edges: DraftSurface
gains declared_depends_on; SequencingService.analyze unions declared edges
(validated: self/out-of-range rejected) with the derived collision rules,
cycle-checked by the existing toposort. confirm_live_batch/preview_batch
map each draft's depends_on through (string indices coerced); intake tool
doc + prompter role prompt instruct verbatim copying. The live S6 root got
1 of its 3 declared in-batch edges and started alongside still-running R3.

Breaker coverage — the progress-aware respawn circuit breaker
(_pm_respawn_should_gate: strike counting, status-advance reset,
tracing-gap budget, DB durability, one-shot CEO notification) was consulted
by only 3 spawn paths; the doc/QA/dev/PR-review/PR-gate/revision/board
paths spawned unguarded at fixed cadence (the 26-respawn fe-doc loop,
~$7.20). Now consulted at every task-keyed spawn site (14 total).

B2 — assembled-branch freshness: submit_up/submit_root auto-sync the
assembled branch when it has fallen behind its base (children are terminal
at submit time, so the rebase is safe; master is never written). A rebase
conflict is a hard reject naming the files instead of a blind re-review —
kills the needs_revision↔awaiting_pr_review ping-pong of re-submitting a
stale head. Leaf i_am_done already had the behind-base gate; claim-time
fetch-fresh cut already existed.

B6 — documenter revision-pass loop: the awaiting_documentation bail
rejections (i_am_blocked/unclaim) now name the actual exit (i_documented
re-affirm) and the documenter prompt gets an explicit revision-pass rule.

* fix(orchestration): assembly-integrity gate + dispatcher heartbeat (incidents #11, #1)

Assembly integrity — submit_up/submit_root refuse when a completed child's
commits are not patch-present in the assembled branch (git cherry —
rebase-safe; branch pruned after merge or any git error fails open). Live
incident #11: a completed revert subtask's merge was lost from the cell
branch and the review gate re-flagged the exact violation the revert fixed,
spawning another revision cycle.

Dispatcher heartbeat — a dispatcher.alive audit row every 5 minutes from
the dispatch loop. The 2026-07-01 outage was 4h25m of fleet-wide silence
with no way to distinguish 'loop dead' from 'no work'; the loop's stdout
died with the container while audit_log survives. CHANGELOG for tonight's
full sweep included.

* style: ruff format for the orchestration sweep

* refactor(gateway): fold the assembled-submit guards + trim complexity under the xenon gate

_assembled_submit_guards combines the #11 integrity check and B2 freshen for
submit_up/submit_root; lifecycle's invalid-source remediate and git's
per-child cherry probe extracted into helpers. Test harnesses built via
__new__ stub the respawn tracker (the breaker now runs on their paths).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-02 05:46:01 +02:00

1394 lines
49 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 datetime import UTC, datetime
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
# #172: a developer fresh claim must carry a substantive step checklist.
# Inert on re-entry/error/non-dev paths (the gate is skipped or the call
# short-circuits before it), so it is safe to pass everywhere.
_STEPS = [
{
"title": "Implement the change",
"description": (
"edit the target file, add tests, run them, and stage the "
"change for commit on the task branch"
),
}
]
# Full parity: a fresh dev claim authors the same rich plan a PM does.
# These satisfy _dev_plan_gate (plan/approach >= 150 chars,
# technical_considerations, risks).
_GOOD_PLAN = (
"Append the timestamp HTML comment to the very bottom of README.md without "
"touching any other line, then commit it on the task branch and open a PR. "
"Verify the diff is a single-line addition before submitting for QA."
)
_GOOD_TC = ["Use a trailing newline so the comment sits on its own line."]
_GOOD_RISKS = [
{
"risk": "An accidental reformat of README.md balloons the diff.",
"mitigation": "Append only; assert the diff touches one line pre-commit.",
}
]
def _wire_dev_task_svc(
task_id: Any,
*,
status: str,
assigned_to: Any = None,
plan: Any = None,
parent_task_id: Any = None,
) -> AsyncMock:
"""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.
Also wires ``session.begin_nested()`` so VerbRunner's savepoint context
manager works against the mock.
"""
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",
commits=[],
pr_number=None,
branch_name="feature/backend/abc",
quick_context=None,
)
task_svc.agent_for.return_value = MagicMock(
role="developer", team="backend", slug=None
)
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.session = MagicMock()
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
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)
# VerbRunner wraps composed atomic actions in
# ``task.session.begin_nested()``. AsyncMock auto-attribute access
# would return an unawaitable coroutine, breaking the
# ``async with`` protocol. Overwrite session with a MagicMock that
# implements the async-context-manager protocol explicitly.
task_dep = base["task"]
task_dep.session = MagicMock()
task_dep.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
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 = []
# C8: default-fresh journal:decision so PM-decision gate passes.
# Tests that exercise the gate boundary stub their own value.
# The check matches MagicMock and AsyncMock (the two default sentinel
# types pytest's unittest.mock leaves on un-stubbed return_values).
_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)
# ---------------------------------------------------------------------------
# _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:
"""When the runner re-raises a RuntimeError from claim(), the verb body
catches it and surfaces an invalid_state envelope with the runner's
message. Pre-spec the verb body produced "claim failed during
finalization"; the spec-driven body produces "verb runner failed:
<exc>" so the agent still gets a remediation hint instead of a 500.
"""
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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "verb runner failed" in body["message"]
assert "workspace down" 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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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, steps=_STEPS)
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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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", steps=_STEPS)
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", steps=_STEPS)
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_pm_exempt_from_already_active_guard() -> None:
"""A PM coordinator is exempt from already_active_guard on i_will_plan: it
plans + delegates many roots in parallel, so holding one in_progress root
must NOT block planning another. (The guard still fires for developers — see
test_choreographer_claim_guards.py.) Repurposed from the pre-fix test that
asserted the now-removed PM block.
"""
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",
)
started = MagicMock(
id=task_id,
status="in_progress",
plan={"text": "x"},
assigned_to=pm_id,
title="t",
team="backend",
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 = []
task_svc.get_subtasks.return_value = []
task_svc.claim.return_value = target
task_svc.set_plan.return_value = target
task_svc.start.return_value = started
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="x" * 30,
rich_plan={
"approach": (
"Decompose the planning task into backend and frontend "
"developer-claimable subtasks. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
assert body.get("error") is None, body
task_svc.start.assert_awaited()
@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.",
rich_plan={
"approach": (
"Split code-typed parent into developer-claimable subtasks: "
"one for API, one for test coverage validation."
),
"sub_tasks": [
{"title": "API subtask", "description": "Implement the endpoint"},
],
},
)
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",
rich_plan={
"approach": (
"Decompose the planning task into backend and frontend "
"developer-claimable subtasks. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
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="Implement endpoint",
description="Add /v1/foo endpoint with passing tests please",
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
intends_to_touch=["backend/api/routers/foo.py"],
),
)
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="Implement endpoint",
description="Add /v1/foo endpoint with passing tests please",
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
intends_to_touch=["backend/api/routers/foo.py"],
),
)
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:
"""A parent with NEITHER a project_id NOR a product_id → invalid_state.
(A parent with a product_id but no project is allowed — subtasks resolve a
repo from the product map — so the guard now requires both to be None.)
"""
pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
status="in_progress",
assigned_to=pm_id,
project_id=None,
product_id=None,
title="p",
# delegate obligates the PM's quick_context; supply it so the
# no-project guard is the load-bearing rejection.
quick_context="Decomposition planned; cells implement their slice next.",
)
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="Implement endpoint",
description="Add /v1/foo endpoint with passing tests please",
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
intends_to_touch=["backend/api/routers/foo.py"],
),
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "project_id" in body["message"] and "product_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
journal.latest_decision_at.return_value = datetime.now(UTC)
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
journal.latest_decision_at.return_value = datetime.now(UTC)
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"]
# ---------------------------------------------------------------------------
# _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, "block resolved upstream; restoring")
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:
# #183: in_progress is now an accepted source (root resumed from paused);
# use paused — a genuinely non-completable status — to exercise the guard.
main_pm_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="paused",
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
journal.latest_decision_at.return_value = None
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", steps=_STEPS)
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:
"""When claim() returns None inside the runner, the savepoint rolls
back and the runner-failure path surfaces as invalid_state. Pre-spec
this branched into a hand-rolled "claim failed" message; now it is
emitted via _claim_plan_start_run's exception handler.
"""
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",
quick_context=None,
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(
id=pm_id, role="cell_pm", team="backend", slug=None
)
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",
rich_plan={
"approach": (
"Decompose the planning task into backend and frontend "
"developer-claimable subtasks. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "verb runner 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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
body = env.as_dict()
assert body["error"] is None
assert body["current_state"] == "in_progress"
assert isinstance(body["valid_next_verbs"], list)
# `valid_next_verbs` lists lifecycle INTENT verbs; `commit` is a
# content tool (do_server), not an intent, so the canonical spec
# excludes it. `open_pr` and `i_am_done` are the in_progress intents.
assert "open_pr" 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", steps=_STEPS)
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"]
@pytest.mark.asyncio
async def test_open_pr_does_not_create_pr_if_no_commits() -> None:
"""Atomic invariant: if commits[] is empty, open_pr must NOT call
git.create_pr. Pre-fix this was already true at the verb level, but
this test pins it as a regression: any future refactor that
re-orders precondition vs side effect breaks the test."""
dev_id = uuid4()
task_id = uuid4()
task = MagicMock(
status="in_progress",
assigned_to=dev_id,
commits=[],
pr_number=None,
branch_name="feature/backend/abc",
id=task_id,
title="t",
team="backend",
task_type="code",
)
task_svc = AsyncMock()
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.create_pr = AsyncMock()
git_svc.push_branch = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
env = await c.open_pr(dev_id, task_id)
body = env.as_dict()
# Spec's PRECONDITION_COMMITS now produces tracing_gap rather than the
# previous bespoke invalid_state. The atomicity invariant the test
# pins (no git side effect when commits=[]) is unchanged.
assert body["error"] == "tracing_gap"
assert body["missing"] == ["commits>=1"]
git_svc.create_pr.assert_not_called()
git_svc.push_branch.assert_not_called()
@pytest.mark.asyncio
async def test_i_will_work_on_missing_plan_does_not_claim_pending_task() -> None:
"""Atomic invariant (Task 5 pattern, Bug A from 2026-05-09 smoke):
if `plan` is missing on the FIRST i_will_work_on call against a
pending task, the task must NOT be claimed. Pre-fix the verb ran
claim() BEFORE checking plan, leaving the task in `claimed` with
no plan — and `_i_will_work_on_claimed` had no recovery path so
the agent looped forever on `start failed`.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(task_id, status="pending")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan=None, steps=_STEPS)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "plan" in body["missing"]
(
task_svc.claim.assert_not_called(),
("claim() ran before plan precondition was satisfied — atomicity broken"),
)
@pytest.mark.asyncio
async def test_i_will_work_on_claimed_with_no_plan_accepts_recovery_plan() -> None:
"""Recovery path (Bug A from 2026-05-09 smoke): if the task is in
`claimed` state without a plan (e.g. from a prior partial-claim race
or an orchestrator restart), a fresh i_will_work_on call WITH plan
must set the plan and then start, not just call start() against a
plan-less task.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = _wire_dev_task_svc(
task_id, status="claimed", assigned_to=agent_id, plan=None
)
started = MagicMock(
status="in_progress",
assigned_to=agent_id,
plan="recovery plan",
id=task_id,
title="t",
task_type="code",
)
task_svc.set_plan.return_value = started
task_svc.start.return_value = started
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="recovery plan", steps=_STEPS)
body = env.as_dict()
assert body["error"] is None, f"expected success, got {body}"
task_svc.set_plan.assert_awaited_once()
# ---------------------------------------------------------------------------
# _pending_assignment_guard: board/advisory roles can idle without claiming
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize("role", ["product_owner", "head_marketing", "auditor"])
async def test_pending_assignment_guard_exempts_board_roles(role: str) -> None:
"""A board/advisory agent that reviewed a still-pending coordination task
has no i_will_work_on/i_will_plan verb, so the idle gate must let it pass."""
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [
MagicMock(id=uuid4(), status="pending")
]
task_svc.agent_for.return_value = MagicMock(role=role, team=None, slug=None)
c = Choreographer(_make_deps(task=task_svc))
assert await c._pending_assignment_guard(uuid4(), {}) is None
@pytest.mark.asyncio
async def test_pending_assignment_guard_still_blocks_developer() -> None:
"""A developer holding a pending unclaimed task is still told to claim it."""
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [
MagicMock(id=uuid4(), status="pending")
]
task_svc.agent_for.return_value = MagicMock(
role="developer", team="backend", slug=None
)
c = Choreographer(_make_deps(task=task_svc))
guard = await c._pending_assignment_guard(uuid4(), {})
assert guard is not None
assert guard.as_dict()["error"] == "invalid_state"