FN-5731: require confirmation before archiving near-duplicate tasks

Stop silent near-duplicate auto-archive by routing duplicates through explicit user confirmation in API, engine, and dashboard flows.

- add new near-duplicate confirmation metadata/types and preserve duplicate task rows until confirmation
- update workflow routes and triage logic to return confirmation-required outcomes instead of immediate archive
- add TaskCard and TaskDetailModal UI/actions plus styling and hook updates for confirming or rejecting duplicate handling
- expand dashboard and engine tests and task-management docs to cover the new confirmation path

Files changed:
docs/task-management.md                            | 14 ++++-
packages/core/src/types.ts                         |  4 ++
packages/dashboard/app/api/legacy.ts               |  1 +
packages/dashboard/app/components/TaskCard.css     | 59 ++++++++++++++++++
packages/dashboard/app/components/TaskCard.tsx     | 46 +++++++++++++-
packages/dashboard/app/components/TaskDetailModal.css   | 34 +++++++++++
packages/dashboard/app/components/TaskDetailModal.tsx   | 71 +++++++++++++++++++++-
packages/dashboard/app/components/__tests__/TaskCard.test.tsx     | 71 ++++++++++++++++++++++
packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx   | 68 +++++++++++++++++++++
packages/dashboard/app/hooks/useTasks.ts           |  2 +-
packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts  | 45 ++++++++++++++
packages/dashboard/src/routes/register-task-workflow-routes.ts    |  9 ++-
packages/engine/src/__tests__/reliability-interactions/near-duplicate-intake.test.ts                  | 16 +++--
packages/engine/src/triage.ts                      | 19 +++---
14 files changed, 433 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-5731

Fusion-Task-Lineage: fa004129-7bce-4457-b8c3-ceb9fb953319
This commit is contained in:
gsxdsm
2026-05-30 15:52:46 -07:00
parent 784c58f5c3
commit 24e0c44b4a
14 changed files with 433 additions and 26 deletions

View File

@@ -87,12 +87,20 @@ Layer 1 persists `source.sourceMetadata.intentSignature` on created tasks so lat
CLI `fn task create` now runs the same near-duplicate intent guard after the FN-4918 deterministic fingerprint guard, using shared `extractIntentSignature` / `findNearDuplicates` helpers from `@fusion/core`. Thresholds and the 7-day comparison window match the dashboard layer exactly. `--no-dedup` remains the single bypass across both duplicate layers: it skips the comparison but still stamps `source.sourceMetadata.intentSignature` when high-signal tokens were extracted. When a near-duplicate is detected, interactive TTY runs prompt `Create anyway? [y/N]`; non-interactive runs refuse creation with exit code 1 and instruct the caller to re-run with `--no-dedup`. The guard is still fail-open: extraction/list/query errors log a warning and continue. `fn task import` (GitHub import) and `fn task plan` intentionally continue to skip both duplicate guards per the FN-5060 same-content-sibling contract.
Layer 2 runs in triage `finalizeApprovedTask` after `PROMPT.md` is written and parses `## File Scope` as an additional backstop. If the new spec overlaps an older active task on concrete File Scope / intent tokens and still clears the title threshold, the newer task is auto-archived instead of moved to `todo`.
Layer 2 runs in triage `finalizeApprovedTask` after `PROMPT.md` is written and parses `## File Scope` as an additional backstop. If the new spec overlaps an older active task on concrete File Scope / intent tokens and still clears the title threshold, the newer task is flagged for user confirmation instead of being silently auto-archived.
Near-duplicate archival is reversible and leaves lineage markers behind:
Near-duplicate flagging now keeps the task in its normal flow column (`todo` / approval flow) and records metadata for UI warnings:
- `source.sourceMetadata.nearDuplicateOf = <canonicalTaskId>`
- activity event `task:auto-archived-near-duplicate`
- `source.sourceMetadata.nearDuplicateScore = <number>`
- `source.sourceMetadata.nearDuplicateSharedTokens = <string[]>`
- optional `source.sourceMetadata.nearDuplicateDismissed = true` after user chooses Keep
- activity event `task:near-duplicate-flagged`
Dashboard surfaces this as a yellow Duplicate chip plus modal actions:
- **Archive** (user-initiated archive path)
- **Keep** (dismisses the warning by setting `nearDuplicateDismissed: true`)
This layer complements, rather than replaces, FN-4829 similarity detection, FN-4918 deterministic deduplication, and FN-4892 same-agent intake heuristics.

