mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Evidence-assembly git legs (diff, changed-files, branch fetch, advisory conventions run) ran unbounded inside claim_review / claim_doc_task / claim_gate_review / evidence() / i_am_done's envelope build, so a slow clone turned the whole verb into a silent 120s FlowVerbTimeout 504. Each leg now runs through run_bounded_leg under a shared LegBudget (evidence_assembly_timeout_seconds, 45s total): a timed-out leg — both asyncio TimeoutError and git's own GitTimeoutError — degrades into an evidence_gaps note on the envelope instead of hanging the verb, while non-timeout git errors still propagate. The advisory conventions run gets an inner-only timeout (conventions_validator_advisory_timeout_ seconds, 30s) threaded down to the subprocess so it is never orphaned by an outer cancel; the fail-closed i_am_done/pr_pass conventions gates keep their hardcoded 120s. _ensure_pm_decision now reports a PmDecisionOutcome: a transient DB failure recording the PM's decision journal (e.g. lock timeout under load) no longer launders into a journal:decision gate rejection that escalates and BLOCKS the task — the verb's own rationale satisfies the gate with a structured warning, across all seven PM verbs. Also: repo-wide ruff realignment to the lockfile-pinned ruff (8 format-only diffs, 14 UP038 isinstance conversions) that a transiently newer venv ruff had masked. Gate: 15474 passed, 459 skipped; ruff/mypy/xenon/vulture/bandit/ pip-audit/deptry/import-linter/foundation-check all green.
229 lines
9.3 KiB
Python
229 lines
9.3 KiB
Python
"""conventions_check_for_task must fail CLOSED on a resolution error.
|
|
|
|
The conventions block gate (i_am_done / pr_pass) treats
|
|
``could_not_run=False`` + no findings as a clean PASS. A workspace or diff
|
|
resolution error that returns ``could_not_run=False`` therefore silently
|
|
disables the block gate — the opposite of the validator's OWN fail-loud
|
|
philosophy (exit 3 → ``could_not_run=True`` → gate blocks, never silently
|
|
passes). A raised exception during workspace/diff resolution is a real error,
|
|
not an empty result, so it must fail closed: ``could_not_run=True``.
|
|
|
|
The two LEGITIMATE fail-open paths stay fail-open:
|
|
- no ``branch_name`` (a branchless/coordination task has nothing to validate)
|
|
- no changed files (nothing changed → nothing to validate)
|
|
These are empty-result cases, not errors, so the gate correctly passes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services import git as git_module
|
|
from roboco.services.git import GitService
|
|
|
|
|
|
def _service() -> GitService:
|
|
return GitService(MagicMock())
|
|
|
|
|
|
def _bind(svc: GitService, name: str, value: object) -> None:
|
|
object.__setattr__(svc, name, value)
|
|
|
|
|
|
def _task(branch_name: str) -> MagicMock:
|
|
return MagicMock(branch_name=branch_name)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_resolution_error_fails_closed() -> None:
|
|
"""``_workspace_for_branch`` raising → could_not_run=True (block), not
|
|
False (silent pass)."""
|
|
svc = _service()
|
|
_bind(
|
|
svc,
|
|
"_workspace_for_branch",
|
|
AsyncMock(side_effect=RuntimeError("workspace clone missing")),
|
|
)
|
|
result = await svc.conventions_check_for_task(uuid4(), _task("feature/backend/abc"))
|
|
assert result["could_not_run"] is True
|
|
assert result["findings"] == []
|
|
# The reason is informational (for logs/debug), capped like the validator path.
|
|
assert isinstance(result.get("reason"), str) and result["reason"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diff_resolution_error_fails_closed() -> None:
|
|
"""``list_changed_files`` raising → could_not_run=True (block), not a
|
|
silent pass. The workspace resolved fine, but the diff itself failed."""
|
|
svc = _service()
|
|
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
|
_bind(
|
|
svc,
|
|
"list_changed_files",
|
|
AsyncMock(side_effect=RuntimeError("git diff errored")),
|
|
)
|
|
result = await svc.conventions_check_for_task(uuid4(), _task("feature/backend/abc"))
|
|
assert result["could_not_run"] is True
|
|
assert result["findings"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_branch_still_fails_open() -> None:
|
|
"""A branchless/coordination task (no branch_name) has nothing to
|
|
validate — the gate correctly passes (could_not_run=False). This is NOT
|
|
an error; it must stay fail-open."""
|
|
svc = _service()
|
|
result = await svc.conventions_check_for_task(uuid4(), _task(""))
|
|
assert result["could_not_run"] is False
|
|
assert result["findings"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_changed_files_still_fails_open() -> None:
|
|
"""A task whose branch resolved but has no changed files has nothing to
|
|
validate — the gate correctly passes (could_not_run=False). This is an
|
|
empty result, not an error; it must stay fail-open."""
|
|
svc = _service()
|
|
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
|
_bind(svc, "list_changed_files", AsyncMock(return_value=[]))
|
|
result = await svc.conventions_check_for_task(uuid4(), _task("feature/backend/abc"))
|
|
assert result["could_not_run"] is False
|
|
assert result["findings"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preferred_parent_forwards_to_list_changed_files() -> None:
|
|
"""The in-path PR-review gate's cross-team parent (see ``diff``) must
|
|
reach ``list_changed_files`` so the validator never analyzes files
|
|
inherited from the wrong-team derived base."""
|
|
svc = _service()
|
|
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
|
changed = AsyncMock(return_value=[])
|
|
_bind(svc, "list_changed_files", changed)
|
|
actor_id = uuid4()
|
|
await svc.conventions_check_for_task(
|
|
actor_id,
|
|
_task("feature/frontend/root--cell"),
|
|
preferred_parent="feature/main_pm/root",
|
|
)
|
|
changed.assert_awaited_once_with(
|
|
branch_name="feature/frontend/root--cell",
|
|
actor_agent_id=actor_id,
|
|
preferred_parent="feature/main_pm/root",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validator_timeout_fails_closed_and_reaps(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A hung conventions validator subprocess (tree-sitter deadlock, huge repo)
|
|
must time out, fail closed (could_not_run=True so the block gate refuses the
|
|
submit), and kill+wait the proc — not hang the gate forever nor orphan the
|
|
subprocess on orchestrator restart.
|
|
"""
|
|
fake_proc = MagicMock()
|
|
fake_proc.returncode = None
|
|
|
|
async def _communicate() -> tuple[bytes, bytes]:
|
|
await asyncio.sleep(30)
|
|
return (b"", b"")
|
|
|
|
fake_proc.communicate = _communicate
|
|
fake_proc.kill = MagicMock()
|
|
fake_proc.wait = AsyncMock(return_value=-9)
|
|
|
|
async def _fake_exec(*_args: object, **_kwargs: object) -> object:
|
|
return fake_proc
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
|
|
monkeypatch.setattr(git_module, "_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS", 0.01)
|
|
|
|
svc = _service()
|
|
result = await svc._run_conventions_validator(tmp_path, ["a.py"])
|
|
assert result["could_not_run"] is True
|
|
assert "timed out" in (result.get("reason") or "")
|
|
fake_proc.kill.assert_called_once()
|
|
fake_proc.wait.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_conventions_validator_timeout_override_used_over_hardcoded(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An explicit ``timeout`` kwarg wins over the module-level hardcoded
|
|
default — the advisory claim_review path's shorter budget must actually
|
|
reach the subprocess wait, not the fail-closed 120s cap. Sets the module
|
|
constant to something LONG (would never fire in the test's real time
|
|
budget) so a failure here would prove the override was ignored, not a
|
|
coincidence of both values being short."""
|
|
fake_proc = MagicMock()
|
|
fake_proc.returncode = None
|
|
|
|
async def _communicate() -> tuple[bytes, bytes]:
|
|
await asyncio.sleep(30)
|
|
return (b"", b"")
|
|
|
|
fake_proc.communicate = _communicate
|
|
fake_proc.kill = MagicMock()
|
|
fake_proc.wait = AsyncMock(return_value=-9)
|
|
|
|
async def _fake_exec(*_args: object, **_kwargs: object) -> object:
|
|
return fake_proc
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
|
|
monkeypatch.setattr(git_module, "_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS", 300)
|
|
|
|
svc = _service()
|
|
result = await svc._run_conventions_validator(tmp_path, ["a.py"], timeout=0.01)
|
|
assert result["could_not_run"] is True
|
|
assert "timed out after 0.01s" in (result.get("reason") or "")
|
|
|
|
|
|
def _task_with_id(branch_name: str) -> MagicMock:
|
|
"""Like ``_task`` but with a real UUID id — ``conventions_check_for_task``
|
|
calls ``require_uuid(task.id)`` outside the resolution try/except, so a
|
|
bare MagicMock id would raise before reaching the validator call."""
|
|
return MagicMock(branch_name=branch_name, id=uuid4())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conventions_check_for_task_forwards_timeout_override() -> None:
|
|
"""``conventions_check_for_task``'s ``timeout`` kwarg must reach
|
|
``_run_conventions_validator`` — the seam ``claim_review`` uses to pin
|
|
the ADVISORY (shorter) budget instead of the fail-closed default."""
|
|
svc = _service()
|
|
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
|
_bind(svc, "list_changed_files", AsyncMock(return_value=["a.py"]))
|
|
_bind(svc, "_worktree_for_task", MagicMock(return_value=Path("/tmp/wt")))
|
|
_bind(svc, "_ensure_worktree_for_commit", AsyncMock(return_value=None))
|
|
validator = AsyncMock(return_value={"findings": [], "could_not_run": False})
|
|
_bind(svc, "_run_conventions_validator", validator)
|
|
|
|
await svc.conventions_check_for_task(
|
|
uuid4(), _task_with_id("feature/backend/abc"), timeout=30.0
|
|
)
|
|
validator.assert_awaited_once_with(Path("/tmp/wt"), ["a.py"], timeout=30.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conventions_check_for_task_default_timeout_is_none() -> None:
|
|
"""The fail-closed callers (i_am_done's ``_conventions_gate``, pr_pass's
|
|
``_conventions_guard``) never pass ``timeout`` — confirming the default
|
|
forwards ``None`` so ``_run_conventions_validator`` falls back to its
|
|
hardcoded fail-closed cap, unchanged."""
|
|
svc = _service()
|
|
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
|
_bind(svc, "list_changed_files", AsyncMock(return_value=["a.py"]))
|
|
_bind(svc, "_worktree_for_task", MagicMock(return_value=Path("/tmp/wt")))
|
|
_bind(svc, "_ensure_worktree_for_commit", AsyncMock(return_value=None))
|
|
validator = AsyncMock(return_value={"findings": [], "could_not_run": False})
|
|
_bind(svc, "_run_conventions_validator", validator)
|
|
|
|
await svc.conventions_check_for_task(uuid4(), _task_with_id("feature/backend/abc"))
|
|
validator.assert_awaited_once_with(Path("/tmp/wt"), ["a.py"], timeout=None)
|