From 9a78814418241e5f90be401c1a156c8d383c1780 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 11 Jun 2026 18:08:17 -0700 Subject: [PATCH] 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 --- .changeset/fn-6245-automerge-toggle.md | 5 ++ docs/settings-reference.md | 2 +- .../move-task-characterization.test.ts | 50 ++++++++++++--- .../core/src/__tests__/store-movement.test.ts | 61 +++++++++++++------ .../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(-) create mode 100644 .changeset/fn-6245-automerge-toggle.md diff --git a/.changeset/fn-6245-automerge-toggle.md b/.changeset/fn-6245-automerge-toggle.md new file mode 100644 index 0000000000..1421fdb8c5 --- /dev/null +++ b/.changeset/fn-6245-automerge-toggle.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4cd8d73b6e..5014ef2dcc 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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"`. | diff --git a/packages/core/src/__tests__/move-task-characterization.test.ts b/packages/core/src/__tests__/move-task-characterization.test.ts index 2507c4a755..a5c3323945 100644 --- a/packages/core/src/__tests__/move-task-characterization.test.ts +++ b/packages/core/src/__tests__/move-task-characterization.test.ts @@ -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); }); }); diff --git a/packages/core/src/__tests__/store-movement.test.ts b/packages/core/src/__tests__/store-movement.test.ts index b8885f49fd..d8d8ab1527 100644 --- a/packages/core/src/__tests__/store-movement.test.ts +++ b/packages/core/src/__tests__/store-movement.test.ts @@ -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 { + 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); }); }); diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index c7e26e981f..8b32a07feb 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -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); }); diff --git a/packages/core/src/default-workflow-hooks.ts b/packages/core/src/default-workflow-hooks.ts index c3dc13495d..2b6c1936da 100644 --- a/packages/core/src/default-workflow-hooks.ts +++ b/packages/core/src/default-workflow-hooks.ts @@ -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 | 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") { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index e5157bc821..ca90c033cd 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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