From ad468813d5944a52d751e3a2fbbda56a0b58d9ac Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:18:24 -0700 Subject: [PATCH] fix(engine): honor per-task auto-merge override when global auto-merge is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks with autoMerge explicitly enabled never auto-merged when the project-level setting was disabled: the merge enqueue gate (allowInReviewMergeProcessing) and all 19 in-review self-healing sweeps checked only settings.autoMerge, and the board stall-signal hydration passed the raw global into the diagnostic gates. Introduce allowsAutoMergeProcessing(task, settings) in core — additive relative to the global setting so configs with global auto-merge ON are unchanged (explicit autoMerge:false tasks still flow to the merger's manual-required parking) — and use it at the enqueue gate, every self-healing sweep, and the store's stall/stalled signal contexts. --- .changeset/per-task-automerge-override.md | 5 + .../core/src/__tests__/task-merge.test.ts | 18 +++ packages/core/src/index.ts | 1 + packages/core/src/store.ts | 13 +- packages/core/src/task-merge.ts | 17 +++ .../src/__tests__/project-engine.test.ts | 26 ++++ .../engine/src/__tests__/self-healing.test.ts | 88 ++++++++++- packages/engine/src/project-engine.ts | 6 +- packages/engine/src/self-healing.ts | 141 ++++++++++-------- 9 files changed, 241 insertions(+), 74 deletions(-) create mode 100644 .changeset/per-task-automerge-override.md diff --git a/.changeset/per-task-automerge-override.md b/.changeset/per-task-automerge-override.md new file mode 100644 index 0000000000..ae495efe7e --- /dev/null +++ b/.changeset/per-task-automerge-override.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Respect per-task auto-merge overrides when the global auto-merge setting is off. Tasks with auto-merge explicitly enabled now get enqueued for merge and covered by the in-review self-healing sweeps (stall surfacing, merged-task finalization, retry recovery) even when the project-level setting is disabled; tasks without an explicit override keep the PR-based/manual review flow untouched. diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index b9a3854406..c7e26e981f 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -8,6 +8,7 @@ import { getTaskHardMergeBlocker, getTaskMergeBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, @@ -46,6 +47,23 @@ describe("resolveEffectiveAutoMerge", () => { }); }); +describe("allowsAutoMergeProcessing", () => { + it("lets explicit per-task true through when the global setting is off (FN per-task override)", () => { + expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: false })).toBe(true); + }); + + it("blocks tasks without an explicit override when the global setting is off", () => { + expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: false })).toBe(false); + expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: false })).toBe(false); + }); + + it("lets everything through when the global setting is on — explicit false still flows so the merger can park it manual-required", () => { + expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: true })).toBe(true); + }); +}); + describe("resolveEffectiveGroupAutoMerge", () => { it("prefers explicit true over global false", () => { expect(resolveEffectiveGroupAutoMerge({ autoMerge: true }, { autoMerge: false })).toBe(true); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a00a01622f..a362011a06 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -318,6 +318,7 @@ export { getTaskHardMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d5ff59dcb9..f5684fbc5c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -9,6 +9,7 @@ import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_ import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js"; import { normalizeTaskPriority } from "./task-priority.js"; +import { allowsAutoMergeProcessing } from "./task-merge.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; import { GlobalSettingsStore } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; @@ -4597,7 +4598,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4610,7 +4611,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4853,7 +4854,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4866,7 +4867,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5016,7 +5017,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5029,7 +5030,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index 479a965a98..0caaac894d 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -47,6 +47,23 @@ export function resolveEffectiveAutoMerge( return task.autoMerge ?? settings.autoMerge; } +/** + * Gate for auto-merge *processing* (engine enqueue + self-healing sweeps). + * Additive relative to the global setting: when `settings.autoMerge` is on, + * every task flows through — tasks with an explicit `autoMerge: false` are + * parked as `manual-required` downstream by the merger, not silently skipped + * here. When the global setting is off, only tasks with an explicit per-task + * `autoMerge: true` override proceed. Distinct from + * `resolveEffectiveAutoMerge`, which resolves the effective boolean and would + * (incorrectly for processing gates) starve the manual-required parking path. + */ +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} + // Resolves group → default-branch PROMOTION auto-merge. See resolveEffectiveAutoMerge for the per-task member→group-integration step; the two are distinct and must not be conflated. export function resolveEffectiveGroupAutoMerge( group: Pick, diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 4d9774959a..4c46d088ac 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -2670,3 +2670,29 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => { await engine.stop(); }); }); + +describe("allowInReviewMergeProcessing per-task autoMerge override", () => { + const gate = (task: Partial, settings: { autoMerge: boolean }) => + (createEngine() as any).allowInReviewMergeProcessing(task, settings) as boolean; + + it("lets an explicit per-task autoMerge:true through when the global setting is off", () => { + expect(gate({ autoMerge: true }, { autoMerge: false })).toBe(true); + }); + + it("blocks tasks without a per-task override when the global setting is off", () => { + expect(gate({}, { autoMerge: false })).toBe(false); + expect(gate({ autoMerge: false }, { autoMerge: false })).toBe(false); + }); + + it("keeps everything flowing when the global setting is on — explicit autoMerge:false is parked manual-required downstream", () => { + expect(gate({}, { autoMerge: true })).toBe(true); + expect(gate({ autoMerge: false }, { autoMerge: true })).toBe(true); + }); + + it("still exempts shared-branch-group member integration when the global setting is off", () => { + expect(gate( + { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, + { autoMerge: false }, + )).toBe(true); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 18e547aa53..87db2fbb2f 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -3365,7 +3365,8 @@ describe("SelfHealingManager", () => { const result = await managerWithRecovery.recoverMergeableReviewTasks(); expect(result).toBe(0); - expect(store.listTasks).not.toHaveBeenCalled(); + // The sweep may list tasks to discover per-task autoMerge overrides, + // but must not merge or enqueue anything without one. expect(store.mergeTask).not.toHaveBeenCalled(); expect(enqueueMerge).not.toHaveBeenCalled(); @@ -3747,7 +3748,10 @@ describe("SelfHealingManager", () => { const result = await managerWithRecovery.finalizeNoOpReviewTasks(); expect(result).toBe(0); - expect(store.listTasks).not.toHaveBeenCalled(); + // The sweep may list tasks to discover per-task autoMerge overrides, + // but must not finalize anything without one. + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); managerWithRecovery.stop(); }); @@ -8227,26 +8231,98 @@ describe("autoMerge gating for mutating in-review sweeps (FN-5147)", () => { "recoverMissingWorktreeReviewFailures", "recoverPartialProgressNoTaskDoneFailures", "reclaimSelfOwnedBranchConflicts", - ] as const)("skips entirely when autoMerge is disabled (respects PR-based review flow): %s", async (methodName) => { + ] as const)("performs no mutations when autoMerge is disabled and no per-task override exists: %s", async (methodName) => { if (methodName === "recoverReviewTasksWithFailedPreMergeSteps") { manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", recoverFailedPreMergeStep: vi.fn() }); } const result = await (manager as any)[methodName](); expect(result).toBe(0); - expect(store.listTasks).not.toHaveBeenCalled(); + // The sweep may list tasks to discover per-task autoMerge overrides, + // but must not mutate anything without one (respects PR-based review flow). expect(store.moveTask).not.toHaveBeenCalled(); expect(store.updateTask).not.toHaveBeenCalled(); expect(store.logEntry).not.toHaveBeenCalled(); }); - it("skips entirely when autoMerge is disabled (respects PR-based review flow): recoverCompletionHandoffLimbo", async () => { + it("performs no mutations when autoMerge is disabled and no per-task override exists: recoverCompletionHandoffLimbo", async () => { const result = await manager.recoverCompletionHandoffLimbo(); expect(result).toBeUndefined(); - expect(store.listTasks).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled(); expect(store.updateTask).not.toHaveBeenCalled(); expect(store.logEntry).not.toHaveBeenCalled(); }); + + it("surfaces in-review stalls for tasks with an explicit autoMerge:true override when the global setting is off", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); + (store.getSettings as ReturnType).mockResolvedValue({ + autoMerge: false, + globalPause: false, + enginePaused: false, + taskStuckTimeoutMs: 60_000, + }); + (store.listTasks as ReturnType).mockResolvedValue([ + { + id: "FN-OVERRIDE", + column: "in-review", + paused: false, + status: "merging", + autoMerge: true, + steps: [], + log: [], + updatedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + columnMovedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + }, + ]); + + const surfaced = await manager.surfaceInReviewStalls(); + + expect(surfaced).toBe(1); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-OVERRIDE", + expect.stringContaining("In-review stall surfaced ["), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps skipping override-less siblings while processing the override task", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); + const staleFields = { + column: "in-review", + paused: false, + status: "merging", + steps: [], + log: [], + updatedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + columnMovedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + }; + (store.getSettings as ReturnType).mockResolvedValue({ + autoMerge: false, + globalPause: false, + enginePaused: false, + taskStuckTimeoutMs: 60_000, + }); + (store.listTasks as ReturnType).mockResolvedValue([ + { id: "FN-OVERRIDE", autoMerge: true, ...staleFields }, + { id: "FN-MANUAL", ...staleFields }, + ]); + + const surfaced = await manager.surfaceInReviewStalls(); + + expect(surfaced).toBe(1); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-MANUAL", + expect.stringContaining("In-review stall surfaced ["), + ); + } finally { + vi.useRealTimers(); + } + }); }); describe("FN-5335 triple-proof no-action unit coverage", () => { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index cfa8d5ca20..fadda27a09 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -10,7 +10,7 @@ import type { ScheduledTask, AutomationRunResult, } from "@fusion/core"; -import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; +import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -1383,8 +1383,8 @@ export class ProjectEngine { * pushed wins. listTasks returns createdAt ASC — without this sort an * older low-priority task would start before a later urgent one. */ - private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { - return settings.autoMerge || isSharedBranchGroupMemberIntegration(task); + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); } private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick): number { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 4f73c4410a..929dbaea75 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -28,7 +28,7 @@ import { promisify } from "node:util"; import { setImmediate as setImmediateCb } from "node:timers"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; +import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js"; @@ -2302,14 +2302,14 @@ export class SelfHealingManager { * Backward lifecycle move gated on triple proof (FN-5335). * When the predicate fails, emits `task:reclaim-self-owned-branch-conflict-no-action` and skips lifecycle mutation. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async reclaimSelfOwnedBranchConflicts(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const todoCandidates = await this.store.listTasks({ column: "todo", slim: true }); const inProgressCandidates = await this.store.listTasks({ column: "in-progress", slim: true }); const inProgressByWorktree = new Map(); @@ -2320,7 +2320,8 @@ export class SelfHealingManager { } const inReviewPausedCandidates = (await this.store.listTasks({ column: "in-review", slim: true })) .filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable"); - const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates]; + const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates] + .filter((task) => allowsAutoMergeProcessing(task, settings)); const activeTaskIds = new Set(); if (this.options.agentStore) { @@ -4484,17 +4485,18 @@ export class SelfHealingManager { * Backward lifecycle move gated on triple proof (FN-5335). * When the unproven fallback predicate fails, emits `task:finalize-no-op-review-no-action` and skips lifecycle mutation. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async finalizeNoOpReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((t) => t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && !t.paused && !isSharedBranchGroupMemberIntegration(t) && Boolean(t.worktree) && @@ -4793,12 +4795,11 @@ export class SelfHealingManager { // "pull-request"`) — see GitHub issue #21. const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const mergeable = tasks.filter((t) => t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && !t.paused && t.status !== "failed" && // Exclude transient merge statuses. Active merges should be left alone; @@ -4898,7 +4899,9 @@ export class SelfHealingManager { * per-task `postReviewFixCount` so a persistently-failing verifier cannot * ping-pong a task forever. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks sent back for fix */ async recoverReviewTasksWithFailedPreMergeSteps(): Promise { @@ -4908,7 +4911,6 @@ export class SelfHealingManager { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const maxFixes = settings.maxPostReviewFixes ?? 1; if (!Number.isFinite(maxFixes) || maxFixes <= 0) return 0; @@ -4917,6 +4919,7 @@ export class SelfHealingManager { const candidates = tasks.filter((task) => { if (task.column !== "in-review") return false; + if (!allowsAutoMergeProcessing(task, settings)) return false; if (task.paused) return false; // Preserve terminal/human-handoff statuses (failed, awaiting-user-review, // merging, etc.). Only revive tasks that are otherwise idle. @@ -4994,13 +4997,14 @@ export class SelfHealingManager { * incomplete step instead of leaving the task stranded in review. * Backward lifecycle move gated on triple proof (FN-5335). * When the predicate fails, emits `task:stale-incomplete-review-no-action` and skips lifecycle mutation. - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverStaleIncompleteReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; @@ -5008,6 +5012,7 @@ export class SelfHealingManager { const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const staleIncomplete = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && !task.status && task.steps.length > 0 && @@ -5056,8 +5061,9 @@ export class SelfHealingManager { * Final-fallback recovery for `in-review` tasks that fell through every other * scan and have sat untouched longer than `taskStuckTimeoutMs`. * - * When `settings.autoMerge` is disabled, this sweep is a no-op because - * PR-based manual review intentionally leaves tasks in `in-review`. + * Tasks not eligible for auto-merge processing (global `autoMerge` off + * without an explicit per-task `autoMerge: true` override) are skipped + * because PR-based manual review intentionally leaves them in `in-review`. * * The other review-recovery scans each require a specific shape (failed * pre-merge step, incomplete steps, mergeable + worktree present, confirmed @@ -5078,8 +5084,10 @@ export class SelfHealingManager { * each kick refreshes `updatedAt`, so a task that re-enters review and gets * stuck again can only be kicked once per `taskStuckTimeoutMs` window. * - * When `settings.autoMerge === false`, this sweep is a no-op because those - * projects intentionally use PR-based/manual in-review ownership. + * Tasks not eligible for auto-merge processing (global `autoMerge` off + * without an explicit per-task `autoMerge: true` override) are skipped + * because those projects intentionally use PR-based/manual in-review + * ownership. * * @returns Number of tasks kicked back to todo */ @@ -5087,8 +5095,6 @@ export class SelfHealingManager { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const cycleStartMs = Date.now(); const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; @@ -5100,6 +5106,7 @@ export class SelfHealingManager { for (const task of tasks) { if (task.deletedAt) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; const signal = getInReviewStallReason(task, { now: cycleStartMs, activeMergeTaskId, @@ -5217,14 +5224,14 @@ export class SelfHealingManager { * - `surfaceStalePausedReviews()` owns paused in-review tasks. * - `surfaceInReviewStalls()` owns reason-driven in-review stalls. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async surfaceInReviewStalled(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const cycleStartMs = Date.now(); const thresholdMs = settings.inReviewStalledThresholdMs; if (!thresholdMs || thresholdMs <= 0) return 0; @@ -5236,6 +5243,7 @@ export class SelfHealingManager { for (const task of tasks) { if (task.deletedAt) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; if (task.paused === true) continue; if (task.id === activeMergeTaskId || executingTaskIds.has(task.id)) continue; @@ -5389,13 +5397,14 @@ export class SelfHealingManager { * Backward lifecycle move gated on triple proof (FN-5335). * When the predicate fails, emits `task:ghost-review-no-action` and skips lifecycle mutation. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverGhostReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; @@ -5404,6 +5413,7 @@ export class SelfHealingManager { const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const ghosts = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && !executingIds.has(task.id) && !(task.status && GHOST_REVIEW_PRESERVED_STATUSES.has(task.status)) && @@ -5465,7 +5475,9 @@ export class SelfHealingManager { * If no landed commit is found, it only clears the stale transient status so * the normal mergeable-review recovery can retry the merge. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks finalized or unblocked */ /** @@ -5486,8 +5498,9 @@ export class SelfHealingManager { * parked as failed and emit `merger:transient-failure-budget-exhausted` * once for diagnostic visibility. * - * No-op when `settings.autoMerge === false`, no `requeueForAutoMerge` - * callback is wired, or global/engine pause is active. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without a per-task `autoMerge: true` override). No-op when no + * `requeueForAutoMerge` callback is wired or global/engine pause is active. * * @returns Number of tasks recovered */ @@ -5496,12 +5509,12 @@ export class SelfHealingManager { if (!requeue) return 0; try { const settings = await this.store.getSettings(); - if (settings.autoMerge === false) return 0; if (settings.globalPause || settings.enginePaused) return 0; const slim = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = slim.filter((t) => t.column === "in-review" + && allowsAutoMergeProcessing(t, settings) && t.status === "failed" && (t.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES && typeof t.error === "string" @@ -5642,13 +5655,13 @@ export class SelfHealingManager { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)) && this.isPastInterruptedMergeGrace(task, timeoutMs), @@ -5956,20 +5969,21 @@ export class SelfHealingManager { * but a later transition failed or another process moved the task before the * final `in-review` → `done` update completed. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks recovered */ async recoverMergedReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const mergedButNotDone = tasks.filter((t) => !t.deletedAt && t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && t.mergeDetails?.mergeConfirmed === true, ); @@ -6087,14 +6101,14 @@ export class SelfHealingManager { * When the no-landed predicate fails, emits `task:stuck-merge-deadlock-no-action` and skips lifecycle mutation. */ /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverStuckMergeDeadlocks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const now = Date.now(); const inReview = await this.store.listTasks({ column: "in-review", slim: true }); const triage = await this.store.listTasks({ column: "triage", slim: true }); @@ -6117,6 +6131,7 @@ export class SelfHealingManager { (dep) => dep.column === "triage" || dep.column === "todo", ); return task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && task.status === "failed" && (task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES && @@ -6278,18 +6293,19 @@ export class SelfHealingManager { } /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverOrphanOnlyScopeViolations(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && task.status === "failed" && task.scopeOverride !== true && task.mergeDetails?.mergeConfirmed !== true && @@ -6442,19 +6458,20 @@ export class SelfHealingManager { * * Idempotency: recovered tasks are moved to `done`, status/error are cleared, * and mergeRetries reset to 0, so subsequent sweeps will not match them. - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverAlreadyMergedReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => !task.deletedAt && task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && task.status === "failed" && (task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES && task.mergeDetails?.mergeConfirmed !== true && @@ -6591,19 +6608,20 @@ export class SelfHealingManager { * Recover completed in-review tasks wedged as failed only because a post-done * session continuation hit a non-continuable signature. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverPostDoneNonContinuableWedge(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: false }); let recovered = 0; for (const task of tasks) { if (task.column !== "in-review" || task.deletedAt) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; if (task.paused || task.userPaused) continue; if (task.status !== "failed") continue; if (this.options.isTaskActive?.(task.id)) continue; @@ -6664,18 +6682,19 @@ export class SelfHealingManager { } /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverCompletionHandoffLimbo(): Promise { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return; - if (settings.autoMerge === false) return; - const tasks = await this.store.listTasks({ column: "in-review", slim: false }); const now = Date.now(); for (const task of tasks) { if (task.column !== "in-review" || task.paused) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; if (task.status != null || task.mergeDetails != null || task.review != null || task.reviewState != null) continue; if (this.options.isTaskActive?.(task.id)) continue; if (getTaskMergeBlocker(task) !== undefined) continue; @@ -6889,20 +6908,21 @@ export class SelfHealingManager { } /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverForeignOnlyContaminatedInReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const inReview = await this.store.listTasks({ column: "in-review", slim: true }); const inProgress = await this.store.listTasks({ column: "in-progress", slim: true }); const candidates = [ ...inReview.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && Boolean(task.branch) && Boolean(task.worktree) && task.mergeDetails?.mergeConfirmed !== true && @@ -6911,6 +6931,7 @@ export class SelfHealingManager { ), ...inProgress.filter((task) => task.column === "in-progress" && + allowsAutoMergeProcessing(task, settings) && task.paused === true && (task.pausedReason === "branch-cross-contamination" || task.pausedReason === "branch-conflict-unrecoverable") && Boolean(task.branch) && @@ -7730,18 +7751,19 @@ export class SelfHealingManager { * `restart-recovery-coordinator.ts`. * We clear stale worktree metadata and failure state, keep step progress and * retry counters, then requeue to todo for a clean retry. - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverMissingWorktreeReviewFailures(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => - isRecoverableMissingWorktreeReviewFailureWithProgress(task) - || isRecoverableMissingWorktreeReviewFailureNoProgress(task), + allowsAutoMergeProcessing(task, settings) + && (isRecoverableMissingWorktreeReviewFailureWithProgress(task) + || isRecoverableMissingWorktreeReviewFailureNoProgress(task)), ); if (candidates.length === 0) return 0; @@ -7813,19 +7835,20 @@ export class SelfHealingManager { * - `recoverNoProgressNoTaskDoneFailures`: `in-progress` with zero progress → clean requeue. * - This one: `in-review` with partial progress → bounded requeue preserving work. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks requeued for retry */ async recoverPartialProgressNoTaskDoneFailures(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && task.status === "failed" && isNoTaskDoneFailure(task) && !task.paused &&