2026-06-06 22:10:48 +02:00
|
|
|
"""The cell_pm `reassign` verb — hand a claimed/in_progress task to another
|
|
|
|
|
developer in the caller's OWN cell, preserving the branch.
|
|
|
|
|
|
|
|
|
|
Covers the intra-cell guard (`Choreographer._validate_reassign`, using real
|
|
|
|
|
agents_config data) and the reaper-safe service write
|
|
|
|
|
(`TaskService.reassign_active_claim`).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-07-23 21:09:50 +02:00
|
|
|
from typing import cast
|
2026-06-06 22:10:48 +02:00
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
from uuid import UUID, uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-07-23 21:09:50 +02:00
|
|
|
from roboco.models.base import AgentStatus, TaskStatus
|
2026-06-06 22:10:48 +02:00
|
|
|
from roboco.seeds.initial_data import AGENT_UUIDS
|
|
|
|
|
from roboco.services.gateway.choreographer._impl import Choreographer
|
|
|
|
|
from roboco.services.task import TaskService
|
|
|
|
|
|
|
|
|
|
_BE_PM = UUID(AGENT_UUIDS["be-pm"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _task(team: str = "backend", status: str = "in_progress") -> MagicMock:
|
|
|
|
|
return MagicMock(team=MagicMock(value=team), status=MagicMock(value=status))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _validate_reassign — intra-cell guard
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_allows_same_cell_developer() -> None:
|
|
|
|
|
assert Choreographer._validate_reassign(_task(), _BE_PM, "be-dev-2") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejects_cross_cell_developer() -> None:
|
|
|
|
|
# fe-dev-1 is a frontend dev; a backend PM may not reassign to it.
|
|
|
|
|
env = Choreographer._validate_reassign(_task("backend"), _BE_PM, "fe-dev-1")
|
|
|
|
|
assert env is not None
|
|
|
|
|
assert env.error == "not_authorized"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejects_non_developer_target() -> None:
|
|
|
|
|
# be-qa is in the cell but is not a developer.
|
|
|
|
|
env = Choreographer._validate_reassign(_task("backend"), _BE_PM, "be-qa")
|
|
|
|
|
assert env is not None
|
|
|
|
|
assert env.error == "not_authorized"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejects_task_outside_callers_cell() -> None:
|
|
|
|
|
env = Choreographer._validate_reassign(_task("frontend"), _BE_PM, "be-dev-2")
|
|
|
|
|
assert env is not None
|
|
|
|
|
assert env.error == "not_authorized"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejects_non_active_status() -> None:
|
|
|
|
|
env = Choreographer._validate_reassign(
|
|
|
|
|
_task("backend", "awaiting_qa"), _BE_PM, "be-dev-2"
|
|
|
|
|
)
|
|
|
|
|
assert env is not None
|
|
|
|
|
assert env.error == "invalid_state"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejects_unknown_slug() -> None:
|
|
|
|
|
env = Choreographer._validate_reassign(_task("backend"), _BE_PM, "be-dev-99")
|
|
|
|
|
assert env is not None
|
|
|
|
|
assert env.error == "invalid_state"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_allows_claimed_status() -> None:
|
|
|
|
|
assert (
|
|
|
|
|
Choreographer._validate_reassign(
|
|
|
|
|
_task("backend", "claimed"), _BE_PM, "be-dev-1"
|
|
|
|
|
)
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# reassign_active_claim — reaper-safe service write
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_task(**over: object) -> MagicMock:
|
|
|
|
|
base: dict[str, object] = {
|
|
|
|
|
"id": uuid4(),
|
|
|
|
|
"status": TaskStatus.IN_PROGRESS,
|
|
|
|
|
"assigned_to": None,
|
|
|
|
|
"claimed_by": None,
|
|
|
|
|
"claimed_at": None,
|
|
|
|
|
"last_heartbeat_at": None,
|
|
|
|
|
"active_claimant_id": None,
|
|
|
|
|
}
|
|
|
|
|
base.update(over)
|
|
|
|
|
return MagicMock(**base)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _service() -> TaskService:
|
|
|
|
|
session = MagicMock()
|
|
|
|
|
session.flush = AsyncMock()
|
2026-07-23 21:09:50 +02:00
|
|
|
# reassign_active_claim now retargets the agent-side claim marker
|
|
|
|
|
# (_retarget_agent_claim), which reads old/new agent rows via
|
|
|
|
|
# session.get — default to "no matching row" so tests that don't care
|
|
|
|
|
# about the agent side effect stay a no-op there, same as before this
|
|
|
|
|
# write existed.
|
|
|
|
|
session.get = AsyncMock(return_value=None)
|
2026-06-06 22:10:48 +02:00
|
|
|
return TaskService(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_reassign_active_claim_seeds_a_fresh_claim() -> None:
|
|
|
|
|
task = _build_task(status=TaskStatus.IN_PROGRESS)
|
|
|
|
|
svc = _service()
|
|
|
|
|
object.__setattr__(svc, "get", AsyncMock(return_value=task))
|
|
|
|
|
new_id = uuid4()
|
|
|
|
|
result = await svc.reassign_active_claim(task.id, new_id)
|
|
|
|
|
assert result is task
|
|
|
|
|
assert task.assigned_to == new_id
|
|
|
|
|
assert task.claimed_by == new_id
|
|
|
|
|
assert task.active_claimant_id == new_id
|
|
|
|
|
# Fresh claim window so the reaper doesn't treat the new dev as stale.
|
|
|
|
|
assert task.claimed_at is not None
|
|
|
|
|
assert task.last_heartbeat_at is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_reassign_active_claim_refuses_non_active_status() -> None:
|
|
|
|
|
task = _build_task(status=TaskStatus.AWAITING_QA)
|
|
|
|
|
svc = _service()
|
|
|
|
|
object.__setattr__(svc, "get", AsyncMock(return_value=task))
|
|
|
|
|
assert await svc.reassign_active_claim(task.id, uuid4()) is None
|
2026-07-23 21:09:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_reassign_active_claim_retargets_agent_active_marker() -> None:
|
|
|
|
|
"""The old claimant's ACTIVE/current_task_id marker must move to the new
|
|
|
|
|
claimant — otherwise the fleet keeps showing the SUPERSEDED agent as
|
|
|
|
|
working on this task, and never shows the real new claimant as active."""
|
|
|
|
|
old_id, new_id = uuid4(), uuid4()
|
|
|
|
|
task = _build_task(status=TaskStatus.IN_PROGRESS, claimed_by=old_id)
|
|
|
|
|
old_agent = MagicMock(status=AgentStatus.ACTIVE, current_task_id=task.id)
|
|
|
|
|
new_agent = MagicMock(status=AgentStatus.IDLE, current_task_id=None)
|
|
|
|
|
svc = _service()
|
|
|
|
|
object.__setattr__(svc, "get", AsyncMock(return_value=task))
|
|
|
|
|
|
|
|
|
|
async def _fake_get(_model: object, agent_id: object) -> object:
|
|
|
|
|
if agent_id == old_id:
|
|
|
|
|
return old_agent
|
|
|
|
|
if agent_id == new_id:
|
|
|
|
|
return new_agent
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
cast("MagicMock", svc.session).get = AsyncMock(side_effect=_fake_get)
|
|
|
|
|
|
|
|
|
|
result = await svc.reassign_active_claim(task.id, new_id)
|
|
|
|
|
|
|
|
|
|
assert result is task
|
|
|
|
|
assert old_agent.status == AgentStatus.IDLE
|
|
|
|
|
assert old_agent.current_task_id is None
|
|
|
|
|
assert new_agent.status == AgentStatus.ACTIVE
|
|
|
|
|
assert new_agent.current_task_id == task.id
|