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