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);
});
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 });

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.
*/
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,

View File

@@ -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<Settings>).merger },
worktrunk: resolveWorktrunkSettings(
globalSettings.worktrunk,
(projectSettings as Partial<Settings>).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 };
}

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -26,11 +26,10 @@
* common path.
* 4. CAS fast-forward of `refs/heads/<integration>` 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<boolean> {
* 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
* 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<void>;
/**
* 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<LandResult> {
@@ -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,