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:
committed by
gsxdsm
parent
f4039266e2
commit
b7ddfc9d20
11
.changeset/FN-5171-cli-near-duplicate.md
Normal file
11
.changeset/FN-5171-cli-near-duplicate.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Layer FN-5152's near-duplicate intent guard onto the CLI `fn task create`
|
||||||
|
direct-store path. Aligned thresholds (≥2 shared high-signal tokens AND
|
||||||
|
title-token Jaccard ≥ 0.30 within a 7-day window), `--no-dedup` bypass,
|
||||||
|
`source.sourceMetadata.intentSignature` stamping, and fail-open semantics
|
||||||
|
match the dashboard `POST /api/tasks` gate. Non-TTY runs refuse with exit
|
||||||
|
1; TTY runs prompt before creating. GitHub-import and AI-planning paths
|
||||||
|
intentionally skip the gate (FN-5060 contract).
|
||||||
@@ -83,6 +83,10 @@ To avoid false positives from broad hot files, the matcher treats an overlap con
|
|||||||
|
|
||||||
Layer 1 persists `source.sourceMetadata.intentSignature` on created tasks so later checks can reuse pre-extracted vectors.
|
Layer 1 persists `source.sourceMetadata.intentSignature` on created tasks so later checks can reuse pre-extracted vectors.
|
||||||
|
|
||||||
|
##### CLI direct-store coverage (FN-5171)
|
||||||
|
|
||||||
|
CLI `fn task create` now runs the same near-duplicate intent guard after the FN-4918 deterministic fingerprint guard, using shared `extractIntentSignature` / `findNearDuplicates` helpers from `@fusion/core`. Thresholds and the 7-day comparison window match the dashboard layer exactly. `--no-dedup` remains the single bypass across both duplicate layers: it skips the comparison but still stamps `source.sourceMetadata.intentSignature` when high-signal tokens were extracted. When a near-duplicate is detected, interactive TTY runs prompt `Create anyway? [y/N]`; non-interactive runs refuse creation with exit code 1 and instruct the caller to re-run with `--no-dedup`. The guard is still fail-open: extraction/list/query errors log a warning and continue. `fn task import` (GitHub import) and `fn task plan` intentionally continue to skip both duplicate guards per the FN-5060 same-content-sibling contract.
|
||||||
|
|
||||||
Layer 2 runs in triage `finalizeApprovedTask` after `PROMPT.md` is written and parses `## File Scope` as an additional backstop. If the new spec overlaps an older active task on concrete File Scope / intent tokens and still clears the title threshold, the newer task is auto-archived instead of moved to `todo`.
|
Layer 2 runs in triage `finalizeApprovedTask` after `PROMPT.md` is written and parses `## File Scope` as an additional backstop. If the new spec overlaps an older active task on concrete File Scope / intent tokens and still clears the title threshold, the newer task is auto-archived instead of moved to `todo`.
|
||||||
|
|
||||||
Near-duplicate archival is reversible and leaves lineage markers behind:
|
Near-duplicate archival is reversible and leaves lineage markers behind:
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ vi.mock("node:child_process", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mock @fusion/core before importing the module under test
|
// 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 COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
|
||||||
const COLUMN_LABELS: Record<string, string> = {
|
const COLUMN_LABELS: Record<string, string> = {
|
||||||
triage: "Triage",
|
triage: "Triage",
|
||||||
@@ -39,11 +40,14 @@ vi.mock("@fusion/core", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...actual,
|
||||||
TaskStore: vi.fn(),
|
TaskStore: vi.fn(),
|
||||||
COLUMNS,
|
COLUMNS,
|
||||||
COLUMN_LABELS,
|
COLUMN_LABELS,
|
||||||
runDeterministicDuplicateGuard: vi.fn(),
|
runDeterministicDuplicateGuard: vi.fn(),
|
||||||
reconcileDeterministicDuplicate: vi.fn(),
|
reconcileDeterministicDuplicate: vi.fn(),
|
||||||
|
extractIntentSignature: vi.fn(),
|
||||||
|
findNearDuplicates: vi.fn(),
|
||||||
getTaskDuplicateLineage: vi.fn((task: { sourceType?: string; sourceParentTaskId?: string; sourceMetadata?: any }) => {
|
getTaskDuplicateLineage: vi.fn((task: { sourceType?: string; sourceParentTaskId?: string; sourceMetadata?: any }) => {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
if (task.sourceType === "task_duplicate" && task.sourceParentTaskId) ids.push(task.sourceParentTaskId);
|
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 { 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 { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { exec } from "node:child_process";
|
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";
|
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",
|
outcome: "kept",
|
||||||
canonical: args.createdTask,
|
canonical: args.createdTask,
|
||||||
}));
|
}));
|
||||||
|
vi.mocked(extractIntentSignature).mockReturnValue({
|
||||||
|
routePaths: [],
|
||||||
|
filePaths: [],
|
||||||
|
identifiers: [],
|
||||||
|
titleTokens: [],
|
||||||
|
});
|
||||||
|
vi.mocked(findNearDuplicates).mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runTaskShow", () => {
|
describe("runTaskShow", () => {
|
||||||
@@ -605,7 +616,164 @@ describe("project-aware task command behavior", () => {
|
|||||||
logSpy.mockRestore();
|
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" }));
|
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
|
||||||
|
|
||||||
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
|
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
|
||||||
@@ -613,6 +781,12 @@ describe("project-aware task command behavior", () => {
|
|||||||
fingerprint: "fp-no-dedup",
|
fingerprint: "fp-no-dedup",
|
||||||
releaseLock: vi.fn(),
|
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(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-008", description: "same task" }) });
|
||||||
|
|
||||||
vi.mocked(resolveProject).mockResolvedValue({
|
vi.mocked(resolveProject).mockResolvedValue({
|
||||||
@@ -620,13 +794,93 @@ describe("project-aware task command behavior", () => {
|
|||||||
projectPath: "/test",
|
projectPath: "/test",
|
||||||
projectName: "demo-project",
|
projectName: "demo-project",
|
||||||
isRegistered: true,
|
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);
|
await runTaskCreate("same task", undefined, undefined, undefined, undefined, true);
|
||||||
|
|
||||||
expect(runDeterministicDuplicateGuard).toHaveBeenCalledWith(expect.anything(), { description: "same task" }, expect.objectContaining({ bypass: 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();
|
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 () => {
|
it("runTaskCreate creates separate tasks when description differs", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
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 { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
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) {
|
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string, noDedup = false) {
|
||||||
let description = descriptionArg;
|
let description = descriptionArg;
|
||||||
const projectContext = await getProjectContext(projectName);
|
const projectContext = await getProjectContext(projectName);
|
||||||
@@ -321,12 +424,23 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
|||||||
task = guard.existing;
|
task = guard.existing;
|
||||||
linkedExisting = true;
|
linkedExisting = true;
|
||||||
} else {
|
} 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({
|
const created = await store.createTask({
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
dependencies: depends,
|
dependencies: depends,
|
||||||
source: {
|
source: {
|
||||||
sourceType: "cli",
|
sourceType: "cli",
|
||||||
sourceMetadata: guard.fingerprint ? { contentFingerprint: guard.fingerprint } : undefined,
|
sourceMetadata: Object.keys(sourceMetadata).length > 0 ? sourceMetadata : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user