FN-7411: prevent task-bound self deletion

Prevent task-bound callers from soft-deleting the task they are currently executing.

- Add a TaskSelfDeleteError guard in TaskStore.deleteTask before mutation or audit emission.
- Pass the current task id through the fn_task_delete audit context so CLI tool calls inherit the store invariant.
- Cover self-delete rejection and cross-task deletion allowance in core and CLI tests.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7411-self-delete-guard.md            |  7 +++
 .../task-delete-allow-resurrection.test.ts         | 48 ++++++++++++++++-
 packages/cli/src/extension.ts                      |  2 +
 .../src/__tests__/store-self-delete-guard.test.ts  | 60 ++++++++++++++++++++++
 packages/core/src/store.ts                         | 22 +++++++-
 5 files changed, 136 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7411

Fusion-Task-Lineage: 50028769-5396-4435-84fb-2ae182315e81

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 23:53:10 -07:00
parent 1a523e7d1b
commit 22261b683f
5 changed files with 136 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent task-bound agents from deleting the task they are currently executing.
category: fix
dev: TaskStore.deleteTask now rejects audit contexts whose caller task matches the delete target.

View File

@@ -4,11 +4,11 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "@fusion/core";
import kbExtension from "../extension.js";
import kbExtension, { closeCachedStores } from "../extension.js";
type RegisteredTool = {
name: string;
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string }) => Promise<any>;
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string; taskId?: string; agentId?: string; runId?: string }) => Promise<any>;
};
function createMockAPI() {
@@ -36,6 +36,7 @@ describe("task delete allowResurrection plumbing", () => {
});
afterEach(async () => {
await closeCachedStores();
await rm(rootDir, { recursive: true, force: true });
});
@@ -68,4 +69,47 @@ describe("task delete allowResurrection plumbing", () => {
expect(deleted.deletedAt).toBeTruthy();
expect(deleted.allowResurrection).toBeUndefined();
});
it("fn_task_delete rejects deleting the caller task and leaves it live", async () => {
const store = new TaskStore(rootDir);
await store.init();
const task = await store.createTask({ title: "self", description: "current task", column: "in-progress" });
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
await expect(
tool.execute("call-self", { id: task.id }, undefined, undefined, {
cwd: rootDir,
taskId: task.id,
agentId: "agent-test",
runId: "run-test",
}),
).rejects.toThrow(`Task ${task.id} cannot delete itself`);
const row = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { deletedAt?: string };
expect(row.deletedAt).toBeUndefined();
});
it("fn_task_delete lets a task-bound caller delete a different task", async () => {
const store = new TaskStore(rootDir);
await store.init();
const caller = await store.createTask({ title: "caller", description: "current task", column: "in-progress" });
const target = await store.createTask({ title: "target", description: "cleanup target", column: "todo" });
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
const result = await tool.execute("call-other", { id: target.id }, undefined, undefined, {
cwd: rootDir,
taskId: caller.id,
agentId: "agent-test",
runId: "run-test",
});
expect(result.content[0]?.text).toBe(`Deleted ${target.id}`);
const deleted = (store as any).readTaskFromDb(target.id, { includeDeleted: true }) as { deletedAt?: string };
expect(deleted.deletedAt).toBeTruthy();
});
});

View File

@@ -1561,11 +1561,13 @@ export default function kbExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const callerTaskId = (ctx as { taskId?: string }).taskId;
const task = await store.deleteTask(params.id, {
allowResurrection: params.allowResurrection === true,
auditContext: {
agentId: "pi-extension",
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
taskId: callerTaskId,
},
});

View File

@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskSelfDeleteError } from "../store.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore.deleteTask self-delete guard (FN-7411)", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
it("rejects when the audit context task is the deletion target before mutation or audit", async () => {
const store = harness.store();
const task = await store.createTask({ title: "self", description: "do not delete self", column: "in-progress" });
await expect(
store.deleteTask(task.id, {
auditContext: {
agentId: "agent-test",
runId: "run-test",
taskId: task.id,
},
}),
).rejects.toMatchObject({
name: "TaskSelfDeleteError",
code: "TASK_SELF_DELETE",
taskId: task.id,
message: `Task ${task.id} cannot delete itself`,
} satisfies Partial<TaskSelfDeleteError>);
const row = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { deletedAt?: string };
expect(row.deletedAt).toBeUndefined();
expect(store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" })).toHaveLength(0);
});
it("allows a task-bound caller to delete a different task", async () => {
const store = harness.store();
const caller = await store.createTask({ title: "caller", description: "current task", column: "in-progress" });
const target = await store.createTask({ title: "target", description: "cleanup target", column: "todo" });
await expect(
store.deleteTask(target.id, {
auditContext: {
agentId: "agent-test",
runId: "run-test",
taskId: caller.id,
},
}),
).resolves.toMatchObject({ id: target.id });
const deleted = (store as any).readTaskFromDb(target.id, { includeDeleted: true }) as { deletedAt?: string };
expect(deleted.deletedAt).toBeTruthy();
expect(store.getRunAuditEvents({ taskId: target.id, mutationType: "task:deleted" })).toHaveLength(1);
});
});

View File

@@ -1085,6 +1085,17 @@ export class TaskHasDependentsError extends Error {
}
}
export class TaskSelfDeleteError extends Error {
readonly taskId: string;
readonly code = "TASK_SELF_DELETE";
constructor(taskId: string) {
super(`Task ${taskId} cannot delete itself`);
this.name = "TaskSelfDeleteError";
this.taskId = taskId;
}
}
export class TaskDeletedError extends Error {
constructor(
public readonly taskId: string,
@@ -11364,10 +11375,19 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
removeLineageReferences?: boolean;
allowResurrection?: boolean;
githubIssueAction?: GithubIssueAction;
auditContext?: { agentId: string; runId: string; sessionId?: string };
auditContext?: { agentId: string; runId: string; sessionId?: string; taskId?: string };
},
): Promise<Task> {
const deletedTask = await this.withTaskLock(id, async () => {
/*
FNXC:TaskDeletion 2026-07-01-00:00:
Task-bound runtime callers may clean up other tasks, but the executing task must never soft-delete itself because that hides active work before the executor can finish or report failure.
Enforce this at the store boundary so future task-delete bridges inherit the same invariant before any mutation, branch cleanup, or task:deleted audit emission.
*/
if (options?.auditContext?.taskId === id) {
throw new TaskSelfDeleteError(id);
}
// Flush buffered agent logs inside the lock so no new appends for this
// task can sneak in between flush and soft-delete mutation.
this.flushAgentLogBuffer();