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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-26 18:11:41 -07:00
parent 0ddfe9a422
commit d9b17dea01
7 changed files with 89 additions and 19 deletions

View File

@@ -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.

View File

@@ -1663,6 +1663,46 @@ describe("TaskStore", () => {
expect(scopedFast.project.ephemeralAgentsEnabled).toBe(false); 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 () => { it("returns the same merged result as getSettings()", async () => {
await harness.store().updateGlobalSettings({ themeMode: "light", ntfyEnabled: true }); await harness.store().updateGlobalSettings({ themeMode: "light", ntfyEnabled: true });
await harness.store().updateSettings({ maxConcurrent: 5, autoMerge: false }); await harness.store().updateSettings({ maxConcurrent: 5, autoMerge: false });

View File

@@ -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. * 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, 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, mergeDiffVolumeMinLines: undefined,
mergeDiffVolumeThreshold: undefined, mergeDiffVolumeThreshold: undefined,
mergeDiffVolumeAllowlist: undefined, mergeDiffVolumeAllowlist: undefined,

View File

@@ -3776,6 +3776,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
...globalSettings, ...globalSettings,
...projectSettings, ...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<Settings>).merger },
worktrunk: resolveWorktrunkSettings( worktrunk: resolveWorktrunkSettings(
globalSettings.worktrunk, globalSettings.worktrunk,
(projectSettings as Partial<Settings>).worktrunk, (projectSettings as Partial<Settings>).worktrunk,
@@ -3823,6 +3828,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
...globalSettings, ...globalSettings,
...projectSettings, ...projectSettings,
merger: { ...DEFAULT_PROJECT_SETTINGS.merger, ...projectSettings?.merger },
worktrunk: resolveWorktrunkSettings(globalSettings.worktrunk, projectSettings?.worktrunk), worktrunk: resolveWorktrunkSettings(globalSettings.worktrunk, projectSettings?.worktrunk),
}; };
try { try {
@@ -3868,6 +3874,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { if (canonicalizedProject.ephemeralAgentsEnabled === undefined) {
canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled;
} }
if (canonicalizedProject.merger?.allowDirtyLocalCheckoutSync === undefined) {
canonicalizedProject.merger = { ...DEFAULT_PROJECT_SETTINGS.merger, ...canonicalizedProject.merger };
}
return { global: globalSettings, project: canonicalizedProject }; return { global: globalSettings, project: canonicalizedProject };
} }
@@ -3912,6 +3921,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { if (canonicalizedProject.ephemeralAgentsEnabled === undefined) {
canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled;
} }
if (canonicalizedProject.merger?.allowDirtyLocalCheckoutSync === undefined) {
canonicalizedProject.merger = { ...DEFAULT_PROJECT_SETTINGS.merger, ...canonicalizedProject.merger };
}
return { global: globalSettings, project: canonicalizedProject }; return { global: globalSettings, project: canonicalizedProject };
} }

View File

@@ -549,9 +549,9 @@ export interface MergerSettings {
* validator/reviewer model lane — there is no merge-specific model setting. */ * validator/reviewer model lane — there is no merge-specific model setting. */
maxReviewPasses?: number; maxReviewPasses?: number;
/** Dangerous compatibility escape hatch for the AI merge landing path. /** Dangerous compatibility escape hatch for the AI merge landing path.
* When false (default), Fusion refuses to land an AI merge if the checked-out * When true (default for resolved project settings), Fusion restores the legacy
* integration worktree is dirty. When true, restores the legacy stash → * stash → fast-forward → restore behavior when the checked-out integration
* fast-forward → restore behavior for operators who explicitly accept that * worktree is dirty. Set false to explicitly opt out and fail closed before
* unrelated local edits can be reintroduced after landing. */ * unrelated local edits can be reintroduced after landing. */
allowDirtyLocalCheckoutSync?: boolean; allowDirtyLocalCheckoutSync?: boolean;
} }

View File

