From 13a2b2a9da260209bcc4a6ea7aa6e4101af05a3d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 23:03:46 -0700 Subject: [PATCH] fix(agent-tools): hide fn_task_create under Deny and widen the dedupe window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator report: with project policy "Ephemeral agent follow-up tasks = Deny", an executing agent filed ten follow-up tasks — five parallel fn_task_create calls it reported as timed out, then five sequential retries. Two defects: 1. Deny was advisory. fn_task_create was registered for every session and only refused inside execute(), so the model still saw the tool, planned around it, and retried it. The pi extension's isEphemeralCallerAgent also failed OPEN whenever the caller id did not resolve to an agent row — which is the normal shape of an ephemeral task-worker — so on that lane Deny was a no-op. 2. The deterministic content-fingerprint duplicate window was 60s, which only covered concurrent in-flight creates. A retry two minutes later saw nothing and filed a second task. Fixes: isAgentTaskCreateToolAvailable() withholds the tool from ephemeral sessions under Deny in both engine lanes (outer execution session, per-step workflow session); isEphemeralCallerAgent fails closed on an unresolvable caller id; the fingerprint window goes 60s -> 10m (clamp ceiling 5m -> 1h). upon_validation keeps the tool, and permanent-agent and human/chat callers are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .../ephemeral-task-create-deny-hides-tool.md | 7 ++ packages/cli/src/extension.ts | 18 +++- .../src/__tests__/duplicate-guard.test.ts | 21 ++++- packages/core/src/duplicate-guard.ts | 20 ++++- .../ephemeral-task-create-gate.test.ts | 89 +++++++++++++++++++ packages/engine/src/agent-tools.ts | 24 +++++ packages/engine/src/executor.ts | 11 ++- packages/engine/src/step-session-executor.ts | 9 +- 8 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 .changeset/ephemeral-task-create-deny-hides-tool.md create mode 100644 packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts 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 })] : [];