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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user