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.
|
- 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.
|
- 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)
|
### 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:
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
computeContentFingerprint,
|
||||||
findDuplicateMatches,
|
findDuplicateMatches,
|
||||||
type DuplicateCandidate,
|
type DuplicateCandidate,
|
||||||
} from "../duplicate-detection.js";
|
} 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", () => {
|
describe("findDuplicateMatches", () => {
|
||||||
it("returns high-similarity title+description matches", () => {
|
it("returns high-similarity title+description matches", () => {
|
||||||
const candidates: DuplicateCandidate[] = [
|
const candidates: DuplicateCandidate[] = [
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import type { Column } from "./types.js";
|
import type { Column } from "./types.js";
|
||||||
|
|
||||||
export interface DuplicateMatch {
|
export interface DuplicateMatch {
|
||||||
@@ -20,6 +22,11 @@ export interface DuplicateCandidate {
|
|||||||
column: Column;
|
column: Column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContentFingerprintInput {
|
||||||
|
title?: string | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_THRESHOLD = 0.45;
|
const DEFAULT_THRESHOLD = 0.45;
|
||||||
const DEFAULT_LIMIT = 5;
|
const DEFAULT_LIMIT = 5;
|
||||||
const DEFAULT_EXCLUDE_COLUMNS: Column[] = ["done", "archived"];
|
const DEFAULT_EXCLUDE_COLUMNS: Column[] = ["done", "archived"];
|
||||||
@@ -39,6 +46,34 @@ const STOPWORDS = new Set([
|
|||||||
"fn",
|
"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[] {
|
function tokenize(value: string): string[] {
|
||||||
return value
|
return value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ export {
|
|||||||
SelfDefeatingDependencyError,
|
SelfDefeatingDependencyError,
|
||||||
} from "./store.js";
|
} from "./store.js";
|
||||||
export {
|
export {
|
||||||
|
computeContentFingerprint,
|
||||||
findDuplicateMatches,
|
findDuplicateMatches,
|
||||||
|
type ContentFingerprintInput,
|
||||||
type DuplicateCandidate,
|
type DuplicateCandidate,
|
||||||
type DuplicateMatch,
|
type DuplicateMatch,
|
||||||
type DuplicateMatchInput,
|
type DuplicateMatchInput,
|
||||||
|
|||||||
@@ -4020,6 +4020,33 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return limit >= 0 ? matches.slice(0, limit) : matches;
|
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(
|
async getTasksByAssignedAgent(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
|
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
|
||||||
|
|||||||
@@ -875,6 +875,7 @@ export type ActivityEventType =
|
|||||||
| "task:merged"
|
| "task:merged"
|
||||||
| "task:failed"
|
| "task:failed"
|
||||||
| "task:duplicate-warning-overridden"
|
| "task:duplicate-warning-overridden"
|
||||||
|
| "task:auto-archived-deterministic-duplicate"
|
||||||
| "task:auto-archived-ghost-bug"
|
| "task:auto-archived-ghost-bug"
|
||||||
| "task:auto-archived-duplicate"
|
| "task:auto-archived-duplicate"
|
||||||
| "settings:updated"
|
| "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 { describe, expect, it, vi } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { Column, Task, TaskStore } from "@fusion/core";
|
import type { Column, Task, TaskStore } from "@fusion/core";
|
||||||
|
import { computeContentFingerprint } from "@fusion/core";
|
||||||
|
|
||||||
import { request as performRequest } from "../test-request.js";
|
import { request as performRequest } from "../test-request.js";
|
||||||
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
||||||
@@ -33,6 +34,20 @@ function buildApp(seed: Task[] = []) {
|
|||||||
|
|
||||||
const store: Partial<TaskStore> = {
|
const store: Partial<TaskStore> = {
|
||||||
searchTasks: vi.fn().mockImplementation(async () => tasks),
|
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 }),
|
getSettingsFast: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||||
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Record<string, unknown> }) => {
|
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Record<string, unknown> }) => {
|
||||||
const task = createTaskFixture({
|
const task = createTaskFixture({
|
||||||
@@ -206,6 +221,44 @@ describe("task duplicate detection routes", () => {
|
|||||||
expect(created.source?.sourceMetadata?.duplicateWarningOverridden).toBeUndefined();
|
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 () => {
|
it("done tasks do not trigger conflict", async () => {
|
||||||
const { app } = buildApp([
|
const { app } = buildApp([
|
||||||
createTaskFixture({ id: "FN-15", title: "Duplicate warning", description: "Warn before task creation", column: "done" }),
|
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(),
|
getTask: vi.fn(),
|
||||||
listTasks: vi.fn().mockResolvedValue([]),
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
searchTasks: vi.fn().mockResolvedValue([]),
|
searchTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
findRecentTasksByContentFingerprint: vi.fn().mockResolvedValue([]),
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
createTaskWithReservedId: undefined,
|
createTaskWithReservedId: undefined,
|
||||||
moveTask: vi.fn(),
|
moveTask: vi.fn(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
TaskStore,
|
TaskStore,
|
||||||
Task,
|
Task,
|
||||||
TaskDetail,
|
TaskDetail,
|
||||||
|
TaskSource,
|
||||||
Column,
|
Column,
|
||||||
TaskReviewData,
|
TaskReviewData,
|
||||||
TaskReviewItem,
|
TaskReviewItem,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
formatRoleMismatchReason,
|
formatRoleMismatchReason,
|
||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
findDuplicateMatches,
|
findDuplicateMatches,
|
||||||
|
computeContentFingerprint,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { GitHubClient } from "../github.js";
|
import { GitHubClient } from "../github.js";
|
||||||
import { createTrackingIssueForTask } from "../github-tracking-hook.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_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 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 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 {
|
function buildDuplicateQuery(title: string | undefined, description: string): string {
|
||||||
const tokens = `${title ?? ""} ${description}`
|
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 {
|
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 stepPart = input.step ? `step-${input.step}` : "step-na";
|
||||||
const verdictPart = (input.verdict ?? "unknown").toLowerCase();
|
const verdictPart = (input.verdict ?? "unknown").toLowerCase();
|
||||||
@@ -249,7 +267,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
// Create task
|
// Create task
|
||||||
router.post("/tasks", async (req, res) => {
|
router.post("/tasks", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -406,7 +424,52 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
|
|
||||||
const normalizedDescription = description.trim();
|
const normalizedDescription = description.trim();
|
||||||
const normalizedTitle = typeof title === "string" ? title : undefined;
|
const normalizedTitle = typeof title === "string" ? title : undefined;
|
||||||
|
const contentFingerprint = computeContentFingerprint({
|
||||||
|
title: normalizedTitle,
|
||||||
|
description: normalizedDescription,
|
||||||
|
});
|
||||||
const acknowledgedDuplicateIds = acknowledgedDuplicates ?? [];
|
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
|
const duplicateMatches = bypassDuplicateCheck === true
|
||||||
? []
|
? []
|
||||||
: await computeDuplicateMatches(scopedStore, {
|
: await computeDuplicateMatches(scopedStore, {
|
||||||
@@ -418,6 +481,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
throw conflict("duplicate_candidates", { matches: matchesAfterAckFilter });
|
throw conflict("duplicate_candidates", { matches: matchesAfterAckFilter });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedTaskSource = normalizedSource as TaskSource;
|
||||||
const createInput = {
|
const createInput = {
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
description: normalizedDescription,
|
description: normalizedDescription,
|
||||||
@@ -437,17 +501,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
reviewLevel: reviewLevel ?? undefined,
|
reviewLevel: reviewLevel ?? undefined,
|
||||||
executionMode: executionMode || undefined,
|
executionMode: executionMode || undefined,
|
||||||
priority: priority ?? undefined,
|
priority: priority ?? undefined,
|
||||||
source:
|
source: {
|
||||||
acknowledgedDuplicateIds.length > 0
|
...normalizedTaskSource,
|
||||||
? {
|
sourceMetadata: {
|
||||||
...(normalizedSource as Record<string, unknown>),
|
...(normalizedTaskSource.sourceMetadata ?? {}),
|
||||||
sourceMetadata: {
|
...(contentFingerprint ? { contentFingerprint } : {}),
|
||||||
...((normalizedSource as { sourceMetadata?: Record<string, unknown> }).sourceMetadata ?? {}),
|
...(acknowledgedDuplicateIds.length > 0
|
||||||
|
? {
|
||||||
duplicateWarningOverridden: true,
|
duplicateWarningOverridden: true,
|
||||||
acknowledgedDuplicateIds,
|
acknowledgedDuplicateIds,
|
||||||
},
|
}
|
||||||
}
|
: {}),
|
||||||
: normalizedSource,
|
},
|
||||||
|
},
|
||||||
branch: normalizedBranch,
|
branch: normalizedBranch,
|
||||||
baseBranch: normalizedBaseBranch,
|
baseBranch: normalizedBaseBranch,
|
||||||
...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}),
|
...(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 } },
|
{ 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) {
|
if (acknowledgedDuplicateIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
await scopedStore.recordActivity({
|
await scopedStore.recordActivity({
|
||||||
|
|||||||
Reference in New Issue
Block a user