feat: auto-resolve merge conflicts via fix-merge LLM session

When auto-mode merges a completed slice and hits code conflicts in
non-.gsd files, dispatch a fix-merge session to resolve them instead
of hard-resetting and stopping. This eliminates the #1 cause of
unnecessary auto-mode stops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-13 23:12:08 -06:00
parent 4924bbe6b2
commit a68fa00b4c
4 changed files with 276 additions and 18 deletions

View file

@ -64,12 +64,13 @@ import {
ensureSliceBranch,
getCurrentBranch,
getMainBranch,
MergeConflictError,
parseSliceBranch,
setActiveMilestoneId,
switchToMain,
mergeSliceToMain,
} from "./worktree.ts";
import { GitServiceImpl } from "./git-service.ts";
import { GitServiceImpl, runGit } from "./git-service.ts";
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.ts";
import type { GitPreferences } from "./git-service.ts";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
@ -696,6 +697,7 @@ function unitVerb(unitType: string): string {
case "replan-slice": return "replanning";
case "reassess-roadmap": return "reassessing";
case "run-uat": return "running UAT";
case "fix-merge": return "resolving conflicts";
default: return unitType;
}
}
@ -711,6 +713,7 @@ function unitPhaseLabel(unitType: string): string {
case "replan-slice": return "REPLAN";
case "reassess-roadmap": return "REASSESS";
case "run-uat": return "UAT";
case "fix-merge": return "MERGE-FIX";
default: return unitType.toUpperCase();
}
}
@ -727,6 +730,7 @@ function peekNext(unitType: string, state: GSDState): string {
case "replan-slice": return `re-execute ${sid}`;
case "reassess-roadmap": return "advance to next slice";
case "run-uat": return "reassess roadmap";
case "fix-merge": return "continue merge";
default: return "";
}
}
@ -1042,6 +1046,48 @@ async function dispatchNextUnit(
return;
}
// ── Mid-merge safety check: detect leftover state from a prior fix-merge session ──
// If MERGE_HEAD or SQUASH_MSG exists, a fix-merge session ran previously.
// Check whether it succeeded (no unmerged entries → finalize) or failed (still conflicted → reset + stop).
{
const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
const hasMergeHead = existsSync(mergeHeadPath);
const hasSquashMsg = existsSync(squashMsgPath);
if (hasMergeHead || hasSquashMsg) {
const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (!unmerged || !unmerged.trim()) {
// fix-merge succeeded — finalize the commit if needed (squash case)
if (hasSquashMsg && !hasMergeHead) {
try {
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
ctx.ui.notify("Fix-merge session succeeded — finalized squash commit.", "info");
} catch {
// Commit may already exist; non-fatal
}
}
// Re-derive state from the now-merged working tree
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
} else {
// fix-merge failed — still has unresolved conflicts, reset and stop
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
ctx.ui.notify(
"Fix-merge session failed to resolve all conflicts. Working tree reset. Fix conflicts manually and restart.",
"error",
);
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
return;
}
}
}
// ── General merge guard: merge completed slice branches before advancing ──
// If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
// merge to main before dispatching the next unit. This handles:
@ -1078,15 +1124,45 @@ async function dispatchNextUnit(
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// MergeConflictError: dispatch a fix-merge session to resolve conflicts
if (error instanceof MergeConflictError) {
const fixMergeUnitId = `${parsedBranch.milestoneId}/${parsedBranch.sliceId}`;
const fixMergePrompt = buildFixMergePrompt(error);
ctx.ui.notify(
`Merge conflict in ${error.conflictedFiles.length} file(s) — dispatching fix-merge session.`,
"warning",
);
// Safety net: if mergeSliceToMain failed to clean up (or the error
// came from switchToMain), ensure the working tree isn't left in a
// conflicted/dirty merge state. Without this, state derivation reads
// conflict-marker-filled files, produces a corrupt phase, and
// dispatch loops forever (see: merge-bug-fix).
// Dispatch fix-merge as the next unit (early-dispatch-and-return)
const fixMergeUnitType = "fix-merge";
currentUnit = { type: fixMergeUnitType, id: fixMergeUnitId, startedAt: Date.now() };
writeUnitRuntimeRecord(basePath, fixMergeUnitType, fixMergeUnitId, currentUnit.startedAt, {
phase: "dispatched",
wrapupWarningSent: false,
timeoutAt: null,
lastProgressAt: currentUnit.startedAt,
progressCount: 0,
lastProgressKind: "dispatch",
});
updateProgressWidget(ctx, fixMergeUnitType, fixMergeUnitId, state);
const result = await cmdCtx!.newSession();
if (result.cancelled) {
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
await stopAuto(ctx, pi);
return;
}
const sessionFile = ctx.sessionManager.getSessionFile();
writeLock(basePath, fixMergeUnitType, fixMergeUnitId, completedUnits.length, sessionFile);
pi.sendMessage(
{ customType: "gsd-auto", content: fixMergePrompt, display: verbose },
{ triggerTurn: true },
);
return;
}
// Non-conflict errors: reset and stop
const message = error instanceof Error ? error.message : String(error);
try {
const { runGit } = await import("./git-service.ts");
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
@ -2276,6 +2352,45 @@ async function buildReassessRoadmapPrompt(
});
}
/**
* Build a prompt for the fix-merge LLM session that resolves merge conflicts.
*/
function buildFixMergePrompt(err: MergeConflictError): string {
const strategyLabel = err.strategy === "merge" ? "merge --no-ff" : "squash merge";
const fileList = err.conflictedFiles.map(f => ` - \`${f}\``).join("\n");
return [
`# Fix Merge Conflicts`,
``,
`A ${strategyLabel} of branch \`${err.branch}\` into \`${err.mainBranch}\` produced conflicts in the following files:`,
``,
fileList,
``,
`## Instructions`,
``,
`1. Read each conflicted file listed above`,
`2. Resolve all conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) by choosing the correct content`,
`3. Stage the resolved files with \`git add <file>\``,
`4. Commit the resolution:`,
err.strategy === "squash"
? ` - This is a squash merge, so run: \`git commit --no-edit\` (the squash message is already prepared)`
: ` - This is a --no-ff merge, so run: \`git commit --no-edit\` (the merge message is already prepared)`,
``,
`## Rules`,
``,
`- Do NOT run \`git merge --abort\` or \`git reset\``,
`- Do NOT modify any files other than the conflicted ones listed above`,
`- Preserve the intent of both sides of the conflict — prefer the slice branch changes when the intent is unclear`,
``,
`## Verification`,
``,
`After committing, verify:`,
`1. \`git diff --name-only --diff-filter=U\` returns empty (no unmerged files)`,
`2. The conflicted files no longer contain any \`<<<<<<<\`, \`=======\`, or \`>>>>>>>\` markers`,
`3. \`git status\` shows a clean working tree`,
].join("\n");
}
function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
if (!content) {
return [
@ -2850,6 +2965,8 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
const dir = resolveMilestonePath(base, mid);
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
}
case "fix-merge":
return null;
default:
return null;
}
@ -2864,7 +2981,16 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
* the summary allowed the unit to be marked complete when the LLM
* skipped writing the UAT file (see #176).
*/
function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
// fix-merge has no file artifact — verify by checking git state
if (unitType === "fix-merge") {
const unmerged = runGit(base, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (unmerged && unmerged.trim()) return false;
if (existsSync(join(base, ".git", "MERGE_HEAD"))) return false;
if (existsSync(join(base, ".git", "SQUASH_MSG"))) return false;
return true;
}
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
if (!absPath) return true;
if (!existsSync(absPath)) return false;
@ -2953,6 +3079,8 @@ function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string
return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
case "complete-milestone":
return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
case "fix-merge":
return "Clean working tree with no unmerged files, no MERGE_HEAD, no SQUASH_MSG (merge conflict resolution)";
default:
return null;
}

View file

@ -44,6 +44,26 @@ export interface MergeSliceResult {
deletedBranch: boolean;
}
/**
* Thrown when a slice merge hits code conflicts in non-.gsd files.
* The working tree is left in a conflicted state (no reset) so the
* caller can dispatch a fix-merge session to resolve it.
*/
export class MergeConflictError extends Error {
constructor(
public readonly conflictedFiles: string[],
public readonly strategy: "squash" | "merge",
public readonly branch: string,
public readonly mainBranch: string,
) {
super(
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" ` +
`failed with conflicts in ${conflictedFiles.length} non-.gsd file(s): ${conflictedFiles.join(", ")}`,
);
this.name = "MergeConflictError";
}
}
export interface PreMergeCheckResult {
passed: boolean;
skipped?: boolean;
@ -696,15 +716,9 @@ export class GitServiceImpl {
this.git(["add", "-A"], { allowFailure: true });
// Don't throw — let the merge proceed
} else {
// Non-.gsd/ conflicts: reset and throw as before
this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
throw new Error(
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed with conflicts in non-.gsd/ files. ` +
`Working tree has been reset to a clean state. ` +
`Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` +
`Original error: ${msg}`,
);
// Non-.gsd/ conflicts: leave working tree in conflicted state and throw
// MergeConflictError so the caller can dispatch a fix-merge session.
throw new MergeConflictError(conflictedFiles, strategy, branch, mainBranch);
}
} else {
// No conflicted files detected but merge still failed — reset and throw

View file

@ -1,10 +1,12 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import {
resolveExpectedArtifactPath,
writeBlockerPlaceholder,
skipExecuteTask,
verifyExpectedArtifact,
} from "../auto.ts";
let passed = 0;
@ -294,6 +296,119 @@ function cleanup(base: string): void {
}
}
// ═══ verifyExpectedArtifact: fix-merge ════════════════════════════════════════
/** Create a real git repo for fix-merge tests */
function createGitBase(): string {
const base = mkdtempSync(join(tmpdir(), "gsd-fixmerge-test-"));
execSync("git init", { cwd: base, stdio: "ignore" });
execSync("git config user.email test@test.com", { cwd: base, stdio: "ignore" });
execSync("git config user.name Test", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "README.md"), "init\n", "utf-8");
execSync("git add -A && git commit -m init", { cwd: base, stdio: "ignore" });
// Create .gsd structure for the fixture
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
return base;
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — clean repo returns true ===");
const base = createGitBase();
try {
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assert(result === true, "clean repo should verify as true");
} finally {
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — MERGE_HEAD present returns false ===");
const base = createGitBase();
try {
writeFileSync(join(base, ".git", "MERGE_HEAD"), "abc123\n", "utf-8");
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assert(result === false, "MERGE_HEAD present should return false");
} finally {
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — SQUASH_MSG present returns false ===");
const base = createGitBase();
try {
writeFileSync(join(base, ".git", "SQUASH_MSG"), "squash msg\n", "utf-8");
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assert(result === false, "SQUASH_MSG present should return false");
} finally {
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — real UU conflict returns false ===");
const base = createGitBase();
try {
// Create a conflict: modify same file on two branches
writeFileSync(join(base, "conflict.txt"), "main content\n", "utf-8");
execSync("git add -A && git commit -m 'main change'", { cwd: base, stdio: "ignore" });
execSync("git checkout -b feature", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "conflict.txt"), "feature content\n", "utf-8");
execSync("git add -A && git commit -m 'feature change'", { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "conflict.txt"), "different main content\n", "utf-8");
execSync("git add -A && git commit -m 'diverge'", { cwd: base, stdio: "ignore" });
try { execSync("git merge feature", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assert(result === false, "UU conflict should return false");
} finally {
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — real DU conflict returns false ===");
const base = createGitBase();
try {
writeFileSync(join(base, "deleted.txt"), "content\n", "utf-8");
execSync("git add -A && git commit -m 'add file'", { cwd: base, stdio: "ignore" });
execSync("git checkout -b feature2", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "deleted.txt"), "modified on feature\n", "utf-8");
execSync("git add -A && git commit -m 'modify on feature'", { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
execSync("git rm deleted.txt", { cwd: base, stdio: "ignore" });
execSync("git commit -m 'delete on main'", { cwd: base, stdio: "ignore" });
try { execSync("git merge feature2", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assert(result === false, "DU conflict should return false");
} finally {
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
cleanup(base);
}
}
{
console.log("\n=== verifyExpectedArtifact: fix-merge — real AA conflict returns false ===");
const base = createGitBase();
try {
execSync("git checkout -b branch-a", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "both.txt"), "branch-a content\n", "utf-8");
execSync("git add -A && git commit -m 'add on branch-a'", { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
execSync("git checkout -b branch-b", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "both.txt"), "branch-b content\n", "utf-8");
execSync("git add -A && git commit -m 'add on branch-b'", { cwd: base, stdio: "ignore" });
try { execSync("git merge branch-a", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assert(result === false, "AA conflict should return false");
} finally {
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
cleanup(base);
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Results
// ═════════════════════════════════════════════════════════════════════════════

View file

@ -22,6 +22,7 @@ import { loadEffectiveGSDPreferences } from "./preferences.ts";
// Re-export MergeSliceResult from the canonical source (D014 — type-only re-export)
export type { MergeSliceResult } from "./git-service.ts";
export { MergeConflictError } from "./git-service.ts";
// ─── Lazy GitServiceImpl Cache ─────────────────────────────────────────────