diff --git a/.changeset/fn-075-dependency-delete-option.md b/.changeset/fn-075-dependency-delete-option.md new file mode 100644 index 0000000000..a7bc28ea12 --- /dev/null +++ b/.changeset/fn-075-dependency-delete-option.md @@ -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. diff --git a/docs/storage.md b/docs/storage.md index 5b3fa937cb..9db18bb545 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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) diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 8774123d6f..9769a93286 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -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 diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index b373891001..592fac25ff 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -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. | diff --git a/packages/cli/src/__tests__/task-lineage-unlink.test.ts b/packages/cli/src/__tests__/task-lineage-unlink.test.ts index 829b2438b0..69b2e58e8a 100644 --- a/packages/cli/src/__tests__/task-lineage-unlink.test.ts +++ b/packages/cli/src/__tests__/task-lineage-unlink.test.ts @@ -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 }); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index ebd0d93ee4..184a80c842 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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: