feat(core,engine): split smart merge strategy into prefer-main / prefer-branch

The single "smart" strategy is now two flavors with the new default flipped
to prefer-main. Both share a pre-cascade `git fetch origin <currentBranch>`
+ best-effort fast-forward so a freshly-pushed sibling commit doesn't get
clobbered when the fallback resolves a conflict against a stale base.

- "smart-prefer-main" (new default): -X ours fallback. Protects just-merged
  sibling work from being regressed by a concurrent task branch.
- "smart-prefer-branch": -X theirs fallback. Equivalent to legacy "smart".

Legacy "smart" / "prefer-main" enum values are accepted and normalized via
`normalizeMergeConflictStrategy()` so existing settings.json files migrate
seamlessly. The fast-forward step gracefully degrades on fetch failure or
divergent local main (logs and continues).

Updates settings UI dropdown, test helpers, and adds 5 fetch+ff regression
tests + 7 normalize-helper tests. Lint cleanup of two empty catch blocks
in scripts/release.mjs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-28 15:44:38 -07:00
parent ef202add1b
commit 22bac2ddc7
9 changed files with 347 additions and 45 deletions

View File

@@ -0,0 +1,12 @@
---
"@runfusion/fusion": minor
---
Refactor merge conflict strategies into two `smart-*` flavors and change the default to "prefer main".
Both smart strategies now run a best-effort `git fetch` + fast-forward of local main from `origin` before the merge cascade — a freshly-pushed sibling commit no longer gets clobbered when the fallback resolves a conflict against a stale base. They differ only in the per-file final fallback:
- **`smart-prefer-main`** (new default): `-X ours` — main wins. Best when concurrent agents could regress just-merged sibling work.
- **`smart-prefer-branch`**: `-X theirs` — task branch wins. Equivalent to the previous `"smart"` behavior.
Legacy enum values are accepted for backwards compatibility and normalized at load time: `"smart"``"smart-prefer-branch"`, `"prefer-main"``"smart-prefer-main"`. Settings on disk continue to work without changes.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { normalizeMergeConflictStrategy } from "../types.js";
describe("normalizeMergeConflictStrategy", () => {
it("maps legacy 'smart' to 'smart-prefer-branch'", () => {
expect(normalizeMergeConflictStrategy("smart")).toBe("smart-prefer-branch");
});
it("maps legacy 'prefer-main' to 'smart-prefer-main'", () => {
expect(normalizeMergeConflictStrategy("prefer-main")).toBe("smart-prefer-main");
});
it("returns 'smart-prefer-main' as the default when undefined", () => {
expect(normalizeMergeConflictStrategy(undefined)).toBe("smart-prefer-main");
});
it("passes through canonical 'smart-prefer-main'", () => {
expect(normalizeMergeConflictStrategy("smart-prefer-main")).toBe("smart-prefer-main");
});
it("passes through canonical 'smart-prefer-branch'", () => {
expect(normalizeMergeConflictStrategy("smart-prefer-branch")).toBe("smart-prefer-branch");
});
it("passes through 'ai-only' unchanged", () => {
expect(normalizeMergeConflictStrategy("ai-only")).toBe("ai-only");
});
it("passes through 'abort' unchanged", () => {
expect(normalizeMergeConflictStrategy("abort")).toBe("abort");
});
});

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -115,7 +115,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
worktreeRebaseBeforeMerge: true,
worktreeRebaseRemote: "",
worktreeRebaseLocalBase: true,
mergeConflictStrategy: "smart",
mergeConflictStrategy: "smart-prefer-main",
workflowStepTimeoutMs: 360_000,
strictScopeEnforcement: false,
buildRetryCount: 0,

View File

