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:
committed by
gsxdsm
parent
a6747c1dfd
commit
2c7ae1c1a4
@@ -75,5 +75,5 @@ describe("commitOrAmendMergeWithFixes already-on-main recovery", () => {
|
||||
strategy: "trailer",
|
||||
});
|
||||
expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha);
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user