From d9b17dea01d740285aaf127cbd0a0f83a5bd161f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 18:11:41 -0700 Subject: [PATCH] FN-7086: default dirty checkout merge sync Default AI merge landing to preserve legacy dirty-checkout synchronization for new and partially configured projects. - Default resolved project merger settings to allow dirty local checkout sync while preserving explicit false values. - Pass the resolved dirty-checkout sync policy through single-repo and workspace AI merge landing paths. - Update settings UI defaults, type docs, regression coverage, and release notes for the new behavior. Files changed: .changeset/fn-7086-allow-dirty-sync-default.md | 7 ++++ packages/core/src/__tests__/store-settings.test.ts | 40 ++++++++++++++++++++++ packages/core/src/settings-schema.ts | 6 +++- packages/core/src/store.ts | 12 +++++++ packages/core/src/types.ts | 6 ++-- .../dashboard/app/components/SettingsModal.tsx | 2 +- packages/engine/src/merger-ai.ts | 35 +++++++++++-------- 7 files changed, 89 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-7086 Fusion-Task-Lineage: 65871e7c-af0c-45b9-af54-749d56dfadd5 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7086-allow-dirty-sync-default.md | 7 ++++ .../core/src/__tests__/store-settings.test.ts | 40 +++++++++++++++++++ packages/core/src/settings-schema.ts | 6 ++- packages/core/src/store.ts | 12 ++++++ packages/core/src/types.ts | 6 +-- .../app/components/SettingsModal.tsx | 2 +- packages/engine/src/merger-ai.ts | 35 +++++++++------- 7 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 .changeset/fn-7086-allow-dirty-sync-default.md diff --git a/.changeset/fn-7086-allow-dirty-sync-default.md b/.changeset/fn-7086-allow-dirty-sync-default.md new file mode 100644 index 0000000000..a3a6503722 --- /dev/null +++ b/.changeset/fn-7086-allow-dirty-sync-default.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: New projects now default AI merge to sync a dirty checked-out integration branch. +category: feature +dev: Flips DEFAULT_PROJECT_SETTINGS merger.allowDirtyLocalCheckoutSync from false to true; explicit persisted values still win, with no existing-project migration. diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index b4090ab48f..17b194aa7b 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -1663,6 +1663,46 @@ describe("TaskStore", () => { expect(scopedFast.project.ephemeralAgentsEnabled).toBe(false); }); + it("defaults merger.allowDirtyLocalCheckoutSync to true for new projects", async () => { + const fast = await harness.store().getSettingsFast(); + const regular = await harness.store().getSettings(); + const scopedFast = await harness.store().getSettingsByScopeFast(); + + expect(fast.merger?.allowDirtyLocalCheckoutSync).toBe(true); + expect(regular.merger?.allowDirtyLocalCheckoutSync).toBe(true); + expect(scopedFast.project.merger?.allowDirtyLocalCheckoutSync).toBe(true); + }); + + it("falls back to merger.allowDirtyLocalCheckoutSync=true when upgrading partial merger settings", async () => { + const db = (harness.store() as any).db; + const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined; + const existingSettings = row?.settings ? JSON.parse(row.settings) : {}; + existingSettings.merger = { mode: "ai", maxReviewPasses: 3 }; + db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings)); + + const fast = await harness.store().getSettingsFast(); + const regular = await harness.store().getSettings(); + const scopedFast = await harness.store().getSettingsByScopeFast(); + + expect(fast.merger?.allowDirtyLocalCheckoutSync).toBe(true); + expect(regular.merger?.allowDirtyLocalCheckoutSync).toBe(true); + expect(scopedFast.project.merger?.allowDirtyLocalCheckoutSync).toBe(true); + }); + + it("preserves explicit merger.allowDirtyLocalCheckoutSync=false", async () => { + await harness.store().updateSettings({ + merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false }, + }); + + const fast = await harness.store().getSettingsFast(); + const regular = await harness.store().getSettings(); + const scopedFast = await harness.store().getSettingsByScopeFast(); + + expect(fast.merger?.allowDirtyLocalCheckoutSync).toBe(false); + expect(regular.merger?.allowDirtyLocalCheckoutSync).toBe(false); + expect(scopedFast.project.merger?.allowDirtyLocalCheckoutSync).toBe(false); + }); + it("returns the same merged result as getSettings()", async () => { await harness.store().updateGlobalSettings({ themeMode: "light", ntfyEnabled: true }); await harness.store().updateSettings({ maxConcurrent: 5, autoMerge: false }); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 32297c588c..aa60d48b05 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -362,7 +362,11 @@ export const DEFAULT_PROJECT_SETTINGS = { * Project settings own the auto-merge conflict retry cap because existing engine/dashboard consumers already resolve project settings; the default imports core's stall-detection fallback to keep every surface on the historical value of 3. */ maxAutoMergeRetries: DEFAULT_MAX_AUTO_MERGE_RETRIES, - merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false }, + /** + * FNXC:Merge 2026-06-26-00:00: + * New and unconfigured projects default AI merge to sync a dirty checked-out integration branch, restoring the legacy stash → fast-forward → restore landing behavior. Explicit persisted merger.allowDirtyLocalCheckoutSync values still win, and no existing-project migration stamps this default into storage. + */ + merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true }, mergeDiffVolumeMinLines: undefined, mergeDiffVolumeThreshold: undefined, mergeDiffVolumeAllowlist: undefined, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 0c9d5a885a..691048717f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3776,6 +3776,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} ...DEFAULT_SETTINGS, ...globalSettings, ...projectSettings, + /** + * FNXC:Merge 2026-06-26-00:00: + * The top-level settings spread is shallow, so legacy project rows with a partial merger object would otherwise drop nested defaults such as allowDirtyLocalCheckoutSync. Merge the nested default explicitly here and in fast/scoped reads, mirroring the worktrunk resolver and ephemeralAgentsEnabled upgrade fallback precedents. + */ + merger: { ...DEFAULT_PROJECT_SETTINGS.merger, ...(projectSettings as Partial).merger }, worktrunk: resolveWorktrunkSettings( globalSettings.worktrunk, (projectSettings as Partial).worktrunk, @@ -3823,6 +3828,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} ...DEFAULT_SETTINGS, ...globalSettings, ...projectSettings, + merger: { ...DEFAULT_PROJECT_SETTINGS.merger, ...projectSettings?.merger }, worktrunk: resolveWorktrunkSettings(globalSettings.worktrunk, projectSettings?.worktrunk), }; try { @@ -3868,6 +3874,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; } + if (canonicalizedProject.merger?.allowDirtyLocalCheckoutSync === undefined) { + canonicalizedProject.merger = { ...DEFAULT_PROJECT_SETTINGS.merger, ...canonicalizedProject.merger }; + } return { global: globalSettings, project: canonicalizedProject }; } @@ -3912,6 +3921,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; } + if (canonicalizedProject.merger?.allowDirtyLocalCheckoutSync === undefined) { + canonicalizedProject.merger = { ...DEFAULT_PROJECT_SETTINGS.merger, ...canonicalizedProject.merger }; + } return { global: globalSettings, project: canonicalizedProject }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b1554dfc9c..887a7e363d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -549,9 +549,9 @@ export interface MergerSettings { * validator/reviewer model lane — there is no merge-specific model setting. */ maxReviewPasses?: number; /** Dangerous compatibility escape hatch for the AI merge landing path. - * When false (default), Fusion refuses to land an AI merge if the checked-out - * integration worktree is dirty. When true, restores the legacy stash → - * fast-forward → restore behavior for operators who explicitly accept that + * When true (default for resolved project settings), Fusion restores the legacy + * stash → fast-forward → restore behavior when the checked-out integration + * worktree is dirty. Set false to explicitly opt out and fail closed before * unrelated local edits can be reintroduced after landing. */ allowDirtyLocalCheckoutSync?: boolean; } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 4ff4022098..b435c38412 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -729,7 +729,7 @@ export function SettingsModal({ maxAutoMergeRetries: 3, mergeIntegrationWorktree: "reuse-task-worktree", mergeAdvanceAutoSync: "stash-and-ff", - merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false }, + merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true }, recycleWorktrees: false, executorAllowSiblingBranchRename: false, worktreeNaming: "random", diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index f8b2b26ab6..ae5cd1f9c1 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -26,11 +26,10 @@ * common path. * 4. CAS fast-forward of `refs/heads/` to the squash (retry on a * concurrent advance by rebuilding on the new tip). - * 5. Sync the user's local checkout to the new tip only when it is clean by - * default. Dirty checked-out integration worktrees fail closed before the - * branch ref advances, preventing unrelated local changes from poisoning - * subsequent merge runs. An explicit escape hatch can opt into the legacy - * stash → ff → restore path. + * 5. Sync the user's local checkout to the new tip. Resolved project settings + * now default to the legacy dirty-checkout stash → ff → restore path, while + * an explicit project opt-out can still fail closed before the branch ref + * advances. * * Pure helpers (prompt builders, verdict parser) are exported for unit testing; * the orchestrator accepts injectable agent functions for the same reason. @@ -382,10 +381,11 @@ async function hasUnresolvedConflicts(cwd: string): Promise { * checkout — `git merge --ff-only ` (it moves both the branch ref * and the working tree). The user's real dirty state is read accurately * BEFORE the fast-forward (while HEAD === tipSha, so `git status` isn't - * polluted by the ref move). By default a dirty checked-out integration - * worktree is a hard blocker; callers must explicitly opt into stash/pop - * reconciliation. If the checkout HEAD has already moved off tipSha, - * that's a concurrent advance → rebuild. + * polluted by the ref move). Project-resolved settings default to stash/pop + * reconciliation for dirty integration checkouts, but this lower-level + * helper still requires direct callers to opt in; otherwise dirty state is + * a hard blocker. If the checkout HEAD has already moved off tipSha, that's + * a concurrent advance → rebuild. * * B. The checkout is on a different branch (or the target isn't checked out * here). We advance the ref atomically via `update-ref` (CAS) and leave the @@ -405,9 +405,10 @@ export async function landSquash(input: { resolveConflicts?: (cwd: string, prompt: string) => Promise; /** * Explicit escape hatch for callers that truly want Fusion to stash/pop real - * local edits in the checked-out integration worktree. The default is false: - * automation must not land a task while also manufacturing uncommitted local - * state in the project root, because that poisons subsequent merge runs. + * local edits in the checked-out integration worktree. + * + * FNXC:Merge 2026-06-26-00:00: + * Resolved project settings default merger.allowDirtyLocalCheckoutSync to true for legacy operator UX, but this helper's parameter default intentionally remains false so direct/programmatic callers and tests fail closed unless they make the dirty-checkout sync policy explicit. */ allowDirtyLocalCheckoutSync?: boolean; }): Promise { @@ -855,6 +856,11 @@ export async function runAiMerge( const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit); const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt()); const includeTaskId = settings.includeTaskIdInCommit !== false; + /* + * FNXC:Merge 2026-06-26-00:00: + * runAiMerge callers may rely on already-resolved project settings instead of forwarding MergerOptions. Preserve an explicit option false, otherwise inherit merger.allowDirtyLocalCheckoutSync so new-project default true reaches both single-repo and workspace landing paths. + */ + const allowDirtyLocalCheckoutSync = options.allowDirtyLocalCheckoutSync ?? (settings.merger?.allowDirtyLocalCheckoutSync === true); // Trailers that link the squash commit to the board task (FN-id + lineage) and deterministic co-author attribution. const trailers = taskTrailers(taskId, task.lineageId, settings); const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; @@ -870,7 +876,7 @@ export async function runAiMerge( taskId, settings, audit, log, setStatus, maxPasses, mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, - allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, + allowDirtyLocalCheckoutSync, store, }); @@ -1107,6 +1113,7 @@ export async function landWorkspaceTask( const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit); const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt()); const includeTaskId = settings.includeTaskIdInCommit !== false; + const allowDirtyLocalCheckoutSync = options.allowDirtyLocalCheckoutSync ?? (settings.merger?.allowDirtyLocalCheckoutSync === true); const trailers = taskTrailers(taskId, task.lineageId, settings); const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; @@ -1199,7 +1206,7 @@ export async function landWorkspaceTask( taskId, settings, audit, log, setStatus, maxPasses, mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, - allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, + allowDirtyLocalCheckoutSync, // FNXC:Workspace 2026-06-24-23:50: one sub-repo's dependency-sync failure must not block // landing the others — degrade verification for that repo, still land the git squash. nonFatalDependencySync: true,