mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
181 lines
5.4 KiB
Python
181 lines
5.4 KiB
Python
"""i_am_done behind-base submit gate (multi-level sequencing Phase B2).
|
|||
|
|
|
||
|
|
A sibling's PR merging into the parent branch while a dev worked leaves the
|
||
|
|
dev's branch behind its base — the assembled PR then can't merge cleanly and
|
||
|
|
the sibling's changes go missing (the 2026-06-27 out-of-order dev-task break).
|
||
|
|
The behind-base gate refuses ``i_am_done`` in that state and steers the dev to
|
||
|
|
``sync_branch`` (the gate-level rebase). Fail-open on git / base-resolution
|
||
|
|
error so a flaky fetch can't strand a task at the submit gate; the merge layer
|
||
|
|
has its own behind checks. Skipped for branchless roots and protected bases.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import AsyncMock, patch
|
||
|
|
from uuid import UUID, uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||
|
|
from roboco.services.gateway.choreographer._impl import _IAmDoneContext
|
||
|
|
|
||
|
|
|
||
|
|
def _make_deps(**overrides: object) -> ChoreographerDeps:
|
||
|
|
base: dict[str, object] = {
|
||
|
|
"task": AsyncMock(),
|
||
|
|
"work_session": AsyncMock(),
|
||
|
|
"git": AsyncMock(),
|
||
|
|
"a2a": AsyncMock(),
|
||
|
|
"journal": AsyncMock(),
|
||
|
|
"audit": AsyncMock(),
|
||
|
|
"evidence_repo": AsyncMock(),
|
||
|
|
}
|
||
|
|
base.update(overrides)
|
||
|
|
repo = base["evidence_repo"]
|
||
|
|
assert isinstance(repo, AsyncMock)
|
||
|
|
for method in (
|
||
|
|
"list_unread_a2a",
|
||
|
|
"list_unread_mentions",
|
||
|
|
"list_pending_notifications",
|
||
|
|
"task_metadata_gaps",
|
||
|
|
"recent_team_activity",
|
||
|
|
"blockers_in_lane",
|
||
|
|
):
|
||
|
|
getattr(repo, method).return_value = []
|
||
|
|
return ChoreographerDeps(**base)
|
||
|
|
|
||
|
|
|
||
|
|
def _ctx(
|
||
|
|
task: object, *, agent_id: UUID | None = None, task_id: UUID | None = None
|
||
|
|
) -> _IAmDoneContext:
|
||
|
|
return _IAmDoneContext(
|
||
|
|
agent_id=agent_id or uuid4(),
|
||
|
|
task_id=task_id or uuid4(),
|
||
|
|
task=task,
|
||
|
|
role_str="developer",
|
||
|
|
briefing={},
|
||
|
|
notes="",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
_BRANCH = "feature/backend/abc12345"
|
||
|
|
_BASE = "feature/backend/parent12345"
|
||
|
|
|
||
|
|
|
||
|
|
class _Task:
|
||
|
|
def __init__(self, branch_name: str | None = _BRANCH) -> None:
|
||
|
|
self.branch_name = branch_name
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_behind_base_gate_refuses_and_steers_to_sync_branch() -> None:
|
||
|
|
t = _Task()
|
||
|
|
task_svc = AsyncMock()
|
||
|
|
git_svc = AsyncMock()
|
||
|
|
git_svc.is_behind_base.return_value = (3, 2) # 3 behind, 2 ahead
|
||
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
||
|
|
c = Choreographer(deps)
|
||
|
|
ctx = _ctx(t)
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
||
|
|
new=AsyncMock(return_value=_BASE),
|
||
|
|
):
|
||
|
|
env = await c._behind_base_gate(ctx)
|
||
|
|
|
||
|
|
git_svc.is_behind_base.assert_awaited_once()
|
||
|
|
assert env is not None
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert "sync_branch" in (env.remediate or "")
|
||
|
|
assert "behind" in (env.message or "")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_behind_base_gate_passes_when_up_to_date() -> None:
|
||
|
|
t = _Task()
|
||
|
|
task_svc = AsyncMock()
|
||
|
|
git_svc = AsyncMock()
|
||
|
|
git_svc.is_behind_base.return_value = (0, 5) # not behind
|
||
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
||
|
|
c = Choreographer(deps)
|
||
|
|
ctx = _ctx(t)
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
||
|
|
new=AsyncMock(return_value=_BASE),
|
||
|
|
):
|
||
|
|
env = await c._behind_base_gate(ctx)
|
||
|
|
|
||
|
|
assert env is None
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_behind_base_gate_skips_branchless_task() -> None:
|
||
|
|
# No branch_name → branchless coordination root; nothing to sync.
|
||
|
|
t = _Task(branch_name=None)
|
||
|
|
git_svc = AsyncMock()
|
||
|
|
deps = _make_deps(git=git_svc)
|
||
|
|
c = Choreographer(deps)
|
||
|
|
ctx = _ctx(t)
|
||
|
|
|
||
|
|
env = await c._behind_base_gate(ctx)
|
||
|
|
|
||
|
|
assert env is None
|
||
|
|
git_svc.is_behind_base.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_behind_base_gate_skips_protected_base() -> None:
|
||
|
|
# A base that resolved to master must never be rebased into — skip (the
|
||
|
|
# merge layer guards master); never block the submit on it.
|
||
|
|
t = _Task()
|
||
|
|
git_svc = AsyncMock()
|
||
|
|
deps = _make_deps(git=git_svc)
|
||
|
|
c = Choreographer(deps)
|
||
|
|
ctx = _ctx(t)
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
||
|
|
new=AsyncMock(return_value="master"),
|
||
|
|
):
|
||
|
|
env = await c._behind_base_gate(ctx)
|
||
|
|
|
||
|
|
assert env is None
|
||
|
|
git_svc.is_behind_base.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_behind_base_gate_fail_opens_on_base_resolution_error() -> None:
|
||
|
|
t = _Task()
|
||
|
|
git_svc = AsyncMock()
|
||
|
|
deps = _make_deps(git=git_svc)
|
||
|
|
c = Choreographer(deps)
|
||
|
|
ctx = _ctx(t)
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
||
|
|
new=AsyncMock(side_effect=RuntimeError("db unavailable")),
|
||
|
|
):
|
||
|
|
env = await c._behind_base_gate(ctx)
|
||
|
|
|
||
|
|
assert env is None
|
||
|
|
git_svc.is_behind_base.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_behind_base_gate_fail_opens_on_git_error() -> None:
|
||
|
|
t = _Task()
|
||
|
|
git_svc = AsyncMock()
|
||
|
|
git_svc.is_behind_base.side_effect = RuntimeError("fetch timeout")
|
||
|
|
deps = _make_deps(git=git_svc)
|
||
|
|
c = Choreographer(deps)
|
||
|
|
ctx = _ctx(t)
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
||
|
|
new=AsyncMock(return_value=_BASE),
|
||
|
|
):
|
||
|
|
env = await c._behind_base_gate(ctx)
|
||
|
|
|
||
|
|
# A flaky fetch must not strand the task at the submit gate — fail-open.
|
||
|
|
assert env is None
|