"""W7 possibilities matrix: the ``_work_appears_done`` predicate + fast path. A task whose work is already done (commits + PR open + every acceptance criterion addressed + no open findings) qualifies for the fast path. These tests pin the predicate's truth table and the schema it actually reads (the per-criterion rows the writer persists use ``artifact_ref``; the predicate unions ``artifact_ref`` / ``referencing_artifact_id`` / ``addressed`` so it sees real data, not the latent reader/writer key drift). """ from __future__ import annotations from dataclasses import dataclass from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest from roboco.foundation.policy.content import markers from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps from roboco.services.gateway.envelope import Envelope def _deps() -> ChoreographerDeps: return ChoreographerDeps( task=AsyncMock(), work_session=AsyncMock(), git=AsyncMock(), a2a=AsyncMock(), journal=AsyncMock(), audit=AsyncMock(), evidence_repo=AsyncMock(), ) async def _no_findings(_task_id: Any) -> tuple[()]: return () async def _one_open(_task_id: Any) -> tuple[str, ...]: return ("f1abcd12",) def _t( *, status: str = "claimed", commits: tuple[int, ...] = (1,), pr_created: bool = True, criteria: tuple[str, ...] = ("ac1", "ac2"), ac_status: list[dict[str, Any]] | None = None, ) -> MagicMock: if ac_status is None: ac_status = [ {"criterion": "ac1", "addressed": True, "artifact_ref": "sha1"}, {"criterion": "ac2", "addressed": True, "artifact_ref": "sha2"}, ] t = MagicMock() t.id = uuid4() t.status = status t.commits = commits t.pr_created = pr_created t.pr_number = 12345 if pr_created else None t.acceptance_criteria = list(criteria) t.acceptance_criteria_status = ac_status t.source = "code" t.orchestration_markers = None return t @pytest.mark.asyncio async def test_work_appears_done_true_when_all_hold( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _no_findings) assert await c._work_appears_done(_t()) is True @pytest.mark.asyncio async def test_work_appears_done_false_when_no_pr( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _no_findings) assert await c._work_appears_done(_t(pr_created=False)) is False @pytest.mark.asyncio async def test_work_appears_done_false_when_no_commits( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _no_findings) assert await c._work_appears_done(_t(commits=())) is False @pytest.mark.asyncio async def test_work_appears_done_false_when_ac_unaddressed( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _no_findings) t = _t( ac_status=[ {"criterion": "ac1", "addressed": True, "artifact_ref": "sha1"}, {"criterion": "ac2", "addressed": False, "artifact_ref": None}, ] ) assert await c._work_appears_done(t) is False @pytest.mark.asyncio async def test_work_appears_done_true_with_no_criteria( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _no_findings) assert await c._work_appears_done(_t(criteria=(), ac_status=[])) is True @pytest.mark.asyncio async def test_work_appears_done_false_when_open_finding( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _one_open) assert await c._work_appears_done(_t()) is False @pytest.mark.asyncio async def test_work_appears_done_false_when_terminal_status( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _no_findings) assert await c._work_appears_done(_t(status="awaiting_qa")) is False assert await c._work_appears_done(_t(status="completed")) is False assert await c._work_appears_done(_t(status="needs_revision")) is False @pytest.mark.asyncio async def test_work_appears_done_reads_referencing_artifact_id_schema( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_open_finding_ids", _no_findings) t = _t( ac_status=[ {"criterion": "ac1", "referencing_artifact_id": "sha1"}, {"criterion": "ac2", "referencing_artifact_id": "sha2"}, ] ) assert await c._work_appears_done(t) is True # --------------------------------------------------------------------------- # _fast_path_quality_verdict: CI-green proxy for the skipped local gate # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_quality_verdict_ci_success_skips_local_gate( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr( c, "_resolve_ci_status", AsyncMock(return_value={"state": "success"}) ) local = AsyncMock(return_value=None) monkeypatch.setattr(c, "_check_quality_gate", local) rejection, ran_local = await c._fast_path_quality_verdict( MagicMock(task_id=uuid4(), task=MagicMock(), briefing={}) ) assert rejection is None assert ran_local is False local.assert_not_awaited() @pytest.mark.asyncio async def test_quality_verdict_ci_failure_refuses( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr( c, "_resolve_ci_status", AsyncMock(return_value={"state": "failure", "failing_checks": ["lint"]}), ) local = AsyncMock(return_value=None) monkeypatch.setattr(c, "_check_quality_gate", local) rejection, ran_local = await c._fast_path_quality_verdict( MagicMock(task_id=uuid4(), task=MagicMock(), briefing={}) ) assert rejection is not None assert ran_local is False local.assert_not_awaited() @pytest.mark.asyncio async def test_quality_verdict_no_ci_falls_back_to_local_gate( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr( c, "_resolve_ci_status", AsyncMock(return_value={"state": "no_ci_configured"}) ) local = AsyncMock(return_value=None) monkeypatch.setattr(c, "_check_quality_gate", local) rejection, ran_local = await c._fast_path_quality_verdict( MagicMock(task_id=uuid4(), task=MagicMock(), briefing={}) ) assert rejection is None assert ran_local is True local.assert_awaited_once() @pytest.mark.asyncio async def test_quality_verdict_unresolvable_falls_back_to_local_gate( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) monkeypatch.setattr(c, "_resolve_ci_status", AsyncMock(return_value=None)) local = AsyncMock(return_value=None) monkeypatch.setattr(c, "_check_quality_gate", local) rejection, ran_local = await c._fast_path_quality_verdict( MagicMock(task_id=uuid4(), task=MagicMock(), briefing={}) ) assert rejection is None assert ran_local is True local.assert_awaited_once() # --------------------------------------------------------------------------- # _i_am_done_fast_path: gate ordering + transition chain (skips rich plan) # --------------------------------------------------------------------------- def _ctx(status: str = "claimed") -> Any: ctx = MagicMock() ctx.agent_id = uuid4() ctx.task_id = uuid4() ctx.task = _t(status=status) ctx.briefing = {} ctx.notes = "done" ctx.resolved_findings = None ctx.role_str = "developer" return ctx @dataclass class _FastPathMocks: """Typed handles to the patched verb-boundary mocks. mypy keeps the declared method types on ``c.`` even after ``monkeypatch.setattr`` (the runtime override is invisible to static analysis), so assertions must go through these locals typed as ``AsyncMock`` — not ``c.``.""" ok: AsyncMock reject: AsyncMock conventions: AsyncMock open_findings: AsyncMock record_milestone: AsyncMock toolchain: AsyncMock def _stub_fast_path( c: Choreographer, monkeypatch: pytest.MonkeyPatch, quality_rejection: Any = None ) -> _FastPathMocks: conventions = AsyncMock(return_value=None) open_findings = AsyncMock(return_value=()) ok = AsyncMock(return_value="OK") reject = AsyncMock(return_value="REJECT") record_milestone = AsyncMock(return_value=None) toolchain = AsyncMock(return_value=None) monkeypatch.setattr(c, "_apply_resolved_findings", AsyncMock(return_value=None)) monkeypatch.setattr(c, "_check_submit_qa_field_gates", AsyncMock(return_value=None)) monkeypatch.setattr(c, "_behind_base_gate", AsyncMock(return_value=None)) monkeypatch.setattr(c, "_ensure_branch_pushed", AsyncMock(return_value=None)) monkeypatch.setattr(c, "_conventions_gate", conventions) monkeypatch.setattr(c, "_open_finding_ids", open_findings) monkeypatch.setattr( c, "_fast_path_quality_verdict", AsyncMock(return_value=(quality_rejection, False)), ) monkeypatch.setattr(c, "_toolchain_broken_guard", toolchain) monkeypatch.setattr(c, "_notify_qa", AsyncMock(return_value=None)) monkeypatch.setattr(c, "_touch", AsyncMock(return_value=None)) monkeypatch.setattr(c, "_record_milestone_progress", record_milestone) monkeypatch.setattr(c, "_build_i_am_done_ok", ok) monkeypatch.setattr(c, "_reject_i_am_done", reject) return _FastPathMocks( ok=ok, reject=reject, conventions=conventions, open_findings=open_findings, record_milestone=record_milestone, toolchain=toolchain, ) @pytest.mark.asyncio async def test_fast_path_claimed_starts_without_set_plan( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) await c._i_am_done_fast_path(_ctx(status="claimed")) stubs.ok.assert_awaited_once() # OK path taken, no rejection c.task.start.assert_awaited_once() # claimed -> in_progress c.task.set_plan.assert_not_awaited() # rich plan SKIPPED (no set_plan) c.task.submit_verification.assert_awaited_once() c.task.submit_qa.assert_awaited_once() stubs.conventions.assert_awaited_once() # conventions KEPT stubs.record_milestone.assert_awaited_once() @pytest.mark.asyncio async def test_fast_path_in_progress_skips_start( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) await c._i_am_done_fast_path(_ctx(status="in_progress")) stubs.ok.assert_awaited_once() c.task.start.assert_not_awaited() # already in_progress c.task.submit_verification.assert_awaited_once() c.task.submit_qa.assert_awaited_once() @pytest.mark.asyncio async def test_fast_path_open_findings_blocks(monkeypatch: pytest.MonkeyPatch) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) monkeypatch.setattr(c, "_open_finding_ids", AsyncMock(return_value=("f1abcd12",))) await c._i_am_done_fast_path(_ctx()) stubs.reject.assert_awaited_once() c.task.submit_qa.assert_not_awaited() @pytest.mark.asyncio async def test_fast_path_video_task_without_render_preview_rejects( monkeypatch: pytest.MonkeyPatch, ) -> None: """A video-authoring task with no request_render preview must not be able to fast-path around looking at the rendered artifact.""" c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) tracing_gap = AsyncMock( return_value=Envelope.tracing_gap(missing=["render_preview"], remediate="x") ) monkeypatch.setattr(c, "_build_tracing_gap", tracing_gap) ctx = _ctx() ctx.task = _t() ctx.task.source = markers.VIDEO_TASK_SOURCE await c._i_am_done_fast_path(ctx) stubs.reject.assert_awaited_once() c.task.submit_qa.assert_not_awaited() tracing_gap.assert_awaited_once_with( ctx.agent_id, ctx.task_id, ["render_preview"], task=ctx.task ) @pytest.mark.asyncio async def test_fast_path_video_task_with_render_preview_passes( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) ctx = _ctx() ctx.task = _t() ctx.task.source = markers.VIDEO_TASK_SOURCE ctx.task.orchestration_markers = {markers.RENDER_PREVIEW: {"frames": ["a.png"]}} await c._i_am_done_fast_path(ctx) stubs.ok.assert_awaited_once() c.task.submit_qa.assert_awaited_once() @pytest.mark.asyncio async def test_fast_path_conventions_block_rejects( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) monkeypatch.setattr( c, "_conventions_gate", AsyncMock(return_value=Envelope.invalid_state(message="x", remediate="fix")), ) await c._i_am_done_fast_path(_ctx()) stubs.reject.assert_awaited_once() c.task.submit_qa.assert_not_awaited() @pytest.mark.asyncio async def test_fast_path_ci_failure_rejects_before_transition( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) monkeypatch.setattr( c, "_fast_path_quality_verdict", AsyncMock( return_value=( Envelope.invalid_state(message="ci red", remediate="fix"), False, ) ), ) await c._i_am_done_fast_path(_ctx()) stubs.reject.assert_awaited_once() c.task.submit_qa.assert_not_awaited() # --------------------------------------------------------------------------- # _i_am_done_fast_path: notes is the sole compensating control for the # skipped journal gates — empty AND soup are rejected (unlike the standard # path's soup-only check, which skips an empty value). # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_fast_path_empty_notes_rejected(monkeypatch: pytest.MonkeyPatch) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) ctx = _ctx() ctx.notes = "" await c._i_am_done_fast_path(ctx) stubs.reject.assert_awaited_once() c.task.submit_verification.assert_not_awaited() c.task.submit_qa.assert_not_awaited() @pytest.mark.asyncio async def test_fast_path_trivial_notes_rejected( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) ctx = _ctx() ctx.notes = "wip" await c._i_am_done_fast_path(ctx) stubs.reject.assert_awaited_once() c.task.submit_verification.assert_not_awaited() c.task.submit_qa.assert_not_awaited() @pytest.mark.asyncio async def test_fast_path_real_notes_passes(monkeypatch: pytest.MonkeyPatch) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) ctx = _ctx() ctx.notes = "already implemented in a prior session" await c._i_am_done_fast_path(ctx) stubs.ok.assert_awaited_once() c.task.submit_qa.assert_awaited_once() # --------------------------------------------------------------------------- # _i_am_done_fast_path: guard order — push before behind-base, mirroring the # standard gate (_behind_base_gate reads origin, which must already reflect # the pushed head). # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_fast_path_pushes_branch_before_behind_base_check( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) _stub_fast_path(c, monkeypatch) order: list[str] = [] async def _push(_ctx: Any) -> None: order.append("push") async def _behind(_ctx: Any) -> None: order.append("behind") monkeypatch.setattr(c, "_ensure_branch_pushed", _push) monkeypatch.setattr(c, "_behind_base_gate", _behind) await c._i_am_done_fast_path(_ctx()) assert order == ["push", "behind"] # --------------------------------------------------------------------------- # _i_am_done_fast_path: toolchain backstop pairs with the local quality gate # fallback (no CI signal) — never with the CI-green branch. # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_fast_path_toolchain_broken_blocks_on_local_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) monkeypatch.setattr( c, "_fast_path_quality_verdict", AsyncMock(return_value=(None, True)), # local gate ran (no CI signal), passed ) stubs.toolchain.return_value = Envelope.invalid_state( message="toolchain broken", remediate="fix" ) await c._i_am_done_fast_path(_ctx()) stubs.reject.assert_awaited_once() c.task.submit_qa.assert_not_awaited() @pytest.mark.asyncio async def test_fast_path_toolchain_guard_skipped_on_ci_green( monkeypatch: pytest.MonkeyPatch, ) -> None: c = Choreographer(_deps()) stubs = _stub_fast_path(c, monkeypatch) monkeypatch.setattr( c, "_fast_path_quality_verdict", AsyncMock(return_value=(None, False)) ) await c._i_am_done_fast_path(_ctx()) stubs.ok.assert_awaited_once() stubs.toolchain.assert_not_awaited()