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:
7
.changeset/fn-7411-self-delete-guard.md
Normal file
7
.changeset/fn-7411-self-delete-guard.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
60
packages/core/src/__tests__/store-self-delete-guard.test.ts
Normal file
60
packages/core/src/__tests__/store-self-delete-guard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user