FN-075: allow explicit dependency reference removal on task delete

Expose the safe, explicit dependency-cleanup retry through the task deletion tool.

- Add removeDependencyReferences plumbing to fn_task_delete.
- Verify refusal without the explicit option and replanning after cleanup.
- Document the operator-facing deletion behavior and publish the fix changeset.

Files changed:
 .changeset/fn-075-dependency-delete-option.md      |  7 ++
 docs/storage.md                                    |  3 +-
 .../cli/skill/fusion/references/extension-tools.md |  3 +-
 .../cli/skill/fusion/references/fusion-capabilities.md |  2 +-
 .../cli/src/__tests__/task-lineage-unlink.test.ts  | 81 +++++++++++++++++++++-
 packages/cli/src/extension.ts                      | 10 ++-
 6 files changed, 99 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-075
Fusion-Task-Lineage: 952c3563-079c-45a8-8b52-46a551ab6779
Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-20 19:13:51 +00:00
parent eb3eeb887a
commit a2856ba629
6 changed files with 99 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Let operators explicitly remove incoming dependency references when soft-deleting a task.
category: fix
dev: `fn_task_delete` forwards `removeDependencyReferences` to the existing PostgreSQL store transaction.

View File

@@ -101,7 +101,8 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi
- 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.
- Agent-facing tool layer: the `fn_task_archive` and `fn_task_delete` pi/CLI tools (`packages/cli/src/extension.ts`) both accept optional `removeLineageReferences` and forward it to `store.archiveTask` / `store.deleteTask`. This clears incoming lineage-parent references (`sourceParentTaskId`) only after the normal `TaskHasLineageChildrenError` refusal.
- `fn_task_delete` also accepts `removeDependencyReferences`. Normal deletion intentionally refuses live dependents; only an explicit retry with `{ removeDependencyReferences: true }` delegates to `store.deleteTask` to atomically remove incoming dependency edges, clear matching blockers, and return affected dependents to planning. It does not hard-delete the prerequisite or require direct PostgreSQL or `task.json` edits.
### Documents under soft-deleted tasks (FN-5140, FX-005)

View File

@@ -143,13 +143,14 @@ Unarchive an archived task (move from archived → its restore column). Restores
### fn_task_delete
Soft-delete a task from active Fusion board views. 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.
Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. If live lineage children or dependents still reference the task, deletion is rejected unless the matching explicit reference-removal option is passed.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID to delete (e.g. FN-001) |
| `allowResurrection` | boolean | — | When true, mark this tombstone as explicitly reusable for future recreation. |
| `removeLineageReferences` | boolean | — | When true, clear incoming lineage-parent references (child sourceParentTaskId) before deleting, so a task still referenced as a lineage parent can be removed. |
| `removeDependencyReferences` | boolean | — | When true, remove incoming dependency edges before soft deletion. Omit or pass false to retain the dependent-conflict refusal. |
### fn_task_browse_gitlab_project_issues

View File

