from __future__ import annotations from typing import TYPE_CHECKING from uuid import uuid4 import pytest import pytest_asyncio from roboco.db.tables import AgentTable, ProjectTable, TaskTable from roboco.models import AgentRole, AgentStatus, Team from roboco.models.base import TaskNature, TaskStatus, TaskType from roboco.services.task import TaskService if TYPE_CHECKING: from collections.abc import AsyncIterator from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession @pytest_asyncio.fixture async def start_setup(db_session: AsyncSession) -> AsyncIterator[dict]: def _agent(slug: str, role: AgentRole) -> AgentTable: return AgentTable( id=uuid4(), name=slug, slug=slug, role=role, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="x", capabilities=[], permissions={}, metrics={}, ) main_pm = _agent("main-pm", AgentRole.MAIN_PM) po = _agent(f"product-owner-{uuid4().hex[:4]}", AgentRole.PRODUCT_OWNER) db_session.add_all([main_pm, po]) await db_session.flush() project = ProjectTable( id=uuid4(), name="P", slug=f"p-{uuid4().hex[:6]}", git_url="https://example.com/r.git", assigned_cell=Team.BACKEND, created_by=po.id, ) db_session.add(project) await db_session.flush() def _task( status: TaskStatus = TaskStatus.PENDING, assigned_to: UUID | None = None, team: Team = Team.MAIN_PM, board_review_complete: bool = False, ) -> TaskTable: t = TaskTable( id=uuid4(), title="Board task", description="d", acceptance_criteria=["ac"], status=status, priority=2, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, project_id=project.id, created_by=po.id, team=team, board_review_complete=board_review_complete, assigned_to=assigned_to if assigned_to else po.id, ) db_session.add(t) return t yield { "svc": TaskService(db_session), "main_pm": main_pm, "po": po, "db": db_session, "mk": _task, } @pytest.mark.asyncio async def test_reassigns_to_main_pm_and_keeps_pending(start_setup: dict) -> None: task = start_setup["mk"]() await start_setup["db"].flush() note = "Board review complete; requirements are clear. Build it." out = await start_setup["svc"].approve_and_start(task.id, note) assert out is not None assert out.assigned_to == start_setup["main_pm"].id assert out.status == TaskStatus.PENDING # The CEO note is a structured marker, not raw soup in quick_context. assert (out.orchestration_markers or {})["approve_and_start_notes"] == note assert "approve_and_start_notes:" not in (out.quick_context or "") @pytest.mark.asyncio async def test_audit_note_preserves_context_and_stores_marker( start_setup: dict, ) -> None: task = start_setup["mk"]() task.quick_context = "prior context" await start_setup["db"].flush() note = "Board signed off; ship it." out = await start_setup["svc"].approve_and_start(task.id, note) assert out is not None # An existing human ResumptionNote is preserved untouched... assert out.quick_context == "prior context" # ...and the CEO note lands in the markers, not appended to the context. assert (out.orchestration_markers or {})["approve_and_start_notes"] == note @pytest.mark.asyncio async def test_idempotent_when_already_main_pm(start_setup: dict) -> None: task = start_setup["mk"](assigned_to=start_setup["main_pm"].id) await start_setup["db"].flush() out = await start_setup["svc"].approve_and_start(task.id, "x" * 25) assert out is not None assert out.assigned_to == start_setup["main_pm"].id assert out.status == TaskStatus.PENDING @pytest.mark.asyncio async def test_returns_none_when_not_pending(start_setup: dict) -> None: task = start_setup["mk"](status=TaskStatus.IN_PROGRESS) await start_setup["db"].flush() out = await start_setup["svc"].approve_and_start(task.id, "x" * 25) assert out is None @pytest.mark.asyncio async def test_returns_none_when_board_review_incomplete(start_setup: dict) -> None: # A task still on the board with an unfinished review must not be started. task = start_setup["mk"](team=Team.BOARD, board_review_complete=False) await start_setup["db"].flush() out = await start_setup["svc"].approve_and_start(task.id, "x" * 25) assert out is None assert task.assigned_to == start_setup["po"].id # not handed to Main PM @pytest.mark.asyncio async def test_succeeds_when_board_review_complete(start_setup: dict) -> None: task = start_setup["mk"](team=Team.BOARD, board_review_complete=True) await start_setup["db"].flush() out = await start_setup["svc"].approve_and_start(task.id, "x" * 25) assert out is not None assert out.assigned_to == start_setup["main_pm"].id assert out.status == TaskStatus.PENDING @pytest.mark.asyncio async def test_approving_megatask_umbrella_releases_backlog_root_subtasks( start_setup: dict, ) -> None: """Approving a board-route MegaTask umbrella releases its held root-subtasks: BACKLOG → PENDING and team → main_pm, so the dependency-gate dispatches them.""" db = start_setup["db"] po = start_setup["po"] project_id = start_setup["mk"]().project_id # reuse the fixture's project batch_id = uuid4() umbrella = TaskTable( id=uuid4(), title="MegaTask: three things", description="d", acceptance_criteria=["ac"], status=TaskStatus.PENDING, priority=1, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, project_id=None, # branchless umbrella: no project, no product product_id=None, batch_id=batch_id, parent_task_id=None, created_by=po.id, team=Team.BOARD, board_review_complete=True, assigned_to=po.id, ) db.add(umbrella) await db.flush() subs = [ TaskTable( id=uuid4(), title=f"Root-subtask {i}", description="d", acceptance_criteria=["ac"], status=TaskStatus.BACKLOG, # held until the batch review approves priority=2, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, project_id=project_id, batch_id=batch_id, parent_task_id=umbrella.id, created_by=po.id, team=Team.BOARD, ) for i in range(2) ] db.add_all(subs) await db.flush() out = await start_setup["svc"].approve_and_start(umbrella.id, "x" * 25) assert out is not None assert out.assigned_to == start_setup["main_pm"].id for sub in subs: await db.refresh(sub) assert sub.status == TaskStatus.PENDING assert sub.team == Team.MAIN_PM