diff --git a/.changeset/fn-8925-paused-stale-merge.md b/.changeset/fn-8925-paused-stale-merge.md new file mode 100644 index 0000000000..c181ae24d6 --- /dev/null +++ b/.changeset/fn-8925-paused-stale-merge.md @@ -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. diff --git a/docs/self-healing-backward-move-audit.md b/docs/self-healing-backward-move-audit.md index 67c8a13e9e..3a94d9a829 100644 --- a/docs/self-healing-backward-move-audit.md +++ b/docs/self-healing-backward-move-audit.md @@ -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` | diff --git a/docs/task-management.md b/docs/task-management.md index 3e469ed239..7f5b975715 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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` diff --git a/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts b/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts index 78c5f97ae3..b92207c2cf 100644 --- a/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts +++ b/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts @@ -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 })); }); diff --git a/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts b/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts index 8c80a7455c..9703dc19ce 100644 --- a/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts +++ b/packages/engine/src/__tests__/self-healing-stale-merge-fanout.test.ts @@ -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(); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 3c5f3f463d..1fd0718a32 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -4956,6 +4956,7 @@ describe("SelfHealingManager", () => { const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge, + getActiveMergeTaskId: () => null, }); (store.getSettings as ReturnType).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).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).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).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).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false }); + (store.listTasks as ReturnType).mockResolvedValue([pausedTask]); + (store.getTask as ReturnType).mockResolvedValue(pausedTask); + const patches: Partial[] = []; + store.updateTaskAtomic = vi.fn(async (_id: string, updater: (live: Task) => Partial | 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).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false }); (store.listTasks as ReturnType).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).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false }); + (store.listTasks as ReturnType).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).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false }); + (store.listTasks as ReturnType).mockResolvedValue([snapshot]); + store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial | 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).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false }); + (store.listTasks as ReturnType).mockResolvedValue([snapshot]); + store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial | 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).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false }); + (store.listTasks as ReturnType).mockResolvedValue([snapshot]); + store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial | 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).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false }); + (store.listTasks as ReturnType).mockResolvedValue([task]); + (store.getTask as ReturnType).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).mockResolvedValue(baseSettings); (store.listTasks as ReturnType) .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).mockResolvedValue({ ...baseSettings, autoMerge: true }); + (store.listTasks as ReturnType).mockReset().mockResolvedValue([parkedThenStamped]); + (store.getTask as ReturnType).mockResolvedValue(parkedThenStamped); + (store.updateTask as ReturnType).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(); }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 0ddc93014e..02605bea3d 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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): 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++;