feat(FN-4892): add same-agent duplicate intake helper and store gate

Fusion-Task-Id: FN-4892
Fusion-Task-Lineage: 6f56b468-a827-416d-ba1f-444e1326d613
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 21:58:51 -07:00
committed by gsxdsm
parent e371ce136f
commit 77d4a9b466
4 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { findSameAgentDuplicates } from "../duplicate-intake.js";
describe("findSameAgentDuplicates", () => {
const nowMs = Date.now();
it("returns same-agent high-similarity match within window", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck in secrets sync", description: "promisify scrypt causes typecheck error" },
[{
id: "FN-1",
title: "Fix typecheck in secrets sync",
description: "promisify scrypt causes typecheck error",
column: "todo",
createdAt: nowMs - 60 * 60 * 1000,
sourceAgentId: "agent-x",
}],
{ nowMs },
);
expect(matches[0]?.id).toBe("FN-1");
});
it("filters out entries older than 24h", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck", description: "typecheck error" },
[{ id: "FN-1", title: "Fix typecheck", description: "typecheck error", column: "todo", createdAt: nowMs - 25 * 60 * 60 * 1000, sourceAgentId: "agent-x" }],
{ nowMs },
);
expect(matches).toEqual([]);
});
it("filters out candidates without source agent", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck", description: "typecheck error" },
[{ id: "FN-1", title: "Fix typecheck", description: "typecheck error", column: "todo", createdAt: nowMs - 60 * 1000, sourceAgentId: null }],
{ nowMs },
);
expect(matches).toEqual([]);
});
it("filters archived candidates via duplicate matcher defaults", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck", description: "typecheck error" },
[{ id: "FN-1", title: "Fix typecheck", description: "typecheck error", column: "archived", createdAt: nowMs - 60 * 1000, sourceAgentId: "agent-x" }],
{ nowMs },
);
expect(matches).toEqual([]);
});
it("respects threshold", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix parser", description: "parse errors on sync job" },
[{ id: "FN-1", title: "Refactor dashboard layout", description: "button spacing and css", column: "todo", createdAt: nowMs - 60 * 1000, sourceAgentId: "agent-x" }],
{ nowMs },
);
expect(matches).toEqual([]);
});
});

View File

@@ -0,0 +1,69 @@
import { findDuplicateMatches } from "./duplicate-detection.js";
import type { Column } from "./types.js";
import type { TaskStore } from "./store.js";
export interface SameAgentDuplicateInput {
title?: string | null;
description: string;
}
export interface SameAgentDuplicateCandidate {
id: string;
title: string;
description: string;
column: Column;
createdAt: number;
sourceAgentId: string | null;
}
export interface SameAgentDuplicateMatch {
id: string;
score: number;
}
export function findSameAgentDuplicates(
input: SameAgentDuplicateInput,
candidates: SameAgentDuplicateCandidate[],
opts?: { threshold?: number; nowMs?: number; windowMs?: number },
): SameAgentDuplicateMatch[] {
const threshold = opts?.threshold ?? 0.75;
const nowMs = opts?.nowMs ?? Date.now();
const windowMs = opts?.windowMs ?? 24 * 60 * 60 * 1000;
const cutoff = nowMs - windowMs;
const recent = candidates.filter((candidate) => candidate.createdAt >= cutoff && candidate.sourceAgentId != null);
const matches = findDuplicateMatches(
{ title: input.title ?? undefined, description: input.description },
recent.map((candidate) => ({
id: candidate.id,
title: candidate.title,
description: candidate.description,
column: candidate.column,
})),
{ threshold },
);
return matches.map((match) => ({ id: match.id, score: match.score }));
}
export async function archiveAsSameAgentDuplicate(
store: TaskStore,
taskId: string,
siblingIds: string[],
scores: Record<string, number>,
): Promise<void> {
await store.moveTask(taskId, "archived");
await store.logEntry(
taskId,
"Auto-archived as same-agent duplicate",
`Duplicate of recently-filed sibling task(s): ${siblingIds.join(", ")}`,
);
// FN-4892: store-side intake path does activity-only emission; run-audit requires runId+agentId context from engine callers.
await store.recordActivity({
type: "task:auto-archived-duplicate",
taskId,
details: "Auto-archived as same-agent duplicate during intake",
metadata: { siblingTaskIds: siblingIds, scores },
});
}

View File

@@ -136,6 +136,13 @@ export {
type DuplicateMatch,
type DuplicateMatchInput,
} from "./duplicate-detection.js";
export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,
type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch,
} from "./duplicate-intake.js";
export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary.js";
export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js";
export { aggregateAgentTokenUsage } from "./agent-token-usage.js";

View File

@@ -43,6 +43,7 @@ import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-li
import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
import { detectStalledReview } from "./stalled-review-detector.js";
import { computeRetrySummary } from "./retry-summary.js";
import { archiveAsSameAgentDuplicate, findSameAgentDuplicates } from "./duplicate-intake.js";
import {
detectTaskIdIntegrityAnomalies,
type TaskIdIntegrityReport,
@@ -3275,6 +3276,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
await this._maybeAutoArchiveSameAgentDuplicate(task, input);
this.emit("task:created", task);
if (options?.invokeTaskCreatedHook !== false) {
await this.invokeTaskCreatedHook(task);
@@ -3282,6 +3285,44 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return task;
}
private async _maybeAutoArchiveSameAgentDuplicate(task: Task, input: TaskCreateInput): Promise<void> {
const sourceAgentId = task.sourceAgentId ?? null;
if (!sourceAgentId) return;
try {
const nowMs = Date.now();
const recent = (await this.listTasks({ slim: true, includeArchived: false })).filter((candidate) => {
if (candidate.id === task.id) return false;
if (candidate.sourceAgentId !== sourceAgentId) return false;
const createdMs = Date.parse(candidate.createdAt);
if (Number.isNaN(createdMs)) return false;
return createdMs >= nowMs - 24 * 60 * 60 * 1000;
});
const matches = findSameAgentDuplicates(
{ title: input.title ?? task.title, description: input.description },
recent.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
description: candidate.description,
column: candidate.column,
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId ?? null,
})),
{ nowMs },
);
if (matches.length === 0) return;
const siblingTaskIds = matches.map((match) => match.id);
const scores = Object.fromEntries(matches.map((match) => [match.id, match.score]));
await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores);
task.column = "archived";
} catch (error) {
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
}
}
private async invokeTaskCreatedHook(task: Task): Promise<void> {
const taskCreatedHook = getTaskCreatedHook();
if (!taskCreatedHook) return;