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();
});
});

View File

@@ -2560,24 +2560,24 @@ export class TriageProcessor {
return;
}
// FN-5152: only archive the task being finalized when it is the newer sibling,
// or the tie-loser when both rows share the same millisecond timestamp.
// FN-5152: when the candidate is older (or tie-canonical), flag for user confirmation.
if (isStrictlyOlderOrTieCanonical(canonicalTask)) {
await this.store.updateTask(task.id, {
sourceMetadataPatch: {
nearDuplicateOf: canonical.id,
nearDuplicateScore: canonical.score,
nearDuplicateSharedTokens: canonical.sharedTokens,
intentSignature: taskIntentSignature,
...(parsedFileScope.length > 0 ? { fileScope: parsedFileScope } : {}),
},
});
await this.store.logEntry(
task.id,
`Auto-archived as near-duplicate of ${canonical.id}`,
`Flagged as near-duplicate of ${canonical.id} (awaiting user decision)`,
`Shared tokens: ${canonical.sharedTokens.join(", ")}`,
);
await this.store.moveTask(task.id, "archived");
await this.store.recordActivity({
type: "task:auto-archived-near-duplicate",
type: "task:near-duplicate-flagged",
taskId: task.id,
taskTitle: task.title ?? "",
details: `Near-duplicate of ${canonical.id}`,
@@ -2587,17 +2587,14 @@ export class TriageProcessor {
score: canonical.score,
},
});
planLog.log(`${task.id} auto-archived as near-duplicate of ${canonical.id}`);
return "archived" as const;
planLog.log(`${task.id} flagged as near-duplicate of ${canonical.id}; awaiting user decision`);
return;
}
planLog.warn(`${task.id}: near-duplicate candidate ${canonical.id} is newer; skipping auto-archive`);
planLog.warn(`${task.id}: near-duplicate candidate ${canonical.id} is newer; skipping near-duplicate flag`);
})(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
]);
if (nearDuplicateResult === "archived") {
return;
}
if (nearDuplicateResult === "timeout") {
planLog.warn(`${task.id}: near-duplicate backstop timed out; proceeding`);
}