fix(orchestrator): un-deadlock a CEO-rejected coordination root

A coordination root (team=main_pm, product-linked, no repo) the CEO sends back
lands in needs_revision, but the dev dispatcher skips it (not a cell team) and
the closure path only handles paused parents — so it sat in needs_revision
forever. (NOT a foundation-spec gap: the spec already allows needs_revision ->
claimed for any role.)

- _dispatch_revision_coordination_roots: re-spawn the owning PM for a
  needs_revision coordination root so it re-coordinates the revision (registered
  in the dispatch loop after PM closure)
- _readiness_check_role_for_status: widen the dev-owned states (needs_revision,
  verifying) to also accept cell_pm/main_pm for coordination roots — a pure
  widening; normal code tasks stay dev/doc-only
- 16 unit tests (dispatcher decision + readiness widening)
This commit is contained in:
Renn F
2026-06-17 16:50:10 +02:00
parent 748ff7813e
commit eec35c0357
3 changed files with 189 additions and 3 deletions
@@ -73,3 +73,60 @@ def test_unmapped_status_allows_any_role() -> None:
)
is None
), f"role={role} status={status} should not be rejected by this gate"
# ---------------------------------------------------------------------------
# Coordination roots — a CEO-rejected coordination root returns to its PM (#5).
# ---------------------------------------------------------------------------
def test_pm_on_needs_revision_coordination_allowed() -> None:
"""A coordination root in needs_revision belongs to its PM, not a dev."""
for role in ("main_pm", "cell_pm"):
assert (
AgentOrchestrator._readiness_check_role_for_status(
agent_id="main-pm",
role=role,
status="needs_revision",
is_coordination=True,
)
is None
)
def test_pm_on_verifying_coordination_allowed() -> None:
assert (
AgentOrchestrator._readiness_check_role_for_status(
agent_id="main-pm", role="main_pm", status="verifying", is_coordination=True
)
is None
)
def test_dev_on_needs_revision_coordination_still_allowed() -> None:
"""Widening is additive — developer is still accepted."""
assert (
AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-dev-1",
role="developer",
status="needs_revision",
is_coordination=True,
)
is None
)
def test_qa_on_needs_revision_coordination_still_blocked() -> None:
"""The widening only adds the PM roles — QA is still a misroute."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-qa", role="qa", status="needs_revision", is_coordination=True
)
assert reason is not None
def test_pm_on_needs_revision_noncoordination_still_blocked() -> None:
"""A normal (code) needs_revision task is still dev/doc-only for a PM."""
reason = AgentOrchestrator._readiness_check_role_for_status(
agent_id="be-pm", role="cell_pm", status="needs_revision", is_coordination=False
)
assert reason is not None
@@ -0,0 +1,87 @@
"""_dispatch_revision_coordination_roots — un-deadlock a CEO-rejected root (#5).
A coordination root (team=main_pm, product-linked, no repo) the CEO sends back
lands in needs_revision. The dev dispatcher skips it (not a cell team) and the
closure path only handles paused parents, so without this dispatcher it sits
forever. This re-spawns its owning PM so it re-coordinates the revision.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
import roboco.runtime.orchestrator as orch_mod
from roboco.runtime.orchestrator import AgentOrchestrator
def _orch(
tasks: list[dict[str, Any]], *, slug: str, active: bool
) -> tuple[AgentOrchestrator, AsyncMock]:
"""A bare orchestrator with its dispatch helpers mocked; returns (orch, spawn)."""
orch = object.__new__(AgentOrchestrator)
spawn = AsyncMock()
object.__setattr__(orch, "_fetch_tasks", AsyncMock(return_value=tasks))
object.__setattr__(
orch, "_is_task_handled_this_tick", MagicMock(return_value=False)
)
object.__setattr__(orch, "_resolve_agent_slug", MagicMock(return_value=slug))
object.__setattr__(orch, "_is_agent_active", MagicMock(return_value=active))
object.__setattr__(orch, "_get_prompt_for_agent", MagicMock(return_value="p"))
object.__setattr__(orch, "_task_git_context", MagicMock(return_value=None))
object.__setattr__(orch, "spawn_agent", spawn)
return orch, spawn
def _task() -> dict[str, Any]:
return {
"id": "t1",
"status": "needs_revision",
"assigned_to": "u1",
"team": "main_pm",
}
@pytest.mark.asyncio
async def test_respawns_pm_for_rejected_coordination_root(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(orch_mod, "_is_coordination_task", lambda _t: True)
orch, spawn = _orch([_task()], slug="main-pm", active=False)
await orch._dispatch_revision_coordination_roots(MagicMock())
spawn.assert_awaited_once()
call = spawn.await_args
assert call is not None
assert call.kwargs["agent_id"] == "main-pm"
assert call.kwargs["task_id"] == "t1"
@pytest.mark.asyncio
async def test_skips_non_coordination_needs_revision(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# A normal (code) needs_revision task → left to the dev dispatcher.
monkeypatch.setattr(orch_mod, "_is_coordination_task", lambda _t: False)
orch, spawn = _orch([_task()], slug="be-dev-1", active=False)
await orch._dispatch_revision_coordination_roots(MagicMock())
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_skips_when_pm_already_active(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(orch_mod, "_is_coordination_task", lambda _t: True)
orch, spawn = _orch([_task()], slug="main-pm", active=True)
await orch._dispatch_revision_coordination_roots(MagicMock())
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_skips_non_pm_owner(monkeypatch: pytest.MonkeyPatch) -> None:
# A coordination root owned by a non-PM role → role guard skips it.
monkeypatch.setattr(orch_mod, "_is_coordination_task", lambda _t: True)
orch, spawn = _orch([_task()], slug="be-dev-1", active=False)
await orch._dispatch_revision_coordination_roots(MagicMock())
spawn.assert_not_awaited()