From 00891c225f8252a3db8b841461d2ddf162594ded Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 11:12:54 -0700 Subject: [PATCH] FN-8440: preserve acknowledged duplicate decisions Keep an operator's same-canonical duplicate decision durable across triage and maintenance reprocessing. - Preserve acknowledged Keep decisions for the same canonical while prompting for a new canonical. - Clear acknowledged duplicate markers without overriding manual pauses. - Export the acknowledgement helper through both core barrels for engine gate loading. - Add regression coverage, operator documentation, and a patch changeset. Files changed: .changeset/fn-8440-persist-duplicate-decision.md | 7 ++ docs/task-management.md | 2 + .../core/src/__tests__/duplicate-intake.test.ts | 16 +++++ packages/core/src/duplicate-intake.ts | 27 ++++++-- packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + .../explicit-duplicate-marker-sweep.test.ts | 59 +++++++++++++++++ .../triage-explicit-duplicate-marker.test.ts | 77 ++++++++++++++++++++-- packages/engine/src/self-healing.ts | 21 +++++- packages/engine/src/triage.ts | 21 ++++++ 10 files changed, 221 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-8440 Fusion-Task-Lineage: 966d7c09-8651-463c-840d-707f2bafd2a7 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8440-persist-duplicate-decision.md | 7 ++ docs/task-management.md | 2 + .../src/__tests__/duplicate-intake.test.ts | 16 ++++ packages/core/src/duplicate-intake.ts | 27 +++++-- packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + .../explicit-duplicate-marker-sweep.test.ts | 59 ++++++++++++++ .../triage-explicit-duplicate-marker.test.ts | 77 +++++++++++++++++-- packages/engine/src/self-healing.ts | 21 ++++- packages/engine/src/triage.ts | 21 +++++ 10 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-8440-persist-duplicate-decision.md diff --git a/.changeset/fn-8440-persist-duplicate-decision.md b/.changeset/fn-8440-persist-duplicate-decision.md new file mode 100644 index 0000000000..47157ed557 --- /dev/null +++ b/.changeset/fn-8440-persist-duplicate-decision.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Persist operator duplicate decisions so Fusion does not re-ask for the same task. +category: fix +dev: Honor nearDuplicateDismissed for the same nearDuplicateOf across triage finalize and explicit-marker self-healing (FN-8440). diff --git a/docs/task-management.md b/docs/task-management.md index 5d77215140..dab2219f3f 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -138,6 +138,8 @@ Layer behavior: - **Triage planning loop** — after triage reads the generated `PROMPT.md`, an exact redirect marker short-circuits directly into `finalizeApprovedTask()`. Normal plans run deterministic spec hygiene checks in triage, then the selected workflow's optional Plan Review gate owns AI plan review before execution. - **Self-healing sweep** — maintenance Batch 2 runs `resolveExplicitDuplicateMarkerTasks()` across `triage`/`todo` tasks to clean up older stuck marker tasks. The sweep is best-effort, capped at 50 marker tasks per cycle, and can be disabled with the internal setting `resolveExplicitDuplicateMarkerEnabled: false` (default `true`). +An operator's decision is durable for a task and its active canonical pair. **Keep** records the acknowledgement, clears the marker-only prompt and triage decision hold, and lets planning continue; triage and self-healing will not ask again if that same marker is reprocessed. A marker for a different active canonical remains a new decision. **Delete** for an explicit-marker decision soft-deletes the duplicate, while **Archive** for an ordinary near-duplicate leaves it terminal in Archived; neither outcome is reopened as a duplicate decision. + All three layers fail open: parse errors, task lookup failures, file-read failures, activity-recording errors, or other unexpected exceptions log a warning and continue normal intake/triage/self-healing flow instead of blocking task creation or recovery. Activity uses the existing `task:auto-archived-duplicate` event with `metadata.source` disambiguators: diff --git a/packages/core/src/__tests__/duplicate-intake.test.ts b/packages/core/src/__tests__/duplicate-intake.test.ts index 1f110798c3..e86425e867 100644 --- a/packages/core/src/__tests__/duplicate-intake.test.ts +++ b/packages/core/src/__tests__/duplicate-intake.test.ts @@ -257,4 +257,20 @@ describe("flagTriageDuplicate", () => { expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ source: "triage-marker-flagged", canonicalTaskId: "FN-1" }) })); expect(store.deleteTask).toBeUndefined(); }); + + it("preserves a same-canonical Keep acknowledgement when re-flagged", async () => { + const { flagTriageDuplicate } = await import("../duplicate-intake.js"); + const store = { + getTask: vi.fn().mockResolvedValue({ sourceMetadata: { nearDuplicateOf: "fn-1", nearDuplicateDismissed: true } }), + logEntry: vi.fn(), + recordActivity: vi.fn(), + updateTask: vi.fn(), + } as any; + + await flagTriageDuplicate(store, "FN-2", "FN-1"); + + expect(store.updateTask).toHaveBeenCalledWith("FN-2", { + sourceMetadataPatch: expect.objectContaining({ nearDuplicateDismissed: true, nearDuplicateOf: "FN-1" }), + }); + }); }); diff --git a/packages/core/src/duplicate-intake.ts b/packages/core/src/duplicate-intake.ts index 6143bf6296..3bc77f57e1 100644 --- a/packages/core/src/duplicate-intake.ts +++ b/packages/core/src/duplicate-intake.ts @@ -277,21 +277,38 @@ export async function flagSameAgentDuplicate( /** - * FNXC:DuplicateIntake 2026-07-16-13:00: - * Issue #2225 keeps triage-marker duplicates visible for an operator decision. Reuse the - * near-duplicate metadata consumed by the existing linked banner/chip; never move or delete here. - * Reset a prior Keep acknowledgement so a later marker cannot leave the task blocked without its UI. + * FNXC:DuplicateIntake 2026-07-20-12:00: + * FN-8440 makes an operator Keep acknowledgement durable for exactly one task/canonical pair. + * Case-insensitive comparison matches task-id handling elsewhere: replan and maintenance must not + * recreate the decision hold for that same pair, while a new canonical remains actionable. + */ +export function isTriageDuplicateKeepAcknowledged( + sourceMetadata: Record | null | undefined, + canonicalId: string, +): boolean { + return sourceMetadata?.nearDuplicateDismissed === true + && typeof sourceMetadata.nearDuplicateOf === "string" + && sourceMetadata.nearDuplicateOf.toLowerCase() === canonicalId.toLowerCase(); +} + +/** + * FNXC:DuplicateIntake 2026-07-20-12:00: + * FN-8440 forbids re-asking after Keep for the same task/canonical pair. A marker for a different + * canonical deliberately resets acknowledgement so its operator decision can still be surfaced. */ export async function flagTriageDuplicate( store: TaskStore, taskId: string, canonicalId: string, ): Promise> { + const existing = typeof store.getTask === "function" + ? await store.getTask(taskId).catch(() => null) + : null; const sourceMetadataPatch = { nearDuplicateOf: canonicalId, nearDuplicateScore: 1, duplicateSource: "triage-marker", - nearDuplicateDismissed: false, + nearDuplicateDismissed: isTriageDuplicateKeepAcknowledged(existing?.sourceMetadata, canonicalId), }; await store.logEntry(taskId, "Flagged as triage duplicate", `Duplicate marker points to ${canonicalId}; awaiting operator decision`); await store.recordActivity({ diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 9f3c3e4e1c..b9601a688b 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -732,6 +732,7 @@ export { archiveAsSameAgentDuplicate, flagSameAgentDuplicate, flagTriageDuplicate, + isTriageDuplicateKeepAcknowledged, type SameAgentDuplicateInput, type SameAgentDuplicateCandidate, type SameAgentDuplicateMatch, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26a9743a90..9cb8bf3fd2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -830,6 +830,7 @@ export { archiveAsSameAgentDuplicate, flagSameAgentDuplicate, flagTriageDuplicate, + isTriageDuplicateKeepAcknowledged, type SameAgentDuplicateInput, type SameAgentDuplicateCandidate, type SameAgentDuplicateMatch, 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 e50c38e0f2..358b180c2c 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 @@ -130,6 +130,65 @@ const canRun = hasGit && hasPg; expect(existsSync(promptPath)).toBe(true); }); + it("does not re-pause a same-canonical Keep acknowledgement during maintenance", async () => { + const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } }); + fixtures.push(fx); + + const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" }); + const duplicate = await createPromptTask(fx, { id: "FN-5302", column: "triage", prompt: duplicateStub(canonical.id) }); + const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md"); + await fx.store.updateTask(duplicate.id, { + sourceMetadataPatch: { nearDuplicateOf: canonical.id.toLowerCase(), duplicateSource: "triage-marker", nearDuplicateDismissed: true }, + }); + + await (fx.manager as any).resolveExplicitDuplicateMarkerTasks(); + + const updated = await fx.store.getTask(duplicate.id); + expect(updated.paused).not.toBe(true); + expect(updated.pausedReason ?? null).toBeNull(); + expect(updated.sourceMetadata).toEqual(expect.objectContaining({ nearDuplicateOf: canonical.id.toLowerCase(), nearDuplicateDismissed: true })); + expect(existsSync(promptPath)).toBe(false); + }); + + it("still prompts when a marker names a different active canonical", async () => { + const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } }); + fixtures.push(fx); + + const originalCanonical = await fx.store.createTask({ title: "Original canonical", description: "original", column: "todo" }); + const canonical = await fx.store.createTask({ title: "New canonical", description: "new", column: "todo" }); + const duplicate = await createPromptTask(fx, { id: "FN-5303", column: "triage", prompt: duplicateStub(canonical.id) }); + await fx.store.updateTask(duplicate.id, { + sourceMetadataPatch: { nearDuplicateOf: originalCanonical.id, duplicateSource: "triage-marker", nearDuplicateDismissed: true }, + }); + + await (fx.manager as any).resolveExplicitDuplicateMarkerTasks(); + + expect(await fx.store.getTask(duplicate.id)).toMatchObject({ + paused: true, + pausedReason: "duplicate-decision-required", + sourceMetadata: expect.objectContaining({ nearDuplicateOf: canonical.id, nearDuplicateDismissed: false }), + }); + }); + + it("preserves a user pause while cleaning an acknowledged marker", async () => { + const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } }); + fixtures.push(fx); + + const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" }); + const duplicate = await createPromptTask(fx, { id: "FN-5304", column: "triage", prompt: duplicateStub(canonical.id) }); + const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md"); + await fx.store.updateTask(duplicate.id, { + paused: true, + pausedReason: "manual", + sourceMetadataPatch: { nearDuplicateOf: canonical.id, duplicateSource: "triage-marker", nearDuplicateDismissed: true }, + }); + + await (fx.manager as any).resolveExplicitDuplicateMarkerTasks(); + + expect(await fx.store.getTask(duplicate.id)).toMatchObject({ paused: true, pausedReason: "manual" }); + expect(existsSync(promptPath)).toBe(true); + }); + it("leaves full specs untouched", async () => { const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } }); fixtures.push(fx); 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 408f956bcf..6eaa5d43cd 100644 --- a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts +++ b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts @@ -9,8 +9,11 @@ function createMockStore(overrides: Partial = {}): TaskStore { getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings), logEntry: vi.fn(), deleteTask: vi.fn(), + deleteTaskIf: vi.fn().mockResolvedValue({ deleted: true }), recordActivity: vi.fn(), updateTask: vi.fn(), + withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise) => await operation()), + readTaskForMove: vi.fn().mockImplementation(async () => createTask()), moveTask: vi.fn(), on: vi.fn(), off: vi.fn(), @@ -57,7 +60,7 @@ describe("triage explicit duplicate marker short-circuit", () => { await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "delete" })).resolves.toBe(true); - expect(store.deleteTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ + expect((store as any).deleteTaskIf).toHaveBeenCalledWith("FN-002", expect.any(Function), expect.objectContaining({ removeLineageReferences: true, auditContext: expect.objectContaining({ agentId: "triage", @@ -83,20 +86,84 @@ describe("triage explicit duplicate marker short-circuit", () => { it("keeps a marker duplicate by clearing its system pause for replanning", async () => { const canonical = createTask({ id: "FN-001", column: "todo" }); - const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) }); - await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "keep" })).resolves.toBe(true); + const task = createTask(); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), + withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise) => await operation()), + readTaskForMove: vi.fn().mockResolvedValue(task), + }); + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "keep" })).resolves.toBe(true); expect(store.deleteTask).not.toHaveBeenCalled(); expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, status: null })); }); + + it("does not re-pause a same-canonical Keep acknowledgement after marker reprocessing", async () => { + const canonical = createTask({ id: "FN-001", column: "todo" }); + const task = createTask({ + sourceMetadata: { nearDuplicateOf: "fn-001", duplicateSource: "triage-marker", nearDuplicateDismissed: true }, + }); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), + withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise) => await operation()), + readTaskForMove: vi.fn().mockResolvedValue(task), + }); + + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); + + expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ + paused: false, + pausedReason: null, + sourceMetadataPatch: { nearDuplicateDismissed: true }, + })); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true })); + }); + + it("prompts when a reprocessed marker names a different active canonical", async () => { + const canonical = createTask({ id: "FN-001", column: "todo" }); + const task = createTask({ + sourceMetadata: { nearDuplicateOf: "FN-003", duplicateSource: "triage-marker", nearDuplicateDismissed: true }, + }); + const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) }); + + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); + + expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ + paused: true, + pausedReason: "duplicate-decision-required", + sourceMetadataPatch: expect.objectContaining({ nearDuplicateDismissed: false, nearDuplicateOf: "FN-001" }), + })); + }); + + it("preserves a user pause when reprocessing an acknowledged marker", async () => { + const canonical = createTask({ id: "FN-001", column: "todo" }); + const task = createTask({ + paused: true, + pausedReason: "manual", + userPaused: true, + sourceMetadata: { nearDuplicateOf: "FN-001", duplicateSource: "triage-marker", nearDuplicateDismissed: true }, + }); + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue(canonical), + readTaskForMove: vi.fn().mockResolvedValue(task), + }); + + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); + + expect(store.updateTask).not.toHaveBeenCalled(); + }); 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) }); + const task = createTask(); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === "FN-001" ? canonical : task), + readTaskForMove: vi.fn().mockResolvedValue(task), + }); - await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true); + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); expect(store.updateTask).toHaveBeenCalledWith("FN-002", { paused: false, diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 364c2a19a0..7b3db0b9e6 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, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, resolveReboundTarget, planLegacyAdoption, 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, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, resolveReboundTarget, planLegacyAdoption, 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"; @@ -12083,6 +12083,25 @@ export class SelfHealingManager { } const resolution = settings.triageDuplicateResolution ?? "prompt"; + /* + FNXC:DuplicateIntake 2026-07-20-12:00: + FN-8440 forbids self-healing from re-arming a duplicate-decision-required hold after + an operator kept this exact task/canonical pair. Keep cleanup is bounded by the same + pause guard as inactive-marker recovery so manual and unrelated pauses are never cleared. + */ + if (resolution === "prompt" && isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalTask.id)) { + if (canClearInactiveMarker) { + rmSync(promptPath, { force: true }); + await this.store.updateTask(task.id, { + paused: false, + pausedReason: null, + status: null, + sourceMetadataPatch: { nearDuplicateDismissed: true }, + }); + resolved += 1; + } + continue; + } if (resolution === "delete") { await this.store.deleteTask(task.id, { removeLineageReferences: true, auditContext: { agentId: "self-healing", runId: generateSyntheticRunId("self-heal-explicit-duplicate", task.id) } }); } else if (resolution === "prompt") { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index bc67b306a2..a8efde27fb 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2502,6 +2502,27 @@ export class TriageProcessor { } const resolution = settings.triageDuplicateResolution ?? "prompt"; + /* + FNXC:DuplicateIntake 2026-07-20-12:00: + FN-8440 requires a Keep decision to survive marker re-ingestion during replan. The + acknowledgement is scoped to this canonical id, so a marker targeting a different active + task still receives its own prompt; user and unrelated pauses remain untouched. + */ + const keepAcknowledged = fusionCore.isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalId); + if (resolution === "prompt" && keepAcknowledged) { + if (canClearInactiveMarker) { + if (!await this.runIfStillPlanningUnderTaskLock(task, async () => { + await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); + })) return; + await this.updatePlanningStateIfStillCurrent(task, { + paused: false, + pausedReason: null, + status: null, + sourceMetadataPatch: { nearDuplicateDismissed: true }, + }); + } + return; + } if (resolution === "delete") { const deleteTaskIf = (this.store as unknown as { deleteTaskIf?: TaskStore["deleteTaskIf"] }).deleteTaskIf; if (typeof deleteTaskIf !== "function") return;