mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
This commit is contained in:
@@ -4,13 +4,22 @@ Pins the truth table — no parent → None, no surfaced siblings → None,
|
||||
file-overlap sibling shown, both-migration shown without file overlap,
|
||||
shared-only-without-overlap NOT shown, declared-vs-actual drift computed,
|
||||
and the caps respected. Pure (no DB, no IO): duck-typed task/sibling rows.
|
||||
|
||||
Also covers the two choreographer-level wrappers around it
|
||||
(``_gate_collision_evidence`` / ``_collision_context_for``): a raise from the
|
||||
builder itself — not just the sibling fetch — must still degrade to
|
||||
``None``/omitted, never break the caller (``claim_gate_review`` evidence /
|
||||
the planning briefing).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
from roboco.services.gateway.choreographer.collision import (
|
||||
COLLISION_GLOB_CAP,
|
||||
COLLISION_SIBLING_CAP,
|
||||
@@ -191,6 +200,80 @@ def test_sort_key_orders_by_priority_then_sequence() -> None:
|
||||
assert [e["sequence"] for e in ctx] == [5, 1, 0]
|
||||
|
||||
|
||||
def _make_choreographer(*, task_service: AsyncMock) -> Choreographer:
|
||||
return Choreographer(
|
||||
ChoreographerDeps(
|
||||
task=task_service,
|
||||
work_session=AsyncMock(),
|
||||
git=AsyncMock(),
|
||||
a2a=AsyncMock(),
|
||||
journal=AsyncMock(),
|
||||
audit=AsyncMock(),
|
||||
evidence_repo=AsyncMock(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestGateCollisionEvidenceDegradesOnBuilderFailure:
|
||||
"""``_gate_collision_evidence`` (claim_gate_review evidence)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builder_raise_omits_block(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
t = _task()
|
||||
task_service = AsyncMock()
|
||||
task_service.get_subtasks.return_value = [_sib()]
|
||||
c = _make_choreographer(task_service=task_service)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.gateway.choreographer.pr_gate.build_collision_context",
|
||||
lambda **_kw: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
result = await c._gate_collision_evidence(t, [])
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_raise_still_omits_block(self) -> None:
|
||||
t = _task()
|
||||
task_service = AsyncMock()
|
||||
task_service.get_subtasks.side_effect = RuntimeError("db down")
|
||||
c = _make_choreographer(task_service=task_service)
|
||||
|
||||
result = await c._gate_collision_evidence(t, [])
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCollisionContextForDegradesOnBuilderFailure:
|
||||
"""``_collision_context_for`` (the planning/claim briefing helper)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builder_raise_returns_none(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
t = _task()
|
||||
task_service = AsyncMock()
|
||||
task_service.get_subtasks.return_value = [_sib()]
|
||||
c = _make_choreographer(task_service=task_service)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.gateway.choreographer._impl.build_collision_context",
|
||||
lambda **_kw: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
assert await c._collision_context_for(t) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_raise_returns_none(self) -> None:
|
||||
t = _task()
|
||||
task_service = AsyncMock()
|
||||
task_service.get_subtasks.side_effect = RuntimeError("db down")
|
||||
c = _make_choreographer(task_service=task_service)
|
||||
|
||||
assert await c._collision_context_for(t) is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ class _FastPathMocks:
|
||||
conventions: AsyncMock
|
||||
open_findings: AsyncMock
|
||||
record_milestone: AsyncMock
|
||||
toolchain: AsyncMock
|
||||
|
||||
|
||||
def _stub_fast_path(
|
||||
@@ -266,6 +267,7 @@ def _stub_fast_path(
|
||||
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))
|
||||
@@ -277,6 +279,7 @@ def _stub_fast_path(
|
||||
"_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)
|
||||
@@ -288,6 +291,7 @@ def _stub_fast_path(
|
||||
conventions=conventions,
|
||||
open_findings=open_findings,
|
||||
record_milestone=record_milestone,
|
||||
toolchain=toolchain,
|
||||
)
|
||||
|
||||
|
||||
@@ -365,3 +369,113 @@ async def test_fast_path_ci_failure_rejects_before_transition(
|
||||
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()
|
||||
|
||||
@@ -80,12 +80,16 @@ async def test_armed_claimed_with_pr_and_commits_is_work_already_done(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_armed_verifying_with_pr_and_commits_is_work_already_done(
|
||||
async def test_armed_verifying_with_pr_and_commits_is_not_work_already_done(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# verifying is excluded from the proxy's trigger set: _i_am_done_pre_gate_dispatch
|
||||
# routes an owned `verifying` task to the resume path before the fast path is
|
||||
# ever reachable, so steering the prompt there would be a dead-end promise.
|
||||
monkeypatch.setattr(settings, "possibilities_matrix_enabled", True)
|
||||
prompt = await _orch()._build_dev_prompt(_task(status="verifying"))
|
||||
assert "WORK ALREADY DONE" in prompt
|
||||
assert "WORK ALREADY DONE" not in prompt
|
||||
assert "WORKFLOW STATE: VERIFYING" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user