"""Startup orphan-claim reconciler. The orchestrator's `_reconcile_orphan_claims_on_startup` rolls back tasks left in CLAIMED/IN_PROGRESS with `branch_name IS NULL` — the half-state from a crash where `_finalize_claim` flushed status=CLAIMED before branch creation failed. """ from __future__ import annotations from typing import TYPE_CHECKING, Any 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.runtime.orchestrator import AgentOrchestrator 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 orphan_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="Reconciler Test", slug=f"recon-{uuid4().hex[:8]}", git_url="https://github.com/example/recon.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() # ORPHAN: status=CLAIMED, assigned_to=dev, branch_name=NULL. orphan = TaskTable( id=uuid4(), title="Orphan from prior crash", description="…", status=TaskStatus.CLAIMED, 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, claimed_by=dev.id, acceptance_criteria=["…"], ) # HEALTHY: status=CLAIMED with branch — must NOT be rolled back. healthy = TaskTable( id=uuid4(), title="Healthy claim", description="…", status=TaskStatus.CLAIMED, 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, claimed_by=dev.id, branch_name="feature/backend/healthy", acceptance_criteria=["…"], ) db_session.add_all([orphan, healthy]) await db_session.flush() yield {"orphan": orphan, "healthy": healthy, "dev": dev} @pytest.mark.asyncio async def test_reconciler_rolls_back_orphan_claims( db_session: AsyncSession, orphan_setup: dict[str, Any] ) -> None: """CLAIMED task with branch_name=NULL → reconciled to PENDING.""" orphan = orphan_setup["orphan"] healthy = orphan_setup["healthy"] svc = TaskService(db_session) orch = AgentOrchestrator.__new__(AgentOrchestrator) # Drive the logic directly via the test-injectable helper so we avoid # the orchestrator's session-factory dance. await orch._reconcile_with_service(svc) refreshed_orphan = await svc.get(orphan.id) refreshed_healthy = await svc.get(healthy.id) assert refreshed_orphan is not None assert str(refreshed_orphan.status) == "pending", ( "orphan must be rolled back to pending" ) # Ownership is preserved on rollback so the same dev resumes — an orphan # claim is rolled back, not stripped of its owner into a dormant pending. assert refreshed_orphan.assigned_to == orphan_setup["dev"].id assert refreshed_orphan.claimed_by == orphan_setup["dev"].id # The live claim is released so the dispatcher can re-spawn cleanly. assert refreshed_orphan.active_claimant_id is None # Healthy claim untouched. assert refreshed_healthy is not None assert str(refreshed_healthy.status) == "claimed" assert refreshed_healthy.branch_name == "feature/backend/healthy"