"""The one-task-per-agent invariant is enforced at the DB level by a PostgreSQL transaction-scoped advisory lock keyed by agent_id, acquired in ``_claim_plan_start_gate`` BEFORE the guard reads (non-coordinator roles only) and held until the request transaction commits, so a second concurrent claim's guard read sees the first's committed in_progress task and is rejected. CRITICAL regression guard: the lock is acquired ONLY for non-coordinator roles — acquiring it for a coordinator would serialize a cell_pm / main_pm's parallel root planning and regress coordinator concurrency (matches the ``_COORDINATOR_ROLES`` guard exemption). """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps # A developer fresh claim must carry a substantive step checklist + rich plan # (parity with a PM) so the tracing-gate passes and the flow reaches claim. _STEPS = [ { "title": "Implement the change", "description": ( "edit the target file, add tests, run them, and stage the " "change for commit on the task branch" ), } ] _GOOD_PLAN = ( "Append the timestamp HTML comment to the very bottom of README.md without " "touching any other line, then commit it on the task branch and open a PR. " "Verify the diff is a single-line addition before submitting for QA." ) _GOOD_TC = ["Use a trailing newline so the comment sits on its own line."] _GOOD_RISKS = [ { "risk": "An accidental reformat of README.md balloons the diff.", "mitigation": "Append only; assert the diff touches one line pre-commit.", } ] def _make_deps(task: AsyncMock) -> ChoreographerDeps: task.session = MagicMock() task.session.begin_nested = MagicMock( return_value=MagicMock( __aenter__=AsyncMock(return_value=None), __aexit__=AsyncMock(return_value=False), ) ) evidence_repo = AsyncMock() for method in ( "list_unread_a2a", "list_unread_mentions", "list_pending_notifications", "task_metadata_gaps", "recent_team_activity", "blockers_in_lane", "journal_highlights_for_task", ): getattr(evidence_repo, method).return_value = [] journal = AsyncMock() journal.latest_decision_at.return_value = datetime.now(UTC) return ChoreographerDeps( task=task, work_session=AsyncMock(), git=AsyncMock(), a2a=AsyncMock(), journal=journal, audit=AsyncMock(), evidence_repo=evidence_repo, ) def _dev_task_svc(agent_id: object, task_id: object) -> AsyncMock: """TaskService mock that completes the dev claim→plan→start flow.""" pending = MagicMock( id=task_id, status="pending", plan=None, assigned_to=None, team="backend", parent_task_id=None, sequence=0, task_type="code", commits=[], pr_number=None, branch_name="feature/backend/abc", quick_context=None, ) in_progress = MagicMock( id=task_id, status="in_progress", plan={"text": "do x"}, assigned_to=agent_id ) task_svc = AsyncMock() task_svc.get.return_value = pending task_svc.agent_for.return_value = MagicMock( id=agent_id, role="developer", team="backend", slug=None ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = MagicMock( id=task_id, status="claimed", plan=None, assigned_to=agent_id ) task_svc.set_plan.return_value = MagicMock( id=task_id, status="claimed", plan={"text": "do x"}, assigned_to=agent_id ) task_svc.start.return_value = in_progress return task_svc @pytest.mark.asyncio async def test_dev_claim_acquires_lock_before_guard_read() -> None: """A developer claim acquires the per-agent advisory lock BEFORE the already_active guard reads the agent's other tasks — the ordering that closes the TOCTOU (the second concurrent claim's read must see the first's committed in_progress task, which requires the lock to be held first).""" agent_id = uuid4() task_id = uuid4() task_svc = _dev_task_svc(agent_id, task_id) # Shared call-order recorder: the lock MUST be acquired before the guard # read, so a shared list distinguishes the correct ordering from the bug # (read-then-claim, where the lock is too late). calls: list[str] = [] async def _lock(_aid: object) -> None: calls.append("lock") async def _read_in_progress(_aid: object) -> list[Any]: calls.append("read_in_progress") return [] async def _read_paused(_aid: object) -> list[Any]: calls.append("read_paused") return [] task_svc.acquire_claim_lock = _lock task_svc.list_in_progress_for_agent.side_effect = _read_in_progress task_svc.list_paused_for_agent.side_effect = _read_paused deps = _make_deps(task_svc) c = Choreographer(deps) env = await c.i_will_work_on( agent_id=agent_id, task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, risks=_GOOD_RISKS, ) assert env.error is None, env.as_dict() # The lock was acquired once for this agent, and BEFORE the guard reads. assert calls[:1] == ["lock"], ( f"advisory lock must be acquired before the guard reads; order was {calls}" ) assert "read_in_progress" in calls and calls.index("lock") < calls.index( "read_in_progress" ) @pytest.mark.asyncio async def test_coordinator_claim_does_not_acquire_lock() -> None: """A coordinator PM (cell_pm / main_pm) must NOT acquire the per-agent advisory lock — the PM coordinator concurrency feature (CLAUDE.md) lets a PM plan + delegate many roots in parallel. Acquiring the lock would serialize those claims and REGRESS that feature. The lock is only for the one-task-per-agent invariant, which coordinators are exempt from (matching the existing ``_COORDINATOR_ROLES`` already_active/paused guard exemption).""" pm_id = uuid4() task_id = uuid4() task_svc = AsyncMock() pending = MagicMock( id=task_id, status="pending", plan=None, assigned_to=None, task_type="planning", parent_task_id=None, sequence=0, team="backend", commits=[], pr_number=None, branch_name=None, quick_context=None, work_session_id=None, ) task_svc.get.return_value = pending task_svc.agent_for.return_value = MagicMock( id=pm_id, role="cell_pm", team="backend", slug=None ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = MagicMock( id=task_id, status="claimed", plan=None, assigned_to=pm_id ) task_svc.set_plan.return_value = MagicMock( id=task_id, status="claimed", plan={"text": "x"}, assigned_to=pm_id ) task_svc.start.return_value = MagicMock( id=task_id, status="in_progress", plan={"text": "x"}, assigned_to=pm_id ) task_svc.ensure_work_session.return_value = None lock_calls: list[object] = [] async def _lock(aid: object) -> None: lock_calls.append(aid) task_svc.acquire_claim_lock = _lock deps = _make_deps(task_svc) c = Choreographer(deps) env = await c.i_will_plan( pm_id, task_id, plan="decompose the feature into cell tasks with acceptance criteria", rich_plan={ "approach": ( "Two-slice decomposition: backend builds the API + DB migration " "first, UX consumes it after the endpoint merges. Strict " "sequencing; the UX slice depends on the backend slice landing." ), "sub_tasks": [ { "title": "Backend slice", "description": ( "be-dev-1 implements the API endpoint and DB migration, " "with tests, behind the existing service layer." ), }, { "title": "UX slice", "description": ( "fe-dev-1 wires the panel view to the new endpoint and " "renders the result list with loading + error states." ), }, ], "risks": [ { "risk": "schema migration may block", "mitigation": "rehearse the migration on a throwaway DB first", } ], }, ) body = env.as_dict() # The flow MUST reach the claim gate (not short-circuit at input validation) # — otherwise ``lock_calls == []`` would pass for the wrong reason. assert body.get("error") != "incomplete_input", body # The coordinator did NOT acquire the lock — parallel planning preserved. assert lock_calls == [], ( "coordinator PM must not acquire the per-agent claim lock (would regress " "the PM coordinator concurrency feature)" ) # --------------------------------------------------------------------------- # the unmet_dependency guard reads dependency state via an unlocked SELECT, # then fires release_dependency_blocked_claim (claimed/in_progress -> pending, # clears branch_name, abandons WorkSession) BEFORE returning the rejection. If # the upstream completes in the microseconds between the read and the release, # the task is NEEDLESSLY released. Dependencies are monotonic (unmet -> met, # terminal never reopen), so a fresh re-read that now finds them met stays met: # safe to proceed without releasing. The fix re-checks unmet_dependency_ids # immediately before the release and skips it when the upstream just completed. # --------------------------------------------------------------------------- # Initial dependency read + the re-check before release. _DEP_READ_INITIAL_PLUS_RECHECK = 2 def _dep_task_svc(agent_id: object, task_id: object, dep_id: object) -> AsyncMock: """TaskService mock for the dependency-guard race test. The task carries one dependency; the guard reads its state via ``unmet_dependency_ids``.""" task = MagicMock( id=task_id, status="claimed", assigned_to=agent_id, parent_task_id=None, dependency_ids=[dep_id], team="backend", ) task_svc = AsyncMock() task_svc.get.return_value = task task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] return task_svc @pytest.mark.asyncio async def test_dependency_guard_skips_release_when_upstream_just_completed() -> None: """The first dependency read sees the upstream still unmet, but the re-check sees it completed; the guard must NOT release the task (the dependency is now met). Returns None (proceed), no release.""" agent_id = uuid4() task_id = uuid4() dep_id = uuid4() task_svc = _dep_task_svc(agent_id, task_id, dep_id) # First read: upstream still unmet. Re-check: upstream just completed -> met. task_svc.unmet_dependency_ids.side_effect = [[dep_id], []] deps = _make_deps(task_svc) c = Choreographer(deps) guard = await c._run_claim_guards( agent_id=agent_id, task=task_svc.get.return_value, role_str="developer", ) # The dependency just completed — the task can proceed, so the guard # returns None (no rejection) and does NOT release the task. assert guard is None, guard task_svc.release_dependency_blocked_claim.assert_not_awaited() # The re-check happened (initial read + re-check): the first saw unmet, # the second saw met. assert task_svc.unmet_dependency_ids.await_count == _DEP_READ_INITIAL_PLUS_RECHECK @pytest.mark.asyncio async def test_dependency_guard_releases_when_still_unmet_no_regression() -> None: """No-regression: both the first read AND the re-check see the upstream still unmet; the guard releases the task to pending and returns the rejection, so the re-check must not weaken the genuine-blocked release path.""" agent_id = uuid4() task_id = uuid4() dep_id = uuid4() task_svc = _dep_task_svc(agent_id, task_id, dep_id) # Both reads: upstream still unmet. task_svc.unmet_dependency_ids.side_effect = [[dep_id], [dep_id]] deps = _make_deps(task_svc) c = Choreographer(deps) guard = await c._run_claim_guards( agent_id=agent_id, task=task_svc.get.return_value, role_str="developer", ) # Still unmet -> release + reject (the unchanged genuine-blocked path). assert guard is not None, guard assert guard.error == "invalid_state", guard.as_dict() task_svc.release_dependency_blocked_claim.assert_awaited_once_with(task_id) assert task_svc.unmet_dependency_ids.await_count == _DEP_READ_INITIAL_PLUS_RECHECK