From dd9fa2d3cba25fa2ebd25b080eacb813ee84f52f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Jul 2026 00:33:02 -0700 Subject: [PATCH] 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) --- .changeset/fn-7661-lineage-unlink-tools.md | 7 + docs/storage.md | 1 + .../src/__tests__/task-lineage-unlink.test.ts | 199 ++++++++++++++++++ packages/cli/src/extension.ts | 28 ++- 4 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-7661-lineage-unlink-tools.md create mode 100644 packages/cli/src/__tests__/task-lineage-unlink.test.ts diff --git a/.changeset/fn-7661-lineage-unlink-tools.md b/.changeset/fn-7661-lineage-unlink-tools.md new file mode 100644 index 0000000000..65b93552c4 --- /dev/null +++ b/.changeset/fn-7661-lineage-unlink-tools.md @@ -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. diff --git a/docs/storage.md b/docs/storage.md index 2588287013..bc4fa37442 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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) diff --git a/packages/cli/src/__tests__/task-lineage-unlink.test.ts b/packages/cli/src/__tests__/task-lineage-unlink.test.ts new file mode 100644 index 0000000000..3b3e7d655d --- /dev/null +++ b/packages/cli/src/__tests__/task-lineage-unlink.test.ts @@ -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; +}; + +function createMockAPI() { + const tools = new Map(); + 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(); + }); +}); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 2fd38b1825..62bf88be11 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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()}`,