Files
roboco/tests/unit/gateway/test_possibilities_matrix.py
T
Renn F 85ac6422ff fix(gateway): possibilities-matrix fast-path hardening + collision-context guards
The W7 fast path now rejects empty/trivial notes (its sole compensating
control for the skipped journal gates), pushes the branch before the
behind-base check, and pairs the local-gate fallback with the toolchain
guard; the WORK_ALREADY_DONE prompt no longer promises a fast path to
verifying tasks the gate routes elsewhere. build_collision_context now
degrades gracefully at all three call sites instead of breaking the
gate review, PM briefing, or collision-map route.
2026-07-15 08:25:06 +02:00

482 lines
16 KiB
Python

"""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.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
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.<method>`` even after ``monkeypatch.setattr``
(the runtime override is invisible to static analysis), so assertions
must go through these locals typed as ``AsyncMock`` — not ``c.<method>``."""
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_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()