mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(notifications): release re-escalation row locks per row; stop swallowing DB errors in notify_get The re-escalation sweep ran one tick-wide transaction, so each CAS claim's row lock was held across every remaining delivery until the single commit — a concurrent mark-read UPDATE on a claimed row starved into the 60s lock_timeout. The sweep now commits per row (claim commit releases the lock before delivery and makes the burned slot durable), re-fetches each row by snapshotted id so one row's rollback can't expire the rest of the tick, and savepoints each recipient's delivery. notify_get's bare except swallowed the resulting LockNotAvailableError into a false "notification not found" and returned a poisoned session to the commit-at-send middleware, which blew up with PendingRollbackError; it now catches only the two domain outcomes. defer_after_commit's listeners fire on SAVEPOINT release too, which would have drained deferred telegram/bus work before real durability — they now skip savepoint boundaries via get_nested_transaction() (the root get_transaction() is non-None inside the listener even at a real commit). acknowledge_for_recipient's Redis dedup-clear moved before the flush so the row lock never spans a Redis round-trip. The five best-effort CEO-notify swallows that persist notification rows are savepointed. * fix(services): contain swallowed best-effort DB write failures instead of poisoning the session Sweep of the same class as the notify_get incident: broad except-Exception handlers that swallow a failure whose try-body writes through the shared session leave the session rollback-pending, and the verb/request then dies later with PendingRollbackError at commit-at-send. Confirmed-dangerous sites now run the write inside a savepoint (safe since defer_after_commit skips savepoint boundaries): ceo_approve's verified-stamp, completion/pitch/postmortem-style CEO notifies, _inherit_upstream_base, _link_commit_to_task (covers every commit route), board-program LEARN records, the QA/PR-gate/PM-merge verified-stamps, and the documenter->PM handoff. _ack_pending_wake_notifications gets the same treatment so a wake-ack failure can't fail the A2A read. telegram_inbound's per-update loop and intake confirm roll back explicitly instead (their success paths commit mid-flow, so a savepoint doesn't fit). A swallowed savepoint rollback fully expires any ORM object mutated inside the block, and the next attribute read raises MissingGreenlet — strictly worse than the original bug. The two paths that keep using the object after the swallow (doc handoff's envelope build, base inheritance's claim continuation) refresh it in the except path; regression tests run against a real session and were verified to fail with the refresh reverted. * test: shape mocked session.execute results so sync accessors stop leaking unawaited coroutines An AsyncMock's auto-created children are themselves AsyncMock, so production code that correctly awaits session.execute() and then calls sync accessors (.scalars().all(), .scalar_one_or_none()) on the result was silently collecting unawaited coroutines in 22 test files — 80 RuntimeWarnings per unit run, and in test_flow_soup_guard one mock raised a real TypeError that a coincidentally-matching invalid_state envelope masked. Each affected fixture now returns a plain MagicMock shaped like a real Result. Zero AsyncMock warnings remain. * docs: document per-row sweep commits and the savepoint/refresh containment pattern --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""Wave C5 (2026-05-12): i_am_done writes per-criterion status.
|
|
|
|
Smoke run 3 showed evidence(task_id).acceptance_criteria_status always
|
|
[]. The i_am_done gate already validates each criterion against the
|
|
dev's journal:reflect; extending it to also persist the per-criterion
|
|
mapping so the panel can render checkmarks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
|
|
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
|
base: dict[str, Any] = {
|
|
"task": AsyncMock(),
|
|
"work_session": AsyncMock(),
|
|
"git": AsyncMock(),
|
|
"a2a": AsyncMock(),
|
|
"journal": AsyncMock(),
|
|
"audit": AsyncMock(),
|
|
"evidence_repo": AsyncMock(),
|
|
}
|
|
base.update(overrides)
|
|
task = base["task"]
|
|
task.session.begin_nested = MagicMock(
|
|
return_value=MagicMock(
|
|
__aenter__=AsyncMock(return_value=None),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
)
|
|
# Findings-ledger reads (ReviewFindingsRepository.list_for_task) go
|
|
# through session.execute — an unconfigured AsyncMock's awaited result
|
|
# is itself an AsyncMock, so a plain sync `.scalars()` call on it leaks
|
|
# an unawaited coroutine. 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",
|
|
"list_unread_mentions",
|
|
"list_pending_notifications",
|
|
"task_metadata_gaps",
|
|
"recent_team_activity",
|
|
"blockers_in_lane",
|
|
"journal_highlights_for_task",
|
|
):
|
|
getattr(repo, method).return_value = []
|
|
# C8: default-fresh journal:decision so PM-decision gate passes.
|
|
# Tests that exercise the gate boundary stub their own value.
|
|
# The check matches MagicMock and AsyncMock (the two default sentinel
|
|
# types pytest's unittest.mock leaves on un-stubbed return_values).
|
|
_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 _ready_task(task_id: Any, agent_id: Any) -> MagicMock:
|
|
"""Build a task that satisfies all tracing AND field-level gates
|
|
but has an empty acceptance_criteria_status so C5 must write it."""
|
|
return MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=agent_id,
|
|
plan={"x": 1},
|
|
branch_name="feature/backend/abc--def",
|
|
work_session_id=uuid4(),
|
|
self_verified=True,
|
|
pr_number=8,
|
|
pr_url="https://x/pr/8",
|
|
team="backend",
|
|
progress_updates=[{"message": "did x"}],
|
|
acceptance_criteria=["Must do X", "Must do Y", "Must do Z"],
|
|
acceptance_criteria_status=[],
|
|
commits=[{"sha": "deadbeef"}],
|
|
documents=[],
|
|
# i_am_done obligates the developer's dev_notes section (>=40 chars).
|
|
dev_notes="Implemented the change and added tests covering the new path.",
|
|
quick_context=None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C5.1 — successful i_am_done writes one entry per criterion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_writes_criteria_status_on_success() -> None:
|
|
"""A successful i_am_done writes acceptance_criteria_status with one
|
|
entry per criterion. The reflect note is the addressing artifact so
|
|
all three criteria get addressed=True with artifact_ref=first-commit-sha.
|
|
"""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.self_verified = False
|
|
t.status = "in_progress"
|
|
|
|
after_verify = MagicMock(
|
|
**{**t.__dict__, "self_verified": True, "status": "verifying"}
|
|
)
|
|
after_submit = MagicMock(**{**after_verify.__dict__, "status": "awaiting_qa"})
|
|
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
id=agent_id, role="developer", team="backend", slug=None
|
|
)
|
|
task_svc.submit_verification.return_value = after_verify
|
|
task_svc.submit_qa.return_value = after_submit
|
|
task_svc.qa_agent_for_team.return_value = MagicMock(
|
|
id=uuid4(), skills=[{"id": "code_review"}]
|
|
)
|
|
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_learning_for_task.return_value = False
|
|
journal_svc.has_struggle_for_task.return_value = False
|
|
|
|
work_svc = AsyncMock()
|
|
work_svc.files_changed.return_value = ["foo.py"]
|
|
|
|
deps = _make_deps(task=task_svc, journal=journal_svc, work_session=work_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_am_done(agent_id, task_id, "done")
|
|
body = env.as_dict()
|
|
|
|
# i_am_done must succeed.
|
|
assert body["error"] is None
|
|
|
|
# set_acceptance_criteria_status must have been called once with
|
|
# one dict per criterion in the same order as acceptance_criteria.
|
|
task_svc.set_acceptance_criteria_status.assert_awaited_once()
|
|
written_args = task_svc.set_acceptance_criteria_status.call_args
|
|
|
|
# First positional arg is task_id, second is the list.
|
|
written_task_id, written_status = written_args.args
|
|
assert written_task_id == task_id
|
|
|
|
expected_criteria = {"Must do X", "Must do Y", "Must do Z"}
|
|
assert len(written_status) == len(expected_criteria)
|
|
criteria_texts = {e["criterion"] for e in written_status}
|
|
assert criteria_texts == expected_criteria
|
|
|
|
for entry in written_status:
|
|
assert entry["addressed"] is True
|
|
# The reflect note is present and a commit exists → artifact_ref is the sha.
|
|
assert entry["artifact_ref"] == "deadbeef"
|
|
assert "checked_at" in entry
|
|
# Entries must use the new shape (not the old referencing_artifact_id shape).
|
|
assert "criterion" in entry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C5.2 — no criteria → set_acceptance_criteria_status not called
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_with_no_criteria_does_not_write_status() -> None:
|
|
"""Task with acceptance_criteria=[] must not call
|
|
set_acceptance_criteria_status — writing an empty list is a no-op
|
|
that wastes a DB round-trip.
|
|
"""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.acceptance_criteria = []
|
|
t.acceptance_criteria_status = []
|
|
t.self_verified = False
|
|
t.status = "in_progress"
|
|
|
|
after_verify = MagicMock(
|
|
**{**t.__dict__, "self_verified": True, "status": "verifying"}
|
|
)
|
|
after_submit = MagicMock(**{**after_verify.__dict__, "status": "awaiting_qa"})
|
|
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
id=agent_id, role="developer", team="backend", slug=None
|
|
)
|
|
task_svc.submit_verification.return_value = after_verify
|
|
task_svc.submit_qa.return_value = after_submit
|
|
task_svc.qa_agent_for_team.return_value = MagicMock(
|
|
id=uuid4(), skills=[{"id": "code_review"}]
|
|
)
|
|
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_learning_for_task.return_value = False
|
|
journal_svc.has_struggle_for_task.return_value = False
|
|
|
|
work_svc = AsyncMock()
|
|
work_svc.files_changed.return_value = ["foo.py"]
|
|
|
|
deps = _make_deps(task=task_svc, journal=journal_svc, work_session=work_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_am_done(agent_id, task_id, "done")
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
task_svc.set_acceptance_criteria_status.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C5.3 — all criteria already addressed → set_acceptance_criteria_status not called
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_skips_write_when_all_criteria_already_addressed() -> None:
|
|
"""When every criterion in acceptance_criteria already has a non-empty
|
|
referencing_artifact_id in acceptance_criteria_status, the writer must
|
|
return early without calling set_acceptance_criteria_status.
|
|
"""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.acceptance_criteria = ["Must do X", "Must do Y"]
|
|
t.acceptance_criteria_status = [
|
|
{"criterion": "Must do X", "referencing_artifact_id": "sha1"},
|
|
{"criterion": "Must do Y", "referencing_artifact_id": "sha2"},
|
|
]
|
|
t.self_verified = False
|
|
t.status = "in_progress"
|
|
|
|
after_verify = MagicMock(
|
|
**{**t.__dict__, "self_verified": True, "status": "verifying"}
|
|
)
|
|
after_submit = MagicMock(**{**after_verify.__dict__, "status": "awaiting_qa"})
|
|
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
id=agent_id, role="developer", team="backend", slug=None
|
|
)
|
|
task_svc.submit_verification.return_value = after_verify
|
|
task_svc.submit_qa.return_value = after_submit
|
|
task_svc.qa_agent_for_team.return_value = MagicMock(
|
|
id=uuid4(), skills=[{"id": "code_review"}]
|
|
)
|
|
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_learning_for_task.return_value = False
|
|
journal_svc.has_struggle_for_task.return_value = False
|
|
|
|
work_svc = AsyncMock()
|
|
work_svc.files_changed.return_value = ["foo.py"]
|
|
|
|
deps = _make_deps(task=task_svc, journal=journal_svc, work_session=work_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_am_done(agent_id, task_id, "done")
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
task_svc.set_acceptance_criteria_status.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C5.4 — gate rejection skips write
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_gate_rejection_skips_criteria_write() -> None:
|
|
"""When the gate rejects (e.g. no reflect note), the criteria status
|
|
write must NOT happen — the write only runs after a full gate pass.
|
|
"""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
# Remove reflect — tracing gate will reject.
|
|
t.acceptance_criteria = ["Must do X"]
|
|
t.acceptance_criteria_status = []
|
|
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
id=agent_id, role="developer", team="backend", slug=None
|
|
)
|
|
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_reflect_for_task.return_value = False
|
|
journal_svc.has_decision_for_task.return_value = False
|
|
journal_svc.latest_decision_at.return_value = None
|
|
journal_svc.has_learning_for_task.return_value = False
|
|
journal_svc.has_struggle_for_task.return_value = False
|
|
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_am_done(agent_id, task_id, "done")
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is not None
|
|
task_svc.set_acceptance_criteria_status.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C5.5 — fallback to "reflect-note" when commit sha is absent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_uses_reflect_note_artifact_when_commit_sha_is_none() -> None:
|
|
"""When commits are present but carry no sha, the artifact_ref falls
|
|
back to 'reflect-note' (the reflect note is what the gate accepted).
|
|
commits=[] would be caught by COMMITS_AT_LEAST_ONE gate before the
|
|
writer runs; this tests the edge case where sha is None inside a commit.
|
|
"""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.acceptance_criteria = ["Must do X"]
|
|
t.acceptance_criteria_status = []
|
|
# commit present but sha is None → sha extraction yields None.
|
|
t.commits = [{"sha": None}]
|
|
t.self_verified = False
|
|
t.status = "in_progress"
|
|
|
|
after_verify = MagicMock(
|
|
**{**t.__dict__, "self_verified": True, "status": "verifying"}
|
|
)
|
|
after_submit = MagicMock(**{**after_verify.__dict__, "status": "awaiting_qa"})
|
|
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(
|
|
id=agent_id, role="developer", team="backend", slug=None
|
|
)
|
|
task_svc.submit_verification.return_value = after_verify
|
|
task_svc.submit_qa.return_value = after_submit
|
|
task_svc.qa_agent_for_team.return_value = MagicMock(
|
|
id=uuid4(), skills=[{"id": "code_review"}]
|
|
)
|
|
|
|
journal_svc = AsyncMock()
|
|
journal_svc.has_reflect_for_task.return_value = True
|
|
journal_svc.has_decision_for_task.return_value = True
|
|
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
|
journal_svc.has_learning_for_task.return_value = False
|
|
journal_svc.has_struggle_for_task.return_value = False
|
|
|
|
work_svc = AsyncMock()
|
|
work_svc.files_changed.return_value = ["foo.py"]
|
|
|
|
deps = _make_deps(task=task_svc, journal=journal_svc, work_session=work_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_am_done(agent_id, task_id, "done")
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
task_svc.set_acceptance_criteria_status.assert_awaited_once()
|
|
_, written_status = task_svc.set_acceptance_criteria_status.call_args.args
|
|
assert len(written_status) == 1
|
|
assert written_status[0]["addressed"] is True
|
|
assert written_status[0]["artifact_ref"] == "reflect-note"
|