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 (