Fix the PR-divergence respawn loop: loop gate, CEO god-mode, PR conflict resolver, sequence-ordered merge (#164)

* fix(orchestrator,panel): bound the respawn loop gate and give the CEO a status override

The PM respawn loop gate could never fire on a recurring tracing_gap: every
same-status respawn that emitted a tracing_gap reset the strike counter, so a
task whose unblock can never satisfy its decision gate respawned forever. Cap
the number of tracing_gap resets (pm_respawn_max_tracing_resets) so strikes
accrue once a gap is clearly recurring rather than progressing, and route the
pm-review and blocker dispatch respawn paths through the gate so it actually
applies to those loops.

Panel: the task status dropdown was driven solely by the lifecycle graph, so a
task wedged in a terminal/blocked state offered no actionable transitions. Add
an audited admin status override (PATCH status -> admin_set_status) for every
non-in-band target, letting the human operator force any state.

* feat(git): add rebase_onto_base and close_pull_request PR-divergence primitives

Agents had no way to resolve a PR that could not merge because a sibling merged
overlapping work first: their only moves were complete (which 405s) or block
(which loops). Add the two missing operations:

- rebase_onto_base rebases a head branch onto the latest base and classifies
  the outcome: superseded (no unique commits -> safe to close), rebased (unique
  work -> force-pushed, ready to merge), or conflicts (aborted, needs a human).
- close_pull_request retires a superseded PR with an explanatory comment.

These back both the sequence-ordered merge and the conflict resolver.

* feat(gateway): auto-resolve a leaf PR that can't merge instead of looping

When a sibling lands overlapping work first, the cell PM's complete() merge
hits a GitHub 405 and the task re-blocks, respawning the PM forever (the
production wedge: one task burned 6000+ tool calls over 3 hours). The merge
now raises MergeConflictError, and cell_pm_complete resolves it:

- rebase the branch onto the current base;
- superseded (no unique commits) -> close the dead PR + complete the task
  without a redundant merge (the manual action operators kept requesting);
- rebased (unique work) -> retry the merge, then complete;
- genuine conflicts -> admin-override the task to awaiting_ceo_approval and
  alert the CEO, so it leaves agent dispatch instead of looping.

MergeConflictError subclasses GitError, so existing handlers are unaffected.

* test(git): silence unused-arg lint in close_pull_request stub

* feat(orchestrator): sequence-ordered merge for leaf siblings

Leaf siblings share one cell branch, but within-cell siblings were all left at
the default sequence 0, so two leaf PRs raced into the same branch and the
second wedged. Now:

- decomposition assigns each new sibling the next ordinal within its parent, so
  the merge order is well-defined;
- the pm-review dispatcher holds a higher-sequence leaf until its earlier
  same-team siblings are terminal, so they merge into the shared branch in order
  instead of racing.

Loop-free by construction: a gated task is simply not dispatched this tick (no
reject, no respawn). Terminal siblings never block, so a cancelled sibling can't
deadlock the rest; any sibling lookup failure degrades to dispatch.

* test: use monkeypatch.setattr instead of type:ignore in new tests

CI type-checks tests/ (the type-gated suite) which my local 'mypy roboco/' skipped.
The method-mock assignments tripped mypy method-assign/assignment; replace the
silencing comments with monkeypatch.setattr and local mock refs for assertions,
matching the project's no-type:ignore rule.

* fix(git): stop get_status misreporting an unstaged deletion as staged

git_status used stdout.strip().split() before parsing porcelain. strip() eats
the leading space on the first line, so an unstaged deletion (' D file') became
'D file' and parsed as a STAGED deletion — the false 'staged' that caused 6
wasted QA cycles when a dev deleted a file without staging it. Use splitlines(),
which preserves the index/worktree status columns.

* feat(panel): mobile sidebar hamburger + Sheet drawer (AC1)

The umbrella's AC1 was never built: on mobile the sidebar had no entry point.
Extract the nav/footer into shared SidebarNav/SidebarFooter, hide the static
sidebar below md, and add a hamburger in the header that opens the same nav in a
left Sheet drawer (closing on navigation). Desktop is unchanged.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-14 23:18:58 +02:00
committed by GitHub
co-authored by Renn F
parent bb9d4ff12a
commit 2817ca1ceb
14 changed files with 1297 additions and 64 deletions
@@ -0,0 +1,166 @@
"""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)
t = MagicMock(pr_number=159, parent_task_id=None, team="frontend")
env = await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/frontend/root--cell", "notes", _EXC
)
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)
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
)
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()
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
env = await choreo._resolve_merge_conflict_on_complete(
uuid4(), tid, t, "feature/backend/root--cell", "notes", _EXC
)
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
@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)
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
)
task.admin_set_status.assert_awaited_once()
task.cell_pm_complete.assert_not_awaited()
git.close_pull_request.assert_not_awaited()