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.
764 lines
28 KiB
Python
764 lines
28 KiB
Python
"""Bounded advisory-evidence legs on claim_review / claim_doc_task /
|
|
claim_gate_review / evidence() / i_am_done's success envelope.
|
|
|
|
Live bug: claim-evidence assembly's slow legs (branch-fetch-backed diff,
|
|
list_changed_files, the conventions-validator subprocess) had no per-leg
|
|
budget, so a hung leg silently ate the whole ``flow_verb_timeout_seconds``
|
|
(120s) and died as a FlowVerbTimeout 504 — holding every row the request
|
|
touched for the duration. Fix: each slow leg runs bounded via
|
|
``run_bounded_leg`` (``asyncio.wait_for``) against a SHARED ``LegBudget`` per
|
|
build; a timeout skips that piece, records a human-readable note in the
|
|
evidence's ``evidence_gaps``, and lets the claim verb succeed with partial
|
|
evidence instead of hanging.
|
|
|
|
Adversarial-review follow-up (round 2) covers four confirmed gaps:
|
|
1. ``run_bounded_leg`` must catch ``GitTimeoutError`` too (``_run_git``'s own
|
|
internal subprocess bound — NOT a ``TimeoutError`` subclass, and usually
|
|
the FIRST bound to trip since it defaults to 30s, shorter than a leg's
|
|
own budget) — every timeout-shaped test below is parametrized over both
|
|
exception shapes.
|
|
2. The conventions leg no longer wraps ``_qa_convention_findings`` in an
|
|
outer ``run_bounded_leg`` — that raced ``conventions_check_for_task``'s
|
|
own inner timeout+cleanup and leaked the validator subprocess. It now
|
|
self-bounds via the ``timeout`` kwarg alone and reports its own gap.
|
|
3. ``fetch_branch_for_inspection`` takes an optional ``subprocess_timeout``
|
|
so its fetch subprocess self-bounds near the leg's own budget instead of
|
|
occupying a thread on the shared default executor for up to 300s.
|
|
4. A shared ``LegBudget`` (one per evidence build) makes every leg's
|
|
``wait_for`` draw from one TOTAL budget instead of getting its own full
|
|
allotment — summed per-leg budgets can no longer exceed the total.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.config import settings
|
|
from roboco.exceptions import GitCommandError, GitTimeoutError
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
from roboco.services.gateway.choreographer.evidence_legs import (
|
|
LegBudget,
|
|
run_bounded_leg,
|
|
)
|
|
|
|
# Every timeout-shaped test is parametrized over both real timeout shapes:
|
|
# asyncio's own cancellation-converted TimeoutError, and GitTimeoutError
|
|
# (GitService._run_git's own internal subprocess bound — a GitError/
|
|
# RobocoError subclass, NOT a TimeoutError subclass, and the most common
|
|
# real-world single-hung-git-call shape since it defaults to a SHORTER
|
|
# window, 30s, than a leg's own budget).
|
|
_TIMEOUT_EXCEPTIONS = (
|
|
TimeoutError("hung"),
|
|
GitTimeoutError("git diff", 30),
|
|
)
|
|
_TIMEOUT_IDS = ("asyncio_timeout", "git_timeout")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_bounded_leg / LegBudget themselves
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_bounded_leg_passes_through_on_success() -> None:
|
|
async def fast() -> str:
|
|
return "value"
|
|
|
|
gaps: list[str] = []
|
|
result = await run_bounded_leg(
|
|
fast(),
|
|
default="fallback",
|
|
budget=LegBudget(5.0),
|
|
leg="unit leg",
|
|
hint="check manually",
|
|
task_id=uuid4(),
|
|
gaps=gaps,
|
|
)
|
|
assert result == "value"
|
|
assert gaps == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
|
|
async def test_run_bounded_leg_degrades_to_default_on_timeout(exc: Exception) -> None:
|
|
async def hangs() -> str:
|
|
raise exc
|
|
|
|
gaps: list[str] = []
|
|
result = await run_bounded_leg(
|
|
hangs(),
|
|
default="fallback",
|
|
budget=LegBudget(5.0),
|
|
leg="unit leg",
|
|
hint="check manually",
|
|
task_id=uuid4(),
|
|
gaps=gaps,
|
|
)
|
|
assert result == "fallback"
|
|
assert len(gaps) == 1
|
|
assert "unit leg unavailable" in gaps[0]
|
|
assert "check manually" in gaps[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_bounded_leg_actually_bounds_a_slow_coroutine() -> None:
|
|
"""A genuinely slow (not pre-raised) coroutine is cancelled at the
|
|
budget's deadline, not awaited to completion."""
|
|
|
|
async def slow() -> str:
|
|
await asyncio.sleep(10)
|
|
return "too late"
|
|
|
|
gaps: list[str] = []
|
|
result = await run_bounded_leg(
|
|
slow(),
|
|
default="fallback",
|
|
budget=LegBudget(0.05),
|
|
leg="unit leg",
|
|
hint="check manually",
|
|
task_id=uuid4(),
|
|
gaps=gaps,
|
|
)
|
|
assert result == "fallback"
|
|
assert len(gaps) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_bounded_leg_other_git_error_still_propagates() -> None:
|
|
"""A real command failure (not a timeout) is NOT a degrade case — it
|
|
must still propagate uncaught, same as any other unexpected exception."""
|
|
|
|
async def fails() -> str:
|
|
raise GitCommandError("git diff", "fatal: bad revision")
|
|
|
|
gaps: list[str] = []
|
|
with pytest.raises(GitCommandError):
|
|
await run_bounded_leg(
|
|
fails(),
|
|
default="fallback",
|
|
budget=LegBudget(5.0),
|
|
leg="unit leg",
|
|
hint="check manually",
|
|
task_id=uuid4(),
|
|
gaps=gaps,
|
|
)
|
|
assert gaps == []
|
|
|
|
|
|
def test_leg_budget_remaining_shrinks_over_time() -> None:
|
|
# Total well above _MIN_LEG_SECONDS (1.0) so the floor never engages
|
|
# here — otherwise both readings would clamp to 1.0 and look equal.
|
|
budget = LegBudget(3.0)
|
|
first = budget.remaining()
|
|
time.sleep(0.1)
|
|
second = budget.remaining()
|
|
assert second < first
|
|
assert first == pytest.approx(3.0, abs=0.05)
|
|
assert (first - second) == pytest.approx(0.1, abs=0.05)
|
|
|
|
|
|
def test_leg_budget_floors_at_minimum() -> None:
|
|
"""A budget already past its deadline still yields a positive window
|
|
(the floor) rather than 0 or a negative timeout — a leg always gets a
|
|
real chance to run, even a badly-exhausted one."""
|
|
budget = LegBudget(0.01)
|
|
time.sleep(0.05)
|
|
assert budget.remaining() == pytest.approx(1.0, abs=0.05)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_bounded_leg_shares_one_shrinking_budget_across_legs() -> None:
|
|
"""Three legs sharing ONE LegBudget: the first two finish fast and
|
|
consume real budget; the last two never finish on their own and get
|
|
progressively SMALLER windows (shrinking, not each getting the full
|
|
total) — both record their own gap, and total wall time stays bounded
|
|
near the shared total instead of the naive per-leg sum (0.1+0.1+5+5s).
|
|
"""
|
|
budget = LegBudget(1.2)
|
|
gaps: list[str] = []
|
|
|
|
async def _takes(seconds: float) -> str:
|
|
await asyncio.sleep(seconds)
|
|
return "done"
|
|
|
|
start = time.monotonic()
|
|
remaining_before_1 = budget.remaining()
|
|
r1 = await run_bounded_leg(
|
|
_takes(0.1),
|
|
default="gap1",
|
|
budget=budget,
|
|
leg="leg1",
|
|
hint="h",
|
|
task_id="t",
|
|
gaps=gaps,
|
|
)
|
|
remaining_before_2 = budget.remaining()
|
|
r2 = await run_bounded_leg(
|
|
_takes(5.0),
|
|
default="gap2",
|
|
budget=budget,
|
|
leg="leg2",
|
|
hint="h",
|
|
task_id="t",
|
|
gaps=gaps,
|
|
)
|
|
remaining_before_3 = budget.remaining()
|
|
r3 = await run_bounded_leg(
|
|
_takes(5.0),
|
|
default="gap3",
|
|
budget=budget,
|
|
leg="leg3",
|
|
hint="h",
|
|
task_id="t",
|
|
gaps=gaps,
|
|
)
|
|
elapsed = time.monotonic() - start
|
|
expected_gap_count = 2 # leg2 + leg3 both timed out; leg1 completed
|
|
|
|
assert r1 == "done"
|
|
assert r2 == "gap2"
|
|
assert r3 == "gap3"
|
|
assert len(gaps) == expected_gap_count
|
|
assert "leg2" in gaps[0]
|
|
assert "leg3" in gaps[1]
|
|
# Each leg's own remaining() reading is strictly smaller than the last
|
|
# — the shared deadline never resets.
|
|
assert remaining_before_1 > remaining_before_2 > remaining_before_3
|
|
# ponytail: the floor (max 1.0s) can inflate the LAST leg's window past
|
|
# what a naive "budget minus elapsed" would give once the deadline is
|
|
# already exhausted — a single floor engagement caps the worst-case
|
|
# overage at _MIN_LEG_SECONDS (1.0s), so budget total + ~1.3s is a safe,
|
|
# honest ceiling rather than a strict `<= budget` bound. Upgrade path:
|
|
# make the floor configurable if a caller ever needs a tighter cap.
|
|
budget_total_seconds = 1.2
|
|
floor_overage_tolerance_seconds = 1.3
|
|
assert elapsed <= budget_total_seconds + floor_overage_tolerance_seconds
|
|
# And it's nowhere near the naive per-leg-gets-its-own-full-timeout sum
|
|
# (0.1 + 5.0 + 5.0 = 10.1s) that pre-LegBudget behavior would produce.
|
|
naive_per_leg_sum_seconds = 5.0
|
|
assert elapsed < naive_per_leg_sum_seconds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared choreographer test harness (mirrors test_choreographer_qa.py /
|
|
# test_claim_doc_task_checkout.py / test_claim_gate_review_guards.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|
base: dict[str, Any] = {
|
|
"task": AsyncMock(),
|
|
"work_session": AsyncMock(),
|
|
"git": AsyncMock(),
|
|
"a2a": AsyncMock(),
|
|
"journal": AsyncMock(),
|
|
"audit": AsyncMock(),
|
|
"evidence_repo": AsyncMock(),
|
|
}
|
|
base.update(overrides)
|
|
repo = base["evidence_repo"]
|
|
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 = []
|
|
_ldef = base["journal"].latest_decision_at.return_value
|
|
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
|
|
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
|
return ChoreographerDeps(**base)
|
|
|
|
|
|
def _stub_empty_ledger(session: MagicMock) -> None:
|
|
session.execute = AsyncMock(
|
|
return_value=MagicMock(
|
|
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
|
)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# claim_review (qa.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PR_NUMBER = 8
|
|
_PR_URL = "https://github.com/x/y/pull/8"
|
|
|
|
|
|
def _qa_task(task_id: Any) -> MagicMock:
|
|
return MagicMock(
|
|
id=task_id,
|
|
status="awaiting_qa",
|
|
assigned_to=None,
|
|
pr_number=_PR_NUMBER,
|
|
pr_url=_PR_URL,
|
|
commits=[{"sha": "abc123", "message": "feat: x"}],
|
|
team="backend",
|
|
branch_name="feature/backend/abc--def",
|
|
work_session_id=uuid4(),
|
|
documents=[],
|
|
dev_notes="implemented x",
|
|
acceptance_criteria=["AC1"],
|
|
acceptance_criteria_status=[
|
|
{"criterion": "AC1", "referencing_artifact_id": "abc123"},
|
|
],
|
|
parent_task_id=None,
|
|
)
|
|
|
|
|
|
def _qa_harness(git_svc: AsyncMock) -> tuple[Choreographer, Any, Any]:
|
|
"""Does NOT touch ``settings.conventions_enabled`` — callers that care
|
|
set it themselves via their own ``monkeypatch`` fixture; a shared
|
|
forced-False here would silently clobber a caller's forced-True set
|
|
moments earlier (``monkeypatch.setattr`` doesn't stack, last write
|
|
wins), which is exactly what broke the conventions-specific tests."""
|
|
qa_id = uuid4()
|
|
task_id = uuid4()
|
|
t_initial = _qa_task(task_id)
|
|
t_claimed = MagicMock(**{**t_initial.__dict__, "assigned_to": qa_id})
|
|
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t_initial
|
|
task_svc.agent_for.return_value = MagicMock(role="qa", team="backend")
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
task_svc.qa_claim.return_value = t_claimed
|
|
_stub_empty_ledger(task_svc.session)
|
|
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
return Choreographer(deps), qa_id, task_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
|
|
async def test_claim_review_diff_timeout_degrades_with_gap(
|
|
monkeypatch: pytest.MonkeyPatch, exc: Exception
|
|
) -> None:
|
|
"""A hung git.diff on claim_review must not hang the verb: it degrades
|
|
to an empty diff, records the gap, and the OTHER leg (list_changed_files)
|
|
still comes through untouched."""
|
|
monkeypatch.setattr(settings, "conventions_enabled", False)
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.side_effect = exc
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
c, qa_id, task_id = _qa_harness(git_svc)
|
|
|
|
env = await c.claim_review(qa_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff_summary"] == ""
|
|
assert ev["files_changed"] == ["README.md"]
|
|
assert "evidence_gaps" in ev
|
|
assert len(ev["evidence_gaps"]) == 1
|
|
assert "pr diff unavailable" in ev["evidence_gaps"][0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_review_conventions_timeout_degrades_with_gap(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""conventions_check_for_task's OWN internal timeout (proc.kill()'d and
|
|
reaped inside git.py, never raising) surfaces as could_not_run=True with
|
|
a "timed out" reason — not an exception. The advisory call site
|
|
(_qa_convention_findings) detects that shape and records the gap
|
|
itself; NO outer run_bounded_leg wraps this leg (that's the fix — see
|
|
module docstring point 2)."""
|
|
monkeypatch.setattr(settings, "conventions_enabled", True)
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = "diff content"
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
git_svc.conventions_check_for_task.return_value = {
|
|
"findings": [],
|
|
"could_not_run": True,
|
|
"reason": "validator timed out after 30.0s",
|
|
}
|
|
c, qa_id, task_id = _qa_harness(git_svc)
|
|
|
|
env = await c.claim_review(qa_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff_summary"] == "diff content"
|
|
assert ev["files_changed"] == ["README.md"]
|
|
assert ev["convention_findings"] == [
|
|
{"could_not_run": True, "reason": "validator timed out after 30.0s"}
|
|
]
|
|
assert "evidence_gaps" in ev
|
|
assert any("conventions findings unavailable" in g for g in ev["evidence_gaps"])
|
|
# The advisory (shorter) ceiling reached the validator call, not the
|
|
# fail-closed i_am_done/pr_pass default (None -> hardcoded 120s).
|
|
git_svc.conventions_check_for_task.assert_awaited_once()
|
|
call_kwargs = git_svc.conventions_check_for_task.await_args.kwargs
|
|
assert (
|
|
call_kwargs["timeout"]
|
|
<= settings.conventions_validator_advisory_timeout_seconds
|
|
)
|
|
assert call_kwargs["timeout"] > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_review_conventions_non_timeout_could_not_run_no_gap(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A genuine resolution failure (not a timeout) still surfaces in
|
|
convention_findings (existing fail-open shape) but must NOT also spam
|
|
evidence_gaps — that's reserved for actual degraded-advisory-leg notes."""
|
|
monkeypatch.setattr(settings, "conventions_enabled", True)
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = "diff content"
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
git_svc.conventions_check_for_task.return_value = {
|
|
"findings": [],
|
|
"could_not_run": True,
|
|
"reason": "resolution failed: NotFoundError: Branch not found",
|
|
}
|
|
c, qa_id, task_id = _qa_harness(git_svc)
|
|
|
|
env = await c.claim_review(qa_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["convention_findings"][0]["could_not_run"] is True
|
|
assert "evidence_gaps" not in ev
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_convention_findings_not_cancelled_by_outer_budget(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Regression for the orphaned-subprocess bug: even with a tiny shared
|
|
evidence-assembly budget, _qa_convention_findings must NOT be cut short
|
|
by an outer wait_for — it awaits conventions_check_for_task to
|
|
completion. A slow-but-real mock (0.15s) run against a budget whose
|
|
total is far smaller (0.02s) proves there is no outer wrap: if there
|
|
still were one, this would return the default/empty shape instead of
|
|
the real result."""
|
|
monkeypatch.setattr(settings, "conventions_enabled", True)
|
|
|
|
async def _slow_check(*_args: object, **_kwargs: object) -> dict[str, Any]:
|
|
await asyncio.sleep(0.15)
|
|
return {"findings": [{"file": "x.py", "line": 1}], "could_not_run": False}
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.conventions_check_for_task.side_effect = _slow_check
|
|
c, _qa_id, _task_id = _qa_harness(git_svc)
|
|
cc: Any = c
|
|
|
|
gaps: list[str] = []
|
|
result = await cc._qa_convention_findings(
|
|
uuid4(), MagicMock(), timeout=0.02, gaps=gaps
|
|
)
|
|
assert result == [{"file": "x.py", "line": 1}]
|
|
assert gaps == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_review_normal_path_has_no_evidence_gaps(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Byte-for-byte unchanged normal path: no evidence_gaps key at all when
|
|
nothing times out."""
|
|
monkeypatch.setattr(settings, "conventions_enabled", False)
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = "diff content"
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
c, qa_id, task_id = _qa_harness(git_svc)
|
|
|
|
env = await c.claim_review(qa_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff_summary"] == "diff content"
|
|
assert ev["files_changed"] == ["README.md"]
|
|
assert "evidence_gaps" not in ev
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# claim_doc_task (doc.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _doc_task(task_id: Any, branch: str) -> MagicMock:
|
|
return MagicMock(
|
|
id=task_id,
|
|
status="awaiting_documentation",
|
|
assigned_to=None,
|
|
task_type="documentation",
|
|
team="backend",
|
|
branch_name=branch,
|
|
quick_context=None,
|
|
documents=[],
|
|
commits=[{"sha": "abc123", "message": "[x] work"}],
|
|
pr_number=7,
|
|
pr_url="https://github.com/x/y/pull/7",
|
|
dev_notes="done",
|
|
acceptance_criteria_status=[],
|
|
work_session_id=uuid4(),
|
|
)
|
|
|
|
|
|
def _doc_harness(git_svc: AsyncMock) -> tuple[Choreographer, Any, Any]:
|
|
doc_id = uuid4()
|
|
task_id = uuid4()
|
|
branch = "feature/backend/root1234--cellpm56--dev78901"
|
|
t_initial = _doc_task(task_id, branch)
|
|
t_claimed = MagicMock(**{**t_initial.__dict__, "assigned_to": doc_id})
|
|
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t_initial
|
|
task_svc.agent_for.return_value = MagicMock(role="documenter", team="backend")
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
task_svc.doc_claim.return_value = t_claimed
|
|
_stub_empty_ledger(task_svc.session)
|
|
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
return Choreographer(deps), doc_id, task_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
|
|
async def test_claim_doc_task_diff_timeout_degrades_with_gap(exc: Exception) -> None:
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.side_effect = exc
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
c, doc_id, task_id = _doc_harness(git_svc)
|
|
|
|
env = await c.claim_doc_task(doc_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff_summary"] == ""
|
|
assert ev["files_changed"] == ["README.md"]
|
|
assert "evidence_gaps" in ev
|
|
assert any("pr diff unavailable" in g for g in ev["evidence_gaps"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
|
|
async def test_claim_doc_task_checkout_timeout_degrades_with_gap(
|
|
exc: Exception,
|
|
) -> None:
|
|
"""The checkout leg (run before evidence assembly) also degrades bounded
|
|
instead of an unbounded suppress(Exception) — its gap folds into the
|
|
same evidence_gaps list the diff/list_changed_files legs use, drawing
|
|
from the SAME shared LegBudget."""
|
|
git_svc = AsyncMock()
|
|
git_svc.checkout_branch_in_agent_workspace.side_effect = exc
|
|
git_svc.diff.return_value = "diff content"
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
c, doc_id, task_id = _doc_harness(git_svc)
|
|
|
|
env = await c.claim_doc_task(doc_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
# The other legs are untouched by the checkout's own timeout.
|
|
assert ev["pr_diff_summary"] == "diff content"
|
|
assert ev["files_changed"] == ["README.md"]
|
|
assert "evidence_gaps" in ev
|
|
assert any("workspace checkout unavailable" in g for g in ev["evidence_gaps"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_doc_task_normal_path_has_no_evidence_gaps() -> None:
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = "diff content"
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
c, doc_id, task_id = _doc_harness(git_svc)
|
|
|
|
env = await c.claim_doc_task(doc_id, task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff_summary"] == "diff content"
|
|
assert ev["files_changed"] == ["README.md"]
|
|
assert "evidence_gaps" not in ev
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# claim_gate_review (pr_gate.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _gate_task() -> MagicMock:
|
|
return MagicMock(
|
|
id=uuid4(),
|
|
status="awaiting_pr_review",
|
|
assigned_to=uuid4(),
|
|
parent_task_id=None,
|
|
task_type="planning",
|
|
dependency_ids=[],
|
|
team="main_pm",
|
|
pr_number=139,
|
|
pr_url="https://example/pr/139",
|
|
branch_name="feature/main_pm/root",
|
|
batch_id=None,
|
|
description=None,
|
|
acceptance_criteria=[],
|
|
)
|
|
|
|
|
|
def _gate_harness(git_svc: AsyncMock) -> tuple[Choreographer, Any, Any]:
|
|
task_svc = AsyncMock()
|
|
t = _gate_task()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
role="pr_reviewer", slug="be-pr-reviewer"
|
|
)
|
|
task_svc.list_in_progress_for_agent.return_value = []
|
|
task_svc.list_paused_for_agent.return_value = []
|
|
task_svc.unmet_dependency_ids = AsyncMock(return_value=[])
|
|
task_svc.has_earlier_incomplete_code_sibling.return_value = False
|
|
task_svc.pr_gate_claim = AsyncMock(return_value=t)
|
|
_stub_empty_ledger(task_svc.session)
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
return Choreographer(deps), t, uuid4()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
|
|
async def test_claim_gate_review_diff_timeout_degrades_with_gap(exc: Exception) -> None:
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.side_effect = exc
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
c, t, reviewer_id = _gate_harness(git_svc)
|
|
|
|
env = await c.claim_gate_review(reviewer_id, t.id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff"] == ""
|
|
assert "evidence_gaps" in ev
|
|
assert any("pr diff unavailable" in g for g in ev["evidence_gaps"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_gate_review_files_changed_timeout_degrades_with_gap(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""list_changed_files hanging must not sink the whole gate claim, and the
|
|
diff leg (which succeeded) must remain intact in the evidence.
|
|
|
|
``_gate_changed_files`` has its own internal ``except Exception`` (an
|
|
existing hard-failure fail-open, untouched by this fix) that would
|
|
swallow a synchronously-raised exception (of either timeout shape)
|
|
before the outer ``run_bounded_leg`` ever saw it — so this uses a
|
|
genuinely slow coroutine + a monkeypatched short budget to exercise the
|
|
real cancel-at-the-wall path (``asyncio.wait_for`` cancelling the
|
|
awaited task via ``CancelledError``, which that ``except Exception``
|
|
does NOT catch), matching what a real hang does in production.
|
|
"""
|
|
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.02)
|
|
|
|
async def _hangs(*_args: object, **_kwargs: object) -> list[str]:
|
|
await asyncio.sleep(5)
|
|
return ["should-not-be-reached"]
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = "diff content"
|
|
git_svc.list_changed_files.side_effect = _hangs
|
|
c, t, reviewer_id = _gate_harness(git_svc)
|
|
|
|
env = await c.claim_gate_review(reviewer_id, t.id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff"] == "diff content"
|
|
assert "evidence_gaps" in ev
|
|
assert any("files_changed unavailable" in g for g in ev["evidence_gaps"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_gate_review_normal_path_has_no_evidence_gaps() -> None:
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = "diff content"
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
c, t, reviewer_id = _gate_harness(git_svc)
|
|
|
|
env = await c.claim_gate_review(reviewer_id, t.id)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["pr_diff"] == "diff content"
|
|
assert "evidence_gaps" not in ev
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_i_am_done_ok (i_am_done's success-envelope evidence). Runs strictly
|
|
# AFTER the composed transition already committed — advisory, not gating —
|
|
# so its list_changed_files leg is bounded exactly like the claim paths.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _done_task(task_id: Any) -> MagicMock:
|
|
return MagicMock(
|
|
id=task_id,
|
|
branch_name="feature/backend/abc",
|
|
commits=[{"sha": "abc123", "message": "x"}],
|
|
dev_notes="done",
|
|
acceptance_criteria_status=[],
|
|
pr_number=5,
|
|
pr_url="https://github.com/x/y/pull/5",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("exc", _TIMEOUT_EXCEPTIONS, ids=_TIMEOUT_IDS)
|
|
async def test_build_i_am_done_ok_files_changed_timeout_degrades_with_gap(
|
|
exc: Exception,
|
|
) -> None:
|
|
"""A hung list_changed_files leg in i_am_done's already-committed
|
|
success-envelope builder must not hang the dev's response — it degrades
|
|
to an empty files_changed and records the gap."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _done_task(task_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
_stub_empty_ledger(task_svc.session)
|
|
git_svc = AsyncMock()
|
|
git_svc.list_changed_files.side_effect = exc
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c._build_i_am_done_ok(agent_id, task_id, t)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["files_changed"] == []
|
|
assert "evidence_gaps" in ev
|
|
assert any("files_changed unavailable" in g for g in ev["evidence_gaps"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_i_am_done_ok_normal_path_has_no_evidence_gaps() -> None:
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _done_task(task_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
_stub_empty_ledger(task_svc.session)
|
|
git_svc = AsyncMock()
|
|
git_svc.list_changed_files.return_value = ["README.md"]
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c._build_i_am_done_ok(agent_id, task_id, t)
|
|
body = env.as_dict()
|
|
assert body["error"] is None, body
|
|
ev = body["evidence"]
|
|
assert ev["files_changed"] == ["README.md"]
|
|
assert "evidence_gaps" not in ev
|