mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user