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.
|
- 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.
|
- `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 })`.
|
- 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)
|
### 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",
|
label: "fn: Archive Task",
|
||||||
description:
|
description:
|
||||||
"Archive a task from any live column (move to archived). " +
|
"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)",
|
promptSnippet: "Archive a Fusion task from any live column (moves to archived column)",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use to clean up tasks from any live board column when you want them hidden from active views",
|
"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",
|
"Already archived tasks cannot be archived again",
|
||||||
"Archived tasks can be unarchived later if needed",
|
"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({
|
parameters: Type.Object({
|
||||||
id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }),
|
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) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const store = await getStore(ctx.cwd);
|
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 {
|
return {
|
||||||
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
|
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
|
||||||
@@ -1587,7 +1600,8 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
label: "fn: Delete Task",
|
label: "fn: Delete Task",
|
||||||
description:
|
description:
|
||||||
"Soft-delete a task from active Fusion board views. " +
|
"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",
|
promptSnippet: "Soft-delete a Fusion task",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use for cleaning up test tasks or tasks created in error when you want the task hidden from active board views",
|
"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 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",
|
"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",
|
"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({
|
parameters: Type.Object({
|
||||||
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
|
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." })),
|
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) {
|
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 callerTaskId = (ctx as { taskId?: string }).taskId;
|
||||||
const task = await store.deleteTask(params.id, {
|
const task = await store.deleteTask(params.id, {
|
||||||
allowResurrection: params.allowResurrection === true,
|
allowResurrection: params.allowResurrection === true,
|
||||||
|
removeLineageReferences: params.removeLineageReferences === true,
|
||||||
auditContext: {
|
auditContext: {
|
||||||
agentId: "pi-extension",
|
agentId: "pi-extension",
|
||||||
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
|
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user