@@ -36,7 +36,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
| `fn_task_archive` | 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. If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed. |
| `fn_task_unarchive` | Unarchive an archived task (move from archived → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo. |
| `fn_task_delete` | Soft-delete a task from active Fusion board views. 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. |
| `fn_task_delete` | Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. If live lineage children or dependents still reference the task, deletion is rejected unless the matching explicit reference-removal option is passed. |
| `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue. |
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. |
| `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. |

View File

@@ -175,14 +175,89 @@ pgDescribe("fn_task_archive / fn_task_delete removeLineageReferences plumbing",
expect(updatedChild.sourceParentTaskId).toBeUndefined();
});
it("fn_task_delete with no lineage children behaves unchanged", async () => {
/*
FNXC:DependencyIntegrity 2026-08-20-19:00:
FN-075 covers the registered operator tool rather than reproducing the store test: an ordinary
delete must preserve a dependent-bearing task unchanged, while the explicit retry delegates the
atomic edge removal and replan fence to TaskStore.deleteTask.
*/
it("fn_task_delete requires an explicit dependency cleanup retry and replans affected dependents", async () => {
const store = h.store();
const task = await store.createTask({ column: "todo", title: "solo", description: "no children" });
const prerequisite = await store.createTask({ column: "todo", title: "prerequisite", description: "delete me" });
const unrelated = await store.createTask({ column: "todo", title: "unrelated", description: "keep me" });
const firstDependent = await store.createTask({
column: "todo",
title: "first dependent",
description: "depends on two tasks",
dependencies: [prerequisite.id, unrelated.id],
});
const secondDependent = await store.createTask({
column: "todo",
title: "second dependent",
description: "depends only on the deleted task",
dependencies: [prerequisite.id],
});
await store.updateTask(firstDependent.id, { status: "queued", blockedBy: prerequisite.id });
const continuation = await store.replaceActiveTaskWorkflowContinuation({
runId: `${firstDependent.id}:continuation:0`,
taskId: firstDependent.id,
nodeId: "plan-review",
kind: "task",
state: "runnable",
stableWorkflowRunId: `${firstDependent.id}:workflow`,
continuationSequence: 0,
waitReason: "planning",
sourceColumn: "todo",
targetColumn: "todo",
irHash: "ir-v1",
});
const api = createMockApi();
registerExtension(api);
const tool = requireTool(api, "fn_task_delete");
const result = await tool.execute("call-8", { id: task.id }, undefined, undefined, ctx());
for (const [callId, params] of [
["call-8-omitted", { id: prerequisite.id }],
["call-8-false", { id: prerequisite.id, removeDependencyReferences: false }],
] as const) {
const refused = await tool.execute(callId, params, undefined, undefined, ctx());
expect(refused.isError).toBe(true);
expect(refused.content[0]?.text).toMatch(/still referenced as a dependency/i);
expect((await store.getTask(prerequisite.id, { includeDeleted: true })).deletedAt).toBeUndefined();
expect((await store.getTask(firstDependent.id)).dependencies).toEqual([prerequisite.id, unrelated.id]);
expect((await store.getTask(firstDependent.id)).blockedBy).toBe(prerequisite.id);
expect((await store.getTask(firstDependent.id)).status).toBe("queued");
expect((await store.getWorkflowWorkItem(continuation.id))?.state).toBe("runnable");
}
const deleted = await tool.execute(
"call-8-forced",
{ id: prerequisite.id, removeDependencyReferences: true },
undefined,
undefined,
ctx(),
);
expect(deleted.content[0]?.text).toBe(`Deleted ${prerequisite.id}`);
expect((await store.getTask(prerequisite.id, { includeDeleted: true })).deletedAt).toBeTruthy();
const updatedFirst = await store.getTask(firstDependent.id);
const updatedSecond = await store.getTask(secondDependent.id);
expect(updatedFirst.dependencies).toEqual([unrelated.id]);
expect(updatedSecond.dependencies).toEqual([]);
expect(updatedFirst.blockedBy).toBeUndefined();
expect(updatedFirst.status).toBe("needs-replan");
expect(updatedSecond.status).toBe("needs-replan");
expect((await store.getWorkflowWorkItem(continuation.id))?.state).toBe("cancelled");
});
it("fn_task_delete with no dependents behaves unchanged", async () => {
const store = h.store();
const task = await store.createTask({ column: "todo", title: "solo", description: "no dependents" });
const api = createMockApi();
registerExtension(api);
const tool = requireTool(api, "fn_task_delete");
const result = await tool.execute("call-9", { id: task.id }, undefined, undefined, ctx());
expect(result.content[0]?.text).toBe(`Deleted ${task.id}`);
const deleted = await store.getTask(task.id, { includeDeleted: true });

View File

@@ -2953,7 +2953,7 @@ export default function kbExtension(pi: ExtensionAPI) {
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. " +
"If the task is still referenced as a lineage parent by another task, deletion is rejected unless removeLineageReferences:true is passed.",
"If live lineage children or dependents still reference the task, deletion is rejected unless the matching explicit reference-removal option 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",
@@ -2962,16 +2962,23 @@ export default function kbExtension(pi: ExtensionAPI) {
"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",
"If deletion fails because live tasks depend on it, first review the conflict, then deliberately retry with removeDependencyReferences:true to atomically remove only those incoming dependency edges and replan affected tasks",
],
/*
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.
FNXC:DependencyIntegrity 2026-08-20-19:00:
FN-075 exposes the store's explicit dependent-conflict recovery at the CLI bridge. The bridge
must delegate incoming-edge removal, stale-blocker clearing, and dependency replan fencing to
the PostgreSQL delete transaction; it must not mutate dependency arrays itself.
*/
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." })),
removeDependencyReferences: Type.Optional(Type.Boolean({ description: "When true, remove incoming dependency edges before soft deletion. Omit or pass false to retain the dependent-conflict refusal." })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -2983,6 +2990,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const task = await store.deleteTask(params.id, {
allowResurrection: params.allowResurrection === true,
removeLineageReferences: params.removeLineageReferences === true,
removeDependencyReferences: params.removeDependencyReferences === true,
auditContext: {
/*
FNXC:TaskDeleteAttribution 2026-07-26-14:30: