"""Tier 3 — end-to-end happy paths against the real test DB. Each test exercises the spec → choreographer → TaskService → DB stack with Alembic migrations applied. Catches "spec says X, DB constraint says Y" mismatches the unit-tier parametrized parity suite cannot detect. Companion to ``tests/integration/test_full_lifecycle_real_db.py`` (audit P2-1 deliverable). That file walks one task through the dev chain end to end; this file isolates each major lifecycle path into its own test so a regression on, say, QA-fail does not also blow up the doc-handoff test. Mocks: only the git layer (workspace + PR ops) is stubbed because the test DB has no checkout. The spec, choreographer, VerbRunner, and TaskService are real — those are the layers Task 30 verifies. """ from __future__ import annotations from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock from uuid import UUID, uuid4 import pytest import pytest_asyncio from roboco.db.tables import AgentTable, ProjectTable, TaskTable from roboco.foundation.policy.lifecycle import Status from roboco.models.base import ( AgentRole, AgentStatus, TaskNature, TaskStatus, TaskType, Team, ) from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps from roboco.services.task import TaskService from sqlalchemy import delete if TYPE_CHECKING: from collections.abc import AsyncIterator from sqlalchemy.ext.asyncio import AsyncSession _BRANCH = "feature/backend/healthz" _PR_NUMBER = 8 _PR_URL = "https://github.com/example/life/pull/8" # QA notes must clear settings.qa_notes_min_chars (default 80). _QA_PASS_NOTES = ( "Reviewed the diff; route returns 200 OK with timestamp. Tests cover " "both acceptance criteria. Approving." ) _QA_FAIL_NOTES_PREFIX = ( "Reviewed the diff; route returns 500 on the timestamp branch and the " "second acceptance criterion is not exercised by the new tests. " ) class _StubGit: """Deterministic GitService stub. Mirrors ``test_full_lifecycle_real_db.py``'s _StubGit. Mutates the test's TaskTable row directly so the choreographer reads consistent pr_number / commits state without disk or network I/O. """ def __init__(self, session: Any, task: TaskTable) -> None: self._session = session self._task = task async def commit( self, *, branch_name: str, message: str, task_id: UUID, files: list[str] | None = None, actor_agent_id: Any = None, ) -> dict[str, Any]: del branch_name, files, actor_agent_id sha = uuid4().hex[:40] commits = list(self._task.commits or []) commits.append({"sha": sha, "message": message, "task_id": str(task_id)}) self._task.commits = commits await self._session.flush() return { "sha": sha, "message": message, "files_changed": 1, "insertions": 1, "deletions": 0, } async def push_branch( self, branch_name: str, *, actor_agent_id: Any = None ) -> tuple[str, int]: del branch_name, actor_agent_id return ("ok", 0) async def create_pr( self, branch_name: str, *, parent: str, is_root_pr: bool, actor_agent_id: Any = None, ) -> dict[str, Any]: del branch_name, parent, actor_agent_id self._task.pr_number = _PR_NUMBER self._task.pr_url = _PR_URL self._task.pr_created = True await self._session.flush() return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr} async def diff( self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None ) -> str: del branch_name, base, actor_agent_id return "stub diff" async def list_changed_files( self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None ) -> list[str]: del branch_name, base, actor_agent_id return [] async def pr_target(self, pr_number: int, *, actor_agent_id: Any = None) -> str: del pr_number, actor_agent_id return "main" async def pr_merge(self, *args: Any, **kwargs: Any) -> dict[str, Any]: del args, kwargs return { "merged": True, "sha": uuid4().hex[:40], "merge_commit_sha": uuid4().hex[:40], } def _mock_evidence_repo() -> Any: 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(repo, method).return_value = [] return repo def _mock_journal_with_reflect() -> Any: """Journal stub that reports reflect/learning/decision entries present. ``latest_decision_at`` is anchored to ``datetime.now(UTC)`` so the C8 recency window on the PM-decision gate accepts it. """ journal = AsyncMock() journal.has_reflect_for_task.return_value = True journal.has_learning_for_task.return_value = True journal.has_decision_for_task.return_value = True journal.has_struggle_for_task.return_value = False journal.latest_decision_at.return_value = datetime.now(UTC) return journal def _mock_work_session() -> Any: """WorkSession stub: stable file list, no unpushed commits.""" ws = AsyncMock() ws.files_changed.return_value = ["roboco/api/routes/health.py"] ws.has_unpushed_commits.return_value = False return ws def _build_choreographer( db_session: Any, task: TaskTable, task_service: TaskService ) -> Choreographer: """Wire a real Choreographer with the supplied TaskService + stubbed git. Caller owns the TaskService so it can use it for direct DB reads (``task_service.get(task_id)``) — sharing one instance keeps the session contract clean and avoids "two TaskServices, two views" surprises. """ deps = ChoreographerDeps( task=task_service, work_session=_mock_work_session(), git=_StubGit(db_session, task), a2a=AsyncMock(), journal=_mock_journal_with_reflect(), audit=AsyncMock(), evidence_repo=_mock_evidence_repo(), ) return Choreographer(deps) async def _seed_agents_and_project( db_session: AsyncSession, ) -> dict[str, Any]: """Seed system + project + dev/qa/doc/cell_pm agents. Slugs match ``agents_config.ESCALATION_CHAIN`` so ``i_am_blocked`` and ``escalate_up`` find a real escalation target. The agents-config chain is the source of truth at runtime; matching it here exercises the same lookup the gateway uses in production. """ 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="Lifecycle Test Project", slug=f"life-{uuid4().hex[:8]}", git_url="https://github.com/example/life.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_agent = AgentTable( id=uuid4(), name="BE Dev 1", slug="be-dev-1", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=["python"], permissions={}, metrics={}, ) qa_agent = AgentTable( id=uuid4(), name="BE QA", slug="be-qa", role=AgentRole.QA, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="qa", capabilities=["review"], permissions={}, metrics={}, ) doc_agent = AgentTable( id=uuid4(), name="BE Doc", slug="be-doc", role=AgentRole.DOCUMENTER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="doc", capabilities=["docs"], permissions={}, metrics={}, ) cell_pm_agent = AgentTable( id=uuid4(), name="BE Cell PM", slug="be-pm", role=AgentRole.CELL_PM, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="cell_pm", capabilities=["coord"], permissions={}, metrics={}, ) db_session.add_all([dev_agent, qa_agent, doc_agent, cell_pm_agent]) await db_session.flush() return { "system_agent": system_agent, "project": project, "dev_agent": dev_agent, "qa_agent": qa_agent, "doc_agent": doc_agent, "cell_pm_agent": cell_pm_agent, } def _build_task( *, project_id: UUID, creator_id: UUID, assignee_id: UUID | None, status: TaskStatus, ) -> TaskTable: """Construct a backend code-typed task pinned to ``status``. ``acceptance_criteria_status`` carries the stub artefact rows the pre-merge gate inspects; supplying them here keeps the per-test setup readable. """ return TaskTable( id=uuid4(), title="Add /healthz endpoint", description="Return 200 OK from /healthz", status=status, priority=2, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, team=Team.BACKEND, project_id=project_id, created_by=creator_id, assigned_to=assignee_id, branch_name=_BRANCH, acceptance_criteria=["Returns 200", "Includes timestamp"], acceptance_criteria_status=[ {"criterion": "Returns 200", "referencing_artifact_id": "stub"}, {"criterion": "Includes timestamp", "referencing_artifact_id": "stub"}, ], ) @pytest_asyncio.fixture async def lifecycle_setup( db_session: AsyncSession, ) -> AsyncIterator[dict[str, Any]]: """Seed agents + project + a single PENDING task assigned to the dev.""" seeded = await _seed_agents_and_project(db_session) task = _build_task( project_id=seeded["project"].id, creator_id=seeded["system_agent"].id, assignee_id=seeded["dev_agent"].id, status=TaskStatus.PENDING, ) db_session.add(task) await db_session.flush() seeded["task"] = task yield seeded # --------------------------------------------------------------------------- # 1. Dev path: pending → claimed → in_progress → verifying → awaiting_qa # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_dev_full_chain_through_awaiting_qa( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """pending → claimed → in_progress → verifying → awaiting_qa. Drives ``i_will_work_on`` (claim+plan+start), a stubbed commit, ``open_pr``, then ``i_am_done`` which auto-runs submit_verification + submit_qa. Asserts the final DB row sits at ``awaiting_qa``. """ task = lifecycle_setup["task"] dev_agent = lifecycle_setup["dev_agent"] task_service = TaskService(db_session) stub_git = _StubGit(db_session, task) deps = ChoreographerDeps( task=task_service, work_session=_mock_work_session(), git=stub_git, a2a=AsyncMock(), journal=_mock_journal_with_reflect(), audit=AsyncMock(), evidence_repo=_mock_evidence_repo(), ) c = Choreographer(deps) env = await c.i_will_work_on(dev_agent.id, task.id, plan="add the route") assert env.error is None, f"i_will_work_on failed: {env.message}" assert env.status == Status.IN_PROGRESS.value # Commit + record progress so open_pr's commits-precondition holds. await stub_git.commit( branch_name=_BRANCH, message=f"[{str(task.id)[:8]}] feat(api): add /healthz", task_id=task.id, ) await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz") env = await c.open_pr(dev_agent.id, task.id) assert env.error is None, f"open_pr failed: {env.message}" env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works") assert env.error is None, f"i_am_done failed: {env.message}" assert env.status == Status.AWAITING_QA.value final = await task_service.get(task.id) assert final is not None assert str(final.status) == Status.AWAITING_QA.value # i_am_done auto-runs submit_qa, which hands the task off to the # backend QA agent (production behaviour — see ``_notify_qa``). # Resolve via the same lookup the choreographer uses so the # assertion is robust to other test fixtures that may have seeded # additional QA agents on the BACKEND team (e.g. smoke_test_batch # commits its agents, so they outlive their session). resolved_qa = await task_service.qa_agent_for_team(Team.BACKEND) assert resolved_qa is not None assert final.assigned_to == resolved_qa.id # --------------------------------------------------------------------------- # 2. QA pass path: awaiting_qa → claimed → awaiting_documentation # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_qa_pass_path( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """awaiting_qa → claim_review → pass_review → awaiting_documentation. ``claim_review`` keeps status at AWAITING_QA (specialised qa_claim sets assignment without transitioning) so the spec's source-status requirement on ``qa_pass`` still matches downstream. """ task = lifecycle_setup["task"] qa_agent = lifecycle_setup["qa_agent"] doc_agent = lifecycle_setup["doc_agent"] # Pin the task at awaiting_qa with a PR + commit so the QA gates # (pr exists, commits non-empty) all pass. task.status = TaskStatus.AWAITING_QA task.pr_number = _PR_NUMBER task.pr_url = _PR_URL task.commits = [ {"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)} ] task.self_verified = True await db_session.flush() task_service = TaskService(db_session) c = _build_choreographer(db_session, task, task_service) env = await c.claim_review(qa_agent.id, task.id) assert env.error is None, f"claim_review failed: {env.message}" after_claim = await task_service.get(task.id) assert after_claim is not None assert str(after_claim.status) == Status.AWAITING_QA.value assert after_claim.assigned_to == qa_agent.id env = await c.pass_review(qa_agent.id, task.id, notes=_QA_PASS_NOTES) assert env.error is None, f"pass_review failed: {env.message}" assert env.status == Status.AWAITING_DOCUMENTATION.value final = await task_service.get(task.id) assert final is not None assert str(final.status) == Status.AWAITING_DOCUMENTATION.value # pass_review reassigns to the team's documenter for handoff. Look # up via the same path the choreographer uses (robust to other # tests' committed BACKEND documenters). resolved_doc = await task_service.documenter_for_team(Team.BACKEND) assert resolved_doc is not None assert final.assigned_to == resolved_doc.id del doc_agent # asserted indirectly via documenter_for_team. # --------------------------------------------------------------------------- # 3. QA fail path: awaiting_qa → claimed → needs_revision # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_qa_fail_path( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """awaiting_qa → claim_review → fail_review(issues) → needs_revision. ``fail_review`` reassigns to the original developer so they can revise; that lookup walks ``quick_context``'s ``original_developer:`` marker, which the dev path stamps on i_will_work_on. Here we set it directly so the assertion is deterministic without re-running the dev chain. """ task = lifecycle_setup["task"] qa_agent = lifecycle_setup["qa_agent"] dev_agent = lifecycle_setup["dev_agent"] task.status = TaskStatus.AWAITING_QA task.pr_number = _PR_NUMBER task.pr_url = _PR_URL task.commits = [ {"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)} ] task.self_verified = True # ``extract_original_developer`` parses a UUID off this line; the # spec layer's slug-based self-review check is a separate code path # (``_extract_original_developer`` in qa.py) which only fires when # an actor's slug equals this value, so a UUID here doesn't trip it. task.quick_context = f"original_developer:{dev_agent.id}" await db_session.flush() task_service = TaskService(db_session) c = _build_choreographer(db_session, task, task_service) env = await c.claim_review(qa_agent.id, task.id) assert env.error is None, f"claim_review failed: {env.message}" issues = ["Returns 500 on the timestamp branch", "Missing test for the second AC"] # fail_review's notes are derived from issues; QA pass-gate also # requires notes >= 80 chars, so we send a leading explanation as # the issues list — the verb concatenates them and easily clears # the threshold. long_issues = [_QA_FAIL_NOTES_PREFIX + issues[0], issues[1]] env = await c.fail_review(qa_agent.id, task.id, issues=long_issues) assert env.error is None, f"fail_review failed: {env.message}" assert env.status == Status.NEEDS_REVISION.value final = await task_service.get(task.id) assert final is not None assert str(final.status) == Status.NEEDS_REVISION.value # fail_qa reassigns to the original developer so they can revise. assert final.assigned_to == dev_agent.id # --------------------------------------------------------------------------- # 4. Doc path: awaiting_documentation → claimed → awaiting_pm_review # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_doc_path( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """awaiting_documentation → claim_doc_task → i_documented → awaiting_pm_review. ``claim_doc_task`` keeps status at AWAITING_DOCUMENTATION (doc_claim is assignment-only, mirroring qa_claim). ``i_documented`` flips to AWAITING_PM_REVIEW and reassigns to the cell PM for that team. """ task = lifecycle_setup["task"] doc_agent = lifecycle_setup["doc_agent"] cell_pm_agent = lifecycle_setup["cell_pm_agent"] task.status = TaskStatus.AWAITING_DOCUMENTATION task.pr_number = _PR_NUMBER task.pr_url = _PR_URL task.pr_created = True task.qa_verified = True task.assigned_to = None # documenter must claim from unassigned. task.commits = [ {"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)} ] await db_session.flush() task_service = TaskService(db_session) c = _build_choreographer(db_session, task, task_service) env = await c.claim_doc_task(doc_agent.id, task.id) assert env.error is None, f"claim_doc_task failed: {env.message}" after_claim = await task_service.get(task.id) assert after_claim is not None assert str(after_claim.status) == Status.AWAITING_DOCUMENTATION.value assert after_claim.assigned_to == doc_agent.id env = await c.i_documented( doc_agent.id, task.id, notes="Documented /healthz behaviour in docs/api/health.md", files=["docs/api/health.md"], ) assert env.error is None, f"i_documented failed: {env.message}" assert env.status == Status.AWAITING_PM_REVIEW.value final = await task_service.get(task.id) assert final is not None assert str(final.status) == Status.AWAITING_PM_REVIEW.value # i_documented hands off to the cell PM for the team. Resolve via # the same lookup the choreographer uses (robust to other tests' # committed BACKEND cell PMs). resolved_pm = await task_service.cell_pm_for_team(Team.BACKEND) assert resolved_pm is not None assert final.assigned_to == resolved_pm.id del cell_pm_agent # asserted indirectly via cell_pm_for_team. # --------------------------------------------------------------------------- # 5. PM complete (Cell PM, simple task): awaiting_pm_review → completed # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_pm_complete_simple_task( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """awaiting_pm_review → cell_pm complete → completed. With no Main PM seeded, ``_handle_cell_pm_escalation`` short-circuits and the task transitions straight to COMPLETED instead of being handed up to the Main PM. This is the "simple task" path the plan calls out — no parent task, no Main PM, no CEO escalation. Test isolation note: ``test_groups_routes.py`` exercises the groups POST endpoint, which commits via ``db.commit()`` and persists a MAIN_PM agent across sessions in the test DB. We delete any pre-existing MAIN_PM rows at the top of this test so the cell PM completion is the only one in play. The delete runs inside this test's session and is unwound by the conftest's rollback, so committed state in the shared DB is untouched. """ await db_session.execute( delete(AgentTable).where(AgentTable.role == AgentRole.MAIN_PM) ) await db_session.flush() task = lifecycle_setup["task"] cell_pm_agent = lifecycle_setup["cell_pm_agent"] task.status = TaskStatus.AWAITING_PM_REVIEW task.pr_number = _PR_NUMBER task.pr_url = _PR_URL task.pr_created = True task.qa_verified = True task.docs_complete = True task.assigned_to = cell_pm_agent.id task.commits = [ {"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)} ] await db_session.flush() task_service = TaskService(db_session) c = _build_choreographer(db_session, task, task_service) env = await c.complete( cell_pm_agent.id, task.id, notes="LGTM — merging the leaf PR." ) assert env.error is None, f"complete failed: {env.message}" assert env.status == Status.COMPLETED.value final = await task_service.get(task.id) assert final is not None assert str(final.status) == Status.COMPLETED.value # --------------------------------------------------------------------------- # 6. PM escalate: awaiting_pm_review → awaiting_ceo_approval # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_pm_escalate_to_ceo_path( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """awaiting_pm_review → main_pm complete on a root task → awaiting_ceo_approval. Main PM completing a root task (no parent) opens the master PR if needed and calls ``task.escalate_to_ceo``, leaving the task at AWAITING_CEO_APPROVAL with ``assigned_to=None``. CEO approval is human-in-the-loop (UI-driven), so the test stops there. """ task = lifecycle_setup["task"] project = lifecycle_setup["project"] system_agent = lifecycle_setup["system_agent"] main_pm_agent = AgentTable( id=uuid4(), name="Main PM", slug="main-pm", role=AgentRole.MAIN_PM, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="main_pm", capabilities=["coord"], permissions={}, metrics={}, ) db_session.add(main_pm_agent) await db_session.flush() del project, system_agent # only needed for fixture wiring above. task.status = TaskStatus.AWAITING_PM_REVIEW task.pr_number = _PR_NUMBER task.pr_url = _PR_URL task.pr_created = True task.qa_verified = True task.docs_complete = True task.parent_task_id = None # explicit — escalate_to_ceo refuses subtasks. task.assigned_to = main_pm_agent.id task.commits = [ {"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)} ] await db_session.flush() task_service = TaskService(db_session) c = _build_choreographer(db_session, task, task_service) # SQLAlchemy column-typed `id` attributes need an explicit UUID # cast for mypy under the project's strict config — the values are # already real ``uuid.UUID`` at runtime. env = await c.complete( UUID(str(main_pm_agent.id)), UUID(str(task.id)), notes="Root task ready for CEO approval — escalating.", ) assert env.error is None, f"complete failed: {env.message}" assert env.status == Status.AWAITING_CEO_APPROVAL.value final = await task_service.get(task.id) assert final is not None assert str(final.status) == Status.AWAITING_CEO_APPROVAL.value # main_pm_complete clears assigned_to so the orchestrator does not # respawn an agent while the task waits on the human CEO. assert final.assigned_to is None # --------------------------------------------------------------------------- # 7. Block + unblock + restore: in_progress → blocked → in_progress # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_block_then_unblock_restore( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """in_progress → i_am_blocked → unblock(restore=True) → in_progress. ``i_am_blocked`` runs the spec's ``block`` action which delegates to ``task_service.escalate``: the task is reassigned to the dev's escalation target (``be-pm`` per ``ESCALATION_CHAIN``) and marked BLOCKED with the original dev stashed in ``blocker_raised_by``. PM ``unblock(restore=True)`` then falls through to the legacy ``unblock`` path (no ``pre_block_state`` snapshot exists for chain escalations), restoring assignment to the dev and flipping back to IN_PROGRESS. """ task = lifecycle_setup["task"] dev_agent = lifecycle_setup["dev_agent"] cell_pm_agent = lifecycle_setup["cell_pm_agent"] task_service = TaskService(db_session) c = _build_choreographer(db_session, task, task_service) # Drive into in_progress via the real claim+start sequence. env = await c.i_will_work_on(dev_agent.id, task.id, plan="implement /healthz") assert env.error is None, f"i_will_work_on failed: {env.message}" assert env.status == Status.IN_PROGRESS.value env = await c.i_am_blocked( dev_agent.id, task.id, reason="external dependency on auth library upgrade", ) assert env.error is None, f"i_am_blocked failed: {env.message}" assert env.status == Status.BLOCKED.value blocked = await task_service.get(task.id) assert blocked is not None assert str(blocked.status) == Status.BLOCKED.value # Escalation reassigns to the cell PM (be-pm) and stashes the dev # as blocker_raised_by so unblock can hand the task back. assert blocked.assigned_to == cell_pm_agent.id assert blocked.blocker_raised_by == dev_agent.id env = await c.unblock(cell_pm_agent.id, task.id, restore=True) assert env.error is None, f"unblock failed: {env.message}" assert env.status == Status.IN_PROGRESS.value restored = await task_service.get(task.id) assert restored is not None assert str(restored.status) == Status.IN_PROGRESS.value # legacy unblock restores assigned_to from blocker_raised_by — the # original dev gets the task back so the orchestrator respawns them. assert restored.assigned_to == dev_agent.id # --------------------------------------------------------------------------- # 8. Pause + resume: in_progress → paused → in_progress # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_pause_then_resume( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: """in_progress → i_am_idle (auto-pause) → resume → in_progress. There is no agent-driven ``pause`` verb. ``i_am_idle`` auto-pauses every in_progress task the agent owns so the closure dispatcher can wake them on respawn. ``resume`` (composes=("resume",)) then flips the same task back to IN_PROGRESS for the same assignee. """ task = lifecycle_setup["task"] dev_agent = lifecycle_setup["dev_agent"] task_service = TaskService(db_session) c = _build_choreographer(db_session, task, task_service) env = await c.i_will_work_on(dev_agent.id, task.id, plan="implement /healthz") assert env.error is None, f"i_will_work_on failed: {env.message}" assert env.status == Status.IN_PROGRESS.value env = await c.i_am_idle(dev_agent.id) assert env.error is None, f"i_am_idle failed: {env.message}" assert env.status == "idle" paused = await task_service.get(task.id) assert paused is not None assert str(paused.status) == Status.PAUSED.value # Auto-pause keeps assigned_to so resume can find the same claimant. assert paused.assigned_to == dev_agent.id env = await c.resume(dev_agent.id, task.id) assert env.error is None, f"resume failed: {env.message}" assert env.status == Status.IN_PROGRESS.value resumed = await task_service.get(task.id) assert resumed is not None assert str(resumed.status) == Status.IN_PROGRESS.value assert resumed.assigned_to == dev_agent.id