2026-07-02 05:46:01 +02:00
|
|
|
"""Behind-base auto-sync for the assembled PM submits (B2).
|
|
|
|
|
|
|
|
|
|
The needs_revision ↔ awaiting_pr_review ping-pong (live, 2026-07-02): a cell /
|
|
|
|
|
root revision re-submitted a head whose BASE had moved (sibling cells merged),
|
|
|
|
|
so the gate re-failed the same missing-work finding every cycle. Leaf devs
|
|
|
|
|
have the ``_behind_base_gate`` + ``sync_branch``; the assembled submits had no
|
|
|
|
|
freshness check at all. ``_freshen_assembled_branch`` closes that: at
|
|
|
|
|
submit_up / submit_root time every child is terminal, so rebasing the
|
|
|
|
|
assembled branch onto its base is safe — conflicts become a clean rejection
|
|
|
|
|
naming the files instead of a blind re-review.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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(**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)
|
|
|
|
|
return ChoreographerDeps(**base)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cell_task() -> MagicMock:
|
|
|
|
|
return MagicMock(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
branch_name="feature/frontend/root--cell",
|
|
|
|
|
team="frontend",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_freshen_noop_when_up_to_date() -> None:
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.is_behind_base.return_value = (0, 3)
|
|
|
|
|
c = Choreographer(_make_deps(git=git))
|
|
|
|
|
env = await c._freshen_assembled_branch(
|
|
|
|
|
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
|
|
|
|
)
|
|
|
|
|
assert env is None
|
|
|
|
|
git.sync_task_branch.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_freshen_rebases_when_behind_and_proceeds() -> None:
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.is_behind_base.return_value = (2, 3)
|
|
|
|
|
git.sync_task_branch.return_value = {"status": "rebased", "unique_commits": 3}
|
|
|
|
|
c = Choreographer(_make_deps(git=git))
|
|
|
|
|
env = await c._freshen_assembled_branch(
|
|
|
|
|
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
|
|
|
|
)
|
|
|
|
|
assert env is None
|
|
|
|
|
git.sync_task_branch.assert_awaited_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_freshen_conflicts_reject_with_files() -> None:
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.is_behind_base.return_value = (2, 3)
|
|
|
|
|
git.sync_task_branch.return_value = {
|
|
|
|
|
"status": "conflicts",
|
|
|
|
|
"files": ["frontend/src/lib/stats.json"],
|
|
|
|
|
}
|
|
|
|
|
c = Choreographer(_make_deps(git=git))
|
|
|
|
|
env = await c._freshen_assembled_branch(
|
|
|
|
|
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
|
|
|
|
)
|
|
|
|
|
assert env is not None
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["error"] == "invalid_state"
|
|
|
|
|
assert "stats.json" in body["message"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 14:34:25 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_freshen_diverged_rejects() -> None:
|
|
|
|
|
"""A diverged branch is refused just like a conflict — never guessed at."""
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.is_behind_base.return_value = (2, 3)
|
|
|
|
|
git.sync_task_branch.return_value = {
|
|
|
|
|
"status": "diverged",
|
|
|
|
|
"local_only": 1,
|
|
|
|
|
"origin_only": 2,
|
|
|
|
|
}
|
|
|
|
|
c = Choreographer(_make_deps(git=git))
|
|
|
|
|
env = await c._freshen_assembled_branch(
|
|
|
|
|
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
|
|
|
|
)
|
|
|
|
|
assert env is not None
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["error"] == "invalid_state"
|
|
|
|
|
assert "DIVERGED" in body["message"]
|
|
|
|
|
assert "reconcile" in body["remediate"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-02 05:46:01 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_freshen_fails_open_on_probe_error() -> None:
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.is_behind_base.side_effect = RuntimeError("network sad")
|
|
|
|
|
c = Choreographer(_make_deps(git=git))
|
|
|
|
|
env = await c._freshen_assembled_branch(
|
|
|
|
|
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
|
|
|
|
)
|
|
|
|
|
assert env is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_freshen_fails_open_on_sync_error() -> None:
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.is_behind_base.return_value = (1, 1)
|
|
|
|
|
git.sync_task_branch.side_effect = RuntimeError("rebase runner sad")
|
|
|
|
|
c = Choreographer(_make_deps(git=git))
|
|
|
|
|
env = await c._freshen_assembled_branch(
|
|
|
|
|
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
|
|
|
|
)
|
|
|
|
|
assert env is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_freshen_skips_branchless_and_missing_base() -> None:
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
c = Choreographer(_make_deps(git=git))
|
|
|
|
|
branchless = MagicMock(id=uuid4(), branch_name=None, team="frontend")
|
|
|
|
|
assert (
|
|
|
|
|
await c._freshen_assembled_branch(branchless, base_branch="x", verb="submit_up")
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await c._freshen_assembled_branch(
|
|
|
|
|
_cell_task(), base_branch="", verb="submit_up"
|
|
|
|
|
)
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
git.is_behind_base.assert_not_awaited()
|