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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 11:12:54 -07:00
parent e43dffad52
commit 00891c225f
10 changed files with 221 additions and 11 deletions

View File

@@ -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).

View File

@@ -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. - **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`). - **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. 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: Activity uses the existing `task:auto-archived-duplicate` event with `metadata.source` disambiguators:

View File

@@ -257,4 +257,20 @@ describe("flagTriageDuplicate", () => {
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ source: "triage-marker-flagged", canonicalTaskId: "FN-1" }) })); expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ source: "triage-marker-flagged", canonicalTaskId: "FN-1" }) }));
expect(store.deleteTask).toBeUndefined(); 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" }),
});
});
}); });

View File

@@ -277,21 +277,38 @@ export async function flagSameAgentDuplicate(
/** /**
* FNXC:DuplicateIntake 2026-07-16-13:00: * FNXC:DuplicateIntake 2026-07-20-12:00:
* Issue #2225 keeps triage-marker duplicates visible for an operator decision. Reuse the * FN-8440 makes an operator Keep acknowledgement durable for exactly one task/canonical pair.
* near-duplicate metadata consumed by the existing linked banner/chip; never move or delete here. * Case-insensitive comparison matches task-id handling elsewhere: replan and maintenance must not
* Reset a prior Keep acknowledgement so a later marker cannot leave the task blocked without its UI. * recreate the decision hold for that same pair, while a new canonical remains actionable.
*/
export function isTriageDuplicateKeepAcknowledged(
sourceMetadata: Record<string, unknown> | 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( export async function flagTriageDuplicate(
store: TaskStore, store: TaskStore,
taskId: string, taskId: string,
canonicalId: string, canonicalId: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const existing = typeof store.getTask === "function"
? await store.getTask(taskId).catch(() => null)
: null;
const sourceMetadataPatch = { const sourceMetadataPatch = {
nearDuplicateOf: canonicalId, nearDuplicateOf: canonicalId,
nearDuplicateScore: 1, nearDuplicateScore: 1,
duplicateSource: "triage-marker", 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.logEntry(taskId, "Flagged as triage duplicate", `Duplicate marker points to ${canonicalId}; awaiting operator decision`);
await store.recordActivity({ await store.recordActivity({

View File

@@ -732,6 +732,7 @@ export {
archiveAsSameAgentDuplicate, archiveAsSameAgentDuplicate,
flagSameAgentDuplicate, flagSameAgentDuplicate,
flagTriageDuplicate, flagTriageDuplicate,
isTriageDuplicateKeepAcknowledged,
type SameAgentDuplicateInput, type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate, type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch, type SameAgentDuplicateMatch,

View File

@@ -830,6 +830,7 @@ export {
archiveAsSameAgentDuplicate, archiveAsSameAgentDuplicate,
flagSameAgentDuplicate, flagSameAgentDuplicate,
flagTriageDuplicate, flagTriageDuplicate,
isTriageDuplicateKeepAcknowledged,
type SameAgentDuplicateInput, type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate, type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch, type SameAgentDuplicateMatch,

View File

@@ -130,6 +130,65 @@ const canRun = hasGit && hasPg;
expect(existsSync(promptPath)).toBe(true); 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 () => { it("leaves full specs untouched", async () => {
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } }); const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } });
fixtures.push(fx); fixtures.push(fx);

View File

@@ -9,8 +9,11 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings), getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings),
logEntry: vi.fn(), logEntry: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
deleteTaskIf: vi.fn().mockResolvedValue({ deleted: true }),
recordActivity: vi.fn(), recordActivity: vi.fn(),
updateTask: vi.fn(), updateTask: vi.fn(),
withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise<unknown>) => await operation()),
readTaskForMove: vi.fn().mockImplementation(async () => createTask()),
moveTask: vi.fn(), moveTask: vi.fn(),
on: vi.fn(), on: vi.fn(),
off: 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); 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, removeLineageReferences: true,
auditContext: expect.objectContaining({ auditContext: expect.objectContaining({
agentId: "triage", 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 () => { it("keeps a marker duplicate by clearing its system pause for replanning", async () => {
const canonical = createTask({ id: "FN-001", column: "todo" }); const canonical = createTask({ id: "FN-001", column: "todo" });
const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) }); const task = createTask();
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "keep" })).resolves.toBe(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<unknown>) => 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.deleteTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, status: null })); 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<unknown>) => 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([ it.each([
["missing", null], ["missing", null],
["soft-deleted", createTask({ id: "FN-001", deletedAt: new Date().toISOString() })], ["soft-deleted", createTask({ id: "FN-001", deletedAt: new Date().toISOString() })],
["done", createTask({ id: "FN-001", column: "done" })], ["done", createTask({ id: "FN-001", column: "done" })],
["archived", createTask({ id: "FN-001", column: "archived" })], ["archived", createTask({ id: "FN-001", column: "archived" })],
])("clears an inactive %s canonical marker instead of pausing for a hidden decision", async (_state, canonical) => { ])("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", { expect(store.updateTask).toHaveBeenCalledWith("FN-002", {
paused: false, paused: false,

View File

@@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path"; 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 type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js"; import { createLogger, schedulerLog } from "./logger.js";
import { mergeEffectiveSettings } from "./effective-settings.js"; import { mergeEffectiveSettings } from "./effective-settings.js";
@@ -12083,6 +12083,25 @@ export class SelfHealingManager {
} }
const resolution = settings.triageDuplicateResolution ?? "prompt"; 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") { if (resolution === "delete") {
await this.store.deleteTask(task.id, { removeLineageReferences: true, auditContext: { agentId: "self-healing", runId: generateSyntheticRunId("self-heal-explicit-duplicate", task.id) } }); await this.store.deleteTask(task.id, { removeLineageReferences: true, auditContext: { agentId: "self-healing", runId: generateSyntheticRunId("self-heal-explicit-duplicate", task.id) } });
} else if (resolution === "prompt") { } else if (resolution === "prompt") {

View File

@@ -2502,6 +2502,27 @@ export class TriageProcessor {
} }
const resolution = settings.triageDuplicateResolution ?? "prompt"; 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") { if (resolution === "delete") {
const deleteTaskIf = (this.store as unknown as { deleteTaskIf?: TaskStore["deleteTaskIf"] }).deleteTaskIf; const deleteTaskIf = (this.store as unknown as { deleteTaskIf?: TaskStore["deleteTaskIf"] }).deleteTaskIf;
if (typeof deleteTaskIf !== "function") return; if (typeof deleteTaskIf !== "function") return;