Files
roboco/tests/unit/runtime/test_per_dev_lane_queue.py
T
8f3f4236c0 feat(tasks): sequence is the bar — strict sibling ordering at the claim chokepoint (#452)
* feat(tasks): enforce sibling sequence order at the claim chokepoint

A task with a parent and effective sequence N (COALESCE(sequence, 0))
can no longer be claimed while any sibling with a strictly lower
effective sequence is non-terminal — assignee-blind, independent of and
stricter than dependency_ids, enforced in _validate_claim_preconditions
so both claim paths (gateway verbs and the dispatcher's raw REST claim)
cross it. Ties run parallel; cancelled siblings never block; sequence 0
and parentless tasks are unaffected. Live failure this guards: a PM
delegated revision subtasks sequenced 0..3 with no dependency edges and
seq 2 started alongside seq 0 — sequence was advisory-only.

set_sequence's contract updated accordingly. New e2e smoke case drives
the refusal and the post-completion claim through the real gateway.

* chore(scripts): skip .uv-cache and .claude in the prose scanner

Repo-local tool dirs (private uv cache, agent worktrees) carry vendored
and generated markdown that tripped make reflow-check.

* fix(tasks): wave-derived delegation sequences + claim-gate hardening

Three fixes from the adversarial review of the sequence claim gate:

Delegation no longer stamps a raw per-sibling ordinal (deterministic
merge-order bookkeeping) as sequence — under the strict gate that
serialized ALL delegated work, including fully independent cross-dev and
cross-cell siblings. Sequences are now wave-derived post-wiring
(stamp_wave_sequence: 1 + max same-parent dependency sequence, 0 when
independent), so independent siblings tie and run parallel while
colliding/ordered work ascends. The cross-cell UX wiring restamps
instead of writing relative ux+1 values (a relative write could invert
a collision-derived stamp), and the dispatch merge/lane barriers gain a
created_at tiebreak for wave-tied siblings so shared-branch merge order
stays deterministic. PM-authored sequences are never rewritten.

The guard now also fires on reclaims from needs_revision (a lower-
sequence sibling delegated after the first claim was invisible), and
tasks.parent_task_id gains an index (migration 069) — the guard's
sibling probe ran as a Seq Scan on the hottest verb.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-10 22:55:00 +02:00

225 lines
7.8 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 datetime import UTC, datetime
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:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
return orch
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_equal_sequence_same_dev_tiebreaks_by_created_at() -> None:
"""Wave ties in a dev's own lane order by created_at (mirroring the merge
barrier): the earlier-created tied sibling holds the later one; the
later-created one does not hold the earlier."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
task["created_at"] = "2026-07-10T12:00:00+00:00"
earlier = _sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)
earlier.created_at = datetime(2026, 7, 10, 11, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([earlier])
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is True
later = _sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)
later.created_at = datetime(2026, 7, 10, 13, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([later])
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()