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:
5
.changeset/fn-6245-automerge-toggle.md
Normal file
5
.changeset/fn-6245-automerge-toggle.md
Normal 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.
|
||||
@@ -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"`. |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user