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
+3
View File
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { ConnectionStatus } from "./connection-status";
import { MobileSidebar } from "./mobile-sidebar";
export function Header() {
const { setTheme } = useTheme();
@@ -20,6 +21,8 @@ export function Header() {
<header className="flex h-16 items-center justify-between border-b bg-background px-6">
{/* Search */}
<div className="flex items-center gap-4 flex-1 max-w-md">
{/* Mobile nav trigger — only shown below md, where the sidebar is hidden */}
<MobileSidebar />
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { SidebarNav, SidebarFooter } from "./sidebar";
/**
* Mobile navigation: a hamburger button (shown only below md, where the static
* sidebar is hidden) that opens the full nav in a left Sheet drawer. Reuses the
* desktop SidebarNav/SidebarFooter so the two never drift, and closes itself on
* navigation so the drawer doesn't linger over the new page.
*/
export function MobileSidebar() {
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label="Open navigation menu"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="flex w-64 flex-col gap-0 p-0">
<SheetHeader className="h-16 justify-center border-b px-4 text-left">
<SheetTitle asChild>
<Link
href="/overview"
onClick={close}
className="flex items-center gap-2"
>
<Image
src="/roboco-logo.png"
alt="RoboCo"
width={32}
height={32}
unoptimized
className="h-8 w-8 rounded"
/>
<span className="text-lg font-semibold">RoboCo</span>
</Link>
</SheetTitle>
</SheetHeader>
<ScrollArea className="flex-1 py-4">
<SidebarNav onNavigate={close} />
</ScrollArea>
<div className="border-t p-2">
<SidebarFooter onNavigate={close} />
</div>
</SheetContent>
</Sheet>
);
}
+81 -48
View File
@@ -27,7 +27,7 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useUIStore } from "@/store";
const navItems = [
export const navItems = [
// Dashboard
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
@@ -55,14 +55,89 @@ const navItems = [
{ title: "Metrics", href: "/metrics", icon: Activity },
];
export function Sidebar() {
const footerItems = [
{ title: "AI Providers", href: "/settings/ai-providers", icon: Cpu },
{ title: "Settings", href: "/settings", icon: Settings },
];
/**
* The navigation links, shared by the desktop sidebar and the mobile Sheet
* drawer so both stay in sync. `collapsed` hides labels (desktop rail);
* `onNavigate` lets the mobile drawer close itself when a link is tapped.
*/
export function SidebarNav({
collapsed = false,
onNavigate,
}: {
collapsed?: boolean;
onNavigate?: () => void;
}) {
const pathname = usePathname();
return (
<nav className="space-y-1 px-2">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
collapsed && "justify-center px-2"
)}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</Link>
);
})}
</nav>
);
}
/** Footer links (AI Providers, Settings), shared by desktop + mobile. */
export function SidebarFooter({
collapsed = false,
onNavigate,
}: {
collapsed?: boolean;
onNavigate?: () => void;
}) {
return (
<div className="space-y-1">
{footerItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
collapsed && "justify-center px-2"
)}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5" />
{!collapsed && <span>{item.title}</span>}
</Link>
))}
</div>
);
}
export function Sidebar() {
const { sidebarCollapsed, setSidebarCollapsed } = useUIStore();
return (
<aside
className={cn(
"flex h-screen flex-col border-r bg-background transition-all duration-300",
// Hidden on mobile — the Header's hamburger opens the same nav in a
// Sheet drawer there (see MobileSidebar). Shown from md upward.
"hidden h-screen flex-col border-r bg-background transition-all duration-300 md:flex",
sidebarCollapsed ? "w-16" : "w-64"
)}
>
@@ -99,54 +174,12 @@ export function Sidebar() {
{/* Navigation */}
<ScrollArea className="flex-1 py-4">
<nav className="space-y-1 px-2">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
sidebarCollapsed && "justify-center px-2"
)}
title={sidebarCollapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5 shrink-0" />
{!sidebarCollapsed && <span>{item.title}</span>}
</Link>
);
})}
</nav>
<SidebarNav collapsed={sidebarCollapsed} />
</ScrollArea>
{/* Footer */}
<div className="border-t p-2 space-y-1">
<Link
href="/settings/ai-providers"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
sidebarCollapsed && "justify-center px-2"
)}
title={sidebarCollapsed ? "AI Providers" : undefined}
>
<Cpu className="h-5 w-5" />
{!sidebarCollapsed && <span>AI Providers</span>}
</Link>
<Link
href="/settings"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
sidebarCollapsed && "justify-center px-2"
)}
title={sidebarCollapsed ? "Settings" : undefined}
>
<Settings className="h-5 w-5" />
{!sidebarCollapsed && <span>Settings</span>}
</Link>
<div className="border-t p-2">
<SidebarFooter collapsed={sidebarCollapsed} />
</div>
</aside>
);
@@ -105,6 +105,15 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
const nextStatuses: TaskStatus[] = (validTransitionsData ?? []).filter(
(s) => s !== task.status
);
// God-mode: the panel always acts as the CEO/operator, so the dropdown also
// offers every OTHER status as a forced admin override — letting the CEO
// recover a task wedged in a state with no valid in-band move (e.g. a task
// stuck in `blocked` whose PR can never merge, or reopening a `cancelled`
// task). These route through the audited admin-override path, not lifecycle
// verbs. See handleStatusChange.
const overrideStatuses: TaskStatus[] = Object.values(TaskStatus).filter(
(s) => s !== task.status && !nextStatuses.includes(s)
);
const [deleteOpen, setDeleteOpen] = useState(false);
// Inline editing states
@@ -177,8 +186,7 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
// Skip if same status
if (newStatus === task.status) return;
// Backend requires lifecycle actions for ALL status changes
// Map target status to the action that achieves it
// Map a target status to the lifecycle action that achieves it in-band.
const statusToAction: Partial<Record<TaskStatus, string>> = {
[TaskStatus.PENDING]: "reopen", // From cancelled
[TaskStatus.CLAIMED]: "claim",
@@ -193,10 +201,26 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
};
const action = statusToAction[newStatus];
if (action && onAction) {
// Prefer the real lifecycle action when this is a valid in-band transition —
// it runs the proper side effects (e.g. `complete` merges the PR).
if (action && nextStatuses.includes(newStatus) && onAction) {
onAction(action);
} else {
toast.error(`Cannot transition to ${statusLabels[newStatus]} from current status`);
return;
}
// God-mode override: force ANY status, even from a state with no valid
// in-band move (a task wedged in `blocked` whose PR can never merge, or
// reopening a `cancelled` task). Audited via PATCH /tasks/{id} {status} ->
// admin_set_status. No PR merge / lifecycle side effects fire — this is a
// pure, operator-driven state correction the CEO is entitled to make.
try {
await updateTask.mutateAsync({
taskId: task.id,
updates: { status: newStatus },
});
toast.success(`Status forced to ${statusLabels[newStatus]}`);
} catch {
toast.error(`Failed to set status to ${statusLabels[newStatus]}`);
}
};
@@ -347,6 +371,19 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
</span>
</SelectItem>
))}
{/* God-mode: every remaining status as an audited admin
override (no valid in-band transition). Marked "force" so
the operator knows it bypasses the normal lifecycle. */}
{overrideStatuses.map((status) => (
<SelectItem key={status} value={status}>
<span className={`px-2 py-0.5 rounded ${statusColors[status]}`}>
{statusLabels[status]}
</span>
<span className="ml-1 text-[10px] uppercase tracking-wide text-muted-foreground">
force
</span>
</SelectItem>
))}
</SelectContent>
</Select>
+11
View File
@@ -453,6 +453,17 @@ def _scrub_git_secrets(text: str) -> str:
return text
class MergeConflictError(GitError):
"""A PR could not be merged because its branch conflicts with the base.
Raised when the GitHub merge API refuses the merge (e.g. HTTP 405 "not
mergeable") after the in-band retry. Distinct from a generic ``GitError``
so the completion path can route to conflict resolution (rebase / close
superseded / escalate) instead of failing and looping. A subclass of
``GitError`` so existing ``except GitError`` handlers stay correct.
"""
class GitCommandError(GitError):
"""Git command execution failed."""
+7
View File
@@ -35,6 +35,13 @@ class BudgetPolicy:
loop_window: int = 10 # rolling-window size
loop_action: Literal["warn", "halt"] = "halt" # NEW: was effectively "warn"
pm_respawn_max_unproductive: int = 3
# A same-status respawn that emitted a tracing_gap is normally treated as a
# rule-following retry and resets the unproductive counter. That reset is
# bounded: a task whose EVERY respawn trips the SAME tracing_gap is a stuck
# loop, not progress (e.g. a cold-respawned PM that can never satisfy the
# unblock journal-decision gate). After this many consecutive resets the
# gap stops counting as progress and strikes accrue, so the loop gate fires.
pm_respawn_max_tracing_resets: int = 3
verb_retry_max_per_minute: int = 3 # default cap for verbs not in VERB_RETRY_LIMITS
+94 -5
View File
@@ -5585,6 +5585,7 @@ Start now: evidence(task_id="{task_id}")
# Use foundation's default; keep the local name for back-compat.
_PM_RESPAWN_MAX_UNPRODUCTIVE = _AGENT_LOOP_BUDGET.pm_respawn_max_unproductive
_PM_RESPAWN_MAX_TRACING_RESETS = _AGENT_LOOP_BUDGET.pm_respawn_max_tracing_resets
async def _pm_respawn_should_gate(
self, agent_slug: str, task: dict[str, Any]
@@ -5629,12 +5630,29 @@ Start now: evidence(task_id="{task_id}")
}
return False
# Same status as last spawn — could be a stuck loop OR a
# rule-following retry. Consult audit before counting.
# rule-following retry. A tracing_gap normally means the agent is
# advancing through a verb chain, so reset the strike counter — but
# only up to a bound. A task whose EVERY respawn trips the same gap is
# wedged, not progressing (e.g. the unblock journal-decision gate a
# cold-respawned PM can never satisfy), so cap the resets and let
# strikes accrue once the budget is exhausted. Without this cap the
# gate never fires for a tracing_gap loop and respawns run forever.
if await self._pm_made_rule_following_retry(agent_slug, task_id, record):
record["count"] = 1
record["last_check"] = now
record["notified"] = False
return False
resets = record.get("tracing_resets", 0)
if resets < self._PM_RESPAWN_MAX_TRACING_RESETS:
record["tracing_resets"] = resets + 1
record["count"] = 1
record["last_check"] = now
record["notified"] = False
return False
logger.warning(
"PM respawn tracing_gap reset budget exhausted — "
"treating recurring gap as a stuck loop",
agent_id=agent_slug,
task_id=task_id,
task_status=current_status,
tracing_resets=resets,
)
record["count"] += 1
record["last_check"] = now
if record["count"] > self._PM_RESPAWN_MAX_UNPRODUCTIVE:
@@ -6690,6 +6708,57 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
)
return True
async def _blocked_by_earlier_sibling(self, task: dict[str, Any]) -> bool:
"""True if a lower-sequence, same-team sibling is not yet terminal.
Sequence-ordered merge: leaf siblings share one cell branch, so merging
a later sibling before an earlier one diverges the branch and wedges the
loser. Hold a higher-sequence sibling's review/merge dispatch until the
earlier ones land (or are cancelled). Loop-free: the task simply isn't
dispatched this tick no reject, no respawn churn.
Only same-team siblings block (they target the same branch). Terminal
siblings (completed/cancelled) never block, so a cancelled sibling can't
deadlock the rest. Best-effort: any lookup failure falls through to
dispatch the ordering check must never wedge the dispatcher.
"""
parent_id = task.get("parent_task_id")
seq = task.get("sequence")
team = task.get("team")
if not parent_id or seq is None:
return False
from uuid import UUID
from roboco.db.base import get_session_factory
from roboco.models.base import TaskStatus
from roboco.services.task import get_task_service
terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
try:
session_factory = get_session_factory()
async with session_factory() as db:
task_svc = get_task_service(db)
siblings = await task_svc.get_subtasks(UUID(str(parent_id)))
except Exception as exc:
logger.debug(
"sibling-order check failed; dispatching anyway",
task_id=task.get("id"),
error=str(exc),
)
return False
for sib in siblings:
sib_seq = getattr(sib, "sequence", 0) or 0
sib_team = getattr(sib, "team", None)
sib_status = getattr(sib, "status", None)
sib_team_val = getattr(sib_team, "value", sib_team)
if (
str(sib_team_val) == str(team)
and sib_seq < seq
and sib_status not in terminal
):
return True
return False
async def _dispatch_pm_review_work(self, client: httpx.AsyncClient) -> None:
"""
Dispatch PM review work to cell PMs or Main PM.
@@ -6703,11 +6772,23 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
team = task.get("team")
assigned_to = task.get("assigned_to")
# Sequence-ordered merge: don't review/merge a leaf until its
# earlier same-team siblings have landed, so they merge into the
# shared cell branch in order instead of racing and wedging.
if await self._blocked_by_earlier_sibling(task):
continue
# If already assigned, check if that agent is running
if assigned_to:
assigned_slug = self._resolve_agent_slug(assigned_to)
if self._is_agent_active(assigned_slug):
continue
# Loop guard: a review task that keeps re-surfacing without
# advancing (e.g. an unmergeable PR that re-blocks every cycle)
# must stop respawning the reviewer, else it burns tokens
# forever. The gate notifies the CEO once it trips.
if await self._pm_respawn_should_gate(assigned_slug, task):
continue
# Agent not running - spawn them to continue
await self.spawn_agent(
agent_id=assigned_slug,
@@ -6819,6 +6900,14 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
if self._is_agent_active(agent_id):
continue
# Loop guard: a blocked task whose unblock can never succeed (e.g.
# a cold-respawned PM that can't satisfy the unblock decision gate,
# or an unresolvable merge conflict) must stop respawning the
# resolver. The gate notifies the CEO once it trips so the wedged
# task surfaces instead of silently burning tokens.
if await self._pm_respawn_should_gate(agent_id, task):
continue
await self.spawn_agent(
agent_id=agent_id,
task_id=task["id"],
+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:
+204 -3
View File
@@ -39,7 +39,12 @@ if TYPE_CHECKING:
)
from roboco.db.tables import TaskTable
from roboco.config import settings
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
from roboco.exceptions import (
GitCommandError,
GitError,
GitTimeoutError,
MergeConflictError,
)
from roboco.models.base import AgentRole, TaskStatus
from roboco.services.base import (
BaseService,
@@ -388,7 +393,13 @@ class GitService(BaseService):
current_branch = branch_result.stdout.strip()
status_result = await self._run_git(workspace, ["status", "--porcelain"])
lines = status_result.stdout.strip().split("\n") if status_result.stdout else []
# Use splitlines(), NOT stdout.strip().split("\n"): porcelain encodes the
# index column in position 0, which is a SPACE for a worktree-only change
# (e.g. " D file" = unstaged deletion). strip() eats that leading space on
# the first line, turning " D file" into "D file" — which then parses as a
# STAGED deletion. That false "staged" caused 6 wasted QA cycles when a
# dev deleted a file but hadn't staged it. splitlines() preserves columns.
lines = status_result.stdout.splitlines() if status_result.stdout else []
staged_files, unstaged_files, untracked_files = self._classify_porcelain(lines)
ahead, behind = await self._ahead_behind(workspace, current_branch)
@@ -2277,7 +2288,11 @@ class GitService(BaseService):
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
if not resp.is_success:
raise GitError(
# A merge refusal (typically 405 "not mergeable") means the branch
# conflicts with the base — a sibling landed overlapping work first.
# Raise the specific subclass so the completion path can rebase /
# close-superseded / escalate instead of failing into a respawn loop.
raise MergeConflictError(
f"GitHub API refused PR merge ({resp.status_code}): {resp.text[:200]}",
{"owner": ctx.owner, "repo": ctx.repo, "pr": ctx.pr_number},
)
@@ -2361,6 +2376,192 @@ class GitService(BaseService):
)
return {"merge_commit_sha": merge_commit or None}
async def _get_pr_refs(
self, owner: str, repo: str, pr_number: int, git_token: str
) -> tuple[str, str] | None:
"""Return ``(head_ref, base_ref)`` for a PR, or ``None`` if unavailable."""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
except httpx.HTTPError:
return None
if not resp.is_success:
return None
data = resp.json()
head = (data.get("head") or {}).get("ref")
base = (data.get("base") or {}).get("ref")
if not head or not base:
return None
return str(head), str(base)
async def rebase_onto_base(
self,
workspace: Path,
*,
head_branch: str,
base_branch: str,
git_token: str,
) -> dict[str, Any]:
"""Rebase ``head_branch`` onto the latest ``base_branch`` from origin.
This is the primitive both the sequence-ordered merge (rebase a later
sibling onto the prior one's merged result) and the conflict resolver
(rebase a wedged PR before re-merging) build on.
Returns one of:
- ``{"status": "superseded"}`` the rebase was clean and the head
has NO commits the base lacks; all its work is already in the base
(e.g. a sibling that merged a superset landed first). Safe to close
the PR + complete the task without a merge.
- ``{"status": "rebased", "unique_commits": int}`` clean rebase
with unique work; the rebased branch was force-pushed to origin and
can now merge cleanly.
- ``{"status": "conflicts", "files": [...]}`` the rebase hit
conflicts and was aborted; a developer must resolve by hand.
Never touches the base branch and only ever force-pushes
``head_branch`` (with ``--force-with-lease``). The caller must ensure
``base_branch`` is not a protected/default branch agents never
rebase-merge into master.
"""
await self._run_git(workspace, ["fetch", "origin"], token=git_token)
await self._run_git(workspace, ["checkout", head_branch])
await self._run_git(workspace, ["reset", "--hard", f"origin/{head_branch}"])
rebase = await self._run_git(
workspace, ["rebase", f"origin/{base_branch}"], check=False
)
if rebase.returncode != 0:
conflict = await self._run_git(
workspace,
["diff", "--name-only", "--diff-filter=U"],
check=False,
)
files = [f for f in conflict.stdout.splitlines() if f.strip()]
await self._run_git(workspace, ["rebase", "--abort"], check=False)
return {"status": "conflicts", "files": files}
count = await self._run_git(
workspace,
["rev-list", "--count", f"origin/{base_branch}..HEAD"],
)
unique = int(count.stdout.strip() or "0")
if unique == 0:
return {"status": "superseded"}
await self._run_git(
workspace,
["push", "--force-with-lease", "origin", f"HEAD:{head_branch}"],
token=git_token,
)
return {"status": "rebased", "unique_commits": unique}
async def rebase_pr_for_task(
self,
pr_number: int,
*,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Resolve workspace/refs for a PR and rebase its branch onto the base.
Thin wrapper over :meth:`rebase_onto_base` that loads the task/project
that owns the PR (mirrors :meth:`pr_merge`) and reads the PR's head/base
refs from GitHub. Returns the same classification dict, or
``{"status": "unknown"}`` when refs can't be resolved.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable).where(_TaskTable.pr_number == pr_number).limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace)
refs = await self._get_pr_refs(owner, repo, pr_number, git_token)
if refs is None:
return {"status": "unknown"}
head_branch, base_branch = refs
return await self.rebase_onto_base(
workspace,
head_branch=head_branch,
base_branch=base_branch,
git_token=git_token,
)
async def close_pull_request(
self,
pr_number: int,
*,
comment: str | None = None,
delete_branch: bool = True,
actor_agent_id: UUID | None = None,
) -> None:
"""Close PR ``pr_number`` on GitHub, optionally with an explanatory comment.
Used to retire a PR whose work is already in the base (superseded) so a
wedged task can complete without a merge the "close the dead PR"
action agents had no verb for. Best-effort branch cleanup on close.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable).where(_TaskTable.pr_number == pr_number).limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace)
headers = {
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
if comment:
await client.post(
f"https://api.github.com/repos/{owner}/{repo}/issues/"
f"{pr_number}/comments",
headers=headers,
json={"body": comment},
)
resp = await client.patch(
f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}",
headers=headers,
json={"state": "closed"},
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR close ({resp.status_code}): {resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
if delete_branch:
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
async def pr_target(
self,
pr_number: int,
@@ -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()
@@ -65,6 +65,40 @@ async def test_three_tracing_gap_responses_do_not_trip_kill() -> None:
assert fake_audit.has_recent_tracing_gap.await_count >= expected_audit_calls
@pytest.mark.asyncio
async def test_unending_tracing_gap_is_bounded_and_eventually_trips() -> None:
"""A task whose EVERY respawn trips the same tracing_gap must still die.
The rule-following reset is bounded by ``pm_respawn_max_tracing_resets``.
Before this bound a permanently-wedged task (e.g. a cold-respawned PM that
can never satisfy the unblock journal-decision gate) emitted a tracing_gap
on every spawn, reset the strike counter every time, and respawned
forever the production bleed. With the cap, resets are exhausted and
strikes accrue until the gate fires.
"""
orch = _new_orchestrator()
task_id = str(uuid4())
task = {"id": task_id, "status": "blocked"}
fake_audit = AsyncMock()
fake_audit.has_recent_tracing_gap = AsyncMock(return_value=True)
with (
patch("roboco.services.audit.get_audit_service", return_value=fake_audit),
patch(
"roboco.services.notification.NotificationService",
return_value=AsyncMock(),
),
):
results = [
await orch._pm_respawn_should_gate("main-pm", task) for _ in range(12)
]
# The bug was that this list would be all-False forever. The gate must
# trip at least once now that the reset budget is finite.
assert any(results), "tracing_gap loop must eventually be gated"
@pytest.mark.asyncio
async def test_three_no_progress_spawns_still_trip_kill() -> None:
"""When there is NO tracing_gap envelope, the strike logic still bites.
@@ -0,0 +1,163 @@
"""Sequence-ordered merge: hold a later sibling's review until earlier ones land.
Leaf siblings share one cell branch, so merging a higher-sequence sibling before
a lower one diverges the branch and wedges the loser. The dispatcher skips a
higher-sequence task while an earlier same-team sibling is still non-terminal
loop-free (not dispatched, not rejected). Terminal siblings never block, so a
cancelled sibling can't deadlock the rest.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.runtime.orchestrator import AgentOrchestrator
def _new_orchestrator() -> AgentOrchestrator:
return AgentOrchestrator.__new__(AgentOrchestrator)
def _sibling(seq: int, team: str, status: TaskStatus) -> MagicMock:
return MagicMock(id=uuid4(), sequence=seq, team=team, status=status)
def _patch_siblings(siblings: list[MagicMock]) -> Any:
"""Patch the orchestrator's direct-DB sibling lookup to return ``siblings``."""
svc = MagicMock()
svc.get_subtasks = AsyncMock(return_value=siblings)
class _CM:
async def __aenter__(self) -> MagicMock:
return MagicMock()
async def __aexit__(self, *_a: Any) -> bool:
return False
factory = MagicMock(return_value=_CM())
return (
patch("roboco.db.base.get_session_factory", return_value=factory),
patch("roboco.services.task.get_task_service", return_value=svc),
)
@pytest.mark.asyncio
async def test_blocks_when_earlier_same_team_sibling_active() -> None:
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 1,
"team": "frontend",
}
siblings = [_sibling(0, "frontend", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_sibling(task) is True
@pytest.mark.asyncio
async def test_not_blocked_when_earlier_sibling_terminal() -> None:
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 1,
"team": "frontend",
}
# Earlier sibling completed -> no longer blocks. (Cancelled likewise.)
siblings = [
_sibling(0, "frontend", TaskStatus.COMPLETED),
_sibling(0, "frontend", TaskStatus.CANCELLED),
]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_not_blocked_by_different_team_sibling() -> None:
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 1,
"team": "frontend",
}
# A backend sibling targets a different branch — never blocks the frontend leaf.
siblings = [_sibling(0, "backend", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_higher_sequence_sibling_does_not_block() -> None:
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 0,
"team": "frontend",
}
# A LATER sibling (seq 1) must not hold up the earlier one (seq 0).
siblings = [_sibling(1, "frontend", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_no_parent_returns_false_without_db() -> None:
orch = _new_orchestrator()
task = {"id": str(uuid4()), "sequence": 0, "team": "frontend"}
# No DB patch: a parentless task must short-circuit before any lookup.
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_db_failure_falls_through_to_dispatch() -> None:
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 1,
"team": "frontend",
}
boom = patch(
"roboco.db.base.get_session_factory", side_effect=RuntimeError("db down")
)
with boom:
# The ordering check must never wedge the dispatcher: degrade to dispatch.
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_dispatch_skips_blocked_sibling(monkeypatch: pytest.MonkeyPatch) -> None:
"""_dispatch_pm_review_work must not spawn a PM for a gated task."""
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 1,
"team": "frontend",
"assigned_to": str(uuid4()),
}
spawn = AsyncMock()
# monkeypatch.setattr keeps mypy's method-assign check satisfied without
# silencing it; the spawn mock is held locally so the assertion is typed.
monkeypatch.setattr(orch, "_fetch_tasks", AsyncMock(return_value=[task]))
monkeypatch.setattr(
orch, "_blocked_by_earlier_sibling", AsyncMock(return_value=True)
)
monkeypatch.setattr(orch, "spawn_agent", spawn)
monkeypatch.setattr(orch, "_resolve_agent_slug", MagicMock(return_value="fe-pm"))
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
await orch._dispatch_pm_review_work(cast("Any", MagicMock()))
spawn.assert_not_awaited()
@@ -0,0 +1,187 @@
"""GitService PR-divergence primitives: rebase_onto_base + close_pull_request.
These back both the sequence-ordered merge (rebase a later sibling onto the
prior one's merged result) and the conflict resolver (rebase a wedged PR,
then close-if-superseded / re-merge / escalate). The classification a rebase
yields superseded vs rebased vs conflicts drives the whole resolution, so
each branch is pinned here against a mocked git.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.git import GitService
def _git_service() -> GitService:
return GitService.__new__(GitService)
def _result(returncode: int = 0, stdout: str = "") -> Any:
return type("R", (), {"returncode": returncode, "stdout": stdout})()
_HEAD = "feature/frontend/root--cell--leaf"
_BASE = "feature/frontend/root--cell"
@pytest.mark.asyncio
async def test_rebase_superseded_when_no_unique_commits() -> None:
"""Clean rebase + zero commits ahead of base => superseded (safe to close)."""
svc = _git_service()
pushed: list[list[str]] = []
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
if args[0] == "push":
pushed.append(args)
if args[:2] == ["rev-list", "--count"]:
return _result(stdout="0\n")
return _result()
with patch.object(svc, "_run_git", new=fake_run):
out = await svc.rebase_onto_base(
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
)
assert out == {"status": "superseded"}
# A superseded branch must NOT be force-pushed — nothing changed.
assert pushed == []
@pytest.mark.asyncio
async def test_rebase_rebased_force_pushes_when_unique_commits() -> None:
"""Clean rebase + commits ahead of base => rebased + force-push the head."""
svc = _git_service()
pushed: list[list[str]] = []
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
if args[0] == "push":
pushed.append(args)
return _result()
if args[:2] == ["rev-list", "--count"]:
return _result(stdout="3\n")
return _result()
with patch.object(svc, "_run_git", new=fake_run):
out = await svc.rebase_onto_base(
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
)
assert out == {"status": "rebased", "unique_commits": 3}
# Only the head branch is force-pushed, with lease, never the base.
assert pushed == [["push", "--force-with-lease", "origin", f"HEAD:{_HEAD}"]]
@pytest.mark.asyncio
async def test_rebase_conflicts_aborts_and_reports_files() -> None:
"""A failed rebase is aborted and the conflicting files reported."""
svc = _git_service()
aborted = False
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
nonlocal aborted
if args == ["rebase", f"origin/{_BASE}"]:
return _result(returncode=1)
if args[:2] == ["diff", "--name-only"]:
return _result(stdout="src/a.tsx\nsrc/b.tsx\n")
if args == ["rebase", "--abort"]:
aborted = True
return _result()
return _result()
with patch.object(svc, "_run_git", new=fake_run):
out = await svc.rebase_onto_base(
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
)
assert out == {"status": "conflicts", "files": ["src/a.tsx", "src/b.tsx"]}
assert aborted is True
@pytest.mark.asyncio
async def test_rebase_never_force_pushes_on_conflict() -> None:
"""Guard: the destructive force-push must not fire when a rebase conflicts."""
svc = _git_service()
pushed: list[list[str]] = []
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
if args[0] == "push":
pushed.append(args)
if args == ["rebase", f"origin/{_BASE}"]:
return _result(returncode=1)
if args[:2] == ["diff", "--name-only"]:
return _result(stdout="")
return _result()
with patch.object(svc, "_run_git", new=fake_run):
await svc.rebase_onto_base(
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
)
assert pushed == []
@pytest.mark.asyncio
async def test_close_pull_request_patches_state_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""close_pull_request issues a PATCH state=closed (and an optional comment)."""
svc = _git_service()
# Stub the task/project/workspace/token/remote resolution chain via
# monkeypatch.setattr (not direct assignment) so mypy's method-assign check
# stays satisfied without silencing it.
task = type("T", (), {"id": "t", "assigned_to": None, "created_by": None})()
session = AsyncMock()
session.execute = AsyncMock(
return_value=type("Res", (), {"scalar_one_or_none": lambda _self: task})()
)
delete_branch = AsyncMock()
monkeypatch.setattr(svc, "session", session, raising=False)
monkeypatch.setattr(
svc,
"_project_for_task",
AsyncMock(return_value=type("P", (), {"slug": "proj"})()),
)
monkeypatch.setattr(
svc, "_resolve_workspace_agent_id", MagicMock(return_value=None)
)
monkeypatch.setattr(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
monkeypatch.setattr(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
)
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
calls: list[tuple[str, str]] = []
class _Resp:
is_success = True
status_code = 200
text = ""
class _Client:
async def __aenter__(self) -> _Client:
return self
async def __aexit__(self, *_a: Any) -> None:
return None
async def post(self, url: str, **_kw: Any) -> _Resp:
calls.append(("POST", url))
return _Resp()
async def patch(self, url: str, **_kw: Any) -> _Resp:
calls.append(("PATCH", url))
return _Resp()
with patch("roboco.services.git.httpx.AsyncClient", return_value=_Client()):
await svc.close_pull_request(159, comment="superseded by #158")
assert (
"POST",
"https://api.github.com/repos/owner/repo/issues/159/comments",
) in calls
assert ("PATCH", "https://api.github.com/repos/owner/repo/pulls/159") in calls
delete_branch.assert_awaited_once()
@@ -0,0 +1,91 @@
"""get_status must not misreport an unstaged deletion as staged.
Porcelain encodes the index (staged) state in column 0 and the worktree state
in column 1. A worktree-only change has a SPACE in column 0 (e.g. " D file" =
unstaged deletion). The old code ran stdout.strip() before splitting, which ate
the leading space on the first line, turning " D file" into "D file" parsed as
a STAGED deletion. That false "staged" caused 6 wasted QA cycles when a dev
deleted a file but had not staged it. Regression test: the deletion must land in
unstaged, never staged.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock
import pytest
from roboco.services.git import GitService
def _git_service() -> GitService:
return GitService.__new__(GitService)
def _result(returncode: int = 0, stdout: str = "") -> Any:
return type("R", (), {"returncode": returncode, "stdout": stdout})()
def _fake_run_with_status(porcelain: str) -> Any:
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
if args[:2] == ["branch", "--show-current"]:
return _result(stdout="feature/x\n")
if args[:2] == ["status", "--porcelain"]:
return _result(stdout=porcelain)
return _result(returncode=1) # ahead/behind rev-list -> treated as 0,0
return fake_run
@pytest.mark.asyncio
async def test_unstaged_deletion_first_line_not_reported_as_staged(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = _git_service()
monkeypatch.setattr(
svc, "_run_git", _fake_run_with_status(" D piragi_patches.py\n")
)
monkeypatch.setattr(svc, "_ahead_behind", AsyncMock(return_value=(0, 0)))
_branch, has_changes, staged, unstaged, _untracked, _a, _b = await svc.get_status(
Path("/tmp/ws")
)
assert "piragi_patches.py" in unstaged
assert "piragi_patches.py" not in staged
assert has_changes is True
@pytest.mark.asyncio
async def test_staged_deletion_still_reported_as_staged(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A genuine staged deletion ('D file') must still read as staged."""
svc = _git_service()
monkeypatch.setattr(svc, "_run_git", _fake_run_with_status("D gone.py\n"))
monkeypatch.setattr(svc, "_ahead_behind", AsyncMock(return_value=(0, 0)))
_branch, _has, staged, unstaged, _untracked, _a, _b = await svc.get_status(
Path("/tmp/ws")
)
assert "gone.py" in staged
assert "gone.py" not in unstaged
@pytest.mark.asyncio
async def test_first_line_unstaged_modify_not_misread(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The strip() bug hit any worktree-only first line, not just deletions."""
svc = _git_service()
monkeypatch.setattr(svc, "_run_git", _fake_run_with_status(" M app.py\n"))
monkeypatch.setattr(svc, "_ahead_behind", AsyncMock(return_value=(0, 0)))
_branch, _has, staged, unstaged, _untracked, _a, _b = await svc.get_status(
Path("/tmp/ws")
)
assert "app.py" in unstaged
assert "app.py" not in staged