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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user