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:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user