feat(KB-010): add autoResolveConflicts setting with intelligent merge conflict resolution

- Add autoResolveConflicts setting to types and store (default: true)
- Implement smart conflict detection for lock files and generated files
- Add 3-attempt retry logic with escalating strategies to merger
- Auto-resolve lock files using 'ours', trivial whitespace conflicts
- Track mergeRetries per task for retry loop management
- Update AGENTS.md with conflict resolution documentation
- Add comprehensive merger tests for retry scenarios
This commit is contained in:
gsxdsm
2026-03-29 18:27:23 -07:00
parent 234f568e12
commit ddaf73554b
9 changed files with 1642 additions and 149 deletions

View File

@@ -20,6 +20,8 @@ function makeMockStore() {
pollIntervalMs: 60_000,
}),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue({ column: "in-review", paused: false }),
updateTask: vi.fn().mockResolvedValue({}),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
@@ -726,3 +728,201 @@ describe("runDashboard — --paused flag", () => {
expect(pausedMessageCalls).toHaveLength(0);
});
});
// ── Merge conflict retry logic tests ────────────────────────────────────
describe("runDashboard — merge conflict retry logic", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
// Default mock store.getTask implementation
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
id,
column: "in-review",
paused: false,
mergeRetries: 0,
}));
const engine = await import("@kb/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
);
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(_store: unknown, _cwd: unknown, opts: unknown) => {
capturedExecutorOpts = opts as Record<string, unknown>;
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
},
);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("increments mergeRetries and re-enqueues on conflict error", async () => {
const { aiMergeTask } = await import("@kb/engine");
// Simulate merge failure with conflict
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Merge conflict detected in package-lock.json"),
);
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: true,
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
mockStore.listTasks.mockResolvedValue([
{ id: "KB-RETRY", column: "in-review", paused: false },
]);
await runDashboard(0, { open: false });
// Wait for retry scheduling
await new Promise((r) => setTimeout(r, 100));
// Should have incremented mergeRetries
expect(mockStore.updateTask).toHaveBeenCalledWith(
"KB-RETRY",
expect.objectContaining({ mergeRetries: 1 }),
);
// Should log retry attempt
const retryLog = consoleSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("retry 1/3"),
);
expect(retryLog).toBeDefined();
});
it("gives up after max retries (3) exceeded", async () => {
const { aiMergeTask } = await import("@kb/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Merge conflict detected"),
);
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: true,
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
// Task already has 3 retries
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
id,
column: "in-review",
paused: false,
mergeRetries: 3,
}));
mockStore.listTasks.mockResolvedValue([
{ id: "KB-MAX", column: "in-review", paused: false, mergeRetries: 3 },
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
// Should log max retries exceeded
const maxRetryLog = consoleSpy.mock.calls.find(
(call) =>
typeof call[0] === "string" && call[0].includes("max retries (3) exceeded"),
);
expect(maxRetryLog).toBeDefined();
// Should reset mergeRetries on the task
expect(mockStore.updateTask).toHaveBeenCalledWith(
"KB-MAX",
expect.objectContaining({ status: null }),
);
});
it("skips retry when autoResolveConflicts is disabled", async () => {
const { aiMergeTask } = await import("@kb/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Merge conflict detected"),
);
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: false, // Disabled
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
mockStore.listTasks.mockResolvedValue([
{ id: "KB-NO-AUTO", column: "in-review", paused: false },
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
// Should log that auto-resolve is disabled
const disabledLog = consoleSpy.mock.calls.find(
(call) =>
typeof call[0] === "string" &&
call[0].includes("autoResolveConflicts disabled"),
);
expect(disabledLog).toBeDefined();
});
it("clears mergeRetries on successful merge after retries", async () => {
const { aiMergeTask } = await import("@kb/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockResolvedValue({ merged: true });
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: true,
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
// Task had previous retries
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
id,
column: "in-review",
paused: false,
mergeRetries: 2,
}));
mockStore.listTasks.mockResolvedValue([
{ id: "KB-SUCCESS", column: "in-review", paused: false, mergeRetries: 2 },
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 100));
// Should clear mergeRetries on success
expect(mockStore.updateTask).toHaveBeenCalledWith(
"KB-SUCCESS",
expect.objectContaining({ mergeRetries: 0 }),
);
});
});

View File

