Files
roboco/tests/unit/runtime/test_per_dev_lane_queue.py
T
Renn F e209e285b8 feat(dispatch): per-dev sequenced queues for code subtasks (guardrails spec 3)
True two-dev parallelism: a cell PM delegates the FULL set of code units up
front — each dev gets its own queue, both build at the same time, each works
its queue one task at a time in order. Replaces the old ceiling (≤2 code
subtasks per parent, one per dev) which structurally forced under-decomposition.

- Cap: `code` removed from `_SPINE_TYPE_CAPS` — no per-parent code cap (total
  fan-out still bounded by `_SUBTASK_HARD_CAP=12`); `planning`/`documentation`
  stay sequential at 1. `_same_assignee_rejection` exempts `code` so a dev may
  own a queue, but still rejects an exact same-title duplicate (the accidental
  re-delegation bug). `_spine_type_dup_envelope` simplified to the sequential
  spine it now only serves.
- Dispatch barrier: `_blocked_by_earlier_lane_sibling` holds a dev's
  higher-sequence pending code leaf while it still has an earlier non-terminal
  code sibling under the same parent (keyed on assignee, gates only code) — the
  dev works its queue in order. Wired into `_spawn_pending_dev`. Loop-free
  (skip the tick, no reject/respawn) and best-effort (lookup failure → dispatch),
  mirroring the existing merge barrier. The merge barrier is unchanged: leaf
  PRs still merge serially in sequence order into the shared cell branch, so the
  independent build lanes never wedge it.
- Prompt: cell_pm role guidance rewritten from the two-subtask-cap model to the
  per-dev-queue model (delegate all units now; dependent units go in one dev's
  queue, upstream first).

Independent per-dev queues (each lane advances at its own pace) rather than
strict cross-dev wave-sync, by design — more parallel and leaves the
wedge-prone merge barrier untouched. Pairs with the spec-2 idle coverage gate:
removing the code cap lets a PM claim every criterion up front, so that gate is
always satisfiable.
2026-06-16 04:10:05 +02:00

200 lines
6.7 KiB
Python

"""Per-dev sequenced queues: a dev works its own code queue one task at a time.
A PM delegates a full per-dev queue of `code` subtasks up front. The dispatch
barrier holds a dev's higher-sequence code leaf until its own lower-sequence
code siblings under the same parent are terminal, so the dev works its queue in
order — while the OTHER dev's lane runs concurrently (two-dev parallelism).
Keyed on the assignee (not the team like the merge barrier) and gates only
`code`. Loop-free (not dispatched, not rejected); best-effort on lookup failure.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.runtime.orchestrator import AgentOrchestrator
def _new_orchestrator() -> AgentOrchestrator:
return AgentOrchestrator.__new__(AgentOrchestrator)
def _sibling(
seq: int,
owner: str,
status: TaskStatus,
*,
task_type: str = "code",
) -> MagicMock:
return MagicMock(
id=uuid4(),
sequence=seq,
assigned_to=owner,
status=status,
task_type=task_type,
)
def _patch_siblings(siblings: list[MagicMock]) -> Any:
"""Patch the orchestrator's direct-DB sibling lookup to return ``siblings``."""
svc = MagicMock()
svc.get_subtasks = AsyncMock(return_value=siblings)
class _CM:
async def __aenter__(self) -> MagicMock:
return MagicMock()
async def __aexit__(self, *_a: Any) -> bool:
return False
factory = MagicMock(return_value=_CM())
return (
patch("roboco.db.base.get_session_factory", return_value=factory),
patch("roboco.services.task.get_task_service", return_value=svc),
)
def _task(seq: int, owner: str, *, task_type: str = "code") -> dict[str, Any]:
return {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": seq,
"assigned_to": owner,
"task_type": task_type,
}
@pytest.mark.asyncio
async def test_blocks_when_same_dev_has_earlier_active_code_sibling() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [_sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is True
@pytest.mark.asyncio
async def test_not_blocked_when_earlier_same_dev_sibling_terminal() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [
_sibling(0, "be-dev-1", TaskStatus.COMPLETED),
_sibling(0, "be-dev-1", TaskStatus.CANCELLED),
]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_other_devs_earlier_sibling_does_not_block() -> None:
"""The whole point of two-dev parallelism: be-dev-2's in-flight wave-0 leaf
must NOT hold be-dev-1's own wave-0 leaf. Lanes are independent."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
siblings = [_sibling(0, "be-dev-2", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_earlier_non_code_sibling_does_not_block() -> None:
"""Only the code queue is gated this way; a planning/doc sibling is irrelevant."""
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [_sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS, task_type="planning")]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_higher_sequence_same_dev_sibling_does_not_block() -> None:
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
siblings = [_sibling(1, "be-dev-1", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_non_code_task_is_never_gated_without_db() -> None:
orch = _new_orchestrator()
# A planning/doc task short-circuits before any lookup.
task = _task(1, "fe-pm", task_type="planning")
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_no_parent_or_owner_short_circuits_without_db() -> None:
orch = _new_orchestrator()
assert (
await orch._blocked_by_earlier_lane_sibling(
{"id": str(uuid4()), "sequence": 0, "task_type": "code"}
)
is False
)
@pytest.mark.asyncio
async def test_db_failure_falls_through_to_dispatch() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
boom = patch(
"roboco.db.base.get_session_factory", side_effect=RuntimeError("db down")
)
with boom:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_spawn_pending_dev_holds_gated_lane_before_validating(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_spawn_pending_dev must short-circuit a gated lane before validating or
spawning — the dev's earlier queue item is still live."""
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
spawn = AsyncMock()
validate = AsyncMock()
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
monkeypatch.setattr(
orch, "_blocked_by_earlier_lane_sibling", AsyncMock(return_value=True)
)
monkeypatch.setattr(orch, "_validate_task_for_spawn", validate)
monkeypatch.setattr(orch, "spawn_agent", spawn)
await orch._spawn_pending_dev(cast("Any", MagicMock()), task, "be-dev-1")
spawn.assert_not_awaited()
validate.assert_not_awaited()
@pytest.mark.asyncio
async def test_spawn_pending_dev_proceeds_when_lane_clear(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the lane is clear (no earlier sibling), the dev is spawned normally."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
spawn = AsyncMock()
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
monkeypatch.setattr(
orch, "_blocked_by_earlier_lane_sibling", AsyncMock(return_value=False)
)
monkeypatch.setattr(orch, "_validate_task_for_spawn", AsyncMock(return_value=None))
monkeypatch.setattr(orch, "spawn_agent", spawn)
monkeypatch.setattr(orch, "_get_prompt_for_agent", MagicMock(return_value="prompt"))
monkeypatch.setattr(orch, "_task_git_context", MagicMock(return_value={}))
await orch._spawn_pending_dev(cast("Any", MagicMock()), task, "be-dev-1")
spawn.assert_awaited_once()