feat(FN-4918): merge fusion/fn-4918

This commit is contained in:
gsxdsm
2026-05-18 16:23:06 -07:00
parent 0bf89e7813
commit 0046fc92e2
11 changed files with 564 additions and 10 deletions

View 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.

View File

@@ -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:

View File

@@ -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[] = [

View File

@@ -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()

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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"

View File

@@ -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();
});
});

View File

@@ -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" }),

View File

@@ -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(),

View File

@@ -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({