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:
committed by
gsxdsm
parent
e371ce136f
commit
77d4a9b466
59
packages/core/src/__tests__/duplicate-intake.test.ts
Normal file
59
packages/core/src/__tests__/duplicate-intake.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
69
packages/core/src/duplicate-intake.ts
Normal file
69
packages/core/src/duplicate-intake.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user