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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user