feat(FN-5220): guard explicit duplicate markers in triage and self-healing

Adds an explicit duplicate-marker guard (FN-5220) spanning core helper, dashboard API endpoint, triage short-circuit, and self-healing sweep to detect and handle duplicate task creation attempts; includes comprehensive test coverage across unit, API, and integration layers plus documentation.

Fusion-Task-Id: FN-5220
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 13:46:34 -07:00
committed by gsxdsm
parent 9eea9f48f0
commit 8f2d5e7e61
12 changed files with 869 additions and 12 deletions

View File

@@ -0,0 +1,159 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { makeReliabilityFixture, type ReliabilityFixture } from "./_helpers.js";
const FULL_SPEC = `# Task: FN-7000 - Example\n\n## Mission\nThis spec mentions duplicate handling, but it is not a redirect marker.\n`;
function duplicateStub(canonicalId: string): string {
return `DUPLICATE: ${canonicalId}\n`;
}
async function createPromptTask(
fx: ReliabilityFixture,
input: { id: string; column: "triage" | "todo" | "in-review"; title?: string; prompt: string },
) {
const task = await fx.store.createTask({
title: input.title ?? input.id,
description: `${input.id} description`,
});
if (input.column !== "triage") {
await fx.store.moveTask(task.id, input.column);
}
const taskDir = join(fx.rootDir, ".fusion", "tasks", task.id);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), input.prompt, "utf-8");
return task;
}
describe("reliability interactions: explicit duplicate marker sweep", () => {
const fixtures: ReliabilityFixture[] = [];
afterEach(async () => {
vi.restoreAllMocks();
while (fixtures.length) {
await fixtures.pop()!.cleanup();
}
});
it("resolves an FN-5217-style stuck marker task during maintenance", async () => {
const fx = await makeReliabilityFixture();
fixtures.push(fx);
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
const duplicate = await createPromptTask(fx, { id: "FN-5217", column: "triage", prompt: duplicateStub(canonical.id) });
await (fx.manager as any).runMaintenance();
await expect(fx.store.getTask(duplicate.id)).rejects.toThrow(`Task ${duplicate.id} not found`);
expect((await fx.store.getTask(canonical.id)).column).toBe("todo");
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 20 });
expect(activity.find((entry) => entry.taskId === duplicate.id)).toEqual(
expect.objectContaining({
metadata: expect.objectContaining({ canonicalTaskId: canonical.id, source: "explicit-marker-sweep" }),
}),
);
});
it("does not disturb unrelated in-review tasks when autoMerge is false", async () => {
const fx = await makeReliabilityFixture({ settings: { autoMerge: false } });
fixtures.push(fx);
await fx.store.updateTask(fx.task.id, {
status: "failed",
branch: undefined,
worktree: undefined,
});
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
await createPromptTask(fx, { id: "FN-5217", column: "triage", prompt: duplicateStub(canonical.id) });
await (fx.manager as any).runMaintenance();
const untouched = await fx.store.getTask(fx.task.id);
expect(untouched.column).toBe("in-review");
expect(untouched.status).toBe("failed");
});
it("leaves marker tasks alone when the canonical target is missing", async () => {
const fx = await makeReliabilityFixture();
fixtures.push(fx);
const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: "DUPLICATE: FN-9999\n" });
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
expect((await fx.store.getTask(duplicate.id)).column).toBe("triage");
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 20 });
expect(activity.find((entry) => entry.taskId === duplicate.id)).toBeUndefined();
});
it("leaves full specs untouched", async () => {
const fx = await makeReliabilityFixture();
fixtures.push(fx);
const duplicate = await createPromptTask(fx, { id: "FN-5302", column: "todo", prompt: FULL_SPEC });
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
expect((await fx.store.getTask(duplicate.id)).column).toBe("todo");
});
it("honors the disable flag", async () => {
const fx = await makeReliabilityFixture({ settings: { resolveExplicitDuplicateMarkerEnabled: false } as never });
fixtures.push(fx);
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
const duplicate = await createPromptTask(fx, { id: "FN-5303", column: "triage", prompt: duplicateStub(canonical.id) });
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
expect((await fx.store.getTask(duplicate.id)).column).toBe("triage");
});
it("caps work at 50 tasks per sweep", async () => {
const fx = await makeReliabilityFixture();
fixtures.push(fx);
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
const ids: string[] = [];
for (let index = 0; index < 60; index += 1) {
const task = await createPromptTask(fx, {
id: `FN-${6000 + index}`,
column: index % 2 === 0 ? "triage" : "todo",
prompt: duplicateStub(canonical.id),
});
ids.push(task.id);
}
expect(await (fx.manager as any).resolveExplicitDuplicateMarkerTasks()).toBe(50);
const remainingAfterFirst = await fx.store.listTasks({ includeArchived: false });
expect(remainingAfterFirst.filter((task) => ids.includes(task.id))).toHaveLength(10);
expect(await (fx.manager as any).resolveExplicitDuplicateMarkerTasks()).toBe(10);
const remainingAfterSecond = await fx.store.listTasks({ includeArchived: false });
expect(remainingAfterSecond.filter((task) => ids.includes(task.id))).toHaveLength(0);
});
it("fails open when one delete throws and continues processing later tasks", async () => {
const fx = await makeReliabilityFixture();
fixtures.push(fx);
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
const first = await createPromptTask(fx, { id: "FN-5304", column: "triage", prompt: duplicateStub(canonical.id) });
const second = await createPromptTask(fx, { id: "FN-5305", column: "triage", prompt: duplicateStub(canonical.id) });
const originalDeleteTask = fx.store.deleteTask.bind(fx.store);
const deleteSpy = vi.spyOn(fx.store, "deleteTask").mockImplementation(async (taskId, options) => {
if (taskId === first.id) {
throw new Error("boom");
}
return await originalDeleteTask(taskId, options as never);
});
expect(await (fx.manager as any).resolveExplicitDuplicateMarkerTasks()).toBe(1);
expect(deleteSpy).toHaveBeenCalled();
expect((await fx.store.getTask(first.id)).column).toBe("triage");
await expect(fx.store.getTask(second.id)).rejects.toThrow(`Task ${second.id} not found`);
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { TriageProcessor } from "../triage.js";
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings),
logEntry: vi.fn(),
deleteTask: vi.fn(),
recordActivity: vi.fn(),
updateTask: vi.fn(),
moveTask: vi.fn(),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-002",
title: "Incoming duplicate",
description: "desc",
column: "triage",
status: "planning",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("triage explicit duplicate marker short-circuit", () => {
const rootDir = process.cwd();
const settings = { requirePlanApproval: true } as Settings;
async function runExplicitDuplicateMarker(
store: TaskStore,
task: Task,
prompt: string,
): Promise<boolean> {
const processor = new TriageProcessor(store, rootDir);
return await (processor as any).tryFinalizeExplicitDuplicateMarker(task, prompt, settings, {});
}
it("deletes the duplicate task and records explicit-marker activity", async () => {
const canonical = createTask({ id: "FN-001", title: "Canonical task", column: "todo" });
const store = createMockStore({
getTask: vi.fn().mockImplementation(async (id: string) => (id === canonical.id ? canonical : null)),
});
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true);
expect(store.deleteTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
removeLineageReferences: true,
auditContext: expect.objectContaining({
agentId: "triage",
runId: expect.stringMatching(/^triage-delete-FN-002-/),
}),
}));
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({
type: "task:auto-archived-duplicate",
taskId: "FN-002",
metadata: expect.objectContaining({ canonicalTaskId: "FN-001", source: "explicit-marker" }),
}));
});
it("does not short-circuit when the canonical target is missing", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(null),
});
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-999\n")).resolves.toBe(false);
expect(store.deleteTask).not.toHaveBeenCalled();
expect(store.recordActivity).not.toHaveBeenCalled();
});
it("does not short-circuit on circular self-reference", async () => {
const task = createTask();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
});
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-002\n")).resolves.toBe(false);
expect(store.deleteTask).not.toHaveBeenCalled();
});
it("does not short-circuit for a full spec that mentions duplicate", async () => {
const store = createMockStore({
getTask: vi.fn(),
});
const fullSpec = `# Task: FN-002 - Example\n\n## Mission\nWe suspected this might duplicate another task, but it is a full prompt body.\n`;
await expect(runExplicitDuplicateMarker(store, createTask(), fullSpec)).resolves.toBe(false);
expect(store.getTask).not.toHaveBeenCalled();
expect(store.deleteTask).not.toHaveBeenCalled();
});
it("fails open when store lookup throws", async () => {
const store = createMockStore({
getTask: vi.fn().mockRejectedValue(new Error("boom")),
});
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(false);
expect(store.deleteTask).not.toHaveBeenCalled();
expect(store.recordActivity).not.toHaveBeenCalled();
});
});

