From 5f70447a629e4e6c4bcecaa844825df32ad6333e Mon Sep 17 00:00:00 2001
From: gsxdsm
Date: Thu, 16 Jul 2026 22:09:49 -0700
Subject: [PATCH] FN-8108: require resolution for triage duplicate tasks
Require explicit resolution for triage-detected duplicate tasks.
- Add configurable prompt, keep, and delete handling for explicit duplicate markers.
- Surface linked duplicate decisions in task details and scheduling settings.
- Preserve task failure provenance and strengthen lifecycle recovery coverage.
Files changed:
.changeset/blocked-park-survives-graph-teardown.md | 7 +
.changeset/failure-provenance-promoter-marker.md | 7 +
.changeset/fn-8108-triage-duplicate-resolution.md | 7 +
.changeset/veto-progressing-does-not-clear.md | 7 +
docs/settings-reference.md | 2 +
.../completed-promotion-failure-provenance.test.ts | 31 +++
.../core/src/__tests__/duplicate-intake.test.ts | 11 ++
.../src/completed-promotion-failure-provenance.ts | 38 +++-
packages/core/src/duplicate-intake.ts | 29 +++
packages/core/src/index.gate.ts | 1 +
packages/core/src/index.ts | 3 +-
packages/core/src/settings-schema.ts | 1 +
packages/core/src/types.ts | 6 +
.../dashboard/app/components/TaskDetailModal.tsx | 38 +++-
.../__tests__/TaskDetailModal.rendering.test.tsx | 26 +++
.../settings/sections/SchedulingSection.search.ts | 9 +
.../settings/sections/SchedulingSection.tsx | 15 ++
.../__tests__/routes-tasks-near-duplicate.test.ts | 31 +++
.../src/routes/register-task-workflow-routes.ts | 19 ++
.../__tests__/executor-task-done-blocked.test.ts | 212 ++++++++++++++++++++-
packages/engine/src/__tests__/merger-ai.test.ts | 56 +++++-
.../__tests__/overseer-noop-finalize-veto.test.ts | 64 ++++++-
.../explicit-duplicate-marker-sweep.test.ts | 19 +-
packages/engine/src/__tests__/self-healing.test.ts | 53 ++++++
.../triage-explicit-duplicate-marker.test.ts | 22 ++-
packages/engine/src/executor.ts | 30 +++
packages/engine/src/merger-ai.ts | 5 +-
packages/engine/src/overseer-noop-finalize-veto.ts | 148 ++++++++++----
packages/engine/src/self-healing.ts | 29 ++-
packages/engine/src/triage.ts | 58 +++---
packages/i18n/locales/en/app.json | 15 +-
packages/i18n/src/resources.d.ts | 13 +-
32 files changed, 897 insertions(+), 115 deletions(-)
Fusion-Task-Id: FN-8108
Fusion-Task-Lineage: 8e732bad-d418-426e-85e1-903a7f990fba
Co-authored-by: Fusion (runfusion.ai)
---
.../fn-8108-triage-duplicate-resolution.md | 7 +++
docs/settings-reference.md | 2 +
.../src/__tests__/duplicate-intake.test.ts | 11 ++++
packages/core/src/duplicate-intake.ts | 29 ++++++++++
packages/core/src/index.gate.ts | 1 +
packages/core/src/index.ts | 1 +
packages/core/src/settings-schema.ts | 1 +
packages/core/src/types.ts | 6 ++
.../app/components/TaskDetailModal.tsx | 38 +++++++++++-
.../TaskDetailModal.rendering.test.tsx | 26 +++++++++
.../sections/SchedulingSection.search.ts | 9 +++
.../settings/sections/SchedulingSection.tsx | 15 +++++
.../routes-tasks-near-duplicate.test.ts | 31 ++++++++++
.../routes/register-task-workflow-routes.ts | 19 ++++++
.../explicit-duplicate-marker-sweep.test.ts | 19 +++---
.../triage-explicit-duplicate-marker.test.ts | 22 ++++++-
packages/engine/src/self-healing.ts | 29 ++++------
packages/engine/src/triage.ts | 58 ++++++++++---------
packages/i18n/locales/en/app.json | 15 ++++-
packages/i18n/src/resources.d.ts | 13 ++++-
20 files changed, 288 insertions(+), 64 deletions(-)
create mode 100644 .changeset/fn-8108-triage-duplicate-resolution.md
diff --git a/.changeset/fn-8108-triage-duplicate-resolution.md b/.changeset/fn-8108-triage-duplicate-resolution.md
new file mode 100644
index 0000000000..6399108bf2
--- /dev/null
+++ b/.changeset/fn-8108-triage-duplicate-resolution.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Triage-detected duplicate tasks are now blocked for a Keep/Delete decision instead of auto-deleted.
+category: feature
+dev: New project setting `triageDuplicateResolution` (`prompt` default | `keep` | `delete`) gates triage explicit-duplicate-marker handling and links the existing decision banner to the duplicate.
diff --git a/docs/settings-reference.md b/docs/settings-reference.md
index 9235822773..89d9a8a11e 100644
--- a/docs/settings-reference.md
+++ b/docs/settings-reference.md
@@ -1771,3 +1771,5 @@ Values are project-scoped and finite values are floored; count/backoff must be a
| `executorEscalationNodeId` | string, unset | Optional configured node target. |
Escalation is enabled only when the toggle is true and either a complete provider/model pair or a node ID is configured. It is single-shot: after FN-7996 exhausts same-model retries, Fusion persists the override and tries once before the existing terminal park. The alternate model enters the [model-selection hierarchy](#model-selection-hierarchy) as a task-level override; a node target enters `resolveEffectiveNode` as a task-level routing override and is requeued so scheduler routing is recalculated. This remains opt-in by default to avoid unexpected model cost or execution behavior. Column-agent overrides still govern their sessions and can supersede a task-level model target.
+
+| `triageDuplicateResolution` | `"prompt" \| "keep" \| "delete"` | `"prompt"` | Controls `DUPLICATE: FN-NNNN` markers emitted during triage. **prompt** flags and system-pauses the task for an operator Keep/Delete decision; the existing decision banner links to the canonical task. **keep** dismisses the marker and replans a real task. **delete** restores legacy auto-delete behavior. |
diff --git a/packages/core/src/__tests__/duplicate-intake.test.ts b/packages/core/src/__tests__/duplicate-intake.test.ts
index 695e136a16..51eb1b92f5 100644
--- a/packages/core/src/__tests__/duplicate-intake.test.ts
+++ b/packages/core/src/__tests__/duplicate-intake.test.ts
@@ -166,3 +166,14 @@ describe("flagSameAgentDuplicate (FN-7658)", () => {
});
});
});
+
+describe("flagTriageDuplicate", () => {
+ it("flags a triage marker without moving or deleting the task", async () => {
+ const { flagTriageDuplicate } = await import("../duplicate-intake.js");
+ const store = { 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: { nearDuplicateOf: "FN-1", nearDuplicateScore: 1, duplicateSource: "triage-marker", nearDuplicateDismissed: false } });
+ expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ source: "triage-marker-flagged", canonicalTaskId: "FN-1" }) }));
+ expect(store.deleteTask).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/duplicate-intake.ts b/packages/core/src/duplicate-intake.ts
index 3ec27e3319..14c26147c4 100644
--- a/packages/core/src/duplicate-intake.ts
+++ b/packages/core/src/duplicate-intake.ts
@@ -157,3 +157,32 @@ export async function flagSameAgentDuplicate(
// without a redundant re-fetch.
return sourceMetadataPatch;
}
+
+
+/**
+ * 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.
+ */
+export async function flagTriageDuplicate(
+ store: TaskStore,
+ taskId: string,
+ canonicalId: string,
+): Promise> {
+ const sourceMetadataPatch = {
+ nearDuplicateOf: canonicalId,
+ nearDuplicateScore: 1,
+ duplicateSource: "triage-marker",
+ nearDuplicateDismissed: false,
+ };
+ await store.logEntry(taskId, "Flagged as triage duplicate", `Duplicate marker points to ${canonicalId}; awaiting operator decision`);
+ await store.recordActivity({
+ type: "task:auto-archived-duplicate",
+ taskId,
+ details: "Flagged (not deleted) as triage-marker duplicate",
+ metadata: { canonicalTaskId: canonicalId, source: "triage-marker-flagged" },
+ });
+ await store.updateTask(taskId, { sourceMetadataPatch });
+ return sourceMetadataPatch;
+}
diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts
index d4f11dd4c0..572e35f9ac 100644
--- a/packages/core/src/index.gate.ts
+++ b/packages/core/src/index.gate.ts
@@ -718,6 +718,7 @@ export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,
flagSameAgentDuplicate,
+ flagTriageDuplicate,
type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c67a2e4f55..77c9143f5e 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -741,6 +741,7 @@ export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,
flagSameAgentDuplicate,
+ flagTriageDuplicate,
type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch,
diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts
index 28c2855c17..a5be16ee85 100644
--- a/packages/core/src/settings-schema.ts
+++ b/packages/core/src/settings-schema.ts
@@ -613,6 +613,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
// decide via the near-duplicate flag/UI instead of tasks silently vanishing
// into `archived` during intake. Set true to restore the pre-FN-7658 behavior.
autoArchiveDuplicateTasksEnabled: false,
+ triageDuplicateResolution: "prompt",
archiveAgentLogMode: "compact",
autoUpdatePrStatus: false,
githubCommentOnDone: false,
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 2bcca9993e..9ed0b176e6 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -3942,6 +3942,12 @@ export interface ProjectSettings {
* `archived`, so the dashboard's yellow "Duplicate" chip with Keep/Archive
* actions surfaces it for a human decision. Default: false. */
autoArchiveDuplicateTasksEnabled?: boolean;
+ /**
+ * FNXC:DuplicateIntake 2026-07-16-13:00:
+ * Issue #2225 requires triage marker duplicates to stay visible by default: `prompt`
+ * blocks for Keep/Delete, `keep` replans, and `delete` restores legacy deletion.
+ */
+ triageDuplicateResolution?: "prompt" | "keep" | "delete";
/** How much agent log content to preserve when a task is moved to cold archive storage.
* - "compact": deterministic summary plus a small recent-entry snapshot (default)
* - "full": copy the full agent.log into archive.db
diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx
index 7bf9299659..038ed8d336 100644
--- a/packages/dashboard/app/components/TaskDetailModal.tsx
+++ b/packages/dashboard/app/components/TaskDetailModal.tsx
@@ -775,6 +775,8 @@ export function TaskDetailContent({
* The Archive/Keep decision banner is actionable only while the referenced canonical exists and is active.
* Suppress the whole affordance for missing, archived, done, or soft-deleted canonicals so no empty banner shell or stale user-decision buttons remain.
*/
+ // FNXC:DuplicateIntake 2026-07-16-13:00: Issue #2225 reuses this linked banner for triage-marker Keep/Delete decisions.
+ const isTriageMarkerDuplicate = workingTask.sourceMetadata?.duplicateSource === "triage-marker";
const showNearDuplicateWarning = Boolean(nearDuplicateOf)
&& workingTask.sourceMetadata?.nearDuplicateDismissed !== true
&& task.column !== "archived"
@@ -2727,6 +2729,30 @@ export function TaskDetailContent({
}
}, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]);
+ /*
+ * FNXC:DuplicateIntake 2026-07-16-14:00:
+ * Issue #2225 requires triage-marker duplicates to offer a real Keep/Delete decision.
+ * Unlike the ordinary near-duplicate Archive action, Delete calls the existing soft-delete
+ * API and clears incoming lineage references so the confirmed duplicate is actually removed.
+ */
+ const handleDeleteTriageDuplicate = useCallback(async () => {
+ const confirmed = await confirm({
+ title: t("taskDetail.nearDuplicate.deleteTitle", "Delete duplicate task"),
+ message: t("taskDetail.nearDuplicate.deleteMessage", "Delete {{id}} as a duplicate of {{duplicateOf}}?", { id: task.id, duplicateOf: nearDuplicateOf }),
+ confirmLabel: t("taskDetail.nearDuplicate.deleteConfirm", "Delete"),
+ cancelLabel: t("common.cancel", "Cancel"),
+ danger: true,
+ });
+ if (!confirmed) return;
+ try {
+ await onDeleteTask(task.id, { removeLineageReferences: true });
+ addToast(t("taskDetail.nearDuplicate.deleted", "Deleted {{id}}", { id: task.id }), "success");
+ requestClose();
+ } catch (err) {
+ addToast(getErrorMessage(err), "error");
+ }
+ }, [onDeleteTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]);
+
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Detail-view Revert action, mirroring TaskCard's `handleRevertClick`: calls the
@@ -4093,14 +4119,20 @@ export function TaskDetailContent({
>
{nearDuplicateOf}
- {". "}{t("taskDetail.nearDuplicate.actions", "Choose Archive to move this task to archived, or Keep to continue with this task.")}
+ {". "}{isTriageMarkerDuplicate
+ ? t("taskDetail.nearDuplicate.triageActions", "Choose Delete to remove this duplicate, or Keep to continue anyway.")
+ : t("taskDetail.nearDuplicate.actions", "Choose Archive to move this task to archived, or Keep to continue with this task.")}
- {onArchiveTask && (
+ {isTriageMarkerDuplicate ? (
+
+ ) : onArchiveTask ? (
- )}
+ ) : null}
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx
index 0f8c989822..bfef2d9c46 100644
--- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx
@@ -2777,6 +2777,32 @@ describe("TaskDetailModal", () => {
});
});
+ it("deletes a triage-marker duplicate through the shared delete API", async () => {
+ const onDeleteTask = vi.fn().mockResolvedValue(makeTask());
+ mockConfirm.mockResolvedValueOnce(true);
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("status")).toHaveTextContent("Choose Delete to remove this duplicate, or Keep to continue anyway.");
+ await userEvent.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { removeLineageReferences: true });
+ });
+ });
+
it("renders corrected stats timing totals in Stats tab", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts b/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts
index 84334b2574..977c329839 100644
--- a/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts
+++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts
@@ -230,6 +230,15 @@ export const schedulingSearchEntries: SettingsSearchEntry[] = [
"Automatically archive tasks detected as same-agent duplicates on creation (off by default). When disabled, duplicates are flagged in place with the yellow Duplicate chip and Keep/Archive actions instead of being archived automatically.",
keywords: ["near duplicate", "dedupe", "repeat"],
},
+ {
+ sectionId: "scheduling",
+ key: "triageDuplicateResolution",
+ labelKey: "settings.scheduling.triageDuplicateResolution",
+ labelFallback: "Triage duplicate resolution",
+ helpKey: "settings.scheduling.triageDuplicateResolutionHelp",
+ helpFallback: "Block triage-detected duplicates for a Keep/Delete decision with a link to the duplicate (default), keep automatically, or delete automatically.",
+ keywords: ["duplicate", "triage", "keep", "delete", "decision"],
+ },
{
sectionId: "scheduling",
key: "maxStuckKills",
diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
index 3fc26d1f1a..1dd5f9ba4e 100644
--- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
@@ -273,6 +273,21 @@ export function SchedulingSection({ form, setForm, concurrencyLoading = false, o
autoArchiveDuplicateTasksEnabled: v === true,
}))}
/>
+ setForm((f) => ({ ...f, triageDuplicateResolution: v as "prompt" | "keep" | "delete" }))}
+ />
tasks.find((task) => task.id === id) ?? null),
+ getRootDir: () => process.cwd(),
updateTask: vi.fn().mockImplementation(async (id: string, updates: Record) => {
const index = tasks.findIndex((task) => task.id === id);
if (index < 0) throw new Error("Task not found");
@@ -250,6 +251,36 @@ describe("routes /api/tasks near duplicate", () => {
);
});
+ it("PATCH Keep resumes a triage-marker duplicate for real planning", async () => {
+ const seeded = mkTask({
+ id: "FN-6002",
+ title: "Triage marker duplicate",
+ description: "Test candidate",
+ column: "triage",
+ paused: true,
+ pausedReason: "duplicate-decision-required",
+ sourceMetadata: { nearDuplicateOf: "FN-1000", duplicateSource: "triage-marker" },
+ });
+ const { app, tasks } = buildApp([seeded]);
+
+ const res = await performRequest(
+ app,
+ "PATCH",
+ "/api/tasks/FN-6002",
+ JSON.stringify({ dismissNearDuplicate: true }),
+ { "content-type": "application/json" },
+ );
+
+ expect(res.status).toBe(200);
+ expect((res.body as Task)).toMatchObject({ paused: false, pausedReason: null, status: null });
+ expect((res.body as Task).sourceMetadata).toMatchObject({
+ nearDuplicateOf: "FN-1000",
+ duplicateSource: "triage-marker",
+ nearDuplicateDismissed: true,
+ });
+ expect(tasks[0]).toMatchObject({ paused: false, pausedReason: null, status: null });
+ });
+
it("PATCH dismissNearDuplicate applies sourceMetadataPatch merge", async () => {
const seeded = mkTask({
id: "FN-6001",
diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts
index 39aa18d6f1..01d4ff2e03 100644
--- a/packages/dashboard/src/routes/register-task-workflow-routes.ts
+++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts
@@ -4735,8 +4735,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
if (hasBodyField("overlapBlockedBy")) updates.overlapBlockedBy = validatedOverlapBlockedBy;
if (hasBodyField("status")) updates.status = validatedStatus;
+ const existingTaskForDuplicateDismissal = dismissNearDuplicate === true
+ ? await scopedStore.getTask(req.params.id)
+ : null;
if (dismissNearDuplicate === true) {
+ const isTriageMarkerDecision = existingTaskForDuplicateDismissal?.sourceMetadata?.duplicateSource === "triage-marker"
+ && existingTaskForDuplicateDismissal.pausedReason === "duplicate-decision-required";
+ /*
+ * FNXC:DuplicateIntake 2026-07-16-13:00:
+ * Keep resolves Issue #2225's default triage-marker hold by acknowledging the link,
+ * clearing only the system pause, and returning to planning without retaining a stub.
+ */
updates.sourceMetadataPatch = { nearDuplicateDismissed: true };
+ if (isTriageMarkerDecision) {
+ updates.paused = false;
+ updates.pausedReason = null;
+ updates.status = null;
+ }
}
/*
@@ -4761,6 +4776,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
const task = await scopedStore.updateTask(req.params.id, updates);
+ if (dismissNearDuplicate === true && task.sourceMetadata?.duplicateSource === "triage-marker") {
+ const { rm } = await import("node:fs/promises");
+ await rm(join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
+ }
const manualUnlinkRequested =
hasBodyField("githubTracking") &&
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 1c89a29f01..9e4714d02b 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
@@ -46,7 +46,7 @@ const canRun = hasGit && hasPg;
});
it("resolves an FN-5217-style stuck marker task during maintenance", async () => {
- const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN" } });
+ const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } });
fixtures.push(fx);
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
@@ -54,16 +54,15 @@ const canRun = hasGit && hasPg;
await (fx.manager as any).runMaintenance();
- await expect(fx.store.getTask(duplicate.id)).rejects.toThrow(`Task ${duplicate.id} not found`);
- const softDeletedDuplicate = await fx.store.getTask(duplicate.id, { includeDeleted: true });
- expect(softDeletedDuplicate.deletedAt).toEqual(expect.any(String));
+ const parkedDuplicate = await fx.store.getTask(duplicate.id);
+ expect(parkedDuplicate).toMatchObject({ paused: true, pausedReason: "duplicate-decision-required", sourceMetadata: expect.objectContaining({ nearDuplicateOf: canonical.id, duplicateSource: "triage-marker" }) });
const liveTasks = await fx.store.listTasks({ includeArchived: false });
- expect(liveTasks.map((task) => task.id)).not.toContain(duplicate.id);
+ expect(liveTasks.map((task) => task.id)).toContain(duplicate.id);
expect((await fx.store.getTask(canonical.id)).column).toBe("todo");
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 20 });
expect(activity.find((entry) => entry.taskId === duplicate.id)).toEqual(
expect.objectContaining({
- metadata: expect.objectContaining({ canonicalTaskId: canonical.id, source: "explicit-marker-sweep" }),
+ metadata: expect.objectContaining({ canonicalTaskId: canonical.id, source: "triage-marker-flagged" }),
}),
);
});
@@ -88,7 +87,7 @@ const canRun = hasGit && hasPg;
});
it("leaves marker tasks alone when the canonical target is missing", async () => {
- const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN" } });
+ const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } });
fixtures.push(fx);
const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: "DUPLICATE: FN-9999\n" });
@@ -101,7 +100,7 @@ const canRun = hasGit && hasPg;
});
it("leaves full specs untouched", async () => {
- const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN" } });
+ const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } });
fixtures.push(fx);
const duplicate = await createPromptTask(fx, { id: "FN-5302", column: "todo", prompt: FULL_SPEC });
@@ -124,7 +123,7 @@ const canRun = hasGit && hasPg;
});
it("caps work at 50 tasks per sweep", async () => {
- const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN" } });
+ const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } });
fixtures.push(fx);
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
@@ -148,7 +147,7 @@ const canRun = hasGit && hasPg;
}, 20_000);
it("fails open when one delete throws and continues processing later tasks", async () => {
- const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN" } });
+ const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } });
fixtures.push(fx);
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
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 edcd10c68e..b93426aa4e 100644
--- a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts
+++ b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts
@@ -43,9 +43,10 @@ describe("triage explicit duplicate marker short-circuit", () => {
store: TaskStore,
task: Task,
prompt: string,
+ testSettings: Settings = settings,
): Promise {
const processor = new TriageProcessor(store, rootDir);
- return await (processor as any).tryFinalizeExplicitDuplicateMarker(task, prompt, settings, {});
+ return await (processor as any).tryFinalizeExplicitDuplicateMarker(task, prompt, testSettings, {});
}
it("deletes the duplicate task and records explicit-marker activity", async () => {
@@ -54,7 +55,7 @@ describe("triage explicit duplicate marker short-circuit", () => {
getTask: vi.fn().mockImplementation(async (id: string) => (id === canonical.id ? canonical : null)),
});
- await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).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({
removeLineageReferences: true,
@@ -70,6 +71,23 @@ describe("triage explicit duplicate marker short-circuit", () => {
}));
});
+
+ it("flags and system-pauses duplicates by default instead of deleting", 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")).resolves.toBe(true);
+ expect(store.deleteTask).not.toHaveBeenCalled();
+ expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true, pausedReason: "duplicate-decision-required" }));
+ expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-001", duplicateSource: "triage-marker" }) }));
+ });
+
+ 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);
+ 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),
diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts
index abc88914f0..c0b3f23afe 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, 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, 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";
@@ -11691,23 +11691,16 @@ export class SelfHealingManager {
continue;
}
- await this.store.deleteTask(task.id, {
- removeLineageReferences: true,
- auditContext: {
- agentId: "self-healing",
- runId: generateSyntheticRunId("self-heal-explicit-duplicate", task.id),
- },
- });
- await this.store.recordActivity({
- type: "task:auto-archived-duplicate",
- taskId: task.id,
- taskTitle: task.title ?? "",
- details: `Duplicate of ${canonicalTask.id} — closed`,
- metadata: {
- canonicalTaskId: canonicalTask.id,
- source: "explicit-marker-sweep",
- },
- });
+ const resolution = settings.triageDuplicateResolution ?? "prompt";
+ 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") {
+ await flagTriageDuplicate(this.store, task.id, canonicalTask.id);
+ await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null });
+ } else {
+ rmSync(promptPath, { force: true });
+ await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null, sourceMetadataPatch: { nearDuplicateOf: canonicalTask.id, nearDuplicateScore: 1, duplicateSource: "triage-marker", nearDuplicateDismissed: true } });
+ }
log.log(`[self-healing] resolved explicit duplicate marker ${task.id} → ${canonicalTask.id}`);
resolved += 1;
} catch (error) {
diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts
index 770d08792d..4e3b6e55a2 100644
--- a/packages/engine/src/triage.ts
+++ b/packages/engine/src/triage.ts
@@ -19,6 +19,7 @@ import {
isUnplannedSeedPrompt,
getTaskDuplicateLineage,
parseExplicitDuplicateMarker,
+ flagTriageDuplicate,
resolveAgentPrompt,
builtinSeamPrompt,
renderTriagePolicyPlaceholders,
@@ -153,7 +154,7 @@ import { withRateLimitRetry } from "./rate-limit-retry.js";
import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js";
import type { StuckTaskDetector } from "./stuck-task-detector.js";
import { exec } from "node:child_process";
-import { readFile, writeFile } from "node:fs/promises";
+import { readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import {
@@ -2647,37 +2648,38 @@ export class TriageProcessor {
} = {},
): Promise {
let written = writtenInput;
- const dupMatch = written.match(/^DUPLICATE:\s*([A-Z]+-\d+)/i);
+ const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
- if (dupMatch) {
- const dupId = dupMatch[1];
- planLog.log(`${task.id} is a duplicate of ${dupId} — closing`);
- await this.store.logEntry(
- task.id,
- `Duplicate of ${dupId} — closed`,
- );
- try {
+ /*
+ * FNXC:DuplicateIntake 2026-07-16-13:00:
+ * Issue #2225 makes triage marker deletion opt-in. Prompt parks a visible linked
+ * near-duplicate decision; keep removes the marker before the next real plan.
+ */
+ if (explicitDuplicateMarker) {
+ const canonicalId = explicitDuplicateMarker.canonicalId;
+ const resolution = settings.triageDuplicateResolution ?? "prompt";
+ if (resolution === "delete") {
await this.store.recordActivity({
- type: "task:auto-archived-duplicate",
- taskId: task.id,
- taskTitle: task.title ?? "",
- details: `Duplicate of ${dupId} — closed`,
- metadata: {
- canonicalTaskId: dupId,
- source: "explicit-marker",
- },
+ type: "task:auto-archived-duplicate", taskId: task.id, taskTitle: task.title ?? "",
+ details: `Duplicate of ${canonicalId} — closed`, metadata: { canonicalTaskId: canonicalId, source: "explicit-marker" },
});
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- planLog.warn(`${task.id}: failed to record explicit duplicate-marker activity (${msg})`);
+ await this.store.deleteTask(task.id, {
+ removeLineageReferences: true,
+ auditContext: { agentId: task.assignedAgentId ?? "triage", runId: generateSyntheticRunId("triage-delete", task.id) },
+ });
+ return;
}
- // Pass removeLineageReferences so a duplicate-close cannot be blocked by lineage children (FN-5129 / FN-5131).
- await this.store.deleteTask(task.id, {
- removeLineageReferences: true,
- auditContext: {
- agentId: task.assignedAgentId ?? "triage",
- runId: generateSyntheticRunId("triage-delete", task.id),
- },
+ if (resolution === "prompt") {
+ await flagTriageDuplicate(this.store, task.id, canonicalId);
+ await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null });
+ return;
+ }
+ 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,
+ sourceMetadataPatch: { nearDuplicateOf: canonicalId, nearDuplicateScore: 1, duplicateSource: "triage-marker", nearDuplicateDismissed: true },
});
return;
}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 4498752bff..01c9de7fd7 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -6678,7 +6678,12 @@
"maxConcurrentTasksHint": "Default: 2.",
"maxConcurrentVerifications": "Max Concurrent Verifications",
"maxConcurrentVerificationsHint": "Caps stacked typecheck/build verification across tasks. Default: 1. Range: 1–8.",
- "pollIntervalMsHint": "Default: 15000 (15 seconds)."
+ "pollIntervalMsHint": "Default: 15000 (15 seconds).",
+ "triageDuplicateResolution": "Triage duplicate resolution",
+ "triageDuplicateResolutionHelp": "Block triage-detected duplicates for a Keep/Delete decision with a link to the duplicate (default), keep automatically, or delete automatically.",
+ "triageDuplicateResolutionPrompt": "Block for decision (default)",
+ "triageDuplicateResolutionKeep": "Keep automatically",
+ "triageDuplicateResolutionDelete": "Delete automatically"
},
"title": "Settings",
"worktrees": {
@@ -7684,9 +7689,15 @@
"archiveMessage": "Archive {{id}} as a duplicate of {{duplicateOf}}?",
"archiveTitle": "Archive near-duplicate task",
"copy": "This task appears to be a near-duplicate of",
+ "deleteBtn": "Delete",
+ "deleteConfirm": "Delete",
+ "deleted": "Deleted {{id}}",
+ "deleteMessage": "Delete {{id}} as a duplicate of {{duplicateOf}}?",
+ "deleteTitle": "Delete duplicate task",
"headline": "Potential duplicate detected",
"keepBtn": "Keep",
- "kept": "Kept {{id}} and dismissed duplicate warning"
+ "kept": "Kept {{id}} and dismissed duplicate warning",
+ "triageActions": "Choose Delete to remove this duplicate, or Keep to continue anyway."
},
"nextRecoveryAt": "Next recovery at {{time}}",
"no": "No",
diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts
index a73b007837..9afe50a743 100644
--- a/packages/i18n/src/resources.d.ts
+++ b/packages/i18n/src/resources.d.ts
@@ -6693,6 +6693,11 @@ export default interface Resources {
"strictDefault": "Strict (default)",
"stuckTaskTimeoutMinutes": "Stuck Task Timeout (minutes)",
"timeoutInMinutesForDetectingStuckTasksWhen": "Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10. Default: 10 minutes (600000ms).",
+ "triageDuplicateResolution": "Triage duplicate resolution",
+ "triageDuplicateResolutionDelete": "Delete automatically",
+ "triageDuplicateResolutionHelp": "Block triage-detected duplicates for a Keep/Delete decision with a link to the duplicate (default), keep automatically, or delete automatically.",
+ "triageDuplicateResolutionKeep": "Keep automatically",
+ "triageDuplicateResolutionPrompt": "Block for decision (default)",
"whenEnabledTasksThatModifyTheSameFiles": "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts. Default: enabled.",
"whenEnabledTasksWithStalePlansPROMPTMd": "When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning. Default: disabled.",
"whenTheStuckDetectorKillsAndReQueues": "When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled."
@@ -7745,9 +7750,15 @@ export default interface Resources {
"archiveTitle": "Archive near-duplicate task",
"archived": "Archived {{id}}",
"copy": "This task appears to be a near-duplicate of",
+ "deleteBtn": "Delete",
+ "deleteConfirm": "Delete",
+ "deleted": "Deleted {{id}}",
+ "deleteMessage": "Delete {{id}} as a duplicate of {{duplicateOf}}?",
+ "deleteTitle": "Delete duplicate task",
"headline": "Potential duplicate detected",
"keepBtn": "Keep",
- "kept": "Kept {{id}} and dismissed duplicate warning"
+ "kept": "Kept {{id}} and dismissed duplicate warning",
+ "triageActions": "Choose Delete to remove this duplicate, or Keep to continue anyway."
},
"nextRecoveryAt": "Next recovery at {{time}}",
"no": "No",