Files
roboco/tests/unit/gateway/test_gate_review_diff_base.py
T
93739a9dca fix(notifications): stop tick-wide row locks + poisoned-session swallows behind the PendingRollbackError 500s (#743)
* 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>
2026-07-30 16:35:42 +02:00

241 lines
9.1 KiB
Python

"""In-path PR-review gate: the assembled diff must use the REAL parent branch.
``_build_gate_review_evidence`` (claim_gate_review) and ``_pr_pass_blocked``
(pr_pass's conventions guard) used to call ``git.diff`` / the conventions
check with no base, which derives the parent via the same-team string
surgery ``parent_branch_for`` — wrong for every cross-team cell→root hop
(the cell task's own team segment can't derive the ``main_pm`` root's
branch). Both now resolve ``preferred_parent`` via
``merge_chain.resolve_parent_branch`` (reads the parent TASK's own
``branch_name``) and thread it through, falling back exactly like the
pre-fix derivation for a root / branchless-parent / parentless task.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_choreographer(*, task_service: AsyncMock, git: AsyncMock) -> Choreographer:
# `_project_slug_for`/`ReviewFindingsRepository.list_for_task` both read
# via session.execute — an unconfigured AsyncMock's awaited result is
# itself an AsyncMock, so a plain sync `.scalars()`/`.scalar_one_or_none()`
# call on it leaks an unawaited coroutine. A bare MagicMock's `.scalars()
# .all()` already returns `[]` by default; these tests don't assert on
# the resolved project/slug, so a default (truthy) `.scalar_one_or_none()`
# is harmless too.
task_service.session.execute = AsyncMock(return_value=MagicMock())
return Choreographer(
ChoreographerDeps(
task=task_service,
work_session=AsyncMock(),
git=git,
a2a=AsyncMock(),
journal=AsyncMock(),
audit=AsyncMock(),
evidence_repo=AsyncMock(),
)
)
def _gate_task(*, branch_name: str, parent_task_id: Any) -> Any:
return MagicMock(
branch_name=branch_name,
parent_task_id=parent_task_id,
pr_number=139,
pr_url="https://example/pr/139",
acceptance_criteria=[],
)
class TestGateDiffParent:
"""``_gate_diff_parent`` mirrors ``resolve_parent_branch``'s three cases."""
@pytest.mark.asyncio
async def test_cross_team_child_uses_parent_task_branch(self) -> None:
parent_id = uuid4()
t = _gate_task(
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
parent_task_id=parent_id,
)
task_service = AsyncMock()
task_service.get.return_value = MagicMock(
branch_name="feature/main_pm/f7d0a61a--e56e6543"
)
c = _make_choreographer(task_service=task_service, git=AsyncMock())
parent = await c._gate_diff_parent(t)
assert parent == "feature/main_pm/f7d0a61a--e56e6543"
task_service.get.assert_awaited_once_with(parent_id)
@pytest.mark.asyncio
async def test_root_subtask_with_branchless_umbrella_uses_project_default(
self,
) -> None:
parent_id = uuid4()
t = _gate_task(
branch_name="feature/main_pm/f7d0a61a--e56e6543", parent_task_id=parent_id
)
task_service = AsyncMock()
task_service.get.return_value = MagicMock(branch_name=None)
task_service.project_default_branch_for_task = AsyncMock(return_value="master")
c = _make_choreographer(task_service=task_service, git=AsyncMock())
parent = await c._gate_diff_parent(t)
assert parent == "master"
@pytest.mark.asyncio
async def test_parentless_root_falls_back_to_string_derivation(self) -> None:
t = _gate_task(branch_name="feature/main_pm/f7d0a61a", parent_task_id=None)
task_service = AsyncMock()
task_service.project_default_branch_for_task = AsyncMock(return_value=None)
c = _make_choreographer(task_service=task_service, git=AsyncMock())
parent = await c._gate_diff_parent(t)
assert parent == "master"
task_service.get.assert_not_called()
@pytest.mark.asyncio
async def test_branchless_task_returns_none(self) -> None:
t = _gate_task(branch_name="", parent_task_id=uuid4())
task_service = AsyncMock()
c = _make_choreographer(task_service=task_service, git=AsyncMock())
assert await c._gate_diff_parent(t) is None
task_service.get.assert_not_called()
@pytest.mark.asyncio
async def test_fails_open_on_parent_lookup_error(self) -> None:
t = _gate_task(
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
parent_task_id=uuid4(),
)
task_service = AsyncMock()
task_service.get.side_effect = RuntimeError("db connection reset")
c = _make_choreographer(task_service=task_service, git=AsyncMock())
assert await c._gate_diff_parent(t) is None
class TestBuildGateReviewEvidence:
@pytest.mark.asyncio
async def test_diff_called_with_resolved_cross_team_parent(self) -> None:
parent_id = uuid4()
t = _gate_task(
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
parent_task_id=parent_id,
)
task_service = AsyncMock()
task_service.get.return_value = MagicMock(
branch_name="feature/main_pm/f7d0a61a--e56e6543"
)
git = AsyncMock()
git.diff.return_value = "diff body"
c = _make_choreographer(task_service=task_service, git=git)
evidence = await c._build_gate_review_evidence(t)
git.diff.assert_awaited_once_with(
branch_name=t.branch_name,
preferred_parent="feature/main_pm/f7d0a61a--e56e6543",
)
assert evidence["pr_diff"] == "diff body"
@pytest.mark.asyncio
async def test_diff_skipped_for_branchless_task(self) -> None:
t = _gate_task(branch_name="", parent_task_id=None)
git = AsyncMock()
c = _make_choreographer(task_service=AsyncMock(), git=git)
evidence = await c._build_gate_review_evidence(t)
git.diff.assert_not_awaited()
assert evidence["pr_diff"] == ""
@pytest.mark.asyncio
async def test_diff_falls_back_when_parent_lookup_fails(self) -> None:
t = _gate_task(
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
parent_task_id=uuid4(),
)
task_service = AsyncMock()
task_service.get.side_effect = RuntimeError("db connection reset")
git = AsyncMock()
git.diff.return_value = "diff body"
c = _make_choreographer(task_service=task_service, git=git)
evidence = await c._build_gate_review_evidence(t)
git.diff.assert_awaited_once_with(
branch_name=t.branch_name, preferred_parent=None
)
assert evidence["pr_diff"] == "diff body"
class TestPrPassBlockedThreadsParent:
"""``_pr_pass_blocked`` resolves the parent ONCE and hands it to the
conventions guard, so a reviewer's block-level finding is never raised
against inherited base-branch content on a cross-team assembled PR."""
@pytest.mark.asyncio
async def test_conventions_guard_receives_resolved_parent(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
parent_id = uuid4()
t = _gate_task(
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
parent_task_id=parent_id,
)
task_service = AsyncMock()
task_service.get.return_value = MagicMock(
branch_name="feature/main_pm/f7d0a61a--e56e6543"
)
c = _make_choreographer(task_service=task_service, git=AsyncMock())
cc: Any = c
cc._toolchain_broken_guard = AsyncMock(return_value=None)
cc._conventions_guard = AsyncMock(return_value=None)
reviewer_id = uuid4()
rejection, _ci_note = await c._pr_pass_blocked(
reviewer_id, uuid4(), t, "pr_reviewer", {}
)
assert rejection is None
cc._conventions_guard.assert_awaited_once_with(
reviewer_id,
t,
{},
preferred_parent="feature/main_pm/f7d0a61a--e56e6543",
)
@pytest.mark.asyncio
async def test_parent_lookup_skipped_when_conventions_off(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
t = _gate_task(
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
parent_task_id=uuid4(),
)
task_service = AsyncMock()
c = _make_choreographer(task_service=task_service, git=AsyncMock())
cc: Any = c
cc._toolchain_broken_guard = AsyncMock(return_value=None)
cc._conventions_guard = AsyncMock(return_value=None)
rejection, _ci_note = await c._pr_pass_blocked(
uuid4(), uuid4(), t, "pr_reviewer", {}
)
assert rejection is None
task_service.get.assert_not_called()
cc._conventions_guard.assert_awaited_once()
assert cc._conventions_guard.await_args.kwargs.get("preferred_parent") is None