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>
396 lines
16 KiB
Python
396 lines
16 KiB
Python
"""Gate Set E: submit-qa field-level gates in Choreographer.i_am_done.
|
|
|
|
Pre-gateway location: roboco/api/routes/tasks.py:903-940 (route layer).
|
|
The four field-level gates returned 400 errors when the dev tried to
|
|
submit for QA without:
|
|
|
|
- NOT_SELF_VERIFIED: task.self_verified must be true.
|
|
- NO_COMMITS: task.commits must be non-empty.
|
|
- NO_PR: task.pr_number must be set.
|
|
- NO_PROGRESS: task.progress_updates must have at least one entry.
|
|
|
|
The gateway's i_am_done previously called _run_catch_up which silently
|
|
auto-ran the full chain. That hid the missing-commits failure mode
|
|
(catch-up tried to push nothing, opened an empty PR, etc.).
|
|
|
|
Now i_am_done is strict and tells the dev exactly which prerequisite
|
|
is missing. A separate i_am_done_with_catchup verb retains the smart-
|
|
catch-up behavior for the explicit-opt-in case.
|
|
"""
|
|
|
|
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)
|
|
# VerbRunner uses task.session.begin_nested() as a savepoint context
|
|
# manager. Keep `session` itself an AsyncMock so other awaited methods
|
|
# (e.g. flush) still work, and override begin_nested with a sync
|
|
# MagicMock that returns the async-context-manager protocol.
|
|
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 tracing AND field-level gates."""
|
|
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=["AC1"],
|
|
acceptance_criteria_status=[
|
|
{"criterion": "AC1", "referencing_artifact_id": "c1"}
|
|
],
|
|
commits=[{"sha": "abc"}],
|
|
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.",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# self_verified is no longer a gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_auto_runs_submit_verification_when_in_progress() -> None:
|
|
"""Strict i_am_done auto-runs submit_verification (in_progress→verifying)
|
|
so the dev doesn't need a separate verb. The previous NOT_SELF_VERIFIED
|
|
gate required submit_for_verification which wasn't on any manifest.
|
|
|
|
The pre-flight tracing gate filters SELF_VERIFIED (it is set by the
|
|
auto-run submit_verification action and re-asserted by the spec's
|
|
own preconditions), so an unverified in_progress task can still
|
|
enter i_am_done.
|
|
"""
|
|
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_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
|
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.submit_verification.assert_awaited_once()
|
|
task_svc.submit_qa.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E.2 NO_COMMITS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_blocks_when_no_commits() -> None:
|
|
"""Spec's PRECONDITION_COMMITS rejects with the canonical
|
|
`commits>=1` missing token before any state mutation."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.commits = []
|
|
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 = True
|
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
|
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
|
|
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"] == "tracing_gap"
|
|
# Spec emits "commits>=1" via PRECONDITION_COMMITS.
|
|
assert "commits>=1" in body["missing"] or "NO_COMMITS" in body["missing"]
|
|
task_svc.submit_qa.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E.3 NO_PR
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_blocks_when_no_pr() -> None:
|
|
"""Defense-in-depth field gate fires NO_PR after the spec gate accepts.
|
|
|
|
The spec doesn't yet model PR-existence; the field-gate helper still
|
|
enforces it post-spec.
|
|
"""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.pr_number = None
|
|
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 = True
|
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
|
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
|
|
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"] == "tracing_gap"
|
|
# foundation.policy.tracing emits "pr_open" via PR_OPEN; the legacy
|
|
# _check_submit_qa_field_gates path emitted "NO_PR" but tracing now
|
|
# short-circuits before that field gate runs.
|
|
assert (
|
|
"pr_open" in body["missing"]
|
|
or "NO_PR" in body["missing"]
|
|
or "pr_number" in body["missing"]
|
|
)
|
|
task_svc.submit_qa.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E.4 NO_PROGRESS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_blocks_when_no_progress() -> None:
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.progress_updates = []
|
|
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 = True
|
|
# JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
|
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
|
|
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"] == "tracing_gap"
|
|
# progress>=1 is the existing tracing_gate Requirement key.
|
|
assert "progress>=1" in body["missing"] or "NO_PROGRESS" in body["missing"]
|
|
task_svc.submit_qa.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Role note-section obligation: dev_notes must be filled (note(scope='handoff'))
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_blocks_when_dev_notes_empty() -> None:
|
|
"""i_am_done obligates the developer's dev_notes section; an empty
|
|
dev_notes (the dev never called note(scope='handoff')) fails the gate."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
t.dev_notes = ""
|
|
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 = 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
|
|
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"] == "tracing_gap"
|
|
assert "dev_notes>=min" in body["missing"]
|
|
assert "scope='handoff'" in body["remediate"]
|
|
task_svc.submit_qa.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E.5 happy path: all gates pass → submit_qa runs (NO catch-up)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_proceeds_when_all_gates_pass() -> None:
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, agent_id)
|
|
# Pre-verifying state (caller already ran submit_for_verification or
|
|
# task is already in `verifying`). i_am_done skips the auto-verify
|
|
# step and goes straight to submit_qa.
|
|
t.status = "verifying"
|
|
t.self_verified = True
|
|
after_submit = MagicMock(
|
|
**{**t.__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_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_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle.
|
|
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, "all done")
|
|
body = env.as_dict()
|
|
assert body["error"] is None
|
|
assert body["status"] == "awaiting_qa"
|
|
task_svc.submit_qa.assert_awaited_once()
|
|
# Already-verifying status: recovery path runs only submit_qa, never
|
|
# the composed submit_verification action.
|
|
task_svc.submit_verification.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Removed: i_am_done_with_catchup verb deleted.
|
|
# Its functionality is now split between submit_for_qa (push + PR) and
|
|
# i_am_done (auto-run submit_verification then submit_qa).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_i_am_done_blocks_unauthorized() -> None:
|
|
"""A caller that does not own the task gets a clear not_authorized that
|
|
steers to give_me_work — not the owns_task tracing_gap it would retry.
|
|
|
|
A reassignment short-circuit runs before the spec gate, so a stale /
|
|
superseded agent is told plainly the task is no longer its own (instead
|
|
of reading PRECONDITION_OWNERSHIP's tracing_gap as a fixable precondition
|
|
and looping i_am_done — the observed owns_task burn-loop).
|
|
"""
|
|
agent_id = uuid4()
|
|
other_id = uuid4()
|
|
task_id = uuid4()
|
|
t = _ready_task(task_id, other_id)
|
|
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
|
|
)
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_am_done(agent_id, task_id, "done")
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized"
|
|
assert "no longer assigned" in (body.get("message") or "").lower()
|
|
assert "give_me_work" in (body.get("remediate") or "")
|