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 (