feat(FN-4899): complete Step 3 — capture duplicate lineage in triage finalize

Fusion-Task-Id: FN-4899
Fusion-Task-Lineage: 2ae24667-6635-4268-a620-d6012266d8f2
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 15:53:59 -07:00
committed by gsxdsm
parent 9eb10fca7e
commit 67d6a292c1
2 changed files with 140 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, 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 {
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
updateTask: vi.fn(),
moveTask: vi.fn(),
logEntry: vi.fn(),
deleteTask: vi.fn(),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Task",
description: "desc",
column: "triage",
status: "planning",
dependencies: [],
steps: [],
currentStep: 0,
log: [{ timestamp: new Date().toISOString(), action: "Spec review: APPROVE" }],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("triage finalize duplicate lineage", () => {
let rootDir = "";
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-dup-"));
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
async function runRecovery(task: Task, prompt: string, store: TaskStore): Promise<void> {
await writeFile(join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), prompt);
const processor = new TriageProcessor(store, rootDir);
await processor.recoverApprovedTask(task);
}
it("captures title-only duplicate references", async () => {
const store = createMockStore();
await runRecovery(
createTask({ title: "Foo (duplicate of FN-4894)", description: "plain" }),
"# Task: FN-001 - Foo\n\nBody",
store,
);
expect(vi.mocked(store.updateTask).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ sourceMetadataPatch: { duplicateOfTaskIds: ["FN-4894"] } }),
);
});
it("dedupes references across title and description in order", async () => {
const store = createMockStore();
await runRecovery(
createTask({ title: "(duplicate of FN-4894)", description: "duplicates FN-4894, FN-4847" }),
"# Task: FN-001 - Foo\n\nBody",
store,
);
expect(vi.mocked(store.updateTask).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ sourceMetadataPatch: { duplicateOfTaskIds: ["FN-4894", "FN-4847"] } }),
);
});
it("filters self references", async () => {
const store = createMockStore();
await runRecovery(
createTask({ title: "(duplicate of FN-001)", description: "duplicate of FN-001" }),
"# Task: FN-001 - Foo\n\nBody",
store,
);
expect(vi.mocked(store.updateTask).mock.calls[0]?.[1]).not.toHaveProperty("sourceMetadataPatch");
});
it("is a no-op when no references are present", async () => {
const store = createMockStore();
await runRecovery(createTask({ title: "Normal title", description: "Normal desc" }), "# Task: FN-001 - Foo\n\nBody", store);
expect(vi.mocked(store.updateTask).mock.calls[0]?.[1]).not.toHaveProperty("sourceMetadataPatch");
});
it("preserves duplicate stub delete path", async () => {
const store = createMockStore();
await runRecovery(createTask(), "DUPLICATE: FN-4894\n", store);
expect(store.deleteTask).toHaveBeenCalledWith("FN-001");
expect(store.updateTask).not.toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,9 @@ import type {
Settings,
} from "@fusion/core";
import {
DUPLICATE_OF_METADATA_KEY,
buildTriageMemoryInstructions,
getTaskDuplicateLineage,
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
compareTaskPriority,
@@ -2187,6 +2189,32 @@ export class TriageProcessor {
taskUpdates.steps = parsedSteps;
}
const duplicateLineage = getTaskDuplicateLineage({
id: task.id,
title: task.title,
description: task.description,
sourceType: task.sourceType,
sourceParentTaskId: task.sourceParentTaskId,
sourceMetadata: task.sourceMetadata,
promptText: written,
}).filter((candidateId) => {
return !(task.sourceType === "task_duplicate" && task.sourceParentTaskId?.toUpperCase() === candidateId);
});
if (duplicateLineage.length > 0) {
const existingMetadataIds = Array.isArray(task.sourceMetadata?.[DUPLICATE_OF_METADATA_KEY])
? task.sourceMetadata[DUPLICATE_OF_METADATA_KEY].filter((value): value is string => typeof value === "string")
: [];
const existingNormalized = existingMetadataIds.map((value) => value.toUpperCase());
const matchesExisting =
existingNormalized.length === duplicateLineage.length
&& existingNormalized.every((value, index) => value === duplicateLineage[index]);
if (!matchesExisting) {
taskUpdates.sourceMetadataPatch = { [DUPLICATE_OF_METADATA_KEY]: duplicateLineage };
}
planLog.log(`${task.id} duplicate-of lineage: ${duplicateLineage.join(", ")}`);
}
const sizeMatch = written.match(/^\*\*Size:\*\*\s+(S|M|L)\b/m);
if (sizeMatch) {
taskUpdates.size = sizeMatch[1] as "S" | "M" | "L";