mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(lifecycle): revision findings ledger — structured failure feedback, persisted and delivered down the chain (#486)
* feat(lifecycle): revision findings ledger — structured QA/PR/PM/CEO failure feedback, persisted and delivered down the chain Every bounce used to survive only as flattened prose: rounds overwrote each other in notes_structured, request_changes persisted nothing, two raw dev_notes appends were silently destroyed by the next handoff note, and the dev prompt pointed at fields (qa_notes via evidence(), pm_notes) the API never delivered. Agents re-interpreted and re-discovered every failure before they could start fixing it. - task_review_findings (migration 071, append-only): file/line/severity/ criterion(AC-id-validated)/expected/actual/fix/evidence per finding, with origin (qa|pr_gate|pm|ceo), round, and an open->addressed->verified lifecycle (waived reserved); new tasks.pm_notes + PmReviewContent give request_changes a structured home - producers: fail_review/pr_fail/request_changes take findings=[...] (prose issues shimmed+merged for one release, deprecation-logged); ceo_reject validates its reason (no 500), lands an origin=ceo finding, and bumps round+audit on branchless coordination roots; guardrails at the verb chokepoint (nudge >5, hard reject >10, field caps, traversal-safe file); the dev_notes data-loss appends are removed; new task.request_changes + task.ceo_reject audit events close rework attribution - delivery: qa_notes/pr_reviewer_notes/pm_notes carry the deterministic [F-id8] rendering; claim briefings, evidence(), the REVISION_REQUIRED spawn prompt, PM triage bounced-blocks, and A2A bodies deliver open findings; round-N+1 QA and gate reviewers get the full prior ledger; panel Findings tab + bounced-xN chip; metrics pm_rejects/ceo_rejects + findings counts; vault task notes render a Findings section (fail-open) - resolution closes for every origin: i_am_done and submit_up/submit_root take resolved_findings gated by FINDINGS_ADDRESSED (owner-gated so a stale non-owner PM can never mutate the ledger); pass_review/pr_pass/ complete verify-stamp same-transaction; ceo_approve stamps best-effort - 24 real-DB integration tests drive the full loop through the real choreographer; full suite 12856 green * docs: revision findings ledger sweep — CLAUDE.md, map, RAG corpus - CLAUDE.md: new ledger section + corrected request_changes row - docs/map/review-findings.md (new subsystem map) + surgical updates to task-service/pr-gate-review/metrics-observability/vault/panel maps - docs/rag: producers' findings contract across qa/pr-reviewer/developer/ cell-pm/main-pm/ceo role docs (the PM docs were missing request_changes entirely), verb references, and a new architecture/review-findings.md disambiguating ledger findings from convention findings * test(e2e): resubmit resolves the pr_fail finding per the ledger contract The scripted pr_fail revision loop resubmitted submit_up without resolved_findings — correctly rejected now that FINDINGS_ADDRESSED gates the PM resubmit verbs (green locally, red only in CI since the e2e suite skips without ROBOCO_E2E_SMOKE=1). The scripted PM now reads the open ledger row pr_fail persisted (new open_finding_ids arc helper) and resolves it on resubmit, asserting the open set drains — exercising the coordinator half of the new contract end to end. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -161,18 +161,38 @@ async def test_fail_review_with_issues_returns_needs_revision() -> None:
|
||||
assert call_args.args[2] == ["Missing error handling", "No unit tests"]
|
||||
|
||||
|
||||
def test_fail_review_rejects_empty_issues_list() -> None:
|
||||
"""POST /api/v1/flow/qa/fail with empty issues list is rejected with 422."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_review_accepts_empty_issues_when_findings_given() -> None:
|
||||
"""POST /api/v1/flow/qa/fail with issues=[] is schema-valid — issues is now
|
||||
optional (deprecated free-text shim); findings carries the structured
|
||||
revision-findings ledger entry instead. The "at least one of the two"
|
||||
rule is enforced by the choreographer, not the HTTP schema."""
|
||||
mock_chore = MagicMock()
|
||||
mock_chore.fail_review = AsyncMock(
|
||||
return_value=_make_envelope(status="needs_revision", task_id=_TASK_ID)
|
||||
)
|
||||
client = TestClient(_build_app(mock_chore))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/flow/qa/fail",
|
||||
json={"task_id": _TASK_ID, "issues": []},
|
||||
json={
|
||||
"task_id": _TASK_ID,
|
||||
"issues": [],
|
||||
"findings": [
|
||||
{
|
||||
"expected": "returns 200",
|
||||
"actual": "returns 500",
|
||||
"severity": "major",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == _HTTP_422
|
||||
assert resp.status_code == _HTTP_200
|
||||
mock_chore.fail_review.assert_awaited_once()
|
||||
call_args = mock_chore.fail_review.call_args
|
||||
assert call_args.args[3][0]["actual"] == "returns 500"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from roboco.foundation.policy.content import (
|
||||
AuditorNote,
|
||||
ContentValidationError,
|
||||
DeveloperNote,
|
||||
DocNote,
|
||||
Finding,
|
||||
PmReviewContent,
|
||||
PrReviewContent,
|
||||
QaNote,
|
||||
ResumptionNote,
|
||||
TaskDescription,
|
||||
validate_content,
|
||||
validate_findings,
|
||||
)
|
||||
from roboco.foundation.policy.content.enums import Severity, Verdict
|
||||
from roboco.foundation.policy.content.models import CONTENT_MODELS
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Valid construction
|
||||
@@ -277,3 +282,182 @@ def test_developer_and_doc_and_auditor_models() -> None:
|
||||
),
|
||||
AuditorNote,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Finding — revision-findings ledger caps (fix / evidence / file-relative)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_FINDING_TEXT_CAP = 300
|
||||
|
||||
|
||||
def _finding(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"file": "roboco/services/task.py",
|
||||
"line": 42,
|
||||
"severity": "major",
|
||||
"expected": "raises on invalid input",
|
||||
"actual": "swallows the error silently",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_finding_accepts_fix_and_evidence() -> None:
|
||||
f = Finding.model_validate(
|
||||
_finding(
|
||||
fix="raise ValueError instead", evidence="Traceback: ...\nAssertionError"
|
||||
)
|
||||
)
|
||||
assert f.fix == "raise ValueError instead"
|
||||
assert f.evidence is not None
|
||||
assert f.evidence.startswith("Traceback")
|
||||
|
||||
|
||||
def test_finding_file_is_optional() -> None:
|
||||
f = Finding.model_validate(_finding(file=None))
|
||||
assert f.file is None
|
||||
|
||||
|
||||
def test_finding_rejects_absolute_unix_path() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(file="/etc/passwd"))
|
||||
|
||||
|
||||
def test_finding_rejects_absolute_windows_path() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(file="C:\\Windows\\system32"))
|
||||
|
||||
|
||||
def test_finding_rejects_dotdot_traversal_path() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(file="a/../../etc/passwd"))
|
||||
|
||||
|
||||
def test_finding_rejects_dotdot_leading_segment() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(file="../roboco/services/task.py"))
|
||||
|
||||
|
||||
def test_finding_accepts_dot_segment_and_double_dot_substring() -> None:
|
||||
# A literal ".." SEGMENT is rejected, but a filename merely containing
|
||||
# dots (not a traversal component) must not false-positive.
|
||||
ok = Finding.model_validate(_finding(file="./roboco/services/foo..bar.py"))
|
||||
assert ok.file == "./roboco/services/foo..bar.py"
|
||||
|
||||
|
||||
def test_finding_rejects_non_positive_line() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(line=0))
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(line=-1))
|
||||
|
||||
|
||||
def test_finding_expected_actual_cap_at_300() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(expected="x" * (_FINDING_TEXT_CAP + 1)))
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(actual="x" * (_FINDING_TEXT_CAP + 1)))
|
||||
# exactly at the cap is fine
|
||||
ok = Finding.model_validate(_finding(expected="x" * _FINDING_TEXT_CAP))
|
||||
assert len(ok.expected) == _FINDING_TEXT_CAP
|
||||
|
||||
|
||||
def test_finding_fix_cap_at_500() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(fix="x" * 501))
|
||||
|
||||
|
||||
def test_finding_evidence_cap_at_2000() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(evidence="x" * 2001))
|
||||
|
||||
|
||||
def test_finding_file_cap_at_300() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(file="a/" * 200 + "f.py"))
|
||||
|
||||
|
||||
def test_finding_rejects_placeholder_fix() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Finding.model_validate(_finding(fix="tbd"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# validate_findings — the ledger's raw list[dict] -> list[Finding] entry point
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_EXPECTED_TWO_FINDINGS = 2
|
||||
|
||||
|
||||
def test_validate_findings_returns_typed_list() -> None:
|
||||
findings = validate_findings([_finding(), _finding(file=None, line=None)])
|
||||
assert len(findings) == _EXPECTED_TWO_FINDINGS
|
||||
assert all(isinstance(f, Finding) for f in findings)
|
||||
assert findings[1].file is None
|
||||
|
||||
|
||||
def test_validate_findings_passes_through_existing_finding_instances() -> None:
|
||||
f = Finding.model_validate(_finding())
|
||||
assert validate_findings([f]) == [f]
|
||||
|
||||
|
||||
def test_validate_findings_raises_content_validation_error() -> None:
|
||||
with pytest.raises(ContentValidationError):
|
||||
validate_findings([_finding(severity="catastrophic")])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# QaNote.findings — parity with PrReviewContent.findings
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_qa_note_accepts_findings() -> None:
|
||||
c = QaNote.model_validate(
|
||||
{
|
||||
"summary": "[F-abc12345] task.py:42 (major) — expected → actual",
|
||||
"findings": [_finding()],
|
||||
"verdict": "failed",
|
||||
}
|
||||
)
|
||||
assert len(c.findings) == 1
|
||||
assert c.findings[0].severity is Severity.MAJOR
|
||||
# QaNote deliberately does not re-render findings into their own section
|
||||
# (summary already carries the deterministic per-finding rendering) —
|
||||
# avoids the double-rendering the PR-gate summary explicitly avoids.
|
||||
rendered = c.render_markdown()
|
||||
assert "## Findings" not in rendered
|
||||
|
||||
|
||||
def test_qa_note_findings_default_empty() -> None:
|
||||
c = QaNote.model_validate(
|
||||
{"summary": "Everything checked out fine.", "verdict": "passed"}
|
||||
)
|
||||
assert c.findings == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PmReviewContent — the request_changes note (no verdict field)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_pm_review_content_valid() -> None:
|
||||
c = validate_content(
|
||||
"pm_review",
|
||||
{
|
||||
"summary": "[F-abc12345] file.py:10 (major) — expected → actual",
|
||||
"findings": [_finding()],
|
||||
},
|
||||
)
|
||||
assert isinstance(c, PmReviewContent)
|
||||
assert len(c.findings) == 1
|
||||
assert c.render_markdown() == "## Summary\n" + c.summary
|
||||
|
||||
|
||||
def test_pm_review_content_rejects_trivial_summary() -> None:
|
||||
with pytest.raises(ContentValidationError):
|
||||
validate_content("pm_review", {"summary": "wip"})
|
||||
|
||||
|
||||
def test_pm_review_content_registered_in_content_models() -> None:
|
||||
assert CONTENT_MODELS["pm_review"] is PmReviewContent
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""The FINDINGS_ADDRESSED tracing requirement — i_am_done's ledger resolution gate.
|
||||
|
||||
Pure unit tests against ``foundation.policy.tracing`` (no DB, no choreographer):
|
||||
the checker itself, its registration in VERB_REQUIREMENTS["i_am_done"], and the
|
||||
"empty ledger passes untouched" contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.foundation.policy import tracing as tr
|
||||
|
||||
|
||||
def test_findings_addressed_is_required_by_i_am_done() -> None:
|
||||
assert tr.Requirement.FINDINGS_ADDRESSED in tr.VERB_REQUIREMENTS["i_am_done"]
|
||||
|
||||
|
||||
def test_no_open_findings_passes_trivially() -> None:
|
||||
result = tr.check_requirements(
|
||||
task=object(),
|
||||
requirements=[tr.Requirement.FINDINGS_ADDRESSED],
|
||||
ctx=tr.GateContext(open_finding_ids=()),
|
||||
)
|
||||
assert result.passed
|
||||
assert result.missing == []
|
||||
|
||||
|
||||
def test_open_findings_block_and_name_each_id() -> None:
|
||||
result = tr.check_requirements(
|
||||
task=object(),
|
||||
requirements=[tr.Requirement.FINDINGS_ADDRESSED],
|
||||
ctx=tr.GateContext(open_finding_ids=("abc12345", "def67890")),
|
||||
)
|
||||
assert not result.passed
|
||||
assert result.missing == ["finding:abc12345", "finding:def67890"]
|
||||
|
||||
|
||||
def test_default_gate_context_has_no_open_findings() -> None:
|
||||
assert tr.GateContext().open_finding_ids == ()
|
||||
|
||||
|
||||
def test_i_am_done_requirements_include_the_pre_existing_set_too() -> None:
|
||||
"""Adding FINDINGS_ADDRESSED must not have dropped any prior requirement."""
|
||||
required = tr.VERB_REQUIREMENTS["i_am_done"]
|
||||
for expected in (
|
||||
tr.Requirement.COMMITS_AT_LEAST_ONE,
|
||||
tr.Requirement.PR_OPEN,
|
||||
tr.Requirement.PROGRESS_AT_LEAST_ONE,
|
||||
tr.Requirement.SELF_VERIFIED,
|
||||
tr.Requirement.JOURNAL_REFLECT,
|
||||
tr.Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE,
|
||||
tr.Requirement.ACCEPTANCE_CRITERIA_ADDRESSED,
|
||||
tr.Requirement.DEV_NOTES_MIN_CHARS,
|
||||
):
|
||||
assert expected in required
|
||||
@@ -45,6 +45,14 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
# cell_pm_complete / main_pm_complete's pm-origin verified-stamp reads via
|
||||
# session.execute (ReviewFindingsRepository.list_for_task) before merging
|
||||
# — an empty scalars result (no findings) so the stamp is a no-op here.
|
||||
task_dep.session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
repo = base["evidence_repo"]
|
||||
for method in (
|
||||
"list_unread_a2a",
|
||||
|
||||
@@ -43,6 +43,16 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
_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)
|
||||
# cell_pm_complete / main_pm_complete's pm-origin verified-stamp reads via
|
||||
# session.execute (ReviewFindingsRepository.list_for_task) before merging
|
||||
# — an empty scalars result (no findings) so the stamp is a no-op here.
|
||||
# Additive (not a session replacement) so a test's own session.begin_nested
|
||||
# setup (e.g. submit_root's savepoint) is untouched.
|
||||
base["task"].session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
|
||||
_EXPECTED_PR_NUMBER = 8
|
||||
_EXPECTED_PR_URL = "https://github.com/x/y/pull/8"
|
||||
_EXPECTED_FINDINGS_COUNT = 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -206,6 +207,17 @@ def _qa_agent_mock(qa_id: Any) -> MagicMock:
|
||||
return MagicMock(id=qa_id, role="qa", team="backend", slug=None)
|
||||
|
||||
|
||||
def _stub_empty_ledger(session: MagicMock) -> None:
|
||||
"""Configure a mock session's ``execute`` so ``ReviewFindingsRepository``
|
||||
finds no rows — covers pass_review's verified-stamp read (list_for_task),
|
||||
which a bare ``session.add``/``flush`` stub doesn't reach."""
|
||||
session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_review_requires_qa_notes_min_chars() -> None:
|
||||
qa_id = uuid4()
|
||||
@@ -290,6 +302,7 @@ async def test_pass_review_succeeds_and_transitions() -> None:
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
_stub_empty_ledger(task_svc.session)
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
a2a_svc = AsyncMock()
|
||||
@@ -341,6 +354,8 @@ async def test_fail_review_succeeds() -> None:
|
||||
task_svc.agent_for.return_value = _qa_agent_mock(qa_id)
|
||||
task_svc.qa_fail.return_value = after
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.add = MagicMock()
|
||||
task_svc.session.flush = AsyncMock()
|
||||
task_svc.session.begin_nested = MagicMock(
|
||||
return_value=MagicMock(
|
||||
__aenter__=AsyncMock(return_value=None),
|
||||
@@ -362,6 +377,8 @@ async def test_fail_review_succeeds() -> None:
|
||||
assert env.status == "needs_revision"
|
||||
task_svc.qa_fail.assert_awaited_once()
|
||||
a2a_svc.send.assert_awaited_once()
|
||||
# The ledger insert ran (2 shimmed findings) before the transition.
|
||||
assert task_svc.session.add.call_count == _EXPECTED_FINDINGS_COUNT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -380,7 +397,7 @@ async def test_fail_review_requires_at_least_one_issue() -> None:
|
||||
env = await c.fail_review(qa_id, task_id, issues=[])
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "issue" in body["message"].lower()
|
||||
assert "finding" in body["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -452,6 +469,7 @@ async def test_pass_review_survives_a2a_send_failure() -> None:
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
_stub_empty_ledger(task_svc.session)
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
a2a_svc = AsyncMock()
|
||||
|
||||
@@ -44,6 +44,14 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
# cell_pm_complete / main_pm_complete's pm-origin verified-stamp reads via
|
||||
# session.execute (ReviewFindingsRepository.list_for_task) before merging
|
||||
# — an empty scalars result (no findings) so the stamp is a no-op here.
|
||||
task.session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
repo = base["evidence_repo"]
|
||||
for method in (
|
||||
"list_unread_a2a",
|
||||
@@ -232,6 +240,16 @@ def _begin_nested_mock() -> Any:
|
||||
)
|
||||
|
||||
|
||||
def _empty_ledger_execute() -> AsyncMock:
|
||||
"""pass_review's verified-stamp (ReviewFindingsRepository.list_for_task)
|
||||
reads via session.execute — an empty scalars result (no findings)."""
|
||||
return AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_review_reassigns_task_to_documenter() -> None:
|
||||
qa_id = uuid4()
|
||||
@@ -252,6 +270,7 @@ async def test_pass_review_reassigns_task_to_documenter() -> None:
|
||||
task_svc.documenter_for_team.return_value = MagicMock(id=doc_id)
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.begin_nested = _begin_nested_mock()
|
||||
task_svc.session.execute = _empty_ledger_execute()
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
@@ -285,6 +304,7 @@ async def test_pass_review_skips_reassign_when_no_documenter() -> None:
|
||||
task_svc.documenter_for_team.return_value = None
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.begin_nested = _begin_nested_mock()
|
||||
task_svc.session.execute = _empty_ledger_execute()
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
@@ -558,6 +578,8 @@ async def test_fail_review_does_not_double_reassign() -> None:
|
||||
task_svc.agent_for.return_value = _qa_agent(qa_id)
|
||||
task_svc.qa_fail.return_value = after
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.add = MagicMock()
|
||||
task_svc.session.flush = AsyncMock()
|
||||
task_svc.session.begin_nested = _begin_nested_mock()
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
|
||||
@@ -80,6 +80,8 @@ async def test_request_changes_succeeds_and_notifies_new_owner() -> None:
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(pm_id)
|
||||
task_svc.request_changes.return_value = after
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.add = MagicMock()
|
||||
task_svc.session.flush = AsyncMock()
|
||||
task_svc.session.begin_nested = MagicMock(
|
||||
return_value=MagicMock(
|
||||
__aenter__=AsyncMock(return_value=None),
|
||||
@@ -98,6 +100,8 @@ async def test_request_changes_succeeds_and_notifies_new_owner() -> None:
|
||||
assert env.status == "needs_revision"
|
||||
task_svc.request_changes.assert_awaited_once()
|
||||
a2a_svc.send.assert_awaited_once()
|
||||
# The ledger insert ran (findings=[the shimmed issue]) before the transition.
|
||||
task_svc.session.add.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -114,7 +118,7 @@ async def test_request_changes_requires_at_least_one_issue() -> None:
|
||||
env = await c.request_changes(pm_id, task_id, issues=[])
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "issue" in body["message"].lower()
|
||||
assert "finding" in body["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -79,5 +79,8 @@ def test_evidence_payload_includes_convention_findings() -> None:
|
||||
|
||||
|
||||
def test_evidence_payload_convention_findings_default_empty() -> None:
|
||||
"""An empty findings list is omitted from as_dict() entirely (zero-noise
|
||||
posture, matching build_task_handoff) — the attribute itself stays []."""
|
||||
ev = build_evidence_for_task(_stub_task(), journal_highlights=[], files_changed=[])
|
||||
assert ev.as_dict()["convention_findings"] == []
|
||||
assert ev.convention_findings == []
|
||||
assert "convention_findings" not in ev.as_dict()
|
||||
|
||||
@@ -8,12 +8,16 @@ from uuid import uuid4
|
||||
|
||||
from roboco.services.gateway.evidence_builder import (
|
||||
BRIEFING_LIST_CAP,
|
||||
FINDING_EVIDENCE_EXCERPT_CAP,
|
||||
BriefingInputs,
|
||||
build_context_briefing,
|
||||
build_evidence_for_task,
|
||||
build_task_handoff,
|
||||
render_findings,
|
||||
)
|
||||
|
||||
_EXPECTED_TWO = 2
|
||||
|
||||
|
||||
def _task(
|
||||
*,
|
||||
@@ -241,3 +245,202 @@ class TestPrReviewSurface:
|
||||
digest = build_task_handoff(t, [])
|
||||
assert digest is not None
|
||||
assert digest["pr_review"]["issues"] == ["fix the off-by-one"]
|
||||
|
||||
|
||||
def _finding_row(**over: Any) -> MagicMock:
|
||||
base: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"round": 2,
|
||||
"origin": "qa",
|
||||
"status": "open",
|
||||
"severity": "major",
|
||||
"file": "roboco/services/task.py",
|
||||
"line": 10,
|
||||
"expected": "raises on bad input",
|
||||
"actual": "swallows the error",
|
||||
"fix": "add the raise",
|
||||
"evidence": None,
|
||||
}
|
||||
base.update(over)
|
||||
return MagicMock(**base)
|
||||
|
||||
|
||||
class TestRenderFindings:
|
||||
def test_renders_compact_dict_with_id8_prefix(self) -> None:
|
||||
row = _finding_row()
|
||||
rendered = render_findings([row])
|
||||
assert len(rendered) == 1
|
||||
entry = rendered[0]
|
||||
assert entry["id"] == str(row.id)[:8]
|
||||
assert entry["round"] == row.round
|
||||
assert entry["origin"] == "qa"
|
||||
assert entry["status"] == "open"
|
||||
assert entry["file"] == "roboco/services/task.py"
|
||||
assert entry["line"] == row.line
|
||||
assert entry["expected"] == "raises on bad input"
|
||||
assert entry["actual"] == "swallows the error"
|
||||
assert entry["fix"] == "add the raise"
|
||||
|
||||
def test_none_rows_render_empty(self) -> None:
|
||||
assert render_findings(None) == []
|
||||
|
||||
def test_caps_defensively(self) -> None:
|
||||
rows = [_finding_row() for _ in range(BRIEFING_LIST_CAP + 5)]
|
||||
assert len(render_findings(rows)) == BRIEFING_LIST_CAP
|
||||
|
||||
def test_evidence_excerpt_clipped_with_omission_note(self) -> None:
|
||||
long_evidence = "x" * (FINDING_EVIDENCE_EXCERPT_CAP + 50)
|
||||
row = _finding_row(evidence=long_evidence)
|
||||
entry = render_findings([row])[0]
|
||||
assert entry["evidence"] is not None
|
||||
assert len(entry["evidence"]) < len(long_evidence)
|
||||
assert "chars omitted" in entry["evidence"]
|
||||
assert entry["evidence"].startswith("x" * FINDING_EVIDENCE_EXCERPT_CAP)
|
||||
|
||||
def test_short_evidence_is_not_annotated(self) -> None:
|
||||
row = _finding_row(evidence="short excerpt")
|
||||
entry = render_findings([row])[0]
|
||||
assert entry["evidence"] == "short excerpt"
|
||||
|
||||
|
||||
class TestEvidencePayloadFindings:
|
||||
def test_revision_and_prior_findings_render(self) -> None:
|
||||
t = _task()
|
||||
open_row = _finding_row(status="open")
|
||||
all_row = _finding_row(status="verified", round=1)
|
||||
ev = build_evidence_for_task(
|
||||
t,
|
||||
journal_highlights=[],
|
||||
files_changed=[],
|
||||
revision_findings=[open_row],
|
||||
prior_findings=[open_row, all_row],
|
||||
)
|
||||
assert len(ev.revision_findings) == 1
|
||||
assert ev.revision_findings[0]["status"] == "open"
|
||||
assert len(ev.prior_findings) == _EXPECTED_TWO
|
||||
|
||||
def test_findings_default_empty_no_noise(self) -> None:
|
||||
t = _task()
|
||||
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
|
||||
assert ev.revision_findings == []
|
||||
assert ev.prior_findings == []
|
||||
|
||||
def test_as_dict_omits_empty_findings_lists(self) -> None:
|
||||
"""Empty findings lists must not serialize into the envelope at all —
|
||||
an absent key reads as 'nothing here', identical to an empty list,
|
||||
at zero token cost (matches build_task_handoff's posture)."""
|
||||
t = _task()
|
||||
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
|
||||
body = ev.as_dict()
|
||||
assert "revision_findings" not in body
|
||||
assert "prior_findings" not in body
|
||||
assert "convention_findings" not in body
|
||||
# Non-empty EvidencePayload fields (even empty lists like
|
||||
# files_changed/commits) are unaffected — only the three findings
|
||||
# fields get the omit-when-empty treatment.
|
||||
assert "commits" in body
|
||||
assert "files_changed" in body
|
||||
|
||||
def test_as_dict_keeps_non_empty_findings_lists(self) -> None:
|
||||
t = _task()
|
||||
open_row = _finding_row(status="open")
|
||||
ev = build_evidence_for_task(
|
||||
t,
|
||||
journal_highlights=[],
|
||||
files_changed=[],
|
||||
revision_findings=[open_row],
|
||||
prior_findings=[open_row],
|
||||
)
|
||||
body = ev.as_dict()
|
||||
assert len(body["revision_findings"]) == 1
|
||||
assert len(body["prior_findings"]) == 1
|
||||
|
||||
|
||||
class TestTaskHandoffRevisionFindings:
|
||||
def test_open_findings_surface_under_revision_findings(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
row = _finding_row(status="open", file="api/routes/foo.py", line=17)
|
||||
digest = build_task_handoff(t, [], [row])
|
||||
assert digest is not None
|
||||
assert len(digest["revision_findings"]) == 1
|
||||
entry = digest["revision_findings"][0]
|
||||
assert entry["file"] == "api/routes/foo.py"
|
||||
assert entry["line"] == row.line
|
||||
|
||||
def test_empty_ledger_is_silent(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
digest = build_task_handoff(t, [], [])
|
||||
assert digest is not None
|
||||
assert "revision_findings" not in digest
|
||||
|
||||
def test_no_findings_arg_is_silent(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
digest = build_task_handoff(t, [])
|
||||
assert digest is not None
|
||||
assert "revision_findings" not in digest
|
||||
|
||||
def test_open_findings_alone_is_prior_work_worth_resuming(self) -> None:
|
||||
"""A task with no commits/dev-notes but an open finding still
|
||||
surfaces the handoff — the bounced dev must see it."""
|
||||
t = _task(pr_number=None, pr_url=None, dev_notes="")
|
||||
t.commits = []
|
||||
t.acceptance_criteria_status = []
|
||||
digest = build_task_handoff(t, [], [_finding_row()])
|
||||
assert digest is not None
|
||||
assert len(digest["revision_findings"]) == 1
|
||||
|
||||
def test_caps_at_briefing_list_cap(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
rows = [_finding_row() for _ in range(BRIEFING_LIST_CAP + 5)]
|
||||
digest = build_task_handoff(t, [], rows)
|
||||
assert digest is not None
|
||||
assert len(digest["revision_findings"]) == BRIEFING_LIST_CAP
|
||||
|
||||
|
||||
class TestExtractQaReview:
|
||||
def test_surfaces_verdict_summary_and_findings_count(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
t.notes_structured = {
|
||||
"qa": {
|
||||
"summary": "2 findings, both blocking",
|
||||
"verdict": "failed",
|
||||
"findings": [{"expected": "x", "actual": "y"}, {"expected": "a"}],
|
||||
}
|
||||
}
|
||||
digest = build_task_handoff(t, [])
|
||||
assert digest is not None
|
||||
qa_review = digest["qa_review"]
|
||||
assert qa_review["verdict"] == "failed"
|
||||
assert qa_review["summary"] == "2 findings, both blocking"
|
||||
assert qa_review["findings_count"] == _EXPECTED_TWO
|
||||
|
||||
def test_absent_when_no_qa_slot(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
t.notes_structured = None
|
||||
digest = build_task_handoff(t, [])
|
||||
assert digest is not None
|
||||
assert "qa_review" not in digest
|
||||
|
||||
|
||||
class TestExtractPmReview:
|
||||
def test_surfaces_summary_and_findings_count_no_verdict(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
t.notes_structured = {
|
||||
"pm_review": {
|
||||
"summary": "merge-review reject",
|
||||
"findings": [{"expected": "x", "actual": "y"}],
|
||||
}
|
||||
}
|
||||
digest = build_task_handoff(t, [])
|
||||
assert digest is not None
|
||||
pm_review = digest["pm_review"]
|
||||
assert pm_review["summary"] == "merge-review reject"
|
||||
assert pm_review["findings_count"] == 1
|
||||
assert "verdict" not in pm_review
|
||||
|
||||
def test_absent_when_no_pm_review_slot(self) -> None:
|
||||
t = _task(pr_number=8, commits=[{"sha": "abc", "message": "feat: x"}])
|
||||
t.notes_structured = {}
|
||||
digest = build_task_handoff(t, [])
|
||||
assert digest is not None
|
||||
assert "pm_review" not in digest
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Wiring tests for the revision-findings ledger's delivery to reviewers +
|
||||
the verified-stamping semantics.
|
||||
|
||||
- ``claim_review`` / ``claim_gate_review`` evidence carries ``prior_findings``
|
||||
(the full ledger, not just what's open) so a round-N+1 reviewer verifies
|
||||
prior rounds item-by-item.
|
||||
- ``pass_review`` stamps ``qa``-origin addressed findings verified;
|
||||
``pr_pass`` stamps ``pr_gate``-origin addressed findings verified. Both
|
||||
run BEFORE the transition (same-transaction posture) — a stamping failure
|
||||
must reject cleanly and never let the transition proceed against a stale
|
||||
ledger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.foundation.policy import lifecycle as spec_module
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||
|
||||
_EXPECTED_TWO = 2
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base = {
|
||||
"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 _row(**over: Any) -> SimpleNamespace:
|
||||
base: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"round": 1,
|
||||
"origin": "qa",
|
||||
"status": "addressed",
|
||||
"severity": "major",
|
||||
"file": "roboco/services/task.py",
|
||||
"line": 10,
|
||||
"expected": "raises",
|
||||
"actual": "swallows",
|
||||
"fix": "add raise",
|
||||
"evidence": None,
|
||||
}
|
||||
base.update(over)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# claim_review — prior_findings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_review_evidence_carries_prior_findings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
qa_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t_initial = MagicMock(
|
||||
id=task_id,
|
||||
status="awaiting_qa",
|
||||
assigned_to=None,
|
||||
pr_number=8,
|
||||
pr_url="https://x/pr/8",
|
||||
commits=[],
|
||||
team="backend",
|
||||
branch_name="feature/backend/abc",
|
||||
work_session_id=None,
|
||||
documents=[],
|
||||
dev_notes="",
|
||||
acceptance_criteria=[],
|
||||
acceptance_criteria_status=[],
|
||||
)
|
||||
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
|
||||
git_svc = AsyncMock()
|
||||
git_svc.diff.return_value = ""
|
||||
git_svc.list_changed_files.return_value = []
|
||||
deps = _make_deps(task=task_svc, git=git_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
verified_row = _row(status="verified", round=1, actual="round 1 issue")
|
||||
open_row = _row(status="open", round=2, actual="round 2 issue")
|
||||
monkeypatch.setattr(
|
||||
findings_lib,
|
||||
"full_ledger_for_task",
|
||||
AsyncMock(return_value=[open_row, verified_row]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
findings_lib, "open_findings_for_task", AsyncMock(return_value=[open_row])
|
||||
)
|
||||
|
||||
env = await c.claim_review(qa_id, task_id)
|
||||
body = env.as_dict()
|
||||
prior = body["evidence"]["prior_findings"]
|
||||
assert len(prior) == _EXPECTED_TWO
|
||||
assert {f["status"] for f in prior} == {"open", "verified"}
|
||||
assert body["evidence"]["revision_findings"] == [
|
||||
{
|
||||
"id": str(open_row.id)[:8],
|
||||
"round": 2,
|
||||
"origin": "qa",
|
||||
"status": "open",
|
||||
"severity": "major",
|
||||
"file": "roboco/services/task.py",
|
||||
"line": 10,
|
||||
"expected": "raises",
|
||||
"actual": "round 2 issue",
|
||||
"fix": "add raise",
|
||||
"evidence": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# claim_gate_review — prior_findings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_gate_review_evidence_carries_prior_findings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
reviewer_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = MagicMock(
|
||||
id=task_id,
|
||||
status="awaiting_pr_review",
|
||||
assigned_to=None,
|
||||
pr_number=42,
|
||||
pr_url="https://x/pr/42",
|
||||
branch_name="feature/main_pm/abc",
|
||||
parent_task_id=None,
|
||||
batch_id=None,
|
||||
acceptance_criteria=["AC1"],
|
||||
)
|
||||
t_claimed = MagicMock(**{**t.__dict__, "assigned_to": reviewer_id})
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = MagicMock(role="pr_reviewer", team=None)
|
||||
task_svc.pr_gate_claim.return_value = t_claimed
|
||||
git_svc = AsyncMock()
|
||||
git_svc.diff.return_value = "+++ diff"
|
||||
deps = _make_deps(task=task_svc, git=git_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
gate_row = _row(origin="pr_gate", status="open", actual="assembled diff issue")
|
||||
monkeypatch.setattr(
|
||||
findings_lib, "full_ledger_for_task", AsyncMock(return_value=[gate_row])
|
||||
)
|
||||
|
||||
env = await c.claim_gate_review(reviewer_id, task_id)
|
||||
body = env.as_dict()
|
||||
prior = body["evidence"]["prior_findings"]
|
||||
assert len(prior) == 1
|
||||
assert prior[0]["origin"] == "pr_gate"
|
||||
assert prior[0]["actual"] == "assembled diff issue"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pass_review — verified-stamp wiring + same-transaction failure semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _qa_owned_task(task_id: Any, qa_id: Any, **overrides: Any) -> MagicMock:
|
||||
base = {
|
||||
"id": task_id,
|
||||
"status": "awaiting_qa",
|
||||
"task_type": "code",
|
||||
"team": "backend",
|
||||
"assigned_to": qa_id,
|
||||
"qa_evidence_inspected": True,
|
||||
"quick_context": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return MagicMock(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_review_stamps_qa_origin_verified(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
qa_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _qa_owned_task(task_id, qa_id)
|
||||
after = MagicMock(
|
||||
id=task_id, status="awaiting_documentation", assigned_to=qa_id, team="backend"
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = MagicMock(role="qa", team="backend", slug=None)
|
||||
task_svc.qa_pass.return_value = after
|
||||
task_svc.documenter_for_team.return_value = None
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.begin_nested = MagicMock(
|
||||
return_value=MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())
|
||||
)
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
stamp = AsyncMock(return_value=1)
|
||||
monkeypatch.setattr(findings_lib, "stamp_addressed_verified", stamp)
|
||||
|
||||
notes = (
|
||||
"Reviewed PR carefully. Branch convention correct. Commit prefix "
|
||||
"verified. README diff matches spec. All acceptance criteria met."
|
||||
)
|
||||
env = await c.pass_review(qa_id, task_id, notes=notes)
|
||||
assert env.error is None, env.as_dict()
|
||||
stamp.assert_awaited_once()
|
||||
call = stamp.await_args
|
||||
assert call is not None
|
||||
assert call.kwargs.get("origin") == "qa"
|
||||
task_svc.qa_pass.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass_review_stamp_failure_rejects_before_transition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
qa_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _qa_owned_task(task_id, qa_id)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = MagicMock(role="qa", team="backend", slug=None)
|
||||
task_svc.session = MagicMock()
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
monkeypatch.setattr(
|
||||
findings_lib,
|
||||
"stamp_addressed_verified",
|
||||
AsyncMock(side_effect=RuntimeError("ledger down")),
|
||||
)
|
||||
|
||||
notes = (
|
||||
"Reviewed PR carefully. Branch convention correct. Commit prefix "
|
||||
"verified. README diff matches spec. All acceptance criteria met."
|
||||
)
|
||||
env = await c.pass_review(qa_id, task_id, notes=notes)
|
||||
assert env.error == "invalid_state"
|
||||
# The transition never ran — the ledger failure didn't leave a passed
|
||||
# task against a stale verified-stamp.
|
||||
task_svc.qa_pass.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pr_pass — verified-stamp wiring + same-transaction failure semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_gate_choreographer() -> Choreographer:
|
||||
base: dict[str, Any] = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
base["task"].session = MagicMock()
|
||||
base["task"].session.add = MagicMock()
|
||||
base["task"].session.flush = AsyncMock()
|
||||
return Choreographer(ChoreographerDeps(**base))
|
||||
|
||||
|
||||
def _stub_gate_path(
|
||||
c: Choreographer, *, reviewer_id: Any, t_before: Any, t_after: Any
|
||||
) -> MagicMock:
|
||||
agent = MagicMock(role="pr_reviewer", slug="be-pr-reviewer")
|
||||
cc: Any = c
|
||||
cc._gate_preflight = AsyncMock(
|
||||
return_value=(
|
||||
t_before,
|
||||
agent,
|
||||
"pr_reviewer",
|
||||
{},
|
||||
spec_module.Context(actor_id=reviewer_id),
|
||||
)
|
||||
)
|
||||
cc._gate_tracing = AsyncMock(return_value=None)
|
||||
cc._project_slug_for = AsyncMock(return_value=None)
|
||||
record_spy = MagicMock()
|
||||
cc._record_gate_verdict = record_spy
|
||||
cc._post_gate_review_to_pr = AsyncMock()
|
||||
runner = MagicMock()
|
||||
runner.run_intent = AsyncMock(return_value=t_after)
|
||||
cc._verb_runner = MagicMock(return_value=runner)
|
||||
return record_spy
|
||||
|
||||
|
||||
def _t(*, status: str = "awaiting_pr_review", pr_number: int | None = 42) -> MagicMock:
|
||||
return MagicMock(
|
||||
id=uuid4(),
|
||||
assigned_to=None,
|
||||
pr_number=pr_number,
|
||||
parent_task_id=uuid4(),
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_pass_stamps_pr_gate_origin_verified(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
reviewer_id = uuid4()
|
||||
t_before = _t()
|
||||
t_after = _t(status="awaiting_pm_review")
|
||||
c = _make_gate_choreographer()
|
||||
record_spy = _stub_gate_path(
|
||||
c, reviewer_id=reviewer_id, t_before=t_before, t_after=t_after
|
||||
)
|
||||
stamp = AsyncMock(return_value=1)
|
||||
monkeypatch.setattr(findings_lib, "stamp_addressed_verified", stamp)
|
||||
|
||||
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
|
||||
|
||||
assert env.error is None, env.as_dict()
|
||||
stamp.assert_awaited_once()
|
||||
call = stamp.await_args
|
||||
assert call is not None
|
||||
assert call.kwargs.get("origin") == "pr_gate"
|
||||
record_spy.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_pass_stamp_failure_rejects_before_transition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
reviewer_id = uuid4()
|
||||
t_before = _t()
|
||||
c = _make_gate_choreographer()
|
||||
record_spy = _stub_gate_path(
|
||||
c, reviewer_id=reviewer_id, t_before=t_before, t_after=None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
findings_lib,
|
||||
"stamp_addressed_verified",
|
||||
AsyncMock(side_effect=RuntimeError("ledger down")),
|
||||
)
|
||||
|
||||
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
|
||||
|
||||
assert env.error == "invalid_state"
|
||||
# The verdict was never recorded and the transition never ran.
|
||||
record_spy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_fail_does_not_stamp_anything(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""pr_fail never verifies findings — only pr_pass does."""
|
||||
reviewer_id = uuid4()
|
||||
t_before = _t()
|
||||
t_after = _t(status="needs_revision")
|
||||
c = _make_gate_choreographer()
|
||||
_stub_gate_path(c, reviewer_id=reviewer_id, t_before=t_before, t_after=t_after)
|
||||
stamp = AsyncMock()
|
||||
monkeypatch.setattr(findings_lib, "stamp_addressed_verified", stamp)
|
||||
|
||||
env = await c.pr_fail(reviewer_id, t_before.id, ["a concrete actionable issue"])
|
||||
|
||||
assert env.error is None, env.as_dict()
|
||||
stamp.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -37,6 +37,20 @@ def _make_choreographer() -> Choreographer:
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
# pr_fail now inserts its findings into the revision-findings ledger
|
||||
# before the transition (real _attach_pr_fail_findings runs in these
|
||||
# tests — only the ownership/tracing plumbing is stubbed); the ledger
|
||||
# repository needs an awaitable ``flush()``.
|
||||
base["task"].session = MagicMock()
|
||||
base["task"].session.add = MagicMock()
|
||||
base["task"].session.flush = AsyncMock()
|
||||
# pr_pass's verified-stamp (ReviewFindingsRepository.list_for_task) reads
|
||||
# via session.execute — an empty scalars result (no findings).
|
||||
base["task"].session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
return Choreographer(ChoreographerDeps(**base))
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.foundation.policy.content import Finding, Severity
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
@@ -157,3 +158,39 @@ def test_pr_pass_leaves_issues_slot_empty() -> None:
|
||||
slot = t.notes_structured["pr_review"]
|
||||
assert slot["verdict"] == "passed"
|
||||
assert slot.get("issues", []) == []
|
||||
|
||||
|
||||
def test_pr_fail_embeds_findings_and_summary_does_not_duplicate() -> None:
|
||||
"""The revision-findings ledger's structured findings must land in the
|
||||
format-enforced ``findings`` slot (its own render_markdown table already
|
||||
displays them); ``summary`` stays the plain "N issue(s)" sentence — baking
|
||||
the per-finding text into both would duplicate every line on the Task
|
||||
Details card (the same anti-duplication the free-text ``issues`` case
|
||||
already established)."""
|
||||
c = _make_choreographer()
|
||||
t = _TaskWithNoNotes()
|
||||
findings = [
|
||||
Finding(
|
||||
file="roboco/api/routes/health.py",
|
||||
line=12,
|
||||
severity=Severity.MAJOR,
|
||||
expected="returns 200",
|
||||
actual="returns 500 on the timestamp branch",
|
||||
)
|
||||
]
|
||||
c._record_gate_verdict(
|
||||
t,
|
||||
"pr_fail",
|
||||
"[F-abc12345] roboco/api/routes/health.py:12 (major) — returns 200 → "
|
||||
"returns 500 on the timestamp branch",
|
||||
findings=findings,
|
||||
)
|
||||
assert t.notes_structured is not None
|
||||
slot = t.notes_structured["pr_review"]
|
||||
assert slot["verdict"] == "failed"
|
||||
assert len(slot["findings"]) == 1
|
||||
assert slot["findings"][0]["actual"] == "returns 500 on the timestamp branch"
|
||||
assert "1 issue(s) listed below" in slot["summary"]
|
||||
assert "returns 500 on the timestamp branch" not in slot["summary"]
|
||||
# The derived TEXT mirror renders the findings table (render_markdown).
|
||||
assert "returns 500 on the timestamp branch" in t.pr_reviewer_notes
|
||||
|
||||
@@ -39,6 +39,18 @@ def _make_choreographer() -> Choreographer:
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
# pr_fail inserts its findings into the ledger before the transition;
|
||||
# the repository needs an awaitable ``flush()`` on the mock session.
|
||||
base["task"].session = MagicMock()
|
||||
base["task"].session.add = MagicMock()
|
||||
base["task"].session.flush = AsyncMock()
|
||||
# pr_pass's verified-stamp (ReviewFindingsRepository.list_for_task) reads
|
||||
# via session.execute — an empty scalars result (no findings).
|
||||
base["task"].session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
return Choreographer(ChoreographerDeps(**base))
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,13 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
)
|
||||
)
|
||||
task.session.flush = AsyncMock()
|
||||
# pass_review's verified-stamp (ReviewFindingsRepository.list_for_task)
|
||||
# reads via session.execute — an empty scalars result (no findings).
|
||||
task.session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
repo = base["evidence_repo"]
|
||||
for method in (
|
||||
"list_unread_a2a",
|
||||
@@ -406,6 +413,9 @@ async def test_fail_review_success_releases_sandbox(
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _qa_agent_mock(qa_id)
|
||||
task_svc.qa_fail.return_value = after
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.add = MagicMock()
|
||||
task_svc.session.flush = AsyncMock()
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_learning_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
|
||||
@@ -299,6 +299,18 @@ def _make_choreographer_for_gate() -> Choreographer:
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
# pr_fail inserts its findings into the ledger before the transition;
|
||||
# the repository needs an awaitable ``flush()`` on the mock session.
|
||||
base["task"].session = MagicMock()
|
||||
base["task"].session.add = MagicMock()
|
||||
base["task"].session.flush = AsyncMock()
|
||||
# pr_pass's verified-stamp (ReviewFindingsRepository.list_for_task) reads
|
||||
# via session.execute — an empty scalars result (no findings).
|
||||
base["task"].session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||
)
|
||||
)
|
||||
return Choreographer(ChoreographerDeps(**base))
|
||||
|
||||
|
||||
|
||||
@@ -256,7 +256,11 @@ def test_i_am_done_sends_task_id_and_notes(flow_module: types.ModuleType) -> Non
|
||||
assert result == {"status": "awaiting_qa"}
|
||||
args, kwargs = fake_client.post.call_args
|
||||
assert "/api/v1/flow/developer/i_am_done" in args[0]
|
||||
assert kwargs["json"] == {"task_id": "task-abc", "notes": "all tests green"}
|
||||
assert kwargs["json"] == {
|
||||
"task_id": "task-abc",
|
||||
"notes": "all tests green",
|
||||
"resolved_findings": [],
|
||||
}
|
||||
|
||||
|
||||
def test_i_am_done_notes_defaults_to_empty(flow_module: types.ModuleType) -> None:
|
||||
@@ -365,7 +369,11 @@ def test_fail_review_passes_issues_list(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
|
||||
args, kwargs = fake_client.post.call_args
|
||||
assert "/api/v1/flow/qa/fail" in args[0]
|
||||
assert kwargs["json"] == {"task_id": "task-uuid", "issues": issues}
|
||||
assert kwargs["json"] == {
|
||||
"task_id": "task-uuid",
|
||||
"issues": issues,
|
||||
"findings": [],
|
||||
}
|
||||
|
||||
|
||||
def test_claim_doc_task_posts_to_documenter_path(
|
||||
|
||||
@@ -243,36 +243,43 @@ def _task(**over: Any) -> dict[str, Any]:
|
||||
("auditor", "AUDIT"),
|
||||
],
|
||||
)
|
||||
def test_get_prompt_for_agent_routes_by_role(agent_slug: str, marker: str) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_for_agent_routes_by_role(
|
||||
agent_slug: str, marker: str
|
||||
) -> None:
|
||||
orch = _orch()
|
||||
prompt = orch._get_prompt_for_agent(agent_slug, _task())
|
||||
prompt = await orch._get_prompt_for_agent(agent_slug, _task())
|
||||
assert marker in prompt
|
||||
|
||||
|
||||
def test_get_prompt_for_pm_is_not_the_dev_prompt() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_for_pm_is_not_the_dev_prompt() -> None:
|
||||
# Regression for #19: a respawned PM must NOT receive the developer prompt.
|
||||
orch = _orch()
|
||||
pm_prompt = orch._get_prompt_for_agent("be-pm", _task())
|
||||
pm_prompt = await orch._get_prompt_for_agent("be-pm", _task())
|
||||
assert "development task" not in pm_prompt
|
||||
assert "You do NOT code" in pm_prompt
|
||||
|
||||
|
||||
def test_get_prompt_for_board_is_not_the_dev_prompt() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_for_board_is_not_the_dev_prompt() -> None:
|
||||
orch = _orch()
|
||||
board_prompt = orch._get_prompt_for_agent("product-owner", _task())
|
||||
board_prompt = await orch._get_prompt_for_agent("product-owner", _task())
|
||||
assert "development task" not in board_prompt
|
||||
assert "do NOT build, code" in board_prompt
|
||||
|
||||
|
||||
def test_head_marketing_prompt_is_marketing_on_marketing_team() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_marketing_prompt_is_marketing_on_marketing_team() -> None:
|
||||
orch = _orch()
|
||||
prompt = orch._get_prompt_for_agent("head-marketing", _task(team="marketing"))
|
||||
prompt = await orch._get_prompt_for_agent("head-marketing", _task(team="marketing"))
|
||||
assert "marketing task" in prompt
|
||||
|
||||
|
||||
def test_head_marketing_prompt_is_board_off_marketing_team() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_marketing_prompt_is_board_off_marketing_team() -> None:
|
||||
orch = _orch()
|
||||
prompt = orch._get_prompt_for_agent("head-marketing", _task(team="backend"))
|
||||
prompt = await orch._get_prompt_for_agent("head-marketing", _task(team="backend"))
|
||||
assert "You are on the Board" in prompt
|
||||
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ async def test_spawn_pending_dev_proceeds_when_lane_clear(
|
||||
)
|
||||
monkeypatch.setattr(orch, "_validate_task_for_spawn", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(orch, "spawn_agent", spawn)
|
||||
monkeypatch.setattr(orch, "_get_prompt_for_agent", MagicMock(return_value="prompt"))
|
||||
monkeypatch.setattr(orch, "_get_prompt_for_agent", AsyncMock(return_value="prompt"))
|
||||
monkeypatch.setattr(orch, "_task_git_context", MagicMock(return_value={}))
|
||||
|
||||
await orch._spawn_pending_dev(cast("Any", MagicMock()), task, "be-dev-1")
|
||||
|
||||
@@ -30,7 +30,7 @@ def _orch(
|
||||
)
|
||||
object.__setattr__(orch, "_resolve_agent_slug", MagicMock(return_value=slug))
|
||||
object.__setattr__(orch, "_is_agent_active", MagicMock(return_value=active))
|
||||
object.__setattr__(orch, "_get_prompt_for_agent", MagicMock(return_value="p"))
|
||||
object.__setattr__(orch, "_get_prompt_for_agent", AsyncMock(return_value="p"))
|
||||
object.__setattr__(orch, "_task_git_context", MagicMock(return_value=None))
|
||||
object.__setattr__(orch, "spawn_agent", spawn)
|
||||
return orch, spawn
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Dispatch prompts render the revision-findings ledger inline.
|
||||
|
||||
The REVISION_REQUIRED block (developer respawn) and the PM triage prompts'
|
||||
bounced-block (cell_pm / main_pm respawn onto a needs_revision root) both
|
||||
render the task's open ledger findings — id, file:line, expected -> actual
|
||||
-> fix — instead of pointing at ``qa_notes`` / ``pm_notes`` fields that
|
||||
``evidence()`` never populated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import (
|
||||
_PROMPT_FINDINGS_CAP,
|
||||
AgentOrchestrator,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def _orch() -> AgentOrchestrator:
|
||||
orch = object.__new__(AgentOrchestrator)
|
||||
orch._instances = {}
|
||||
return orch
|
||||
|
||||
|
||||
class _Row:
|
||||
"""A bare stand-in for a ``TaskReviewFindingTable`` row."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
file: str | None = "roboco/services/task.py",
|
||||
line: int | None = 42,
|
||||
expected: str = "raises ValueError",
|
||||
actual: str = "swallows the error",
|
||||
fix: str | None = "add the raise",
|
||||
) -> None:
|
||||
self.id = uuid4()
|
||||
self.file = file
|
||||
self.line = line
|
||||
self.expected = expected
|
||||
self.actual = actual
|
||||
self.fix = fix
|
||||
|
||||
|
||||
def _patch_findings_repo(rows: list[_Row]) -> tuple[Any, Any]:
|
||||
@asynccontextmanager
|
||||
async def _fake_ctx() -> AsyncIterator[AsyncMock]:
|
||||
yield AsyncMock()
|
||||
|
||||
repo = AsyncMock()
|
||||
repo.list_for_task = AsyncMock(return_value=rows)
|
||||
return (
|
||||
patch("roboco.db.base.get_db_context", _fake_ctx),
|
||||
patch(
|
||||
"roboco.services.repositories.review_findings.ReviewFindingsRepository",
|
||||
return_value=repo,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _open_findings_prompt_block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_renders_file_line_expected_actual_fix() -> None:
|
||||
orch = _orch()
|
||||
row = _Row()
|
||||
db_ctx, repo_ctx = _patch_findings_repo([row])
|
||||
with db_ctx, repo_ctx:
|
||||
block = await orch._open_findings_prompt_block(str(uuid4()))
|
||||
|
||||
assert "roboco/services/task.py:42" in block
|
||||
assert "raises ValueError" in block
|
||||
assert "swallows the error" in block
|
||||
assert "add the raise" in block
|
||||
assert f"F-{str(row.id)[:8]}" in block
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_ledger_returns_empty_string() -> None:
|
||||
orch = _orch()
|
||||
db_ctx, repo_ctx = _patch_findings_repo([])
|
||||
with db_ctx, repo_ctx:
|
||||
assert await orch._open_findings_prompt_block(str(uuid4())) == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_task_id_returns_empty_string_without_db() -> None:
|
||||
orch = _orch()
|
||||
with patch("roboco.db.base.get_db_context") as ctx:
|
||||
assert await orch._open_findings_prompt_block("") == ""
|
||||
ctx.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caps_at_ten_with_overflow_line() -> None:
|
||||
orch = _orch()
|
||||
rows = [_Row(actual=f"issue {i}") for i in range(_PROMPT_FINDINGS_CAP + 3)]
|
||||
db_ctx, repo_ctx = _patch_findings_repo(rows)
|
||||
with db_ctx, repo_ctx:
|
||||
block = await orch._open_findings_prompt_block(str(uuid4()))
|
||||
|
||||
lines = block.splitlines()
|
||||
assert len(lines) == _PROMPT_FINDINGS_CAP + 1 # 10 findings + overflow line
|
||||
assert "+3 more via evidence()" in lines[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_error_fails_open_to_empty_string() -> None:
|
||||
orch = _orch()
|
||||
with patch("roboco.db.base.get_db_context", side_effect=RuntimeError("db down")):
|
||||
assert await orch._open_findings_prompt_block(str(uuid4())) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_dev_prompt — REVISION_REQUIRED renders the block inline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _task(**over: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": str(uuid4()),
|
||||
"title": "Fix the parser",
|
||||
"status": "needs_revision",
|
||||
"plan": "did the thing",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revision_required_prompt_embeds_seeded_finding() -> None:
|
||||
orch = _orch()
|
||||
task = _task()
|
||||
row = _Row(file="api/routes/foo.py", line=17, actual="missing null guard")
|
||||
db_ctx, repo_ctx = _patch_findings_repo([row])
|
||||
with db_ctx, repo_ctx:
|
||||
prompt = await orch._build_dev_prompt(task)
|
||||
|
||||
assert "api/routes/foo.py:17" in prompt
|
||||
assert "missing null guard" in prompt
|
||||
assert "qa_notes" not in prompt
|
||||
assert "pm_notes" not in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_revision_prompt_never_touches_db() -> None:
|
||||
"""EXECUTING (in_progress) must not pay for a findings-ledger fetch."""
|
||||
orch = _orch()
|
||||
task = _task(status="in_progress")
|
||||
with patch("roboco.db.base.get_db_context") as ctx:
|
||||
prompt = await orch._build_dev_prompt(task)
|
||||
ctx.assert_not_called()
|
||||
assert "IN PROGRESS" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revision_required_with_no_findings_still_renders() -> None:
|
||||
orch = _orch()
|
||||
task = _task()
|
||||
db_ctx, repo_ctx = _patch_findings_repo([])
|
||||
with db_ctx, repo_ctx:
|
||||
prompt = await orch._build_dev_prompt(task)
|
||||
|
||||
assert "REVISION REQUESTED" in prompt
|
||||
assert "no findings on the ledger" in prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PM triage prompts — the bounced-block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pm_triage_prompt_prepends_bounced_block_when_given() -> None:
|
||||
orch = _orch()
|
||||
prompt = orch._build_pm_triage_prompt(
|
||||
_task(team="backend"), bounced_block="[F-abcd1234] api.py:9 — x -> y"
|
||||
)
|
||||
assert prompt.startswith("## THIS TASK BOUNCED")
|
||||
assert "[F-abcd1234] api.py:9" in prompt
|
||||
assert "You are the PM for backend team" in prompt
|
||||
|
||||
|
||||
def test_pm_triage_prompt_omits_block_when_empty() -> None:
|
||||
orch = _orch()
|
||||
prompt = orch._build_pm_triage_prompt(_task(team="backend"), bounced_block="")
|
||||
assert "THIS TASK BOUNCED" not in prompt
|
||||
assert prompt.startswith("You are the PM for backend team")
|
||||
|
||||
|
||||
def test_main_pm_triage_prompt_prepends_bounced_block_when_given() -> None:
|
||||
orch = _orch()
|
||||
prompt = orch._build_main_pm_triage_prompt(
|
||||
_task(), bounced_block="[F-abcd1234] api.py:9 — x -> y"
|
||||
)
|
||||
assert prompt.startswith("## THIS ROOT BOUNCED")
|
||||
assert "[F-abcd1234] api.py:9" in prompt
|
||||
assert "You are the MAIN PM at RoboCo" in prompt
|
||||
|
||||
|
||||
def test_main_pm_triage_prompt_omits_block_when_empty() -> None:
|
||||
orch = _orch()
|
||||
prompt = orch._build_main_pm_triage_prompt(_task(), bounced_block="")
|
||||
assert "THIS ROOT BOUNCED" not in prompt
|
||||
assert prompt.startswith("You are the MAIN PM at RoboCo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_prompt_for_agent — threads the bounced-block into cell_pm/main_pm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_for_agent_fetches_bounced_block_for_needs_revision_pm(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
orch = _orch()
|
||||
monkeypatch.setattr(
|
||||
orch, "_revision_bounced_block", AsyncMock(return_value="[F-11112222] x")
|
||||
)
|
||||
prompt = await orch._get_prompt_for_agent("main-pm", _task(status="needs_revision"))
|
||||
assert "[F-11112222] x" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revision_bounced_block_skips_fetch_when_not_needs_revision() -> None:
|
||||
orch = _orch()
|
||||
with patch("roboco.db.base.get_db_context") as ctx:
|
||||
block = await orch._revision_bounced_block(_task(status="in_progress"))
|
||||
assert block == ""
|
||||
ctx.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Real-DB tests for the consumer-side findings helpers in
|
||||
``choreographer/findings.py`` (``open_findings_for_task``,
|
||||
``full_ledger_for_task``, ``stamp_addressed_verified``) — the fetch/stamp
|
||||
layer every evidence/handoff/claim surface and the pass_review/pr_pass
|
||||
verified-stamp thread through.
|
||||
|
||||
Follows ``test_review_findings_repository.py``'s pattern: real Postgres via
|
||||
the session-scoped test DB (local: ROBOCO_TEST_DB_PORT=55432
|
||||
ROBOCO_TEST_DB_USER=renzof).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.foundation.policy.content import Finding, Severity
|
||||
from roboco.models.base import AgentRole, AgentStatus, TaskStatus, TaskType, Team
|
||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||
from roboco.services.repositories.review_findings import (
|
||||
STATUS_ADDRESSED,
|
||||
STATUS_OPEN,
|
||||
STATUS_VERIFIED,
|
||||
STATUS_WAIVED,
|
||||
ReviewFindingsRepository,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_EXPECTED_TWO = 2
|
||||
|
||||
|
||||
async def _seed_agent(session: AsyncSession) -> UUID:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Consumer Helper Test Agent",
|
||||
slug=f"consumer-helper-test-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="consumer helper test",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
session.add(agent)
|
||||
await session.flush()
|
||||
return UUID(str(agent.id))
|
||||
|
||||
|
||||
async def _seed_task(session: AsyncSession, created_by: UUID) -> UUID:
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="consumer helper seed task",
|
||||
description="seed",
|
||||
acceptance_criteria=["seeded"],
|
||||
status=TaskStatus.NEEDS_REVISION,
|
||||
priority=2,
|
||||
task_type=TaskType.CODE,
|
||||
team=Team.BACKEND,
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
return UUID(str(task.id))
|
||||
|
||||
|
||||
def _finding(**overrides: object) -> Finding:
|
||||
base: dict[str, object] = {
|
||||
"file": "roboco/services/task.py",
|
||||
"line": 10,
|
||||
"severity": Severity.MAJOR,
|
||||
"expected": "raises on bad input",
|
||||
"actual": "swallows the error",
|
||||
}
|
||||
base.update(overrides)
|
||||
return Finding.model_validate(base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_findings_for_task / full_ledger_for_task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_findings_for_task_excludes_addressed(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(), _finding(actual="second")],
|
||||
)
|
||||
await repo.mark_addressed(task_id, str(rows[0].id), commit="abc", note="fixed")
|
||||
|
||||
open_rows = await findings_lib.open_findings_for_task(db_session, task_id)
|
||||
assert len(open_rows) == 1
|
||||
assert open_rows[0].id == rows[1].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_findings_for_task_caps(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
findings = [_finding(actual=f"issue {i}") for i in range(3)]
|
||||
await repo.insert_many(
|
||||
task_id=task_id, origin="qa", round=1, author_slug="be-qa", findings=findings
|
||||
)
|
||||
|
||||
cap = _EXPECTED_TWO
|
||||
rows = await findings_lib.open_findings_for_task(db_session, task_id, limit=cap)
|
||||
assert len(rows) == cap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_ledger_for_task_includes_every_status(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(), _finding(actual="second")],
|
||||
)
|
||||
await repo.mark_addressed(task_id, str(rows[0].id), commit="abc", note="fixed")
|
||||
|
||||
full = await findings_lib.full_ledger_for_task(db_session, task_id)
|
||||
assert len(full) == _EXPECTED_TWO
|
||||
statuses = {r.status for r in full}
|
||||
assert statuses == {STATUS_ADDRESSED, STATUS_OPEN}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_and_full_ledger_empty_for_unknown_task(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
assert await findings_lib.open_findings_for_task(db_session, uuid4()) == []
|
||||
assert await findings_lib.full_ledger_for_task(db_session, uuid4()) == []
|
||||
|
||||
|
||||
class _BoomSession:
|
||||
"""A session stand-in whose ``execute`` always raises — simulates a
|
||||
ledger-read failure so the fetch helpers' fail-open posture is provable
|
||||
without a real outage."""
|
||||
|
||||
async def execute(self, *_args: object, **_kwargs: object) -> None:
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_findings_for_task_fails_open_on_db_error() -> None:
|
||||
assert await findings_lib.open_findings_for_task(_BoomSession(), uuid4()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_ledger_for_task_fails_open_on_db_error() -> None:
|
||||
assert await findings_lib.full_ledger_for_task(_BoomSession(), uuid4()) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stamp_addressed_verified — origin-scoped, status-scoped verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_verifies_only_addressed_rows_of_the_given_origin(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
|
||||
qa_rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(actual="qa addressed"), _finding(actual="qa still open")],
|
||||
)
|
||||
gate_rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="pr_gate",
|
||||
round=1,
|
||||
author_slug="be-pr-reviewer",
|
||||
findings=[_finding(actual="gate addressed")],
|
||||
)
|
||||
# Mark one qa finding + the one pr_gate finding addressed; leave the
|
||||
# second qa finding open.
|
||||
await repo.mark_addressed(task_id, str(qa_rows[0].id), commit="c1", note="fixed")
|
||||
await repo.mark_addressed(task_id, str(gate_rows[0].id), commit="c2", note="fixed")
|
||||
|
||||
count = await findings_lib.stamp_addressed_verified(
|
||||
db_session, task_id, origin="qa"
|
||||
)
|
||||
|
||||
assert count == 1
|
||||
all_rows = await repo.list_for_task(task_id)
|
||||
by_id = {r.id: r for r in all_rows}
|
||||
# The addressed qa finding is now verified.
|
||||
assert by_id[qa_rows[0].id].status == STATUS_VERIFIED
|
||||
# The still-open qa finding is untouched.
|
||||
assert by_id[qa_rows[1].id].status == STATUS_OPEN
|
||||
# The addressed pr_gate finding is untouched — different origin.
|
||||
assert by_id[gate_rows[0].id].status == STATUS_ADDRESSED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_does_not_touch_waived_rows(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
await repo.mark_waived(UUID(str(rows[0].id)), "not a real defect")
|
||||
|
||||
count = await findings_lib.stamp_addressed_verified(
|
||||
db_session, task_id, origin="qa"
|
||||
)
|
||||
|
||||
assert count == 0
|
||||
waived = await repo.list_for_task(task_id, status=STATUS_WAIVED)
|
||||
assert len(waived) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_is_a_noop_when_nothing_addressed(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
|
||||
count = await findings_lib.stamp_addressed_verified(
|
||||
db_session, task_id, origin="qa"
|
||||
)
|
||||
|
||||
assert count == 0
|
||||
open_rows = await repo.list_for_task(task_id, status=STATUS_OPEN)
|
||||
assert len(open_rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_propagates_on_repo_error() -> None:
|
||||
"""Not best-effort — a repo error must propagate so the caller (pass_review /
|
||||
pr_pass) fails the whole verb cleanly instead of silently landing a
|
||||
passed/gated task against a stale ledger."""
|
||||
with pytest.raises(RuntimeError):
|
||||
await findings_lib.stamp_addressed_verified(
|
||||
_BoomSession(), uuid4(), origin="qa"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Real-DB tests for ReviewFindingsRepository — the revision-findings ledger.
|
||||
|
||||
Follows the ``test_audit_real_query.py`` / ``test_vault_task_queries_real.py``
|
||||
pattern: real Postgres via the session-scoped test DB (local:
|
||||
ROBOCO_TEST_DB_PORT=55432 ROBOCO_TEST_DB_USER=renzof). ``Base.metadata.create_all``
|
||||
builds the schema from live ORM metadata, so ``TaskReviewFindingTable`` needs no
|
||||
migration replay here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.foundation.policy.content import Finding, Severity
|
||||
from roboco.models.base import AgentRole, AgentStatus, TaskStatus, TaskType, Team
|
||||
from roboco.services.repositories.review_findings import (
|
||||
STATUS_ADDRESSED,
|
||||
STATUS_OPEN,
|
||||
STATUS_VERIFIED,
|
||||
STATUS_WAIVED,
|
||||
ReviewFindingsRepository,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_EXPECTED_TWO = 2
|
||||
|
||||
|
||||
async def _seed_agent(session: AsyncSession) -> UUID:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Ledger Test Agent",
|
||||
slug=f"ledger-test-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="ledger test",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
session.add(agent)
|
||||
await session.flush()
|
||||
return UUID(str(agent.id))
|
||||
|
||||
|
||||
async def _seed_task(session: AsyncSession, created_by: UUID) -> UUID:
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="ledger seed task",
|
||||
description="seed",
|
||||
acceptance_criteria=["seeded"],
|
||||
status=TaskStatus.NEEDS_REVISION,
|
||||
priority=2,
|
||||
task_type=TaskType.CODE,
|
||||
team=Team.BACKEND,
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
return UUID(str(task.id))
|
||||
|
||||
|
||||
def _finding(**overrides: object) -> Finding:
|
||||
base: dict[str, object] = {
|
||||
"file": "roboco/services/task.py",
|
||||
"line": 10,
|
||||
"severity": Severity.MAJOR,
|
||||
"expected": "raises on bad input",
|
||||
"actual": "swallows the error",
|
||||
}
|
||||
base.update(overrides)
|
||||
return Finding.model_validate(base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_many_persists_one_row_per_finding(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(), _finding(criterion="AC1")],
|
||||
)
|
||||
|
||||
assert len(rows) == _EXPECTED_TWO
|
||||
assert all(r.id is not None for r in rows)
|
||||
assert all(r.status == STATUS_OPEN for r in rows)
|
||||
assert all(r.round == 1 for r in rows)
|
||||
assert all(r.origin == "qa" for r in rows)
|
||||
assert rows[1].criterion == "AC1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_for_task_orders_newest_round_first(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
|
||||
await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="pr_gate",
|
||||
round=2,
|
||||
author_slug="be-pr-reviewer",
|
||||
findings=[_finding(actual="round 2 issue")],
|
||||
)
|
||||
|
||||
rows = await repo.list_for_task(task_id)
|
||||
assert [r.round for r in rows] == [2, 1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_for_task_filters_by_status(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
await repo.mark_addressed(task_id, str(rows[0].id), commit="abc123", note="fixed")
|
||||
|
||||
open_rows = await repo.list_for_task(task_id, status=STATUS_OPEN)
|
||||
addressed_rows = await repo.list_for_task(task_id, status=STATUS_ADDRESSED)
|
||||
assert open_rows == []
|
||||
assert len(addressed_rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_for_task_scoped_to_one_task(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_a = await _seed_task(db_session, agent_id)
|
||||
task_b = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
await repo.insert_many(
|
||||
task_id=task_a, origin="qa", round=1, author_slug="be-qa", findings=[_finding()]
|
||||
)
|
||||
|
||||
assert len(await repo.list_for_task(task_a)) == 1
|
||||
assert await repo.list_for_task(task_b) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_addressed_by_full_id(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
|
||||
updated = await repo.mark_addressed(
|
||||
task_id, str(rows[0].id), commit="deadbeef", note="fixed the guard"
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.status == STATUS_ADDRESSED
|
||||
assert updated.addressed_by_commit == "deadbeef"
|
||||
assert updated.resolution_note == "fixed the guard"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_addressed_by_8_char_prefix(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
prefix = str(rows[0].id)[:8]
|
||||
|
||||
updated = await repo.mark_addressed(task_id, prefix, commit=None, note=None)
|
||||
assert updated is not None
|
||||
assert updated.id == rows[0].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_addressed_unknown_ref_is_a_noop(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
|
||||
result = await repo.mark_addressed(task_id, "ffffffff", commit=None, note=None)
|
||||
assert result is None
|
||||
assert len(await repo.list_for_task(task_id, status=STATUS_OPEN)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_addressed_wrong_task_is_a_noop(db_session: AsyncSession) -> None:
|
||||
"""A finding_ref that exists but belongs to a DIFFERENT task must not match —
|
||||
an agent can never address another task's finding."""
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_a = await _seed_task(db_session, agent_id)
|
||||
task_b = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_a, origin="qa", round=1, author_slug="be-qa", findings=[_finding()]
|
||||
)
|
||||
|
||||
result = await repo.mark_addressed(task_b, str(rows[0].id), commit=None, note=None)
|
||||
assert result is None
|
||||
assert len(await repo.list_for_task(task_a, status=STATUS_OPEN)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_verified_bulk_by_id(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(), _finding(actual="second")],
|
||||
)
|
||||
ids = [UUID(str(r.id)) for r in rows]
|
||||
|
||||
count = await repo.mark_verified(ids)
|
||||
assert count == _EXPECTED_TWO
|
||||
verified = await repo.list_for_task(task_id, status=STATUS_VERIFIED)
|
||||
assert len(verified) == _EXPECTED_TWO
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_verified_empty_list_is_a_noop(db_session: AsyncSession) -> None:
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
assert await repo.mark_verified([]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_waived_requires_note(db_session: AsyncSession) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding()],
|
||||
)
|
||||
|
||||
ok = await repo.mark_waived(UUID(str(rows[0].id)), "not a real defect")
|
||||
assert ok is True
|
||||
waived = await repo.list_for_task(task_id, status=STATUS_WAIVED)
|
||||
assert len(waived) == 1
|
||||
assert waived[0].resolution_note == "not a real defect"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_waived_unknown_id_returns_false(db_session: AsyncSession) -> None:
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
assert await repo.mark_waived(uuid4(), "note") is False
|
||||
@@ -34,6 +34,7 @@ from roboco.services.task import (
|
||||
VIDEO_SOURCE,
|
||||
GatewayAgentView,
|
||||
TaskService,
|
||||
_ceo_reject_finding_texts,
|
||||
get_task_service,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
@@ -538,7 +539,12 @@ async def test_qa_pass_delegates_to_pass_qa() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qa_fail_appends_issues_to_dev_notes() -> None:
|
||||
async def test_qa_fail_does_not_touch_dev_notes() -> None:
|
||||
"""qa_fail must NOT raw-append issues onto dev_notes (the data-loss bug the
|
||||
revision-findings ledger fix retires) — the choreographer's fail_review verb
|
||||
already persisted the concrete findings structurally (the ledger + the
|
||||
QaNote) before this call. The next developer handoff note must be free to
|
||||
fully overwrite dev_notes without destroying anything qa_fail wrote."""
|
||||
qa_id = uuid4()
|
||||
task = _build_task(dev_notes=None, claimed_by=qa_id)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
@@ -547,9 +553,7 @@ async def test_qa_fail_appends_issues_to_dev_notes() -> None:
|
||||
_bind(svc, "fail_qa", fail_qa_mock)
|
||||
issues = ["missing test", "no docstring"]
|
||||
await svc.qa_fail(qa_id, task.id, "blocking", issues)
|
||||
assert task.dev_notes is not None
|
||||
assert "missing test" in task.dev_notes
|
||||
assert "no docstring" in task.dev_notes
|
||||
assert task.dev_notes is None
|
||||
fail_qa_mock.assert_awaited_once_with(task.id, notes="blocking", agent_role="qa")
|
||||
|
||||
|
||||
@@ -896,9 +900,12 @@ async def test_admin_set_status_non_blocked_source_keeps_claim() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_routes_leaf_back_to_original_dev() -> None:
|
||||
"""PM merge-review reject: awaiting_pm_review -> needs_revision, issues
|
||||
appended for the dev, task re-owned by the original developer (the QA-fail
|
||||
routing), stale claimant cleared."""
|
||||
"""PM merge-review reject: awaiting_pm_review -> needs_revision, task
|
||||
re-owned by the original developer (the QA-fail routing), stale claimant
|
||||
cleared. Issues no longer raw-append onto dev_notes (the data-loss bug the
|
||||
revision-findings ledger fix retires — the choreographer's request_changes
|
||||
verb persists them structurally, into pm_notes + the ledger, before this
|
||||
call) so dev_notes stays exactly as it was."""
|
||||
dev = uuid4()
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
@@ -919,8 +926,7 @@ async def test_request_changes_routes_leaf_back_to_original_dev() -> None:
|
||||
assert task.assigned_to == dev
|
||||
assert task.claimed_by == dev
|
||||
assert task.active_claimant_id is None
|
||||
assert "[PM REVIEW ISSUES]" in (task.dev_notes or "")
|
||||
assert "frontend/CLAUDE.md modified out of scope" in (task.dev_notes or "")
|
||||
assert task.dev_notes is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2134,3 +2140,42 @@ async def test_list_completed_video_tasks_bounded_to_scan_limit(
|
||||
assert not missing_new, (
|
||||
f"{len(missing_new)} newest unrendered tasks dropped by the bound"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ceo_reject_finding_texts — caller-side truncation for the ceo_reject finding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CEO_ACTUAL_CAP = 300
|
||||
_CEO_EVIDENCE_CAP = 2000
|
||||
|
||||
|
||||
def test_ceo_reject_finding_texts_short_reason_untruncated() -> None:
|
||||
actual, evidence = _ceo_reject_finding_texts("redo the auth flow")
|
||||
assert actual == "redo the auth flow"
|
||||
assert evidence is None
|
||||
|
||||
|
||||
def test_ceo_reject_finding_texts_truncates_over_actual_cap() -> None:
|
||||
reason = "x" * (_CEO_ACTUAL_CAP + 50)
|
||||
actual, evidence = _ceo_reject_finding_texts(reason)
|
||||
assert len(actual) <= _CEO_ACTUAL_CAP
|
||||
assert actual.endswith("]")
|
||||
assert "chars omitted" in actual
|
||||
# The untruncated reason survives in evidence (well under its own cap).
|
||||
assert evidence == reason
|
||||
|
||||
|
||||
def test_ceo_reject_finding_texts_caps_evidence_too() -> None:
|
||||
reason = "y" * (_CEO_EVIDENCE_CAP + 500)
|
||||
actual, evidence = _ceo_reject_finding_texts(reason)
|
||||
assert len(actual) <= _CEO_ACTUAL_CAP
|
||||
assert evidence is not None
|
||||
assert len(evidence) <= _CEO_EVIDENCE_CAP
|
||||
assert "chars omitted" in evidence
|
||||
|
||||
|
||||
def test_ceo_reject_finding_texts_strips_whitespace() -> None:
|
||||
actual, evidence = _ceo_reject_finding_texts(" redo it ")
|
||||
assert actual == "redo it"
|
||||
assert evidence is None
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""TaskService._audit_events_for — the rejector-attributed audit event selection.
|
||||
|
||||
A transition always emits the generic ``task.<status>``; a reviewer bounce to
|
||||
needs_revision additionally emits ``task.qa_fail`` / ``task.pr_fail`` keyed on
|
||||
the acting role, so the per-agent rework scorecard can attribute the rejection.
|
||||
needs_revision additionally emits ``task.qa_fail`` / ``task.pr_fail`` /
|
||||
``task.request_changes`` / ``task.ceo_reject`` keyed on the acting role, so the
|
||||
per-agent rework scorecard can attribute the rejection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,10 +31,24 @@ def test_pr_fail_adds_named_event() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_ceo_reject_to_needs_revision_has_no_named_event() -> None:
|
||||
# A CEO rejection is a needs_revision bounce but not a QA/PR-review fail.
|
||||
def test_request_changes_adds_named_event_for_cell_pm() -> None:
|
||||
assert TaskService._audit_events_for("needs_revision", "cell_pm") == [
|
||||
"task.needs_revision",
|
||||
"task.request_changes",
|
||||
]
|
||||
|
||||
|
||||
def test_request_changes_adds_named_event_for_main_pm() -> None:
|
||||
assert TaskService._audit_events_for("needs_revision", "main_pm") == [
|
||||
"task.needs_revision",
|
||||
"task.request_changes",
|
||||
]
|
||||
|
||||
|
||||
def test_ceo_reject_to_needs_revision_adds_named_event() -> None:
|
||||
assert TaskService._audit_events_for("needs_revision", "ceo") == [
|
||||
"task.needs_revision"
|
||||
"task.needs_revision",
|
||||
"task.ceo_reject",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""assemble_task_note_data threads the revision-findings ledger.
|
||||
|
||||
Fetched via ``task_service.session`` rather than a new threaded parameter —
|
||||
see ``roboco/services/vault_assembly.py`` ``_resolve_findings`` for why (every
|
||||
real caller already carries a real session; only some unit-test stubs don't,
|
||||
and those must degrade to empty rather than crash).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.vault_assembly import assemble_task_note_data
|
||||
from roboco.services.vault_writer import VaultWriter
|
||||
|
||||
|
||||
def _task(**overrides: Any) -> SimpleNamespace:
|
||||
base: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"title": "t",
|
||||
"description": "d",
|
||||
"status": "in_progress",
|
||||
"team": "backend",
|
||||
"priority": 2,
|
||||
"task_type": "code",
|
||||
"acceptance_criteria": [],
|
||||
"pr_number": None,
|
||||
"pr_url": None,
|
||||
"project_id": None,
|
||||
"parent_task_id": None,
|
||||
"dependency_ids": [],
|
||||
"batch_id": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _finding_row(**overrides: Any) -> SimpleNamespace:
|
||||
base: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"severity": "major",
|
||||
"file": "roboco/services/task.py",
|
||||
"line": 42,
|
||||
"expected": "x",
|
||||
"actual": "y",
|
||||
"fix": "do z",
|
||||
"status": "open",
|
||||
"round": 1,
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assemble_threads_findings_via_task_service_session() -> None:
|
||||
task_service = MagicMock()
|
||||
task_service.session = MagicMock()
|
||||
task_service.get_subtasks = AsyncMock(return_value=[])
|
||||
project_service = MagicMock()
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_for_task = AsyncMock(return_value=[_finding_row()])
|
||||
with patch(
|
||||
"roboco.services.vault_assembly.ReviewFindingsRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
data = await assemble_task_note_data(task_service, project_service, _task())
|
||||
|
||||
assert len(data.findings) == 1
|
||||
row = data.findings[0]
|
||||
assert (row.severity, row.file, row.fix, row.status, row.round) == (
|
||||
"major",
|
||||
"roboco/services/task.py",
|
||||
"do z",
|
||||
"open",
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assemble_findings_empty_when_task_service_has_no_session() -> None:
|
||||
"""A duck-typed stub without ``.session`` (e.g. the vault-janitor unit
|
||||
tests' ``_TaskSvcStub``) yields no findings rather than crashing."""
|
||||
task_service = SimpleNamespace(get_subtasks=AsyncMock(return_value=[]))
|
||||
project_service = MagicMock()
|
||||
|
||||
data = await assemble_task_note_data(task_service, project_service, _task())
|
||||
assert data.findings == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assemble_fails_open_when_findings_fetch_raises(tmp_path: Any) -> None:
|
||||
"""A raising repository degrades to an empty findings tuple — the note is
|
||||
still assembled and materializes. The best-effort vault seams swallow
|
||||
exceptions, so a raise here would silently kill write_task entirely."""
|
||||
task_service = MagicMock()
|
||||
task_service.session = MagicMock()
|
||||
task_service.get_subtasks = AsyncMock(return_value=[])
|
||||
project_service = MagicMock()
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_for_task = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
with patch(
|
||||
"roboco.services.vault_assembly.ReviewFindingsRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
data = await assemble_task_note_data(task_service, project_service, _task())
|
||||
|
||||
assert data.findings == ()
|
||||
note = VaultWriter(tmp_path).write_task(data)
|
||||
assert note.exists()
|
||||
assert "## Findings" not in note.read_text(encoding="utf-8")
|
||||
@@ -14,6 +14,7 @@ from roboco.services.vault_writer import (
|
||||
A2AMessageData,
|
||||
AgentNoteData,
|
||||
BottleneckRow,
|
||||
FindingRow,
|
||||
JournalNoteData,
|
||||
OrgReportData,
|
||||
StageTimingRow,
|
||||
@@ -362,3 +363,60 @@ def test_write_org_report_same_week_overwrites(tmp_path: Path) -> None:
|
||||
p2 = writer.write_org_report(_report_data())
|
||||
assert p1 == p2
|
||||
assert len(list((tmp_path / "RoboCo" / "Reports").glob("*.md"))) == 1
|
||||
|
||||
|
||||
# --- revision-findings ledger section -------------------------------------- #
|
||||
|
||||
_FINDINGS_CAP = 20
|
||||
|
||||
|
||||
def test_write_task_omits_findings_section_when_empty(tmp_path: Path) -> None:
|
||||
writer = VaultWriter(tmp_path)
|
||||
text = writer.write_task(_task_data()).read_text(encoding="utf-8")
|
||||
assert "## Findings" not in text
|
||||
|
||||
|
||||
def test_write_task_renders_findings_section(tmp_path: Path) -> None:
|
||||
writer = VaultWriter(tmp_path)
|
||||
finding = FindingRow(
|
||||
id8="aaaaaaaa",
|
||||
severity="blocker",
|
||||
file="roboco/services/task.py",
|
||||
line=42,
|
||||
expected="the endpoint returns 404",
|
||||
actual="the endpoint returns 500",
|
||||
fix="add a not-found guard",
|
||||
status="open",
|
||||
round=2,
|
||||
)
|
||||
text = writer.write_task(_task_data(findings=(finding,))).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "## Findings" in text
|
||||
assert "[F-aaaaaaaa] (blocker, round 2, open)" in text
|
||||
assert "roboco/services/task.py:42" in text
|
||||
assert (
|
||||
"the endpoint returns 404 → the endpoint returns 500 → "
|
||||
"add a not-found guard" in text
|
||||
)
|
||||
|
||||
|
||||
def test_write_task_findings_capped_with_overflow_line(tmp_path: Path) -> None:
|
||||
writer = VaultWriter(tmp_path)
|
||||
many = tuple(
|
||||
FindingRow(
|
||||
id8=f"{i:08d}",
|
||||
severity="minor",
|
||||
file=None,
|
||||
line=None,
|
||||
expected="x",
|
||||
actual="y",
|
||||
fix=None,
|
||||
status="open",
|
||||
round=1,
|
||||
)
|
||||
for i in range(25)
|
||||
)
|
||||
text = writer.write_task(_task_data(findings=many)).read_text(encoding="utf-8")
|
||||
assert text.count("[F-") == _FINDINGS_CAP
|
||||
assert "5 more" in text
|
||||
|
||||
Reference in New Issue
Block a user