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
@@ -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