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
5
.changeset/FN-5232-verification-followup-dedup.md
Normal file
5
.changeset/FN-5232-verification-followup-dedup.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add deterministic automated follow-up dedup for verification failures and related engine-created recovery tasks.
|
||||||
@@ -1578,10 +1578,17 @@ The GitHub tracking state listener now attaches to every registered project stor
|
|||||||
- Existing task-scoped surfacing remains: merger warnings still log to `mergerLog.warn` and `store.logEntry` for the active merge task.
|
- Existing task-scoped surfacing remains: merger warnings still log to `mergerLog.warn` and `store.logEntry` for the active merge task.
|
||||||
- New global surfacing adds `merger:autostashOrphans` TaskStore events, engine helpers (`listAutostashOrphans`, `getAutostashDiff`, `applyAutostashBySha`, `dropAutostashBySha`), and dashboard API endpoints under `/api/stash-recovery/*`.
|
- New global surfacing adds `merger:autostashOrphans` TaskStore events, engine helpers (`listAutostashOrphans`, `getAutostashDiff`, `applyAutostashBySha`, `dropAutostashBySha`), and dashboard API endpoints under `/api/stash-recovery/*`.
|
||||||
- `merger:autostashOrphans` records now include provenance fields (`sourcePhase`, `detectedByTaskId`, `detectedAt`) so operators can attribute leftovers to the merge phase and surfacing task/session.
|
- `merger:autostashOrphans` records now include provenance fields (`sourcePhase`, `detectedByTaskId`, `detectedAt`) so operators can attribute leftovers to the merge phase and surfacing task/session.
|
||||||
- `ProjectEngine` consumes the orphan event stream and auto-creates deduplicated `sourceType: "recovery"` follow-up tasks keyed by `sourceParentTaskId` for live leftovers, so repeated detections do not spam the board.
|
- `ProjectEngine` consumes the orphan event stream and auto-creates deduplicated `sourceType: "recovery"` follow-up tasks for live leftovers, so repeated detections do not spam the board.
|
||||||
- Dashboard operators can inspect orphan counts, review diffs, apply stashes, and explicitly drop entries with confirmation.
|
- Dashboard operators can inspect orphan counts, review diffs, apply stashes, and explicitly drop entries with confirmation.
|
||||||
- Decision: recovery stays user-gated. Auto-apply was rejected because clean-tree checks are racy, stash placement is ambiguous after source task merge, and apply conflicts can produce hard-to-untangle state. `sweepAutostashOrphans` continues to auto-drop only subsumed entries while preserving live developer work.
|
- Decision: recovery stays user-gated. Auto-apply was rejected because clean-tree checks are racy, stash placement is ambiguous after source task merge, and apply conflicts can produce hard-to-untangle state. `sweepAutostashOrphans` continues to auto-drop only subsumed entries while preserving live developer work.
|
||||||
|
|
||||||
|
#### Automated follow-up dedup (FN-5232)
|
||||||
|
- Engine-side automated follow-up creation now routes through `packages/engine/src/verification-followup-dedup.ts` instead of calling `TaskStore.createTask()` directly from recovery/eval/PR-comment paths.
|
||||||
|
- Verification-style follow-ups stamp `sourceMetadata.verificationFailureSignature`, a deterministic SHA-256 digest over `{ lane, sorted failing test basenames }` (or `lane|no-files` when no files can be parsed). Open matches reuse the existing task and append at most one `[verification recurrence]` log entry per hour; closed/done/archived matches within 24 hours create a fresh task with `sourceMetadata.supersedesTaskId` pointing at the prior task.
|
||||||
|
- Non-verification automated follow-ups can supply `extraMatchKeys` (for example eval `suggestionId` or PR `prNumber`) so dedup stays deterministic even when no test-file signature exists.
|
||||||
|
- This layer composes with FN-4892 same-agent intake dedup in `@fusion/core`: engine dedup prevents repeated automated recovery spam up front, while store-side same-agent dedup still archives newly-created near-duplicates when `sourceAgentId` is present.
|
||||||
|
- Run-audit emits `verification:followup-created` and `verification:followup-deduped` database events with hashed signature metadata only; no raw stdout/stderr or secret material is persisted in the audit payload.
|
||||||
|
|
||||||
### Conflict handling
|
### Conflict handling
|
||||||
`merger.ts` includes conflict classification and auto-resolution helpers:
|
`merger.ts` includes conflict classification and auto-resolution helpers:
|
||||||
- lock files (`LOCKFILE_PATTERNS`)
|
- lock files (`LOCKFILE_PATTERNS`)
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { normalizeEvalFollowUpText } from "@fusion/core";
|
import { normalizeEvalFollowUpText } from "@fusion/core";
|
||||||
import { materializeEvalFollowUps, normalizeEvalFollowUps, resolveEvalFollowUpPolicyMode } from "../eval-followups.js";
|
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 openTasks = params.openTasks ?? [];
|
||||||
const priorDedupeKeys = params.priorDedupeKeys ?? [];
|
const priorDedupeKeys = params.priorDedupeKeys ?? [];
|
||||||
|
const taskLogsById = params.taskLogsById ?? {};
|
||||||
return {
|
return {
|
||||||
listTasks: async () => openTasks,
|
listTasks: async () => openTasks,
|
||||||
createTask: vi.fn(async () => ({ id: "FN-created" })),
|
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: () => ({
|
getEvalStore: () => ({
|
||||||
listTaskResults: () => [{ followUps: priorDedupeKeys.map((dedupeKey) => ({ dedupeKey })) }],
|
listTaskResults: () => [{ followUps: priorDedupeKeys.map((dedupeKey) => ({ dedupeKey })) }],
|
||||||
}),
|
}),
|
||||||
@@ -115,4 +123,46 @@ describe("normalizeEvalFollowUps", () => {
|
|||||||
expect(created?.state).toBe("created");
|
expect(created?.state).toBe("created");
|
||||||
expect(created?.createdTaskId).toBe("FN-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.createTask).not.toHaveBeenCalled();
|
||||||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||||||
TASK_ID,
|
TASK_ID,
|
||||||
expect.stringContaining("already owns branch `fusion/fn-2084`"),
|
expect.stringContaining("follow-up already exists (FN-8888)"),
|
||||||
"agent",
|
"agent",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -494,10 +494,10 @@ describe("ProjectEngine merge error recovery", () => {
|
|||||||
|
|
||||||
expect(store.createTask).toHaveBeenCalledWith(
|
expect(store.createTask).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: {
|
source: expect.objectContaining({
|
||||||
sourceType: "recovery",
|
sourceType: "recovery",
|
||||||
sourceParentTaskId: TASK_ID,
|
sourceParentTaskId: TASK_ID,
|
||||||
},
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -558,7 +558,7 @@ describe("ProjectEngine merge error recovery", () => {
|
|||||||
expect(store.createTask).not.toHaveBeenCalled();
|
expect(store.createTask).not.toHaveBeenCalled();
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
TASK_ID,
|
TASK_ID,
|
||||||
expect.stringContaining("active recovery already owns branch"),
|
expect.stringContaining("skipped duplicate follow-up (existing FN-8888)"),
|
||||||
"MergeConflictGiveUp",
|
"MergeConflictGiveUp",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const mockStore = {
|
|||||||
getTask: vi.fn<(id: string) => Promise<Task>>().mockResolvedValue({ id: "FN-001", review: undefined } as Task),
|
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),
|
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),
|
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),
|
moveTask: vi.fn<(id: string, column: Task["column"]) => Promise<Task>>().mockResolvedValue({ id: "FN-001", column: "in-progress" } as Task),
|
||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
@@ -15,6 +18,10 @@ describe("PrCommentHandler", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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);
|
handler = new PrCommentHandler(mockStore);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,5 +295,34 @@ describe("PrCommentHandler", () => {
|
|||||||
expect(description).toContain("First issue");
|
expect(description).toContain("First issue");
|
||||||
expect(description).toContain("Second 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type FollowUpDraft,
|
type FollowUpDraft,
|
||||||
type TaskStore,
|
type TaskStore,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
import { createAutomatedFollowup } from "./verification-followup-dedup.js";
|
||||||
|
|
||||||
const OPEN_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
|
const OPEN_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||||
const GENERIC_TITLE_PATTERNS = [/^follow\s*-?up$/i, /^todo$/i, /^fix\s+issue$/i, /^improve\s+task$/i, /^investigate$/i];
|
const GENERIC_TITLE_PATTERNS = [/^follow\s*-?up$/i, /^todo$/i, /^fix\s+issue$/i, /^improve\s+task$/i, /^investigate$/i];
|
||||||
@@ -187,39 +188,47 @@ export async function materializeEvalFollowUps(input: MaterializeEvalFollowUpsIn
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdTask = await store.createTask({
|
const result = await createAutomatedFollowup(store, {
|
||||||
title: followUp.title,
|
kind: "eval",
|
||||||
description: [
|
parentTaskId,
|
||||||
`Follow-up generated from evaluation run ${runId} for ${parentTaskId}.`,
|
extraMatchKeys: { suggestionId: followUp.suggestionId },
|
||||||
"",
|
createInput: {
|
||||||
`Problem summary: ${followUp.description}`,
|
title: followUp.title,
|
||||||
"Expected outcome: Investigate and resolve the issue identified by evaluation findings.",
|
description: [
|
||||||
`Eval severity/score: ${followUp.severity} (${overallScore})`,
|
`Follow-up generated from evaluation run ${runId} for ${parentTaskId}.`,
|
||||||
`Rationale: ${followUp.rationale}`,
|
"",
|
||||||
`Evidence refs: ${followUp.evidenceRefs.map((ref) => ref.evidenceId).join(", ") || "none"}`,
|
`Problem summary: ${followUp.description}`,
|
||||||
].join("\n"),
|
"Expected outcome: Investigate and resolve the issue identified by evaluation findings.",
|
||||||
column: "triage",
|
`Eval severity/score: ${followUp.severity} (${overallScore})`,
|
||||||
priority: followUp.priority,
|
`Rationale: ${followUp.rationale}`,
|
||||||
source: {
|
`Evidence refs: ${followUp.evidenceRefs.map((ref) => ref.evidenceId).join(", ") || "none"}`,
|
||||||
sourceType: "automation",
|
].join("\n"),
|
||||||
sourceParentTaskId: parentTaskId,
|
column: "triage",
|
||||||
sourceMetadata: {
|
priority: followUp.priority,
|
||||||
type: "eval_follow_up",
|
source: {
|
||||||
runId,
|
sourceType: "automation",
|
||||||
suggestionId: followUp.suggestionId,
|
sourceParentTaskId: parentTaskId,
|
||||||
policyMode,
|
sourceMetadata: {
|
||||||
dedupeKey: followUp.dedupeKey,
|
type: "eval_follow_up",
|
||||||
|
runId,
|
||||||
|
suggestionId: followUp.suggestionId,
|
||||||
|
policyMode,
|
||||||
|
dedupeKey: followUp.dedupeKey,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createdTaskId = result.outcome === "created" ? result.task.id : result.existingTaskId;
|
||||||
created.push({
|
created.push({
|
||||||
...followUp,
|
...followUp,
|
||||||
state: "created",
|
state: "created",
|
||||||
createdTaskId: createdTask.id,
|
createdTaskId,
|
||||||
recommendation: {
|
recommendation: {
|
||||||
...followUp.recommendation,
|
...followUp.recommendation,
|
||||||
reason: `Created as ${createdTask.id} by follow-up policy`,
|
reason: result.outcome === "created"
|
||||||
|
? `Created as ${createdTaskId} by follow-up policy`
|
||||||
|
: `Reused existing follow-up ${createdTaskId} by follow-up policy`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
import type { PrInfo } from "@fusion/core";
|
import type { PrInfo } from "@fusion/core";
|
||||||
import { prMonitorLog } from "./logger.js";
|
import { prMonitorLog } from "./logger.js";
|
||||||
|
import { createAutomatedFollowup } from "./verification-followup-dedup.js";
|
||||||
|
|
||||||
interface PrComment {
|
interface PrComment {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -228,19 +229,28 @@ ${summary}
|
|||||||
Please review the PR comments and address any remaining issues.`;
|
Please review the PR comments and address any remaining issues.`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const task = await this.store.createTask({
|
const result = await createAutomatedFollowup(this.store, {
|
||||||
title: `Follow-up: Address PR #${prInfo.number} feedback`,
|
kind: "pr-comment",
|
||||||
description,
|
parentTaskId: originalTaskId,
|
||||||
column: "triage",
|
extraMatchKeys: { prNumber: prInfo.number },
|
||||||
dependencies: [originalTaskId],
|
createInput: {
|
||||||
source: {
|
title: `Follow-up: Address PR #${prInfo.number} feedback`,
|
||||||
sourceType: "api",
|
description,
|
||||||
sourceParentTaskId: originalTaskId,
|
column: "triage",
|
||||||
sourceMetadata: { prNumber: prInfo.number, prUrl: prInfo.url },
|
dependencies: [originalTaskId],
|
||||||
|
source: {
|
||||||
|
sourceType: "api",
|
||||||
|
sourceParentTaskId: originalTaskId,
|
||||||
|
sourceMetadata: { prNumber: prInfo.number, prUrl: prInfo.url },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
prMonitorLog.log(`Created follow-up task ${task.id} for PR #${prInfo.number}`);
|
if (result.outcome === "created") {
|
||||||
|
prMonitorLog.log(`Created follow-up task ${result.task.id} for PR #${prInfo.number}`);
|
||||||
|
} else {
|
||||||
|
prMonitorLog.log(`Reused follow-up task ${result.existingTaskId} for PR #${prInfo.number}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
prMonitorLog.error(`Failed to create follow-up task:`, err);
|
prMonitorLog.error(`Failed to create follow-up task:`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import { ResearchOrchestrator } from "./research-orchestrator.js";
|
|||||||
import { ResearchRunDispatcher } from "./research-dispatcher.js";
|
import { ResearchRunDispatcher } from "./research-dispatcher.js";
|
||||||
import { ResearchStepRunner } from "./research-step-runner.js";
|
import { ResearchStepRunner } from "./research-step-runner.js";
|
||||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||||
|
import {
|
||||||
|
computeVerificationFailureSignature,
|
||||||
|
createAutomatedFollowup,
|
||||||
|
extractFailingTestFiles,
|
||||||
|
} from "./verification-followup-dedup.js";
|
||||||
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
|
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
|
||||||
import type {
|
import type {
|
||||||
ExternalTunnelInfo,
|
ExternalTunnelInfo,
|
||||||
@@ -89,6 +94,21 @@ function isInvalidDoneTransitionError(error: unknown): boolean {
|
|||||||
return message.includes("Invalid transition:") && message.includes("→ 'done'");
|
return message.includes("Invalid transition:") && message.includes("→ 'done'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildVerificationFailureSignature(error: VerificationError): string {
|
||||||
|
const commandResult = error.verificationResult.testResult ?? error.verificationResult.buildResult;
|
||||||
|
const lane = commandResult?.command?.trim()
|
||||||
|
|| error.verificationResult.failedCommand?.trim()
|
||||||
|
|| "verification-failure";
|
||||||
|
const failingTestFiles = commandResult
|
||||||
|
? extractFailingTestFiles(commandResult.stdout, commandResult.stderr)
|
||||||
|
: [];
|
||||||
|
return computeVerificationFailureSignature({
|
||||||
|
lane,
|
||||||
|
failingTestFiles,
|
||||||
|
failedCommand: commandResult?.command ?? error.verificationResult.failedCommand ?? null,
|
||||||
|
}).signature;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutomationSubsystemHealth {
|
export interface AutomationSubsystemHealth {
|
||||||
status: "not-initialized" | "initializing" | "ready" | "degraded";
|
status: "not-initialized" | "initializing" | "ready" | "degraded";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1700,23 +1720,17 @@ export class ProjectEngine {
|
|||||||
`Investigate repeated ${failedKind} verification failure on ${taskId} (${taskOnErr.title || "untitled"}). ` +
|
`Investigate repeated ${failedKind} verification failure on ${taskId} (${taskOnErr.title || "untitled"}). ` +
|
||||||
`Auto-merge attempted to fix and re-verify ${nextBounces} times without success — likely a flaky test or unrelated regression rather than a fix this task can produce on its own. ` +
|
`Auto-merge attempted to fix and re-verify ${nextBounces} times without success — likely a flaky test or unrelated regression rather than a fix this task can produce on its own. ` +
|
||||||
`Look at the most recent [verification] log entries on ${taskId} for the failing command and output, then either fix the underlying issue or quarantine the flake.`;
|
`Look at the most recent [verification] log entries on ${taskId} for the failing command and output, then either fix the underlying issue or quarantine the flake.`;
|
||||||
const existingFollowUp = await this.findActiveRecoveryFollowUp(store, taskId);
|
const verificationAuditor = createRunAuditor(store, {
|
||||||
if (existingFollowUp) {
|
runId: generateSyntheticRunId("auto-merge", taskId),
|
||||||
await store.addTaskComment(
|
agentId: "auto-merge",
|
||||||
taskId,
|
taskId,
|
||||||
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Reusing existing follow-up ${existingFollowUp.task.id}.`,
|
phase: "merge",
|
||||||
"agent",
|
});
|
||||||
);
|
const followUpResult = await createAutomatedFollowup(store, {
|
||||||
await store.logEntry(
|
kind: "verification-failure",
|
||||||
taskId,
|
parentTaskId: taskId,
|
||||||
`Auto-merge gave up after ${nextBounces} verification-failure bounces — skipped creating duplicate follow-up (existing ${existingFollowUp.task.id})`,
|
signature: err instanceof VerificationError ? buildVerificationFailureSignature(err) : undefined,
|
||||||
"VerificationError",
|
createInput: {
|
||||||
);
|
|
||||||
runtimeLog.warn(
|
|
||||||
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — skipped duplicate follow-up (existing ${existingFollowUp.task.id})`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const followUp = await store.createTask({
|
|
||||||
description: followUpDescription,
|
description: followUpDescription,
|
||||||
column: "triage",
|
column: "triage",
|
||||||
priority: "high",
|
priority: "high",
|
||||||
@@ -1724,19 +1738,36 @@ export class ProjectEngine {
|
|||||||
sourceType: "recovery",
|
sourceType: "recovery",
|
||||||
sourceParentTaskId: taskId,
|
sourceParentTaskId: taskId,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
auditor: verificationAuditor,
|
||||||
|
});
|
||||||
|
if (followUpResult.outcome === "deduped") {
|
||||||
await store.addTaskComment(
|
await store.addTaskComment(
|
||||||
taskId,
|
taskId,
|
||||||
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Created follow-up ${followUp.id} to investigate.`,
|
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Reusing existing follow-up ${followUpResult.existingTaskId}.`,
|
||||||
"agent",
|
"agent",
|
||||||
);
|
);
|
||||||
await store.logEntry(
|
await store.logEntry(
|
||||||
taskId,
|
taskId,
|
||||||
`Auto-merge gave up after ${nextBounces} verification-failure bounces — created follow-up ${followUp.id}`,
|
`Auto-merge gave up after ${nextBounces} verification-failure bounces — skipped creating duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||||
"VerificationError",
|
"VerificationError",
|
||||||
);
|
);
|
||||||
runtimeLog.warn(
|
runtimeLog.warn(
|
||||||
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and created follow-up ${followUp.id}`,
|
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await store.addTaskComment(
|
||||||
|
taskId,
|
||||||
|
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Created follow-up ${followUpResult.task.id} to investigate.`,
|
||||||
|
"agent",
|
||||||
|
);
|
||||||
|
await store.logEntry(
|
||||||
|
taskId,
|
||||||
|
`Auto-merge gave up after ${nextBounces} verification-failure bounces — created follow-up ${followUpResult.task.id}`,
|
||||||
|
"VerificationError",
|
||||||
|
);
|
||||||
|
runtimeLog.warn(
|
||||||
|
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and created follow-up ${followUpResult.task.id}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (followUpErr) {
|
} catch (followUpErr) {
|
||||||
@@ -1887,31 +1918,15 @@ export class ProjectEngine {
|
|||||||
// auto-resolve is just disabled, the user is presumed to
|
// auto-resolve is just disabled, the user is presumed to
|
||||||
// be handling merges manually and a follow-up is noise.
|
// be handling merges manually and a follow-up is noise.
|
||||||
try {
|
try {
|
||||||
const existingFollowUp = await this.findActiveRecoveryFollowUp(
|
const followUpResult = await createAutomatedFollowup(store, {
|
||||||
store,
|
kind: "merge-conflict",
|
||||||
taskId,
|
parentTaskId: taskId,
|
||||||
taskOnErr.branch,
|
branch: taskOnErr.branch,
|
||||||
);
|
signature: computeVerificationFailureSignature({
|
||||||
if (existingFollowUp) {
|
lane: "merge-conflict",
|
||||||
const dedupReason =
|
failingTestFiles: [],
|
||||||
existingFollowUp.reason === "branch"
|
}).signature,
|
||||||
? `active recovery already owns branch \`${taskOnErr.branch ?? "?"}\``
|
createInput: {
|
||||||
: "active recovery already exists for this parent task";
|
|
||||||
await store.addTaskComment(
|
|
||||||
taskId,
|
|
||||||
`Auto-merge recovery follow-up already exists (${existingFollowUp.task.id}; ${dedupReason}). Skipping duplicate follow-up creation.`,
|
|
||||||
"agent",
|
|
||||||
);
|
|
||||||
await store.logEntry(
|
|
||||||
taskId,
|
|
||||||
`Auto-merge conflict recovery skipped duplicate follow-up (existing ${existingFollowUp.task.id}; ${dedupReason})`,
|
|
||||||
"MergeConflictGiveUp",
|
|
||||||
);
|
|
||||||
runtimeLog.warn(
|
|
||||||
`Auto-merge: ${taskId} conflict give-up skipped duplicate follow-up (existing ${existingFollowUp.task.id}; reason=${existingFollowUp.reason})`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const followUp = await store.createTask({
|
|
||||||
description:
|
description:
|
||||||
`Resolve auto-merge conflict on ${taskId} (${taskOnErr.title || "untitled"}). ` +
|
`Resolve auto-merge conflict on ${taskId} (${taskOnErr.title || "untitled"}). ` +
|
||||||
`Auto-merge attempted to rebase + resolve ${nextBounces - 1} times against main and exhausted retries each pass. ` +
|
`Auto-merge attempted to rebase + resolve ${nextBounces - 1} times against main and exhausted retries each pass. ` +
|
||||||
@@ -1923,10 +1938,32 @@ export class ProjectEngine {
|
|||||||
sourceType: "recovery",
|
sourceType: "recovery",
|
||||||
sourceParentTaskId: taskId,
|
sourceParentTaskId: taskId,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
auditor: createRunAuditor(store, {
|
||||||
|
runId: generateSyntheticRunId("auto-merge", taskId),
|
||||||
|
agentId: "auto-merge",
|
||||||
|
taskId,
|
||||||
|
phase: "merge",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (followUpResult.outcome === "deduped") {
|
||||||
await store.addTaskComment(
|
await store.addTaskComment(
|
||||||
taskId,
|
taskId,
|
||||||
`Created follow-up ${followUp.id} to track manual conflict resolution.`,
|
`Auto-merge recovery follow-up already exists (${followUpResult.existingTaskId}). Skipping duplicate follow-up creation.`,
|
||||||
|
"agent",
|
||||||
|
);
|
||||||
|
await store.logEntry(
|
||||||
|
taskId,
|
||||||
|
`Auto-merge conflict recovery skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||||
|
"MergeConflictGiveUp",
|
||||||
|
);
|
||||||
|
runtimeLog.warn(
|
||||||
|
`Auto-merge: ${taskId} conflict give-up skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await store.addTaskComment(
|
||||||
|
taskId,
|
||||||
|
`Created follow-up ${followUpResult.task.id} to track manual conflict resolution.`,
|
||||||
"agent",
|
"agent",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2110,20 +2147,36 @@ export class ProjectEngine {
|
|||||||
const parentTaskId = record.sourceTaskId;
|
const parentTaskId = record.sourceTaskId;
|
||||||
if (!parentTaskId) continue;
|
if (!parentTaskId) continue;
|
||||||
try {
|
try {
|
||||||
const existingFollowUp = await this.findActiveRecoveryFollowUp(store, parentTaskId);
|
|
||||||
if (existingFollowUp) continue;
|
|
||||||
const sourcePhase = record.sourcePhase ?? "unknown";
|
const sourcePhase = record.sourcePhase ?? "unknown";
|
||||||
await store.createTask({
|
const followUpResult = await createAutomatedFollowup(store, {
|
||||||
description:
|
kind: "autostash-orphan",
|
||||||
`Investigate preserved merger autostash leftover from ${parentTaskId} (${record.sha.slice(0, 7)}). ` +
|
parentTaskId,
|
||||||
`Detected by ${record.detectedByTaskId ?? "merge sweep"} during ${sourcePhase}; ` +
|
signature: computeVerificationFailureSignature({
|
||||||
`stash label: ${record.label}. Recover from stash-recovery before dropping.`,
|
lane: "autostash-orphan",
|
||||||
sourceType: "recovery",
|
failingTestFiles: [],
|
||||||
sourceParentTaskId: parentTaskId,
|
}).signature,
|
||||||
} as any);
|
createInput: {
|
||||||
|
description:
|
||||||
|
`Investigate preserved merger autostash leftover from ${parentTaskId} (${record.sha.slice(0, 7)}). ` +
|
||||||
|
`Detected by ${record.detectedByTaskId ?? "merge sweep"} during ${sourcePhase}; ` +
|
||||||
|
`stash label: ${record.label}. Recover from stash-recovery before dropping.`,
|
||||||
|
source: {
|
||||||
|
sourceType: "recovery",
|
||||||
|
sourceParentTaskId: parentTaskId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auditor: createRunAuditor(store, {
|
||||||
|
runId: generateSyntheticRunId("auto-merge", parentTaskId),
|
||||||
|
agentId: "auto-merge",
|
||||||
|
taskId: parentTaskId,
|
||||||
|
phase: "merge",
|
||||||
|
}),
|
||||||
|
});
|
||||||
await store.logEntry(
|
await store.logEntry(
|
||||||
parentTaskId,
|
parentTaskId,
|
||||||
`Auto-created recovery follow-up for live autostash orphan ${record.sha.slice(0, 7)}`,
|
followUpResult.outcome === "deduped"
|
||||||
|
? `Auto-detected live autostash orphan ${record.sha.slice(0, 7)} — reused follow-up ${followUpResult.existingTaskId}`
|
||||||
|
: `Auto-created recovery follow-up ${followUpResult.task.id} for live autostash orphan ${record.sha.slice(0, 7)}`,
|
||||||
`detectedBy=${record.detectedByTaskId ?? "unknown"}; phase=${sourcePhase}; stash=${record.label}`,
|
`detectedBy=${record.detectedByTaskId ?? "unknown"}; phase=${sourcePhase}; stash=${record.label}`,
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ export type DatabaseMutationType =
|
|||||||
| "task:auto-merge-skipped-already-done"
|
| "task:auto-merge-skipped-already-done"
|
||||||
/** Metadata: { taskId, commitSha, failedCommand, exitCode, errorTail } */
|
/** Metadata: { taskId, commitSha, failedCommand, exitCode, errorTail } */
|
||||||
| "task:post-finalize-verification-no-op"
|
| "task:post-finalize-verification-no-op"
|
||||||
|
/** Metadata: { kind, parentTaskId, existingTaskId, signature, rateLimited } */
|
||||||
|
| "verification:followup-deduped"
|
||||||
|
/** Metadata: { kind, parentTaskId, newTaskId, signature, supersedesTaskId } */
|
||||||
|
| "verification:followup-created"
|
||||||
| "task:auto-recover-branch-misbound"
|
| "task:auto-recover-branch-misbound"
|
||||||
| "task:auto-recover-misrouted-foreign-commit"
|
| "task:auto-recover-misrouted-foreign-commit"
|
||||||
| "task:auto-recover-foreign-only-contamination"
|
| "task:auto-recover-foreign-only-contamination"
|
||||||
|
|||||||
289
packages/engine/src/verification-followup-dedup.ts
Normal file
289
packages/engine/src/verification-followup-dedup.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import type { Task, TaskCreateInput, TaskStore } from "@fusion/core";
|
||||||
|
import { basename } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { runtimeLog } from "./logger.js";
|
||||||
|
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
|
||||||
|
|
||||||
|
const RECURRENCE_LOG_TAG = "[verification recurrence]";
|
||||||
|
const RECURRENCE_RATE_LIMIT_MS = 60 * 60 * 1000;
|
||||||
|
const SUPERSEDES_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const CLOSED_COLUMNS = new Set(["done", "archived"]);
|
||||||
|
|
||||||
|
export type VerificationFailureSignatureInput = {
|
||||||
|
lane: string;
|
||||||
|
failingTestFiles: string[];
|
||||||
|
failedCommand?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutomatedFollowupKind =
|
||||||
|
| "verification-failure"
|
||||||
|
| "merge-conflict"
|
||||||
|
| "autostash-orphan"
|
||||||
|
| "eval"
|
||||||
|
| "pr-comment"
|
||||||
|
| "scope-leak"
|
||||||
|
| "contamination";
|
||||||
|
|
||||||
|
export type FollowupDedupDecision =
|
||||||
|
| { action: "create-new"; supersedesTaskId?: string }
|
||||||
|
| { action: "append-log"; existingTaskId: string; rateLimited: boolean };
|
||||||
|
|
||||||
|
export function computeVerificationFailureSignature(input: VerificationFailureSignatureInput): {
|
||||||
|
signature: string;
|
||||||
|
failingBasenames: string[];
|
||||||
|
lane: string;
|
||||||
|
} {
|
||||||
|
const lane = input.lane.trim();
|
||||||
|
const failingBasenames = [...new Set(input.failingTestFiles.map((file) => basename(file.trim())).filter(Boolean))].sort();
|
||||||
|
const signatureSource = failingBasenames.length > 0
|
||||||
|
? JSON.stringify({ lane, files: failingBasenames })
|
||||||
|
: `${lane}|no-files`;
|
||||||
|
const signature = createHash("sha256").update(signatureSource).digest("hex");
|
||||||
|
return { signature, failingBasenames, lane };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractFailingTestFiles(stdout: string, stderr: string): string[] {
|
||||||
|
const text = `${stdout}\n${stderr}`;
|
||||||
|
const files = new Set<string>();
|
||||||
|
const patterns = [
|
||||||
|
/^FAIL\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
|
||||||
|
/^[\u00D7\u2716]\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
|
||||||
|
/^Error in\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
for (const match of text.matchAll(pattern)) {
|
||||||
|
const candidate = normalizeMatchedPath(match[1]);
|
||||||
|
if (candidate) files.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...files].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatchedPath(input: string | undefined): string | null {
|
||||||
|
if (!input) return null;
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const firstToken = trimmed.split(/\s+/)[0] ?? "";
|
||||||
|
const withoutDecorators = firstToken
|
||||||
|
.replace(/^\(+/, "")
|
||||||
|
.replace(/\)+$/, "")
|
||||||
|
.replace(/^['"`]/, "")
|
||||||
|
.replace(/['"`:,;]+$/, "");
|
||||||
|
if (!withoutDecorators || !/[\\/]|\.[cm]?[jt]sx?$/.test(withoutDecorators)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return basename(withoutDecorators);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataMatches(
|
||||||
|
task: Task,
|
||||||
|
parentTaskId: string,
|
||||||
|
extraMatchKeys: Record<string, string | number> | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!extraMatchKeys || Object.keys(extraMatchKeys).length === 0) return false;
|
||||||
|
if (task.sourceParentTaskId !== parentTaskId) return false;
|
||||||
|
const metadata = task.sourceMetadata;
|
||||||
|
if (!metadata) return false;
|
||||||
|
return Object.entries(extraMatchKeys).every(([key, value]) => metadata[key] === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVerificationSignature(task: Task): string | undefined {
|
||||||
|
const signature = task.sourceMetadata?.verificationFailureSignature;
|
||||||
|
return typeof signature === "string" && signature.trim().length > 0 ? signature : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultAuditor(store: TaskStore, parentTaskId: string): RunAuditor {
|
||||||
|
return createRunAuditor(store, {
|
||||||
|
runId: generateSyntheticRunId("followup-dedup", parentTaskId),
|
||||||
|
agentId: "automated-followup",
|
||||||
|
taskId: parentTaskId,
|
||||||
|
phase: "followup-dedup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRateLimited(store: TaskStore, taskId: string, now: number): Promise<boolean> {
|
||||||
|
const fullTask = await store.getTask(taskId);
|
||||||
|
for (let index = fullTask.log.length - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = fullTask.log[index];
|
||||||
|
if (!entry?.action?.startsWith(RECURRENCE_LOG_TAG)) continue;
|
||||||
|
const entryMs = Date.parse(entry.timestamp);
|
||||||
|
if (Number.isNaN(entryMs)) return false;
|
||||||
|
return entryMs > now - RECURRENCE_RATE_LIMIT_MS;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decideAutomatedFollowup(
|
||||||
|
store: TaskStore,
|
||||||
|
params: {
|
||||||
|
kind: AutomatedFollowupKind;
|
||||||
|
parentTaskId: string;
|
||||||
|
signature?: string;
|
||||||
|
branch?: string | null;
|
||||||
|
extraMatchKeys?: Record<string, string | number>;
|
||||||
|
now?: number;
|
||||||
|
},
|
||||||
|
): Promise<FollowupDedupDecision> {
|
||||||
|
const now = params.now ?? Date.now();
|
||||||
|
const tasks = await store.listTasks({ slim: true, includeArchived: true });
|
||||||
|
const candidateTasks = tasks.filter((task) => task.id !== params.parentTaskId);
|
||||||
|
const openTasks = candidateTasks.filter((task) => !CLOSED_COLUMNS.has(task.column));
|
||||||
|
|
||||||
|
const signatureMatch = params.signature
|
||||||
|
? openTasks.find((task) => getVerificationSignature(task) === params.signature)
|
||||||
|
: undefined;
|
||||||
|
if (signatureMatch) {
|
||||||
|
return {
|
||||||
|
action: "append-log",
|
||||||
|
existingTaskId: signatureMatch.id,
|
||||||
|
rateLimited: await computeRateLimited(store, signatureMatch.id, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraMatch = openTasks.find((task) => metadataMatches(task, params.parentTaskId, params.extraMatchKeys));
|
||||||
|
if (extraMatch) {
|
||||||
|
return {
|
||||||
|
action: "append-log",
|
||||||
|
existingTaskId: extraMatch.id,
|
||||||
|
rateLimited: await computeRateLimited(store, extraMatch.id, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyParentMatch = openTasks.find(
|
||||||
|
(task) => task.sourceType === "recovery" && task.sourceParentTaskId === params.parentTaskId,
|
||||||
|
);
|
||||||
|
if (legacyParentMatch) {
|
||||||
|
return {
|
||||||
|
action: "append-log",
|
||||||
|
existingTaskId: legacyParentMatch.id,
|
||||||
|
rateLimited: await computeRateLimited(store, legacyParentMatch.id, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.branch) {
|
||||||
|
const branchMatch = openTasks.find((task) => task.branch === params.branch);
|
||||||
|
if (branchMatch) {
|
||||||
|
return {
|
||||||
|
action: "append-log",
|
||||||
|
existingTaskId: branchMatch.id,
|
||||||
|
rateLimited: await computeRateLimited(store, branchMatch.id, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.signature) {
|
||||||
|
const recentClosedMatch = candidateTasks.find((task) => {
|
||||||
|
if (!CLOSED_COLUMNS.has(task.column)) return false;
|
||||||
|
if (getVerificationSignature(task) !== params.signature) return false;
|
||||||
|
const closedMs = Date.parse(task.updatedAt || task.createdAt);
|
||||||
|
return !Number.isNaN(closedMs) && closedMs > now - SUPERSEDES_WINDOW_MS;
|
||||||
|
});
|
||||||
|
if (recentClosedMatch) {
|
||||||
|
return { action: "create-new", supersedesTaskId: recentClosedMatch.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "create-new" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAutomatedFollowup(
|
||||||
|
store: TaskStore,
|
||||||
|
params: {
|
||||||
|
kind: AutomatedFollowupKind;
|
||||||
|
parentTaskId: string;
|
||||||
|
signature?: string;
|
||||||
|
branch?: string | null;
|
||||||
|
extraMatchKeys?: Record<string, string | number>;
|
||||||
|
createInput: TaskCreateInput;
|
||||||
|
auditor?: RunAuditor;
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
| { outcome: "deduped"; existingTaskId: string; rateLimited: boolean }
|
||||||
|
| { outcome: "created"; task: Awaited<ReturnType<TaskStore["createTask"]>>; supersedesTaskId?: string }
|
||||||
|
> {
|
||||||
|
const auditor = params.auditor ?? buildDefaultAuditor(store, params.parentTaskId);
|
||||||
|
try {
|
||||||
|
const decision = await decideAutomatedFollowup(store, {
|
||||||
|
kind: params.kind,
|
||||||
|
parentTaskId: params.parentTaskId,
|
||||||
|
signature: params.signature,
|
||||||
|
branch: params.branch,
|
||||||
|
extraMatchKeys: params.extraMatchKeys,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.action === "append-log") {
|
||||||
|
if (!decision.rateLimited) {
|
||||||
|
await store.logEntry(
|
||||||
|
decision.existingTaskId,
|
||||||
|
`${RECURRENCE_LOG_TAG} signature=${params.signature ?? "none"}`,
|
||||||
|
`kind=${params.kind}; parentTaskId=${params.parentTaskId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await auditor.database({
|
||||||
|
type: "verification:followup-deduped",
|
||||||
|
target: decision.existingTaskId,
|
||||||
|
metadata: {
|
||||||
|
kind: params.kind,
|
||||||
|
parentTaskId: params.parentTaskId,
|
||||||
|
existingTaskId: decision.existingTaskId,
|
||||||
|
signature: params.signature,
|
||||||
|
rateLimited: decision.rateLimited,
|
||||||
|
},
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return {
|
||||||
|
outcome: "deduped",
|
||||||
|
existingTaskId: decision.existingTaskId,
|
||||||
|
rateLimited: decision.rateLimited,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = params.createInput.source
|
||||||
|
? {
|
||||||
|
...params.createInput.source,
|
||||||
|
sourceMetadata: {
|
||||||
|
...(params.createInput.source.sourceMetadata ?? {}),
|
||||||
|
...(params.signature ? { verificationFailureSignature: params.signature } : {}),
|
||||||
|
...(decision.supersedesTaskId ? { supersedesTaskId: decision.supersedesTaskId } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const task = await store.createTask({
|
||||||
|
...params.createInput,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditor.database({
|
||||||
|
type: "verification:followup-created",
|
||||||
|
target: task.id,
|
||||||
|
metadata: {
|
||||||
|
kind: params.kind,
|
||||||
|
parentTaskId: params.parentTaskId,
|
||||||
|
newTaskId: task.id,
|
||||||
|
signature: params.signature,
|
||||||
|
supersedesTaskId: decision.supersedesTaskId,
|
||||||
|
},
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcome: "created",
|
||||||
|
task,
|
||||||
|
...(decision.supersedesTaskId ? { supersedesTaskId: decision.supersedesTaskId } : {}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
runtimeLog.warn(
|
||||||
|
`Automated follow-up dedup failed open for ${params.parentTaskId} (${params.kind}): ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
const task = await store.createTask(params.createInput);
|
||||||
|
return { outcome: "created", task };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __testing__ = {
|
||||||
|
normalizeMatchedPath,
|
||||||
|
RECURRENCE_LOG_TAG,
|
||||||
|
RECURRENCE_RATE_LIMIT_MS,
|
||||||
|
SUPERSEDES_WINDOW_MS,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user