feat(kanban): auto-resolve merge-all conflicts with an agent
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / TypeScript Check (docs) (push) Has been cancelled
CI / TypeScript Check (site) (push) Has been cancelled
CI / Security Audit (push) Has been cancelled

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
This commit is contained in:
2026-06-17 22:07:49 -04:00
parent d538ccdd4e
commit 607f3fbd8b
8 changed files with 309 additions and 58 deletions

View File

@@ -52,6 +52,8 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
Target branch: <code className="font-mono text-cyan">{result.target}</code>
{' · '}
{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 && (
<div style={bannerStyle('var(--red)')}>
Conflict merging <code style={monoStyle}>{result.haltedOn}</code> batch stopped,{' '}
{result.target} left clean. Resolve or drop that branch, then re-run.
Conflict merging <code style={monoStyle}>{result.haltedOn}</code> could not be
resolved automatically batch stopped, {result.target} left clean. Resolve or drop
that branch, then re-run.
</div>
)}
@@ -80,11 +83,27 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
</p>
)}
{result.recoveryRef && (
<div style={bannerStyle('var(--fg-dim)')}>
Recovery ref created at pre-batch HEAD:{' '}
<code style={monoStyle}>{result.recoveryRef}</code>. Roll back the whole batch with{' '}
<code style={monoStyle}>git reset --hard {result.recoveryRef}</code>.
</div>
)}
{result.items.length > 0 && (
<div className="flex flex-col gap-2">
{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 (
<div
key={item.runId}
@@ -97,12 +116,15 @@ export function MergeAllModal({ result, busy, onClose }: MergeAllModalProps) {
>
<div className="flex items-center gap-2">
<span style={{ color: tone, fontWeight: 600, fontSize: '0.8rem' }}>
{item.alreadyMerged ? '⇢' : item.ok ? '✓' : '⚠'}
{glyph}
</span>
<code style={monoStyle}>{item.branch}</code>
<span className="text-[0.7rem] text-muted">
{item.alreadyMerged ? 'already in target' : item.ok ? 'merged' : 'conflict'}
</span>
<span className="text-[0.7rem] text-muted">{label}</span>
{item.resolutionRunId && (
<span className="text-[0.62rem] text-muted" title={item.resolutionRunId}>
{item.resolutionRunId.slice(0, 8)}
</span>
)}
</div>
{!item.ok && item.output && (
<pre

View File

@@ -117,6 +117,10 @@ export interface MergeResult {
export interface BatchMergeItem extends MergeResult {
/** The run this branch belonged to. */
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. */
@@ -130,6 +134,9 @@ export interface BatchMergeResult {
aborted: boolean;
/** Human-readable reason when aborted or halted. */
reason: string | null;
/** Branch ref snapshotting HEAD before the batch, so the operator can reset
* the whole batch (`git reset --hard <recoveryRef>`) if a resolution misfires. */
recoveryRef: string | null;
}
export const orchestratorApi = {

View File

@@ -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);