feat(FN-5232): add deterministic verification-followup deduplication to pre
Implements deterministic followup deduplication for the project engine, routing eval and PR followups through a new `verification-followup-dedup` helper that excludes the parent task to prevent self-referential loops, with tests covering eval-followups, merge-error-recovery, PR comments, and the new Fusion-Task-Id: FN-5232
This commit is contained in:
committed by
gsxdsm
parent
e67df21de1
commit
b078a8135e
@@ -2,12 +2,20 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizeEvalFollowUpText } from "@fusion/core";
|
||||
import { materializeEvalFollowUps, normalizeEvalFollowUps, resolveEvalFollowUpPolicyMode } from "../eval-followups.js";
|
||||
|
||||
function makeStore(params: { openTasks?: Array<{ id: string; column: string; title?: string; description: string }>; priorDedupeKeys?: string[] }) {
|
||||
function makeStore(params: {
|
||||
openTasks?: Array<Record<string, unknown>>;
|
||||
priorDedupeKeys?: string[];
|
||||
taskLogsById?: Record<string, Array<{ action: string; timestamp: string }>>;
|
||||
}) {
|
||||
const openTasks = params.openTasks ?? [];
|
||||
const priorDedupeKeys = params.priorDedupeKeys ?? [];
|
||||
const taskLogsById = params.taskLogsById ?? {};
|
||||
return {
|
||||
listTasks: async () => openTasks,
|
||||
createTask: vi.fn(async () => ({ id: "FN-created" })),
|
||||
getTask: vi.fn(async (id: string) => ({ id, log: taskLogsById[id] ?? [] })),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
recordRunAuditEvent: vi.fn(async () => undefined),
|
||||
getEvalStore: () => ({
|
||||
listTaskResults: () => [{ followUps: priorDedupeKeys.map((dedupeKey) => ({ dedupeKey })) }],
|
||||
}),
|
||||
@@ -115,4 +123,46 @@ describe("normalizeEvalFollowUps", () => {
|
||||
expect(created?.state).toBe("created");
|
||||
expect(created?.createdTaskId).toBe("FN-created");
|
||||
});
|
||||
|
||||
it("reuses an existing task when the suggestion id already has an open follow-up", async () => {
|
||||
const store = makeStore({
|
||||
openTasks: [{
|
||||
id: "FN-existing",
|
||||
column: "todo",
|
||||
description: "existing eval follow-up",
|
||||
sourceParentTaskId: "FN-parent",
|
||||
sourceMetadata: { suggestionId: "efs-1" },
|
||||
}],
|
||||
});
|
||||
|
||||
const [created] = await materializeEvalFollowUps({
|
||||
parentTaskId: "FN-parent",
|
||||
runId: "ER-5",
|
||||
policyMode: "create_all_non_duplicates",
|
||||
overallScore: 42,
|
||||
store,
|
||||
followUps: [{
|
||||
suggestionId: "efs-1",
|
||||
dedupeKey: "k",
|
||||
title: "Investigate issue",
|
||||
description: "Investigate issue found by eval.",
|
||||
priority: "high",
|
||||
severity: "weak",
|
||||
rationale: "Signals showed repeated failures.",
|
||||
evidenceRefs: [{ evidenceId: "workflow-1", source: "other" }],
|
||||
recommendation: { shouldCreate: true, reason: "qualified", policyQualified: true },
|
||||
state: "suggested",
|
||||
policyMode: "create_all_non_duplicates",
|
||||
}],
|
||||
});
|
||||
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-existing",
|
||||
expect.stringContaining("[verification recurrence] signature=none"),
|
||||
expect.stringContaining("kind=eval; parentTaskId=FN-parent"),
|
||||
);
|
||||
expect(created?.createdTaskId).toBe("FN-existing");
|
||||
expect(created?.recommendation.reason).toContain("Reused existing follow-up FN-existing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -462,7 +462,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||||
TASK_ID,
|
||||
expect.stringContaining("already owns branch `fusion/fn-2084`"),
|
||||
expect.stringContaining("follow-up already exists (FN-8888)"),
|
||||
"agent",
|
||||
);
|
||||
});
|
||||
@@ -494,10 +494,10 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: {
|
||||
source: expect.objectContaining({
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: TASK_ID,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -558,7 +558,7 @@ describe("ProjectEngine merge error recovery", () => {
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
TASK_ID,
|
||||
expect.stringContaining("active recovery already owns branch"),
|
||||
expect.stringContaining("skipped duplicate follow-up (existing FN-8888)"),
|
||||
"MergeConflictGiveUp",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ const mockStore = {
|
||||
getTask: vi.fn<(id: string) => Promise<Task>>().mockResolvedValue({ id: "FN-001", review: undefined } as Task),
|
||||
updateTask: vi.fn<(id: string, updates: Partial<Task>) => Promise<Task>>().mockResolvedValue({ id: "FN-001" } as Task),
|
||||
createTask: vi.fn<(input: Parameters<TaskStore["createTask"]>[0]) => Promise<Task>>().mockResolvedValue({ id: "FN-123" } as Task),
|
||||
listTasks: vi.fn<() => Promise<Task[]>>().mockResolvedValue([]),
|
||||
logEntry: vi.fn<(id: string, action: string, outcome?: string) => Promise<Task>>().mockResolvedValue({ id: "FN-001" } as Task),
|
||||
recordRunAuditEvent: vi.fn<(event: unknown) => Promise<void>>().mockResolvedValue(),
|
||||
moveTask: vi.fn<(id: string, column: Task["column"]) => Promise<Task>>().mockResolvedValue({ id: "FN-001", column: "in-progress" } as Task),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
@@ -15,6 +18,10 @@ describe("PrCommentHandler", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001", review: undefined } as Task);
|
||||
(mockStore.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(mockStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-123" } as Task);
|
||||
(mockStore.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001" } as Task);
|
||||
handler = new PrCommentHandler(mockStore);
|
||||
});
|
||||
|
||||
@@ -288,5 +295,34 @@ describe("PrCommentHandler", () => {
|
||||
expect(description).toContain("First issue");
|
||||
expect(description).toContain("Second issue");
|
||||
});
|
||||
|
||||
it("reuses an existing PR follow-up when the same parent/prNumber is still open", async () => {
|
||||
(mockStore.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([{
|
||||
id: "FN-existing",
|
||||
column: "todo",
|
||||
description: "existing pr follow-up",
|
||||
sourceParentTaskId: "FN-001",
|
||||
sourceMetadata: { prNumber: 42 },
|
||||
}]);
|
||||
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-existing", log: [] } as unknown as Task);
|
||||
|
||||
await handler.createFollowUpTask("FN-001", mockPrInfo, [
|
||||
{
|
||||
id: 1,
|
||||
body: "This needs fixing",
|
||||
user: { login: "reviewer" },
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockStore.createTask).not.toHaveBeenCalled();
|
||||
expect(mockStore.logEntry).toHaveBeenCalledWith(
|
||||
"FN-existing",
|
||||
expect.stringContaining("[verification recurrence] signature=none"),
|
||||
expect.stringContaining("kind=pr-comment; parentTaskId=FN-001"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import {
|
||||
computeVerificationFailureSignature,
|
||||
createAutomatedFollowup,
|
||||
} from "../../verification-followup-dedup.js";
|
||||
|
||||
async function createStore() {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-verification-followup-dedup-reliability-"));
|
||||
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
|
||||
await store.init();
|
||||
return {
|
||||
store,
|
||||
cleanup: async () => {
|
||||
store.close();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("reliability interactions: verification follow-up dedup", () => {
|
||||
const fixtures: Array<Awaited<ReturnType<typeof createStore>>> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
while (fixtures.length) await fixtures.pop()!.cleanup();
|
||||
});
|
||||
|
||||
it("FN-5224 dedups repeated verification follow-ups to one task and one hourly recurrence log", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const parent = await fx.store.createTask({ description: "parent task" });
|
||||
const signature = computeVerificationFailureSignature({
|
||||
lane: "pnpm test",
|
||||
failingTestFiles: ["packages/dashboard/app/__tests__/verification.test.ts"],
|
||||
}).signature;
|
||||
|
||||
const results = [] as Array<Awaited<ReturnType<typeof createAutomatedFollowup>>>;
|
||||
results.push(await createAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature,
|
||||
createInput: {
|
||||
title: "Investigate repeated verification failure",
|
||||
description: "Investigate repeated verification failure.",
|
||||
column: "triage",
|
||||
source: { sourceType: "recovery", sourceParentTaskId: parent.id },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000);
|
||||
results.push(await createAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature,
|
||||
createInput: {
|
||||
title: "Investigate repeated verification failure",
|
||||
description: "Investigate repeated verification failure.",
|
||||
column: "triage",
|
||||
source: { sourceType: "recovery", sourceParentTaskId: parent.id },
|
||||
},
|
||||
}));
|
||||
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
vi.advanceTimersByTime(9 * 60 * 1000);
|
||||
results.push(await createAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature,
|
||||
createInput: {
|
||||
title: "Investigate repeated verification failure",
|
||||
description: "Investigate repeated verification failure.",
|
||||
column: "triage",
|
||||
source: { sourceType: "recovery", sourceParentTaskId: parent.id },
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
expect(results[0]?.outcome).toBe("created");
|
||||
expect(results.slice(1).every((result) => result.outcome === "deduped")).toBe(true);
|
||||
expect(results.slice(1).map((result) => result.outcome === "deduped" ? result.rateLimited : null)).toEqual([
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
|
||||
const allTasks = await fx.store.listTasks({ slim: true, includeArchived: true });
|
||||
const followups = allTasks.filter((task) => task.sourceParentTaskId === parent.id && task.id !== parent.id);
|
||||
expect(followups).toHaveLength(1);
|
||||
|
||||
const followup = await fx.store.getTask(followups[0]!.id);
|
||||
const recurrenceLogs = followup.log.filter((entry) => entry.action.startsWith("[verification recurrence]"));
|
||||
expect(recurrenceLogs).toHaveLength(1);
|
||||
|
||||
const dedupedAudits = fx.store.getRunAuditEvents({ mutationType: "verification:followup-deduped" });
|
||||
expect(dedupedAudits).toHaveLength(5);
|
||||
expect(dedupedAudits.filter((event) => event.metadata?.rateLimited === true)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("creates a new follow-up that supersedes a recent archived sibling", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const parent = await fx.store.createTask({ description: "parent task" });
|
||||
const signature = computeVerificationFailureSignature({ lane: "pnpm test", failingTestFiles: [] }).signature;
|
||||
|
||||
vi.setSystemTime(new Date("2026-05-18T13:00:00.000Z"));
|
||||
const archived = await fx.store.createTask({
|
||||
description: "old archived follow-up",
|
||||
column: "archived",
|
||||
source: {
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: parent.id,
|
||||
sourceMetadata: { verificationFailureSignature: signature },
|
||||
},
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00.000Z"));
|
||||
const result = await createAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature,
|
||||
createInput: {
|
||||
description: "new follow-up",
|
||||
column: "triage",
|
||||
source: { sourceType: "recovery", sourceParentTaskId: parent.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("created");
|
||||
if (result.outcome === "created") {
|
||||
expect(result.supersedesTaskId).toBe(archived.id);
|
||||
expect(result.task.sourceMetadata?.supersedesTaskId).toBe(archived.id);
|
||||
}
|
||||
|
||||
const audits = fx.store.getRunAuditEvents({ mutationType: "verification:followup-created" });
|
||||
expect(audits.at(-1)?.metadata?.supersedesTaskId).toBe(archived.id);
|
||||
});
|
||||
|
||||
it("does not supersede a done task older than 24 hours", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const parent = await fx.store.createTask({ description: "parent task" });
|
||||
const signature = computeVerificationFailureSignature({ lane: "pnpm test", failingTestFiles: [] }).signature;
|
||||
|
||||
vi.setSystemTime(new Date("2026-05-18T10:59:59.000Z"));
|
||||
await fx.store.createTask({
|
||||
description: "old done follow-up",
|
||||
column: "done",
|
||||
source: {
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: parent.id,
|
||||
sourceMetadata: { verificationFailureSignature: signature },
|
||||
},
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00.000Z"));
|
||||
const result = await createAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature,
|
||||
createInput: {
|
||||
description: "new follow-up",
|
||||
column: "triage",
|
||||
source: { sourceType: "recovery", sourceParentTaskId: parent.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("created");
|
||||
if (result.outcome === "created") {
|
||||
expect(result.supersedesTaskId).toBeUndefined();
|
||||
expect(result.task.sourceMetadata?.supersedesTaskId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps signatures stable across clock changes", () => {
|
||||
const input = { lane: "pnpm test", failingTestFiles: ["packages/engine/src/__tests__/alpha.test.ts"] };
|
||||
const first = computeVerificationFailureSignature(input);
|
||||
vi.advanceTimersByTime(123_456);
|
||||
const second = computeVerificationFailureSignature(input);
|
||||
|
||||
expect(first.signature).toBe(second.signature);
|
||||
});
|
||||
|
||||
it("remains additive with FN-4892 same-agent duplicate intake", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const source = {
|
||||
sourceType: "api" as const,
|
||||
sourceAgentId: "agent-1",
|
||||
sourceParentTaskId: "FN-parent-a",
|
||||
};
|
||||
|
||||
const canonical = await fx.store.createTask({
|
||||
title: "Follow-up: same agent duplicate",
|
||||
description: "Same-agent duplicate description.",
|
||||
source,
|
||||
});
|
||||
|
||||
const result = await createAutomatedFollowup(fx.store, {
|
||||
kind: "pr-comment",
|
||||
parentTaskId: "FN-parent-b",
|
||||
createInput: {
|
||||
title: "Follow-up: same agent duplicate",
|
||||
description: "Same-agent duplicate description.",
|
||||
source: {
|
||||
sourceType: "api",
|
||||
sourceAgentId: "agent-1",
|
||||
sourceParentTaskId: "FN-parent-b",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("created");
|
||||
if (result.outcome === "created") {
|
||||
expect(result.task.column).toBe("archived");
|
||||
}
|
||||
|
||||
const visibleSameAgentTasks = (await fx.store.listTasks({ slim: true, includeArchived: true }))
|
||||
.filter((task) => task.sourceAgentId === "agent-1" && task.column !== "archived");
|
||||
expect(visibleSameAgentTasks.map((task) => task.id)).toEqual([canonical.id]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import {
|
||||
__testing__,
|
||||
computeVerificationFailureSignature,
|
||||
createAutomatedFollowup,
|
||||
decideAutomatedFollowup,
|
||||
extractFailingTestFiles,
|
||||
} from "../verification-followup-dedup.js";
|
||||
|
||||
async function createStore() {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-verification-followup-dedup-"));
|
||||
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
|
||||
await store.init();
|
||||
return {
|
||||
store,
|
||||
cleanup: async () => {
|
||||
store.close();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("verification follow-up dedup", () => {
|
||||
const fixtures: Array<Awaited<ReturnType<typeof createStore>>> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
while (fixtures.length) await fixtures.pop()!.cleanup();
|
||||
});
|
||||
|
||||
it("computes stable signatures from sorted basenames only", () => {
|
||||
const a = computeVerificationFailureSignature({
|
||||
lane: "pnpm --filter @fusion/dashboard test",
|
||||
failingTestFiles: ["packages/dashboard/app/foo.test.tsx", "/tmp/bar.test.ts", "packages/dashboard/app/foo.test.tsx"],
|
||||
failedCommand: "pnpm test --pid=123",
|
||||
});
|
||||
const b = computeVerificationFailureSignature({
|
||||
lane: "pnpm --filter @fusion/dashboard test",
|
||||
failingTestFiles: ["/another/path/bar.test.ts", "packages/dashboard/app/foo.test.tsx"],
|
||||
failedCommand: "pnpm test --pid=999",
|
||||
});
|
||||
|
||||
expect(a.failingBasenames).toEqual(["bar.test.ts", "foo.test.tsx"]);
|
||||
expect(a.signature).toBe(b.signature);
|
||||
});
|
||||
|
||||
it("uses a deterministic lane-only fallback when no files are parsed", () => {
|
||||
const a = computeVerificationFailureSignature({ lane: "merge-conflict", failingTestFiles: [] });
|
||||
const b = computeVerificationFailureSignature({ lane: "merge-conflict", failingTestFiles: [] });
|
||||
const c = computeVerificationFailureSignature({ lane: "autostash-orphan", failingTestFiles: [] });
|
||||
|
||||
expect(a.failingBasenames).toEqual([]);
|
||||
expect(a.signature).toBe(b.signature);
|
||||
expect(a.signature).not.toBe(c.signature);
|
||||
});
|
||||
|
||||
it("extracts failing test file basenames from common runner output", () => {
|
||||
const files = extractFailingTestFiles(
|
||||
[
|
||||
"FAIL packages/engine/src/__tests__/alpha.test.ts",
|
||||
"\u00D7 packages/engine/src/__tests__/beta.test.ts",
|
||||
"\u2716 packages/engine/src/__tests__/gamma.test.ts:12:2",
|
||||
"Error in packages/engine/src/__tests__/delta.test.ts",
|
||||
].join("\n"),
|
||||
"",
|
||||
);
|
||||
|
||||
expect(files).toEqual(["alpha.test.ts", "beta.test.ts", "delta.test.ts", "gamma.test.ts"]);
|
||||
});
|
||||
|
||||
it("ignores timestamps and unrelated command noise when recomputing the same signature", () => {
|
||||
const first = computeVerificationFailureSignature({
|
||||
lane: "pnpm test",
|
||||
failingTestFiles: ["/tmp/worker-123/foo.test.ts"],
|
||||
failedCommand: "pnpm test --reporter dot --pid=123",
|
||||
});
|
||||
vi.advanceTimersByTime(30_000);
|
||||
const second = computeVerificationFailureSignature({
|
||||
lane: "pnpm test",
|
||||
failingTestFiles: ["/var/tmp/worker-999/foo.test.ts"],
|
||||
failedCommand: `pnpm test --reporter dot --pid=${Date.now()}`,
|
||||
});
|
||||
|
||||
expect(first.signature).toBe(second.signature);
|
||||
});
|
||||
|
||||
it("allows a new recurrence exactly one hour later", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const parent = await fx.store.createTask({ description: "parent task" });
|
||||
const followup = await fx.store.createTask({
|
||||
description: "existing follow-up",
|
||||
source: {
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: parent.id,
|
||||
sourceMetadata: { verificationFailureSignature: "sig-1" },
|
||||
},
|
||||
});
|
||||
await fx.store.logEntry(
|
||||
followup.id,
|
||||
`${__testing__.RECURRENCE_LOG_TAG} signature=sig-1`,
|
||||
"kind=verification-failure; parentTaskId=FN-parent",
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(__testing__.RECURRENCE_RATE_LIMIT_MS);
|
||||
|
||||
const decision = await decideAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature: "sig-1",
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ action: "append-log", existingTaskId: followup.id, rateLimited: false });
|
||||
});
|
||||
|
||||
it("rate-limits a recurrence logged within one hour", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const parent = await fx.store.createTask({ description: "parent task" });
|
||||
const followup = await fx.store.createTask({
|
||||
description: "existing follow-up",
|
||||
source: {
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: parent.id,
|
||||
sourceMetadata: { verificationFailureSignature: "sig-1" },
|
||||
},
|
||||
});
|
||||
await fx.store.logEntry(
|
||||
followup.id,
|
||||
`${__testing__.RECURRENCE_LOG_TAG} signature=sig-1`,
|
||||
"kind=verification-failure; parentTaskId=FN-parent",
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(__testing__.RECURRENCE_RATE_LIMIT_MS - 1);
|
||||
|
||||
const decision = await decideAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature: "sig-1",
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ action: "append-log", existingTaskId: followup.id, rateLimited: true });
|
||||
});
|
||||
|
||||
it("dedups extra metadata keys only within the same parent task", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const parentA = await fx.store.createTask({ description: "parent A" });
|
||||
const parentB = await fx.store.createTask({ description: "parent B" });
|
||||
const existing = await fx.store.createTask({
|
||||
description: "existing eval follow-up",
|
||||
source: {
|
||||
sourceType: "automation",
|
||||
sourceParentTaskId: parentA.id,
|
||||
sourceMetadata: { suggestionId: "suggestion-1" },
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await decideAutomatedFollowup(fx.store, {
|
||||
kind: "eval",
|
||||
parentTaskId: parentB.id,
|
||||
extraMatchKeys: { suggestionId: "suggestion-1" },
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
expect(existing.id).toBeDefined();
|
||||
expect(decision).toEqual({ action: "create-new" });
|
||||
});
|
||||
|
||||
it("fails open to direct task creation when dedup decision throws", async () => {
|
||||
const fx = await createStore();
|
||||
fixtures.push(fx);
|
||||
const parent = await fx.store.createTask({ description: "parent task" });
|
||||
vi.spyOn(fx.store, "listTasks").mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const result = await createAutomatedFollowup(fx.store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: parent.id,
|
||||
signature: "sig-1",
|
||||
createInput: {
|
||||
description: "fallback create",
|
||||
source: { sourceType: "recovery", sourceParentTaskId: parent.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("created");
|
||||
if (result.outcome === "created") {
|
||||
expect(result.task.sourceMetadata?.verificationFailureSignature).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user