@@ -93,15 +93,57 @@ export type ColorTheme = (typeof COLOR_THEMES)[number];
export type PrStatus = "open" | "closed" | "merged";
export type MergeStrategy = "direct" | "pull-request";
/** How merge conflicts are resolved when the AI agent can't (or shouldn't) decide.
* - "smart" (default): try AI, then auto-resolve lock/generated/trivial files,
* then fall back to `-X theirs` (task branch wins). Backwards-compatible.
*
* Both `smart-*` strategies share the same cascade: pre-merge fetch +
* fast-forward of local main from origin (graceful degrade on failure),
* then AI, then auto-resolve lock/generated/trivial files. They differ only
* in the final per-file fallback when conflicts remain:
*
* - "smart-prefer-main" (default): fall back to `-X ours` so main's state
* wins. Best when concurrent tasks could regress just-merged sibling work.
* - "smart-prefer-branch": fall back to `-X theirs` so the task branch wins.
* Best when one agent at a time is dominant and you trust their output.
* - "ai-only": run AI on every attempt; never silently prefer one side.
* - "prefer-main": after AI/auto-resolve, fall back to `-X ours` so main's
* state wins. Best when concurrent tasks frequently regress just-merged
* sibling work.
* - "abort": run AI once; if conflict remains, fail the merge so a human
* can resolve it. */
export type MergeConflictStrategy = "smart" | "ai-only" | "prefer-main" | "abort";
* can resolve it.
*
* Legacy values `"smart"` and `"prefer-main"` are accepted for backwards
* compatibility and normalized via {@link normalizeMergeConflictStrategy}.
* `"smart"` maps to `"smart-prefer-branch"` (its historical fallback) and
* `"prefer-main"` maps to `"smart-prefer-main"`. */
export type MergeConflictStrategy =
| "smart-prefer-main"
| "smart-prefer-branch"
| "ai-only"
| "abort"
/** @deprecated use "smart-prefer-branch" */
| "smart"
/** @deprecated use "smart-prefer-main" */
| "prefer-main";
/** Canonical (post-migration) values that the merger actually dispatches on. */
export type CanonicalMergeConflictStrategy = Exclude<
MergeConflictStrategy,
"smart" | "prefer-main"
>;
/** Translate legacy `mergeConflictStrategy` values into their canonical form.
* Pass-through for already-canonical values; defaults to "smart-prefer-main"
* when the input is undefined. */
export function normalizeMergeConflictStrategy(
value: MergeConflictStrategy | undefined,
): CanonicalMergeConflictStrategy {
switch (value) {
case "smart":
return "smart-prefer-branch";
case "prefer-main":
return "smart-prefer-main";
case undefined:
return "smart-prefer-main";
default:
return value;
}
}
/** Policy for handling task execution when the selected node is unavailable/unhealthy. */
export type UnavailableNodePolicy = "block" | "fallback-local";

View File

@@ -2993,26 +2993,28 @@ export function SettingsModal({
<label htmlFor="mergeConflictStrategy">Conflict Fallback Strategy</label>
<select
id="mergeConflictStrategy"
value={form.mergeConflictStrategy ?? "smart"}
value={form.mergeConflictStrategy ?? "smart-prefer-main"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart" | "ai-only" | "prefer-main" | "abort" }))
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))
}
>
<option value="smart">Smart, prefer task on fallback — AI → auto-resolve → -X theirs (default, original behavior)</option>
<option value="prefer-main">Smart, prefer main on fallback — AI → auto-resolve → -X ours (protects just-merged sibling work)</option>
<option value="smart-prefer-main">Smart, prefer main on fallback — fetch+ff origin → AI → auto-resolve → -X ours (default; protects just-merged sibling work)</option>
<option value="smart-prefer-branch">Smart, prefer task on fallback — fetch+ff origin → AI → auto-resolve → -X theirs (legacy "smart" behavior; task branch wins)</option>
<option value="ai-only">AI only — AI → auto-resolve → AI retry; never silently pick a side</option>
<option value="abort">Abort — one AI attempt; require manual resolution if it fails</option>
</select>
<small>
Every option starts with the same first two attempts: an AI agent resolves the conflict, then auto-resolve handles lock/generated/trivial files. They differ only in the <em>final fallback</em>:
Both <strong>Smart</strong> options start with a best-effort <code>git fetch</code> + fast-forward of local main from <code>origin</code> (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the <em>final fallback</em>:
{" "}
<strong>Smart, prefer main</strong> uses <code>-X ours</code> so main wins — protects just-merged sibling work and is the new default.
{" "}
<strong>Smart, prefer task</strong> uses <code>-X theirs</code> so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression).
{" "}
<strong>Smart, prefer main</strong> uses <code>-X ours</code> so main wins instead — same speed, protects just-merged sibling work.
{" "}
<strong>AI only</strong> retries the AI agent rather than auto-picking a side.
{" "}
<strong>Abort</strong> stops after the first AI attempt and waits for a human.
{" "}
<em>Legacy <code>"smart"</code> and <code>"prefer-main"</code> values from older settings are migrated automatically.</em>
</small>
</div>
<div className="form-group">

