From 4f7b3ac78f1a1427bac95f4e14c99cdebd58bc9b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 14 Jun 2026 08:55:03 -0700 Subject: [PATCH] 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 --- docs/task-management.md | 4 +- .../near-duplicate-stale-flag-clear.test.ts | 105 ++++++++++++++++++ .../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 ++ .../dashboard/app/components/TaskCard.tsx | 12 +- .../app/components/TaskDetailModal.tsx | 12 +- .../app/components/WorktreeGroup.tsx | 30 ++++- .../components/__tests__/TaskCard.test.tsx | 28 +++++ .../TaskDetailModal.rendering.test.tsx | 26 +++++ .../near-duplicate-intake.test.ts | 24 ++++ packages/engine/src/triage.ts | 10 ++ 16 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/__tests__/near-duplicate-stale-flag-clear.test.ts create mode 100644 packages/core/src/near-duplicate-canonical.ts diff --git a/docs/task-management.md b/docs/task-management.md index 849f2641c4..1b4089510e 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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`) diff --git a/packages/core/src/__tests__/near-duplicate-stale-flag-clear.test.ts b/packages/core/src/__tests__/near-duplicate-stale-flag-clear.test.ts new file mode 100644 index 0000000000..a1cb1ce348 --- /dev/null +++ b/packages/core/src/__tests__/near-duplicate-stale-flag-clear.test.ts @@ -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 { + return store.createTask({ title: "Canonical task", description: "Canonical intent" }); + } + + async function createReferencingTask(canonicalId: string, title = "Referencing task"): Promise { + 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 { + 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 { + 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" }); + }); +}); diff --git a/packages/core/src/__tests__/near-duplicate.test.ts b/packages/core/src/__tests__/near-duplicate.test.ts index 53b83b4913..38c10d376f 100644 --- a/packages/core/src/__tests__/near-duplicate.test.ts +++ b/packages/core/src/__tests__/near-duplicate.test.ts @@ -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( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 23a35ef3c5..3bd55b0625 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/near-duplicate-canonical.ts b/packages/core/src/near-duplicate-canonical.ts new file mode 100644 index 0000000000..48a56b6ea0 --- /dev/null +++ b/packages/core/src/near-duplicate-canonical.ts @@ -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); +} diff --git a/packages/core/src/near-duplicate.ts b/packages/core/src/near-duplicate.ts index c9480d4826..5a239645b9 100644 --- a/packages/core/src/near-duplicate.ts +++ b/packages/core/src/near-duplicate.ts @@ -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; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index af54729181..e6a1446b56 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { + 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 { + 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 { - 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 { - 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; } /** diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index e59a202dcf..7f99dbcc92 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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 ( 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, diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 89093feaae..011dd7e56c 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -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(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 && ( diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index b90fafcd2d..3e5898ec52 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 2d2865e2ae..2714485b62 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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(null); const [selectedSourceAgentId, setSelectedSourceAgentId] = useState(null); const provenanceDisplay = getProvenanceLabel(workingTask, { diff --git a/packages/dashboard/app/components/WorktreeGroup.tsx b/packages/dashboard/app/components/WorktreeGroup.tsx index 16aacae6ec..29576bb962 100644 --- a/packages/dashboard/app/components/WorktreeGroup.tsx +++ b/packages/dashboard/app/components/WorktreeGroup.tsx @@ -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 (
@@ -71,7 +79,26 @@ function WorktreeGroupComponent({ {label}
{activeTasks.map((task) => ( - + ))} {queuedTasks.map((task) => ( ))} diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 7dd8dea42c..3b89f34ae7 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( + , + ); + + expect(screen.queryByText("Duplicate of FN-1234")).toBeNull(); + }); + + it("renders duplicate chip when canonical activity is unknown", () => { + render( + , + ); + + expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument(); + }); + it("hides duplicate chip in archived and done columns", () => { const { rerender } = render( { render( { render( { 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( + , + ); + + 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( { 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); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 212cbaf04f..21f6d76f14 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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, {