FN-6439: clear stale near-duplicate flags

Clear near-duplicate decisions when their canonical task is no longer active.

- Add a shared canonical activity helper for near-duplicate detection.
- Clear stale persisted near-duplicate metadata when canonical tasks are done, archived, or deleted.
- Prevent triage and dashboard surfaces from presenting duplicate decisions for inactive canonicals.
- Cover stale-flag cleanup and hidden dashboard affordances with regression tests.

Files changed:
 docs/task-management.md                            |   4 +-
 .../near-duplicate-stale-flag-clear.test.ts        | 105 +++++++++++++++++++++
 packages/core/src/__tests__/near-duplicate.test.ts |  19 +++-
 packages/core/src/index.ts                         |   2 +
 packages/core/src/near-duplicate-canonical.ts      |  25 +++++
 packages/core/src/near-duplicate.ts                |   3 +
 packages/core/src/store.ts                         | 102 +++++++++++++++++++-
 packages/dashboard/app/App.tsx                     |   7 +-
 packages/dashboard/app/components/Column.tsx       |  10 ++
 packages/dashboard/app/components/TaskCard.tsx     |  12 ++-
 .../dashboard/app/components/TaskDetailModal.tsx   |  12 ++-
 packages/dashboard/app/components/WorktreeGroup.tsx     |  30 +++++-
 .../app/components/__tests__/TaskCard.test.tsx     |  28 ++++++
 .../__tests__/TaskDetailModal.rendering.test.tsx   |  26 +++++
 .../near-duplicate-intake.test.ts                  |  24 +++++
 packages/engine/src/triage.ts                      |  10 ++
 16 files changed, 411 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-6439

Fusion-Task-Lineage: e57b80e8-36aa-4b96-ad13-53b5a431477d
This commit is contained in:
gsxdsm
2026-06-14 08:55:03 -07:00
parent 175aab15ac
commit 4f7b3ac78f
16 changed files with 411 additions and 8 deletions

View File

