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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 22:09:49 -07:00
parent 0753476c0a
commit 5f70447a62
20 changed files with 288 additions and 64 deletions

View File

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

View File

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

View File

@@ -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();
});
});

View File

@@ -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<Record<string, unknown>> {
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;
}

View File

@@ -718,6 +718,7 @@ export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,
flagSameAgentDuplicate,
flagTriageDuplicate,
type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch,

View File

@@ -741,6 +741,7 @@ export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,
flagSameAgentDuplicate,
flagTriageDuplicate,
type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch,

View File

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

View File

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

View File

@@ -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}
</button>
{". "}{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.")}
</p>
<div className="detail-near-duplicate-banner__actions">
{onArchiveTask && (
{isTriageMarkerDuplicate ? (
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleDeleteTriageDuplicate()}>
{t("taskDetail.nearDuplicate.deleteBtn", "Delete")}
</button>
) : onArchiveTask ? (
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleArchiveNearDuplicate()}>
{t("taskDetail.nearDuplicate.archiveBtn", "Archive")}
</button>
)}
) : null}
<button type="button" className="btn btn-sm" onClick={() => void handleDismissNearDuplicate()}>
{t("taskDetail.nearDuplicate.keepBtn", "Keep")}
</button>

View File

@@ -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(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", duplicateSource: "triage-marker" } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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);

View File

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

View File

@@ -273,6 +273,21 @@ export function SchedulingSection({ form, setForm, concurrencyLoading = false, o
autoArchiveDuplicateTasksEnabled: v === true,
}))}
/>
<SettingsSelectRow
descriptor={{
key: "triageDuplicateResolution",
label: t("settings.scheduling.triageDuplicateResolution", "Triage duplicate resolution"),
help: t("settings.scheduling.triageDuplicateResolutionHelp", "Block triage duplicates for a linked Keep/Delete decision (default), keep automatically, or delete automatically."),
scope: "project",
options: [
{ value: "prompt", label: t("settings.scheduling.triageDuplicateResolutionPrompt", "Block for decision (default)") },
{ value: "keep", label: t("settings.scheduling.triageDuplicateResolutionKeep", "Keep automatically") },
{ value: "delete", label: t("settings.scheduling.triageDuplicateResolutionDelete", "Delete automatically") },
],
}}
value={form.triageDuplicateResolution ?? "prompt"}
onChange={(v) => setForm((f) => ({ ...f, triageDuplicateResolution: v as "prompt" | "keep" | "delete" }))}
/>
<SettingsNumberRow
descriptor={{
key: "maxStuckKills",

View File

@@ -42,6 +42,7 @@ function buildApp(seed: Task[]) {
return created;
}),
getTask: vi.fn().mockImplementation(async (id: string) => tasks.find((task) => task.id === id) ?? null),
getRootDir: () => process.cwd(),
updateTask: vi.fn().mockImplementation(async (id: string, updates: Record<string, unknown>) => {
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",

View File

@@ -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") &&

View File

@@ -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" });

View File

@@ -43,9 +43,10 @@ describe("triage explicit duplicate marker short-circuit", () => {
store: TaskStore,
task: Task,
prompt: string,
testSettings: Settings = settings,
): Promise<boolean> {
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),

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 { 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) {

View File

@@ -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<void> {
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;
}

View File

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

View File

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