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. - 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. - 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) ### 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 ### 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 | | Parameter | Type | Required | Description |
|-----------|------|----------|-------------| |-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID to delete (e.g. FN-001) | | `id` | string | ✓ | Task ID to delete (e.g. FN-001) |
| `allowResurrection` | boolean | — | When true, mark this tombstone as explicitly reusable for future recreation. | | `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. | | `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 ### 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_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_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_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` | 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_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. | | `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(); 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 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(); const api = createMockApi();
registerExtension(api); registerExtension(api);
const tool = requireTool(api, "fn_task_delete"); 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}`); expect(result.content[0]?.text).toBe(`Deleted ${task.id}`);
const deleted = await store.getTask(task.id, { includeDeleted: true }); const deleted = await store.getTask(task.id, { includeDeleted: true });

View File

@@ -2953,7 +2953,7 @@ export default function kbExtension(pi: ExtensionAPI) {
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.", "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", 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",
@@ -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", "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", "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: FNXC:TaskLifecycleTools 2026-07-07-00:00:
See matching comment on fn_task_archive above (FN-7661): the store's TaskHasLineageChildrenError message 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. 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({ 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." })), 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) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -2983,6 +2990,7 @@ export default function kbExtension(pi: ExtensionAPI) {
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, removeLineageReferences: params.removeLineageReferences === true,
removeDependencyReferences: params.removeDependencyReferences === true,
auditContext: { auditContext: {
/* /*
FNXC:TaskDeleteAttribution 2026-07-26-14:30: FNXC:TaskDeleteAttribution 2026-07-26-14:30: