FN-8925: clear orphaned paused merge stamps safely

Allow only engine-owned deadlock pauses to clear stale merge stamps without resuming work.

- Fail closed when merge ownership probing is unavailable.
- Clear stale stamps only for merge-deadlock-detected pauses and suppress their enqueue.
- Add race coverage, audit documentation, and a patch changeset.

Files changed:
 .changeset/fn-8925-paused-stale-merge.md           |   7 +
 docs/self-healing-backward-move-audit.md           |   2 +-
 docs/task-management.md                            |   4 +-
 .../self-healing-query-filter-blindness.test.ts    |   5 +-
 .../self-healing-stale-merge-fanout.test.ts        |   1 +
 packages/engine/src/__tests__/self-healing.test.ts | 203 +++++++++++++++++++--
 packages/engine/src/self-healing.ts                |  45 ++++-
 7 files changed, 241 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-8925

Fusion-Task-Lineage: e5491e1a-ca0a-4a82-b4e0-4002f30e869e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>,
This commit is contained in:
gsxdsm
2026-08-09 23:13:34 -07:00
parent e7a873c505
commit d9a2d9dba3
7 changed files with 243 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Clear orphaned merge status from eligible engine-paused review cards without resuming them.
category: fix
dev: The stale merge sweep permits only merge-deadlock-detected clear-only recovery and never enqueues paused cards.

View File

@@ -13,7 +13,7 @@ Stages that cannot satisfy all three must either (a) tighten predicate to requir
|---|---:|---|---|---|---|---|---|---|---|
| recoverCompletedTasks | 1455 | in-progress + all steps terminal | n/a | step completion | move to in-review | FORWARD | keep | n/a | n/a |
| recoverStrandedCompletedTodoTasks | 1507 | todo + all steps terminal | n/a | step completion | move to in-progress (resume) | FORWARD | keep | n/a | n/a |
| recoverStaleMergingStatus | 1557 | in-review stale merging status | `staleMergingStatusMinAgeMs` | stale status + no active merge | clear status | RECONCILE-ONLY | keep | n/a | n/a |
| recoverStaleMergingStatus | 3521 | in-review stale merging status | `staleMergingStatusMinAgeMs` | stale status + no active merge | clear status; re-enqueue eligible unpaused non-workspace task | RECONCILE + FORWARD RE-ENQUEUE | keep | paused cards never enqueue; only `merge-deadlock-detected` may clear its orphaned stamp | n/a |
| reclaimPrConflicts | 1606 | PR mergeable=conflicting | path-dependent | conflict inspect result | delegates to reclaimPrConflictForTask | RECONCILE/BACKWARD mix | keep | n/a | n/a |
| reclaimPrConflictForTask | 1622 | reclaimable conflicting PR branch | inspectConflict + usable worktree checks | active-session/usable-worktree checks only | may move in-review→todo | BACKWARD | tighten | triple proof on in-review→todo path | gate move; emit `task:reclaim-pr-conflict-no-action` |
| reclaimSelfOwnedBranchConflicts | 1772 | branch conflict self-owned | conflict inspector local checks | conflict classifier only | may move in-review→todo | BACKWARD | tighten | triple proof before backward move | gate move; emit `task:reclaim-self-owned-branch-conflict-no-action` |

View File

@@ -298,7 +298,7 @@ This is a forward-safety guard for stranded completed tasks. See FN-4055/FN-4079
Fusion now derives `task.inReviewStall` for non-paused `in-review` tasks when a known stuck-state shape is detected. This signal is state-based (not log-heuristic) and is computed server-side on task hydration.
`InReviewStallCode` values:
- `transient-merge-status-no-owner` — task is still in `merging`/`merging-pr`/`merging-fix` after the stale-merging age threshold, but no active merger owns it. `recoverStaleMergingStatus()` clears this stamp and re-enqueues auto-merge-eligible, non-workspace, non-`mergeConfirmed` tasks; paused tasks remain skipped and the signal itself remains diagnostic-only.
- `transient-merge-status-no-owner` — task is still in `merging`/`merging-pr`/`merging-fix` after the stale-merging age threshold, but no active merger owns it. `recoverStaleMergingStatus()` clears this stamp and re-enqueues auto-merge-eligible, non-workspace, non-`mergeConfirmed` **unpaused** tasks. Paused tasks never re-enqueue; the sole clear-only exception is the engine-owned `merge-deadlock-detected` hold, whose status-preserving park can otherwise retain an orphan stamp indefinitely. Explicit human, approval, and unknown pauses remain intentionally suppressed. The signal itself remains diagnostic-only.
- `merge-retries-exhausted` — `mergeRetries` reached the auto-merge retry cap without `mergeDetails.mergeConfirmed === true`.
- `no-worktree-no-merge-confirmed` — task has no worktree path and merge is not confirmed (excluding explicit no-op merges).
- `merge-blocker` — `getTaskMergeBlocker()` reports a merge/finalization blocker.
@@ -360,7 +360,7 @@ Scheduler-side reporting emits structured, rate-limited task-log and engine-log
Success metric: maintainers can find all stale cards from the board UI in under 30 seconds.
Auto-completion/finalization remains owned by existing recovery passes:
- `recoverStaleMergingStatus`
- `recoverStaleMergingStatus` — reconciles unowned aged merge stamps. It may clear the stamp on an engine-owned `merge-deadlock-detected` pause but never resumes, unpauses, moves, finalizes, or enqueues a paused card.
- `finalizeNoOpReviewTasks`
- `recoverMergeableReviewTasks`
- `recoverAlreadyMergedReviewTasks`

