Files
roboco/tests/unit/gateway/test_open_pr_milestone_lock.py
T

180 lines
6.8 KiB
Python
Raw Normal View History

2026-06-29 05:38:21 +02:00
"""open_pr's idempotent re-entry guard reads ``t.pr_number`` from an unlocked
fetch, so two CONCURRENT (respawn-race) retries both pass the guard and both
emit the 70% "opened PR #N" milestone progress entry — double-counting one
PR-open event in the Progress tab + cycle-time metrics (no PR duplication;
GitHub's 422 'already exists' guard holds).
The fix: a PostgreSQL transaction-scoped advisory lock keyed by the task id
(seed ``2``, disjoint from the per-agent claim lock seed ``0`` and the
per-parent delegate lock seed ``1``) acquired at the top of ``open_pr``
BEFORE the ``t = await self.task.get(...)`` fetch and held through
``_record_milestone_progress`` + the outer request commit. The second
same-task concurrent ``open_pr`` blocks until the first commits; its fetch
then sees the committed ``pr_number``, the idempotent guard fires, and it
short-circuits without re-emitting. Per-TASK (not per-agent): the
single-active-task guard means a dev has one task at a time, so concurrent
``open_pr`` on the SAME task is purely the respawn-race case — no legitimate
concurrency is regressed.
"""
from __future__ import annotations
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(task: AsyncMock, git: AsyncMock | None = None) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": task,
"work_session": AsyncMock(),
"git": git or AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
repo = base["evidence_repo"]
for m in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
):
getattr(repo, m).return_value = []
return ChoreographerDeps(**base)
def _wire_savepoint(task_svc: AsyncMock) -> None:
"""Stub task_svc.session.begin_nested() as an async context manager."""
task_svc.session = MagicMock()
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
def _task_with_pr(*, pr_number: int | None) -> MagicMock:
return MagicMock(
id=uuid4(),
status="in_progress",
assigned_to=None, # set per-test
plan="x",
commits=[{"sha": "abc"}],
pr_number=pr_number,
parent_task_id=None,
branch_name="feature/backend/abc12345",
)
@pytest.mark.asyncio
async def test_open_pr_acquires_task_lock_before_fetch() -> None:
"""The per-task advisory lock MUST be acquired before the first
``task.get`` fetch (the read the idempotent re-entry guard consults for
``t.pr_number``). This is the ordering that closes the TOCTOU: the second
concurrent same-task open_pr blocks on the lock before its fetch, so once
the first commits its ``pr_number`` the second's fetch sees it and the
idempotent guard short-circuits without re-emitting the milestone. A lock
acquired AFTER the fetch would leave the second call's ``t`` stale
(fetched before the first committed, ``pr_number=None``) — the guard would
not fire and the milestone would re-emit, so 'lock before runner' alone is
insufficient; the lock must precede the fetch."""
aid = uuid4()
tid = uuid4()
t = _task_with_pr(pr_number=None)
t.assigned_to = aid
t.id = tid
t_after = _task_with_pr(pr_number=42)
t_after.assigned_to = aid
t_after.id = tid
t_after.pr_url = "https://gh/x/42"
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
_wire_savepoint(task_svc)
git_svc = AsyncMock()
git_svc.push_branch.return_value = ("feature/backend/abc12345", 1)
git_svc.create_pr.return_value = {
"pr_number": 42,
"pr_url": "https://gh/x/42",
"is_root_pr": False,
}
# Shared call-order recorder: the lock must precede the first get fetch.
# First fetch (idempotent guard read) returns pr_number=None; the
# post-runner re-fetch in _open_pr_success_envelope returns pr_number=42.
calls: list[str] = []
staged: list[Any] = [t, t_after]
async def _lock(_tid: object) -> None:
calls.append("lock")
async def _get_staged(_tid: object) -> Any:
calls.append("get")
return staged.pop(0) if staged else t_after
task_svc.acquire_task_lock = _lock
task_svc.get.side_effect = _get_staged
deps = _make_deps(task_svc, git_svc)
c = Choreographer(deps)
env = await c.open_pr(aid, tid)
assert env.error is None, env.as_dict()
# The flow reached the milestone (otherwise the lock-ordering assertion
# would pass for the wrong reason — a short-circuit before the runner).
assert calls.count("get") >= 1, calls
# The lock was acquired exactly once, before the first fetch.
assert calls.count("lock") == 1, calls
assert calls.index("lock") < calls.index("get"), (
f"task lock must be acquired before the first task.get fetch; order was {calls}"
)
@pytest.mark.asyncio
async def test_open_pr_emits_milestone_once_with_task_lock() -> None:
"""No-regression: acquiring the per-task lock must not break the normal
open_pr path — the PR is still opened (git.create_pr awaited), the
milestone is still emitted exactly ONCE (add_progress awaited once with
the "opened PR #N" message), and the lock is awaited once with the task
id. The lock is transparent to the happy path."""
aid = uuid4()
tid = uuid4()
t = _task_with_pr(pr_number=None)
t.assigned_to = aid
t.id = tid
t_after = _task_with_pr(pr_number=42)
t_after.assigned_to = aid
t_after.id = tid
t_after.pr_url = "https://gh/x/42"
task_svc = AsyncMock()
task_svc.get.side_effect = [t, t_after]
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
_wire_savepoint(task_svc)
git_svc = AsyncMock()
git_svc.push_branch.return_value = ("feature/backend/abc12345", 1)
git_svc.create_pr.return_value = {
"pr_number": 42,
"pr_url": "https://gh/x/42",
"is_root_pr": False,
}
# Leave the default AsyncMock for acquire_task_lock (transparent no-op).
deps = _make_deps(task_svc, git_svc)
c = Choreographer(deps)
env = await c.open_pr(aid, tid)
assert env.error is None, env.as_dict()
git_svc.create_pr.assert_awaited_once()
# The milestone emitted exactly once — the 70% "opened PR #42" entry.
task_svc.add_progress.assert_awaited_once()
assert "opened PR #42" in str(task_svc.add_progress.call_args)
task_svc.acquire_task_lock.assert_awaited_once_with(tid)