FN-6245: keep default auto-merge tasks on live settings

Keep tasks without explicit auto-merge overrides tied to the live global setting instead of freezing it at review entry.

- Stop in-review entry effects from copying the project autoMerge setting onto tasks.
- Preserve explicit per-task auto-merge overrides while allowing default tasks to follow later global toggles.
- Add regression coverage for move-to-review behavior and live auto-merge resolution.
- Document the default auto-merge behavior and add a patch changeset.

Files changed:
 .changeset/fn-6245-automerge-toggle.md             |  5 ++
 docs/settings-reference.md                         |  2 +-
 .../__tests__/move-task-characterization.test.ts   | 48 ++++++++++++++---
 packages/core/src/__tests__/store-movement.test.ts | 63 +++++++++++++++-------
 packages/core/src/__tests__/task-merge.test.ts     | 12 ++++-
 packages/core/src/default-workflow-hooks.ts        | 28 ++++++----
 packages/core/src/store.ts                         | 13 ++---
 7 files changed, 121 insertions(+), 50 deletions(-)

Fusion-Task-Id: FN-6245
Fusion-Task-Lineage: 1f349f0c-e513-4d82-b77a-9d0c353c163c
This commit is contained in:
gsxdsm
2026-06-11 18:08:17 -07:00
parent 3cb0f9f371
commit 9a78814418
7 changed files with 121 additions and 50 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Stop review entry from freezing the global auto-merge setting onto tasks. Tasks without an explicit per-task auto-merge override now continue to follow the live global setting, so toggling global auto-merge off stops newly-entered non-override in-review tasks from being auto-merge processed.

View File

