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:
gsxdsm
2026-07-08 00:33:02 -07:00
parent 97e46674f4
commit dd9fa2d3cb
4 changed files with 232 additions and 3 deletions

View 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.

View File

@@ -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)

View 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();
});
});

View File

@@ -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()}`,