fix(engine): honor per-task auto-merge override when global auto-merge is off

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.
This commit is contained in:
gsxdsm
2026-06-03 10:18:24 -07:00
parent 04a5cd196c
commit ad468813d5
9 changed files with 241 additions and 74 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -318,6 +318,7 @@ export {
getTaskHardMergeBlocker,
getTaskCompletionBlocker,
isTaskReadyForMerge,
allowsAutoMergeProcessing,
isSharedBranchGroupMemberIntegration,
resolveEffectiveAutoMerge,
resolveEffectiveGroupAutoMerge,

View File

@@ -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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<TaskStoreEvents> {
task.inReviewStalled = getInReviewStalledSignal(task, {
now,
thresholdMs: settings.inReviewStalledThresholdMs,
autoMerge: settings.autoMerge,
autoMerge: allowsAutoMergeProcessing(task, settings),
engineActiveSinceMs: settings.engineActiveSinceMs,
engineActivationGraceMs: settings.engineActivationGraceMs,
});

View File

@@ -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<Task, "autoMerge">,
settings: Pick<Settings, "autoMerge">,
): 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<BranchGroup, "autoMerge">,

View File

@@ -2670,3 +2670,29 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => {
await engine.stop();
});
});
describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
const gate = (task: Partial<Task>, 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);
});
});

View File

@@ -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<typeof vi.fn>).mockResolvedValue({
autoMerge: false,
globalPause: false,
enginePaused: false,
taskStuckTimeoutMs: 60_000,
});
(store.listTasks as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
autoMerge: false,
globalPause: false,
enginePaused: false,
taskStuckTimeoutMs: 60_000,
});
(store.listTasks as ReturnType<typeof vi.fn>).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", () => {

View File

@@ -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<Task, "branchContext">, settings: Pick<Settings, "autoMerge">): boolean {
return settings.autoMerge || isSharedBranchGroupMemberIntegration(task);
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">): boolean {
return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task);
}
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge">): number {

View File

@@ -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<number> {
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<string, string>();
@@ -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<string>();
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<number> {
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<number> {
@@ -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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<string>();
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<number> {
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<string>();
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<number> {
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<void> {
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<number> {
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<string>();
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<number> {
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<number> {
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 &&