@@ -280,7 +280,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
| `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. |
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled). For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |

View File

@@ -9,7 +9,7 @@
// - merge-blocker on in-review → done (user source)
// - userPaused set only for user-source in-progress → todo
// - reopen field/step resets on in-review/done → todo|triage
// - autoMerge stamping on → in-review
// - autoMerge live-global inheritance on → in-review
// - timing fields (cumulativeActiveMs / executionStartedAt) on in-progress
//
// It runs GREEN against the unmodified store first, then runs forever against
@@ -17,11 +17,13 @@
// Any divergence between the two flag states is a U4 parity FAILURE.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { allowsAutoMergeProcessing, resolveEffectiveAutoMerge } from "../task-merge.js";
import { VALID_TRANSITIONS } from "../types.js";
import type { Column, Task } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
const MOVE_SOURCES = ["user", "engine", "scheduler"] as const;
// Flag states the characterization runs against. OFF is the legacy path; ON is
// the workflow-resolved path. The default workflow MUST reproduce identical
@@ -83,7 +85,7 @@ for (const flag of flagStates) {
describe("transition allow/reject matrix (every from×to×moveSource)", () => {
for (const from of ALL_COLUMNS) {
for (const to of ALL_COLUMNS) {
for (const moveSource of ["user", "engine"] as const) {
for (const moveSource of MOVE_SOURCES) {
const allowed = from === to || VALID_TRANSITIONS[from].includes(to);
const label = `${from} → ${to} [${moveSource}] should ${allowed ? "ALLOW" : "REJECT"}`;
it(label, async () => {
@@ -170,15 +172,45 @@ for (const flag of flagStates) {
});
});
describe("autoMerge stamping (→ in-review)", () => {
it("stamps autoMerge from settings when undefined", async () => {
await store.updateSettings({ autoMerge: true });
const task = await seedInColumn("in-progress");
const result = await store.moveTask(task.id, "in-review", {
moveSource: "user",
describe("autoMerge live-global inheritance (→ in-review)", () => {
for (const moveSource of MOVE_SOURCES) {
it(`leaves undefined autoMerge to follow live settings for ${moveSource}-source moves`, async () => {
await store.updateSettings({ autoMerge: true });
const task = await seedInColumn("in-progress");
const result = await store.moveTask(task.id, "in-review", {
moveSource,
allowDirectInReviewMove: true,
});
expect(result.autoMerge).toBeUndefined();
expect(allowsAutoMergeProcessing(result, { autoMerge: false })).toBe(false);
expect(allowsAutoMergeProcessing(result, { autoMerge: true })).toBe(true);
expect(resolveEffectiveAutoMerge(result, { autoMerge: false })).toBe(false);
expect(resolveEffectiveAutoMerge(result, { autoMerge: true })).toBe(true);
});
}
it("preserves explicit task autoMerge overrides", async () => {
await store.updateSettings({ autoMerge: false });
const explicitTrue = await seedInColumn("in-progress");
await store.updateTask(explicitTrue.id, { autoMerge: true });
const trueResult = await store.moveTask(explicitTrue.id, "in-review", {
moveSource: "engine",
allowDirectInReviewMove: true,
});
expect(result.autoMerge).toBe(true);
expect(trueResult.autoMerge).toBe(true);
expect(allowsAutoMergeProcessing(trueResult, { autoMerge: false })).toBe(true);
await store.updateSettings({ autoMerge: true });
const explicitFalse = await seedInColumn("in-progress");
await store.updateTask(explicitFalse.id, { autoMerge: false });
const falseResult = await store.moveTask(explicitFalse.id, "in-review", {
moveSource: "scheduler",
allowDirectInReviewMove: true,
});
expect(falseResult.autoMerge).toBe(false);
expect(resolveEffectiveAutoMerge(falseResult, { autoMerge: true })).toBe(false);
expect(resolveEffectiveAutoMerge(falseResult, { autoMerge: false })).toBe(false);
});
});

View File

@@ -7,6 +7,7 @@ import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { TaskStore, TaskHasDependentsError } from "../store.js";
import { allowsAutoMergeProcessing, resolveEffectiveAutoMerge } from "../task-merge.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
@@ -60,36 +61,58 @@ describe("TaskStore", () => {
});
describe("moveTask — autoMerge snapshot on in-review", () => {
it("snapshots global autoMerge=true when task override is undefined", async () => {
describe("moveTask — autoMerge follows live settings on in-review", () => {
async function createInProgressTask(description: string): Promise<Task> {
const task = await store.createTask({ description });
await store.moveTask(task.id, "todo");
return store.moveTask(task.id, "in-progress");
}
it("does not snapshot global autoMerge=true when task override is undefined", async () => {
await store.updateSettings({ autoMerge: true });
const task = await store.createTask({ description: "snapshot true" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const task = await createInProgressTask("no snapshot true");
const moved = await store.moveTask(task.id, "in-review");
expect(moved.autoMerge).toBe(true);
expect(moved.autoMerge).toBeUndefined();
expect(allowsAutoMergeProcessing(moved, { autoMerge: true })).toBe(true);
expect(allowsAutoMergeProcessing(moved, { autoMerge: false })).toBe(false);
});
it("snapshots global autoMerge=false when task override is undefined", async () => {
it("does not snapshot global autoMerge=false when task override is undefined", async () => {
await store.updateSettings({ autoMerge: false });
const task = await store.createTask({ description: "snapshot false" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const task = await createInProgressTask("no snapshot false");
const moved = await store.moveTask(task.id, "in-review");
expect(moved.autoMerge).toBe(false);
expect(moved.autoMerge).toBeUndefined();
expect(resolveEffectiveAutoMerge(moved, { autoMerge: false })).toBe(false);
expect(resolveEffectiveAutoMerge(moved, { autoMerge: true })).toBe(true);
});
it("preserves explicit task autoMerge override when entering in-review", async () => {
await store.updateSettings({ autoMerge: false });
const task = await store.createTask({ description: "explicit override" });
await store.updateTask(task.id, { autoMerge: true });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
it("tracks live global toggles for undefined while preserving explicit overrides", async () => {
await store.updateSettings({ autoMerge: true });
const inherited = await createInProgressTask("inherits live global");
const inheritedMoved = await store.moveTask(inherited.id, "in-review");
const moved = await store.moveTask(task.id, "in-review");
expect(moved.autoMerge).toBe(true);
expect(inheritedMoved.autoMerge).toBeUndefined();
expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: false })).toBe(false);
expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: true })).toBe(true);
const explicitTrue = await createInProgressTask("explicit true override");
await store.updateTask(explicitTrue.id, { autoMerge: true });
const explicitTrueMoved = await store.moveTask(explicitTrue.id, "in-review");
expect(explicitTrueMoved.autoMerge).toBe(true);
expect(allowsAutoMergeProcessing(explicitTrueMoved, { autoMerge: false })).toBe(true);
expect(resolveEffectiveAutoMerge(explicitTrueMoved, { autoMerge: false })).toBe(true);
const explicitFalse = await createInProgressTask("explicit false override");
await store.updateTask(explicitFalse.id, { autoMerge: false });
const explicitFalseMoved = await store.moveTask(explicitFalse.id, "in-review");
expect(explicitFalseMoved.autoMerge).toBe(false);
expect(allowsAutoMergeProcessing(explicitFalseMoved, { autoMerge: false })).toBe(false);
expect(resolveEffectiveAutoMerge(explicitFalseMoved, { autoMerge: false })).toBe(false);
expect(resolveEffectiveAutoMerge(explicitFalseMoved, { autoMerge: true })).toBe(false);
});
});

View File

@@ -45,6 +45,13 @@ describe("resolveEffectiveAutoMerge", () => {
it("falls back to global false when task value is undefined", () => {
expect(resolveEffectiveAutoMerge({ autoMerge: undefined }, { autoMerge: false })).toBe(false);
});
it("tracks live global toggles while task value remains undefined", () => {
const task = { autoMerge: undefined };
expect(resolveEffectiveAutoMerge(task, { autoMerge: true })).toBe(true);
expect(resolveEffectiveAutoMerge(task, { autoMerge: false })).toBe(false);
expect(resolveEffectiveAutoMerge(task, { autoMerge: true })).toBe(true);
});
});
describe("allowsAutoMergeProcessing", () => {
@@ -53,7 +60,10 @@ describe("allowsAutoMergeProcessing", () => {
});
it("blocks tasks without an explicit override when the global setting is off", () => {
expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: false })).toBe(false);
const task = { autoMerge: undefined };
expect(allowsAutoMergeProcessing(task, { autoMerge: true })).toBe(true);
expect(allowsAutoMergeProcessing(task, { autoMerge: false })).toBe(false);
expect(allowsAutoMergeProcessing(task, { autoMerge: true })).toBe(true);
expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: false })).toBe(false);
});

View File

@@ -2,9 +2,10 @@
* Default-workflow trait hook implementations (U4).
*
* The legacy per-column side effects of `moveTaskInternal` — timing /
* `cumulativeActiveMs` accounting, reopen field/step resets, autoMerge stamping
* + merge-queue enqueue, and abort-on-exit (hard-cancel incl. `userPaused` only
* for user-source moves) — become the default workflow's trait hook
* `cumulativeActiveMs` accounting, reopen field/step resets, in-review
* auto-merge handoff preparation + merge-queue enqueue, and abort-on-exit
* (hard-cancel incl. `userPaused` only for user-source moves) — become the
* default workflow's trait hook
* implementations, registered through U2's DI seam (`registerTraitHookImpl`).
*
* IMPORTANT (per U4): this is the FLAG-ON path. The legacy inline code in
@@ -73,7 +74,11 @@ export interface DefaultWorkflowMoveContext {
/** True when guards + abort-on-exit are bypassed (engine/recovery, KTD-9). */
bypassGuards: boolean;
movedAt: string;
/** Settings snapshot for autoMerge stamping (only read when entering review). */
/**
* Settings snapshot available to move effects that need it. Review entry must
* not copy global `autoMerge` onto the task; an undefined task value follows
* the live global setting at processing time.
*/
settings: Pick<Settings, "autoMerge"> | undefined;
/** Move options that influence reopen/timing semantics. */
options: {
@@ -165,15 +170,16 @@ export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void
}
}
/** `merge` trait onEnter (in-review): autoMerge stamping + scheduler-state
* clearing. The queue enqueue itself is in-txn and store-owned (handoff path);
* the field effects mirror the legacy in-review block. */
/** `merge` trait onEnter (in-review): scheduler-state clearing while
* preserving explicit per-task autoMerge overrides. The queue enqueue itself is
* in-txn and store-owned (handoff path); the field effects mirror the legacy
* in-review block. Keep this flag-ON path in sync with the flag-OFF inline
* block in store.ts. */
export function applyInReviewEnterEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, toColumn, settings } = ctx;
const { task, toColumn } = ctx;
if (toColumn !== "in-review") return;
if (task.autoMerge === undefined && settings) {
task.autoMerge = settings.autoMerge;
}
// Do not snapshot the global autoMerge setting here. Undefined means "follow
// the live global setting"; only an explicit task value should stay sticky.
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
if (task.status === "queued") {

View File

@@ -6832,11 +6832,6 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
}
}
const settingsForInReview =
toColumn === "in-review" && task.autoMerge === undefined
? await this.getSettingsFast()
: undefined;
const movedAt = internal.now ?? new Date().toISOString();
task.column = toColumn;
task.columnMovedAt = movedAt;
@@ -6854,7 +6849,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
moveSource,
bypassGuards,
movedAt,
settings: settingsForInReview,
settings: undefined,
options: {
preserveStatus: options?.preserveStatus,
preserveResumeState: options?.preserveResumeState,
@@ -6958,9 +6953,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
}
if (toColumn === "in-review") {
if (task.autoMerge === undefined && settingsForInReview) {
task.autoMerge = settingsForInReview.autoMerge;
}
// Keep this flag-OFF inline path in sync with applyInReviewEnterEffects.
// Do not snapshot global autoMerge: undefined follows the live setting,
// while explicit per-task true/false overrides remain sticky.
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
// Clear scheduler-side dispatch state: `queued`, `blockedBy`, and