View File

@@ -26,9 +26,9 @@
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import { RemovalReason, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
@@ -1298,6 +1298,7 @@ export class SelfHealingManager {
{ name: "recover-partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures() },
{ name: "recover-orphaned-executions", fn: () => this.recoverOrphanedExecutions() },
{ name: "recover-approved-triage", fn: () => this.recoverApprovedTriageTasks() },
{ name: "resolve-explicit-duplicate-markers", fn: () => this.resolveExplicitDuplicateMarkerTasks() },
{ name: "recover-starved-refinement", fn: () => this.recoverStarvedRefinementTriageTasks() },
{ name: "recover-orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks() },
{ name: "recover-ghost-review", fn: () => this.recoverGhostReviewTasks() },
@@ -6559,6 +6560,76 @@ export class SelfHealingManager {
}
}
async resolveExplicitDuplicateMarkerTasks(): Promise<number> {
try {
const settings = await this.store.getSettings();
const enabled = (settings as Settings & { resolveExplicitDuplicateMarkerEnabled?: boolean }).resolveExplicitDuplicateMarkerEnabled !== false;
if (!enabled) {
return 0;
}
const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 });
const candidates = tasks.filter((task) => task.column === "triage" || task.column === "todo");
let resolved = 0;
let processedMarkers = 0;
for (const task of candidates) {
try {
const promptPath = join(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
if (!existsSync(promptPath)) {
continue;
}
const written = readFileSync(promptPath, "utf-8");
const marker = parseExplicitDuplicateMarker(written);
if (!marker) {
continue;
}
if (processedMarkers >= 50) {
break;
}
processedMarkers += 1;
const canonicalTask = await this.store.getTask(marker.canonicalId).catch(() => null);
if (
!canonicalTask ||
canonicalTask.deletedAt ||
canonicalTask.id.toLowerCase() === task.id.toLowerCase()
) {
continue;
}
await this.store.deleteTask(task.id, {
removeLineageReferences: true,
auditContext: {
agentId: "self-healing",
runId: generateSyntheticRunId("self-heal-explicit-duplicate", task.id),
},
});
await this.store.recordActivity({
type: "task:auto-archived-duplicate",
taskId: task.id,
taskTitle: task.title ?? "",
details: `Duplicate of ${canonicalTask.id} — closed`,
metadata: {
canonicalTaskId: canonicalTask.id,
source: "explicit-marker-sweep",
},
});
log.log(`[self-healing] resolved explicit duplicate marker ${task.id}${canonicalTask.id}`);
resolved += 1;
} catch (error) {
log.warn(`Failed explicit duplicate-marker sweep for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return resolved;
} catch (error) {
log.error(`Explicit duplicate-marker sweep failed: ${error instanceof Error ? error.message : String(error)}`);
return 0;
}
}
/**
* Recover refinement tasks that have sat in triage long enough to indicate
* starvation while the rest of the board keeps progressing.

View File

@@ -11,6 +11,7 @@ import {
TaskDeletedError,
buildTriageMemoryInstructions,
getTaskDuplicateLineage,
parseExplicitDuplicateMarker,
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
compareTaskPriority,
@@ -1508,6 +1509,25 @@ export class TriageProcessor {
}
}
const written = await readFile(
join(this.rootDir, promptPath),
"utf-8",
).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to read generated PROMPT.md before finalization (${promptPath}): ${msg}`);
return "";
});
// FN-5220: planning agents that emit a `DUPLICATE: FN-NNNN` redirect
// do not call `fn_review_spec()`; short-circuit the APPROVE gate.
if (await this.tryFinalizeExplicitDuplicateMarker(task, written, settings, {
isReplan,
feedback,
})) {
this.options.onSpecifyComplete?.(task);
return;
}
// Post-session APPROVE gate: only advance to todo when the spec
// reviewer explicitly approved. Any other verdict (REVISE,
// RETHINK, UNAVAILABLE) or a missing review (null) stays in triage
@@ -1576,15 +1596,6 @@ export class TriageProcessor {
return;
}
const written = await readFile(
join(this.rootDir, promptPath),
"utf-8",
).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to read generated PROMPT.md before finalization (${promptPath}): ${msg}`);
return "";
});
await this.finalizeApprovedTask(task, written, settings, {
isReplan,
feedback,
@@ -2243,6 +2254,41 @@ export class TriageProcessor {
};
}
private async tryFinalizeExplicitDuplicateMarker(
task: Task,
written: string,
settings: Settings,
options: {
isReplan?: boolean;
feedback?: string;
} = {},
): Promise<boolean> {
try {
const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
if (!explicitDuplicateMarker) {
return false;
}
const canonicalId = explicitDuplicateMarker.canonicalId;
const canonicalTask = await this.store.getTask(canonicalId).catch(() => null);
if (
!canonicalTask ||
canonicalTask.deletedAt ||
canonicalTask.id.toLowerCase() === task.id.toLowerCase()
) {
return false;
}
planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`);
await this.finalizeApprovedTask(task, written, settings, options);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: explicit duplicate marker short-circuit failed; proceeding with normal approval gate (${msg})`);
return false;
}
}
private async finalizeApprovedTask(
task: Task,
written: string,
@@ -2262,6 +2308,21 @@ export class TriageProcessor {
task.id,
`Duplicate of ${dupId} — closed`,
);
try {
await this.store.recordActivity({
type: "task:auto-archived-duplicate",
taskId: task.id,
taskTitle: task.title ?? "",
details: `Duplicate of ${dupId} — closed`,
metadata: {
canonicalTaskId: dupId,
source: "explicit-marker",
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to record explicit duplicate-marker activity (${msg})`);
}
// Pass removeLineageReferences so a duplicate-close cannot be blocked by lineage children (FN-5129 / FN-5131).
await this.store.deleteTask(task.id, {
removeLineageReferences: true,