View File

@@ -851,7 +851,10 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
} as unknown as Task;
const { store, updateTask } = productionFaithfulStore([stuck]);
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverStaleMergingStatus();
await new SelfHealingManager(store, {
rootDir: "/repo",
getActiveMergeTaskId: () => null,
}).recoverStaleMergingStatus();
expect(updateTask).toHaveBeenCalledWith("FN-STALESTAMP", expect.objectContaining({ status: null }));
});

View File

@@ -80,6 +80,7 @@ describe("SelfHealingManager stale merge fanout recovery (FN-4241)", () => {
rootDir: "/tmp/test-project",
staleMergingStatusMinAgeMs: 5 * 60_000,
staleMergingFanoutMinAgeMs: 15 * 60_000,
getActiveMergeTaskId: () => null,
});
const recoveredMerging = await manager.recoverStaleMergingStatus();

View File

@@ -4956,6 +4956,7 @@ describe("SelfHealingManager", () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
enqueueMerge,
getActiveMergeTaskId: () => null,
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoMerge: true,
@@ -5002,6 +5003,7 @@ describe("SelfHealingManager", () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
enqueueMerge,
getActiveMergeTaskId: () => null,
});
const staleTask = {
id: "FN-8912-enqueue-throws",
@@ -5035,6 +5037,7 @@ describe("SelfHealingManager", () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
enqueueMerge,
getActiveMergeTaskId: () => null,
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoMerge: false,
@@ -5098,6 +5101,7 @@ describe("SelfHealingManager", () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
enqueueMerge,
getActiveMergeTaskId: () => null,
});
const workspaceTask = {
id: "FN-8912-workspace-auto-on",
@@ -5132,6 +5136,7 @@ describe("SelfHealingManager", () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
clearMergeActive,
getActiveMergeTaskId: () => null,
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
globalPause: false,
@@ -5183,25 +5188,162 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("keeps paused stale merge statuses untouched", async () => {
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const pausedTask = {
id: "FN-8912-paused",
column: "in-review",
paused: true,
status: "merging",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(),
steps: [], workflowStepResults: [], log: [],
};
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
autoMerge: true,
globalPause: false,
enginePaused: false,
/*
FNXC:MergeReliability 2026-08-10-05:32:
`merge-deadlock-detected` is the sole automation pause allowed to clear an orphaned stamp; the
clear must not unpause or enqueue it. REVERT CHECK (measured): restore `|| task.paused` and this
test returns 0; remove the live `current.paused !== true` enqueue gate and its enqueue assertion fails.
*/
it("clears an unowned stale merge stamp on a merge-deadlock pause without resuming it", async () => {
const clearMergeActive = vi.fn();
const enqueueMerge = vi.fn();
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project", clearMergeActive, enqueueMerge,
getActiveMergeTaskId: () => null,
});
const pausedTask = {
id: "FN-8925-merge-deadlock", column: "in-review", paused: true,
pausedReason: "merge-deadlock-detected", status: "merging",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
};
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([pausedTask]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(pausedTask);
const patches: Partial<Task>[] = [];
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (live: Task) => Partial<Task> | null) => {
const patch = updater(pausedTask as Task);
if (patch) { patches.push(patch); Object.assign(pausedTask, patch); }
return pausedTask as Task;
});
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
expect(pausedTask.status).toBeNull();
expect(clearMergeActive).toHaveBeenCalledWith(pausedTask.id);
expect(enqueueMerge).not.toHaveBeenCalled();
expect(patches).toEqual([{ status: null }]);
expect(pausedTask).toMatchObject({ paused: true, pausedReason: "merge-deadlock-detected", column: "in-review" });
managerWithRecovery.stop();
});
/*
FNXC:MergeReliability 2026-08-10-05:49:
An unwired ownership probe cannot prove an unowned stamp. REVERT CHECK (measured): restore the
null fallback and this fixture clears, recreating the false no-owner assumption that code review found.
*/
it("fails closed when the active-merge ownership probe is unwired", async () => {
const clearMergeActive = vi.fn();
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project", clearMergeActive, enqueueMerge: vi.fn(),
});
const pausedTask = {
id: "FN-8925-unwired-owner", column: "in-review", paused: true,
pausedReason: "merge-deadlock-detected", status: "merging",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
};
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([pausedTask]);
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
expect(store.updateTask).not.toHaveBeenCalledWith(pausedTask.id, { status: null });
expect(store.listTasks).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
expect(clearMergeActive).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it.each([
["human pause", { userPaused: true }],
["approval pause", { pausedReason: "awaiting-approval" }],
["unknown pause", { pausedReason: "future-engine-pause" }],
])("keeps %s stale merge stamps fully suppressed", async (_name, extra) => {
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge: vi.fn(), getActiveMergeTaskId: () => null });
const pausedTask = {
id: `FN-8925-suppressed-${_name}`, column: "in-review", paused: true, status: "merging",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [], ...extra,
};
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([pausedTask]);
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("suppresses enqueue when a live row is paused after an eligible snapshot", async () => {
const enqueueMerge = vi.fn();
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge, getActiveMergeTaskId: () => null });
const snapshot = {
id: "FN-8925-pause-race", column: "in-review", paused: false, status: "merging",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
};
const live = { ...snapshot, paused: true, pausedReason: "merge-deadlock-detected" };
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([snapshot]);
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial<Task> | null) => {
const patch = updater(live as Task);
if (patch) Object.assign(live, patch);
return live as Task;
});
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
expect(live.status).toBeNull();
expect(enqueueMerge).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("does not clear when a merge-deadlock snapshot is unpaused into a newly claimed live task", async () => {
const snapshot = {
id: "FN-8925-unpause-race", column: "in-review", paused: true, pausedReason: "merge-deadlock-detected", status: "merging",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
};
const live = { ...snapshot, paused: false, pausedReason: undefined };
let reads = 0;
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project", getActiveMergeTaskId: () => (++reads === 1 ? null : snapshot.id), enqueueMerge: vi.fn(),
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([snapshot]);
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial<Task> | null) => {
expect(updater(live as Task)).toBeNull();
return live as Task;
});
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
managerWithRecovery.stop();
});
it("does not clear when merge confirmation appears on the live atomic row", async () => {
const snapshot = {
id: "FN-8925-confirm-race", column: "in-review", paused: false, status: "merging",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
};
const live = { ...snapshot, mergeDetails: { mergeConfirmed: true } };
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge: vi.fn(), getActiveMergeTaskId: () => null });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([snapshot]);
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial<Task> | null) => {
expect(updater(live as Task)).toBeNull();
return live as Task;
});
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
managerWithRecovery.stop();
});
it("applies the merge-deadlock allowlist in the non-atomic compatibility fallback", async () => {
const task = {
id: "FN-8925-fallback", column: "in-review", paused: true, pausedReason: "merge-deadlock-detected", status: "landing",
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
};
const enqueueMerge = vi.fn();
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge, getActiveMergeTaskId: () => null });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
store.updateTaskAtomic = undefined;
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: null });
expect(enqueueMerge).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
@@ -6723,8 +6865,19 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("pauses genuine failures and leaves blockedBy untouched", async () => {
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
/*
FNXC:MergeReliability 2026-08-10-05:42:
The deadlock producer must persist provenance so the stale-stamp fixture above represents a real
engine park rather than a no-reason human pause. REVERT CHECK (measured): omit pausedReason here
and the producer no longer reaches the clear-only merge-deadlock policy.
*/
it("pauses genuine failures with deadlock provenance and leaves blockedBy untouched", async () => {
const clearMergeActive = vi.fn();
const enqueueMerge = vi.fn();
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project", clearMergeActive, enqueueMerge,
getActiveMergeTaskId: () => null,
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
(store.listTasks as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([{ id: "FN-stuck", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt", log: [] }])
@@ -6736,11 +6889,29 @@ describe("SelfHealingManager", () => {
const result = await managerWithRecovery.recoverStuckMergeDeadlocks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", { paused: true });
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", {
paused: true,
pausedReason: "merge-deadlock-detected",
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith("FN-dep", { blockedBy: null });
expect(getSelfHealingLogger().warn).toHaveBeenCalledWith(expect.stringContaining("paused-for-manual"));
// Model a late superseded merge-body stamp after this exact producer park.
const parkedThenStamped = {
id: "FN-stuck", column: "in-review", paused: true, pausedReason: "merge-deadlock-detected",
status: "merging", updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
};
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...baseSettings, autoMerge: true });
(store.listTasks as ReturnType<typeof vi.fn>).mockReset().mockResolvedValue([parkedThenStamped]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parkedThenStamped);
(store.updateTask as ReturnType<typeof vi.fn>).mockClear();
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", { status: null });
expect(clearMergeActive).toHaveBeenCalledWith("FN-stuck");
expect(enqueueMerge).not.toHaveBeenCalled();
managerWithRecovery.stop();
});

View File

@@ -3526,8 +3526,16 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const minAgeMs = this.getStaleMergingStatusMinAgeMs();
if (!Number.isFinite(minAgeMs) || minAgeMs <= 0) return 0;
/*
FNXC:MergeReliability 2026-08-10-05:49:
Stale-stamp recovery needs a positive ownership probe before it can clear any status. An unwired
probe is unknown ownership, not proof that no merger exists; fail closed rather than allowing the
merge-deadlock pause exception to erase a live merge stamp in a structural integration.
*/
const getActiveMergeTaskId = this.options.getActiveMergeTaskId;
if (typeof getActiveMergeTaskId !== "function") return 0;
const now = Date.now();
const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null;
const activeMergeTaskId = getActiveMergeTaskId();
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-first sweep):
Clears a `merging`/`merging-pr` stamp left on a review card with no live merger behind it. The
@@ -3559,8 +3567,20 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
staleMergeLanes.set(entry.id, new Set(staleMergeReviewColumns));
}
}
/*
FNXC:MergeReliability 2026-08-10-05:32:
A bare `merge-deadlock-detected` engine pause can retain an unowned merge stamp forever because,
unlike `pauseTaskImpl`, its writer does not replace `status` with `paused`. Admit only that
automation-owned shape for a clear-only repair. Every human, approval, known operator-attention,
and unknown pause remains default-denied so a future pause writer cannot silently become eligible.
*/
const canClearPausedMergeDeadlockStamp = (candidate: Pick<Task, "paused" | "userPaused" | "pausedReason">): boolean =>
candidate.paused === true
&& candidate.userPaused !== true
&& candidate.pausedReason === "merge-deadlock-detected";
const stale = tasks.filter((task) => {
if (!(staleMergeLanes.get(task.id) ?? staleMergeReviewColumns).has(task.column) || task.paused) return false;
if (!(staleMergeLanes.get(task.id) ?? staleMergeReviewColumns).has(task.column)) return false;
if (task.paused && !canClearPausedMergeDeadlockStamp(task)) return false;
/*
FNXC:MergeReliability 2026-07-15-21:45 (FN-8004 follow-up):
Staleness now comes from the shared `isStaleMergeActiveStatus` leaf, which the dashboard's
@@ -3598,9 +3618,10 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
*/
let previousStatus: string | null | undefined;
const clearIfStillStale = (live: Task): boolean => {
const currentActiveMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null;
const currentActiveMergeTaskId = getActiveMergeTaskId();
if (
!shouldClearOrphanedMergeStamp(live)
(live.paused === true && !canClearPausedMergeDeadlockStamp(live))
|| !shouldClearOrphanedMergeStamp(live)
|| !isStaleMergeActiveStatus(live, {
activeMergeTaskId: currentActiveMergeTaskId,
nowMs: Date.now(),
@@ -3636,8 +3657,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
task.id,
`Auto-recovered: cleared stale '${previousStatus}' status (no active merger)`,
);
/*
FNXC:MergeReliability 2026-08-10-05:32:
Clearing the false badge is not permission to restart parked work. Check the live row returned
by the locked updater so a pause that races this sweep suppresses enqueue without weakening it.
*/
if (
allowsAutoMergeProcessing(current, settings)
current.paused !== true
&& allowsAutoMergeProcessing(current, settings)
&& current.mergeDetails?.mergeConfirmed !== true
&& !isWorkspaceTask(current)
) {
@@ -10939,7 +10966,13 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
if (!proof.ok) {
await this.emitBackwardMoveNoAction(task, "stuck-merge-deadlock", "task:stuck-merge-deadlock-no-action", proof);
} else {
await this.store.updateTask(task.id, { paused: true });
/*
FNXC:MergeReliability 2026-08-10-05:42:
A deadlock park preserves its merge-active status so operators can see the failed merge,
but it must carry durable automation provenance. That lets stale-stamp recovery repair only
this engine-owned false badge without treating a no-reason human pause as eligible.
*/
await this.store.updateTask(task.id, { paused: true, pausedReason: "merge-deadlock-detected" });
await this.store.logEntry(task.id, "merge-deadlock-detected: requires manual intervention — verified content not on main");
log.warn(`self-heal:deadlock-recovered ${JSON.stringify({ stuckTaskId: task.id, blockedTaskIds, attributedSha: null, action: "paused-for-manual" })}`);
recovered++;