@@ -97,7 +97,9 @@ Near-duplicate flagging now keeps the task in its normal flow column (`todo` / a
- 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:
A near-duplicate flag is only actionable while the canonical task is active. The triage backstop does not persist `nearDuplicateOf` for archived, soft-deleted, done, or missing canonicals; when a canonical later becomes inactive through archive, soft-delete, or move-to-done, the store clears `nearDuplicateOf`, `nearDuplicateScore`, `nearDuplicateSharedTokens`, and `nearDuplicateDismissed` from active referrers and records an informational log entry without pausing or failing those tasks.
Dashboard surfaces this as a yellow Duplicate chip plus modal actions only while the canonical exists and is active:
- **Archive** (user-initiated archive path)
- **Keep** (dismisses the warning by setting `nearDuplicateDismissed: true`)

View File

@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskStore } from "../store.js";
import type { Task } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("near-duplicate stale flag clearing", () => {
const harness = createTaskStoreTestHarness();
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
async function createCanonical(): Promise<Task> {
return store.createTask({ title: "Canonical task", description: "Canonical intent" });
}
async function createReferencingTask(canonicalId: string, title = "Referencing task"): Promise<Task> {
return store.createTask({
title,
description: "Similar intent that should stop asking for a duplicate decision",
source: {
sourceType: "automation",
sourceMetadata: {
nearDuplicateOf: canonicalId,
nearDuplicateScore: 0.92,
nearDuplicateSharedTokens: ["packages/core/src/store.ts", "nearDuplicateOf"],
nearDuplicateDismissed: true,
retainedMetadata: "kept",
},
},
});
}
async function moveCanonicalToDone(taskId: string): Promise<void> {
await store.moveTask(taskId, "todo");
await store.moveTask(taskId, "in-progress");
await store.moveTask(taskId, "in-review", { allowDirectInReviewMove: true });
await store.moveTask(taskId, "done", { skipMergeBlocker: true });
}
async function expectFlagCleared(taskId: string, canonicalId: string, reason: string): Promise<void> {
const updated = await store.getTask(taskId);
expect(updated.sourceMetadata).toEqual({ retainedMetadata: "kept" });
expect(updated.paused).not.toBe(true);
expect(updated.status).not.toBe("failed");
expect(updated.log.some((entry) => entry.action.includes(`Near-duplicate canonical ${canonicalId} is now inactive (${reason}); cleared duplicate flag`))).toBe(true);
}
it("clears active referrers when the canonical is archived without cleanup", async () => {
const canonical = await createCanonical();
const referrer = await createReferencingTask(canonical.id);
await store.archiveTask(canonical.id, { cleanup: false });
await expectFlagCleared(referrer.id, canonical.id, "archived");
});
it("clears multiple active referrers when the canonical is archived with cleanup", async () => {
const canonical = await createCanonical();
const first = await createReferencingTask(canonical.id, "First referrer");
const second = await createReferencingTask(canonical.id, "Second referrer");
await store.archiveTask(canonical.id, { cleanup: true });
await expectFlagCleared(first.id, canonical.id, "archived");
await expectFlagCleared(second.id, canonical.id, "archived");
});
it("clears active referrers when the canonical is soft-deleted", async () => {
const canonical = await createCanonical();
const referrer = await createReferencingTask(canonical.id);
await store.deleteTask(canonical.id);
await expectFlagCleared(referrer.id, canonical.id, "deleted");
});
it("clears active referrers when the canonical moves to done", async () => {
const canonical = await createCanonical();
const referrer = await createReferencingTask(canonical.id);
await moveCanonicalToDone(canonical.id);
await expectFlagCleared(referrer.id, canonical.id, "done");
});
it("does not fail canonical inactive transitions when there are no referrers", async () => {
const archived = await createCanonical();
await expect(store.archiveTask(archived.id, { cleanup: false })).resolves.toMatchObject({ id: archived.id, column: "archived" });
const deleted = await createCanonical();
await expect(store.deleteTask(deleted.id)).resolves.toMatchObject({ id: deleted.id });
const done = await createCanonical();
await expect(moveCanonicalToDone(done.id)).resolves.toBeUndefined();
await expect(store.getTask(done.id)).resolves.toMatchObject({ id: done.id, column: "done" });
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { extractIntentSignature, findNearDuplicates } from "../near-duplicate.js";
import { extractIntentSignature, findNearDuplicates, isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "../near-duplicate.js";
const fn5144Title = "Create PR dialog missing /pr/options /pr/preflight /pr/generate-metadata routes";
const fn5144Description =
@@ -50,6 +50,23 @@ describe("extractIntentSignature", () => {
});
});
describe("near-duplicate canonical activity predicates", () => {
it("treats non-terminal live columns as active", () => {
expect(isActiveNearDuplicateColumn("triage")).toBe(true);
expect(isActiveNearDuplicateColumn("todo")).toBe(true);
expect(isActiveNearDuplicateColumn("in-progress")).toBe(true);
expect(isActiveNearDuplicateColumn("in-review")).toBe(true);
});
it("treats archived, done, soft-deleted, and missing canonicals as inactive", () => {
expect(isNearDuplicateCanonicalInactive(undefined)).toBe(true);
expect(isNearDuplicateCanonicalInactive({ column: "archived" })).toBe(true);
expect(isNearDuplicateCanonicalInactive({ column: "done" })).toBe(true);
expect(isNearDuplicateCanonicalInactive({ column: "todo", deletedAt: "2026-06-14T00:00:00.000Z" })).toBe(true);
expect(isNearDuplicateCanonicalInactive({ column: "todo", deletedAt: null })).toBe(false);
});
});
describe("findNearDuplicates", () => {
it("flags FN-5144 and FN-5149 pair via shared PR route tokens", () => {
const matches = findNearDuplicates(

View File

@@ -17,6 +17,8 @@ export type {
} from "./branch-assignment.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { redactSecrets } from "./redact-secrets.js";
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js";
export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js";
export * from "./frontend-ux-policy.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";

View File

@@ -0,0 +1,25 @@
import type { ColumnId } from "./types.js";
export interface NearDuplicateCanonicalState {
column?: ColumnId | null;
deletedAt?: string | null;
}
export function isActiveNearDuplicateColumn(column: ColumnId | null | undefined): boolean {
return column !== "archived" && column !== "done";
}
/**
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
* A near-duplicate flag is only actionable while its canonical task exists and remains active.
* Treat missing, archived, done, and soft-deleted canonicals as inactive so stale persisted flags cannot strand executable work behind a false user-decision block.
*/
export function isNearDuplicateCanonicalInactive(canonical: NearDuplicateCanonicalState | undefined): boolean {
if (!canonical) {
return true;
}
if (canonical.deletedAt) {
return true;
}
return !isActiveNearDuplicateColumn(canonical.column);
}

View File

@@ -39,6 +39,9 @@ export interface NearDuplicateCandidate {
createdAt?: number;
}
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js";
export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js";
export interface NearDuplicateMatch {
id: string;
score: number;

View File

@@ -158,6 +158,7 @@ import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNod
import { detectStalledReview } from "./stalled-review-detector.js";
import { computeRetrySummary } from "./retry-summary.js";
import { archiveAsSameAgentDuplicate, findSameAgentDuplicates } from "./duplicate-intake.js";
import { isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js";
import {
detectTaskIdIntegrityAnomalies,
type TaskIdIntegrityReport,
@@ -6178,6 +6179,78 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return rows.map((row) => this.rowToTask(row));
}
/**
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
* FN-6439 requires the store to reconcile persisted duplicate flags after a canonical becomes inactive.
* sourceMetadataPatch only merges, so this reverse lookup performs a bounded read-modify-write that strips stale near-duplicate keys without pausing or failing the referencing tasks.
*/
private async clearNearDuplicateReferencesTo(
canonicalId: string,
inactiveState: { column?: ColumnId | null; deletedAt?: string | null; reason: string },
): Promise<Task[]> {
if (!isNearDuplicateCanonicalInactive(inactiveState)) {
return [];
}
const selectClause = this.getTaskSelectClause(false, "t");
const rows = this.db.prepare(`
SELECT ${selectClause}
FROM tasks t
WHERE t."deletedAt" IS NULL
AND t."column" != 'archived'
AND t."column" != 'done'
AND json_extract(t.sourceMetadata, '$.nearDuplicateOf') = ?
ORDER BY t.createdAt ASC
`).all(canonicalId) as TaskRow[];
const updatedTasks: Task[] = [];
for (const row of rows) {
const task = this.rowToTask(row);
const nextSourceMetadata = { ...(task.sourceMetadata ?? {}) };
delete nextSourceMetadata.nearDuplicateOf;
delete nextSourceMetadata.nearDuplicateScore;
delete nextSourceMetadata.nearDuplicateSharedTokens;
delete nextSourceMetadata.nearDuplicateDismissed;
task.sourceMetadata = Object.keys(nextSourceMetadata).length > 0 ? nextSourceMetadata : undefined;
const updatedAt = new Date().toISOString();
task.updatedAt = updatedAt;
task.log = [
...(task.log ?? []),
{
timestamp: updatedAt,
action: `Near-duplicate canonical ${canonicalId} is now inactive (${inactiveState.reason}); cleared duplicate flag (informational, no decision required)`,
},
];
this.db.transactionImmediate(() => {
this.upsertTaskWithFtsRecovery(task);
this.db.bumpLastModified();
});
await this.writeTaskJsonFile(this.taskDir(task.id), task);
if (this.isWatching) this.taskCache.set(task.id, { ...task });
this.emit("task:updated", task);
updatedTasks.push(task);
}
return updatedTasks;
}
private async clearNearDuplicateReferencesToFailSoft(
canonicalId: string,
inactiveState: { column?: ColumnId | null; deletedAt?: string | null; reason: string },
): Promise<void> {
try {
await this.clearNearDuplicateReferencesTo(canonicalId, inactiveState);
} catch (error) {
storeLog.warn("Failed to clear stale near-duplicate references (degraded)", {
taskId: canonicalId,
reason: inactiveState.reason,
error: error instanceof Error ? error.message : String(error),
});
}
}
async getTasksByAssignedAgent(
agentId: string,
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
@@ -6691,6 +6764,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
}
if (toColumn === "done") {
await this.clearNearDuplicateReferencesToFailSoft(id, {
column: "done",
reason: "done",
});
}
return task;
}
@@ -7224,6 +7303,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
if (fromColumn !== toColumn) {
this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource });
}
if (toColumn === "done") {
await this.clearNearDuplicateReferencesToFailSoft(id, {
column: "done",
reason: "done",
});
}
return task;
}
@@ -10141,7 +10226,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
auditContext?: { agentId: string; runId: string; sessionId?: string };
},
): Promise<Task> {
return this.withTaskLock(id, async () => {
const deletedTask = await this.withTaskLock(id, async () => {
// Flush buffered agent logs inside the lock so no new appends for this
// task can sneak in between flush and soft-delete mutation.
this.flushAgentLogBuffer();
@@ -10244,6 +10329,13 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
this.emit("task:deleted", task, { githubIssueAction: options?.githubIssueAction ?? "auto" });
return task;
});
await this.clearNearDuplicateReferencesToFailSoft(id, {
column: "archived",
deletedAt: deletedTask.deletedAt ?? new Date().toISOString(),
reason: "deleted",
});
return deletedTask;
}
private deleteTaskById(taskId: string): void {
@@ -10812,7 +10904,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
id: string,
optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean } = true,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const archivedTask = await this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
@@ -10898,6 +10990,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" });
return this.archiveEntryToTask(entry, false);
});
await this.clearNearDuplicateReferencesToFailSoft(id, {
column: "archived",
reason: "archived",
});
return archivedTask;
}
/**

View File

@@ -8,6 +8,7 @@ import {
type TaskDetail,
type WorkflowStep,
} from "@fusion/core";
import { isNearDuplicateCanonicalInactive } from "../../core/src/near-duplicate-canonical";
import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { TaskCard } from "./components/TaskCard";
@@ -1469,13 +1470,14 @@ function AppInner() {
// Project view
if (resolvedPluginTaskView) {
const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
return (
<PageErrorBoundary>
<PluginDashboardViewHost
taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
context={{
projectId: currentProject?.id,
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
tasks: pluginTasks,
workflowSteps,
subscribePluginEvents,
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab),
@@ -1490,6 +1492,9 @@ function AppInner() {
disableDrag={true}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"
? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf))
: undefined}
/>
),
addToast,

View File

@@ -4,6 +4,7 @@ import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
import { useConfirm } from "../hooks/useConfirm";
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { TaskCard } from "./TaskCard";
import { WorktreeGroup } from "./WorktreeGroup";
import { QuickEntryBox } from "./QuickEntryBox";
@@ -198,6 +199,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const menuRef = useRef<HTMLDivElement | null>(null);
const countFlashing = useFlashOnIncrease(tasks.length);
const { confirm } = useConfirm();
const resolveNearDuplicateCanonicalInactive = useCallback((task: Task): boolean | undefined => {
const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf;
if (typeof nearDuplicateOf !== "string" || !allTasks) {
return undefined;
}
return isNearDuplicateCanonicalInactive(allTasks.find((candidate) => candidate.id === nearDuplicateOf));
}, [allTasks]);
// Clear the inline capacity-exhausted banner once the column's task list
// changes via SSE (e.g. an occupant moves out and capacity frees up). The
@@ -724,6 +732,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
allTasks={allTasks}
/>
))
)
@@ -757,6 +766,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

@@ -425,6 +425,8 @@ interface TaskCardProps {
* DISTINCT from staleness/stall badges (which U8 suppresses in these states).
* Undefined when the task has no CLI session → no badge (card unchanged).
*/
/** True when the board-level task list proves the near-duplicate canonical is inactive or missing. */
nearDuplicateCanonicalInactive?: boolean;
cliSessionState?: CliCardState;
}
@@ -576,6 +578,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.prNode?.state === next.prNode?.state &&
previous.prNode?.prNumber === next.prNode?.prNumber &&
previous.cliSessionState?.agentState === next.cliSessionState?.agentState &&
previous.nearDuplicateCanonicalInactive === next.nearDuplicateCanonicalInactive &&
previous.cardFieldDefs === next.cardFieldDefs &&
(previous.cardFieldDefs == null && next.cardFieldDefs == null
? true
@@ -701,6 +704,7 @@ function TaskCardComponent({
prNode,
onOpenPullRequest,
cliSessionState,
nearDuplicateCanonicalInactive,
}: TaskCardProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
@@ -1021,10 +1025,16 @@ function TaskCardComponent({
const showTrackingIndicator = hasGithubTrackingLink
&& !hasMatchingIssueInfoBadge
&& !hasMatchingSourceIssue;
/**
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
* The card chip is a user-facing duplicate affordance, so hide it when a parent with the task list proves the canonical is inactive or missing.
* Undefined preserves legacy rendering for embedded card surfaces that cannot resolve the canonical locally.
*/
const showNearDuplicateChip = Boolean(task.sourceMetadata?.nearDuplicateOf)
&& task.sourceMetadata?.nearDuplicateDismissed !== true
&& task.column !== "archived"
&& task.column !== "done";
&& task.column !== "done"
&& nearDuplicateCanonicalInactive !== true;
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
const isAgentCreated = isAgentCreatedTask(task);

View File

@@ -21,6 +21,7 @@ import {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api";
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
@@ -643,10 +644,19 @@ export function TaskDetailContent({
const nearDuplicateOf = typeof workingTask.sourceMetadata?.nearDuplicateOf === "string"
? workingTask.sourceMetadata.nearDuplicateOf
: null;
const nearDuplicateCanonical = nearDuplicateOf
? tasks.find((candidate) => candidate.id === nearDuplicateOf)
: undefined;
/**
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
* 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.
*/
const showNearDuplicateWarning = Boolean(nearDuplicateOf)
&& workingTask.sourceMetadata?.nearDuplicateDismissed !== true
&& task.column !== "archived"
&& task.column !== "done";
&& task.column !== "done"
&& !isNearDuplicateCanonicalInactive(nearDuplicateCanonical);
const [sourceAgent, setSourceAgent] = useState<Agent | null>(null);
const [selectedSourceAgentId, setSelectedSourceAgentId] = useState<string | null>(null);
const provenanceDisplay = getProvenanceLabel(workingTask, {

View File

@@ -1,6 +1,7 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import type { Task, TaskDetail } from "@fusion/core";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { ClipboardList, GitBranch } from "lucide-react";
import { TaskCard } from "./TaskCard";
import type { ToastType } from "../hooks/useToast";
@@ -10,6 +11,7 @@ interface WorktreeGroupProps {
label: string;
activeTasks: Task[];
queuedTasks: Task[];
allTasks?: Task[];
projectId?: string;
onOpenDetail: (task: Task | TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
@@ -42,6 +44,7 @@ function WorktreeGroupComponent({
label,
activeTasks,
queuedTasks,
allTasks,
projectId,
onOpenDetail,
addToast,
@@ -61,6 +64,11 @@ function WorktreeGroupComponent({
const { t } = useTranslation("app");
const upNextLabel = t("worktree.upNext", "Up Next");
const unassignedLabel = t("worktree.unassigned", "Unassigned");
const resolveNearDuplicateCanonicalInactive = (task: Task): boolean | undefined => {
const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf;
if (typeof nearDuplicateOf !== "string" || !allTasks) return undefined;
return isNearDuplicateCanonicalInactive(allTasks.find((candidate) => candidate.id === nearDuplicateOf));
};
return (
<div className="worktree-group">
@@ -71,7 +79,26 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} cardFieldDefs={taskCardFieldDefs?.get(task.id)} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
<TaskCard
key={task.id}
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}
nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)}
/>
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -93,6 +120,7 @@ function WorktreeGroupComponent({
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}
nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)}
/>
))}
</div>

View File

@@ -4347,6 +4347,34 @@ describe("TaskCard near-duplicate chip", () => {
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
});
it("hides duplicate chip when parent resolves the canonical as inactive or missing", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
nearDuplicateCanonicalInactive={true}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
});
it("renders duplicate chip when canonical activity is unknown", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
nearDuplicateCanonicalInactive={undefined}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
});
it("hides duplicate chip in archived and done columns", () => {
const { rerender } = render(
<TaskCard

View File

@@ -2037,6 +2037,7 @@ describe("TaskDetailModal", () => {
render(
<TaskDetailModal
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -2058,6 +2059,7 @@ describe("TaskDetailModal", () => {
render(
<TaskDetailModal
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -2070,6 +2072,29 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("Potential duplicate detected")).toBeNull();
});
it.each([
["archived", makeTask({ id: "FN-1234", column: "archived" })],
["done", makeTask({ id: "FN-1234", column: "done" })],
["missing", undefined],
])("hides near-duplicate decision banner when canonical is %s", (_label, canonical) => {
render(
<TaskDetailModal
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
tasks={canonical ? [canonical] : []}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Potential duplicate detected")).toBeNull();
expect(screen.queryByRole("button", { name: "Archive" })).toBeNull();
expect(screen.queryByRole("button", { name: "Keep" })).toBeNull();
});
it("archives from near-duplicate banner when confirmed", async () => {
const onArchiveTask = vi.fn().mockResolvedValue(makeTask({ column: "archived" }));
mockConfirm.mockResolvedValueOnce(true);
@@ -2077,6 +2102,7 @@ describe("TaskDetailModal", () => {
render(
<TaskDetailModal
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}

View File

@@ -71,6 +71,30 @@ describe("reliability interactions: near-duplicate intake", () => {
expect(archivedActivity.some((entry) => entry.taskId === incoming.id)).toBe(false);
});
it("does not flag when the only near-duplicate candidate is archived", async () => {
const fx = await createFixture();
fixtures.push(fx);
const canonical = await fx.store.createTask({
title: "Create PR routes missing handlers",
description: "Missing /api/tasks/:id/pr/options and /api/tasks/:id/pr/preflight and /api/tasks/:id/pr/generate-metadata",
column: "todo",
});
await fx.store.archiveTask(canonical.id, { cleanup: false });
const incoming = await fx.store.createTask({
title: "Missing handlers for create PR routes",
description: "GET /api/tasks/:id/pr/options and GET /api/tasks/:id/pr/preflight and POST /api/tasks/:id/pr/generate-metadata all fail",
});
await (fx.triage as any).finalizeApprovedTask(incoming, basePrompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(incoming.id);
expect(updated.column).toBe("todo");
expect(updated.sourceMetadata?.nearDuplicateOf).toBeFalsy();
const flaggedActivity = await fx.store.getActivityLog({ type: "task:near-duplicate-flagged", limit: 20 });
expect(flaggedActivity.some((entry) => entry.taskId === incoming.id)).toBe(false);
});
it("does not archive generic file overlap only", async () => {
const fx = await createFixture();
fixtures.push(fx);

View File

@@ -24,6 +24,7 @@ import {
resolveAgentMemoryInclusionMode,
extractIntentSignature,
findNearDuplicates,
isNearDuplicateCanonicalInactive,
applyFrontendUxCriteria,
type NearDuplicateCandidate,
} from "@fusion/core";
@@ -2239,6 +2240,15 @@ export class TriageProcessor {
return;
}
/**
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
* FN-6439 makes the triage backstop defense-in-depth: never persist a user-decision duplicate flag when the canonical is inactive, even if candidate filtering regresses or a stale snapshot slips through.
*/
if (isNearDuplicateCanonicalInactive(canonicalTask)) {
planLog.log(`${task.id}: near-duplicate candidate ${canonical.id} is inactive; skipping near-duplicate flag`);
return;
}
// FN-5152: when the candidate is older (or tie-canonical), flag for user confirmation.
if (isStrictlyOlderOrTieCanonical(canonicalTask)) {
await this.store.updateTask(task.id, {