FN-7661: expose removeLineageReferences on fn_task_archive/fn_task_delete
Fixes fn_task_archive and fn_task_delete rejecting tasks still referenced as a lineage parent, with no tool-exposed way to clear that reference. - Add optional removeLineageReferences boolean param to fn_task_archive and fn_task_delete tool schemas, forwarded to store.archiveTask/store.deleteTask - Update tool descriptions and prompt guidelines to advertise the recovery path (removeLineageReferences:true) when a lineage-parent block occurs - Add task-lineage-unlink.test.ts covering the new parameter behavior - Document the change in docs/storage.md - Add changeset (@runfusion/fusion minor, category: fix) Files changed: .changeset/fn-7661-lineage-unlink-tools.md | 7 + docs/storage.md | 1 + packages/cli/src/__tests__/task-lineage-unlink.test.ts | 199 +++++++++++++++++++++ packages/cli/src/extension.ts | 28 ++- 4 files changed, 232 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7661 Fusion-Task-Lineage: 414c046c-43df-4995-85a5-ff00b345de50 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7661-lineage-unlink-tools.md
Normal file
7
.changeset/fn-7661-lineage-unlink-tools.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: fn_task_archive and fn_task_delete now accept removeLineageReferences to clear a lineage-parent block.
|
||||
category: fix
|
||||
dev: Forwards the boolean to store.archiveTask/deleteTask (FN-7661); resolves the tools referencing a parameter their schema never exposed.
|
||||
@@ -55,6 +55,7 @@
|
||||
- Gate boundary: soft-deleted children and archived-column children do **not** block parent removal; only live non-archived children block.
|
||||
- `cleanupArchivedTasks` intentionally tolerates dangling lineage pointers in historical/archive cleanup flows; it does not run lineage rewrites.
|
||||
- For forensic reads, soft-deleted parents remain accessible through `readTaskFromDb(id, { includeDeleted: true })`.
|
||||
- Agent-facing tool layer (FN-7661): the `fn_task_archive` and `fn_task_delete` pi/CLI tools (`packages/cli/src/extension.ts`) both accept an optional `removeLineageReferences` boolean and forward it to `store.archiveTask` / `store.deleteTask`, so an agent that hits `TaskHasLineageChildrenError` can retry with `{ removeLineageReferences: true }` to clear the block — matching the recovery path the error message already advertises.
|
||||
|
||||
### Documents under soft-deleted tasks (FN-5140)
|
||||
|
||||
|
||||
199
packages/cli/src/__tests__/task-lineage-unlink.test.ts
Normal file
199
packages/cli/src/__tests__/task-lineage-unlink.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import kbExtension, { closeCachedStores } from "../extension.js";
|
||||
|
||||
/*
|
||||
FNXC:TaskLifecycleTools 2026-07-07-00:00:
|
||||
Regression coverage for FN-7661: fn_task_archive / fn_task_delete previously never exposed
|
||||
removeLineageReferences, so a task still referenced as a lineage parent by another task was
|
||||
permanently stuck even though the store's TaskHasLineageChildrenError message told callers to
|
||||
pass that flag. These tests reproduce the original stuck-task symptom and assert it is gone via
|
||||
the actual agent-facing tools, mirroring the mock-API harness in task-delete-allow-resurrection.test.ts
|
||||
and the lineage fixture setup in soft-delete-lineage-children.test.ts.
|
||||
*/
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string; taskId?: string; agentId?: string; runId?: string }) => Promise<any>;
|
||||
};
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
tools,
|
||||
registerTool(tool: RegisteredTool) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
registerCommand() {
|
||||
// no-op for tests
|
||||
},
|
||||
on() {
|
||||
// no-op for tests
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", () => {
|
||||
let rootDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await mkdtemp(join(tmpdir(), "fn-task-lineage-unlink-"));
|
||||
await mkdir(join(rootDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeCachedStores();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createParentAndChild(store: TaskStore, parentColumn: "todo" | "done" = "todo") {
|
||||
const parent = await store.createTask({ column: parentColumn, title: "parent", description: "parent" });
|
||||
const child = await store.createTask({ column: "todo", title: "child", description: "child" });
|
||||
(store as any).db
|
||||
.prepare("UPDATE tasks SET sourceParentTaskId = ?, sourceType = ?, updatedAt = ? WHERE id = ?")
|
||||
.run(parent.id, "task_refine", new Date().toISOString(), child.id);
|
||||
return { parent, child: await store.getTask(child.id) };
|
||||
}
|
||||
|
||||
it("fn_task_archive rejects a lineage parent when removeLineageReferences is omitted", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
|
||||
await expect(tool.execute("call-1", { id: parent.id }, undefined, undefined, { cwd: rootDir })).rejects.toThrow(
|
||||
/still referenced as a lineage parent/,
|
||||
);
|
||||
|
||||
const row = (store as any).readTaskFromDb(parent.id, { includeDeleted: true }) as { column: string };
|
||||
expect(row.column).not.toBe("archived");
|
||||
});
|
||||
|
||||
it("fn_task_archive rejects a lineage parent when removeLineageReferences is explicitly false", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
|
||||
await expect(
|
||||
tool.execute("call-2", { id: parent.id, removeLineageReferences: false }, undefined, undefined, { cwd: rootDir }),
|
||||
).rejects.toThrow(/still referenced as a lineage parent/);
|
||||
});
|
||||
|
||||
it("fn_task_archive with removeLineageReferences:true archives the parent and clears the child reference", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const { parent, child } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
const result = await tool.execute(
|
||||
"call-3",
|
||||
{ id: parent.id, removeLineageReferences: true },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
|
||||
expect(result.details.column).toBe("archived");
|
||||
const archived = await store.getTask(parent.id);
|
||||
expect(archived.column).toBe("archived");
|
||||
|
||||
const updatedChild = await store.getTask(child.id);
|
||||
expect(updatedChild.sourceParentTaskId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fn_task_archive with no lineage children behaves unchanged and preserves cleanup default", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const task = await store.createTask({ column: "done", title: "solo", description: "no children" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_archive") as RegisteredTool;
|
||||
const result = await tool.execute("call-4", { id: task.id }, undefined, undefined, { cwd: rootDir });
|
||||
|
||||
expect(result.details.column).toBe("archived");
|
||||
});
|
||||
|
||||
it("fn_task_delete rejects a lineage parent when removeLineageReferences is omitted", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
|
||||
await expect(tool.execute("call-5", { id: parent.id }, undefined, undefined, { cwd: rootDir })).rejects.toThrow(
|
||||
/still referenced as a lineage parent/,
|
||||
);
|
||||
|
||||
const row = (store as any).readTaskFromDb(parent.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
expect(row.deletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fn_task_delete rejects a lineage parent when removeLineageReferences is explicitly false", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const { parent } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
|
||||
await expect(
|
||||
tool.execute("call-6", { id: parent.id, removeLineageReferences: false }, undefined, undefined, { cwd: rootDir }),
|
||||
).rejects.toThrow(/still referenced as a lineage parent/);
|
||||
});
|
||||
|
||||
it("fn_task_delete with removeLineageReferences:true soft-deletes the parent and clears the child reference", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const { parent, child } = await createParentAndChild(store);
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const result = await tool.execute(
|
||||
"call-7",
|
||||
{ id: parent.id, removeLineageReferences: true },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
|
||||
expect(result.content[0]?.text).toBe(`Deleted ${parent.id}`);
|
||||
const deleted = (store as any).readTaskFromDb(parent.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
|
||||
const updatedChild = await store.getTask(child.id);
|
||||
expect(updatedChild.sourceParentTaskId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fn_task_delete with no lineage children behaves unchanged", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const task = await store.createTask({ column: "todo", title: "solo", description: "no children" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
const result = await tool.execute("call-8", { id: task.id }, undefined, undefined, { cwd: rootDir });
|
||||
|
||||
expect(result.content[0]?.text).toBe(`Deleted ${task.id}`);
|
||||
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1530,20 +1530,33 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
label: "fn: Archive Task",
|
||||
description:
|
||||
"Archive a task from any live column (move to archived). " +
|
||||
"Archived tasks are preserved for historical reference but moved out of the main board view.",
|
||||
"Archived tasks are preserved for historical reference but moved out of the main board view. " +
|
||||
"If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed.",
|
||||
promptSnippet: "Archive a Fusion task from any live column (moves to archived column)",
|
||||
promptGuidelines: [
|
||||
"Use to clean up tasks from any live board column when you want them hidden from active views",
|
||||
"Already archived tasks cannot be archived again",
|
||||
"Archived tasks can be unarchived later if needed",
|
||||
"If archiving fails because the task is still referenced as a lineage parent by another task, retry with removeLineageReferences:true to clear that reference and unblock the archive",
|
||||
],
|
||||
/*
|
||||
FNXC:TaskLifecycleTools 2026-07-07-00:00:
|
||||
fn_task_archive and fn_task_delete both gate on store.TaskHasLineageChildrenError, whose message tells the
|
||||
caller to pass { removeLineageReferences: true } — but neither tool schema exposed that parameter, leaving
|
||||
lineage-parent tasks permanently stuck (FN-7661). Expose it on both tools' Type.Object schema and forward it
|
||||
to the store call so the recovery path the error message advertises is actually reachable by agents. Keep
|
||||
this in sync with store.archiveTask / store.deleteTask option shapes if they change.
|
||||
*/
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }),
|
||||
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references (child sourceParentTaskId) before archiving, so a task still referenced as a lineage parent can be archived." })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const task = await store.archiveTask(params.id);
|
||||
const task = await store.archiveTask(params.id, {
|
||||
removeLineageReferences: params.removeLineageReferences === true,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
|
||||
@@ -1587,7 +1600,8 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
label: "fn: Delete Task",
|
||||
description:
|
||||
"Soft-delete a task from active Fusion board views. " +
|
||||
"The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation.",
|
||||
"The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. " +
|
||||
"If the task is still referenced as a lineage parent by another task, deletion is rejected unless removeLineageReferences:true is passed.",
|
||||
promptSnippet: "Soft-delete a Fusion task",
|
||||
promptGuidelines: [
|
||||
"Use for cleaning up test tasks or tasks created in error when you want the task hidden from active board views",
|
||||
@@ -1595,10 +1609,17 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
"Use allowResurrection:true when operators want the deleted task ID to be intentionally reusable on future createTask calls",
|
||||
"Use fn_task_archive for completed work you want to keep referenceable in the board",
|
||||
"True hard removal is handled by archive cleanup paths (archiveTaskAndCleanup / cleanupArchivedTasks), not fn_task_delete",
|
||||
"If deletion fails because the task is still referenced as a lineage parent by another task, retry with removeLineageReferences:true to clear that reference and unblock the delete",
|
||||
],
|
||||
/*
|
||||
FNXC:TaskLifecycleTools 2026-07-07-00:00:
|
||||
See matching comment on fn_task_archive above (FN-7661): the store's TaskHasLineageChildrenError message
|
||||
advertises { removeLineageReferences: true } as the recovery path, so this tool must expose and forward it too.
|
||||
*/
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
|
||||
allowResurrection: Type.Optional(Type.Boolean({ description: "When true, mark this tombstone as explicitly reusable for future recreation." })),
|
||||
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references (child sourceParentTaskId) before deleting, so a task still referenced as a lineage parent can be removed." })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
@@ -1606,6 +1627,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const callerTaskId = (ctx as { taskId?: string }).taskId;
|
||||
const task = await store.deleteTask(params.id, {
|
||||
allowResurrection: params.allowResurrection === true,
|
||||
removeLineageReferences: params.removeLineageReferences === true,
|
||||
auditContext: {
|
||||
agentId: "pi-extension",
|
||||
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
|
||||
|
||||
Reference in New Issue
Block a user