View File

@@ -164,8 +164,12 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*
* For tests that want the merge to fail after 3 AI attempts (before -X theirs succeeds),
* call setupFailingTheirsStrategy() instead.
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
*
* For tests that want the merge to fail after 3 attempts, call
* setupFailingFallbackStrategy() instead.
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
@@ -176,7 +180,9 @@ function setupHappyPathExecSync() {
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
@@ -189,10 +195,11 @@ function setupHappyPathExecSync() {
}
/**
* Same as setupHappyPathExecSync but makes -X theirs merge fail.
* Use this for tests that expect the merge to throw after 3 AI attempts fail.
* Same as setupHappyPathExecSync but makes the final fallback merge fail
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
* to throw after 3 attempts fail.
*/
function setupFailingTheirsStrategy() {
function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
@@ -201,9 +208,9 @@ function setupFailingTheirsStrategy() {
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// -X theirs should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash")) {
const err = new Error("fatal: git merge -X theirs failed with unresolved conflicts");
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
@@ -218,6 +225,9 @@ function setupFailingTheirsStrategy() {
});
}
/** @deprecated Renamed to setupFailingFallbackStrategy. */
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
describe("findWorktreeUser", () => {
it("returns null when no other task uses the worktree", async () => {
const store = createMockStore({}, [
@@ -246,6 +256,140 @@ describe("findWorktreeUser", () => {
});
});
describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
function setupSyncMock({
behind,
ahead,
fetchFails = false,
}: {
behind: number;
ahead: number;
fetchFails?: boolean;
}) {
let fetchCalled = false;
let ffCalled = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --abbrev-ref HEAD")) return "main" as any;
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("rev-list --left-right --count")) {
return `${behind}\t${ahead}` as any;
}
if (cmdStr.includes("git fetch origin")) {
fetchCalled = true;
if (fetchFails) throw new Error("fatal: unable to access remote");
return Buffer.from("");
}
if (cmdStr.includes("merge --ff-only")) {
ffCalled = true;
return Buffer.from("");
}
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
return {
get fetchCalled() { return fetchCalled; },
get ffCalled() { return ffCalled; },
};
}
it("fast-forwards local main when origin is strictly ahead (default smart-prefer-main)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 2, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(true);
});
it("skips fast-forward when local main has unpushed commits (divergent)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 1, ahead: 1 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(false);
});
it("continues merge when fetch fails (graceful degrade)", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
const probe = setupSyncMock({ behind: 0, ahead: 0, fetchFails: true });
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(false);
expect(result.merged).toBe(true);
});
it("does not fetch for ai-only strategy", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "ai-only",
});
const probe = setupSyncMock({ behind: 5, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(false);
});
it("normalizes legacy 'smart' setting and still fetches", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart" as any,
});
const probe = setupSyncMock({ behind: 1, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(probe.fetchCalled).toBe(true);
expect(probe.ffCalled).toBe(true);
});
});
describe("aiMergeTask abort handling", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1945,6 +2089,12 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Pin the strategy: default is now "smart-prefer-main" (-X ours), but
// this test specifically exercises the -X theirs fallback path.
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeConflictStrategy: "smart-prefer-branch",
});
let squashCallCount = 0;
let theirsCallCount = 0;
@@ -2297,7 +2447,7 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
const resetFailureMessage = "retry cleanup reset failed";
let mergeSquashCalls = 0;
let resetCalls = 0;
let usedTheirsStrategy = false;
let usedFallbackStrategy = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
@@ -2316,13 +2466,13 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
return Buffer.from("");
}
if (cmdStr.includes("merge -X theirs --squash")) {
usedTheirsStrategy = true;
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
usedFallbackStrategy = true;
return Buffer.from("");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
if (!usedTheirsStrategy && mergeSquashCalls === 2) {
if (!usedFallbackStrategy && mergeSquashCalls === 2) {
return "src/complex.ts\n";
}
return "";
@@ -2519,7 +2669,7 @@ describe("aiMergeTask — reset cleanup failure diagnostics", () => {
return Buffer.from("");
}
if (cmdStr.includes("merge -X theirs --squash")) {
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
throw new Error("Merge conflict");
}

View File

@@ -133,7 +133,7 @@ async function execWithProcessGroup(
}
import { existsSync } from "node:fs";
import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig, type MergeConflictStrategy } from "@fusion/core";
import { getTaskMergeBlocker, normalizeMergeConflictStrategy, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig, type CanonicalMergeConflictStrategy } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -2170,7 +2170,17 @@ export async function aiMergeTask(
const includeTaskId = settings.includeTaskIdInCommit !== false;
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
const mergeConflictStrategy: NonNullable<MergeConflictStrategy> = settings.mergeConflictStrategy ?? "smart";
const mergeConflictStrategy: CanonicalMergeConflictStrategy = normalizeMergeConflictStrategy(
settings.mergeConflictStrategy,
);
// Pre-merge sync: for the smart strategies, opportunistically fast-forward
// local main from origin so a freshly-pushed sibling commit isn't clobbered
// by `-X ours`/`-X theirs` falling back to a stale base. Best-effort: any
// failure (no remote, network down, divergent local) logs and continues.
if (mergeConflictStrategy === "smart-prefer-main" || mergeConflictStrategy === "smart-prefer-branch") {
await tryFastForwardFromOrigin(rootDir, taskId);
}
// 3. Check branch exists
try {
@@ -2779,7 +2789,7 @@ export async function aiMergeTask(
merged = await mergeAttempt(2);
}
// Attempt 3: -X theirs (smart) or -X ours (prefer-main) fallback.
// Attempt 3: -X theirs (smart-prefer-branch) or -X ours (smart-prefer-main) fallback.
// Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
if (
!merged
@@ -3012,6 +3022,61 @@ export async function aiMergeTask(
return result;
}
/** Best-effort `git fetch origin <currentBranch>` + fast-forward of local
* HEAD when origin is strictly ahead. Returns silently on any failure
* (no remote configured, network down, divergent local commits, etc.).
* Only called for the smart strategies, which want to avoid resolving a
* conflict against a stale local base. */
async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> {
let currentBranch: string;
try {
currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
}).trim();
} catch {
return;
}
if (!currentBranch || currentBranch === "HEAD") return;
try {
await execAsync(`git fetch origin "${currentBranch}"`, { cwd: rootDir });
} catch (err) {
mergerLog.log(`${taskId}: pre-merge fetch failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
return;
}
// Detect divergence: local must be strictly behind remote (no local-only commits).
let behind = 0;
let ahead = 0;
try {
const counts = execSync(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
}).trim();
const [b, a] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
behind = b;
ahead = a;
} catch {
return;
}
if (behind === 0) return; // already up to date
if (ahead > 0) {
mergerLog.log(`${taskId}: local ${currentBranch} has ${ahead} unpushed commit(s); skipping fast-forward`);
return;
}
try {
await execAsync(`git merge --ff-only "origin/${currentBranch}"`, { cwd: rootDir });
mergerLog.log(`${taskId}: fast-forwarded ${currentBranch} by ${behind} commit(s) from origin`);
} catch (err) {
mergerLog.log(`${taskId}: fast-forward failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
}
}
/** Get the resolution strategy based on attempt number and settings.
* `mergeConflictStrategy` controls the FALLBACK on attempt 3 (and gates the
* whole cascade on "abort"); attempts 12 always try AI then auto-resolve so
@@ -3019,7 +3084,7 @@ export async function aiMergeTask(
function getResolutionStrategy(
attemptNum: 1 | 2 | 3,
smartConflictResolution: boolean,
mergeConflictStrategy: NonNullable<MergeConflictStrategy> = "smart",
mergeConflictStrategy: CanonicalMergeConflictStrategy = "smart-prefer-main",
): MergeResult["resolutionStrategy"] {
if (!smartConflictResolution || attemptNum === 1) {
return "ai";
@@ -3031,11 +3096,11 @@ function getResolutionStrategy(
switch (mergeConflictStrategy) {
case "ai-only":
return "ai";
case "prefer-main":
case "smart-prefer-main":
return "ours";
case "abort":
return "abort";
case "smart":
case "smart-prefer-branch":
default:
return "theirs";
}
@@ -3071,7 +3136,7 @@ interface MergeAttemptParams {
diffStat: string;
includeTaskId: boolean;
smartConflictResolution: boolean;
mergeConflictStrategy: NonNullable<MergeConflictStrategy>;
mergeConflictStrategy: CanonicalMergeConflictStrategy;
attemptNum: 1 | 2 | 3;
options: MergerOptions;
result: MergeResult;
@@ -3123,10 +3188,9 @@ async function executeMergeAttempt(
// Attempt 3: dispatch on the configured fallback strategy.
// Note: "ai-only" and "abort" are filtered out by the mergeAttempt cascade
// before reaching here — only "smart" (theirs) and "prefer-main" (ours)
// legitimately run attempt 3.
// before reaching here — only the two smart variants legitimately run attempt 3.
if (attemptNum === 3) {
if (params.mergeConflictStrategy === "prefer-main") {
if (params.mergeConflictStrategy === "smart-prefer-main") {
return attemptWithSideStrategy(params, "ours");
}
return attemptWithSideStrategy(params, "theirs");
@@ -3404,8 +3468,8 @@ async function executeMergeAttempt(
/**
* Attempt 3: Use git merge -X{theirs,ours} --squash strategy.
* Side controls which version wins on conflicts:
* - "theirs" — the task branch wins (default fallback)
* - "ours" — the main branch wins (used by mergeConflictStrategy="prefer-main")
* - "theirs" — the task branch wins (mergeConflictStrategy="smart-prefer-branch")
* - "ours" — the main branch wins (mergeConflictStrategy="smart-prefer-main", default)
*/
async function attemptWithSideStrategy(
params: MergeAttemptParams,

View File

@@ -193,7 +193,7 @@ function computeReleasePlan() {
fail(`Failed to compute release plan:\n${r.stderr || r.stdout}`);
}
const plan = JSON.parse(readFileSync(out, "utf8"));
try { unlinkSync(out); } catch {}
try { unlinkSync(out); } catch { /* tmp cleanup is best-effort */ }
const bumpedReleases = (plan.releases || []).filter((rel) => rel.type !== "none");
if (bumpedReleases.length === 0) {
@@ -290,7 +290,7 @@ function findPackageDir(name) {
try {
const pkg = JSON.parse(readFileSync(p, "utf8"));
if (pkg.name === name) return join(root, entry);
} catch {}
} catch { /* skip unreadable/broken package.json */ }
}
}
return null;