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
+143 -3
View File
@@ -19,6 +19,7 @@ from uuid import UUID
import structlog
from roboco.exceptions import MergeConflictError
from roboco.foundation.policy import lifecycle as spec_module
from roboco.services.gateway.choreographer._verb_runner import VerbRunner
from roboco.services.gateway.claim_guards import (
@@ -4102,6 +4103,14 @@ class Choreographer:
estimated_complexity=complexity_enum,
)
new_task = await self.task.create_subtask(req)
# Assign a distinct ordinal within the parent's siblings so the merge
# order is deterministic. Within-cell siblings were all left at the
# default sequence 0 — which is why two leaf PRs raced into the same
# cell branch and the second wedged. Each new sibling takes the next
# ordinal (the count of pre-existing siblings).
siblings = await self.task.get_subtasks(parent_task_id)
next_seq = len([s for s in siblings if s.id != new_task.id])
await self.task.set_sequence(new_task.id, next_seq)
# Thread the parent's existing session links onto the
# new subtask so the assigned agent (dev/qa/doc) lands in the
# group chat the PM has already been talking in. Pre-gateway
@@ -4631,16 +4640,37 @@ class Choreographer:
# change); for a cell task it is the root branch (feature/main_pm/…),
# which parent_branch_for would have mis-derived as feature/<cellteam>/…
target = await resolve_parent_branch(t, self.task)
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
try:
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
)
except MergeConflictError as exc:
# A sibling landed overlapping work first, so this PR can't merge.
# Resolve it (rebase / close-superseded / escalate) instead of
# letting the failure re-block the task and respawn the PM forever.
return await self._resolve_merge_conflict_on_complete(
pm_agent_id, task_id, t, target, notes, exc
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, merge_result.get("merge_commit_sha")
)
async def _finalize_cell_complete(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
notes: str,
merge_commit: str | None,
) -> Envelope:
"""Mark the leaf completed and propagate the completion to its parent."""
leaf_parent_id = t.parent_task_id
leaf_team = t.team
t = await self.task.cell_pm_complete(
pm_agent_id,
task_id,
notes,
merge_commit=merge_result.get("merge_commit_sha"),
merge_commit=merge_commit,
)
# Now that the leaf is completed, propagate the completion up to the
# parent task: if the parent's subtasks are all terminal, hand the
@@ -4655,6 +4685,116 @@ class Choreographer:
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role="cell_pm")
async def _resolve_merge_conflict_on_complete(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
target: str,
notes: str,
exc: MergeConflictError,
) -> Envelope:
"""Resolve a leaf PR that couldn't merge because a sibling landed first.
Rebase the branch onto the current base and act on the outcome rather
than failing (which re-blocks the task and respawns the PM forever):
- ``rebased`` — the branch now integrates cleanly; retry the merge.
- ``superseded`` — every change is already in the base via the sibling;
close the dead PR and complete the task without a redundant merge.
- ``conflicts`` / ``unknown`` — a human must resolve; escalate to the
CEO (``awaiting_ceo_approval``) so the task leaves the agent loop.
"""
rebase = await self.git.rebase_pr_for_task(
t.pr_number, actor_agent_id=pm_agent_id
)
status = rebase.get("status")
if status == "rebased":
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, merge_result.get("merge_commit_sha")
)
if status == "superseded":
await self.git.close_pull_request(
t.pr_number,
comment=(
"Closed as superseded: every change on this branch is "
"already present in the base via a sibling PR that merged "
"first. Completing the task without a redundant merge."
),
actor_agent_id=pm_agent_id,
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, None
)
return await self._escalate_merge_conflict_to_ceo(
pm_agent_id, task_id, t, rebase, exc
)
async def _escalate_merge_conflict_to_ceo(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
rebase: dict[str, Any],
exc: MergeConflictError,
) -> Envelope:
"""Route an unresolvable PR conflict to the CEO; never loop on it.
Moves the task to ``awaiting_ceo_approval`` (admin override — the leaf
has no in-band edge there) so it leaves agent dispatch, and best-effort
alerts the CEO with the conflicting files.
"""
from roboco.models.base import TaskStatus
files = rebase.get("files") or []
logger.info(
"merge conflict escalated to CEO",
task_id=str(task_id),
conflicting_files=len(files),
rebase_status=rebase.get("status"),
merge_error=str(exc),
)
await self.task.admin_set_status(
task_id,
TaskStatus.AWAITING_CEO_APPROVAL,
actor_id=pm_agent_id,
actor_role="cell_pm",
)
await self._notify_ceo_merge_conflict(task_id, files)
t = await self.task.get(task_id)
detail = f" ({len(files)} conflicting file(s))" if files else ""
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"the PR has merge conflicts that could not be resolved "
f"automatically{detail}; escalated to the CEO. A developer can "
"rebase the branch, or the CEO can close the PR if superseded."
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role="cell_pm")
async def _notify_ceo_merge_conflict(self, task_id: UUID, files: list[str]) -> None:
"""Best-effort CEO alert for a wedged merge conflict; never raises."""
from roboco.services.notification import NotificationService
try:
await NotificationService().send_stuck_agent_notification(
task_id=str(task_id),
agent_slug="cell_pm",
task_status="awaiting_ceo_approval",
to_agent="ceo",
)
except Exception:
logger.warning(
"failed to send CEO merge-conflict notification",
task_id=str(task_id),
conflicting_files=len(files),
)
async def _maybe_advance_parent_to_pm_review(
self, parent_task_id: UUID, leaf_team: Any
) -> None: