feat(FN-5060): complete Step 3 — extract core duplicate guard helper

This commit is contained in:
gsxdsm
2026-05-18 16:30:16 -07:00
parent f11695311a
commit 9b88f1a3ed
3 changed files with 377 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
import { describe, expect, it, vi } from "vitest";
import type { Column, Task, TaskStore } from "../types.js";
import {
__getDeterministicGuardMutexSize,
reconcileDeterministicDuplicate,
runDeterministicDuplicateGuard,
} from "../duplicate-guard.js";
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 makeStore(seed: Task[] = []): { tasks: Task[]; store: TaskStore } {
const tasks = [...seed];
const store = {
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 created = mkTask({
id: `FN-${tasks.length + 1}`,
title: input.title,
description: input.description,
column: "todo",
createdAt: now,
updatedAt: now,
source: input.source ?? { sourceType: "api" },
});
tasks.push(created);
return created;
}),
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),
} as unknown as TaskStore;
return { tasks, store };
}
const INPUT = {
title: "Move retry counter badge next to GitHub tracking badge",
description: "Move the retry counter badge to the left of the GitHub tracking badge",
};
describe("runDeterministicDuplicateGuard", () => {
it("returns duplicate when same fingerprint task already exists", async () => {
const existing = mkTask({
id: "FN-1",
title: INPUT.title,
description: INPUT.description,
column: "todo",
source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } },
});
const { store } = makeStore([existing]);
vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([existing]);
const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" });
expect(result.action).toBe("duplicate");
expect(result.existing?.id).toBe("FN-1");
result.releaseLock();
});
it("serializes concurrent calls with same lock scope", async () => {
const { store, tasks } = makeStore();
const first = runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" });
const secondPromise = runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" });
const firstResult = await first;
expect(firstResult.action).toBe("proceed");
const created = await store.createTask({
title: INPUT.title,
description: INPUT.description,
source: { sourceType: "api", sourceMetadata: { contentFingerprint: firstResult.fingerprint ?? undefined } },
});
expect(tasks).toHaveLength(1);
firstResult.releaseLock();
const secondResult = await secondPromise;
expect(secondResult.action).toBe("duplicate");
expect(secondResult.existing?.id).toBe(created.id);
secondResult.releaseLock();
});
it("allows concurrent proceed without shared lock scope", async () => {
const { store } = makeStore();
const [a, b] = await Promise.all([
runDeterministicDuplicateGuard(store, INPUT),
runDeterministicDuplicateGuard(store, INPUT),
]);
expect(a.action).toBe("proceed");
expect(b.action).toBe("proceed");
});
it("allows proceed when duplicate is acknowledged", async () => {
const existing = mkTask({ id: "FN-1", title: INPUT.title, description: INPUT.description, column: "todo", source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } });
const { store } = makeStore([existing]);
vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([existing]);
const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1", acknowledgedDuplicates: ["FN-1"] });
expect(result.action).toBe("proceed");
result.releaseLock();
});
it("bypass skips mutex allocation", async () => {
const { store } = makeStore();
const before = __getDeterministicGuardMutexSize();
const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1", bypass: true });
const after = __getDeterministicGuardMutexSize();
expect(result.action).toBe("proceed");
expect(after).toBe(before);
});
it("ignores rows older than window", async () => {
const oldTs = new Date(Date.now() - 120_000).toISOString();
const existing = mkTask({ id: "FN-1", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: oldTs, updatedAt: oldTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } });
const { store } = makeStore([existing]);
vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([]);
const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1", windowMs: 60_000 });
expect(result.action).toBe("proceed");
result.releaseLock();
});
it("returns null fingerprint for empty description", async () => {
const { store } = makeStore();
const result = await runDeterministicDuplicateGuard(store, { title: "x", description: "..." }, { lockScope: "p-1" });
expect(result.action).toBe("proceed");
expect(result.fingerprint).toBeNull();
});
});
describe("reconcileDeterministicDuplicate", () => {
it("archives late-race loser and records activity metadata", async () => {
const canonicalTs = new Date(Date.now() - 2_000).toISOString();
const createdTs = new Date().toISOString();
const canonical = mkTask({ id: "FN-1", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: canonicalTs, updatedAt: canonicalTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } });
const created = mkTask({ id: "FN-2", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: createdTs, updatedAt: createdTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } });
const { store } = makeStore([canonical, created]);
vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([canonical, created]);
const result = await reconcileDeterministicDuplicate(store, { createdTask: created, fingerprint: "fp" });
expect(result).toEqual({ outcome: "archived", canonical });
expect(store.updateTask).toHaveBeenCalledWith("FN-2", {
sourceMetadataPatch: {
contentFingerprint: "fp",
deterministicDuplicateOf: "FN-1",
},
});
expect(store.moveTask).toHaveBeenCalledWith("FN-2", "archived");
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({
type: "task:auto-archived-deterministic-duplicate",
metadata: { canonicalTaskId: "FN-1", contentFingerprint: "fp" },
}));
});
it("fails open when archive move throws", async () => {
const canonicalTs = new Date(Date.now() - 2_000).toISOString();
const createdTs = new Date().toISOString();
const canonical = mkTask({ id: "FN-1", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: canonicalTs, updatedAt: canonicalTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } });
const created = mkTask({ id: "FN-2", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: createdTs, updatedAt: createdTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } });
const { store } = makeStore([canonical, created]);
vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([canonical, created]);
vi.spyOn(store, "moveTask").mockRejectedValueOnce(new Error("archive failed"));
const warn = vi.fn();
const result = await reconcileDeterministicDuplicate(store, { createdTask: created, fingerprint: "fp", logger: { warn } });
expect(result).toEqual({ outcome: "kept", canonical: created });
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,162 @@
import type { Task, TaskStore } from "./types.js";
import { computeContentFingerprint } from "./duplicate-detection.js";
const DEFAULT_WINDOW_MS = 60_000;
const MAX_WINDOW_MS = 300_000;
const deterministicGuardLocks = new Map<string, Promise<void>>();
export interface DeterministicGuardOptions {
windowMs?: number;
lockScope?: string;
acknowledgedDuplicates?: readonly string[];
bypass?: boolean;
logger?: { warn(msg: string, data?: Record<string, unknown>): void };
}
export interface DeterministicGuardOutcome {
action: "proceed" | "duplicate";
fingerprint: string | null;
existing?: Task;
releaseLock: () => void;
}
export function __getDeterministicGuardMutexSize(): number {
return deterministicGuardLocks.size;
}
function clampWindowMs(windowMs?: number): number {
const requested = windowMs ?? DEFAULT_WINDOW_MS;
return Math.max(1, Math.min(MAX_WINDOW_MS, Math.trunc(requested)));
}
function noop(): void {}
export async function runDeterministicDuplicateGuard(
store: TaskStore,
input: { title?: string | null; description: string },
opts?: DeterministicGuardOptions,
): Promise<DeterministicGuardOutcome> {
const fingerprint = computeContentFingerprint(input);
if (opts?.bypass === true || !fingerprint) {
return { action: "proceed", fingerprint, releaseLock: noop };
}
const acknowledged = new Set(opts?.acknowledgedDuplicates ?? []);
const windowMs = clampWindowMs(opts?.windowMs);
if (!opts?.lockScope) {
try {
const deterministicMatches = await store.findRecentTasksByContentFingerprint(fingerprint, {
windowMs,
includeArchived: false,
});
const deterministicConflict = deterministicMatches.find((match) => !acknowledged.has(match.id));
if (deterministicConflict) {
return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock: noop };
}
} catch (error) {
opts?.logger?.warn("Deterministic duplicate pre-check failed; proceeding", {
error: error instanceof Error ? error.message : String(error),
});
}
return { action: "proceed", fingerprint, releaseLock: noop };
}
const lockKey = `${opts.lockScope}:${fingerprint}`;
const existingLock = deterministicGuardLocks.get(lockKey);
if (existingLock) {
await existingLock;
}
let releaseCalled = false;
let resolveGate: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
resolveGate = resolve;
});
deterministicGuardLocks.set(lockKey, gate);
const releaseLock = () => {
if (releaseCalled) {
return;
}
releaseCalled = true;
resolveGate?.();
deterministicGuardLocks.delete(lockKey);
};
try {
const deterministicMatches = await store.findRecentTasksByContentFingerprint(fingerprint, {
windowMs,
includeArchived: false,
});
const deterministicConflict = deterministicMatches.find((match) => !acknowledged.has(match.id));
if (deterministicConflict) {
return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock };
}
} catch (error) {
opts?.logger?.warn("Deterministic duplicate pre-check failed; proceeding", {
lockKey,
error: error instanceof Error ? error.message : String(error),
});
}
return { action: "proceed", fingerprint, releaseLock };
}
export async function reconcileDeterministicDuplicate(
store: TaskStore,
args: {
createdTask: Task;
fingerprint: string | null;
windowMs?: number;
logger?: { warn(msg: string, data?: Record<string, unknown>): void };
},
): Promise<{ outcome: "kept" | "archived"; canonical: Task }> {
if (!args.fingerprint) {
return { outcome: "kept", canonical: args.createdTask };
}
try {
const siblings = await store.findRecentTasksByContentFingerprint(args.fingerprint, {
windowMs: clampWindowMs(args.windowMs),
includeArchived: false,
});
const olderSibling = siblings.find((sibling) => sibling.id !== args.createdTask.id && sibling.createdAt < args.createdTask.createdAt);
if (!olderSibling) {
return { outcome: "kept", canonical: args.createdTask };
}
await store.updateTask(args.createdTask.id, {
sourceMetadataPatch: {
contentFingerprint: args.fingerprint,
deterministicDuplicateOf: olderSibling.id,
},
});
await store.moveTask(args.createdTask.id, "archived");
try {
await store.recordActivity({
type: "task:auto-archived-deterministic-duplicate",
taskId: args.createdTask.id,
taskTitle: args.createdTask.title,
details: `Auto-archived as deterministic duplicate of ${olderSibling.id}`,
metadata: { canonicalTaskId: olderSibling.id, contentFingerprint: args.fingerprint },
});
} catch (error) {
args.logger?.warn("Failed to record deterministic-duplicate activity", {
taskId: args.createdTask.id,
canonicalTaskId: olderSibling.id,
error: error instanceof Error ? error.message : String(error),
});
}
return { outcome: "archived", canonical: olderSibling };
} catch (error) {
args.logger?.warn("Deterministic duplicate reconciliation failed; keeping created task", {
taskId: args.createdTask.id,
error: error instanceof Error ? error.message : String(error),
});
return { outcome: "kept", canonical: args.createdTask };
}
}

View File

@@ -139,6 +139,13 @@ export {
type DuplicateMatchInput,
} from "./duplicate-detection.js";
export { getTaskDuplicateLineage } from "./duplicate-lineage.js";
export {
__getDeterministicGuardMutexSize,
runDeterministicDuplicateGuard,
reconcileDeterministicDuplicate,
type DeterministicGuardOptions,
type DeterministicGuardOutcome,
} from "./duplicate-guard.js";
export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,