feat(FN-5171): add near-duplicate task guard to CLI create command

Adds a near-duplicate guard to the CLI task creation command (FN-5171), preventing creation of tasks with titles that are visually or semantically identical to existing ones, with 262 new test cases covering the detection logic and the standard changeset entry.

Fusion-Task-Id: FN-5171
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 00:35:21 -07:00
committed by gsxdsm
parent f4039266e2
commit b7ddfc9d20
4 changed files with 389 additions and 6 deletions

View File

@@ -28,7 +28,8 @@ vi.mock("node:child_process", async () => {
});
// Mock @fusion/core before importing the module under test
vi.mock("@fusion/core", () => {
vi.mock("@fusion/core", async (importActual) => {
const actual = await importActual<typeof import("@fusion/core")>();
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
const COLUMN_LABELS: Record<string, string> = {
triage: "Triage",
@@ -39,11 +40,14 @@ vi.mock("@fusion/core", () => {
};
return {
...actual,
TaskStore: vi.fn(),
COLUMNS,
COLUMN_LABELS,
runDeterministicDuplicateGuard: vi.fn(),
reconcileDeterministicDuplicate: vi.fn(),
extractIntentSignature: vi.fn(),
findNearDuplicates: vi.fn(),
getTaskDuplicateLineage: vi.fn((task: { sourceType?: string; sourceParentTaskId?: string; sourceMetadata?: any }) => {
const ids: string[] = [];
if (task.sourceType === "task_duplicate" && task.sourceParentTaskId) ids.push(task.sourceParentTaskId);
@@ -115,7 +119,7 @@ vi.mock("../../project-context.js", () => ({
}));
import { createInterface } from "node:readline/promises";
import { TaskStore, CentralCore, runDeterministicDuplicateGuard, reconcileDeterministicDuplicate } from "@fusion/core";
import { TaskStore, CentralCore, extractIntentSignature, findNearDuplicates, runDeterministicDuplicateGuard, reconcileDeterministicDuplicate } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { exec } from "node:child_process";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskBranchRecovery, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
@@ -157,6 +161,13 @@ beforeEach(() => {
outcome: "kept",
canonical: args.createdTask,
}));
vi.mocked(extractIntentSignature).mockReturnValue({
routePaths: [],
filePaths: [],
identifiers: [],
titleTokens: [],
});
vi.mocked(findNearDuplicates).mockReturnValue([]);
});
describe("runTaskShow", () => {
@@ -605,7 +616,164 @@ describe("project-aware task command behavior", () => {
logSpy.mockRestore();
});
it("runTaskCreate --no-dedup bypasses deterministic guard", async () => {
it("runTaskCreate proceeds when no high-signal tokens are present", async () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-007", description: "plain task" }));
const listTasks = vi.fn();
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("plain task");
expect(findNearDuplicates).not.toHaveBeenCalled();
expect(listTasks).not.toHaveBeenCalled();
expect(mockCreateTask).toHaveBeenCalledOnce();
});
it("runTaskCreate blocks near-duplicates in non-interactive mode", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((((code?: number) => {
throw new Error(`exit:${code}`);
}) as unknown) as (code?: string | number | null | undefined) => never);
const originalInTTY = process.stdin.isTTY;
const originalOutTTY = process.stdout.isTTY;
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
const mockCreateTask = vi.fn();
const listTasks = vi.fn().mockResolvedValue([
makeTask({ id: "FN-9001", title: "Add /pr/options preflight flow", description: "touch /pr/options and /pr/preflight", column: "todo" }),
]);
vi.mocked(extractIntentSignature).mockReturnValue({
routePaths: ["/pr/options", "/pr/preflight"],
filePaths: [],
identifiers: [],
titleTokens: [],
});
vi.mocked(findNearDuplicates).mockReturnValue([
{ id: "FN-9001", score: 0.7, sharedTokens: ["/pr/options", "/pr/preflight"], titleScore: 0.5, reason: "near-duplicate-intent" },
]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await expect(runTaskCreate("Investigate /pr/options /pr/preflight flow")).rejects.toThrow("exit:1");
expect(mockCreateTask).not.toHaveBeenCalled();
const errorOutput = errorSpy.mock.calls.map((call) => call.join(" ")).join("\n");
expect(errorOutput).toContain("FN-9001");
expect(errorOutput).toContain("/pr/options");
expect(errorOutput).toContain("--no-dedup");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalInTTY });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalOutTTY });
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("runTaskCreate proceeds on TTY when user confirms near-duplicate creation", async () => {
const originalInTTY = process.stdin.isTTY;
const originalOutTTY = process.stdout.isTTY;
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
const listTasks = vi.fn().mockResolvedValue([
makeTask({ id: "FN-9001", title: "Add /pr/options preflight flow", description: "touch /pr/options and /pr/preflight", column: "todo" }),
]);
const close = vi.fn();
vi.mocked(createInterface).mockReturnValue({
question: vi.fn().mockResolvedValue("y"),
close,
} as never);
vi.mocked(extractIntentSignature).mockReturnValue({
routePaths: ["/pr/options", "/pr/preflight"],
filePaths: [],
identifiers: [],
titleTokens: [],
});
vi.mocked(findNearDuplicates).mockReturnValue([
{ id: "FN-9001", score: 0.7, sharedTokens: ["/pr/options", "/pr/preflight"], titleScore: 0.5, reason: "near-duplicate-intent" },
]);
vi.mocked(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-008", description: "same task" }) });
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({
source: expect.objectContaining({
sourceMetadata: expect.objectContaining({
intentSignature: expect.objectContaining({ routePaths: ["/pr/options", "/pr/preflight"] }),
}),
}),
}));
expect(close).toHaveBeenCalled();
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalInTTY });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalOutTTY });
});
it("runTaskCreate cancels on TTY when user declines near-duplicate creation", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation((((code?: number) => {
throw new Error(`exit:${code}`);
}) as unknown) as (code?: string | number | null | undefined) => never);
const originalInTTY = process.stdin.isTTY;
const originalOutTTY = process.stdout.isTTY;
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const mockCreateTask = vi.fn();
const listTasks = vi.fn().mockResolvedValue([
makeTask({ id: "FN-9001", title: "Add /pr/options preflight flow", description: "touch /pr/options and /pr/preflight", column: "todo" }),
]);
vi.mocked(createInterface).mockReturnValue({
question: vi.fn().mockResolvedValue(""),
close: vi.fn(),
} as never);
vi.mocked(extractIntentSignature).mockReturnValue({
routePaths: ["/pr/options", "/pr/preflight"],
filePaths: [],
identifiers: [],
titleTokens: [],
});
vi.mocked(findNearDuplicates).mockReturnValue([
{ id: "FN-9001", score: 0.7, sharedTokens: ["/pr/options", "/pr/preflight"], titleScore: 0.5, reason: "near-duplicate-intent" },
]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await expect(runTaskCreate("Investigate /pr/options /pr/preflight flow")).rejects.toThrow("exit:0");
expect(mockCreateTask).not.toHaveBeenCalled();
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalInTTY });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalOutTTY });
exitSpy.mockRestore();
});
it("runTaskCreate --no-dedup bypasses deterministic and near-duplicate guards but still stamps signature", async () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
@@ -613,6 +781,12 @@ describe("project-aware task command behavior", () => {
fingerprint: "fp-no-dedup",
releaseLock: vi.fn(),
});
vi.mocked(extractIntentSignature).mockReturnValue({
routePaths: ["/pr/options", "/pr/preflight"],
filePaths: [],
identifiers: [],
titleTokens: [],
});
vi.mocked(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-008", description: "same task" }) });
vi.mocked(resolveProject).mockResolvedValue({
@@ -620,13 +794,93 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("same task", undefined, undefined, undefined, undefined, true);
expect(runDeterministicDuplicateGuard).toHaveBeenCalledWith(expect.anything(), { description: "same task" }, expect.objectContaining({ bypass: true }));
expect(findNearDuplicates).not.toHaveBeenCalled();
expect(extractIntentSignature).toHaveBeenCalledWith({ description: "same task" });
expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({
source: expect.objectContaining({
sourceMetadata: expect.objectContaining({
contentFingerprint: "fp-no-dedup",
intentSignature: expect.objectContaining({ routePaths: ["/pr/options", "/pr/preflight"] }),
}),
}),
}));
});
it("runTaskCreate fails open when listTasks throws during near-duplicate checking", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
vi.mocked(extractIntentSignature).mockReturnValue({
routePaths: ["/pr/options", "/pr/preflight"],
filePaths: [],
identifiers: [],
titleTokens: [],
});
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks: vi.fn().mockRejectedValue(new Error("list boom")), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
expect(errorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain("near-duplicate check failed (list boom)");
expect(mockCreateTask).toHaveBeenCalledOnce();
errorSpy.mockRestore();
});
it("runTaskCreate fails open when intent extraction throws", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
vi.mocked(extractIntentSignature).mockImplementation(() => {
throw new Error("extract boom");
});
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
expect(errorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain("near-duplicate check failed (extract boom)");
expect(mockCreateTask).toHaveBeenCalledOnce();
errorSpy.mockRestore();
});
it("runTaskCreate keeps deterministic duplicate short-circuit ahead of near-duplicate checks", async () => {
const existing = makeTask({ id: "FN-777", description: "same task", column: "todo" });
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
action: "duplicate",
fingerprint: "fp-dupe",
existing,
releaseLock: vi.fn(),
});
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: vi.fn(), listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("same task");
expect(extractIntentSignature).not.toHaveBeenCalled();
expect(findNearDuplicates).not.toHaveBeenCalled();
});
it("runTaskCreate creates separate tasks when description differs", async () => {

View File

@@ -1,6 +1,6 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch } from "@fusion/core";
import { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -288,6 +288,109 @@ async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string
}
}
function truncateNearDuplicateLabel(value: string): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) return "(untitled)";
return normalized.length > 60 ? `${normalized.slice(0, 60)}` : normalized;
}
function formatNearDuplicateMatch(match: NearDuplicateMatch, candidates: Map<string, NearDuplicateCandidate>): string[] {
const candidate = candidates.get(match.id);
const labelSource = candidate?.title?.trim() || candidate?.description?.trim() || "(untitled)";
const shared = match.sharedTokens.slice(0, 6).join(", ");
return [
` ${match.id} (${candidate?.column ?? "unknown"}) ${truncateNearDuplicateLabel(labelSource)}`,
` score: ${match.score.toFixed(2)} shared: ${shared}`,
];
}
interface CliNearDuplicateOutcome {
signature?: IntentSignature;
}
function hasIntentSignal(signature?: IntentSignature): signature is IntentSignature {
return !!signature && (signature.routePaths.length + signature.filePaths.length + signature.identifiers.length) > 0;
}
async function runCliNearDuplicateCheck(args: {
store: TaskStore;
description: string;
bypass: boolean;
}): Promise<CliNearDuplicateOutcome> {
let signature: IntentSignature | undefined;
let matches: NearDuplicateMatch[] = [];
const candidates = new Map<string, NearDuplicateCandidate>();
try {
signature = extractIntentSignature({ description: args.description });
const signalCount = signature.routePaths.length + signature.filePaths.length + signature.identifiers.length;
if (signalCount === 0) {
return { signature };
}
if (!args.bypass) {
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
const taskCandidates = (await args.store.listTasks({ slim: false, includeArchived: false }))
.filter((task) => task.column !== "done")
.filter((task) => {
const createdAtMs = Date.parse(task.createdAt);
return Number.isFinite(createdAtMs) && createdAtMs >= cutoff;
})
.slice(0, 200)
.map((task) => ({
id: task.id,
title: task.title ?? "",
description: task.description,
column: task.column,
fileScope: Array.isArray(task.source?.sourceMetadata?.fileScope)
? task.source.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string")
: undefined,
createdAt: Date.parse(task.createdAt),
} satisfies NearDuplicateCandidate));
for (const candidate of taskCandidates) {
candidates.set(candidate.id, candidate);
}
matches = findNearDuplicates(
{ description: args.description },
taskCandidates,
{ windowMs: 7 * 24 * 60 * 60 * 1000 },
);
}
} catch (error) {
console.error(`Warning: near-duplicate check failed (${error instanceof Error ? error.message : String(error)}); proceeding.`);
return { signature: undefined };
}
if (matches.length === 0) {
return { signature };
}
console.error("Possible near-duplicate of existing task(s):");
for (const match of matches) {
for (const line of formatNearDuplicateMatch(match, candidates)) {
console.error(line);
}
}
console.error("Pass --no-dedup to create anyway.");
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
console.error("Refusing to create near-duplicate task in non-interactive mode. Re-run with --no-dedup to override.");
process.exit(1);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = (await rl.question("Create anyway? [y/N]: ")).trim().toLowerCase();
if (answer === "y" || answer === "yes") {
return { signature };
}
} finally {
rl.close();
}
console.error("Task creation cancelled. Use --no-dedup to bypass.");
process.exit(0);
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string, noDedup = false) {
let description = descriptionArg;
const projectContext = await getProjectContext(projectName);
@@ -321,12 +424,23 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
task = guard.existing;
linkedExisting = true;
} else {
// FN-5171: create ordering remains deterministic duplicate (FN-4918) -> near-duplicate intent.
// Mirrors the dashboard FN-5152 guard while keeping CLI create fail-open.
const nearDuplicate = await runCliNearDuplicateCheck({
store,
description: description.trim(),
bypass: noDedup,
});
const sourceMetadata = {
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
...(hasIntentSignal(nearDuplicate.signature) ? { intentSignature: nearDuplicate.signature } : {}),
};
const created = await store.createTask({
description: description.trim(),
dependencies: depends,
source: {
sourceType: "cli",
sourceMetadata: guard.fingerprint ? { contentFingerprint: guard.fingerprint } : undefined,
sourceMetadata: Object.keys(sourceMetadata).length > 0 ? sourceMetadata : undefined,
},
});