diff --git a/.changeset/ephemeral-task-create-deny-hides-tool.md b/.changeset/ephemeral-task-create-deny-hides-tool.md new file mode 100644 index 0000000000..9990b22267 --- /dev/null +++ b/.changeset/ephemeral-task-create-deny-hides-tool.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Deny now hides fn_task_create from agents, and retried task creates no longer duplicate. +category: fix +dev: Adds `isAgentTaskCreateToolAvailable(settings, callerIsEphemeral)` in `@fusion/engine` agent-tools; the outer execution session (`executor.ts`) and per-step workflow sessions (`step-session-executor.ts`) omit `fn_task_create` from the tool list when the project policy resolves to `deny`. `isEphemeralCallerAgent` in the pi extension now fails closed: a caller id that is present but unresolvable counts as ephemeral, so the policy still applies. `upon_validation` keeps the tool (it proposes to the mailbox); permanent-agent and human/chat callers are unaffected. Separately, the deterministic content-fingerprint duplicate window in `duplicate-guard.ts` goes 60s -> 10m (clamp ceiling 5m -> 1h) so an agent that retries a create after a tool timeout links the existing task instead of filing a second one. diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 166a8985a8..4062c6e3f8 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -729,18 +729,30 @@ FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: fn_task_create runs inside whatever agent loaded the pi extension. When the caller is an ephemeral/runtime task-worker (executor-FN-XXXX and friends), the project setting `ephemeralAgentsCanCreateTasks` decides whether it may open new tasks. Human/dashboard/CLI callers have no `ctx.agentId`, so they are never gated here — the setting only constrains runtime-managed agents. Resolution is fail-open on lookup errors: a missing/unresolvable caller is treated as non-ephemeral so a store hiccup never blocks legitimate task creation. + +FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20: +Fail-open was too generous for the identity signal itself. A runtime task-worker session always +carries a caller id; only a human/dashboard/CLI caller has none. So an id that is PRESENT but does +not resolve to an agent row (deleted ephemeral row, cross-project store, transient read failure) is +a runtime caller with an unknown identity, and is now classified ephemeral so the project policy +still applies. An absent id keeps the old human pass-through, and a resolved permanent agent is +still never gated. + +Incident: with the project policy on Deny, an executing agent still filed ten follow-up tasks — +an execute-time gate that answers "not ephemeral" whenever identity resolution comes up empty is +indistinguishable from no gate at all on exactly the sessions the setting exists to constrain. */ async function isEphemeralCallerAgent(cwd: string, callerAgentId: string | undefined): Promise { if (!callerAgentId) return false; try { - + const agentStore = await getAgentStore(cwd); await agentStore.init(); const agent = await agentStore.resolveAgent(callerAgentId); - if (!agent) return false; + if (!agent) return true; return isEphemeralAgent(agent); } catch { - return false; + return true; } } diff --git a/packages/core/src/__tests__/duplicate-guard.test.ts b/packages/core/src/__tests__/duplicate-guard.test.ts index 761775fd5d..b67104b937 100644 --- a/packages/core/src/__tests__/duplicate-guard.test.ts +++ b/packages/core/src/__tests__/duplicate-guard.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Column, Task } from "../types.js"; import type { TaskStore } from "../store.js"; +import { computeContentFingerprint } from "../duplicate-detection.js"; import { __getDeterministicGuardMutexSize, reconcileDeterministicDuplicate, @@ -31,7 +32,8 @@ function makeStore(seed: Task[] = []): { tasks: Task[]; store: TaskStore } { const tasks = [...seed]; const store = { findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fp: string, options?: { windowMs?: number; includeArchived?: boolean }) => { - const windowMs = Math.max(1, Math.min(300_000, Math.trunc(options?.windowMs ?? 60_000))); + // Mirrors clampWindowMs in duplicate-guard.ts (default 10m, ceiling 1h). + const windowMs = Math.max(1, Math.min(3_600_000, Math.trunc(options?.windowMs ?? 600_000))); const cutoff = Date.now() - windowMs; return tasks .filter((task) => task.source?.sourceMetadata?.contentFingerprint === fp) @@ -211,6 +213,23 @@ describe("runDeterministicDuplicateGuard", () => { result.releaseLock(); }); + /* + FNXC:TaskCreationDeduplication 2026-07-26-06:45: + Regression for the ten-duplicate incident: an agent's parallel fn_task_create calls appeared to + time out, it retried them minutes later, and the retries fell outside the old 60s window. The + default window must cover a full timeout-and-retry cycle on every entry point — the guard's + pre-check AND the post-create reconciliation. + */ + it("catches a retry of the same content minutes after the original committed", async () => { + const originalTs = new Date(Date.now() - 150_000).toISOString(); + const original = mkTask({ id: "FN-1", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: originalTs, updatedAt: originalTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: computeContentFingerprint(INPUT)! } } }); + const { store } = makeStore([original]); + const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" }); + expect(result.action).toBe("duplicate"); + expect(result.existing?.id).toBe("FN-1"); + result.releaseLock(); + }); + it("returns null fingerprint for empty description", async () => { const { store } = makeStore(); const result = await runDeterministicDuplicateGuard(store, { title: "x", description: "..." }, { lockScope: "p-1" }); diff --git a/packages/core/src/duplicate-guard.ts b/packages/core/src/duplicate-guard.ts index 48eb996bc6..cfff030bbc 100644 --- a/packages/core/src/duplicate-guard.ts +++ b/packages/core/src/duplicate-guard.ts @@ -2,8 +2,24 @@ import type { Task } from "./types.js"; import type { TaskStore } from "./store.js"; import { computeContentFingerprint } from "./duplicate-detection.js"; -const DEFAULT_WINDOW_MS = 60_000; -const MAX_WINDOW_MS = 300_000; +/* +FNXC:TaskCreationDeduplication 2026-07-26-06:45: +The window must outlive one agent timeout-and-retry cycle, not one request. + +Incident: an agent fired five parallel fn_task_create calls, reported them as timed out, and +retried them sequentially about two minutes later. The originals had committed, but the retries +landed outside the old 60s window, so the exact-content guard saw nothing and the board took ten +tasks instead of five. 60s only covered concurrent in-flight creates; a model that pauses to +explain itself and then retries always beat it. + +Ten minutes is chosen to span a stalled tool call plus the model's retry turn. False positives stay +cheap and rare: this is an EXACT normalized title+description hash, and a legitimate repeat of +byte-identical content inside ten minutes is a double-submit, not distinct work. Near-duplicate +(paraphrase) matching is unaffected and keeps its own thresholds/windows. The clamp ceiling rises +with it so an explicit caller-supplied window is not silently cut back to five minutes. +*/ +const DEFAULT_WINDOW_MS = 600_000; +const MAX_WINDOW_MS = 3_600_000; export const deterministicGuardLocks = new Map>(); // Test-only compatibility hook used by dashboard deterministic-dedup route tests. diff --git a/packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts b/packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts new file mode 100644 index 0000000000..6c532e5340 --- /dev/null +++ b/packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { TaskStore } from "@fusion/core"; +import { createTaskCreateTool, isAgentTaskCreateToolAvailable } from "../agent-tools.js"; + +/* +FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20: +Operator report: with project policy "Ephemeral agent follow-up tasks = Deny", an executing agent +still filed ten follow-up tasks (five parallel fn_task_create calls it reported as timed out, then +five sequential retries). Deny must be structural — the tool is not registered for an ephemeral +session at all — not merely an execute-time refusal the model can keep retrying. + +These tests assert the invariant on every surface that can hand fn_task_create to an ephemeral +worker: the shared registration predicate, the execute-time gate inside the factory (defense in +depth), and the two engine lanes that register the tool (outer execution session, per-step session). +*/ + +function settings(policy?: "allow" | "upon_validation" | "deny", legacy?: boolean) { + return { + ...(policy ? { ephemeralAgentTaskCreationPolicy: policy } : {}), + ...(legacy === undefined ? {} : { ephemeralAgentsCanCreateTasks: legacy }), + } as Parameters[0]; +} + +function readEngineSource(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(`../${relativePath}`, import.meta.url)), "utf8"); +} + +describe("isAgentTaskCreateToolAvailable", () => { + it("withholds the tool from an ephemeral caller when the policy denies creation", () => { + expect(isAgentTaskCreateToolAvailable(settings("deny"), true)).toBe(false); + }); + + it("honors the legacy boolean when no explicit policy is persisted", () => { + expect(isAgentTaskCreateToolAvailable(settings(undefined, false), true)).toBe(false); + expect(isAgentTaskCreateToolAvailable(settings(undefined, true), true)).toBe(true); + }); + + it("keeps the tool for allow and upon_validation (a proposal is a supported action)", () => { + expect(isAgentTaskCreateToolAvailable(settings("allow"), true)).toBe(true); + expect(isAgentTaskCreateToolAvailable(settings("upon_validation"), true)).toBe(true); + }); + + it("never gates a non-ephemeral caller, even under deny", () => { + expect(isAgentTaskCreateToolAvailable(settings("deny"), false)).toBe(true); + expect(isAgentTaskCreateToolAvailable(settings("deny"), undefined)).toBe(true); + }); + + it("defaults to available when settings are unreadable", () => { + expect(isAgentTaskCreateToolAvailable(undefined, true)).toBe(true); + expect(isAgentTaskCreateToolAvailable(settings(), true)).toBe(true); + }); +}); + +describe("fn_task_create execute-time gate (defense in depth)", () => { + it("refuses an ephemeral caller under deny without touching the store", async () => { + let createCalls = 0; + const store = { + getSettings: async () => settings("deny"), + createTask: async () => { createCalls += 1; throw new Error("createTask must not run under deny"); }, + } as unknown as TaskStore; + + const tool = createTaskCreateTool(store, { sourceType: "api" }, { callerIsEphemeral: true }); + const result = await (tool.execute as unknown as ( + id: string, + params: unknown, + ) => Promise<{ isError?: boolean; details?: unknown }>)( + "call-1", + { description: "Follow-up work discovered mid-task" }, + ); + + expect(result.isError).toBe(true); + expect((result.details as { rule?: string }).rule).toBe("ephemeral-agents-cannot-create-tasks"); + expect(createCalls).toBe(0); + }); +}); + +describe("engine lanes that register fn_task_create", () => { + it("guards the outer execution session registration with the availability predicate", () => { + const source = readEngineSource("executor.ts"); + expect(source).toContain("isAgentTaskCreateToolAvailable(settings, executionCallerIsEphemeral)"); + }); + + it("guards the per-step workflow session registration with the availability predicate", () => { + const source = readEngineSource("step-session-executor.ts"); + expect(source).toContain("isAgentTaskCreateToolAvailable(settings, this.options.callerIsEphemeral)"); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 79119c4b1a..be038bab51 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -977,6 +977,30 @@ async function getAgentMemoryWindow(rootDir: string, agentMemory: AgentMemoryCon // ── Tool factory functions ──────────────────────────────────────────────── +/** + * FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20: + * When the project policy is `deny`, an ephemeral/runtime task-worker must not merely + * be REFUSED at execute time — `fn_task_create` must not be registered for that session + * at all, so the model never sees the tool in its tool list. + * + * Incident: an executing agent under a `deny` project fired five parallel `fn_task_create` + * calls, reported them as timed out, retried them sequentially, and left ten tasks on a + * board whose operator had switched follow-up creation off. An execute-time-only refusal + * still invites the model to plan around the tool, burn turns retrying it, and — on any + * lane where `callerIsEphemeral` fails to reach the factory — create the tasks anyway. + * Suppressing registration makes the operator's Deny structural instead of advisory. + * + * `upon_validation` keeps the tool registered: that policy routes a proposal to the + * operator mailbox and is a supported agent action, not a prohibition. + */ +export function isAgentTaskCreateToolAvailable( + settings: Pick | undefined | null, + callerIsEphemeral: boolean | undefined, +): boolean { + if (!callerIsEphemeral) return true; + return fusionCore.resolveEphemeralTaskCreationPolicy(settings ?? {}) !== "deny"; +} + type AgentTaskCreationOptions = { rootDir?: string; bypassDuplicateCheck?: boolean; diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 5c039150dc..fc19a71284 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -256,6 +256,7 @@ import { createArtifactRegisterTool as sharedCreateArtifactRegisterTool, createArtifactViewTool as sharedCreateArtifactViewTool, createTaskCreateTool as sharedCreateTaskCreateTool, + isAgentTaskCreateToolAvailable, createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool, createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool, createTaskPromptWriteTool as sharedCreateTaskPromptWriteTool, @@ -12642,11 +12643,19 @@ export class TaskExecutor { }; await runPendingTaskVerification(); + /* + FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20: + A `deny` project policy removes fn_task_create from the session's tool list instead of + registering a tool that only refuses at execute time; see isAgentTaskCreateToolAvailable. + */ + const executionCallerIsEphemeral = !identityAgent || isEphemeralAgent(identityAgent); const customTools = [ this.createTaskUpdateTool(task.id, codeReviewVerdicts, sessionRef, stuckDetector), this.createTaskLogTool(task.id), this.createTaskLogsReadTool(task.id), - this.createTaskCreateTool(!identityAgent || isEphemeralAgent(identityAgent), task.id, identityAgent?.id), + ...(isAgentTaskCreateToolAvailable(settings, executionCallerIsEphemeral) + ? [this.createTaskCreateTool(executionCallerIsEphemeral, task.id, identityAgent?.id)] + : []), this.createTaskAddDepTool(task.id), this.createTaskDoneTool(task.id, worktreePath, detail.prompt ?? "", codeReviewVerdicts, () => { taskDone = true; }, audit), createRunVerificationTool({ diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 75df192ee2..cc9d26895f 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -51,6 +51,7 @@ import { createReadMessagesTool, createSendMessageTool, createTaskCreateTool, + isAgentTaskCreateToolAvailable, createTaskDocumentReadTool, createTaskDocumentWriteTool, createTaskLogTool, @@ -1329,7 +1330,13 @@ export class StepSessionExecutor { createTaskLogsReadTool(this.options.store, taskDetail.id), ] : []; - const taskCreateTool = this.options.store + /* + FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20: + Per-step workflow sessions honor the same registration-time Deny as the outer + execution session: an ephemeral step worker under `deny` is never handed + fn_task_create, rather than being handed a tool that only refuses on call. + */ + const taskCreateTool = this.options.store && isAgentTaskCreateToolAvailable(settings, this.options.callerIsEphemeral) ? [createTaskCreateTool(this.options.store, undefined, { rootDir: this.options.rootDir, callerIsEphemeral: this.options.callerIsEphemeral, sourceTaskId: this.options.sourceTaskId ?? taskDetail.id, sourceAgentId: this.options.sourceAgentId ?? taskDetail.assignedAgentId, messageStore: this.options.messageStore })] : [];