feat(kanban): auto-resolve merge-all conflicts with an agent
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:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user