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:
7
.changeset/fn-075-dependency-delete-option.md
Normal file
7
.changeset/fn-075-dependency-delete-option.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user