From a487ba2b9553c04430b5c758247e3b5ef4bcbefb Mon Sep 17 00:00:00 2001 From: francy51 Date: Wed, 17 Jun 2026 22:20:28 -0400 Subject: [PATCH] fix(kanban): skip gone branches in merge-all instead of halting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge-all halted with "merge: - not something we can merge" when a candidate's branch ref no longer resolved (worktree+branch cleaned up out-of-band while the run row lingered with branch set). That's not a conflict — there's nothing for a resolution agent to do — yet it stopped the whole batch. - Verify each candidate resolves to a commit (resolveRef) before attempting; non-resolving branches are skipped (⤳) and the batch continues. - Null the stale branch on the run row so it stops reappearing as a candidate. - BatchMergeItem gains `skipped`; the modal shows skipped branches and counts them in the summary. EOF && echo "" && git push 2>&1 | tail -4 --- apps/api/src/orchestrator/runs.ts | 33 ++++++++++++++--- apps/api/src/orchestrator/worktrees.ts | 17 +++++++++ .../src/components/kanban/MergeAllModal.tsx | 35 ++++++++++++++----- apps/docs/src/lib/orchestratorApi.ts | 3 ++ 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/apps/api/src/orchestrator/runs.ts b/apps/api/src/orchestrator/runs.ts index c9f5ba9..5c3b3be 100644 --- a/apps/api/src/orchestrator/runs.ts +++ b/apps/api/src/orchestrator/runs.ts @@ -15,7 +15,7 @@ import { newToken, REPO_ROOT } from './config.js'; import { buildPrompt, buildConflictResolutionPrompt } from './prompt.js'; import { Runner, type PersistedEvent } from './runner.js'; import { BevyProcess } from './bevy.js'; -import { attemptMerge, abortMerge, createRecoveryBranch, createWorktree, currentBranch, dirtySummary, hasMergeInProgress, headSha, isMergedIntoHead, isWorktreePresent, mainDirtySummary, removeWorktree, type BatchMergeItem, type BatchMergeResult, type Worktree } from './worktrees.js'; +import { attemptMerge, abortMerge, createRecoveryBranch, createWorktree, currentBranch, dirtySummary, hasMergeInProgress, headSha, isMergedIntoHead, isWorktreePresent, mainDirtySummary, removeWorktree, resolveRef, type BatchMergeItem, type BatchMergeResult, type Worktree } from './worktrees.js'; export interface StartRunInput { cardId: string; @@ -302,13 +302,25 @@ class RunManager { const seenBranch = new Set(); const candidates: { branch: string; runId: string; cardId: string }[] = []; + const stale: { branch: string; runId: string }[] = []; for (const row of rows) { const branch = row.branch!; if (seenBranch.has(branch)) continue; // dedup (e.g. multiple runs same branch) seenBranch.add(branch); + // A branch that no longer resolves to a commit means the worktree+branch + // were cleaned up out-of-band but the row lingered. Skip it (don't halt), + // and null the stale branch so it stops reappearing as a candidate. + if (!resolveRef(branch)) { + stale.push({ branch, runId: row.id }); + continue; + } if (isMergedIntoHead(branch)) continue; // nothing to merge candidates.push({ branch, runId: row.id, cardId: row.card_id }); } + if (stale.length > 0) { + const setNull = stale.map((s) => '?').join(','); + db.prepare(`UPDATE agent_runs SET branch = NULL WHERE id IN (${setNull})`).run(...stale.map((s) => s.runId)); + } // Snapshot HEAD so the operator can reset the entire batch if a resolution // misfires (`git reset --hard `). @@ -316,7 +328,17 @@ class RunManager { createRecoveryBranch(recoveryRef); this.mergeBatchInProgress = true; - const items: BatchMergeItem[] = []; + const items: BatchMergeItem[] = stale.map(({ branch, runId }) => ({ + ok: false, + alreadyMerged: false, + conflict: false, + skipped: true, + target, + branch, + output: 'branch no longer resolves (worktree cleaned up) — skipped', + runId, + resolutionRunId: null, + })); let haltedOn: string | null = null; let reason: string | null = null; @@ -325,14 +347,14 @@ class RunManager { const attempt = attemptMerge(branch); if (attempt.ok) { // Clean merge (or already-merged no-op) — nothing more to do. - items.push({ ok: true, alreadyMerged: attempt.alreadyMerged, conflict: false, target, branch, output: attempt.output, runId, resolutionRunId: null }); + items.push({ ok: true, alreadyMerged: attempt.alreadyMerged, conflict: false, skipped: false, target, branch, output: attempt.output, runId, resolutionRunId: null }); continue; } if (!attempt.conflict) { // Non-conflict failure (e.g. dirty mid-batch): halt, tree already clean. haltedOn = branch; reason = attempt.output; - items.push({ ok: false, alreadyMerged: false, conflict: false, target, branch, output: attempt.output, runId, resolutionRunId: null }); + items.push({ ok: false, alreadyMerged: false, conflict: false, skipped: false, target, branch, output: attempt.output, runId, resolutionRunId: null }); break; } @@ -349,6 +371,7 @@ class RunManager { ok: true, alreadyMerged: false, conflict: true, + skipped: false, target, branch, output: `Conflict resolved by agent${summary ? `: ${summary}` : '.'}`, @@ -360,7 +383,7 @@ class RunManager { abortMerge(); haltedOn = branch; reason = `Conflict resolution agent did not complete the merge (status: ${status}). Aborted; ${target} left clean.`; - items.push({ ok: false, alreadyMerged: false, conflict: true, target, branch, output: reason, runId, resolutionRunId: resRunId }); + items.push({ ok: false, alreadyMerged: false, conflict: true, skipped: false, target, branch, output: reason, runId, resolutionRunId: resRunId }); break; } } finally { diff --git a/apps/api/src/orchestrator/worktrees.ts b/apps/api/src/orchestrator/worktrees.ts index 7380601..d280b74 100644 --- a/apps/api/src/orchestrator/worktrees.ts +++ b/apps/api/src/orchestrator/worktrees.ts @@ -185,6 +185,10 @@ export interface BatchMergeItem extends MergeResult { conflict: boolean; /** When an agent resolved a conflict for this branch, its run id; else null. */ resolutionRunId: string | null; + /** True if the branch couldn't be merged because it no longer resolves to a + * commit (worktree/branch cleaned up but the run row lingered). Such branches + * are skipped — the batch continues — rather than halting the whole run. */ + skipped: boolean; } /** Result of merging several branches sequentially into the main branch. */ @@ -224,6 +228,19 @@ export function unmergedPaths(): string[] { } } +/** + * Resolve a ref to its commit SHA, or null if it doesn't resolve (branch deleted, + * never created, dangling). Used to skip stale run records whose worktree+branch + * were cleaned up out-of-band but whose row still carries a `branch` value. + */ +export function resolveRef(ref: string): string | null { + try { + return git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); + } catch { + return null; + } +} + /** Abort an in-progress merge in the main worktree (best-effort). */ export function abortMerge(): void { try { diff --git a/apps/docs/src/components/kanban/MergeAllModal.tsx b/apps/docs/src/components/kanban/MergeAllModal.tsx index 22cfe5d..7b2c371 100644 --- a/apps/docs/src/components/kanban/MergeAllModal.tsx +++ b/apps/docs/src/components/kanban/MergeAllModal.tsx @@ -56,6 +56,8 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) { ` · ${result.items.filter((i) => i.conflict && i.ok).length} conflicts resolved by agent`} {result.items.some((i) => i.alreadyMerged) && ` · ${result.items.filter((i) => i.alreadyMerged).length} already in`} + {result.items.some((i) => i.skipped) && + ` · ${result.items.filter((i) => i.skipped).length} skipped`} {result.haltedOn && ' · halted on conflict'}

@@ -94,16 +96,31 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) { {result.items.length > 0 && (
{result.items.map((item) => { - const tone = - item.alreadyMerged ? 'var(--muted)' : item.ok ? 'var(--green)' : 'var(--red)'; - const label = item.alreadyMerged - ? 'already in target' - : item.conflict && item.ok - ? 'conflict resolved by agent' + const tone = item.skipped + ? 'var(--muted)' + : item.alreadyMerged + ? 'var(--muted)' : item.ok - ? 'merged' - : 'conflict'; - const glyph = item.alreadyMerged ? '⇢' : item.conflict && item.ok ? '🤖' : item.ok ? '✓' : '⚠'; + ? 'var(--green)' + : 'var(--red)'; + const label = item.skipped + ? 'skipped (branch gone)' + : item.alreadyMerged + ? 'already in target' + : item.conflict && item.ok + ? 'conflict resolved by agent' + : item.ok + ? 'merged' + : 'conflict'; + const glyph = item.skipped + ? '⤳' + : item.alreadyMerged + ? '⇢' + : item.conflict && item.ok + ? '🤖' + : item.ok + ? '✓' + : '⚠'; return (