Files
roboco/tests/unit/runtime/test_per_dev_lane_queue.py
T
cea3e56628 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>
2026-07-11 22:54:42 +02:00

225 lines
7.8 KiB
Python

"""Per-dev sequenced queues: a dev works its own code queue one task at a time.
A PM delegates a full per-dev queue of `code` subtasks up front. The dispatch
barrier holds a dev's higher-sequence code leaf until its own lower-sequence
code siblings under the same parent are terminal, so the dev works its queue in
order — while the OTHER dev's lane runs concurrently (two-dev parallelism).
Keyed on the assignee (not the team like the merge barrier) and gates only
`code`. Loop-free (not dispatched, not rejected); best-effort on lookup failure.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.runtime.orchestrator import AgentOrchestrator
def _new_orchestrator() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
return orch
def _sibling(
seq: int,
owner: str,
status: TaskStatus,
*,
task_type: str = "code",
) -> MagicMock:
return MagicMock(
id=uuid4(),
sequence=seq,
assigned_to=owner,
status=status,
task_type=task_type,
)
def _patch_siblings(siblings: list[MagicMock]) -> Any:
"""Patch the orchestrator's direct-DB sibling lookup to return ``siblings``."""
svc = MagicMock()
svc.get_subtasks = AsyncMock(return_value=siblings)
class _CM:
async def __aenter__(self) -> MagicMock:
return MagicMock()
async def __aexit__(self, *_a: Any) -> bool:
return False
factory = MagicMock(return_value=_CM())
return (
patch("roboco.db.base.get_session_factory", return_value=factory),
patch("roboco.services.task.get_task_service", return_value=svc),
)
def _task(seq: int, owner: str, *, task_type: str = "code") -> dict[str, Any]:
return {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": seq,
"assigned_to": owner,
"task_type": task_type,
}
@pytest.mark.asyncio
async def test_blocks_when_same_dev_has_earlier_active_code_sibling() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [_sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is True
@pytest.mark.asyncio
async def test_not_blocked_when_earlier_same_dev_sibling_terminal() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [
_sibling(0, "be-dev-1", TaskStatus.COMPLETED),
_sibling(0, "be-dev-1", TaskStatus.CANCELLED),
]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_other_devs_earlier_sibling_does_not_block() -> None:
"""The whole point of two-dev parallelism: be-dev-2's in-flight wave-0 leaf
must NOT hold be-dev-1's own wave-0 leaf. Lanes are independent."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
siblings = [_sibling(0, "be-dev-2", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_earlier_non_code_sibling_does_not_block() -> None:
"""Only the code queue is gated this way; a planning/doc sibling is irrelevant."""
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [_sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS, task_type="planning")]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_higher_sequence_same_dev_sibling_does_not_block() -> None:
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
siblings = [_sibling(1, "be-dev-1", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_equal_sequence_same_dev_tiebreaks_by_created_at() -> None:
"""Wave ties in a dev's own lane order by created_at (mirroring the merge
barrier): the earlier-created tied sibling holds the later one; the
later-created one does not hold the earlier."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
task["created_at"] = "2026-07-10T12:00:00+00:00"
earlier = _sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)
earlier.created_at = datetime(2026, 7, 10, 11, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([earlier])
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is True
later = _sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)
later.created_at = datetime(2026, 7, 10, 13, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([later])
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_non_code_task_is_never_gated_without_db() -> None:
orch = _new_orchestrator()
# A planning/doc task short-circuits before any lookup.
task = _task(1, "fe-pm", task_type="planning")
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_no_parent_or_owner_short_circuits_without_db() -> None:
orch = _new_orchestrator()
assert (
await orch._blocked_by_earlier_lane_sibling(
{"id": str(uuid4()), "sequence": 0, "task_type": "code"}
)
is False
)
@pytest.mark.asyncio
async def test_db_failure_falls_through_to_dispatch() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
boom = patch(
"roboco.db.base.get_session_factory", side_effect=RuntimeError("db down")
)
with boom:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_spawn_pending_dev_holds_gated_lane_before_validating(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_spawn_pending_dev must short-circuit a gated lane before validating or
spawning — the dev's earlier queue item is still live."""
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
spawn = AsyncMock()
validate = AsyncMock()
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
monkeypatch.setattr(
orch, "_blocked_by_earlier_lane_sibling", AsyncMock(return_value=True)
)
monkeypatch.setattr(orch, "_validate_task_for_spawn", validate)
monkeypatch.setattr(orch, "spawn_agent", spawn)
await orch._spawn_pending_dev(cast("Any", MagicMock()), task, "be-dev-1")
spawn.assert_not_awaited()
validate.assert_not_awaited()
@pytest.mark.asyncio
async def test_spawn_pending_dev_proceeds_when_lane_clear(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the lane is clear (no earlier sibling), the dev is spawned normally."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
spawn = AsyncMock()
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
monkeypatch.setattr(
orch, "_blocked_by_earlier_lane_sibling", AsyncMock(return_value=False)
)
monkeypatch.setattr(orch, "_validate_task_for_spawn", AsyncMock(return_value=None))
monkeypatch.setattr(orch, "spawn_agent", spawn)
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")
spawn.assert_awaited_once()