2026-06-14 23:18:58 +02:00
|
|
|
"""Conflict resolver: a leaf PR that can't merge is resolved, never looped.
|
|
|
|
|
|
|
|
|
|
When a sibling lands overlapping work first, ``cell_pm_complete``'s merge
|
|
|
|
|
raises ``MergeConflictError``. The resolver rebases and acts on the outcome:
|
|
|
|
|
re-merge (rebased), close + complete (superseded), or escalate to the CEO
|
|
|
|
|
(genuine conflicts) — instead of failing back into a respawn loop.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.exceptions import MergeConflictError
|
|
|
|
|
from roboco.models.base import TaskStatus
|
|
|
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps:
|
|
|
|
|
task = overrides.get("task", AsyncMock())
|
|
|
|
|
git = overrides.get("git", AsyncMock())
|
|
|
|
|
evidence_repo = overrides.get("evidence_repo", AsyncMock())
|
|
|
|
|
return ChoreographerDeps(
|
|
|
|
|
task=task,
|
|
|
|
|
work_session=overrides.get("work_session", AsyncMock()),
|
|
|
|
|
git=git,
|
|
|
|
|
a2a=overrides.get("a2a", AsyncMock()),
|
|
|
|
|
journal=overrides.get("journal", AsyncMock()),
|
|
|
|
|
audit=overrides.get("audit", AsyncMock()),
|
|
|
|
|
evidence_repo=evidence_repo,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _choreo(
|
|
|
|
|
task: AsyncMock, git: AsyncMock, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> Choreographer:
|
|
|
|
|
choreo = Choreographer(_make_deps(task=task, git=git))
|
|
|
|
|
# Isolate from briefing assembly (hits many repos) and CEO notification.
|
|
|
|
|
# monkeypatch.setattr (not direct assignment) keeps mypy's method-assign
|
|
|
|
|
# check satisfied without silencing it.
|
|
|
|
|
monkeypatch.setattr(choreo, "_briefing_for", AsyncMock(return_value={}))
|
|
|
|
|
monkeypatch.setattr(choreo, "_notify_ceo_merge_conflict", AsyncMock())
|
|
|
|
|
monkeypatch.setattr(choreo, "_maybe_advance_parent_to_pm_review", AsyncMock())
|
|
|
|
|
return choreo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_EXC = MergeConflictError("GitHub API refused PR merge (405): not mergeable")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_superseded_closes_pr_and_completes_without_merge(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""All work already in base -> close the dead PR + complete, no re-merge."""
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.rebase_pr_for_task = AsyncMock(return_value={"status": "superseded"})
|
|
|
|
|
git.close_pull_request = AsyncMock()
|
|
|
|
|
git.pr_merge = AsyncMock()
|
|
|
|
|
task = AsyncMock()
|
|
|
|
|
task.cell_pm_complete = AsyncMock(
|
|
|
|
|
return_value=MagicMock(status="completed", parent_task_id=None, team="frontend")
|
|
|
|
|
)
|
|
|
|
|
choreo = _choreo(task, git, monkeypatch)
|
2026-06-29 05:38:21 +02:00
|
|
|
t = MagicMock(
|
|
|
|
|
pr_number=159, project_id=uuid4(), parent_task_id=None, team="frontend"
|
|
|
|
|
)
|
2026-06-14 23:18:58 +02:00
|
|
|
|
|
|
|
|
env = await choreo._resolve_merge_conflict_on_complete(
|
2026-07-26 15:07:28 +02:00
|
|
|
pm_agent_id=uuid4(),
|
|
|
|
|
task_id=uuid4(),
|
|
|
|
|
t=t,
|
|
|
|
|
target="feature/frontend/root--cell",
|
|
|
|
|
notes="notes",
|
|
|
|
|
exc=_EXC,
|
2026-06-14 23:18:58 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
git.close_pull_request.assert_awaited_once()
|
|
|
|
|
task.cell_pm_complete.assert_awaited_once()
|
|
|
|
|
# No redundant merge for a superseded branch.
|
|
|
|
|
git.pr_merge.assert_not_awaited()
|
|
|
|
|
# Completed without a merge commit.
|
|
|
|
|
assert task.cell_pm_complete.await_args.kwargs["merge_commit"] is None
|
|
|
|
|
assert env.error is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_rebased_retries_merge_and_completes(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Clean rebase with unique work -> retry the merge, then complete."""
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.rebase_pr_for_task = AsyncMock(
|
|
|
|
|
return_value={"status": "rebased", "unique_commits": 2}
|
|
|
|
|
)
|
|
|
|
|
git.pr_merge = AsyncMock(return_value={"merge_commit_sha": "abc123"})
|
|
|
|
|
git.close_pull_request = AsyncMock()
|
|
|
|
|
task = AsyncMock()
|
|
|
|
|
task.cell_pm_complete = AsyncMock(
|
|
|
|
|
return_value=MagicMock(status="completed", parent_task_id=None, team="frontend")
|
|
|
|
|
)
|
|
|
|
|
choreo = _choreo(task, git, monkeypatch)
|
2026-06-29 05:38:21 +02:00
|
|
|
t = MagicMock(
|
|
|
|
|
pr_number=160, project_id=uuid4(), parent_task_id=None, team="backend"
|
|
|
|
|
)
|
2026-06-14 23:18:58 +02:00
|
|
|
|
|
|
|
|
await choreo._resolve_merge_conflict_on_complete(
|
2026-07-26 15:07:28 +02:00
|
|
|
pm_agent_id=uuid4(),
|
|
|
|
|
task_id=uuid4(),
|
|
|
|
|
t=t,
|
|
|
|
|
target="feature/backend/root--cell",
|
|
|
|
|
notes="notes",
|
|
|
|
|
exc=_EXC,
|
2026-06-14 23:18:58 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
git.pr_merge.assert_awaited_once()
|
|
|
|
|
git.close_pull_request.assert_not_awaited()
|
|
|
|
|
assert task.cell_pm_complete.await_args.kwargs["merge_commit"] == "abc123"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_genuine_conflict_escalates_to_ceo_and_does_not_loop(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Unresolvable conflict -> admin override to awaiting_ceo_approval."""
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.rebase_pr_for_task = AsyncMock(
|
|
|
|
|
return_value={"status": "conflicts", "files": ["src/a.py", "src/b.py"]}
|
|
|
|
|
)
|
|
|
|
|
git.close_pull_request = AsyncMock()
|
|
|
|
|
git.pr_merge = AsyncMock()
|
|
|
|
|
task = AsyncMock()
|
|
|
|
|
task.admin_set_status = AsyncMock()
|
|
|
|
|
task.get = AsyncMock(return_value=MagicMock(status="awaiting_ceo_approval"))
|
|
|
|
|
choreo = _choreo(task, git, monkeypatch)
|
|
|
|
|
# Hold a local ref to the notification mock so the assertion has a typed
|
|
|
|
|
# AsyncMock to call (the attribute itself reads as the original method type).
|
|
|
|
|
notify = AsyncMock()
|
|
|
|
|
monkeypatch.setattr(choreo, "_notify_ceo_merge_conflict", notify)
|
|
|
|
|
tid = uuid4()
|
2026-06-29 05:38:21 +02:00
|
|
|
t = MagicMock(
|
|
|
|
|
pr_number=160, project_id=uuid4(), parent_task_id=None, team="backend"
|
|
|
|
|
)
|
2026-06-14 23:18:58 +02:00
|
|
|
|
|
|
|
|
env = await choreo._resolve_merge_conflict_on_complete(
|
2026-07-26 15:07:28 +02:00
|
|
|
pm_agent_id=uuid4(),
|
|
|
|
|
task_id=tid,
|
|
|
|
|
t=t,
|
|
|
|
|
target="feature/backend/root--cell",
|
|
|
|
|
notes="notes",
|
|
|
|
|
exc=_EXC,
|
2026-06-14 23:18:58 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
task.admin_set_status.assert_awaited_once()
|
|
|
|
|
args = task.admin_set_status.await_args.args
|
|
|
|
|
assert args[0] == tid
|
|
|
|
|
assert args[1] == TaskStatus.AWAITING_CEO_APPROVAL
|
|
|
|
|
# Never closes or re-merges a branch with real unresolved conflicts.
|
|
|
|
|
git.close_pull_request.assert_not_awaited()
|
|
|
|
|
git.pr_merge.assert_not_awaited()
|
|
|
|
|
task.cell_pm_complete.assert_not_awaited()
|
|
|
|
|
notify.assert_awaited_once()
|
|
|
|
|
assert env.error is None
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 14:34:25 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_diverged_rebase_outcome_escalates_rather_than_completing(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A diverged rebase (local and origin each carry unique commits) must
|
|
|
|
|
escalate like any other non-rebased/non-superseded outcome — never
|
|
|
|
|
silently complete or re-merge a branch that could still be missing one
|
|
|
|
|
side's work."""
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.rebase_pr_for_task = AsyncMock(
|
|
|
|
|
return_value={"status": "diverged", "local_only": 1, "origin_only": 2}
|
|
|
|
|
)
|
|
|
|
|
git.close_pull_request = AsyncMock()
|
|
|
|
|
git.pr_merge = AsyncMock()
|
|
|
|
|
task = AsyncMock()
|
|
|
|
|
task.admin_set_status = AsyncMock()
|
|
|
|
|
task.get = AsyncMock(return_value=MagicMock(status="awaiting_ceo_approval"))
|
|
|
|
|
choreo = _choreo(task, git, monkeypatch)
|
|
|
|
|
t = MagicMock(
|
|
|
|
|
pr_number=161, project_id=uuid4(), parent_task_id=None, team="backend"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await choreo._resolve_merge_conflict_on_complete(
|
2026-07-26 15:07:28 +02:00
|
|
|
pm_agent_id=uuid4(),
|
|
|
|
|
task_id=uuid4(),
|
|
|
|
|
t=t,
|
|
|
|
|
target="feature/backend/root--cell",
|
|
|
|
|
notes="notes",
|
|
|
|
|
exc=_EXC,
|
2026-07-24 14:34:25 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
task.admin_set_status.assert_awaited_once()
|
|
|
|
|
task.cell_pm_complete.assert_not_awaited()
|
|
|
|
|
git.close_pull_request.assert_not_awaited()
|
|
|
|
|
git.pr_merge.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 23:18:58 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_unknown_rebase_outcome_escalates_rather_than_completing(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A non-classifiable rebase result must escalate, not silently complete."""
|
|
|
|
|
git = AsyncMock()
|
|
|
|
|
git.rebase_pr_for_task = AsyncMock(return_value={"status": "unknown"})
|
|
|
|
|
git.close_pull_request = AsyncMock()
|
|
|
|
|
git.pr_merge = AsyncMock()
|
|
|
|
|
task = AsyncMock()
|
|
|
|
|
task.admin_set_status = AsyncMock()
|
|
|
|
|
task.get = AsyncMock(return_value=MagicMock(status="awaiting_ceo_approval"))
|
|
|
|
|
choreo = _choreo(task, git, monkeypatch)
|
2026-06-29 05:38:21 +02:00
|
|
|
t = MagicMock(
|
|
|
|
|
pr_number=160, project_id=uuid4(), parent_task_id=None, team="backend"
|
|
|
|
|
)
|
2026-06-14 23:18:58 +02:00
|
|
|
|
|
|
|
|
await choreo._resolve_merge_conflict_on_complete(
|
2026-07-26 15:07:28 +02:00
|
|
|
pm_agent_id=uuid4(),
|
|
|
|
|
task_id=uuid4(),
|
|
|
|
|
t=t,
|
|
|
|
|
target="feature/backend/root--cell",
|
|
|
|
|
notes="notes",
|
|
|
|
|
exc=_EXC,
|
2026-06-14 23:18:58 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
task.admin_set_status.assert_awaited_once()
|
|
|
|
|
task.cell_pm_complete.assert_not_awaited()
|
|
|
|
|
git.close_pull_request.assert_not_awaited()
|