feat(FN-5060): complete Step 4 — integrate CLI deterministic dedup

This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 17:26:31 -07:00
committed by gsxdsm
parent daa1edf1dc
commit 6365fceee4
3 changed files with 174 additions and 17 deletions

View File

@@ -262,7 +262,7 @@ Usage:
fn desktop --paused Launch with automation paused
fn update [--check] [--global] [--json] Update Fusion to the latest version
fn upgrade Alias for fn update
fn task create [desc] [opts] Create a new task (goes to triage; supports --node <name>)
fn task create [desc] [opts] Create a new task (goes to triage; supports --node <name>, --no-dedup)
fn task plan [description] [opts] Create task via AI-guided planning
fn task list List all tasks
fn task show <id> Show task details, steps, log
@@ -395,6 +395,7 @@ Options:
--dev Start dashboard only (no AI engine)
--attach <file> Attach file(s) on task create (repeatable)
--depends <id> Declare dependency on task create (repeatable)
--no-dedup Bypass deterministic duplicate guard on task create
--feedback <text> Refinement feedback (non-interactive mode)
--yes Skip confirmation prompts (planning mode)
--limit, -l <n> Max issues to import (default: 30, max: 100)
@@ -1006,6 +1007,7 @@ async function main() {
const attachFiles: string[] = [];
const dependsIds: string[] = [];
let nodeName: string | undefined;
let noDedup = false;
const descParts: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
@@ -1017,12 +1019,14 @@ async function main() {
} else if (createArgs[i] === "--node" && i + 1 < createArgs.length) {
nodeName = createArgs[i + 1];
i++; // skip the value
} else if (createArgs[i] === "--no-dedup") {
noDedup = true;
} else {
descParts.push(createArgs[i]);
}
}
const title = descParts.join(" ");
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName, nodeName);
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName, nodeName, noDedup);
break;
}
case "plan": {

View File

@@ -42,6 +42,8 @@ vi.mock("@fusion/core", () => {
TaskStore: vi.fn(),
COLUMNS,
COLUMN_LABELS,
runDeterministicDuplicateGuard: vi.fn(),
reconcileDeterministicDuplicate: 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);
@@ -113,7 +115,7 @@ vi.mock("../../project-context.js", () => ({
}));
import { createInterface } from "node:readline/promises";
import { TaskStore, CentralCore } from "@fusion/core";
import { TaskStore, CentralCore, 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";
@@ -145,6 +147,18 @@ function makeTask(overrides: Record<string, unknown> = {}) {
};
}
beforeEach(() => {
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
action: "proceed",
fingerprint: null,
releaseLock: vi.fn(),
});
vi.mocked(reconcileDeterministicDuplicate).mockImplementation(async (_store, args) => ({
outcome: "kept",
canonical: args.createdTask,
}));
});
describe("runTaskShow", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
@@ -488,18 +502,29 @@ describe("project-aware task command behavior", () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-002", description: "test task" }));
const mockAddAttachment = vi.fn();
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
action: "proceed",
fingerprint: "fp-1",
releaseLock: vi.fn(),
});
vi.mocked(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-002", description: "test task" }) });
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: mockAddAttachment } as unknown as TaskStore,
store: { createTask: mockCreateTask, addAttachment: mockAddAttachment, getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("test task", undefined, undefined, "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockCreateTask).toHaveBeenCalledWith({ description: "test task", dependencies: undefined, source: { sourceType: "cli" } });
expect(mockCreateTask).toHaveBeenCalledWith({
description: "test task",
dependencies: undefined,
source: { sourceType: "cli", sourceMetadata: { contentFingerprint: "fp-1" } },
});
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Project: demo-project"))).toBe(true);
logSpy.mockRestore();
@@ -507,18 +532,21 @@ describe("project-aware task command behavior", () => {
it("runTaskCreate without project flag uses shared resolution flow", async () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-003", description: "default task" }));
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({ action: "proceed", fingerprint: null, releaseLock: vi.fn() });
vi.mocked(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-003", description: "default task" }) });
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_default",
projectPath: "/default/project",
projectName: "default-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: vi.fn() } as unknown as TaskStore,
store: { createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/default/project") } as unknown as TaskStore,
});
await runTaskCreate("default task");
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockCreateTask).toHaveBeenCalledWith({ description: "default task", dependencies: undefined, source: { sourceType: "cli" } });
expect(mockCreateTask).toHaveBeenCalledWith({ description: "default task", dependencies: undefined, source: { sourceType: "cli", sourceMetadata: undefined } });
});
it("runTaskCreate without project flag falls back to TaskStore(process.cwd()) when resolution fails", async () => {
@@ -526,6 +554,9 @@ describe("project-aware task command behavior", () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-004", description: "local task" }));
const init = vi.fn();
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({ action: "proceed", fingerprint: "fp-local", releaseLock: vi.fn() });
vi.mocked(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-004", description: "local task" }) });
vi.mocked(resolveProject).mockRejectedValueOnce(
new Error("No fn project found in current directory. Use --project or run from a project directory.")
);
@@ -534,6 +565,7 @@ describe("project-aware task command behavior", () => {
init,
createTask: mockCreateTask,
addAttachment: vi.fn(),
getRootDir: vi.fn().mockReturnValue(projectPath),
projectPath,
}));
@@ -542,10 +574,88 @@ describe("project-aware task command behavior", () => {
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).toHaveBeenCalledWith("/current/project");
expect(init).toHaveBeenCalledOnce();
expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined, source: { sourceType: "cli" } });
expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined, source: { sourceType: "cli", sourceMetadata: { contentFingerprint: "fp-local" } } });
cwdSpy.mockRestore();
});
it("runTaskCreate links existing task on deterministic duplicate", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const existing = makeTask({ id: "FN-777", description: "same task", column: "todo" });
const mockCreateTask = vi.fn();
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: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("same task");
expect(mockCreateTask).not.toHaveBeenCalled();
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Linked existing FN-777"))).toBe(true);
logSpy.mockRestore();
});
it("runTaskCreate --no-dedup bypasses deterministic guard", async () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
action: "proceed",
fingerprint: "fp-no-dedup",
releaseLock: vi.fn(),
});
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, 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(mockCreateTask).toHaveBeenCalledOnce();
});
it("runTaskCreate creates separate tasks when description differs", async () => {
const mockCreateTask = vi
.fn()
.mockResolvedValueOnce(makeTask({ id: "FN-010", description: "task a" }))
.mockResolvedValueOnce(makeTask({ id: "FN-011", description: "task b" }));
vi.mocked(runDeterministicDuplicateGuard)
.mockResolvedValueOnce({ action: "proceed", fingerprint: "fp-a", releaseLock: vi.fn() })
.mockResolvedValueOnce({ action: "proceed", fingerprint: "fp-b", releaseLock: vi.fn() });
vi.mocked(reconcileDeterministicDuplicate)
.mockResolvedValueOnce({ outcome: "kept", canonical: makeTask({ id: "FN-010", description: "task a" }) })
.mockResolvedValueOnce({ outcome: "kept", canonical: makeTask({ id: "FN-011", description: "task b" }) });
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("task a");
await runTaskCreate("task b");
expect(mockCreateTask).toHaveBeenCalledTimes(2);
});
it("runTaskLogs uses resolved project path in follow mode", async () => {
const mockGetTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-001" }));
const mockGetAgentLogs = vi.fn().mockResolvedValue([]);

View File

@@ -1,6 +1,6 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, getTaskDuplicateLineage, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } 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,7 +288,7 @@ async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string
}
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string) {
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string, noDedup = false) {
let description = descriptionArg;
const projectContext = await getProjectContext(projectName);
@@ -304,11 +304,47 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
}
const store = projectContext?.store ?? await getStore(projectName);
const task = await store.createTask({
description: description.trim(),
dependencies: depends,
source: { sourceType: "cli" },
});
const guard = await runDeterministicDuplicateGuard(
store,
{ description: description.trim() },
{
lockScope: projectContext?.projectId ?? store.getRootDir?.() ?? process.cwd(),
bypass: noDedup,
},
);
let task = guard.existing;
let linkedExisting = false;
try {
if (guard.action === "duplicate" && guard.existing) {
task = guard.existing;
linkedExisting = true;
} else {
const created = await store.createTask({
description: description.trim(),
dependencies: depends,
source: {
sourceType: "cli",
sourceMetadata: guard.fingerprint ? { contentFingerprint: guard.fingerprint } : undefined,
},
});
const reconcileResult = await reconcileDeterministicDuplicate(store, {
createdTask: created,
fingerprint: guard.fingerprint,
});
task = reconcileResult.canonical;
linkedExisting = reconcileResult.outcome === "archived";
}
} finally {
guard.releaseLock();
}
if (!task) {
console.error("Failed to create or link task");
process.exit(1);
}
let resolvedNode: { id: string; name?: string } | undefined;
if (nodeName) {
@@ -329,8 +365,12 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
if (projectContext) {
console.log(` Project: ${projectContext.projectName}`);
}
console.log(` ✓ Created ${task.id}: ${label}`);
console.log(` Column: triage`);
if (linkedExisting) {
console.log(` ✓ Linked existing ${task.id}: ${label}`);
} else {
console.log(` ✓ Created ${task.id}: ${label}`);
}
console.log(` Column: ${task.column}`);
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
@@ -1180,6 +1220,7 @@ export async function runTaskImportGitHubInteractive(
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
// FN-5060: intentional same-content sibling; deterministic guard skipped here.
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
@@ -1335,6 +1376,7 @@ export async function runTaskImportFromGitHub(
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
// FN-5060: intentional same-content sibling; deterministic guard skipped here.
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
@@ -1943,6 +1985,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
if (confirmed) {
// Create the task
// FN-5060: intentional same-content sibling; deterministic guard skipped here.
const task = await store.createTask({
title: result.data.title,
description: result.data.description,