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
+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>
);