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

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

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

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

Fusion-Task-Id: FN-5731

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

View File

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