mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
180 lines
6.8 KiB
Python
180 lines
6.8 KiB
Python
"""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)
|