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:
@@ -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`)
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
25
packages/core/src/near-duplicate-canonical.ts
Normal file
25
packages/core/src/near-duplicate-canonical.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user