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",