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:
7
.changeset/fn-8108-triage-duplicate-resolution.md
Normal file
7
.changeset/fn-8108-triage-duplicate-resolution.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -718,6 +718,7 @@ export {
|
||||
findSameAgentDuplicates,
|
||||
archiveAsSameAgentDuplicate,
|
||||
flagSameAgentDuplicate,
|
||||
flagTriageDuplicate,
|
||||
type SameAgentDuplicateInput,
|
||||
type SameAgentDuplicateCandidate,
|
||||
type SameAgentDuplicateMatch,
|
||||
|
||||
@@ -741,6 +741,7 @@ export {
|
||||
findSameAgentDuplicates,
|
||||
archiveAsSameAgentDuplicate,
|
||||
flagSameAgentDuplicate,
|
||||
flagTriageDuplicate,
|
||||
type SameAgentDuplicateInput,
|
||||
type SameAgentDuplicateCandidate,
|
||||
type SameAgentDuplicateMatch,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") &&
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
packages/i18n/src/resources.d.ts
vendored
13
packages/i18n/src/resources.d.ts
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user