@@ -164,12 +164,53 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
console.log(`[auto-merge] Merging ${taskId}...`);
await onMerge(taskId);
console.log(`[auto-merge] ✓ ${taskId} merged`);
// Clear mergeRetries on success
if (task.mergeRetries && task.mergeRetries > 0) {
await store.updateTask(taskId, { mergeRetries: 0 });
}
} catch (err: any) {
console.log(`[auto-merge] ✗ ${taskId}: ${err.message ?? err}`);
// Reset task status so it doesn't appear stuck as "merging" in the UI
try {
await store.updateTask(taskId, { status: null });
} catch { /* best-effort */ }
const errorMsg = err.message ?? String(err);
console.log(`[auto-merge] ✗ ${taskId}: ${errorMsg}`);
// Check if this is a conflict error and if we should retry
const isConflictError = errorMsg.includes("conflict") || errorMsg.includes("Conflict");
const task = await store.getTask(taskId).catch(() => null);
if (task && isConflictError) {
const settings = await store.getSettings().catch(() => ({ autoResolveConflicts: true }));
const currentRetries = task.mergeRetries ?? 0;
const maxRetries = 3;
if (settings.autoResolveConflicts !== false && currentRetries < maxRetries) {
// Increment retry counter and re-enqueue with delay
const newRetryCount = currentRetries + 1;
await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null });
// Calculate exponential backoff delay: 5s, 10s, 20s
const delayMs = 5000 * Math.pow(2, currentRetries);
console.log(`[auto-merge] ↻ ${taskId}: retry ${newRetryCount}/${maxRetries} in ${delayMs / 1000}s`);
setTimeout(() => {
enqueueMerge(taskId);
}, delayMs);
} else {
// Max retries exceeded or auto-resolve disabled - keep in in-review
if (currentRetries >= maxRetries) {
console.log(`[auto-merge] ⊘ ${taskId}: max retries (${maxRetries}) exceeded — manual resolution required`);
} else {
console.log(`[auto-merge] ⊘ ${taskId}: autoResolveConflicts disabled — manual resolution required`);
}
// Reset task status so it doesn't appear stuck as "merging" in the UI
try {
await store.updateTask(taskId, { status: null });
} catch { /* best-effort */ }
}
} else {
// Non-conflict error - reset task status
try {
await store.updateTask(taskId, { status: null });
} catch { /* best-effort */ }
}
} finally {
mergeActive.delete(taskId);
}

View File

