"""Branch creation atomicity. When ``_ensure_branch_for_task`` raises (git checkout fails, push fails, no token, etc.), ``_finalize_claim`` must roll back the claim fields it just flushed — otherwise the task is left CLAIMED with branch_name=NULL and the next claim attempt collides on a non-idempotent ``git checkout -b``. This test exercises the rollback path against a real Postgres session by patching ``_ensure_branch_for_task`` to raise. """ from __future__ import annotations from typing import TYPE_CHECKING, Any from unittest.mock import patch from uuid import uuid4 import pytest import pytest_asyncio from roboco.db.tables import AgentTable, ProjectTable, TaskTable from roboco.models.base import ( AgentRole, AgentStatus, TaskNature, TaskStatus, TaskType, Team, ) from roboco.services.task import TaskService if TYPE_CHECKING: from collections.abc import AsyncIterator from sqlalchemy.ext.asyncio import AsyncSession @pytest_asyncio.fixture async def claim_setup(db_session: AsyncSession) -> AsyncIterator[dict[str, Any]]: system_agent = AgentTable( id=uuid4(), name="System", slug=f"system-{uuid4().hex[:8]}", role=AgentRole.SYSTEM, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="system", capabilities=[], permissions={}, metrics={}, ) db_session.add(system_agent) await db_session.flush() project = ProjectTable( id=uuid4(), name="Atom Test", slug=f"atom-{uuid4().hex[:8]}", git_url="https://github.com/example/atom.git", default_branch="main", protected_branches=["main"], assigned_cell=Team.BACKEND, created_by=system_agent.id, is_active=True, ) db_session.add(project) await db_session.flush() dev = AgentTable( id=uuid4(), name="BE Dev", slug=f"be-dev-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=["python"], permissions={}, metrics={}, ) db_session.add(dev) await db_session.flush() task = TaskTable( id=uuid4(), title="Task that will fail at branch creation", description="…", status=TaskStatus.PENDING, priority=2, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, team=Team.BACKEND, project_id=project.id, created_by=system_agent.id, assigned_to=dev.id, acceptance_criteria=["does the thing"], # No branch_name — claim path will try to create one. ) db_session.add(task) await db_session.flush() yield {"task": task, "dev": dev, "project": project} @pytest.mark.asyncio async def test_finalize_claim_rolls_back_on_branch_failure( db_session: AsyncSession, claim_setup: dict[str, Any] ) -> None: """git failure during _ensure_branch_for_task must revert claim fields. Without rollback the task is left CLAIMED with branch_name=NULL and `git checkout -b` is non-idempotent on retry. """ task = claim_setup["task"] dev = claim_setup["dev"] svc = TaskService(db_session) # Snapshot pre-claim state so we can assert exact rollback. pre_status = task.status pre_assigned = task.assigned_to pre_claimed_by = task.claimed_by pre_claimed_at = task.claimed_at pre_heartbeat = task.last_heartbeat_at pre_claimant = task.active_claimant_id async def boom(_self: Any, _task: Any, _agent_id: Any) -> str: raise RuntimeError("simulated: git checkout -b failed") with ( patch.object(TaskService, "_ensure_branch_for_task", boom), pytest.raises(RuntimeError, match="git checkout -b failed"), ): await svc.claim(task.id, dev.id) # Re-read the task from a clean state via a fresh fetch. refreshed = await svc.get(task.id) assert refreshed is not None assert refreshed.status == pre_status, "status must roll back" assert refreshed.assigned_to == pre_assigned, "assigned_to must roll back" assert refreshed.claimed_by == pre_claimed_by, "claimed_by must roll back" assert refreshed.claimed_at == pre_claimed_at, "claimed_at must roll back" assert refreshed.last_heartbeat_at == pre_heartbeat, "heartbeat must roll back" assert refreshed.active_claimant_id == pre_claimant, ( "active_claimant_id must roll back too" )