From 607f3fbd8bf78a4d17d6c181b91d521bcf708088 Mon Sep 17 00:00:00 2001
From: francy51
Date: Wed, 17 Jun 2026 22:07:49 -0400
Subject: [PATCH] feat(kanban): auto-resolve merge-all conflicts with an agent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the merge-all batch hits a conflict, spawn a conflict-resolution
agent in the main worktree (where the merge is mid-conflict), await it,
and continue the batch through the remaining branches instead of
aborting for manual resolution.
- worktrees: replace abort-on-conflict mergeBranches with non-aborting
attemptMerge (leaves the tree mid-merge, returns conflicted paths),
plus abortMerge/hasMergeInProgress/unmergedPaths/createRecoveryBranch.
Batch types gain conflict/resolutionRunId/recoveryRef.
- prompt: buildConflictResolutionPrompt resolves both sides preserving
intent, honors repo conventions (no Rust warning suppression), runs
cargo/pnpm checks, then completes the merge with a commit.
- runs: mergeAll() is async; per branch it attemptMerges, and on conflict
starts a streamable resolution run in REPO_ROOT, awaits it, and
verifies the merge actually completed (no MERGE_HEAD, clean tree) before
continuing — otherwise aborts and halts. A mergeBatchInProgress guard
prevents two batches at once; a pre-batch recovery ref snapshots HEAD.
- route: /runs/merge-all is async; node request/socket timeouts disabled
so a long multi-conflict batch survives.
- UI: per-branch status shows "conflict resolved by agent" + resolution
run id; a recovery-ref banner documents how to roll the batch back.
Safety: resolution only counts if verified; otherwise the merge is
aborted and main left clean. Resolution runs are real agent_runs (stream
into the dock, stoppable) and own no worktree.
EOF && echo "" && git log --oneline -3
---
apps/api/src/index.ts | 11 +-
apps/api/src/orchestrator/prompt.ts | 40 +++++
apps/api/src/orchestrator/runs.ts | 137 ++++++++++++++++--
apps/api/src/orchestrator/worktrees.ts | 123 +++++++++++-----
apps/api/src/routes/orchestrator.ts | 14 +-
.../src/components/kanban/MergeAllModal.tsx | 34 ++++-
apps/docs/src/lib/orchestratorApi.ts | 7 +
apps/docs/src/pages/docs/KanbanBoardPage.tsx | 1 +
8 files changed, 309 insertions(+), 58 deletions(-)
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 350152f..c71379b 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -1,5 +1,6 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
+import type { Server as HttpServer } from 'node:http';
import './db.js'; // importing db runs migrate()
import { db } from './db.js';
import { appendRunEvent } from './orchestrator/events.js';
@@ -40,6 +41,12 @@ app.route('/api/internal', internal);
const port = Number(process.env.PORT ?? 3001);
-serve({ fetch: app.fetch, port }, (info) => {
+const server = serve({ fetch: app.fetch, port }, (info) => {
console.log(`[api] VOID::NAV Kanban API on http://localhost:${info.port}`);
-});
+}) as unknown as HttpServer;
+
+// Merge-all can block for many minutes while conflict-resolution agents work;
+// disable Node's default request/socket timeouts so those long requests survive.
+server.requestTimeout = 0;
+server.headersTimeout = 0;
+server.setTimeout(0);
diff --git a/apps/api/src/orchestrator/prompt.ts b/apps/api/src/orchestrator/prompt.ts
index 98f56a9..f748fbe 100644
--- a/apps/api/src/orchestrator/prompt.ts
+++ b/apps/api/src/orchestrator/prompt.ts
@@ -154,3 +154,43 @@ Begin now. Start by reading the relevant docs and code, then implement.
`;
}
+/**
+ * Prompt for a conflict-resolution agent run. A batch merge of `branch` into
+ * `target` stopped with conflicts; the merge is mid-progress in the main
+ * checkout, so the agent resolves the conflicted files, verifies, and commits
+ * to complete the merge.
+ */
+export function buildConflictResolutionPrompt(
+ target: string,
+ branch: string,
+ conflictedPaths: string[],
+): string {
+ const fileList = conflictedPaths.map((p) => `- ${p}`).join('\n');
+ return `You are resolving git merge conflicts in the VOID::NAV repo. A batch merge of
+the agent branch \`${branch}\` into \`${target}\` stopped with conflicts. The merge
+is IN PROGRESS in this working tree (the main checkout) — conflict markers are
+present in the files below.
+
+## Your job
+1. For each conflicted file, read BOTH sides (the markers show ${target} vs ${branch})
+ and resolve them, preserving the intent of each change. Do not simply discard
+ one side unless it is genuinely obsolete.
+2. Honor repo conventions: in \`apps/game\` (Rust + Bevy) NEVER suppress compiler
+ warnings/errors (no \`#[allow(...)]\`, no \`_unused\` prefixes) — fix the cause.
+ Keep TS types clean in the TS apps.
+3. Verify your resolution builds: in \`apps/game\` run \`cargo check\` (and
+ \`cargo clippy\` if you touched Rust); for TS apps run
+ \`pnpm --filter @void-nav/ check\` from the repo root. Fix anything your
+ resolution broke.
+4. Stage every resolved file and COMPLETE THE MERGE with a commit:
+ \`git add -A\` then \`git commit --no-edit\` (a merge is in progress, so this
+ finalizes the merge commit). Do NOT push.
+5. Stop once the merge commit is made and the working tree is clean.
+
+## Conflicted files
+${fileList || '(none listed — run \\`git status\\` to find them)'}
+
+Resolve the conflicts now, verify, and commit to complete the merge.
+`;
+}
+
diff --git a/apps/api/src/orchestrator/runs.ts b/apps/api/src/orchestrator/runs.ts
index 6c6ecf8..c9f5ba9 100644
--- a/apps/api/src/orchestrator/runs.ts
+++ b/apps/api/src/orchestrator/runs.ts
@@ -12,10 +12,10 @@ import { db, type AgentRunRow } from '../db.js';
import type { AgentRun, AgentRunEvent, Card, RunStatus } from '../types.js';
import { appendRunEvent } from './events.js';
import { newToken, REPO_ROOT } from './config.js';
-import { buildPrompt } from './prompt.js';
+import { buildPrompt, buildConflictResolutionPrompt } from './prompt.js';
import { Runner, type PersistedEvent } from './runner.js';
import { BevyProcess } from './bevy.js';
-import { createWorktree, dirtySummary, headSha, isWorktreePresent, isMergedIntoHead, mergeBranches, removeWorktree, type BatchMergeResult, type Worktree } from './worktrees.js';
+import { attemptMerge, abortMerge, createRecoveryBranch, createWorktree, currentBranch, dirtySummary, hasMergeInProgress, headSha, isMergedIntoHead, isWorktreePresent, mainDirtySummary, removeWorktree, type BatchMergeItem, type BatchMergeResult, type Worktree } from './worktrees.js';
export interface StartRunInput {
cardId: string;
@@ -39,6 +39,8 @@ class RunManager {
private active = new Map();
/** Active Bevy test processes keyed by run id (independent of agent runs). */
private bevy = new Map();
+ /** Guards against two merge-all batches running at once. */
+ private mergeBatchInProgress = false;
/** Create and start a run for a card. Returns the persisted run. */
start(card: Card, input: StartRunInput): AgentRun {
@@ -271,11 +273,23 @@ class RunManager {
* Merge all reviewable agent branches into the main branch sequentially.
*
* Candidates are settled runs that own their worktree and have commits not
- * yet in HEAD, oldest first (so merges land in roughly the order the runs
- * happened). De-dups by branch and skips anything already merged. The actual
- * sequential merge + conflict handling is in `worktrees.mergeBranches`.
+ * yet in HEAD, oldest first. Each branch is attempted with a non-aborting
+ * merge; on conflict a resolution agent is spawned in the main worktree
+ * (where the merge is mid-conflict), awaited, and verified — if it resolves
+ * and commits cleanly the batch continues, otherwise that merge is aborted
+ * and the batch halts. A recovery ref snapshots pre-batch HEAD so the whole
+ * batch is undoable. Only one batch may run at a time.
*/
- mergeAll(): BatchMergeResult {
+ async mergeAll(): Promise {
+ const target = currentBranch();
+ if (this.mergeBatchInProgress) {
+ return { target, items: [], haltedOn: null, aborted: true, reason: 'a merge-all batch is already in progress', recoveryRef: null };
+ }
+ const dirty = mainDirtySummary();
+ if (dirty) {
+ return { target, items: [], haltedOn: null, aborted: true, reason: `Cannot merge: ${dirty}`, recoveryRef: null };
+ }
+
const rows = db
.prepare(
`SELECT * FROM agent_runs
@@ -287,18 +301,73 @@ class RunManager {
.all() as AgentRunRow[];
const seenBranch = new Set();
- const candidates: { branch: string; runId: string }[] = [];
+ const candidates: { branch: string; runId: string; cardId: 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);
- // Skip branches already fully in HEAD — nothing to merge, and including
- // them would let an unrelated failure mask that they're done.
- if (isMergedIntoHead(branch)) continue;
- candidates.push({ branch, runId: row.id });
+ if (isMergedIntoHead(branch)) continue; // nothing to merge
+ candidates.push({ branch, runId: row.id, cardId: row.card_id });
}
- return mergeBranches(candidates);
+ // Snapshot HEAD so the operator can reset the entire batch if a resolution
+ // misfires (`git reset --hard `).
+ const recoveryRef = `merge-all-recovery-${Date.now()}`;
+ createRecoveryBranch(recoveryRef);
+
+ this.mergeBatchInProgress = true;
+ const items: BatchMergeItem[] = [];
+ let haltedOn: string | null = null;
+ let reason: string | null = null;
+
+ try {
+ for (const { branch, runId, cardId } of candidates) {
+ 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 });
+ 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 });
+ break;
+ }
+
+ // Conflict — spin up a resolution agent in the main worktree.
+ const prompt = buildConflictResolutionPrompt(target, branch, attempt.conflictedPaths);
+ const { runId: resRunId, settled } = this.startResolutionRun({ cardId, branch, prompt });
+ const { status, summary } = await settled;
+
+ // Success only if the agent completed AND finalized the merge (no
+ // MERGE_HEAD, clean tree). Otherwise abort and halt so main stays clean.
+ const clean = status === 'completed' && !hasMergeInProgress() && mainDirtySummary() === '';
+ if (clean) {
+ items.push({
+ ok: true,
+ alreadyMerged: false,
+ conflict: true,
+ target,
+ branch,
+ output: `Conflict resolved by agent${summary ? `: ${summary}` : '.'}`,
+ runId,
+ resolutionRunId: resRunId,
+ });
+ continue;
+ }
+ 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 });
+ break;
+ }
+ } finally {
+ this.mergeBatchInProgress = false;
+ }
+
+ return { target, items, haltedOn, aborted: false, reason, recoveryRef };
}
/** Replayable event history for a run, optionally after a sequence number. */
@@ -359,6 +428,50 @@ class RunManager {
return run;
}
+ /**
+ * Spawn a conflict-resolution agent run in the main worktree (where a merge is
+ * mid-conflict) and return a promise that resolves when it settles. The run
+ * is a real, streamable agent_run tied to the conflicted branch's card; the
+ * caller decides success by checking the merge state after settle.
+ */
+ private startResolutionRun(input: {
+ cardId: string;
+ branch: string;
+ prompt: string;
+ }): { runId: string; settled: Promise<{ status: RunStatus; summary: string | null }> } {
+ const id = randomUUID();
+ const token = newToken();
+ const now = new Date().toISOString();
+ db.prepare(
+ `INSERT INTO agent_runs
+ (id, card_id, status, use_worktree, owns_worktree, branch, worktree_path, session_file, prompt, token, created_at, started_at)
+ VALUES (?, ?, 'running', 0, 0, ?, NULL, NULL, ?, ?, ?, ?)`,
+ ).run(id, input.cardId, input.branch, input.prompt, token, now, now);
+
+ let resolveSettled!: (v: { status: RunStatus; summary: string | null }) => void;
+ const settled = new Promise<{ status: RunStatus; summary: string | null }>((resolve) => {
+ resolveSettled = resolve;
+ });
+
+ const runner = new Runner({
+ runId: id,
+ cwd: REPO_ROOT,
+ prompt: input.prompt,
+ onSettled: (status, summary) => {
+ this.settle(id, status, summary);
+ resolveSettled({ status, summary });
+ },
+ onSessionResolved: (sessionFile) => {
+ db.prepare('UPDATE agent_runs SET session_file = ? WHERE id = ?').run(sessionFile, id);
+ },
+ });
+ // Resolution runs own no worktree (they run in the main checkout) and never
+ // clean anything up.
+ this.active.set(id, { runner, worktree: null, cleanup: false });
+ runner.start();
+ return { runId: id, settled };
+ }
+
private settle(id: string, status: RunStatus, summary: string | null): void {
const entry = this.active.get(id);
const now = new Date().toISOString();
diff --git a/apps/api/src/orchestrator/worktrees.ts b/apps/api/src/orchestrator/worktrees.ts
index 95c079f..7380601 100644
--- a/apps/api/src/orchestrator/worktrees.ts
+++ b/apps/api/src/orchestrator/worktrees.ts
@@ -181,6 +181,10 @@ export interface MergeResult {
export interface BatchMergeItem extends MergeResult {
/** The run this branch belonged to (for UI correlation). */
runId: string;
+ /** True if this branch conflicted (whether or not resolution succeeded). */
+ conflict: boolean;
+ /** When an agent resolved a conflict for this branch, its run id; else null. */
+ resolutionRunId: string | null;
}
/** Result of merging several branches sequentially into the main branch. */
@@ -195,53 +199,100 @@ export interface BatchMergeResult {
aborted: boolean;
/** Human-readable reason when `aborted` or `haltedOn`. */
reason: string | null;
+ /** Branch ref snapshotting HEAD before the batch, so the operator can reset
+ * the whole batch (`git reset --hard `) if a resolution misfires. */
+ recoveryRef: string | null;
+}
+
+/** Whether a merge is currently in progress in the main worktree (MERGE_HEAD). */
+export function hasMergeInProgress(): boolean {
+ try {
+ execSync('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT, stdio: 'ignore' });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Files with unresolved merge conflicts in the main worktree. */
+export function unmergedPaths(): string[] {
+ try {
+ const out = git(['diff', '--name-only', '--diff-filter=U']);
+ return out ? out.split('\n').filter(Boolean) : [];
+ } catch {
+ return [];
+ }
+}
+
+/** Abort an in-progress merge in the main worktree (best-effort). */
+export function abortMerge(): void {
+ try {
+ execSync('git merge --abort', { cwd: REPO_ROOT, stdio: 'ignore' });
+ } catch {
+ /* not mid-merge */
+ }
+}
+
+/** Outcome of a non-aborting merge attempt. */
+export interface AttemptMergeResult {
+ /** Clean merge committed, or branch was already in HEAD. */
+ ok: boolean;
+ alreadyMerged: boolean;
+ /** True when the merge stopped with conflicts (tree left mid-merge). */
+ conflict: boolean;
+ /** Conflicted file paths when `conflict`. */
+ conflictedPaths: string[];
+ output: string;
}
/**
- * Merge multiple agent branches into the main worktree's checked-out branch,
- * sequentially. Each successful merge advances HEAD, so later branches merge
- * against the accumulated result.
+ * Attempt to merge `branch` into the main worktree WITHOUT aborting on conflict.
*
- * Safety: refuses outright if the main tree is dirty. On the first conflict,
- * aborts that merge (leaving the main tree clean), records it as `haltedOn`,
- * and stops — branches after it are NOT attempted, and prior successful merges
- * stay in place. The caller should surface `haltedOn` + remaining branches so
- * the operator can resolve or re-run after fixing the conflict.
- *
- * @param candidates branches to merge, in order, each tagged with its run id.
- * Already-merged branches are reported as no-ops and skipped (never halt).
+ * On conflict the main tree is left mid-merge (MERGE_HEAD present, conflict
+ * markers in files) so a conflict-resolution agent can work on it; the caller
+ * must then either complete it or call `abortMerge()`. Returns the conflicted
+ * paths so the resolver can be pointed at them. Non-conflict failures are
+ * aborted so the tree isn't left half-merged.
*/
-export function mergeBranches(candidates: { branch: string; runId: string }[]): BatchMergeResult {
+export function attemptMerge(branch: string): AttemptMergeResult {
const target = currentBranch();
- const dirty = mainDirtySummary();
- if (dirty) {
+ if (isMergedIntoHead(branch)) {
+ return { ok: true, alreadyMerged: true, conflict: false, conflictedPaths: [], output: `${branch} is already in ${target}.` };
+ }
+ try {
+ const out = execSync(
+ `git merge --no-ff --no-edit -m ${JSON.stringify(`Merge agent branch ${branch} into ${target}`)} ${branch}`,
+ { encoding: 'utf8', cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
+ );
+ return { ok: true, alreadyMerged: false, conflict: false, conflictedPaths: [], output: out.trim() || `Merged ${branch} into ${target}.` };
+ } catch (err) {
+ const stderr = (err as { stderr?: Buffer }).stderr?.toString().trim() ?? '';
+ const conflictedPaths = unmergedPaths();
+ // A genuine conflict leaves the tree mid-merge for the resolver; anything
+ // else is an outright failure, so clean up so the tree isn't half-merged.
+ if (conflictedPaths.length === 0) {
+ abortMerge();
+ return { ok: false, alreadyMerged: false, conflict: false, conflictedPaths: [], output: stderr || `Merge of ${branch} failed (aborted; ${target} left clean).` };
+ }
return {
- target,
- items: [],
- haltedOn: null,
- aborted: true,
- reason: `Cannot merge: ${dirty}`,
+ ok: false,
+ alreadyMerged: false,
+ conflict: true,
+ conflictedPaths,
+ output: stderr || `Merge of ${branch} conflicted in ${conflictedPaths.length} file(s).`,
};
}
-
- const items: BatchMergeItem[] = [];
- let haltedOn: string | null = null;
- let reason: string | null = null;
-
- for (const { branch, runId } of candidates) {
- const result = mergeBranch(branch);
- items.push({ ...result, runId });
- if (!result.ok) {
- // A conflict aborts the batch: stop here, leaving earlier merges in place.
- haltedOn = branch;
- reason = `Conflict merging ${branch} — aborted, ${target} left clean.`;
- break;
- }
- }
-
- return { target, items, haltedOn, aborted: false, reason };
}
+/** Snapshot HEAD into a branch ref as a recovery checkpoint. */
+export function createRecoveryBranch(name: string): void {
+ execSync(`git update-ref refs/heads/${name} HEAD`, { cwd: REPO_ROOT, stdio: 'ignore' });
+}
+
+// Batch merge orchestration lives in the run manager (`runs.ts`), which can
+// spawn conflict-resolution agents between attempts. See `attemptMerge` above
+// for the non-aborting primitive it builds on.
+
/**
* Merge an agent branch into the main worktree's checked-out branch.
*
diff --git a/apps/api/src/routes/orchestrator.ts b/apps/api/src/routes/orchestrator.ts
index 0ead666..990731b 100644
--- a/apps/api/src/routes/orchestrator.ts
+++ b/apps/api/src/routes/orchestrator.ts
@@ -144,8 +144,18 @@ orchestrator.post('/runs/:id/merge', (c) => {
return c.json(mergeBranch(run.branch));
});
-/** Merge all reviewable agent branches into the main branch sequentially. */
-orchestrator.post('/runs/merge-all', (c) => c.json(runManager.mergeAll()));
+/** Merge all reviewable agent branches into the main branch sequentially,
+ * spawning a conflict-resolution agent on any conflict. May block for a long
+ * time while agents resolve conflicts. */
+orchestrator.post('/runs/merge-all', async (c) => {
+ try {
+ const result = await runManager.mergeAll();
+ return c.json(result);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'merge-all failed';
+ return c.json({ error: message }, 409);
+ }
+});
/** Whether a Bevy playtest is running for a run. */
orchestrator.get('/runs/:id/bevy', (c) => {
diff --git a/apps/docs/src/components/kanban/MergeAllModal.tsx b/apps/docs/src/components/kanban/MergeAllModal.tsx
index 2f4bead..22cfe5d 100644
--- a/apps/docs/src/components/kanban/MergeAllModal.tsx
+++ b/apps/docs/src/components/kanban/MergeAllModal.tsx
@@ -52,6 +52,8 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
Target branch: {result.target}
{' · '}
{result.items.filter((i) => i.ok && !i.alreadyMerged).length} merged
+ {result.items.some((i) => i.conflict && i.ok) &&
+ ` · ${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.haltedOn && ' · halted on conflict'}
@@ -63,8 +65,9 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
{result.haltedOn && (
- ⚠ Conflict merging {result.haltedOn} — batch stopped,{' '}
- {result.target} left clean. Resolve or drop that branch, then re-run.
+ ⚠ Conflict merging {result.haltedOn} could not be
+ resolved automatically — batch stopped, {result.target} left clean. Resolve or drop
+ that branch, then re-run.
)}
@@ -80,11 +83,27 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
)}
+ {result.recoveryRef && (
+
+ Recovery ref created at pre-batch HEAD:{' '}
+ {result.recoveryRef}. Roll back the whole batch with{' '}
+ git reset --hard {result.recoveryRef}.
+
+ )}
+
{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'
+ : item.ok
+ ? 'merged'
+ : 'conflict';
+ const glyph = item.alreadyMerged ? '⇢' : item.conflict && item.ok ? '🤖' : item.ok ? '✓' : '⚠';
return (
- {item.alreadyMerged ? '⇢' : item.ok ? '✓' : '⚠'}
+ {glyph}
{item.branch}
-
- {item.alreadyMerged ? 'already in target' : item.ok ? 'merged' : 'conflict'}
-
+ {label}
+ {item.resolutionRunId && (
+
+ ↳ {item.resolutionRunId.slice(0, 8)}
+
+ )}
{!item.ok && item.output && (
`) if a resolution misfires. */
+ recoveryRef: string | null;
}
export const orchestratorApi = {
diff --git a/apps/docs/src/pages/docs/KanbanBoardPage.tsx b/apps/docs/src/pages/docs/KanbanBoardPage.tsx
index 19d1b9d..2341cb6 100644
--- a/apps/docs/src/pages/docs/KanbanBoardPage.tsx
+++ b/apps/docs/src/pages/docs/KanbanBoardPage.tsx
@@ -72,6 +72,7 @@ export function KanbanBoardPage() {
haltedOn: null,
aborted: true,
reason: e instanceof Error ? e.message : 'merge-all failed',
+ recoveryRef: null,
});
} finally {
setMergeAllBusy(false);