@@ -729,7 +729,7 @@ export function SettingsModal({
maxAutoMergeRetries: 3, maxAutoMergeRetries: 3,
mergeIntegrationWorktree: "reuse-task-worktree", mergeIntegrationWorktree: "reuse-task-worktree",
mergeAdvanceAutoSync: "stash-and-ff", mergeAdvanceAutoSync: "stash-and-ff",
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false }, merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true },
recycleWorktrees: false, recycleWorktrees: false,
executorAllowSiblingBranchRename: false, executorAllowSiblingBranchRename: false,
worktreeNaming: "random", worktreeNaming: "random",

View File

@@ -26,11 +26,10 @@
* common path. * common path.
* 4. CAS fast-forward of `refs/heads/<integration>` to the squash (retry on a * 4. CAS fast-forward of `refs/heads/<integration>` to the squash (retry on a
* concurrent advance by rebuilding on the new tip). * 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 * 5. Sync the user's local checkout to the new tip. Resolved project settings
* default. Dirty checked-out integration worktrees fail closed before the * now default to the legacy dirty-checkout stash → ff → restore path, while
* branch ref advances, preventing unrelated local changes from poisoning * an explicit project opt-out can still fail closed before the branch ref
* subsequent merge runs. An explicit escape hatch can opt into the legacy * advances.
* stash → ff → restore path.
* *
* Pure helpers (prompt builders, verdict parser) are exported for unit testing; * Pure helpers (prompt builders, verdict parser) are exported for unit testing;
* the orchestrator accepts injectable agent functions for the same reason. * the orchestrator accepts injectable agent functions for the same reason.
@@ -382,10 +381,11 @@ async function hasUnresolvedConflicts(cwd: string): Promise<boolean> {
* checkout — `git merge --ff-only <squash>` (it moves both the branch ref * checkout — `git merge --ff-only <squash>` (it moves both the branch ref
* and the working tree). The user's real dirty state is read accurately * 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 * BEFORE the fast-forward (while HEAD === tipSha, so `git status` isn't
* polluted by the ref move). By default a dirty checked-out integration * polluted by the ref move). Project-resolved settings default to stash/pop
* worktree is a hard blocker; callers must explicitly opt into stash/pop * reconciliation for dirty integration checkouts, but this lower-level
* reconciliation. If the checkout HEAD has already moved off tipSha, * helper still requires direct callers to opt in; otherwise dirty state is
* that's a concurrent advance → rebuild. * 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 * 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 * 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<void>; resolveConflicts?: (cwd: string, prompt: string) => Promise<void>;
/** /**
* Explicit escape hatch for callers that truly want Fusion to stash/pop real * 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: * local edits in the checked-out integration worktree.
* automation must not land a task while also manufacturing uncommitted local *
* state in the project root, because that poisons subsequent merge runs. * 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; allowDirtyLocalCheckoutSync?: boolean;
}): Promise<LandResult> { }): Promise<LandResult> {
@@ -855,6 +856,11 @@ export async function runAiMerge(
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit); const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt()); const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
const includeTaskId = settings.includeTaskIdInCommit !== false; 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. // 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 trailers = taskTrailers(taskId, task.lineageId, settings);
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; 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, taskId, settings, audit, log, setStatus, maxPasses,
mergeAgent, reviewAgent, stashResolveAgent, mergeAgent, reviewAgent, stashResolveAgent,
includeTaskId, trailers, taskTitle, signal: options.signal, includeTaskId, trailers, taskTitle, signal: options.signal,
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, allowDirtyLocalCheckoutSync,
store, store,
}); });
@@ -1107,6 +1113,7 @@ export async function landWorkspaceTask(
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit); const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt()); const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
const includeTaskId = settings.includeTaskIdInCommit !== false; const includeTaskId = settings.includeTaskIdInCommit !== false;
const allowDirtyLocalCheckoutSync = options.allowDirtyLocalCheckoutSync ?? (settings.merger?.allowDirtyLocalCheckoutSync === true);
const trailers = taskTrailers(taskId, task.lineageId, settings); const trailers = taskTrailers(taskId, task.lineageId, settings);
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; 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, taskId, settings, audit, log, setStatus, maxPasses,
mergeAgent, reviewAgent, stashResolveAgent, mergeAgent, reviewAgent, stashResolveAgent,
includeTaskId, trailers, taskTitle, signal: options.signal, 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 // 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. // landing the others — degrade verification for that repo, still land the git squash.
nonFatalDependencySync: true, nonFatalDependencySync: true,