FN-6569: make auto-merge retry cap configurable

Adds a project setting that controls how many auto-merge conflict retries run before recovery paths give up.

- Add maxAutoMergeRetries to project settings defaults, schema, types, and dashboard controls.
- Resolve the retry cap in engine merge handling, self-healing recovery, stall detection, and blocker fanout logic.
- Cover custom retry caps with core, dashboard, and engine regression tests.

Files changed:
 .changeset/fn-6569-max-auto-merge-retries.md       |  5 ++
 docs/architecture.md                               |  2 +-
 docs/settings-reference.md                         |  1 +
 .../core/src/__tests__/settings-defaults.test.ts   | 12 ++++
 packages/core/src/in-review-stall.ts               | 16 ++++-
 packages/core/src/index.ts                         |  1 +
 packages/core/src/settings-schema.ts               |  6 ++
 packages/core/src/task-priority.ts                 |  4 +-
 packages/core/src/types.ts                         |  8 +++
 .../dashboard/app/components/SettingsModal.tsx     |  8 +++
 .../components/settings/sections/MergeSection.tsx  | 26 +++++++
 .../app/hooks/__tests__/useBlockerFanout.test.ts   |  5 +-
 packages/dashboard/app/hooks/useBlockerFanout.ts   |  3 +-
 .../auto-merge-retry-cap-settings.test.ts          | 79 ++++++++++++++++++++++
 packages/engine/src/project-engine.ts              | 77 +++++++++++++--------
 packages/engine/src/self-healing.ts                | 30 +++++---
 16 files changed, 236 insertions(+), 47 deletions(-)

Fusion-Task-Id: FN-6569
Fusion-Task-Lineage: 42242d6a-68bc-41f1-b2d9-af2e6f168eed
This commit is contained in:
gsxdsm
2026-06-17 04:51:31 -07:00
parent 6ced5d73de
commit e2a3a37b26
16 changed files with 236 additions and 47 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a project setting for configuring the auto-merge conflict retry cap before Fusion parks or bounces tasks for recovery.

View File

@@ -1812,7 +1812,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
- **Soft-delete in-flight abort (FN-5142)**: `task:deleted` must immediately abort/dispose active executor work (`activeSessions`, `activeStepExecutors`, `activeWorkflowStepSessions`, reviewer subagents), interrupt active merge state (`mergeAbortController`, `activeMergeSession`, `activeMergeTaskId`, `mergeActive`, `mergeQueue`, `pausedReviewTaskIds`), and abort triage specify/subagent sessions for that id. Handlers are per-task and idempotent.
- **Soft-delete audit + column reconcile (FN-5175)**: `TaskStore.deleteTask` records a `runAuditEvents` row (`mutationType: "task:deleted"`, `domain: "database"`) inside the same transaction that sets `deletedAt`, and sets `"column" = 'archived'` on the row. Callers without a heartbeat run context (`fn task delete`, pi extension, dashboard delete route) pass an `auditContext` with `agentId: "system"` and a synthetic `runId`. The watcher cross-instance emit path does NOT re-record the audit event. The row stays in `tasks` (not `archivedTasks`); `archiveTask` is unchanged.
- **Soft-delete resurrection guard (FN-5208)**: `TaskStore.readTaskJson()` must never fall back to `.fusion/tasks/<id>/task.json` when the DB row exists with `deletedAt` set — it throws `TaskDeletedError`. `atomicCreateTaskJson` / `atomicWriteTaskJson` / `atomicWriteTaskJsonWithAudit` refuse to upsert a task whose row is currently soft-deleted (unless the in-memory task carries `deletedAt` itself, for soft-delete maintenance paths), emit a `[soft-delete-resurrection-blocked]` log line, and record a `task:resurrection-blocked` run-audit event. Stale in-flight planner/triage writes for a soft-deleted ID surface `TaskDeletedError` and abort cleanly without emitting `task:created`.
- **Exhausted in-review visibility surfaces (FN-5513)**: retry-exhausted merge failures (`column='in-review'`, `status='failed'`, `mergeRetries >= 3`) can remain soft-deleted for lifecycle safety, but are now intentionally discoverable through opt-in read paths: `TaskStore.listExhaustedInReviewTasks({ includeDeleted })`, `GET /api/tasks/exhausted-in-review`, `GET /api/tasks/:id?includeDeleted=true`, CLI `fn_task_show` soft-delete fallback marker, CLI `fn_task_list({ includeDeleted: true })`, and the dashboard ReliabilityView "Exhausted in-review (hidden blockers)" panel. This complements FN-5488/FN-5496 downstream blocker healing by surfacing the upstream blocker without mutating lifecycle state.
- **Exhausted in-review visibility surfaces (FN-5513/FN-6569)**: retry-exhausted merge failures (`column='in-review'`, `status='failed'`, `mergeRetries >= maxAutoMergeRetries`, default `3`) can remain soft-deleted for lifecycle safety, but are now intentionally discoverable through opt-in read paths: `TaskStore.listExhaustedInReviewTasks({ includeDeleted })`, `GET /api/tasks/exhausted-in-review`, `GET /api/tasks/:id?includeDeleted=true`, CLI `fn_task_show` soft-delete fallback marker, CLI `fn_task_list({ includeDeleted: true })`, and the dashboard ReliabilityView "Exhausted in-review (hidden blockers)" panel. This complements FN-5488/FN-5496 downstream blocker healing by surfacing the upstream blocker without mutating lifecycle state.
- **Soft-delete stream verification gate (FN-5153)**: `docs/soft-delete-verification-matrix.md` is the authoritative checklist for the FN-5105 → FN-5143 soft-delete stream. Every scenario × layer cell must be GREEN (or have a linked follow-up FN) before the stream is closed; `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` is the cross-layer regression backstop.
## Reliability interaction backstops