View File

@@ -1024,6 +1024,7 @@ export type ActivityEventType =
| "task:duplicate-warning-overridden"
| "task:auto-archived-deterministic-duplicate"
| "task:auto-archived-near-duplicate"
| "task:near-duplicate-flagged"
| "task:auto-archived-ghost-bug"
| "task:auto-archived-duplicate"
| "task:merge-worktree-reacquired"
@@ -1660,6 +1661,9 @@ export interface TaskSource {
* Reserved metadata keys:
* - `duplicateOfTaskIds: string[]` stores structured duplicate lineage captured
* from triage parsing and backfills.
* - near-duplicate markers: `nearDuplicateOf` (canonical task id),
* `nearDuplicateScore` (number), `nearDuplicateSharedTokens` (string[]),
* and optional `nearDuplicateDismissed` (boolean).
*/
sourceMetadata?: Record<string, unknown>;
}

View File

@@ -467,6 +467,7 @@ export function updateTask(
repoOverride?: string | null;
issue?: null;
} | null;
dismissNearDuplicate?: boolean;
},
projectId?: string,
): Promise<Task> {

View File

@@ -788,12 +788,71 @@
text-decoration: none;
}
.card-duplicate-chip,
.card-duplicate-keep {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
height: var(--card-chip-height);
min-height: var(--card-chip-height);
border: var(--btn-border-width) solid transparent;
border-radius: var(--radius-pill);
font-size: 0.6875rem;
font-weight: 600;
font-family: var(--font-mono);
line-height: 1;
white-space: nowrap;
}
.card-duplicate-chip > span {
display: inline-flex;
align-items: center;
line-height: 1;
transform: translateY(1px);
}
.card-time-indicator {
border-color: color-mix(in srgb, var(--text-muted) 30%, transparent);
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
color: var(--text-muted);
}
.card-duplicate-chip {
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
color: var(--color-warning);
}
.card-duplicate-keep {
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
background: color-mix(in srgb, var(--surface-secondary) 90%, transparent);
color: var(--text);
cursor: pointer;
appearance: none;
background-image: none;
transition:
background-color var(--transition-fast),
color var(--transition-fast),
box-shadow var(--transition-fast);
}
.card-duplicate-keep:hover {
background: var(--card-hover);
}
.card-duplicate-keep:focus-visible {
box-shadow: var(--focus-ring-strong);
outline: none;
}
@media (max-width: 768px) {
.card-duplicate-chip {
max-width: 100%;
}
}
.card-github-tracking-chip .provider-icon svg {
width: 12px;
height: 12px;

View File

@@ -274,7 +274,7 @@ interface TaskCardProps {
globalPaused?: boolean;
onUpdateTask?: (
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean }
) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
@@ -492,6 +492,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.sourceAgentId === nextTask.sourceAgentId &&
previousTask.sourceMetadata?.issueUrl === nextTask.sourceMetadata?.issueUrl &&
previousTask.sourceMetadata?.agentName === nextTask.sourceMetadata?.agentName &&
previousTask.sourceMetadata?.nearDuplicateOf === nextTask.sourceMetadata?.nearDuplicateOf &&
previousTask.sourceMetadata?.nearDuplicateDismissed === nextTask.sourceMetadata?.nearDuplicateDismissed &&
previousTask.stalledReview?.reason === nextTask.stalledReview?.reason &&
previousTask.stalledReview?.heuristic === nextTask.stalledReview?.heuristic &&
previousTask.stalledReview?.matchCount === nextTask.stalledReview?.matchCount &&
@@ -845,6 +847,10 @@ function TaskCardComponent({
const showTrackingIndicator = hasGithubTrackingLink
&& !hasMatchingIssueInfoBadge
&& !hasMatchingSourceIssue;
const showNearDuplicateChip = Boolean(task.sourceMetadata?.nearDuplicateOf)
&& task.sourceMetadata?.nearDuplicateDismissed !== true
&& task.column !== "archived"
&& task.column !== "done";
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
const isAgentCreated = isAgentCreatedTask(task);
@@ -1225,6 +1231,18 @@ function TaskCardComponent({
el.style.height = el.scrollHeight + "px";
}, []);
const handleDismissNearDuplicate = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onUpdateTask) return;
try {
await onUpdateTask(task.id, { dismissNearDuplicate: true });
addToast(`Kept ${task.id}; duplicate warning dismissed`, "success");
} catch (err) {
addToast(`Failed to keep ${task.id}: ${getErrorMessage(err)}`, "error");
}
}, [addToast, onUpdateTask, task.id]);
const handleArchiveClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onArchiveTask) return;
@@ -1934,7 +1952,7 @@ function TaskCardComponent({
</>
);
})()}
{(filesChangedButton || isGitHubImportedTask || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
{(filesChangedButton || isGitHubImportedTask || showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
<div className={`card-footer-row${chipFarRight ? " card-footer-row--chip-far-right" : ""}`}>
{filesChangedButton}
{isGitHubImportedTask && !showLinkedIssueChipForImport && (
@@ -1946,8 +1964,30 @@ function TaskCardComponent({
<ProviderIcon provider="github" size="sm" />
</span>
)}
{(((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
{(showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
<div className="card-footer-row-right">
{showNearDuplicateChip && (
<>
<span
className="card-duplicate-chip"
title={`Potential near-duplicate of ${String(task.sourceMetadata?.nearDuplicateOf)}`}
aria-label={`Potential near-duplicate of ${String(task.sourceMetadata?.nearDuplicateOf)}`}
>
<span>{`Duplicate of ${String(task.sourceMetadata?.nearDuplicateOf)}`}</span>
</span>
{onUpdateTask && (
<button
type="button"
className="card-duplicate-keep"
onClick={(e) => void handleDismissNearDuplicate(e)}
title="Keep this task and dismiss duplicate warning"
aria-label="Keep this task and dismiss duplicate warning"
>
Keep
</button>
)}
</>
)}
{chipFarRight && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && (
<a
className="card-github-tracking-chip card-github-tracking-link"

View File

@@ -1908,6 +1908,39 @@
}
}
.detail-near-duplicate-banner {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
margin-bottom: var(--space-md);
}
.detail-near-duplicate-banner__header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.detail-near-duplicate-banner__headline {
font-weight: 600;
color: var(--text);
}
.detail-near-duplicate-banner__copy {
margin: 0;
color: var(--text);
}
.detail-near-duplicate-banner__actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.rebind-banner {
display: flex;
flex-direction: column;
@@ -1948,6 +1981,7 @@
}
@media (max-width: 768px) {
.detail-near-duplicate-banner__actions,
.rebind-banner-actions {
flex-direction: column;
align-items: flex-start;

View File

@@ -1,6 +1,6 @@
import "./TaskDetailModal.css";
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2 } from "lucide-react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -542,6 +542,13 @@ export function TaskDetailContent({
(task.stuckKillCount ?? 0) > 0 ||
(task.recoveryRetryCount ?? 0) > 0 ||
Boolean(task.nextRecoveryAt);
const nearDuplicateOf = typeof workingTask.sourceMetadata?.nearDuplicateOf === "string"
? workingTask.sourceMetadata.nearDuplicateOf
: null;
const showNearDuplicateWarning = Boolean(nearDuplicateOf)
&& workingTask.sourceMetadata?.nearDuplicateDismissed !== true
&& task.column !== "archived"
&& task.column !== "done";
const [sourceAgent, setSourceAgent] = useState<Agent | null>(null);
const [selectedSourceAgentId, setSelectedSourceAgentId] = useState<string | null>(null);
const provenanceDisplay = getProvenanceLabel(workingTask, {
@@ -1695,6 +1702,35 @@ export function TaskDetailContent({
}
}, [task.id, onDuplicateTask, requestClose, addToast, confirm]);
const handleDismissNearDuplicate = useCallback(async () => {
try {
const updatedTask = await updateTask(task.id, { dismissNearDuplicate: true }, projectId);
onTaskUpdated?.(updatedTask);
addToast(`Kept ${task.id} and dismissed duplicate warning`, "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [task.id, projectId, onTaskUpdated, addToast]);
const handleArchiveNearDuplicate = useCallback(async () => {
if (!onArchiveTask) return;
const confirmed = await confirm({
title: "Archive near-duplicate task",
message: `Archive ${task.id} as a duplicate of ${nearDuplicateOf}?`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
danger: true,
});
if (!confirmed) return;
try {
await onArchiveTask(task.id);
addToast(`Archived ${task.id}`, "success");
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]);
const isTaskPaused = task.paused || task.userPaused;
const showRecoverBranchBindingBanner = task.column === "in-review" && !task.branch;
@@ -2421,6 +2457,39 @@ export function TaskDetailContent({
</>
);
})()}
{showNearDuplicateWarning && (
<div className="detail-near-duplicate-banner" role="status" aria-live="polite">
<div className="detail-near-duplicate-banner__header">
<AlertTriangle aria-hidden="true" />
<span className="detail-near-duplicate-banner__headline">Potential duplicate detected</span>
</div>
<p className="detail-near-duplicate-banner__copy">
This task appears to be a near-duplicate of{" "}
<button
type="button"
className="detail-provenance-link"
onClick={() => {
if (nearDuplicateOf) {
handleDepClick(nearDuplicateOf);
}
}}
>
{nearDuplicateOf}
</button>
. Choose Archive to move this task to archived, or Keep to continue with this task.
</p>
<div className="detail-near-duplicate-banner__actions">
{onArchiveTask && (
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleArchiveNearDuplicate()}>
Archive
</button>
)}
<button type="button" className="btn btn-sm" onClick={() => void handleDismissNearDuplicate()}>
Keep
</button>
</div>
</div>
)}
<div className="detail-meta">
<div className="detail-meta-inline-controls" data-testid="detail-meta-inline-controls">
<label

View File

@@ -3932,6 +3932,77 @@ describe("TaskCard provider icons on agent row", () => {
});
});
describe("TaskCard near-duplicate chip", () => {
it("renders duplicate chip when nearDuplicateOf is present", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" })).toBeInTheDocument();
});
it("hides duplicate chip when nearDuplicateDismissed is true", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
});
it("hides duplicate chip in archived and done columns", () => {
const { rerender } = render(
<TaskCard
task={makeTask({ column: "archived", sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
rerender(
<TaskCard
task={makeTask({ column: "done", sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
});
it("clicking Keep calls updateTask dismissNearDuplicate", async () => {
const onUpdateTask = vi.fn().mockResolvedValue(makeTask());
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={onUpdateTask}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" }));
await waitFor(() => {
expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { dismissNearDuplicate: true });
});
});
});
describe("TaskCard memo comparator provenance behavior", () => {
it("returns false when prAuthAvailable changes", () => {
const task = makeTask({ column: "in-review" });

View File

@@ -1904,6 +1904,74 @@ describe("TaskDetailModal", () => {
});
});
it("shows near-duplicate banner and keeps warning on Keep click", async () => {
const { updateTask } = await import("../../api");
const mockUpdateTask = vi.mocked(updateTask);
mockUpdateTask.mockResolvedValueOnce(makeTask({
id: "FN-099",
sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true },
}));
render(
<TaskDetailModal
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Potential duplicate detected")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-099", { dismissNearDuplicate: true }, undefined);
});
});
it("hides near-duplicate banner once dismissed", () => {
render(
<TaskDetailModal
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Potential duplicate detected")).toBeNull();
});
it("archives from near-duplicate banner when confirmed", async () => {
const onArchiveTask = vi.fn().mockResolvedValue(makeTask({ column: "archived" }));
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailModal
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onArchiveTask={onArchiveTask}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Archive" }));
await waitFor(() => {
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
});
});
it("renders corrected stats timing totals in Stats tab", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);

View File

@@ -545,7 +545,7 @@ export function useTasks(options?: UseTasksOptions) {
const updateTask = useCallback(async (
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean }
): Promise<Task> => {
const previousTask = tasksRef.current.find((t) => t.id === id);
const optimisticTask = previousTask

View File

@@ -41,6 +41,22 @@ function buildApp(seed: Task[]) {
tasks.push(created);
return created;
}),
getTask: vi.fn().mockImplementation(async (id: string) => tasks.find((task) => task.id === id) ?? null),
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");
const current = tasks[index];
const sourceMetadataPatch = updates.sourceMetadataPatch as Record<string, unknown> | undefined;
const next = {
...current,
...updates,
...(sourceMetadataPatch
? { sourceMetadata: { ...(current.sourceMetadata ?? {}), ...sourceMetadataPatch } }
: {}),
};
tasks[index] = next;
return next;
}),
recordActivity: vi.fn().mockResolvedValue(undefined),
};
@@ -233,4 +249,33 @@ describe("routes /api/tasks near duplicate", () => {
expect.objectContaining({ error: "boom" }),
);
});
it("PATCH dismissNearDuplicate applies sourceMetadataPatch merge", async () => {
const seeded = mkTask({
id: "FN-6001",
title: "Near duplicate candidate",
description: "Test candidate",
column: "todo",
sourceMetadata: { nearDuplicateOf: "FN-1000" },
});
const { app, tasks } = buildApp([seeded]);
const res = await performRequest(
app,
"PATCH",
"/api/tasks/FN-6001",
JSON.stringify({ dismissNearDuplicate: true }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect((res.body as Task).sourceMetadata).toMatchObject({
nearDuplicateOf: "FN-1000",
nearDuplicateDismissed: true,
});
expect(tasks[0]?.sourceMetadata).toMatchObject({
nearDuplicateOf: "FN-1000",
nearDuplicateDismissed: true,
});
});
});

View File

@@ -2723,7 +2723,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.patch("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status } = req.body;
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
@@ -2912,6 +2912,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
validatedStatus = null;
}
if (hasBodyField("dismissNearDuplicate") && dismissNearDuplicate !== undefined && typeof dismissNearDuplicate !== "boolean") {
throw new Error("dismissNearDuplicate must be a boolean");
}
const updates: Parameters<typeof scopedStore.updateTask>[1] = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
@@ -2940,6 +2944,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
if (hasBodyField("overlapBlockedBy")) updates.overlapBlockedBy = validatedOverlapBlockedBy;
if (hasBodyField("status")) updates.status = validatedStatus;
if (dismissNearDuplicate === true) {
updates.sourceMetadataPatch = { nearDuplicateDismissed: true };
}
if (hasBodyField("nodeId") && validatedNodeId !== undefined) {
const currentTask = await scopedStore.getTask(req.params.id);

View File

@@ -44,7 +44,7 @@ describe("reliability interactions: near-duplicate intake", () => {
while (fixtures.length) await fixtures.pop()!.cleanup();
});
it("archives newer task as near-duplicate and records activity", async () => {
it("flags newer task as near-duplicate and records activity", async () => {
const fx = await createFixture();
fixtures.push(fx);
@@ -61,10 +61,14 @@ describe("reliability interactions: near-duplicate intake", () => {
await (fx.triage as any).finalizeApprovedTask(incoming, basePrompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(incoming.id);
expect(updated.column).toBe("archived");
expect(updated.column).toBe("todo");
expect(updated.sourceMetadata?.nearDuplicateOf).toBeTruthy();
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-near-duplicate", limit: 20 });
expect(activity.some((entry) => entry.taskId === incoming.id)).toBe(true);
expect(typeof updated.sourceMetadata?.nearDuplicateScore).toBe("number");
expect(Array.isArray(updated.sourceMetadata?.nearDuplicateSharedTokens)).toBe(true);
const flaggedActivity = await fx.store.getActivityLog({ type: "task:near-duplicate-flagged", limit: 20 });
expect(flaggedActivity.some((entry) => entry.taskId === incoming.id)).toBe(true);
const archivedActivity = await fx.store.getActivityLog({ type: "task:auto-archived-near-duplicate", limit: 20 });
expect(archivedActivity.some((entry) => entry.taskId === incoming.id)).toBe(false);
});
it("does not archive generic file overlap only", async () => {
@@ -149,7 +153,7 @@ describe("reliability interactions: near-duplicate intake", () => {
expect(updatedNewer.column).toBe("todo");
});
it("archives at most one sibling when both near-duplicates finalize in the same millisecond", async () => {
it("does not auto-archive siblings when both near-duplicates finalize in the same millisecond", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-19T12:00:00.000Z"));
@@ -173,7 +177,7 @@ describe("reliability interactions: near-duplicate intake", () => {
const refreshed = await fx.store.listTasks({ includeArchived: true });
const archived = refreshed.filter((task) => task.id === first.id || task.id === second.id).filter((task) => task.column === "archived");
expect(archived.length).toBeLessThanOrEqual(1);
expect(archived.length).toBe(0);
vi.useRealTimers();
});
});

View File

@@ -2560,24 +2560,24 @@ export class TriageProcessor {
return;
}
// FN-5152: only archive the task being finalized when it is the newer sibling,
// or the tie-loser when both rows share the same millisecond timestamp.
// FN-5152: when the candidate is older (or tie-canonical), flag for user confirmation.
if (isStrictlyOlderOrTieCanonical(canonicalTask)) {
await this.store.updateTask(task.id, {
sourceMetadataPatch: {
nearDuplicateOf: canonical.id,
nearDuplicateScore: canonical.score,
nearDuplicateSharedTokens: canonical.sharedTokens,
intentSignature: taskIntentSignature,
...(parsedFileScope.length > 0 ? { fileScope: parsedFileScope } : {}),
},
});
await this.store.logEntry(
task.id,
`Auto-archived as near-duplicate of ${canonical.id}`,
`Flagged as near-duplicate of ${canonical.id} (awaiting user decision)`,
`Shared tokens: ${canonical.sharedTokens.join(", ")}`,
);
await this.store.moveTask(task.id, "archived");
await this.store.recordActivity({
type: "task:auto-archived-near-duplicate",
type: "task:near-duplicate-flagged",
taskId: task.id,
taskTitle: task.title ?? "",
details: `Near-duplicate of ${canonical.id}`,
@@ -2587,17 +2587,14 @@ export class TriageProcessor {
score: canonical.score,
},
});
planLog.log(`${task.id} auto-archived as near-duplicate of ${canonical.id}`);
return "archived" as const;
planLog.log(`${task.id} flagged as near-duplicate of ${canonical.id}; awaiting user decision`);
return;
}
planLog.warn(`${task.id}: near-duplicate candidate ${canonical.id} is newer; skipping auto-archive`);
planLog.warn(`${task.id}: near-duplicate candidate ${canonical.id} is newer; skipping near-duplicate flag`);
})(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
]);
if (nearDuplicateResult === "archived") {
return;
}
if (nearDuplicateResult === "timeout") {
planLog.warn(`${task.id}: near-duplicate backstop timed out; proceeding`);
}