feat(FN-5131): add lineage-unlink flag to triage parent deletes

Adds a `lineage-unlink` flag to triage parent deletes (FN-5131), enabling the split-into-subtasks path to break parent-child lineage on deletion. The primary addition is a comprehensive new test covering that split scenario, with minor adjustments to two existing lineage-related test files.

Fusion-Task-Id: FN-5131

Fusion-Task-Lineage: cb720e8f-8c49-42e4-873a-9949f3ba3427
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 07:20:28 -07:00
committed by gsxdsm
parent a6747c1dfd
commit 2c7ae1c1a4
6 changed files with 162 additions and 8 deletions

View File

@@ -75,5 +75,5 @@ describe("commitOrAmendMergeWithFixes already-on-main recovery", () => {
strategy: "trailer",
});
expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha);
});
}, 20_000);
});

View File

@@ -106,7 +106,7 @@ describe("triage finalize duplicate lineage", () => {
const store = createMockStore();
await runRecovery(createTask(), "DUPLICATE: FN-4894\n", store);
expect(store.deleteTask).toHaveBeenCalledWith("FN-001");
expect(store.deleteTask).toHaveBeenCalledWith("FN-001", { removeLineageReferences: true });
expect(store.updateTask).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TriageProcessor } from "../triage.js";
const { mockCreateFnAgent } = vi.hoisted(() => ({ mockCreateFnAgent: vi.fn() }));
vi.mock("../pi.js", () => ({
createFnAgent: mockCreateFnAgent,
describeModel: vi.fn().mockReturnValue("mock-model"),
promptWithFallback: vi.fn(),
}));
vi.mock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../test/mockCore.js");
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
resolveAgentPrompt: vi.fn().mockReturnValue(null),
});
});
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-500",
title: "Parent task",
description: "Oversized task",
column: "triage",
status: "planning",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
function createStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(createTask({ attachments: [], comments: [] } as any)),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn().mockResolvedValueOnce({ id: "FN-501" }).mockResolvedValueOnce({ id: "FN-502" }),
moveTask: vi.fn(),
updateTask: vi.fn().mockResolvedValue(undefined),
deleteTask: vi.fn().mockResolvedValue(undefined),
mergeTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
planningFallbackProvider: "fallback-provider",
planningFallbackModelId: "fallback-model",
} as Settings),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function mockSessionFactory(captureTools: { current: any[] }): void {
mockCreateFnAgent.mockImplementation(async (opts: any) => {
captureTools.current = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
sessionManager: {
getLeafId: vi.fn().mockReturnValue(null),
navigateTree: vi.fn(),
},
},
};
});
}
async function createChildrenFromTool(tools: any[]): Promise<void> {
const taskCreate = tools.find((tool) => tool.name === "fn_task_create");
if (!taskCreate) return;
await taskCreate.execute("c1", { title: "Part 1", description: "One", dependencies: [] });
await taskCreate.execute("c2", { title: "Part 2", description: "Two", dependencies: [] });
}
describe("triage split/delete lineage forwarding", () => {
it("passes removeLineageReferences when split-close happens on the primary planning path", async () => {
const store = createStore();
const captured = { current: [] as any[] };
mockSessionFactory(captured);
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementation(async () => {
await createChildrenFromTool(captured.current);
});
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
await processor.specifyTask(createTask());
expect(store.deleteTask).toHaveBeenCalledWith("FN-500", { removeLineageReferences: true });
});
it("passes removeLineageReferences when split-close happens on the fallback planning path", async () => {
const store = createStore();
const captured = { current: [] as any[] };
mockSessionFactory(captured);
let promptCallCount = 0;
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementation(async () => {
promptCallCount += 1;
if (promptCallCount === 4) {
await createChildrenFromTool(captured.current);
}
});
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
await processor.specifyTask(createTask({ id: "FN-600" }));
expect(store.deleteTask).toHaveBeenCalledWith("FN-600", { removeLineageReferences: true });
});
it("passes removeLineageReferences on DUPLICATE close", async () => {
const store = createStore({
getTask: vi.fn().mockResolvedValue(undefined),
deleteTask: vi.fn().mockResolvedValue(undefined),
});
const rootDir = await mkdtemp(join(tmpdir(), "triage-dup-"));
try {
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
await writeFile(join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), "DUPLICATE: FN-4894\n");
const processor = new TriageProcessor(store, rootDir);
await processor.recoverApprovedTask(
createTask({ id: "FN-001", log: [{ timestamp: new Date().toISOString(), action: "Spec review: APPROVE" }] as any }),
);
expect(store.deleteTask).toHaveBeenCalledWith("FN-001", { removeLineageReferences: true });
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});
});

View File

@@ -2216,7 +2216,7 @@ describe("taskCreate tool model inheritance", () => {
"FN-500",
expect.stringContaining("Converted into subtasks: FN-501, FN-502"),
);
expect(store.deleteTask).toHaveBeenCalledWith("FN-500");
expect(store.deleteTask).toHaveBeenCalledWith("FN-500", { removeLineageReferences: true });
});
});

View File

@@ -1291,7 +1291,8 @@ export class TriageProcessor {
`Converted into subtasks: ${childTaskIds}`,
);
try {
await this.store.deleteTask(task.id);
// FN-5129 / FN-5131: split-close must unlink lineage children when deleting the parent.
await this.store.deleteTask(task.id, { removeLineageReferences: true });
planLog.log(`${task.id} split into subtasks (${childTaskIds}) and closed`);
} catch (err: unknown) {
// deleteTask refuses when live tasks still depend on this id.
@@ -1435,7 +1436,8 @@ export class TriageProcessor {
task.id,
`Converted into subtasks: ${childTaskIds}`,
);
await this.store.deleteTask(task.id);
// FN-5129 / FN-5131: split-close must unlink lineage children when deleting the parent.
await this.store.deleteTask(task.id, { removeLineageReferences: true });
planLog.log(`${task.id} split into subtasks (${childTaskIds}) and closed`);
return;
}
@@ -2191,7 +2193,8 @@ export class TriageProcessor {
task.id,
`Duplicate of ${dupId} — closed`,
);
await this.store.deleteTask(task.id);
// Pass removeLineageReferences so a duplicate-close cannot be blocked by lineage children (FN-5129 / FN-5131).
await this.store.deleteTask(task.id, { removeLineageReferences: true });
return;
}