@@ -345,6 +345,19 @@ describe("TaskStore", () => {
});
});
describe("autoResolveConflicts setting", () => {
it("persists autoResolveConflicts and returns it via getSettings", async () => {
await store.updateSettings({ autoResolveConflicts: false });
const settings = await store.getSettings();
expect(settings.autoResolveConflicts).toBe(false);
});
it("default settings have autoResolveConflicts set to true", async () => {
const settings = await store.getSettings();
expect(settings.autoResolveConflicts).toBe(true);
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {

View File

@@ -100,6 +100,8 @@ export interface Task {
log: TaskLogEntry[];
size?: "S" | "M" | "L";
reviewLevel?: number;
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
mergeRetries?: number;
/** ISO-8601 timestamp of when the task last entered its current column.
* Used to sort cards within a column so that recently-moved cards appear at the top. */
columnMovedAt?: string;
@@ -177,6 +179,11 @@ export interface Settings {
* produce better results but cost more. When undefined, the engine
* uses the model's default thinking level. */
defaultThinkingLevel?: ThinkingLevel;
/** When true, auto-merge will automatically resolve common conflict patterns
* (lock files, generated files, trivial conflicts) without requiring AI
* intervention. When AI resolution fails, the system will retry with escalating
* strategies. Default: true. */
autoResolveConflicts?: boolean;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -194,6 +201,7 @@ export const DEFAULT_SETTINGS: Settings = {
defaultProvider: undefined,
defaultModelId: undefined,
defaultThinkingLevel: undefined,
autoResolveConflicts: true,
};
export interface BoardConfig {
@@ -208,6 +216,10 @@ export interface MergeResult {
worktreeRemoved: boolean;
branchDeleted: boolean;
error?: string;
/** Strategy that successfully resolved the merge, if any */
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
/** Number of retry attempts made (1 = first attempt succeeded, 2-3 = retries needed) */
attemptsMade?: 1 | 2 | 3;
}
export const COLUMN_LABELS: Record<Column, string> = {

View File

@@ -11,17 +11,26 @@ vi.mock("node:child_process", () => ({
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
import { aiMergeTask, findWorktreeUser } from "./merger.js";
import {
aiMergeTask,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
type ConflictCategory,
} from "./merger.js";
import { createKbAgent } from "./pi.js";
import { execSync } from "node:child_process";
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@kb/core";
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
const mockedExecSync = vi.mocked(execSync);
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
const baseTask: Task = {
@@ -679,3 +688,693 @@ describe("aiMergeTask — onSession callback", () => {
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).resolves.toBeDefined();
});
});
// ── Conflict Detection & Auto-Resolution ─────────────────────────────────
describe("detectResolvableConflicts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty array when no conflicts exist", () => {
mockedExecSync.mockReturnValue(""); // Empty output = no conflicts
const result = detectResolvableConflicts("/tmp/root");
expect(result).toEqual([]);
});
it("detects package-lock.json as auto-resolvable with 'theirs' strategy", () => {
mockedExecSync.mockReturnValue("package-lock.json\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "package-lock.json",
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects pnpm-lock.yaml as lock file with 'ours' strategy", () => {
mockedExecSync.mockReturnValue("pnpm-lock.yaml\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "pnpm-lock.yaml",
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects yarn.lock as lock file with 'ours' strategy", () => {
mockedExecSync.mockReturnValue("yarn.lock\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects Gemfile.lock as lock file with 'ours' strategy", () => {
mockedExecSync.mockReturnValue("Gemfile.lock\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects .gen.ts files as generated files with 'ours' strategy", () => {
mockedExecSync.mockReturnValue("src/types.gen.ts\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "generated-file",
});
});
it("detects dist/ paths as generated files", () => {
mockedExecSync.mockReturnValue("dist/index.js\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "generated-file",
});
});
it("detects coverage/ paths as generated files", () => {
mockedExecSync.mockReturnValue("coverage/lcov-report/index.html\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "generated-file",
});
});
it("marks regular source files as complex conflicts", () => {
mockedExecSync.mockReturnValue("src/components/App.tsx\n");
const result = detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
filePath: "src/components/App.tsx",
autoResolvable: false,
reason: "complex",
});
});
it("handles multiple conflicted files with mixed categories", () => {
mockedExecSync.mockReturnValue(
"package-lock.json\nsrc/components/App.tsx\ndist/bundle.js\n",
);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(3);
const lockFile = result.find((r) => r.filePath === "package-lock.json");
const sourceFile = result.find((r) => r.filePath === "src/components/App.tsx");
const distFile = result.find((r) => r.filePath === "dist/bundle.js");
expect(lockFile).toMatchObject({ autoResolvable: true, reason: "lock-file" });
expect(sourceFile).toMatchObject({ autoResolvable: false, reason: "complex" });
expect(distFile).toMatchObject({ autoResolvable: true, reason: "generated-file" });
});
it("returns empty array on git command failure", () => {
mockedExecSync.mockImplementation(() => {
throw new Error("git command failed");
});
const result = detectResolvableConflicts("/tmp/root");
expect(result).toEqual([]);
});
});
describe("autoResolveFile", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock returns empty buffer for all git commands
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git checkout --theirs for 'theirs' resolution", () => {
autoResolveFile("package-lock.json", "theirs", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git checkout --theirs"),
);
expect(checkoutCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("package-lock.json");
});
it("calls git checkout --ours for 'ours' resolution", () => {
autoResolveFile("config.json", "ours", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git checkout --ours"),
);
expect(checkoutCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("config.json");
});
it("stages the resolved file with git add", () => {
autoResolveFile("package-lock.json", "theirs", "/tmp/root");
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(addCall).toBeDefined();
expect(String(addCall![0])).toContain("package-lock.json");
});
it("throws error when git checkout fails", () => {
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd).includes("checkout")) {
throw new Error("checkout failed");
}
return Buffer.from("");
});
expect(() => autoResolveFile("file.ts", "theirs", "/tmp/root")).toThrow(
"Failed to auto-resolve",
);
});
});
describe("resolveConflicts", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock - success
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("resolves lock files and returns remaining complex conflicts", () => {
const categories: ConflictCategory[] = [
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
{ filePath: "dist/bundle.js", autoResolvable: true, strategy: "ours", reason: "generated-file" },
];
const remaining = resolveConflicts(categories, "/tmp/root");
// Should have resolved package-lock.json and dist/bundle.js
expect(remaining).toEqual(["src/App.tsx"]);
// Should have called checkout and add for resolved files
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
String(call[0]).includes("checkout"),
);
expect(checkoutCalls).toHaveLength(2);
});
it("returns all files when none are auto-resolvable", () => {
const categories: ConflictCategory[] = [
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
{ filePath: "src/utils.ts", autoResolvable: false, reason: "complex" },
];
const remaining = resolveConflicts(categories, "/tmp/root");
expect(remaining).toEqual(["src/App.tsx", "src/utils.ts"]);
// No checkout calls should be made
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
String(call[0]).includes("checkout"),
);
expect(checkoutCalls).toHaveLength(0);
});
it("returns empty array when all conflicts are resolved", () => {
const categories: ConflictCategory[] = [
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
{ filePath: "yarn.lock", autoResolvable: true, strategy: "ours", reason: "lock-file" },
];
const remaining = resolveConflicts(categories, "/tmp/root");
expect(remaining).toEqual([]);
});
});
// ── Trivial Conflict Detection Tests ──────────────────────────────────────
describe("trivial conflict detection (isTrivialConflict via detectResolvableConflicts)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("detects whitespace-only conflicts as trivial", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
const fileContent = `function foo() {
<<<<<<< HEAD
return 1;
=======
return 1;
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "src/utils.ts",
autoResolvable: true,
strategy: "ours",
reason: "trivial",
});
});
it("detects conflicts with different line endings as trivial", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
// Same content but different line ending style - CRLF vs LF
const fileContent = "const x = 1;\r\n<<<<<<< HEAD\r\nconst y = 2;\r\n=======\r\nconst y = 2;\n>>>>>>> feature-branch";
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: true,
reason: "trivial",
});
});
it("marks conflicts with actual content differences as complex", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
const fileContent = `function foo() {
<<<<<<< HEAD
return 1;
=======
return 2;
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "src/utils.ts",
autoResolvable: false,
reason: "complex",
});
});
it("handles multiple conflict sections - all trivial", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
const fileContent = `function foo() {
<<<<<<< HEAD
return 1;
=======
return 1;
>>>>>>> feature-branch
}
function bar() {
<<<<<<< Updated upstream
const x = 2;
=======
const x = 2;
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: true,
reason: "trivial",
});
});
it("handles multiple conflict sections - one non-trivial makes complex", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
const fileContent = `function foo() {
<<<<<<< HEAD
return 1;
=======
return 1;
>>>>>>> feature-branch
}
function bar() {
<<<<<<< Updated upstream
const x = 2;
=======
const x = 999;
>>>>>>> feature-branch
}`;
mockedReadFileSync.mockReturnValue(fileContent);
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: false,
reason: "complex",
});
});
it("handles file read errors as complex conflicts", () => {
mockedExecSync.mockReturnValue("src/utils.ts\n");
mockedReadFileSync.mockImplementation(() => {
throw new Error("ENOENT: no such file");
});
const result = detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: false,
reason: "complex",
});
});
});
// ── Retry Logic Tests ───────────────────────────────────────────────────
describe("aiMergeTask — retry logic with escalating strategies", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
// Default mock: successful happy path
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) return Buffer.from("");
// Post-squash check: "1" = has staged changes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "0" = committed
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("attempt 1 success: sets resolutionStrategy to 'ai' and attemptsMade to 1", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Clean merge with no conflicts - simulate empty diff for conflicts
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// No conflicts
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
// Has staged changes that need committing
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ai");
expect(result.attemptsMade).toBe(1);
});
it("with autoResolveConflicts disabled: only makes 1 attempt on conflict", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
autoResolveConflicts: false, // Disabled
});
let agentCallCount = 0;
// Simulate: merge succeeds but leaves conflicts, agent is called but fails
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
// Merge command succeeds but leaves conflict markers
return Buffer.from("");
}
// Conflict detection returns conflicts
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return "src/file.ts\n";
}
// Staged changes check after merge (conflicts present but not staged)
if (cmdStr.includes("diff --cached --quiet")) {
return "1"; // Has staged changes from the merge
}
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
// Agent will be called and will fail
mockedCreateHaiAgent.mockImplementation(() => {
agentCallCount++;
return Promise.resolve({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
});
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow();
// Should have called agent exactly once (no retries since autoResolve is disabled)
expect(agentCallCount).toBe(1);
});
it("attempt 1 fails, attempt 2 auto-resolves lock files: sets resolutionStrategy to 'auto-resolve'", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
let mergeCallCount = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) {
mergeCallCount++;
if (mergeCallCount === 1) {
// First attempt: conflict
throw new Error("Merge conflict");
}
// Second attempt succeeds after auto-resolution
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
// First time: return lock file, second time: empty
if (mergeCallCount === 1) return "package-lock.json\n";
return "";
}
if (cmdStr.includes("checkout --ours")) return Buffer.from("");
if (cmdStr.includes("git add")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "0"; // All resolved
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
// Agent should not be called since all conflicts are auto-resolved
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("auto-resolve");
expect(result.attemptsMade).toBe(2);
});
it("attempt 3 uses -X theirs strategy: sets resolutionStrategy to 'theirs'", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
let squashCallCount = 0;
let theirsCallCount = 0;
let hasConflicts = true;
let agentCallCount = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("--stat")) return "1 file changed";
// First two regular squash merges fail with conflicts
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
squashCallCount++;
throw new Error("Merge conflict");
}
// Third attempt with -X theirs succeeds (no conflicts)
if (cmdStr.includes("merge -X theirs --squash")) {
theirsCallCount++;
hasConflicts = false;
return Buffer.from("");
}
// After -X theirs, no conflicts
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return hasConflicts ? "src/complex.ts\n" : "";
}
if (cmdStr.includes("diff --cached --quiet")) return hasConflicts ? "1" : "0";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
// Agent fails on attempt 2 (when called to resolve complex conflicts)
mockedCreateHaiAgent.mockImplementation(() => {
agentCallCount++;
if (agentCallCount === 1) {
// First agent call (attempt 2) fails
return Promise.resolve({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
}
// Should not reach here
return Promise.resolve({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("theirs");
expect(result.attemptsMade).toBe(3);
expect(theirsCallCount).toBe(1); // -X theirs was used once
expect(agentCallCount).toBe(1); // Agent was called once (on attempt 2, which failed)
});
it("all 3 attempts fail: throws error and calls git reset --merge", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const resetCalls: string[] = [];
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X theirs")) {
// All merge attempts fail with conflicts
throw new Error("Merge conflict");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return "src/always-conflicts.ts\n"; // Always has conflicts
}
if (cmdStr.includes("reset --merge")) {
resetCalls.push(cmdStr);
return Buffer.from("");
}
return Buffer.from("");
});
// Agent will also fail
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow(
"all 3 attempts exhausted",
);
// Should have cleanup calls after each failed attempt plus final cleanup
expect(resetCalls.length).toBeGreaterThanOrEqual(3);
});
it("tracks resolutionStrategy as 'ai' when attempt 1 succeeds even with autoResolve enabled", async () => {
const store = createMockStore(
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Clean merge with no conflicts
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return ""; // No conflicts
if (cmdStr.includes("diff --cached --quiet")) return "1"; // Has staged changes
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ai");
expect(result.attemptsMade).toBe(1);
});
});

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import type { TaskStore, Task, MergeResult } from "@kb/core";
import { createKbAgent } from "./pi.js";
import type { WorktreePool } from "./worktree-pool.js";
@@ -7,6 +7,190 @@ import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js";
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
/** Conflict category for a file with merge conflicts */
export type ConflictResolution = "ours" | "theirs";
export interface ConflictCategory {
filePath: string;
/** Whether this conflict can be auto-resolved without AI */
autoResolvable: boolean;
/** Resolution strategy: 'ours' = take current branch, 'theirs' = take incoming branch */
strategy?: ConflictResolution;
/** Reason for the categorization */
reason: "lock-file" | "generated-file" | "trivial" | "complex";
}
/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
const LOCK_FILE_PATTERNS = [
/package-lock\.json$/,
/pnpm-lock\.yaml$/,
/yarn\.lock$/,
/Gemfile\.lock$/,
/Cargo\.lock$/,
/composer\.lock$/,
/poetry\.lock$/,
];
/** Generated file patterns that should auto-resolve using "ours" */
const GENERATED_FILE_PATTERNS = [
/\.gen\.(ts|js|tsx|jsx|mjs|cjs)$/,
/dist\//,
/coverage\//,
/\.next\//,
/\.nuxt\//,
/\.output\//,
/\.cache\//,
/__generated__\//,
/generated\//,
];
/**
* Detect and categorize merge conflicts in the working directory.
* Returns array of ConflictCategory for each conflicted file.
*/
export function detectResolvableConflicts(rootDir: string): ConflictCategory[] {
try {
// Get list of conflicted files
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (!conflictedOutput) {
return [];
}
const conflictedFiles = conflictedOutput.split("\n").filter(Boolean);
return conflictedFiles.map((filePath): ConflictCategory => {
// Check for lock files - always take "ours" (current branch's version)
if (LOCK_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
return {
filePath,
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
};
}
// Check for generated files - take "ours" (regenerate after merge)
if (GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
return {
filePath,
autoResolvable: true,
strategy: "ours",
reason: "generated-file",
};
}
// Check for trivial conflicts (whitespace-only)
if (isTrivialConflict(filePath, rootDir)) {
return {
filePath,
autoResolvable: true,
strategy: "ours", // Either would work, but ours is current branch
reason: "trivial",
};
}
// Complex conflicts require AI intervention
return {
filePath,
autoResolvable: false,
reason: "complex",
};
});
} catch (error) {
mergerLog.error(`Failed to detect conflicts: ${error}`);
return [];
}
}
/**
* Check if a conflicted file has only trivial changes (whitespace-only differences).
* Reads the working directory file and compares the conflict sections.
*/
function isTrivialConflict(filePath: string, rootDir: string): boolean {
try {
const fullPath = `${rootDir}/${filePath}`;
const content = readFileSync(fullPath, "utf-8");
// Look for conflict markers - support any text after <<<<<<< (HEAD, ours, Updated upstream, etc.)
const conflictRegex = /<<<<<<<\s+.+?[\s\S]*?^=======([\s\S]*?)^>>>>>>>\s+/gm;
let hasConflicts = false;
for (const match of content.matchAll(conflictRegex)) {
hasConflicts = true;
const fullMatch = match[0];
const theirsContent = match[1];
// Extract "ours" content (between <<<<<<< line and ======= line)
const oursMatch = fullMatch.match(/<<<<<<<\s+.+?\n([\s\S]*?)\n=======/);
if (!oursMatch) continue;
const oursContent = oursMatch[1];
// Normalize: remove all whitespace and compare
const oursNormalized = oursContent.replace(/\s+/g, "");
const theirsNormalized = theirsContent.replace(/\s+/g, "");
// If content is the same after stripping whitespace, it's trivial
if (oursNormalized !== theirsNormalized) {
return false; // Real content difference found
}
}
return hasConflicts; // Only trivial if we found conflicts and they're all trivial
} catch {
return false; // On error, assume complex
}
}
/**
* Auto-resolve a single file using git checkout --ours or --theirs.
* Stages the resolved file.
*/
export function autoResolveFile(
filePath: string,
resolution: ConflictResolution,
rootDir: string,
): void {
try {
execSync(`git checkout --${resolution} "${filePath}"`, {
cwd: rootDir,
stdio: "pipe",
});
execSync(`git add "${filePath}"`, {
cwd: rootDir,
stdio: "pipe",
});
mergerLog.log(`Auto-resolved ${filePath} using --${resolution}`);
} catch (error) {
throw new Error(`Failed to auto-resolve ${filePath}: ${error}`);
}
}
/**
* Auto-resolve all resolvable conflicts from the categorization.
* Returns the list of remaining complex conflicts that need AI resolution.
*/
export function resolveConflicts(
categories: ConflictCategory[],
rootDir: string,
): string[] {
const remainingComplex: string[] = [];
for (const category of categories) {
if (category.autoResolvable && category.strategy) {
autoResolveFile(category.filePath, category.strategy, rootDir);
} else {
remainingComplex.push(category.filePath);
}
}
return remainingComplex;
}
/**
* Build the merge system prompt. When `includeTaskId` is true (default),
* the commit format uses `<type>(<scope>): <summary>` where scope is the
@@ -114,8 +298,11 @@ export interface MergerOptions {
}
/**
* AI-powered merge: resolves conflicts with a pi agent and
* writes a commit message that summarizes the branch's work.
* AI-powered merge with 3-attempt retry logic when autoResolveConflicts is enabled.
*
* Attempt 1: Standard merge + AI agent with full context
* Attempt 2 (if enabled and Attempt 1 failed): Auto-resolve lock/generated files, retry AI
* Attempt 3 (if enabled and Attempt 2 failed): Reset and use git merge -X theirs --squash
*
* When `options.pool` is provided and `recycleWorktrees` is enabled in
* settings, the worktree is detached from its branch and released to the
@@ -151,9 +338,10 @@ export async function aiMergeTask(
mergerLog.warn(`${taskId}: no worktree path set — skipping worktree cleanup`);
}
// 2. Read settings early (reused later for recycleWorktrees)
// 2. Read settings
const settings = await store.getSettings();
const includeTaskId = settings.includeTaskIdInCommit !== false;
const autoResolveConflicts = settings.autoResolveConflicts !== false;
// 3. Check branch exists
try {
@@ -167,7 +355,7 @@ export async function aiMergeTask(
return result;
}
// 3. Gather context for the agent
// 4. Gather context for the agent (used in all attempts)
let commitLog = "";
let diffStat = "";
try {
@@ -187,131 +375,82 @@ export async function aiMergeTask(
diffStat = "(unable to read diff)";
}
// 4. Start the merge (--no-commit so the agent controls the message)
let hasConflicts = false;
try {
execSync(`git merge --squash "${branch}"`, {
cwd: rootDir,
stdio: "pipe",
});
// 5. Execute merge with retry logic
await store.updateTask(taskId, { status: "merging" });
// If the squash staged nothing, the branch's changes are already on main
// (e.g. branch was based on a dep that has since been merged). Skip the
// agent entirely — there is nothing to commit.
const squashIsEmpty = execSync(
"git diff --cached --quiet 2>&1; echo $?",
{ cwd: rootDir, encoding: "utf-8" },
).trim() === "0";
const mergeAttempt = async (attemptNum: 1 | 2 | 3): Promise<boolean> => {
mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
if (squashIsEmpty) {
mergerLog.log(`${taskId}: squash merge staged nothing — branch already merged via dependency`);
result.merged = true;
}
} catch {
// Conflicts or other merge issue — check if it's conflicts
try {
const conflicted = execSync("git diff --name-only --diff-filter=U", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
hasConflicts = conflicted.length > 0;
// Try the merge with appropriate strategy for this attempt
const success = await executeMergeAttempt({
store,
rootDir,
taskId,
branch,
commitLog,
diffStat,
includeTaskId,
autoResolveConflicts,
attemptNum,
options,
});
if (!hasConflicts) {
// Not conflicts — some other merge failure. Abort and throw.
if (success) {
result.attemptsMade = attemptNum;
result.resolutionStrategy = getResolutionStrategy(attemptNum, autoResolveConflicts);
result.merged = true;
return true;
}
// If not successful and we have more attempts, clean up and try again
if (attemptNum < 3) {
mergerLog.log(`${taskId}: attempt ${attemptNum} failed, cleaning up for retry...`);
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ }
throw new Error(`Merge failed for branch '${branch}'`);
} catch { /* ignore cleanup errors */ }
}
} catch (e: any) {
if (e.message.includes("Merge failed")) throw e;
// git diff itself failed — abort
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ }
throw new Error(`Merge failed for branch '${branch}'`);
return false;
} catch (error: any) {
// Clean up on error before potentially rethrowing or retrying
if (attemptNum < 3 && autoResolveConflicts) {
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* ignore cleanup errors */ }
return false; // Allow retry
}
throw error; // Last attempt or auto-resolve disabled - propagate error
}
};
// Execute attempts with escalation
let merged = false;
// Attempt 1: Standard AI merge
merged = await mergeAttempt(1);
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled)
if (!merged && autoResolveConflicts) {
merged = await mergeAttempt(2);
}
// 5. Spawn pi agent to resolve conflicts (if any) and write commit message.
// Skip entirely when the squash staged nothing (branch already merged via dep).
if (!result.merged) {
await store.updateTask(taskId, { status: "merging" });
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
const agentLogger = new AgentLogger({
store,
taskId,
agent: "merger",
// Merger callbacks don't include taskId — wrap to match AgentLogger signature
onAgentText: options.onAgentText
? (_id, delta) => options.onAgentText!(delta)
: undefined,
onAgentTool: options.onAgentTool
? (_id, name) => options.onAgentTool!(name)
: undefined,
});
// Forward model settings from store so the merger honours the user's model choice
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: buildMergeSystemPrompt(includeTaskId),
tools: "coding",
onText: agentLogger.onText,
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
// Notify the caller so it can track/dispose the session externally (e.g. on global pause)
options.onSession?.(session);
// Attempt 3: Use -X theirs merge strategy (if enabled)
if (!merged && autoResolveConflicts) {
merged = await mergeAttempt(3);
}
// If all attempts failed
if (!merged) {
// Final cleanup
try {
const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
await session.prompt(prompt);
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
checkSessionError(session);
// 6. Verify the commit happened — if there are still staged changes, agent didn't commit
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (staged !== "0") {
mergerLog.log("Agent didn't commit — committing with fallback message");
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
}
result.merged = true;
} catch (err: any) {
// Agent failed — try to abort the merge
mergerLog.error(`Agent failed: ${err.message}`);
// Check if the error is a usage-limit error and trigger global pause
if (options.usageLimitPauser && isUsageLimitError(err.message)) {
await options.usageLimitPauser.onUsageLimitHit("merger", taskId, err.message);
}
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ }
throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
} finally {
await agentLogger.flush();
session.dispose();
}
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* */ }
throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
}
// 7. Delete branch (always per-task, regardless of worktree sharing)
// 6. Delete branch
try {
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
result.branchDeleted = true;
@@ -322,7 +461,7 @@ export async function aiMergeTask(
} catch { /* non-fatal */ }
}
// 8. Clean up worktree — only if no other non-done task still references it
// 7. Clean up worktree
if (worktreePath && existsSync(worktreePath)) {
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
if (otherUser) {
@@ -342,31 +481,367 @@ export async function aiMergeTask(
}
}
// 9. Move task to done
// 8. Move task to done
await completeTask(store, taskId, result);
return result;
}
async function completeTask(
store: TaskStore,
taskId: string,
result: MergeResult,
): Promise<void> {
// Clear transient status before moving to done
await store.updateTask(taskId, { status: null });
// Use moveTask for proper event emission
const task = await store.moveTask(taskId, "done");
result.task = task;
store.emit("task:merged", result);
/** Get the resolution strategy based on attempt number and settings */
function getResolutionStrategy(
attemptNum: 1 | 2 | 3,
autoResolveConflicts: boolean,
): MergeResult["resolutionStrategy"] {
if (!autoResolveConflicts || attemptNum === 1) {
return "ai";
}
if (attemptNum === 2) {
return "auto-resolve";
}
return "theirs";
}
function buildMergePrompt(
taskId: string,
branch: string,
commitLog: string,
diffStat: string,
hasConflicts: boolean,
): string {
interface MergeAttemptParams {
store: TaskStore;
rootDir: string;
taskId: string;
branch: string;
commitLog: string;
diffStat: string;
includeTaskId: boolean;
autoResolveConflicts: boolean;
attemptNum: 1 | 2 | 3;
options: MergerOptions;
}
/**
* Execute a single merge attempt with the specified strategy.
* Returns true if merge succeeded, false if should retry (for attempts 1-2).
* Throws on unrecoverable errors.
*/
async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean> {
const {
store,
rootDir,
taskId,
branch,
commitLog,
diffStat,
includeTaskId,
autoResolveConflicts,
attemptNum,
options,
} = params;
// Attempt 3: Use -X theirs strategy
if (attemptNum === 3) {
return attemptWithTheirsStrategy(params);
}
// Attempt 1 & 2: Standard squash merge
let hasConflicts = false;
try {
// For attempt 2, try with auto-resolution first
if (attemptNum === 2 && autoResolveConflicts) {
// First, do a standard merge to get conflicts
// Note: git merge --squash exits with code 1 when conflicts exist
// This is expected - we catch it and proceed with auto-resolution
let mergeExitedWithConflicts = false;
try {
execSync(`git merge --squash "${branch}"`, {
cwd: rootDir,
stdio: "pipe",
});
} catch {
// Merge exits with code 1 when conflicts exist - this is expected
mergeExitedWithConflicts = true;
}
// Check if we have conflicts (either from merge throwing or detected conflicts)
const conflictCategories = detectResolvableConflicts(rootDir);
if (conflictCategories.length > 0 || mergeExitedWithConflicts) {
const autoResolvable = conflictCategories.filter((c) => c.autoResolvable);
const complex = conflictCategories.filter((c) => !c.autoResolvable);
if (autoResolvable.length > 0) {
mergerLog.log(
`${taskId}: auto-resolving ${autoResolvable.length} lock/generated file(s) before AI retry`,
);
resolveConflicts(conflictCategories, rootDir);
}
// If only auto-resolvable conflicts, commit them directly
if (complex.length === 0) {
// All conflicts auto-resolved, commit with fallback message
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (staged !== "0") {
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
mergerLog.log(`${taskId}: committed after auto-resolving conflicts`);
}
return true;
}
// Has complex conflicts - continue to AI agent with simplified context
hasConflicts = true;
} else {
// No conflicts - check if squash is empty
const squashIsEmpty = execSync(
"git diff --cached --quiet 2>&1; echo $?",
{ cwd: rootDir, encoding: "utf-8" },
).trim() === "0";
if (squashIsEmpty) {
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
return true;
}
// No conflicts but has staged changes - continue to AI for commit message
}
} else {
// Attempt 1: Standard merge
execSync(`git merge --squash "${branch}"`, {
cwd: rootDir,
stdio: "pipe",
});
// Check if squash is empty
const squashIsEmpty = execSync(
"git diff --cached --quiet 2>&1; echo $?",
{ cwd: rootDir, encoding: "utf-8" },
).trim() === "0";
if (squashIsEmpty) {
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
return true;
}
// Check for conflicts
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
hasConflicts = conflictedOutput.length > 0;
if (hasConflicts && !autoResolveConflicts) {
// No auto-resolve - AI will handle all conflicts
mergerLog.log(`${taskId}: conflicts detected, AI will resolve`);
} else if (hasConflicts && autoResolveConflicts) {
// Has conflicts and auto-resolve enabled - should be handled in attempt 2
// Reset and return false to trigger attempt 2
mergerLog.log(`${taskId}: conflicts detected, will retry with auto-resolution`);
return false;
}
}
// At this point, either:
// - No conflicts (attempt 1) - AI writes commit message
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them
// Spawn AI agent
return await runAiAgentForCommit({
store,
rootDir,
taskId,
branch,
commitLog,
diffStat,
includeTaskId,
hasConflicts,
simplifiedContext: attemptNum === 2,
options,
});
} catch (error: any) {
// Check if it's a non-conflict merge failure
if (error.message?.includes("Merge failed")) {
throw error; // Fatal
}
// For attempt 1, return false to trigger attempt 2
if (attemptNum === 1 && autoResolveConflicts) {
return false;
}
// Otherwise propagate
throw error;
}
}
/**
* Attempt 3: Use git merge -X theirs --squash strategy
*/
async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<boolean> {
const { rootDir, branch, commitLog, includeTaskId, taskId } = params;
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
try {
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
execSync(`git merge -X theirs --squash "${branch}"`, {
cwd: rootDir,
stdio: "pipe",
});
// Check if there are still conflicts (some types can't be auto-resolved)
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (conflictedOutput.length > 0) {
mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`);
return false; // Still has conflicts after -X theirs
}
// Check if there's anything staged
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (staged === "0") {
// Nothing staged - already merged
return true;
}
// Commit with fallback message
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
return true;
} catch (error) {
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
return false;
}
}
interface AiAgentParams {
store: TaskStore;
rootDir: string;
taskId: string;
branch: string;
commitLog: string;
diffStat: string;
includeTaskId: boolean;
hasConflicts: boolean;
simplifiedContext: boolean;
options: MergerOptions;
}
/**
* Run the AI agent to resolve conflicts and/or write commit message.
*/
async function runAiAgentForCommit(params: AiAgentParams): Promise<boolean> {
const {
store,
rootDir,
taskId,
branch,
commitLog,
diffStat,
includeTaskId,
hasConflicts,
simplifiedContext,
options,
} = params;
const settings = await store.getSettings();
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
const agentLogger = new AgentLogger({
store,
taskId,
agent: "merger",
onAgentText: options.onAgentText
? (_id, delta) => options.onAgentText!(delta)
: undefined,
onAgentTool: options.onAgentTool
? (_id, name) => options.onAgentTool!(name)
: undefined,
});
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: buildMergeSystemPrompt(includeTaskId),
tools: "coding",
onText: agentLogger.onText,
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
options.onSession?.(session);
try {
// Build appropriate prompt
const prompt = buildMergePrompt({
taskId,
branch,
commitLog: simplifiedContext ? "(see branch commits)" : commitLog,
diffStat,
hasConflicts,
simplifiedContext,
});
await session.prompt(prompt);
checkSessionError(session);
// Verify commit happened
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (staged !== "0") {
mergerLog.log("Agent didn't commit — committing with fallback message");
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
}
return true;
} catch (err: any) {
mergerLog.error(`Agent failed: ${err.message}`);
if (options.usageLimitPauser && isUsageLimitError(err.message)) {
await options.usageLimitPauser.onUsageLimitHit("merger", taskId, err.message);
}
throw err;
} finally {
await agentLogger.flush();
session.dispose();
}
}
interface MergePromptParams {
taskId: string;
branch: string;
commitLog: string;
diffStat: string;
hasConflicts: boolean;
simplifiedContext?: boolean;
}
function buildMergePrompt(params: MergePromptParams): string {
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext } = params;
const parts = [
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
"",
@@ -374,13 +849,18 @@ function buildMergePrompt(
"```",
commitLog,
"```",
"",
"## Files changed",
"```",
diffStat,
"```",
];
if (!simplifiedContext) {
parts.push(
"",
"## Files changed",
"```",
diffStat,
"```",
);
}
if (hasConflicts) {
parts.push(
"",
@@ -400,3 +880,16 @@ function buildMergePrompt(
return parts.join("\n");
}
async function completeTask(
store: TaskStore,
taskId: string,
result: MergeResult,
): Promise<void> {
// Clear transient status before moving to done
await store.updateTask(taskId, { status: null });
// Use moveTask for proper event emission
const task = await store.moveTask(taskId, "done");
result.task = task;
store.emit("task:merged", result);
}

View File

@@ -351,7 +351,7 @@ describe("In-review merge handling after restart", () => {
} as any);
await expect(aiMergeTask(store, "/tmp/root", "KB-055")).rejects.toThrow(
"AI merge failed for KB-055: merge agent crashed",
"AI merge failed for KB-055: all 3 attempts exhausted",
);
// Should have attempted git reset --merge cleanup