diff --git a/.changeset/fn-7411-self-delete-guard.md b/.changeset/fn-7411-self-delete-guard.md new file mode 100644 index 0000000000..4885bcda26 --- /dev/null +++ b/.changeset/fn-7411-self-delete-guard.md @@ -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. diff --git a/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts b/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts index 8e468f223e..544741a8b0 100644 --- a/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts +++ b/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts @@ -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; + execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string; taskId?: string; agentId?: string; runId?: string }) => Promise; }; 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(); + }); }); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index a608bb2f25..d2aac1f466 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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, }, }); diff --git a/packages/core/src/__tests__/store-self-delete-guard.test.ts b/packages/core/src/__tests__/store-self-delete-guard.test.ts new file mode 100644 index 0000000000..57a11f5a78 --- /dev/null +++ b/packages/core/src/__tests__/store-self-delete-guard.test.ts @@ -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); + + 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); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9817718bd5..3828c92ea7 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { 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();