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:
Renzo F
2026-07-11 22:54:42 +02:00
committed by GitHub
co-authored by Renn F
parent d03181ab48
commit cea3e56628
103 changed files with 7283 additions and 399 deletions
+24 -4
View File
@@ -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)
+19 -1
View File
@@ -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()
+203
View File
@@ -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))
+10 -2
View File
@@ -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
+54 -9
View File
@@ -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
+20 -5
View File
@@ -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")
+58
View File
@@ -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