View File

@@ -312,6 +312,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `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); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js";
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS } from "../settings-schema.js";
import {
__resetLegacyCwdMainWarningForTests,
@@ -25,6 +26,17 @@ describe("settings defaults invariants", () => {
expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined();
});
it("defaults maxAutoMergeRetries to the historical project-scoped cap", () => {
expect(DEFAULT_PROJECT_SETTINGS.maxAutoMergeRetries).toBe(DEFAULT_MAX_AUTO_MERGE_RETRIES);
expect("maxAutoMergeRetries" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
expect(resolveMaxAutoMergeRetries(undefined)).toBe(3);
expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: 1 })).toBe(1);
expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: 5 })).toBe(5);
expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: 0 })).toBe(3);
expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: -1 })).toBe(3);
expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: Number.NaN })).toBe(3);
});
it("resolves worktrunk as disabled when both scopes are unset or empty", () => {
expect(resolveWorktrunkSettings(undefined, undefined).enabled).toBe(false);
expect(resolveWorktrunkSettings({}, {}).enabled).toBe(false);

View File

@@ -39,8 +39,20 @@ export interface InReviewStallContext {
/** Keep aligned with engine DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS. */
export const DEFAULT_STALE_MERGING_MIN_AGE_MS = 5 * 60_000;
/** Keep aligned with engine MAX_AUTO_MERGE_RETRIES (core must not import engine). */
/** Historical default for the configurable auto-merge conflict retry cap. */
export const DEFAULT_MAX_AUTO_MERGE_RETRIES = 3;
/**
* FNXC:AutoMergeRetries 2026-06-17-04:20:
* Every engine, self-healing, dashboard, and core display surface must resolve the same project setting with defensive fallback semantics. Invalid persisted values intentionally fall back to 3 so old configs and hand-edits preserve the prior hardcoded behavior.
*/
export function resolveMaxAutoMergeRetries(settings?: { maxAutoMergeRetries?: unknown } | null): number {
const configured = Number(settings?.maxAutoMergeRetries);
if (Number.isFinite(configured) && configured > 0) {
return Math.floor(configured);
}
return DEFAULT_MAX_AUTO_MERGE_RETRIES;
}
export const IN_REVIEW_STALL_LOG_PREFIX = "In-review stall surfaced [";
export const IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX = "In-review stall auto-disposed [";
export const IN_REVIEW_STALL_TERMINAL_LOG_PREFIX = "In-review stall terminal disposed [";
@@ -126,7 +138,7 @@ export function getInReviewStallReason(
const now = context.now ?? Date.now();
const observedAt = new Date(now).toISOString();
const staleMergingMinAgeMs = context.staleMergingMinAgeMs ?? DEFAULT_STALE_MERGING_MIN_AGE_MS;
const maxAutoMergeRetries = context.maxAutoMergeRetries ?? DEFAULT_MAX_AUTO_MERGE_RETRIES;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(context);
if (task.mergeDetails?.mergeConfirmed === true) {
return undefined;

View File

@@ -676,6 +676,7 @@ export {
IN_REVIEW_STALL_TERMINAL_LOG_PREFIX,
DEFAULT_STALE_MERGING_MIN_AGE_MS,
DEFAULT_MAX_AUTO_MERGE_RETRIES,
resolveMaxAutoMergeRetries,
} from "./in-review-stall.js";
export type { InReviewStallSignal, InReviewStallCode, ProviderErrorClassification } from "./in-review-stall.js";
export {

View File

@@ -1,3 +1,4 @@
import { DEFAULT_MAX_AUTO_MERGE_RETRIES } from "./in-review-stall.js";
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js";
export interface MergeRequestContractShadowSettingsSource {
@@ -314,6 +315,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
],
prerebaseDivergenceThreshold: 50,
mergeConflictStrategy: "smart-prefer-main",
/**
* FNXC:AutoMergeRetries 2026-06-17-04:20:
* Project settings own the auto-merge conflict retry cap because existing engine/dashboard consumers already resolve project settings; the default imports core's stall-detection fallback to keep every surface on the historical value of 3.
*/
maxAutoMergeRetries: DEFAULT_MAX_AUTO_MERGE_RETRIES,
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false },
mergeDiffVolumeMinLines: undefined,
mergeDiffVolumeThreshold: undefined,

View File

@@ -1,6 +1,6 @@
import { computeBlockerFanoutMap } from "./blocker-fanout.js";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES } from "./types.js";
import type { Task, TaskPriority } from "./types.js";
import type { ProjectSettings, Task, TaskPriority } from "./types.js";
export interface TaskPrioritySortable {
id: string;
@@ -90,7 +90,7 @@ const UNBLOCK_ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-pr
const DONE_COLUMNS = new Set<Task["column"]>(["done", "archived"]);
export interface BuildUnblockWeightMapOptions {
maxAutoMergeRetries?: number;
maxAutoMergeRetries?: ProjectSettings["maxAutoMergeRetries"];
}
function countUnmetDependencies(task: Task, taskById: Map<string, Task>): number {

View File

@@ -3645,6 +3645,14 @@ export interface ProjectSettings {
/** Strategy used when a merge conflict can't be resolved by AI. See
* {@link MergeConflictStrategy}. Default: "smart". */
mergeConflictStrategy?: MergeConflictStrategy;
/**
* FNXC:AutoMergeRetries 2026-06-17-04:20:
* The auto-merge conflict-resolution retry cap is project-configurable so operators can tune when tasks park for human visibility. Default 3 preserves the historical fixed cap; non-positive or non-finite values fall back to the default.
*
* Maximum number of auto-merge conflict-resolution retries before a task is
* parked as failed for manual recovery. Must be a positive integer. Default: 3.
*/
maxAutoMergeRetries?: number;
/** AI merge path configuration (FN-5633). See {@link MergerSettings}.
* When mode is "ai" (default), the standalone AI merge path is used and the
* legacy merge settings above/below it do not apply. */

View File

@@ -214,6 +214,11 @@ type SettingsSection = {
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
function resolveMaxAutoMergeRetriesForSettingsForm(settings?: { maxAutoMergeRetries?: unknown } | null): number {
const configured = Number(settings?.maxAutoMergeRetries);
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 3;
}
const SETTINGS_SECTIONS: SettingsSection[] = [
// Global group (shared across all Fusion projects)
{ id: "__global_header", label: "Global", labelKey: "settings.nav.globalHeader", scope: undefined, isGroupHeader: true },
@@ -630,6 +635,7 @@ export function SettingsModal({
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
maxAutoMergeRetries: 3,
mergeIntegrationWorktree: "reuse-task-worktree",
mergeAdvanceAutoSync: "stash-and-ff",
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false },
@@ -891,6 +897,7 @@ export function SettingsModal({
...s,
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
};
setForm(normalizedSettings);
setInitialValues(normalizedSettings); // Store initial values to detect explicit clears
@@ -2253,6 +2260,7 @@ export function SettingsModal({
binaryPath: form.worktrunk?.binaryPath?.trim() || undefined,
onFailure: form.worktrunk?.onFailure ?? "fail",
},
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(form),
taskPrefix: form.taskPrefix?.trim() || undefined,
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
githubAuthToken: form.githubAuthToken?.trim() || undefined,

View File

@@ -17,6 +17,11 @@ import type { Settings } from "@fusion/core";
import { MovedSettingsStub } from "./MovedSettingsStub";
import type { SectionBaseProps } from "./context";
function resolveMaxAutoMergeRetriesForMergeForm(value: unknown): number {
const configured = Number(value);
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 3;
}
interface LegacyAutoMergeStampCandidate {
taskId: string;
column: string;
@@ -127,6 +132,27 @@ export function MergeSection({
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
</details>
</div>
<div className="form-group">
<label htmlFor="maxAutoMergeRetries">Auto-merge conflict retries</label>
{/*
FNXC:AutoMergeRetries 2026-06-17-04:20:
Operators need a merge-section control for maxAutoMergeRetries so conflict-heavy projects can tune how many auto-resolution attempts occur before Fusion parks a task for human recovery. Invalid input falls back to 3 to preserve prior behavior.
*/}
<input
id="maxAutoMergeRetries"
type="number"
min={1}
step={1}
value={form.maxAutoMergeRetries ?? 3}
onChange={(e) =>
setForm((f) => ({
...f,
maxAutoMergeRetries: e.target.value === "" ? undefined : resolveMaxAutoMergeRetriesForMergeForm(e.target.value),
}))
}
/>
<small>Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.</small>
</div>
<div className="form-group" data-testid="legacy-automerge-stamp-cleanup-panel">
<h5 className="settings-section-heading">Legacy auto-merge stamp cleanup</h5>
<small>

View File

@@ -128,10 +128,11 @@ describe("computeBlockerFanoutMap", () => {
expect(entry?.escalation?.activeTodoCount).toBe(5);
});
it("keeps MAX_AUTO_MERGE_RETRIES aligned with engine self-healing source", () => {
it("keeps the dashboard fallback aligned with the documented self-healing default seed", () => {
const testDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8");
const match = source.match(/const MAX_AUTO_MERGE_RETRIES = (\d+);/);
const match = source.match(/export const MAX_AUTO_MERGE_RETRIES = (\d+);/);
expect(match?.[1]).toBe(String(MAX_AUTO_MERGE_RETRIES));
expect(source).toContain("SelfHealingManager must call resolveMaxAutoMergeRetries(settings)");
});
});

View File

@@ -7,7 +7,8 @@ import {
export type { BlockerFanoutEntry };
// Keep in sync with packages/engine/src/self-healing.ts
// Keep in sync with packages/engine/src/self-healing.ts default export.
// FNXC:AutoMergeRetries 2026-06-17-04:20: Dashboard fanout copy uses this as a display fallback until task-card surfaces receive live project settings; engine/self-healing decisions use resolveMaxAutoMergeRetries(settings) and are authoritative.
export const MAX_AUTO_MERGE_RETRIES = 3;
export interface UseBlockerFanoutOptions {

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { shouldRetryAutoMergeConflict } from "../project-engine.js";
import { SelfHealingManager } from "../self-healing.js";
function inReviewFailedTask(mergeRetries: number): Task {
return {
id: "FN-6569-BLOCKER",
title: "blocked merge",
description: "",
priority: "normal",
column: "in-review",
status: "failed",
error: "target-not-queued",
steps: [],
dependencies: [],
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
mergeRetries,
log: [],
} as Task;
}
describe("maxAutoMergeRetries setting", () => {
it("drives ProjectEngine conflict retry decisions for 1, 5, unset, and invalid values", () => {
expect(shouldRetryAutoMergeConflict(0, { maxAutoMergeRetries: 1 })).toMatchObject({
shouldRetry: false,
maxAutoMergeRetries: 1,
nextRetryCount: 1,
});
expect(shouldRetryAutoMergeConflict(3, { maxAutoMergeRetries: 5 })).toMatchObject({
shouldRetry: true,
maxAutoMergeRetries: 5,
nextRetryCount: 4,
});
expect(shouldRetryAutoMergeConflict(4, { maxAutoMergeRetries: 5 })).toMatchObject({
shouldRetry: false,
maxAutoMergeRetries: 5,
nextRetryCount: 5,
});
expect(shouldRetryAutoMergeConflict(1, {})).toMatchObject({
shouldRetry: true,
maxAutoMergeRetries: 3,
nextRetryCount: 2,
});
expect(shouldRetryAutoMergeConflict(2, {})).toMatchObject({
shouldRetry: false,
maxAutoMergeRetries: 3,
nextRetryCount: 3,
});
expect(shouldRetryAutoMergeConflict(2, { maxAutoMergeRetries: 0 })).toMatchObject({
shouldRetry: false,
maxAutoMergeRetries: 3,
nextRetryCount: 3,
});
expect(shouldRetryAutoMergeConflict(0, { autoResolveConflicts: false, maxAutoMergeRetries: 5 }).shouldRetry).toBe(false);
});
it("keeps SelfHealingManager from treating retries below a configured cap as exhausted", async () => {
const task = inReviewFailedTask(3);
const requeueForAutoMerge = vi.fn(async () => undefined);
const store = {
getSettings: vi.fn(async () => ({ maxAutoMergeRetries: 5, autoMerge: true })),
listTasks: vi.fn(async () => [task]),
getTask: vi.fn(async () => task),
logEntry: vi.fn(async () => undefined),
updateTask: vi.fn(async () => undefined),
};
const manager = new SelfHealingManager(store as any, { requeueForAutoMerge } as any);
await expect(manager.recoverTransientMergeFailures()).resolves.toBe(0);
expect(requeueForAutoMerge).not.toHaveBeenCalled();
expect(store.getTask).not.toHaveBeenCalled();
});
});

View File

@@ -13,7 +13,7 @@ import type {
ResearchSynthesisRequest,
ResearchSynthesisResult,
} from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
@@ -106,6 +106,18 @@ function isInvalidDoneTransitionError(error: unknown): boolean {
return message.includes("Invalid transition:") && message.includes("→ 'done'");
}
export function shouldRetryAutoMergeConflict(
currentRetries: number,
settings: { autoResolveConflicts?: boolean; maxAutoMergeRetries?: unknown } | null | undefined,
): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } {
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
return {
shouldRetry: settings?.autoResolveConflicts !== false && currentRetries + 1 < maxAutoMergeRetries,
maxAutoMergeRetries,
nextRetryCount: currentRetries + 1,
};
}
/**
* FN-5627: Defense-in-depth gate for the auto-merge "merge already confirmed"
* fast-path. Verifies the task's recorded `mergeDetails.commitSha` is actually
@@ -364,7 +376,6 @@ export class ProjectEngine {
for (const r of this.takeMergeResolvers(taskId)) r.reject(err);
}
private static readonly MAX_AUTO_MERGE_RETRIES = 3;
/** FN-5697/FN-5674: cap transient provider/network abort retries in auto-merge.
* Examples: "This operation was aborted", "socket hang up", `server_error`.
* After this cap, the task is parked failed for human visibility. */
@@ -1442,9 +1453,9 @@ export class ProjectEngine {
column: string;
error?: string | null;
log?: Array<{ action?: string }>;
}): boolean {
}, maxAutoMergeRetries: number): boolean {
if (task.column !== "in-review") return false;
if ((task.mergeRetries ?? 0) < ProjectEngine.MAX_AUTO_MERGE_RETRIES) return false;
if ((task.mergeRetries ?? 0) < maxAutoMergeRetries) return false;
const err = task.error ?? "";
const matchesVerificationError =
err.includes("Deterministic test verification failed") ||
@@ -1490,7 +1501,7 @@ export class ProjectEngine {
log?: Array<{ action?: string }>;
updatedAt?: string | null;
mergeDetails?: { mergeConfirmed?: boolean } | null;
}): boolean {
}, maxAutoMergeRetries: number): boolean {
// Merge-confirmed tasks use the fast-path finalizer, which applies blocker
// checks after clearing transient status/error state. Once that path parks
// a blocked task as failed, skip future auto-merge retries.
@@ -1503,8 +1514,8 @@ export class ProjectEngine {
// error). The task is parked for human/follow-up intervention.
if (task.status === "failed") return false;
return (
(task.mergeRetries ?? 0) < ProjectEngine.MAX_AUTO_MERGE_RETRIES ||
this.hasAutoHealableVerificationBufferFailure(task) ||
(task.mergeRetries ?? 0) < maxAutoMergeRetries ||
this.hasAutoHealableVerificationBufferFailure(task, maxAutoMergeRetries) ||
this.isRetryCooldownElapsed(task)
);
}
@@ -1677,9 +1688,10 @@ export class ProjectEngine {
}
}
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge">): number {
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge" | "maxAutoMergeRetries">): number {
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const eligible = sortTasksByPriorityThenAgeAndId(
tasks.filter((t) => !t.paused && this.canMergeTask(t as any) && this.allowInReviewMergeProcessing(t, settings)) as Task[],
tasks.filter((t) => !t.paused && this.canMergeTask(t as any, maxAutoMergeRetries) && this.allowInReviewMergeProcessing(t, settings)) as Task[],
);
for (const t of eligible) {
this.internalEnqueueMerge(t.id);
@@ -1783,6 +1795,7 @@ export class ProjectEngine {
if (!hasManualResolver) {
// Re-check autoMerge and pause before each merge
const settings = await store.getSettings();
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
if (settings.globalPause || settings.enginePaused) {
runtimeLog.log(
`Auto-merge skipping ${taskId} — ${settings.globalPause ? "global pause" : "engine paused"} active`,
@@ -1804,7 +1817,7 @@ export class ProjectEngine {
// Intentional cast to access Task properties needed by merge validation
if (!this.canMergeTask(task as any)) {
if (!this.canMergeTask(task as any, maxAutoMergeRetries)) {
continue;
}
@@ -1842,11 +1855,14 @@ export class ProjectEngine {
cwd,
});
if (!reachability.reachable) {
/*
* FNXC:AutoMergeRetries 2026-06-17-04:20:
* Fast-path recovery must consume the resolved project retry cap, not a class constant, because poisoned merge-confirmed rows otherwise park or retry at the old fixed value after operators tune maxAutoMergeRetries.
*/
const sha = task.mergeDetails.commitSha || "";
const shortSha = sha ? sha.slice(0, 8) : "<no-sha>";
const currentRetries = task.mergeRetries ?? 0;
const budgetExhausted =
currentRetries >= ProjectEngine.MAX_AUTO_MERGE_RETRIES;
const budgetExhausted = currentRetries >= maxAutoMergeRetries;
// Clear poisoned mergeDetails fields. These persisted before
// the integration ref-advance actually succeeded (pre-FN-5627
@@ -1926,14 +1942,14 @@ export class ProjectEngine {
// integration tip.
const nextRetries = currentRetries + 1;
runtimeLog.warn(
`Auto-merge: ${taskId} fast-path REFUSED — auto-recovering (attempt ${nextRetries}/${ProjectEngine.MAX_AUTO_MERGE_RETRIES}): ${reachability.reason}: ${reachability.diagnostic}`,
`Auto-merge: ${taskId} fast-path REFUSED — auto-recovering (attempt ${nextRetries}/${maxAutoMergeRetries}): ${reachability.reason}: ${reachability.diagnostic}`,
);
// Prefix MUST be "Auto-recovered:" so NotificationService's
// maybeSuppressTransientFailedNotification cancels the pending
// ntfy fired off the underlying task:failed event.
await store.logEntry(
taskId,
`Auto-recovered: fast-path refused — cleared poisoned mergeDetails (commit ${shortSha} not reachable from ${integrationBranchForGate}, ${reachability.reason}). Re-enqueueing for fresh merge attempt ${nextRetries}/${ProjectEngine.MAX_AUTO_MERGE_RETRIES} [FN-5627].`,
`Auto-recovered: fast-path refused — cleared poisoned mergeDetails (commit ${shortSha} not reachable from ${integrationBranchForGate}, ${reachability.reason}). Re-enqueueing for fresh merge attempt ${nextRetries}/${maxAutoMergeRetries} [FN-5627].`,
);
await store.updateTask(taskId, {
mergeDetails: cleanedMergeDetails,
@@ -1958,7 +1974,7 @@ export class ProjectEngine {
reason: reachability.reason,
diagnostic: reachability.diagnostic,
mergeRetries: nextRetries,
maxRetries: ProjectEngine.MAX_AUTO_MERGE_RETRIES,
maxRetries: maxAutoMergeRetries,
},
});
} catch (auditErr) {
@@ -2076,14 +2092,14 @@ export class ProjectEngine {
// Auto-heal verification buffer failures by resetting retry counter
if (this.hasAutoHealableVerificationBufferFailure(task as any)) {
if (this.hasAutoHealableVerificationBufferFailure(task as any, maxAutoMergeRetries)) {
await store.logEntry(
taskId,
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
);
await store.updateTask(taskId, { mergeRetries: 0, error: null, status: null });
} else if (
(task.mergeRetries ?? 0) >= ProjectEngine.MAX_AUTO_MERGE_RETRIES &&
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
this.isRetryCooldownElapsed(task as any)
) {
@@ -2329,6 +2345,7 @@ export class ProjectEngine {
const settingsOnErr = await store
.getSettings()
.catch(() => ({ autoResolveConflicts: true }));
const maxAutoMergeRetriesOnErr = resolveMaxAutoMergeRetries(settingsOnErr as { maxAutoMergeRetries?: unknown });
const taskOnErr = await store.getTask(taskId).catch(() => null);
const mergeStrategyOnErr =
this.options.getMergeStrategy?.(settingsOnErr as Settings) ?? "direct";
@@ -2579,6 +2596,10 @@ export class ProjectEngine {
if (taskOnErr && isConflictError) {
const currentRetries = taskOnErr.mergeRetries ?? 0;
/*
* FNXC:AutoMergeRetries 2026-06-17-04:20:
* The conflict retry loop resolves maxAutoMergeRetries from settings on every caught merge failure so changed project policy affects the next retry/bounce decision without changing the historical default of 3.
*/
// Use `currentRetries + 1 < MAX` (not `currentRetries < MAX`) so
// the LAST retry's failure goes straight to the bounce code in
// this same engine tick. The previous condition scheduled a
@@ -2586,17 +2607,15 @@ export class ProjectEngine {
// before that timer fired (common during dev), the task was
// stranded with mergeRetries=MAX and only the cooldown sweep
// could ever try again (silent loop).
if (
(settingsOnErr as Settings).autoResolveConflicts !== false &&
currentRetries + 1 < ProjectEngine.MAX_AUTO_MERGE_RETRIES
) {
const newRetryCount = currentRetries + 1;
const retryDecision = shouldRetryAutoMergeConflict(currentRetries, settingsOnErr);
if (retryDecision.shouldRetry) {
const newRetryCount = retryDecision.nextRetryCount;
await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null });
// Exponential backoff: 5s, 10s, 20s
const delayMs = 5000 * Math.pow(2, currentRetries);
runtimeLog.log(
`Auto-merge conflict retry ${newRetryCount}/${ProjectEngine.MAX_AUTO_MERGE_RETRIES} for ${taskId} in ${delayMs / 1000}s`,
`Auto-merge conflict retry ${newRetryCount}/${maxAutoMergeRetriesOnErr} for ${taskId} in ${delayMs / 1000}s`,
);
setTimeout(() => {
if (!this.shuttingDown) this.internalEnqueueMerge(taskId);
@@ -2627,12 +2646,12 @@ export class ProjectEngine {
try {
await store.updateTask(taskId, {
status: "failed",
mergeRetries: ProjectEngine.MAX_AUTO_MERGE_RETRIES,
mergeRetries: maxAutoMergeRetriesOnErr,
error: `Auto-merge gave up: ${reason}. ${errorMsg}`,
});
await store.addTaskComment(
taskId,
`Auto-merge gave up after ${ProjectEngine.MAX_AUTO_MERGE_RETRIES} conflict-resolution retries (${reason}). ` +
`Auto-merge gave up after ${maxAutoMergeRetriesOnErr} conflict-resolution retries (${reason}). ` +
`Resolve the conflict on branch \`${taskOnErr.branch ?? "?"}\` manually, then unpause/retry.`,
"agent",
);
@@ -2711,7 +2730,7 @@ export class ProjectEngine {
try {
await store.addTaskComment(
taskId,
`Auto-merge could not resolve conflicts within ${ProjectEngine.MAX_AUTO_MERGE_RETRIES} retries (bounce ${nextBounces}/${bounceCap}). ` +
`Auto-merge could not resolve conflicts within ${maxAutoMergeRetriesOnErr} retries (bounce ${nextBounces}/${bounceCap}). ` +
`Bouncing back to in-progress for a fresh rebase against main; the executor will re-run quality gates and re-attempt the merge.`,
"agent",
);
@@ -2724,7 +2743,7 @@ export class ProjectEngine {
await store.moveTask(taskId, "in-progress");
await store.logEntry(
taskId,
`Auto-merge conflicts unresolved (${ProjectEngine.MAX_AUTO_MERGE_RETRIES}/${ProjectEngine.MAX_AUTO_MERGE_RETRIES}) — bounced to in-progress for re-rebase (bounce ${nextBounces}/${bounceCap})`,
`Auto-merge conflicts unresolved (${maxAutoMergeRetriesOnErr}/${maxAutoMergeRetriesOnErr}) — bounced to in-progress for re-rebase (bounce ${nextBounces}/${bounceCap})`,
"MergeConflictBounce",
);
runtimeLog.log(
@@ -2781,7 +2800,7 @@ export class ProjectEngine {
}
await store.updateTask(taskId, {
status: "failed",
mergeRetries: ProjectEngine.MAX_AUTO_MERGE_RETRIES,
mergeRetries: maxAutoMergeRetriesOnErr,
error: errorMsg,
});
await store.logEntry(
@@ -2837,7 +2856,7 @@ export class ProjectEngine {
}
await store.updateTask(taskId, {
status: "failed",
mergeRetries: ProjectEngine.MAX_AUTO_MERGE_RETRIES,
mergeRetries: maxAutoMergeRetriesOnErr,
error: errorMsg,
});
} catch (recoveryErr) {

View File

@@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, 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, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, resolveMaxAutoMergeRetries, 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 { mergeEffectiveSettings } from "./effective-settings.js";
@@ -392,6 +392,10 @@ const ORPHANED_WITH_WORKTREE_GRACE_MS = 300_000;
*/
const MAX_TASK_DONE_RETRIES = 3;
export const MAX_WORKTREE_SESSION_RETRIES = 3;
/**
* FNXC:AutoMergeRetries 2026-06-17-04:20:
* Keep this export as the historical default seed for tests and dashboard fallback alignment, but SelfHealingManager must call resolveMaxAutoMergeRetries(settings) at decision points so configured projects do not recover or stall at the old fixed value.
*/
export const MAX_AUTO_MERGE_RETRIES = 3;
/**
* FN-5627 follow-up: bounded budget for self-healing transient-merge-failure
@@ -4404,6 +4408,7 @@ export class SelfHealingManager {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const staleMergingStatusMinAgeMs = this.options.staleMergingStatusMinAgeMs ?? DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS;
const configuredFanoutMinAgeMs = this.options.staleMergingFanoutMinAgeMs ?? DEFAULT_STALE_MERGING_FANOUT_MIN_AGE_MS;
@@ -4518,10 +4523,10 @@ export class SelfHealingManager {
} else if (
blocker.column === "in-review" &&
blocker.status === "failed" &&
(blocker.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
(blocker.mergeRetries ?? 0) >= maxAutoMergeRetries
) {
reasonCode = "failed-retry-exhausted";
reason = `blocker ${blockerId} in-review + failed (mergeRetries ${blocker.mergeRetries ?? 0}/${MAX_AUTO_MERGE_RETRIES})`;
reason = `blocker ${blockerId} in-review + failed (mergeRetries ${blocker.mergeRetries ?? 0}/${maxAutoMergeRetries})`;
} else if (
blocker.column === "in-review" &&
blocker.status === "failed" &&
@@ -5373,6 +5378,7 @@ export class SelfHealingManager {
// "pull-request"`) — see GitHub issue #21.
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
const mergeable = tasks.filter((t) =>
@@ -5393,7 +5399,7 @@ export class SelfHealingManager {
// refreshes updatedAt, preventing cooldown-based retries from ever
// becoming eligible. Also skip tasks explicitly tagged as no-op merges
// in case updateTask(moveTask) is briefly out-of-order during recovery.
(t.mergeRetries ?? 0) < MAX_AUTO_MERGE_RETRIES &&
(t.mergeRetries ?? 0) < maxAutoMergeRetries &&
getTaskMergeBlocker(t) === undefined,
);
const unownedMergeable = mergeable.filter((task) => !this.isMergeLaneOwned(task.id));
@@ -5686,6 +5692,7 @@ export class SelfHealingManager {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const cycleStartMs = Date.now();
const timeoutMs = settings.taskStuckTimeoutMs;
if (!timeoutMs || timeoutMs <= 0) return 0;
@@ -5703,7 +5710,7 @@ export class SelfHealingManager {
activeMergeTaskId,
executingTaskIds,
staleMergingMinAgeMs: this.options.staleMergingStatusMinAgeMs ?? DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS,
maxAutoMergeRetries: MAX_AUTO_MERGE_RETRIES,
maxAutoMergeRetries,
engineActiveSinceMs: settings.engineActiveSinceMs,
engineActivationGraceMs: settings.engineActivationGraceMs,
});
@@ -6133,13 +6140,14 @@ export class SelfHealingManager {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
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
&& (t.mergeRetries ?? 0) >= maxAutoMergeRetries
&& typeof t.error === "string"
&& t.error.length > 0
&& classifyTransientMergeError(t.error) !== null,
@@ -6147,7 +6155,7 @@ export class SelfHealingManager {
if (candidates.length === 0) return 0;
log.warn(
`Found ${candidates.length} in-review task(s) with transient merge failures stuck at mergeRetries=${MAX_AUTO_MERGE_RETRIES}; attempting auto-recovery`,
`Found ${candidates.length} in-review task(s) with transient merge failures stuck at mergeRetries=${maxAutoMergeRetries}; attempting auto-recovery`,
);
let recovered = 0;
@@ -6159,7 +6167,7 @@ export class SelfHealingManager {
if (
task.column !== "in-review"
|| task.status !== "failed"
|| (task.mergeRetries ?? 0) < MAX_AUTO_MERGE_RETRIES
|| (task.mergeRetries ?? 0) < maxAutoMergeRetries
) {
continue;
}
@@ -6732,6 +6740,7 @@ export class SelfHealingManager {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
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 });
@@ -6757,7 +6766,7 @@ export class SelfHealingManager {
allowsAutoMergeProcessing(task, settings) &&
!task.paused &&
task.status === "failed" &&
(task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES &&
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
task.mergeDetails?.mergeConfirmed !== true &&
(hasBlockedDependents || Boolean(task.worktree)) &&
cooldownElapsed >= DEADLOCK_RECOVERY_COOLDOWN_MS;
@@ -7089,6 +7098,7 @@ export class SelfHealingManager {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
const candidates = tasks.filter((task) =>
@@ -7096,7 +7106,7 @@ export class SelfHealingManager {
task.column === "in-review" &&
allowsAutoMergeProcessing(task, settings) &&
task.status === "failed" &&
(task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES &&
(task.mergeRetries ?? 0) >= maxAutoMergeRetries &&
task.mergeDetails?.mergeConfirmed !== true &&
!executingIds.has(task.id),
);