Files
fusion/packages/engine/src/__tests__/reliability-interactions/near-duplicate-intake.test.ts
gsxdsm 24e0c44b4a 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
2026-05-30 15:52:46 -07:00

184 lines
7.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { execSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DEFAULT_SETTINGS, TaskStore } from "@fusion/core";
import * as core from "@fusion/core";
import { TriageProcessor } from "../../triage.js";
function git(cwd: string, command: string): string {
return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
async function createFixture() {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-near-dup-"));
git(rootDir, "git init -b main");
git(rootDir, 'git config user.email "test@example.com"');
git(rootDir, 'git config user.name "Test User"');
git(rootDir, "git commit --allow-empty -m init");
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await store.init();
await store.updateSettings({ ...DEFAULT_SETTINGS, requirePlanApproval: false });
const triage = new TriageProcessor(store, rootDir);
return {
rootDir,
store,
triage,
cleanup: async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
},
};
}
const basePrompt = `# Task: FN-1 - test\n\n**Size:** S\n\n## Review Level: 1\n\n## File Scope\n- packages/dashboard/src/routes/register-git-github.ts\n`;
describe("reliability interactions: near-duplicate intake", () => {
const fixtures: Array<Awaited<ReturnType<typeof createFixture>>> = [];
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
while (fixtures.length) await fixtures.pop()!.cleanup();
});
it("flags newer task as near-duplicate and records activity", async () => {
const fx = await createFixture();
fixtures.push(fx);
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",
});
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).toBeTruthy();
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 () => {
const fx = await createFixture();
fixtures.push(fx);
await fx.store.createTask({
title: "Fix PR comments pagination",
description: "Touch register-git-github.ts pagination only",
column: "todo",
});
const incoming = await fx.store.createTask({
title: "Add PR merge auto-rebase option",
description: "Touch register-git-github.ts merge behavior only",
});
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");
});
it("does not archive when candidate is older than window", async () => {
vi.useFakeTimers();
const fx = await createFixture();
fixtures.push(fx);
vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z"));
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",
});
vi.setSystemTime(new Date("2026-05-10T00:00:00.000Z"));
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");
vi.useRealTimers();
});
it("fails open when extractor throws", async () => {
const fx = await createFixture();
fixtures.push(fx);
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",
});
vi.spyOn(core, "extractIntentSignature").mockImplementation(() => {
throw new Error("boom");
});
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");
});
it("does not archive older task when newer candidate exists", async () => {
const fx = await createFixture();
fixtures.push(fx);
const older = 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",
});
const newer = 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",
column: "todo",
});
await (fx.triage as any).finalizeApprovedTask(older, basePrompt, await fx.store.getSettings(), {});
const updatedOlder = await fx.store.getTask(older.id);
expect(updatedOlder.column).not.toBe("archived");
expect(updatedOlder.column).toBe("todo");
const updatedNewer = await fx.store.getTask(newer.id);
expect(updatedNewer.column).toBe("todo");
});
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"));
const fx = await createFixture();
fixtures.push(fx);
const first = 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",
});
const second = 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",
});
const settings = await fx.store.getSettings();
await Promise.all([
(fx.triage as any).finalizeApprovedTask(first, basePrompt, settings, {}),
(fx.triage as any).finalizeApprovedTask(second, basePrompt, settings, {}),
]);
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).toBe(0);
vi.useRealTimers();
});
});