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

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

View File

@@ -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/<pkg> 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.
`;
}

View File

@@ -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<string, { runner: Runner; worktree: Worktree | null; cleanup: boolean }>();
/** Active Bevy test processes keyed by run id (independent of agent runs). */
private bevy = new Map<string, BevyProcess>();
/** 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<BatchMergeResult> {
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<string>();
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 <recoveryRef>`).
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();

View File

@@ -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 <recoveryRef>`) 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.
*

View File

@@ -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) => {

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