feat(FN-4918): merge fusion/fn-4918
This commit is contained in:
5
.changeset/FN-4918-deterministic-dedup.md
Normal file
5
.changeset/FN-4918-deterministic-dedup.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add deterministic duplicate guard at task intake: identical-content POSTs within a 60s window are rejected with `409 duplicate_candidates` or auto-archived with a `source.sourceMetadata.deterministicDuplicateOf` lineage marker. Complements the existing FN-4829 similarity warning.
|
||||
@@ -28,6 +28,18 @@ Dashboard `POST /tasks` now performs a pre-create duplicate gate using token-ove
|
||||
- Override creates emit activity type `task:duplicate-warning-overridden` with acknowledged IDs and scored candidate metadata.
|
||||
- Duplicate lineage is persisted on the task row via canonical source fields (`sourceType: "task_duplicate"`, `sourceParentTaskId`) plus `sourceMetadata.duplicateOfTaskIds` when available, so `fn task show <id>` and Task Detail views can render duplicate-of linkage directly from task provenance.
|
||||
|
||||
#### Deterministic duplicate guard (FN-4918)
|
||||
|
||||
`POST /tasks` also applies a deterministic guard for exact normalized content matches (title + description fingerprint) within a 60s window.
|
||||
|
||||
- If an existing same-fingerprint task is found in-window, create returns `409` with `{ error: "duplicate_candidates", details: { matches: [{ ..., score: 1, deterministic: true }] } }`.
|
||||
- If two creates race across processes and both reach persistence, post-create reconciliation can return `200` with the canonical older task and auto-archive the newer sibling.
|
||||
- New rows stamp `task.source.sourceMetadata.contentFingerprint` for deterministic matching.
|
||||
- Reconciled losers stamp `task.source.sourceMetadata.deterministicDuplicateOf = <canonicalTaskId>` and are archived (not deleted).
|
||||
- Reconciliation archives record activity event `task:auto-archived-deterministic-duplicate`.
|
||||
|
||||
This deterministic layer complements (does not replace) the FN-4829 similarity warning gate. `bypassDuplicateCheck: true` on `POST /tasks` disables both gates. FN-4892 remains a separate engine-side same-agent intake heuristic at triage finalize.
|
||||
|
||||
### Intake auto-archive (ghost-bug preflight + same-agent duplicate)
|
||||
|
||||
Fusion applies two conservative intake heuristics that may auto-archive newly filed tasks before execution starts:
|
||||
|
||||
@@ -1,10 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeContentFingerprint,
|
||||
findDuplicateMatches,
|
||||
type DuplicateCandidate,
|
||||
} from "../duplicate-detection.js";
|
||||
|
||||
describe("computeContentFingerprint", () => {
|
||||
it("returns identical fingerprint for identical title and description", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("normalizes whitespace, casing, and trailing punctuation", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Duplicate Warning Route",
|
||||
description: "Warn before creating duplicate tasks!!!",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: " duplicate warning route ",
|
||||
description: "warn before creating duplicate tasks",
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("returns different fingerprints for different titles", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: "Retry counter badge placement",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("returns null for empty or whitespace-only descriptions", () => {
|
||||
expect(
|
||||
computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: " ",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "...",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("matches the FN-4909/FN-4910 reproduction pair", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Move retry counter badge next to GitHub tracking badge",
|
||||
description:
|
||||
"Move the retry counter badge to the left of the GitHub tracking badge",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: "Move retry counter badge next to GitHub tracking badge",
|
||||
description:
|
||||
"Move the retry counter badge to the left of the GitHub tracking badge.",
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDuplicateMatches", () => {
|
||||
it("returns high-similarity title+description matches", () => {
|
||||
const candidates: DuplicateCandidate[] = [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Column } from "./types.js";
|
||||
|
||||
export interface DuplicateMatch {
|
||||
@@ -20,6 +22,11 @@ export interface DuplicateCandidate {
|
||||
column: Column;
|
||||
}
|
||||
|
||||
export interface ContentFingerprintInput {
|
||||
title?: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 0.45;
|
||||
const DEFAULT_LIMIT = 5;
|
||||
const DEFAULT_EXCLUDE_COLUMNS: Column[] = ["done", "archived"];
|
||||
@@ -39,6 +46,34 @@ const STOPWORDS = new Set([
|
||||
"fn",
|
||||
]);
|
||||
|
||||
function normalizeFingerprintPart(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[,.;:!?"'`(){}]+/g, "")
|
||||
.replaceAll("[", "")
|
||||
.replaceAll("]", "")
|
||||
.replace(/…$/u, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic dedup fingerprint for task content.
|
||||
*/
|
||||
export function computeContentFingerprint(
|
||||
input: ContentFingerprintInput,
|
||||
): string | null {
|
||||
const normalizedDescription = normalizeFingerprintPart(input.description);
|
||||
if (normalizedDescription.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTitle = normalizeFingerprintPart(input.title ?? "");
|
||||
return createHash("sha256")
|
||||
.update(`${normalizedTitle}\n${normalizedDescription}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
|
||||
@@ -131,7 +131,9 @@ export {
|
||||
SelfDefeatingDependencyError,
|
||||
} from "./store.js";
|
||||
export {
|
||||
computeContentFingerprint,
|
||||
findDuplicateMatches,
|
||||
type ContentFingerprintInput,
|
||||
type DuplicateCandidate,
|
||||
type DuplicateMatch,
|
||||
type DuplicateMatchInput,
|
||||
|
||||
@@ -4020,6 +4020,33 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return limit >= 0 ? matches.slice(0, limit) : matches;
|
||||
}
|
||||
|
||||
async findRecentTasksByContentFingerprint(
|
||||
fingerprint: string,
|
||||
options?: { windowMs?: number; includeArchived?: boolean },
|
||||
): Promise<Task[]> {
|
||||
const trimmedFingerprint = fingerprint.trim();
|
||||
if (trimmedFingerprint.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requestedWindowMs = options?.windowMs ?? 60_000;
|
||||
const windowMs = Math.max(1, Math.min(300_000, Math.trunc(requestedWindowMs)));
|
||||
const cutoffIso = new Date(Date.now() - windowMs).toISOString();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const selectClause = this.getTaskSelectClause(false, "t");
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT ${selectClause}
|
||||
FROM tasks t
|
||||
WHERE json_extract(t.sourceMetadata, '$.contentFingerprint') = ?
|
||||
AND t.createdAt >= ?
|
||||
${includeArchived ? "" : "AND t.\"column\" != 'archived'"}
|
||||
ORDER BY t.createdAt ASC
|
||||
`).all(trimmedFingerprint, cutoffIso) as TaskRow[];
|
||||
|
||||
return rows.map((row) => this.rowToTask(row));
|
||||
}
|
||||
|
||||
async getTasksByAssignedAgent(
|
||||
agentId: string,
|
||||
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
|
||||
|
||||
@@ -875,6 +875,7 @@ export type ActivityEventType =
|
||||
| "task:merged"
|
||||
| "task:failed"
|
||||
| "task:duplicate-warning-overridden"
|
||||
| "task:auto-archived-deterministic-duplicate"
|
||||
| "task:auto-archived-ghost-bug"
|
||||
| "task:auto-archived-duplicate"
|
||||
| "settings:updated"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { computeContentFingerprint, type Column, type Task, type TaskStore } from "@fusion/core";
|
||||
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
import { ApiError, sendErrorResponse } from "../api-error.js";
|
||||
|
||||
const TITLE = "Move retry counter badge next to GitHub tracking badge";
|
||||
const DESCRIPTION = "Move the retry counter badge to the left of the GitHub tracking badge";
|
||||
const FINGERPRINT = computeContentFingerprint({ title: TITLE, description: DESCRIPTION }) as string;
|
||||
|
||||
function mkTask(overrides: Partial<Task> & { id: string; description: string; column: Column }): Task {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
description: overrides.description,
|
||||
column: overrides.column,
|
||||
dependencies: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
size: "M",
|
||||
subtasks: [],
|
||||
log: [],
|
||||
tags: [],
|
||||
blockedBy: [],
|
||||
source: { sourceType: "api" },
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function buildApp(seed: Task[] = []) {
|
||||
const tasks = [...seed];
|
||||
const runtimeLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
||||
const store: Partial<TaskStore> = {
|
||||
searchTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fp: string, options?: { windowMs?: number; includeArchived?: boolean }) => {
|
||||
const windowMs = Math.max(1, Math.min(300_000, Math.trunc(options?.windowMs ?? 60_000)));
|
||||
const cutoff = Date.now() - windowMs;
|
||||
return tasks
|
||||
.filter((task) => task.source?.sourceMetadata?.contentFingerprint === fp)
|
||||
.filter((task) => (options?.includeArchived ?? false) || task.column !== "archived")
|
||||
.filter((task) => Date.parse(task.createdAt) >= cutoff)
|
||||
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
}),
|
||||
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Task["source"] }) => {
|
||||
const now = new Date().toISOString();
|
||||
const task = mkTask({
|
||||
id: `FN-${tasks.length + 100}`,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
column: "todo",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
source: input.source ?? { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } },
|
||||
});
|
||||
tasks.push(task);
|
||||
return task;
|
||||
}),
|
||||
updateTask: vi.fn().mockImplementation(async (id: string, updates: { sourceMetadataPatch?: Record<string, unknown> }) => {
|
||||
const task = tasks.find((item) => item.id === id);
|
||||
if (!task) return null;
|
||||
task.source = {
|
||||
...(task.source ?? { sourceType: "api" }),
|
||||
sourceMetadata: {
|
||||
...(task.source?.sourceMetadata ?? {}),
|
||||
...(updates.sourceMetadataPatch ?? {}),
|
||||
},
|
||||
};
|
||||
return task;
|
||||
}),
|
||||
moveTask: vi.fn().mockImplementation(async (id: string, column: Column) => {
|
||||
const task = tasks.find((item) => item.id === id);
|
||||
if (!task) return null;
|
||||
task.column = column;
|
||||
return task;
|
||||
}),
|
||||
recordActivity: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
registerTaskWorkflowRoutes(
|
||||
{
|
||||
router,
|
||||
store: store as TaskStore,
|
||||
options: {},
|
||||
runtimeLogger: runtimeLogger as never,
|
||||
planningLogger: runtimeLogger as never,
|
||||
chatLogger: runtimeLogger as never,
|
||||
getProjectIdFromRequest: () => undefined,
|
||||
getScopedStore: async () => store as TaskStore,
|
||||
getProjectContext: async () => ({ store: store as TaskStore, engine: undefined, projectId: "p-1" }),
|
||||
prioritizeProjectsForCurrentDirectory: (projects) => projects,
|
||||
emitRemoteRouteDiagnostic: () => {},
|
||||
emitAuthSyncAuditLog: () => {},
|
||||
parseScopeParam: () => undefined,
|
||||
resolveAutomationStore: () => ({}) as never,
|
||||
resolveRoutineStore: () => ({}) as never,
|
||||
resolveRoutineRunner: () => ({}) as never,
|
||||
registerDispose: () => {},
|
||||
dispose: () => {},
|
||||
rethrowAsApiError: (error: unknown): never => {
|
||||
if (error instanceof ApiError) throw error;
|
||||
throw new ApiError(500, error instanceof Error ? error.message : "Internal server error");
|
||||
},
|
||||
},
|
||||
{
|
||||
runtimeLogger: { error: vi.fn(), warn: runtimeLogger.warn },
|
||||
upload: { single: () => (_req: unknown, _res: unknown, next: () => void) => next() },
|
||||
taskDetailActivityLogLimit: 100,
|
||||
validateOptionalModelField: (value) => (typeof value === "string" ? value : undefined),
|
||||
normalizeModelSelectionPair: (provider, modelId) => ({ provider: provider ?? null, modelId: modelId ?? null }),
|
||||
runGitCommand: async () => "",
|
||||
trimTaskDetailActivityLog: (task) => task,
|
||||
triggerCommentWakeForAssignedAgent: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", router);
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (error instanceof ApiError) {
|
||||
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
|
||||
return;
|
||||
}
|
||||
sendErrorResponse(res, 500, error instanceof Error ? error.message : "Internal server error");
|
||||
});
|
||||
|
||||
return { app, store, tasks, runtimeLogger };
|
||||
}
|
||||
|
||||
describe("task deterministic dedup", () => {
|
||||
it("blocks sequential duplicate create with deterministic 409", async () => {
|
||||
const { app } = buildApp([
|
||||
mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "todo", source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }),
|
||||
]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: DESCRIPTION }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((res.body as { details: { matches: Array<{ deterministic: boolean; id: string }> } }).details.matches[0]).toMatchObject({ deterministic: true, id: "FN-1" });
|
||||
});
|
||||
|
||||
it("concurrent identical submissions keep one canonical row", async () => {
|
||||
const { app, tasks } = buildApp();
|
||||
const body = JSON.stringify({ title: TITLE, description: DESCRIPTION });
|
||||
const [a, b] = await Promise.all([
|
||||
performRequest(app, "POST", "/api/tasks", body, { "content-type": "application/json" }),
|
||||
performRequest(app, "POST", "/api/tasks", body, { "content-type": "application/json" }),
|
||||
]);
|
||||
|
||||
const fingerprintRows = tasks.filter((task) => task.source?.sourceMetadata?.contentFingerprint === FINGERPRINT && task.column !== "archived");
|
||||
expect(fingerprintRows).toHaveLength(1);
|
||||
const canonicalId = fingerprintRows[0]?.id;
|
||||
expect([a.status, b.status].every((status) => status === 201 || status === 200 || status === 409)).toBe(true);
|
||||
const responseIds = [a, b].map((res) => {
|
||||
if (res.status === 409) {
|
||||
return (res.body as { details: { matches: Array<{ id: string }> } }).details.matches[0]?.id;
|
||||
}
|
||||
return (res.body as Task).id;
|
||||
});
|
||||
expect(responseIds).toContain(canonicalId);
|
||||
});
|
||||
|
||||
it("concurrent triple submissions keep one canonical row", async () => {
|
||||
const { app, tasks } = buildApp();
|
||||
const body = JSON.stringify({ title: TITLE, description: DESCRIPTION });
|
||||
const responses = await Promise.all([
|
||||
performRequest(app, "POST", "/api/tasks", body, { "content-type": "application/json" }),
|
||||
performRequest(app, "POST", "/api/tasks", body, { "content-type": "application/json" }),
|
||||
performRequest(app, "POST", "/api/tasks", body, { "content-type": "application/json" }),
|
||||
]);
|
||||
|
||||
const fingerprintRows = tasks.filter((task) => task.source?.sourceMetadata?.contentFingerprint === FINGERPRINT && task.column !== "archived");
|
||||
expect(fingerprintRows).toHaveLength(1);
|
||||
expect(responses.some((res) => res.status === 201 || res.status === 200)).toBe(true);
|
||||
});
|
||||
|
||||
it("different content does not collide", async () => {
|
||||
const { app, store } = buildApp();
|
||||
const [a, b] = await Promise.all([
|
||||
performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: "fix retry badge overlap on board" }), { "content-type": "application/json" }),
|
||||
performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: "add scheduler retry diagnostics telemetry" }), { "content-type": "application/json" }),
|
||||
]);
|
||||
|
||||
expect(a.status).toBe(201);
|
||||
expect(b.status).toBe(201);
|
||||
expect((store.createTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("allows bypassDuplicateCheck to create duplicates", async () => {
|
||||
const { app, store } = buildApp();
|
||||
const a = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: DESCRIPTION }), { "content-type": "application/json" });
|
||||
const b = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: DESCRIPTION, bypassDuplicateCheck: true }), { "content-type": "application/json" });
|
||||
expect(a.status).toBe(201);
|
||||
expect(b.status).toBe(201);
|
||||
expect((store.createTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("respects the 60s fingerprint window", async () => {
|
||||
const oldTs = new Date(Date.now() - 120_000).toISOString();
|
||||
const { app, store } = buildApp([
|
||||
mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "todo", createdAt: oldTs, updatedAt: oldTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }),
|
||||
]);
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: DESCRIPTION, acknowledgedDuplicates: ["FN-1"] }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect((store.createTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("reconciles late race by archiving loser and returning canonical", async () => {
|
||||
const canonicalTs = new Date(Date.now() - 2_000).toISOString();
|
||||
const { app, store } = buildApp([
|
||||
mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "todo", createdAt: canonicalTs, updatedAt: canonicalTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }),
|
||||
]);
|
||||
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: DESCRIPTION, acknowledgedDuplicates: ["FN-1"] }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as Task).id).toBe("FN-1");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-101", "archived");
|
||||
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "task:auto-archived-deterministic-duplicate" }));
|
||||
});
|
||||
|
||||
it("fails open when reconciliation archive fails", async () => {
|
||||
const canonicalTs = new Date(Date.now() - 2_000).toISOString();
|
||||
const { app, store, runtimeLogger } = buildApp([
|
||||
mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "todo", createdAt: canonicalTs, updatedAt: canonicalTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }),
|
||||
]);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("archive failed"));
|
||||
|
||||
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title: TITLE, description: DESCRIPTION, acknowledgedDuplicates: ["FN-1"] }), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(runtimeLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { Column, Task, TaskStore } from "@fusion/core";
|
||||
import { computeContentFingerprint } from "@fusion/core";
|
||||
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
||||
@@ -33,6 +34,20 @@ function buildApp(seed: Task[] = []) {
|
||||
|
||||
const store: Partial<TaskStore> = {
|
||||
searchTasks: vi.fn().mockImplementation(async () => tasks),
|
||||
findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fingerprint: string, options?: { windowMs?: number; includeArchived?: boolean }) => {
|
||||
const windowMs = Math.max(1, Math.min(300_000, Math.trunc(options?.windowMs ?? 60_000)));
|
||||
const cutoff = Date.now() - windowMs;
|
||||
return tasks.filter((task) => {
|
||||
const taskFingerprint = task.source?.sourceMetadata?.contentFingerprint;
|
||||
if (taskFingerprint !== fingerprint) {
|
||||
return false;
|
||||
}
|
||||
if ((options?.includeArchived ?? false) !== true && task.column === "archived") {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(task.createdAt) >= cutoff;
|
||||
});
|
||||
}),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Record<string, unknown> }) => {
|
||||
const task = createTaskFixture({
|
||||
@@ -206,6 +221,44 @@ describe("task duplicate detection routes", () => {
|
||||
expect(created.source?.sourceMetadata?.duplicateWarningOverridden).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deterministic check still blocks when only similarity duplicate is acknowledged", async () => {
|
||||
const fingerprint = computeContentFingerprint({
|
||||
title: "Move retry counter badge next to GitHub tracking badge",
|
||||
description: "Move the retry counter badge to the left of the GitHub tracking badge",
|
||||
}) as string;
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({
|
||||
id: "FN-31",
|
||||
title: "Similar title",
|
||||
description: "Move the retry counter badge left of GitHub tracking badge",
|
||||
column: "todo",
|
||||
}),
|
||||
createTaskFixture({
|
||||
id: "FN-32",
|
||||
title: "Move retry counter badge next to GitHub tracking badge",
|
||||
description: "Move the retry counter badge to the left of the GitHub tracking badge",
|
||||
column: "todo",
|
||||
source: { sourceType: "api", sourceMetadata: { contentFingerprint: fingerprint } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
title: "Move retry counter badge next to GitHub tracking badge",
|
||||
description: "Move the retry counter badge to the left of the GitHub tracking badge",
|
||||
acknowledgedDuplicates: ["FN-31"],
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = res.body as { details: { matches: Array<{ id: string; deterministic?: boolean }> } };
|
||||
expect(body.details.matches[0]).toMatchObject({ id: "FN-32", deterministic: true });
|
||||
});
|
||||
|
||||
it("done tasks do not trigger conflict", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({ id: "FN-15", title: "Duplicate warning", description: "Warn before task creation", column: "done" }),
|
||||
|
||||
@@ -178,6 +178,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
findRecentTasksByContentFingerprint: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
createTaskWithReservedId: undefined,
|
||||
moveTask: vi.fn(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
TaskStore,
|
||||
Task,
|
||||
TaskDetail,
|
||||
TaskSource,
|
||||
Column,
|
||||
TaskReviewData,
|
||||
TaskReviewItem,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
formatRoleMismatchReason,
|
||||
getCurrentRepo,
|
||||
findDuplicateMatches,
|
||||
computeContentFingerprint,
|
||||
} from "@fusion/core";
|
||||
import { GitHubClient } from "../github.js";
|
||||
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
|
||||
@@ -36,6 +38,7 @@ const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Pla
|
||||
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
|
||||
const fingerprintCreateLocks = new Map<string, Promise<void>>();
|
||||
|
||||
function buildDuplicateQuery(title: string | undefined, description: string): string {
|
||||
const tokens = `${title ?? ""} ${description}`
|
||||
@@ -83,6 +86,21 @@ async function computeDuplicateMatches(
|
||||
);
|
||||
}
|
||||
|
||||
async function findOlderSameFingerprintSibling(
|
||||
scopedStore: TaskStore,
|
||||
fingerprint: string,
|
||||
taskId: string,
|
||||
createdAt: string,
|
||||
windowMs: number,
|
||||
): Promise<Task | null> {
|
||||
const siblings = await scopedStore.findRecentTasksByContentFingerprint(fingerprint, {
|
||||
windowMs,
|
||||
includeArchived: false,
|
||||
});
|
||||
|
||||
return siblings.find((sibling) => sibling.id !== taskId && sibling.createdAt < createdAt) ?? null;
|
||||
}
|
||||
|
||||
function buildReviewerAgentItemId(input: { index: number; reviewType: "plan" | "code"; step?: number; verdict?: string; createdAt?: string }): string {
|
||||
const stepPart = input.step ? `step-${input.step}` : "step-na";
|
||||
const verdictPart = (input.verdict ?? "unknown").toLowerCase();
|
||||
@@ -249,7 +267,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
// Create task
|
||||
router.post("/tasks", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
@@ -406,7 +424,52 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
|
||||
const normalizedDescription = description.trim();
|
||||
const normalizedTitle = typeof title === "string" ? title : undefined;
|
||||
const contentFingerprint = computeContentFingerprint({
|
||||
title: normalizedTitle,
|
||||
description: normalizedDescription,
|
||||
});
|
||||
const acknowledgedDuplicateIds = acknowledgedDuplicates ?? [];
|
||||
|
||||
if (bypassDuplicateCheck !== true && contentFingerprint) {
|
||||
const fingerprintLockKey = `${projectId}:${contentFingerprint}`;
|
||||
const existingLock = fingerprintCreateLocks.get(fingerprintLockKey);
|
||||
if (existingLock) {
|
||||
await existingLock;
|
||||
}
|
||||
|
||||
let releaseLock: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
fingerprintCreateLocks.set(fingerprintLockKey, gate);
|
||||
try {
|
||||
const deterministicMatches = await scopedStore.findRecentTasksByContentFingerprint(contentFingerprint, {
|
||||
windowMs: 60_000,
|
||||
includeArchived: false,
|
||||
});
|
||||
const deterministicConflict = deterministicMatches.find(
|
||||
(match) => !acknowledgedDuplicateIds.includes(match.id),
|
||||
);
|
||||
if (deterministicConflict) {
|
||||
throw conflict("duplicate_candidates", {
|
||||
matches: [{
|
||||
id: deterministicConflict.id,
|
||||
title: deterministicConflict.title ?? "",
|
||||
description: deterministicConflict.description ?? "",
|
||||
column: deterministicConflict.column,
|
||||
score: 1,
|
||||
deterministic: true,
|
||||
}],
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (releaseLock) {
|
||||
releaseLock();
|
||||
}
|
||||
fingerprintCreateLocks.delete(fingerprintLockKey);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateMatches = bypassDuplicateCheck === true
|
||||
? []
|
||||
: await computeDuplicateMatches(scopedStore, {
|
||||
@@ -418,6 +481,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
throw conflict("duplicate_candidates", { matches: matchesAfterAckFilter });
|
||||
}
|
||||
|
||||
const normalizedTaskSource = normalizedSource as TaskSource;
|
||||
const createInput = {
|
||||
title: normalizedTitle,
|
||||
description: normalizedDescription,
|
||||
@@ -437,17 +501,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
reviewLevel: reviewLevel ?? undefined,
|
||||
executionMode: executionMode || undefined,
|
||||
priority: priority ?? undefined,
|
||||
source:
|
||||
acknowledgedDuplicateIds.length > 0
|
||||
? {
|
||||
...(normalizedSource as Record<string, unknown>),
|
||||
sourceMetadata: {
|
||||
...((normalizedSource as { sourceMetadata?: Record<string, unknown> }).sourceMetadata ?? {}),
|
||||
source: {
|
||||
...normalizedTaskSource,
|
||||
sourceMetadata: {
|
||||
...(normalizedTaskSource.sourceMetadata ?? {}),
|
||||
...(contentFingerprint ? { contentFingerprint } : {}),
|
||||
...(acknowledgedDuplicateIds.length > 0
|
||||
? {
|
||||
duplicateWarningOverridden: true,
|
||||
acknowledgedDuplicateIds,
|
||||
},
|
||||
}
|
||||
: normalizedSource,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
branch: normalizedBranch,
|
||||
baseBranch: normalizedBaseBranch,
|
||||
...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}),
|
||||
@@ -459,6 +525,52 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
|
||||
);
|
||||
|
||||
if (bypassDuplicateCheck !== true && contentFingerprint) {
|
||||
try {
|
||||
const olderSibling = await findOlderSameFingerprintSibling(
|
||||
scopedStore,
|
||||
contentFingerprint,
|
||||
task.id,
|
||||
task.createdAt,
|
||||
60_000,
|
||||
);
|
||||
|
||||
if (olderSibling) {
|
||||
await scopedStore.updateTask(task.id, {
|
||||
sourceMetadataPatch: {
|
||||
contentFingerprint,
|
||||
deterministicDuplicateOf: olderSibling.id,
|
||||
},
|
||||
});
|
||||
await scopedStore.moveTask(task.id, "archived");
|
||||
try {
|
||||
await scopedStore.recordActivity({
|
||||
type: "task:auto-archived-deterministic-duplicate",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
details: `Auto-archived as deterministic duplicate of ${olderSibling.id}`,
|
||||
metadata: { canonicalTaskId: olderSibling.id, contentFingerprint },
|
||||
});
|
||||
} catch (error) {
|
||||
runtimeLogger.warn("Failed to record deterministic-duplicate activity", {
|
||||
taskId: task.id,
|
||||
canonicalTaskId: olderSibling.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json(olderSibling);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// FN-4918: fail open if reconciliation cannot complete after create.
|
||||
runtimeLogger.warn("Deterministic duplicate reconciliation failed; returning created task", {
|
||||
taskId: task.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (acknowledgedDuplicateIds.length > 0) {
|
||||
try {
|
||||
await scopedStore.recordActivity({
|
||||
|
||||
Reference in New Issue
Block a user