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:
7
.changeset/fn-7086-allow-dirty-sync-default.md
Normal file
7
.changeset/fn-7086-allow-dirty-sync-default.md
Normal 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.
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user