fix(kanban): skip gone branches in merge-all instead of halting
merge-all halted with "merge: <branch> - 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
This commit is contained in:
@@ -15,7 +15,7 @@ import { newToken, REPO_ROOT } from './config.js';
|
|||||||
import { buildPrompt, buildConflictResolutionPrompt } from './prompt.js';
|
import { buildPrompt, buildConflictResolutionPrompt } from './prompt.js';
|
||||||
import { Runner, type PersistedEvent } from './runner.js';
|
import { Runner, type PersistedEvent } from './runner.js';
|
||||||
import { BevyProcess } from './bevy.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 {
|
export interface StartRunInput {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@@ -302,13 +302,25 @@ class RunManager {
|
|||||||
|
|
||||||
const seenBranch = new Set<string>();
|
const seenBranch = new Set<string>();
|
||||||
const candidates: { branch: string; runId: string; cardId: string }[] = [];
|
const candidates: { branch: string; runId: string; cardId: string }[] = [];
|
||||||
|
const stale: { branch: string; runId: string }[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const branch = row.branch!;
|
const branch = row.branch!;
|
||||||
if (seenBranch.has(branch)) continue; // dedup (e.g. multiple runs same branch)
|
if (seenBranch.has(branch)) continue; // dedup (e.g. multiple runs same branch)
|
||||||
seenBranch.add(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
|
if (isMergedIntoHead(branch)) continue; // nothing to merge
|
||||||
candidates.push({ branch, runId: row.id, cardId: row.card_id });
|
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
|
// Snapshot HEAD so the operator can reset the entire batch if a resolution
|
||||||
// misfires (`git reset --hard <recoveryRef>`).
|
// misfires (`git reset --hard <recoveryRef>`).
|
||||||
@@ -316,7 +328,17 @@ class RunManager {
|
|||||||
createRecoveryBranch(recoveryRef);
|
createRecoveryBranch(recoveryRef);
|
||||||
|
|
||||||
this.mergeBatchInProgress = true;
|
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 haltedOn: string | null = null;
|
||||||
let reason: string | null = null;
|
let reason: string | null = null;
|
||||||
|
|
||||||
@@ -325,14 +347,14 @@ class RunManager {
|
|||||||
const attempt = attemptMerge(branch);
|
const attempt = attemptMerge(branch);
|
||||||
if (attempt.ok) {
|
if (attempt.ok) {
|
||||||
// Clean merge (or already-merged no-op) — nothing more to do.
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
if (!attempt.conflict) {
|
if (!attempt.conflict) {
|
||||||
// Non-conflict failure (e.g. dirty mid-batch): halt, tree already clean.
|
// Non-conflict failure (e.g. dirty mid-batch): halt, tree already clean.
|
||||||
haltedOn = branch;
|
haltedOn = branch;
|
||||||
reason = attempt.output;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +371,7 @@ class RunManager {
|
|||||||
ok: true,
|
ok: true,
|
||||||
alreadyMerged: false,
|
alreadyMerged: false,
|
||||||
conflict: true,
|
conflict: true,
|
||||||
|
skipped: false,
|
||||||
target,
|
target,
|
||||||
branch,
|
branch,
|
||||||
output: `Conflict resolved by agent${summary ? `: ${summary}` : '.'}`,
|
output: `Conflict resolved by agent${summary ? `: ${summary}` : '.'}`,
|
||||||
@@ -360,7 +383,7 @@ class RunManager {
|
|||||||
abortMerge();
|
abortMerge();
|
||||||
haltedOn = branch;
|
haltedOn = branch;
|
||||||
reason = `Conflict resolution agent did not complete the merge (status: ${status}). Aborted; ${target} left clean.`;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ export interface BatchMergeItem extends MergeResult {
|
|||||||
conflict: boolean;
|
conflict: boolean;
|
||||||
/** When an agent resolved a conflict for this branch, its run id; else null. */
|
/** When an agent resolved a conflict for this branch, its run id; else null. */
|
||||||
resolutionRunId: string | 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. */
|
/** 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). */
|
/** Abort an in-progress merge in the main worktree (best-effort). */
|
||||||
export function abortMerge(): void {
|
export function abortMerge(): void {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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.filter((i) => i.conflict && i.ok).length} conflicts resolved by agent`}
|
||||||
{result.items.some((i) => i.alreadyMerged) &&
|
{result.items.some((i) => i.alreadyMerged) &&
|
||||||
` · ${result.items.filter((i) => i.alreadyMerged).length} already in`}
|
` · ${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'}
|
{result.haltedOn && ' · halted on conflict'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -94,16 +96,31 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
|
|||||||
{result.items.length > 0 && (
|
{result.items.length > 0 && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{result.items.map((item) => {
|
{result.items.map((item) => {
|
||||||
const tone =
|
const tone = item.skipped
|
||||||
item.alreadyMerged ? 'var(--muted)' : item.ok ? 'var(--green)' : 'var(--red)';
|
? 'var(--muted)'
|
||||||
const label = item.alreadyMerged
|
: item.alreadyMerged
|
||||||
? 'already in target'
|
? 'var(--muted)'
|
||||||
: item.conflict && item.ok
|
|
||||||
? 'conflict resolved by agent'
|
|
||||||
: item.ok
|
: item.ok
|
||||||
? 'merged'
|
? 'var(--green)'
|
||||||
: 'conflict';
|
: 'var(--red)';
|
||||||
const glyph = item.alreadyMerged ? '⇢' : item.conflict && item.ok ? '🤖' : item.ok ? '✓' : '⚠';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.runId}
|
key={item.runId}
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ export interface BatchMergeItem extends MergeResult {
|
|||||||
conflict: boolean;
|
conflict: boolean;
|
||||||
/** When an agent resolved a conflict for this branch, its run id; else null. */
|
/** When an agent resolved a conflict for this branch, its run id; else null. */
|
||||||
resolutionRunId: string | null;
|
resolutionRunId: string | null;
|
||||||
|
/** True if the branch no longer resolves (cleaned up but the row lingered) —
|
||||||
|
* skipped, batch continues. */
|
||||||
|
skipped: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of merging several branches sequentially into the main branch. */
|
/** Result of merging several branches sequentially into the main branch. */
|
||||||
|
|||||||
Reference in New Issue
Block a user