2026-06-29 11:32:34 +02:00
|
|
|
"""Per-dev lane barrier on the give_me_work -> claim path.
|
|
|
|
|
|
|
|
|
|
A developer with a pre-delegated sequenced code queue must not start a later
|
|
|
|
|
code leaf while an earlier same-assignee sibling is still open: that is the
|
|
|
|
|
out-of-order start that wedged the merge (a later PR cut from a base that
|
|
|
|
|
predates the earlier sibling's unmerged changes). ``i_am_idle`` already drops
|
|
|
|
|
lane-held leaves via ``_pending_not_lane_held``; this locks the same barrier
|
|
|
|
|
on ``give_me_work``'s pre-assigned path and on ``_run_claim_guards`` (the
|
|
|
|
|
direct claim verb), so neither route can jump the queue.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
from uuid import UUID, uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_deps(task: AsyncMock) -> ChoreographerDeps:
|
|
|
|
|
return ChoreographerDeps(
|
|
|
|
|
task=task,
|
|
|
|
|
work_session=AsyncMock(),
|
|
|
|
|
git=AsyncMock(),
|
|
|
|
|
a2a=AsyncMock(),
|
|
|
|
|
journal=AsyncMock(),
|
|
|
|
|
audit=AsyncMock(),
|
|
|
|
|
evidence_repo=AsyncMock(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dev_agent_task_svc() -> tuple[AsyncMock, UUID]:
|
|
|
|
|
task_svc = AsyncMock()
|
|
|
|
|
task_svc.agent_for.return_value = MagicMock(role="developer")
|
|
|
|
|
task_svc.list_pending_for_agent.return_value = []
|
|
|
|
|
task_svc.list_assigned_for_agent.return_value = []
|
|
|
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
|
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
|
|
|
# Default: lane clear (no earlier incomplete sibling).
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = False
|
|
|
|
|
return task_svc, uuid4()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# give_me_work: pre-assigned path must drop a lane-held code leaf
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_give_me_work_skips_lane_held_pre_assigned_dev_task() -> None:
|
|
|
|
|
"""A pre-assigned pending code leaf sitting behind an earlier open
|
|
|
|
|
same-assignee sibling is dropped (not offered); with nothing else
|
|
|
|
|
available the dev goes idle rather than jumping its queue."""
|
|
|
|
|
task_svc, agent_id = _dev_agent_task_svc()
|
|
|
|
|
leaf = MagicMock(id=uuid4(), status="pending", title="later-leaf")
|
|
|
|
|
task_svc.list_pending_for_agent.return_value = [leaf]
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = True
|
|
|
|
|
deps = _make_deps(task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
env = await c.give_me_work(agent_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["status"] == "idle"
|
|
|
|
|
assert body["task_id"] is None
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.assert_awaited_once_with(leaf)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_give_me_work_offers_pre_assigned_when_lane_clear() -> None:
|
|
|
|
|
"""A pre-assigned code leaf whose lane is clear (no earlier open sibling)
|
|
|
|
|
is offered as normal — the filter only drops positively lane-held leaves."""
|
|
|
|
|
task_svc, agent_id = _dev_agent_task_svc()
|
|
|
|
|
leaf = MagicMock(id=uuid4(), status="pending", title="ready-leaf")
|
|
|
|
|
task_svc.list_pending_for_agent.return_value = [leaf]
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = False
|
|
|
|
|
deps = _make_deps(task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
env = await c.give_me_work(agent_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["task_id"] == str(leaf.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_give_me_work_lane_filter_inert_under_partial_mock() -> None:
|
|
|
|
|
"""An AsyncMock stub returns a truthy non-bool (not ``True``); ``is not
|
|
|
|
|
True`` keeps the filter inert so a partial test mock never drops a leaf
|
|
|
|
|
it cannot positively confirm is lane-held."""
|
|
|
|
|
task_svc, agent_id = _dev_agent_task_svc()
|
|
|
|
|
leaf = MagicMock(id=uuid4(), status="pending", title="maybe-leaf")
|
|
|
|
|
task_svc.list_pending_for_agent.return_value = [leaf]
|
|
|
|
|
# Truthy stub, NOT the literal bool True -> inert (leaf kept).
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = MagicMock()
|
|
|
|
|
deps = _make_deps(task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
env = await c.give_me_work(agent_id)
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["task_id"] == str(leaf.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _run_claim_guards: a direct claim of a lane-held code task is refused
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _claim_task(
|
|
|
|
|
*, task_type: str = "code", dependency_ids: list[Any] | None = None
|
|
|
|
|
) -> Any:
|
|
|
|
|
return MagicMock(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
status="pending",
|
|
|
|
|
assigned_to=uuid4(),
|
|
|
|
|
parent_task_id=uuid4(),
|
|
|
|
|
task_type=task_type,
|
|
|
|
|
dependency_ids=dependency_ids or [],
|
|
|
|
|
team="backend",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_claim_guard_blocks_lane_held_code_task() -> None:
|
|
|
|
|
"""A direct claim of a code leaf with an earlier open same-assignee
|
|
|
|
|
sibling is refused (invalid_state) and parked back to pending."""
|
|
|
|
|
task_svc, agent_id = _dev_agent_task_svc()
|
|
|
|
|
task = _claim_task()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = True
|
|
|
|
|
deps = _make_deps(task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
guard = await c._run_claim_guards(
|
|
|
|
|
agent_id=agent_id, task=task, role_str="developer"
|
|
|
|
|
)
|
|
|
|
|
assert guard is not None
|
|
|
|
|
assert guard.error == "invalid_state"
|
|
|
|
|
task_svc.release_dependency_blocked_claim.assert_awaited_once_with(task.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_claim_guard_allows_when_lane_clear() -> None:
|
|
|
|
|
"""A code leaf whose lane is clear proceeds (no rejection)."""
|
|
|
|
|
task_svc, agent_id = _dev_agent_task_svc()
|
|
|
|
|
task = _claim_task()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = False
|
|
|
|
|
deps = _make_deps(task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
guard = await c._run_claim_guards(
|
|
|
|
|
agent_id=agent_id, task=task, role_str="developer"
|
|
|
|
|
)
|
|
|
|
|
assert guard is None
|
|
|
|
|
task_svc.release_dependency_blocked_claim.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_claim_guard_fail_closed_on_lookup_error() -> None:
|
|
|
|
|
"""If the lane lookup raises, the claim is refused (fail-closed) rather
|
2026-06-30 08:08:35 +02:00
|
|
|
than letting an out-of-order start through on a DB hiccup. The fail-closed
|
|
|
|
|
path returns the error envelope WITHOUT releasing the claim (#29): a
|
|
|
|
|
transient lookup error must not bounce the dev + abandon their work
|
|
|
|
|
session when the lane may actually be free — they retry give_me_work and
|
|
|
|
|
keep their claim."""
|
2026-06-29 11:32:34 +02:00
|
|
|
task_svc, agent_id = _dev_agent_task_svc()
|
|
|
|
|
task = _claim_task()
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.side_effect = RuntimeError("db down")
|
|
|
|
|
deps = _make_deps(task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
guard = await c._run_claim_guards(
|
|
|
|
|
agent_id=agent_id, task=task, role_str="developer"
|
|
|
|
|
)
|
|
|
|
|
assert guard is not None
|
|
|
|
|
assert guard.error == "invalid_state"
|
2026-06-30 08:08:35 +02:00
|
|
|
# No claim release on a transient lookup error — the dev keeps their claim.
|
|
|
|
|
task_svc.release_dependency_blocked_claim.assert_not_awaited()
|
2026-06-29 11:32:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_claim_guard_lane_inert_for_non_code_task() -> None:
|
|
|
|
|
"""A non-code task (e.g. planning) is not lane-ordered; even if the
|
|
|
|
|
predicate were to return True the guard must not block a coordinator's
|
|
|
|
|
non-code claim — the lane is code-only. Predicate False -> proceed."""
|
|
|
|
|
task_svc, agent_id = _dev_agent_task_svc()
|
|
|
|
|
task = _claim_task(task_type="planning")
|
|
|
|
|
task_svc.get.return_value = task
|
|
|
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = False
|
|
|
|
|
deps = _make_deps(task_svc)
|
|
|
|
|
c = Choreographer(deps)
|
|
|
|
|
|
|
|
|
|
guard = await c._run_claim_guards(agent_id=agent_id, task=task, role_str="main_pm")
|
|
|
|
|
assert guard is None
|