diff --git a/.changeset/fn-8356-stale-duplicate-decision.md b/.changeset/fn-8356-stale-duplicate-decision.md new file mode 100644 index 0000000000..de5ee3d546 --- /dev/null +++ b/.changeset/fn-8356-stale-duplicate-decision.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix tasks stuck on "Needs your decision" when their duplicate is already done. +category: fix +dev: Adds the task:reconcile-stale-duplicate-decision self-healing audit event. diff --git a/AGENTS.md b/AGENTS.md index 00580da989..0befcbe09f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-8004: `agent:heartbeat-move-skipped-soft-delete` records a heartbeat move that races a soft-deleted task without parking the durable agent. Metadata remains ids/timestamps/source only (`agentId`, optional `taskId`/`deletedAt`, `moveAttemptedAt`, optional `source`); it never stores error prose. - FN-8141: the executor's `fn_task_done(outcome="blocked", reason=..., blockedBy?=[...])` honest-blocked exit emits `task:execution-blocked-parked` when an executor parks a genuinely-impossible task `failed` (`error = "BLOCKED: "`) instead of laundering it to `done` by skipping steps. It bypasses the completion/verdict/bulk-completion gates (blocked is not a completion claim), leaves steps in their true statuses, preserves worktree/branch, records `blockedBy` as real `task.dependencies` edges so the task requeues behind the blocker, and does NOT hand off to review — the parked row is honored by the executor's `status === "failed"` post-loop branch and is not auto-recovered into in-review by `recoverStrandedCompletedTodoTasks` (steps are not all done/skipped and `task.error` is set). Metadata stays ids/outcomes-only (`taskId`, `blockedBy` ids, `hasReason` boolean — never the reason prose). - FN-8305: durable symbol-lock operations emit `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`. Metadata is ids/counts/outcomes-only; normalized opaque symbol keys are permitted IDs, while raw symbol prose is not. +- FN-8356: self-healing emits `task:reconcile-stale-duplicate-decision` when it clears a triage-marker duplicate-decision pause against a missing, deleted, done, or archived canonical. Metadata is ids/outcomes-only (`taskId`, `canonicalId`, `canonicalColumn`, `canonicalDeleted`, `priorPausedReason`); active canonical decisions and user pauses remain untouched. ## Reference docs (deeper detail) diff --git a/docs/architecture.md b/docs/architecture.md index 9c99ef679f..4f375b3657 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1112,6 +1112,7 @@ The run-audit system records every mutation performed by the engine across four - **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put). - **Database / `task:reattach-orphaned-execution`** — emitted by `reattachOrphanedAssignedExecutions` (FN-6336) when self-healing re-dispatches an idle assigned `in-progress` task forward via `executor.resumeTaskForAgent(agentId)` after proving the assigned agent has no active heartbeat run or active execution. - **Database / `task:reconcile-stale-agent-assignment`** — emitted when self-healing or heartbeat reconciliation clears stale durable `Agent.taskId`/`state` for a task parked in `todo`/`triage` without live execution proof. Metadata includes `{ agentId, taskId, taskColumn, agentState, status, blockedBy, overlapBlockedBy, hadFreshRun, hadActiveExecution, reason }`; task queue/lease fields are preserved. +- **Database / `task:reconcile-stale-duplicate-decision`** — emitted when self-healing clears a triage-marker duplicate-decision pause whose canonical is missing, deleted, done, or archived. Metadata is ids/outcomes-only: `{ taskId, canonicalId, canonicalColumn, canonicalDeleted, priorPausedReason }`; active canonicals and user pauses are excluded. - **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`. - **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution. - **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list. diff --git a/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts b/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts index 9e4714d02b..e50c38e0f2 100644 --- a/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdir, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { join } from "node:path"; // FNXC:SqliteRemoval 2026-07-14: hasPg guard added — makeReliabilityFixture requires PG after SQLite removal (VAL-REMOVAL-005). @@ -86,17 +87,47 @@ const canRun = hasGit && hasPg; expect(untouched.status).toBe("failed"); }); - it("leaves marker tasks alone when the canonical target is missing", async () => { - const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } }); + it.each(["missing", "done", "archived", "soft-deleted"] as const)("cleans an inactive %s canonical marker instead of parking a hidden decision", async (state) => { + const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } }); fixtures.push(fx); - const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: "DUPLICATE: FN-9999\n" }); + let canonicalId = "FN-9999"; + if (state !== "missing") { + const canonical = await fx.store.createTask({ title: "Inactive canonical", description: "canonical", column: state === "soft-deleted" ? "triage" : state }); + canonicalId = canonical.id; + if (state === "soft-deleted") { + await fx.store.deleteTask(canonical.id, { removeLineageReferences: true }); + } + } + const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: duplicateStub(canonicalId) }); + const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md"); await (fx.manager as any).resolveExplicitDuplicateMarkerTasks(); - expect((await fx.store.getTask(duplicate.id)).column).toBe("triage"); - const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 20 }); - expect(activity.find((entry) => entry.taskId === duplicate.id)).toBeUndefined(); + const updated = await fx.store.getTask(duplicate.id); + expect(updated.paused).not.toBe(true); + expect(updated.pausedReason ?? null).toBeNull(); + expect(updated.status ?? null).toBeNull(); + expect(existsSync(promptPath)).toBe(false); + }); + + it.each([ + ["user pause", { userPaused: true, paused: true, pausedReason: "manual" }], + ["implicit user pause", { paused: true, pausedReason: null }], + ["unrelated pause", { paused: true, pausedReason: "awaiting-approval" }], + ])("preserves a %s while an inactive marker is encountered", async (_label, pause) => { + const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } }); + fixtures.push(fx); + const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: duplicateStub("FN-9999") }); + await fx.store.updateTask(duplicate.id, pause); + const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md"); + + await (fx.manager as any).resolveExplicitDuplicateMarkerTasks(); + + const updated = await fx.store.getTask(duplicate.id); + expect(updated.paused).toBe(true); + expect(updated.pausedReason ?? null).toBe(pause.pausedReason ?? null); + expect(existsSync(promptPath)).toBe(true); }); it("leaves full specs untouched", async () => { diff --git a/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts b/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts new file mode 100644 index 0000000000..0e0dd586dd --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Settings, Task, TaskStore } from "@fusion/core"; + +const { recordRunAuditEventMock } = vi.hoisted(() => ({ + recordRunAuditEventMock: vi.fn(async () => undefined), +})); +vi.mock("../run-audit.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRunAuditor: vi.fn(() => ({ database: recordRunAuditEventMock, git: vi.fn(), filesystem: vi.fn(), sandbox: vi.fn() })), + }; +}); + +import { SelfHealingManager } from "../self-healing.js"; + +function task(id: string, overrides: Partial = {}): Task { + return { + id, + title: id, + description: id, + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +function stranded(id: string, canonicalId: string, overrides: Partial = {}): Task { + return task(id, { + paused: true, + pausedReason: "duplicate-decision-required", + sourceMetadata: { duplicateSource: "triage-marker", nearDuplicateOf: canonicalId }, + ...overrides, + }); +} + +function storeFor(tasks: Task[]): TaskStore & EventEmitter { + const tasksById = new Map(tasks.map((entry) => [entry.id, entry])); + return Object.assign(new EventEmitter(), { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false } as Settings)), + listTasks: vi.fn(async () => [...tasksById.values()]), + getTask: vi.fn(async (id: string) => tasksById.get(id)), + updateTask: vi.fn(async (id: string, patch: Partial & { sourceMetadataPatch?: Record }) => { + const current = tasksById.get(id)!; + const next = { + ...current, + ...patch, + sourceMetadata: patch.sourceMetadataPatch ? { ...current.sourceMetadata, ...patch.sourceMetadataPatch } : current.sourceMetadata, + } as Task; + tasksById.set(id, next); + return next; + }), + }) as unknown as TaskStore & EventEmitter; +} + +describe("FN-8356: reconcile stale duplicate-decision pauses", () => { + beforeEach(() => vi.clearAllMocks()); + + it("clears the FN-8353-shaped hidden decision for every inactive canonical state and audits each recovery", async () => { + const done = task("FN-DONE", { column: "done" }); + const archived = task("FN-ARCHIVED", { column: "archived" }); + const deleted = task("FN-DELETED", { deletedAt: new Date().toISOString() }); + const tasks = [ + stranded("FN-1", done.id), done, + stranded("FN-2", archived.id), archived, + stranded("FN-3", deleted.id), deleted, + stranded("FN-4", "FN-MISSING"), + ]; + const store = storeFor(tasks); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + + expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(4); + for (const id of ["FN-1", "FN-2", "FN-3", "FN-4"]) { + const recovered = await store.getTask(id); + expect(recovered?.paused).toBe(false); + expect(recovered?.pausedReason).toBeNull(); + expect(recovered?.sourceMetadata?.nearDuplicateDismissed).toBe(true); + // TaskCard and NotificationService both key their decision affordance on this predicate. + expect(recovered?.pausedReason === "duplicate-decision-required").toBe(false); + } + expect(recordRunAuditEventMock).toHaveBeenCalledTimes(4); + expect(recordRunAuditEventMock).toHaveBeenCalledWith(expect.objectContaining({ + type: "task:reconcile-stale-duplicate-decision", + metadata: expect.objectContaining({ priorPausedReason: "duplicate-decision-required" }), + })); + }); + + it("leaves active canonical decisions, user pauses, unrelated reasons, and non-marker sources untouched", async () => { + const active = task("FN-ACTIVE", { column: "todo" }); + const activeDecision = stranded("FN-1", active.id); + const userPaused = stranded("FN-2", "FN-MISSING", { userPaused: true }); + const unrelatedPause = stranded("FN-3", "FN-MISSING", { pausedReason: "awaiting-approval" }); + const nonMarker = stranded("FN-4", "FN-MISSING", { sourceMetadata: { duplicateSource: "other", nearDuplicateOf: "FN-MISSING" } }); + const store = storeFor([active, activeDecision, userPaused, unrelatedPause, nonMarker]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + + expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(0); + for (const entry of [activeDecision, userPaused, unrelatedPause, nonMarker]) { + expect(await store.getTask(entry.id)).toMatchObject({ paused: true, pausedReason: entry.pausedReason }); + } + expect(recordRunAuditEventMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts index b93426aa4e..408f956bcf 100644 --- a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts +++ b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts @@ -88,15 +88,35 @@ describe("triage explicit duplicate marker short-circuit", () => { expect(store.deleteTask).not.toHaveBeenCalled(); expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, status: null })); }); - it("does not short-circuit when the canonical target is missing", async () => { - const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(null), + it.each([ + ["missing", null], + ["soft-deleted", createTask({ id: "FN-001", deletedAt: new Date().toISOString() })], + ["done", createTask({ id: "FN-001", column: "done" })], + ["archived", createTask({ id: "FN-001", column: "archived" })], + ])("clears an inactive %s canonical marker instead of pausing for a hidden decision", async (_state, canonical) => { + const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) }); + + await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true); + + expect(store.updateTask).toHaveBeenCalledWith("FN-002", { + paused: false, + pausedReason: null, + status: null, }); - - await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-999\n")).resolves.toBe(false); - + expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true })); expect(store.deleteTask).not.toHaveBeenCalled(); - expect(store.recordActivity).not.toHaveBeenCalled(); + }); + + it.each([ + ["user pause", createTask({ userPaused: true, paused: true, pausedReason: "manual" })], + ["implicit user pause", createTask({ paused: true, pausedReason: null })], + ["unrelated pause", createTask({ paused: true, pausedReason: "awaiting-approval" })], + ])("preserves a %s while an inactive marker is encountered", async (_label, task) => { + const store = createMockStore({ getTask: vi.fn().mockResolvedValue(null) }); + + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); + + expect(store.updateTask).not.toHaveBeenCalled(); }); it("does not short-circuit on circular self-reference", async () => { diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 9131989757..32a452afaf 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -555,6 +555,8 @@ export type DatabaseMutationType = * Self-healing must leave file-scope lease queues intact while recording when stale durable Agent.taskId/state drift is cleared. Metadata: { agentId, taskId, taskColumn, agentState, status, blockedBy, overlapBlockedBy, hadFreshRun, hadActiveExecution, reason }. */ | "task:reconcile-stale-agent-assignment" + /** Metadata: { taskId, canonicalId, canonicalColumn, canonicalDeleted, priorPausedReason } */ + | "task:reconcile-stale-duplicate-decision" /** * FNXC:MergeQueue 2026-07-15-10:05: * Wedged single-flight merge reclaim. Metadata ids/outcomes-only: diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 7a9529ec5f..4c0fc1493b 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, flagTriageDuplicate, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core"; +import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; @@ -1365,6 +1365,7 @@ export class SelfHealingManager { // FN-5092: must run BEFORE any merger pickup path so the merger queue is // not stalled by a leaked `status: "merging"` on an already-done task. { name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus().then(() => undefined) }, + { name: "reconcile-stale-duplicate-decision", fn: () => this.reconcileStaleDuplicateDecisionPause().then(() => undefined) }, { name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks().then(() => undefined) }, { name: "recover-post-done-noncontinuable-wedge", fn: () => this.recoverPostDoneNonContinuableWedge().then(() => undefined) }, { name: "recover-completion-handoff-limbo", fn: () => this.recoverCompletionHandoffLimbo().then(() => undefined) }, @@ -2659,6 +2660,7 @@ export class SelfHealingManager { { name: "finalize-noop-review", fn: () => this.finalizeNoOpReviewTasks() }, { name: "reconcile-done-task-integrity", fn: () => this.reconcileDoneTaskIntegrity() }, { name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus() }, + { name: "reconcile-stale-duplicate-decision", fn: () => this.reconcileStaleDuplicateDecisionPause() }, { name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() }, // FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode reconcilers. { name: "reconcile-workspace-partial-lands", fn: () => this.reconcileWorkspacePartialLands() }, @@ -6338,6 +6340,65 @@ export class SelfHealingManager { } } + /** + * FNXC:NearDuplicateDetection 2026-07-17-20:10: + * FN-8356 reconciles only triage-marker duplicate-decision pauses whose canonical is no longer + * actionable. Its audit payload is ids/outcomes-only so stale-decision recovery never stores + * prompt or decision prose; active canonicals and user-owned pauses remain untouched. + */ + async reconcileStaleDuplicateDecisionPause(): Promise { + try { + const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 }); + const candidates = tasks.filter((task) => + task.paused === true + && task.userPaused !== true + && task.pausedReason === "duplicate-decision-required" + && task.sourceMetadata?.duplicateSource === "triage-marker" + && typeof task.sourceMetadata?.nearDuplicateOf === "string", + ); + + let cleared = 0; + for (const task of candidates.slice(0, 50)) { + try { + const canonicalId = task.sourceMetadata!.nearDuplicateOf as string; + const canonical = await this.store.getTask(canonicalId).catch(() => null); + if (!isNearDuplicateCanonicalInactive(canonical ?? undefined)) continue; + + await this.store.updateTask(task.id, { + paused: false, + pausedReason: null, + status: null, + sourceMetadataPatch: { nearDuplicateDismissed: true }, + }); + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("reconcile-stale-duplicate-decision", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reconcile-stale-duplicate-decision", + }).database({ + type: "task:reconcile-stale-duplicate-decision" as DatabaseMutationType, + target: task.id, + metadata: { + taskId: task.id, + canonicalId, + canonicalColumn: canonical?.column ?? null, + canonicalDeleted: Boolean(canonical?.deletedAt), + priorPausedReason: "duplicate-decision-required", + }, + }); + cleared += 1; + } catch (error) { + log.warn(`reconcileStaleDuplicateDecisionPause: failed for ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return cleared; + } catch (error) { + log.error(`reconcileStaleDuplicateDecisionPause failed: ${error instanceof Error ? error.message : String(error)}`); + return 0; + } + } + /** * 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. @@ -11718,11 +11779,25 @@ export class SelfHealingManager { processedMarkers += 1; const canonicalTask = await this.store.getTask(marker.canonicalId).catch(() => null); - if ( - !canonicalTask || - canonicalTask.deletedAt || - canonicalTask.id.toLowerCase() === task.id.toLowerCase() - ) { + if (canonicalTask?.id.toLowerCase() === task.id.toLowerCase()) { + continue; + } + + /* + FNXC:NearDuplicateDetection 2026-07-17-20:10: + FN-8356 keeps maintenance from re-parking a marker against a missing, deleted, done, + or archived canonical. Such a decision has no detail-banner action, so cleanup restores + eligible work to planning while preserving explicit, implicit, and unrelated system pauses. + */ + const canClearInactiveMarker = task.userPaused !== true + && (task.paused !== true || task.pausedReason === "duplicate-decision-required") + && (task.pausedReason == null || task.pausedReason === "duplicate-decision-required"); + if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask)) { + if (canClearInactiveMarker) { + rmSync(promptPath, { force: true }); + await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null }); + resolved += 1; + } continue; } diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index cfb8996b48..7205f4e6bc 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2867,16 +2867,23 @@ export class TriageProcessor { } const canonicalId = explicitDuplicateMarker.canonicalId; - const canonicalTask = await this.store.getTask(canonicalId).catch(() => null); - if ( - !canonicalTask || - canonicalTask.deletedAt || - canonicalTask.id.toLowerCase() === task.id.toLowerCase() - ) { + // A transient lookup failure must still fail open; only a genuine missing row is inactive. + const canonicalTask = await this.store.getTask(canonicalId); + if (canonicalTask?.id.toLowerCase() === task.id.toLowerCase()) { return false; } - planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`); + /* + FNXC:NearDuplicateDetection 2026-07-17-20:10: + FN-8356 requires missing, deleted, done, and archived duplicate canonicals to flow through + marker cleanup instead of being rejected here. The detail banner cannot offer a decision for + an inactive canonical, so parking the card would strand its Needs your decision badge. + */ + if (isNearDuplicateCanonicalInactive(canonicalTask)) { + planLog.log(`${task.id} explicit duplicate marker targets inactive ${canonicalId}; clearing marker for replanning`); + } else { + planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`); + } await this.finalizeApprovedTask(task, written, settings, options); return true; } catch (err) { @@ -2931,6 +2938,26 @@ export class TriageProcessor { */ if (explicitDuplicateMarker) { const canonicalId = explicitDuplicateMarker.canonicalId; + const canonicalTask = await this.store.getTask(canonicalId).catch(() => null); + const canClearInactiveMarker = task.userPaused !== true + && (task.paused !== true || task.pausedReason === "duplicate-decision-required") + && (task.pausedReason == null || task.pausedReason === "duplicate-decision-required"); + + /* + FNXC:NearDuplicateDetection 2026-07-17-20:10: + FN-8356 prevents an inactive duplicate canonical from creating a prompt pause. The detail + view deliberately hides decisions for missing, deleted, done, or archived canonicals, so + remove only the marker and return eligible work to planning instead of stranding its badge; + explicit, implicit, and unrelated pauses are preserved. + */ + if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined)) { + if (canClearInactiveMarker) { + await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); + await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null }); + } + return; + } + const resolution = settings.triageDuplicateResolution ?? "prompt"; if (resolution === "delete") { await this.store.recordActivity({