diff --git a/.changeset/ephemeral-agents-can-create-tasks.md b/.changeset/ephemeral-agents-can-create-tasks.md new file mode 100644 index 0000000000..9468a9cca8 --- /dev/null +++ b/.changeset/ephemeral-agents-can-create-tasks.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a project setting to allow or block ephemeral agents from creating tasks (default on). +category: feature +dev: New project setting `ephemeralAgentsCanCreateTasks` (default true) in DEFAULT_PROJECT_SETTINGS; gated in both fn_task_create surfaces (pi extension caller-agent check and the engine executor's ephemeral task-worker tool via `AgentTaskCreationOptions.callerIsEphemeral`). Toggle lives in Settings → General. diff --git a/.changeset/ephemeral-disabled-blocks-workflow-dispatch.md b/.changeset/ephemeral-disabled-blocks-workflow-dispatch.md new file mode 100644 index 0000000000..9d45de8e5e --- /dev/null +++ b/.changeset/ephemeral-disabled-blocks-workflow-dispatch.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Disabling ephemeral agents now also stops the workflow engine from running unassigned tasks. +category: fix +dev: Added `TaskExecutor.blockOuterDispatchWhenEphemeralDisabled` gate at the top of `execute()`, ahead of all three workflow dispatch paths (maybeExecuteWorkflowGraph, workflowAuthoritativeDispatch, maybeDispatchWorkflowWorkEngine). Previously `ephemeralAgentsEnabled=false` was enforced only on the legacy scheduler/EphemeralWorkerManager path; the workflow-engine paths ran unassigned tasks anyway because the spawn refusal is a post-execution fire-and-forget callback. Unassigned tasks are now re-queued for permanent-agent assignment; tasks bound to a permanent agent still run. diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index b7138ab317..a608bb2f25 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -241,6 +241,26 @@ async function validateAssignableAgentId( return null; } +/* +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. +*/ +async function isEphemeralCallerAgent(cwd: string, callerAgentId: string | undefined): Promise { + if (!callerAgentId) return false; + try { + const { AgentStore, isEphemeralAgent } = await import("@fusion/core"); + const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) }); + await agentStore.init(); + const agent = await agentStore.resolveAgent(callerAgentId); + if (!agent) return false; + return isEphemeralAgent(agent); + } catch { + return false; + } +} + function normalizeNullableStringInput(value: string | null | undefined): string | null | undefined { if (value === undefined) { return undefined; @@ -741,6 +761,25 @@ export default function kbExtension(pi: ExtensionAPI) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const store = await getStore(ctx.cwd); + /* + FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: + Gate ephemeral task-worker callers behind the project `ephemeralAgentsCanCreateTasks` toggle (default true). Only runtime-managed agents are affected; humans/CLI/dashboard calls carry no ctx.agentId and pass through. + */ + const fnCtx = ctx as typeof ctx & { agentId?: string }; + const projectSettingsForGate = await store.getSettings(); + if ( + projectSettingsForGate.ephemeralAgentsCanCreateTasks === false && + (await isEphemeralCallerAgent(ctx.cwd ?? process.cwd(), fnCtx.agentId)) + ) { + const error = + "Ephemeral task-worker agents are not allowed to create tasks (ephemeralAgentsCanCreateTasks is disabled for this project)."; + return { + content: [{ type: "text", text: `ERROR: ${error}` }], + isError: true, + details: { error, rule: "ephemeral-agents-cannot-create-tasks", callerAgentId: fnCtx.agentId }, + }; + } + const normalizedAgentId = normalizeNullableStringInput(params.agentId); if (normalizedAgentId !== undefined && normalizedAgentId !== null) { @@ -756,11 +795,10 @@ export default function kbExtension(pi: ExtensionAPI) { } try { - const projectSettings = await store.getSettings(); const globalSettings = await store.getGlobalSettingsStore().getSettings(); const resolvedTracking = resolveTaskGithubTracking( { githubTracking: undefined }, - projectSettings, + projectSettingsForGate, globalSettings, ); const workflowId = params.workflow_id?.trim() || undefined; diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index e86914ae24..a513b11b17 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -184,6 +184,12 @@ describe("settings key parity", () => { expect(isGlobalSettingsKey("ephemeralAgentsEnabled")).toBe(false); }); + it("defaults ephemeralAgentsCanCreateTasks to true and keeps it project-scoped", () => { + expect(DEFAULT_PROJECT_SETTINGS.ephemeralAgentsCanCreateTasks).toBe(true); + expect(isProjectSettingsKey("ephemeralAgentsCanCreateTasks")).toBe(true); + expect(isGlobalSettingsKey("ephemeralAgentsCanCreateTasks")).toBe(false); + }); + it("defaults completionDocumentationMode to off", () => { expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off"); }); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 08f0efb572..0ac270bd1c 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -418,6 +418,11 @@ export const DEFAULT_PROJECT_SETTINGS = { // coverage. Falls back to package/explicit command when no tests resolve. scopeVerificationToChangedFiles: true, ephemeralAgentsEnabled: true, + /* + FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: + Default-on so ephemeral task-worker agents keep the ability to open follow-up tasks via fn_task_create. Operators who want to confine task creation to humans/permanent agents flip this off. + */ + ephemeralAgentsCanCreateTasks: true, agentProvisioning: {}, sandboxProvisioning: {}, defaultAgentPermissionPolicy: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index dba2fa19c1..9d5f46a799 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4012,6 +4012,12 @@ export interface ProjectSettings { * to permanent executor agents using the reporting chain heuristic. * Tasks without an eligible permanent executor remain queued. */ ephemeralAgentsEnabled?: boolean; + /** + * FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: + * Gates whether ephemeral/runtime-managed task-worker agents may create new tasks via `fn_task_create`. + * Default true preserves the existing behavior where a task-worker can spin off follow-up tasks. + * When false, an ephemeral caller's `fn_task_create` is rejected while human/dashboard/CLI callers and permanent agents remain unaffected. */ + ephemeralAgentsCanCreateTasks?: boolean; /** Approval policy for agent provisioning tools (fn_agent_create/fn_agent_delete). */ agentProvisioning?: { approvalMode?: AgentProvisioningApprovalMode; diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index becc88a20d..0d33f66731 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -96,6 +96,15 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))}/>{t("settings.general.useEphemeralTaskWorkerAgents", " Use ephemeral task-worker agents ")} {t("settings.general.whenEnabledDefaultFusionSpawnsShortLived", " When enabled (default), Fusion spawns short-lived ")}executor-FN-XXXX{t("settings.general.agentsToRunEachTaskWhenDisabledOnly", " agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued. ")} + {/* + FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: + Default-on toggle controlling whether ephemeral task-worker agents may open new tasks via fn_task_create. Turning it off confines task creation to humans and permanent agents; ephemeral callers get a rejection. + */} +
+ + {t("settings.general.allowEphemeralAgentsToCreateTasksHint", "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.")} +
{/* FNXC:Workspace 2026-06-24-16:00: Workspace mode toggle: when enabled, the project root is treated as a workspace parent diff --git a/packages/engine/src/__tests__/agent-tools.test.ts b/packages/engine/src/__tests__/agent-tools.test.ts index ddf6675097..b964ba6167 100644 --- a/packages/engine/src/__tests__/agent-tools.test.ts +++ b/packages/engine/src/__tests__/agent-tools.test.ts @@ -155,6 +155,49 @@ describe("createTaskCreateTool", () => { expect(responseText).toContain("(depends on: PROJ-001)"); }); + // FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: ephemeral callers are gated by the project toggle. + it("rejects ephemeral callers when ephemeralAgentsCanCreateTasks is false", async () => { + const store = { + getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false, ephemeralAgentsCanCreateTasks: false }), + createTask: vi.fn(), + }; + + const tool = createTaskCreateTool(store as any, { sourceType: "api" }, { callerIsEphemeral: true }); + const result = await tool.execute("call-1", { description: "Follow-up" } as any, undefined, undefined, {} as any); + + expect((result as { isError?: boolean }).isError).toBe(true); + expect(store.createTask).not.toHaveBeenCalled(); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toContain("not allowed to create tasks"); + }); + + it("allows ephemeral callers when ephemeralAgentsCanCreateTasks is true (default)", async () => { + const store = { + getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false, ephemeralAgentsCanCreateTasks: true }), + createTask: vi.fn().mockResolvedValue({ id: "PROJ-050", description: "Follow-up", dependencies: [], column: "triage" }), + }; + + const tool = createTaskCreateTool(store as any, { sourceType: "api" }, { callerIsEphemeral: true }); + const result = await tool.execute("call-1", { description: "Follow-up" } as any, undefined, undefined, {} as any); + + expect((result as { isError?: boolean }).isError).toBeFalsy(); + expect(store.createTask).toHaveBeenCalled(); + }); + + it("never gates permanent callers even when the toggle is off", async () => { + const store = { + getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false, ephemeralAgentsCanCreateTasks: false }), + createTask: vi.fn().mockResolvedValue({ id: "PROJ-051", description: "Follow-up", dependencies: [], column: "triage" }), + }; + + // No callerIsEphemeral → permanent-agent/human session; toggle is ignored. + const tool = createTaskCreateTool(store as any, { sourceType: "api" }); + const result = await tool.execute("call-1", { description: "Follow-up" } as any, undefined, undefined, {} as any); + + expect((result as { isError?: boolean }).isError).toBeFalsy(); + expect(store.createTask).toHaveBeenCalled(); + }); + it("passes explicit priority to store.createTask", async () => { const store = { getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }), diff --git a/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts b/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts new file mode 100644 index 0000000000..50aa723ee4 --- /dev/null +++ b/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TaskDetail } from "@fusion/core"; +import "./executor-test-helpers.js"; +import { TaskExecutor } from "../executor.js"; +import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js"; + +/* +FNXC:EphemeralAgents 2026-07-01-00:00: +Regression coverage for the ephemeral-disabled dispatch gate. `ephemeralAgentsEnabled: false` +must stop the workflow engine from running unassigned work, not just the legacy spawn path. +The bug: EphemeralWorkerManager.onTaskStart is a fire-and-forget bookkeeping callback that runs +AFTER execution begins, and the three workflow dispatch paths in TaskExecutor.execute() +(maybeExecuteWorkflowGraph, workflowAuthoritativeDispatch, maybeDispatchWorkflowWorkEngine) +never consulted the toggle — so tasks reaching execute() without a permanent assignment ran +anyway. These tests assert the invariant across ALL THREE workflow dispatch entry points +(Surface Enumeration), not just one reproduction. +*/ + +const now = "2026-07-01T00:00:00.000Z"; + +function task(overrides: Partial = {}): TaskDetail { + return { + id: "FN-EPHEMERAL-GATE", + title: "Ephemeral-disabled dispatch gate", + description: "Gate coverage for ephemeralAgentsEnabled=false workflow dispatch", + column: "in-progress", + dependencies: [], + steps: [{ name: "Implement", status: "pending" }], + currentStep: 0, + log: [], + branch: "fusion/fn-ephemeral-gate", + baseBranch: "main", + worktree: "/tmp/fusion-fn-ephemeral-gate", + status: null, + error: null, + paused: false, + userPaused: false, + autoMerge: true, + mergeRetries: 0, + createdAt: now, + updatedAt: now, + ...overrides, + } as TaskDetail; +} + +function settings(overrides: Record = {}) { + return { + autoMerge: true, + maxAutoMergeRetries: 3, + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + ...overrides, + }; +} + +describe("executor ephemeral-disabled dispatch gate", () => { + it("blocks and re-queues an unassigned task when ephemeralAgentsEnabled=false", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ column: "in-progress", assignedAgentId: undefined }); + store.getTask.mockResolvedValue(live); + store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); + const executor = new TaskExecutor(store, "/tmp/test"); + + const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); + + expect(blocked).toBe(true); + expect(store.moveTask).toHaveBeenCalledWith( + live.id, + "todo", + expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }), + ); + expect(store.updateTask).toHaveBeenCalledWith( + live.id, + expect.objectContaining({ status: "queued" }), + undefined, + ); + expect(store.logEntry).toHaveBeenCalledWith( + live.id, + expect.stringContaining("ephemeral agents disabled"), + expect.stringContaining("Executor pre-dispatch ephemeral gate"), + undefined, + ); + }); + + it("allows dispatch when ephemeralAgentsEnabled is on (default)", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ assignedAgentId: undefined }); + store.getTask.mockResolvedValue(live); + store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: true })); + const executor = new TaskExecutor(store, "/tmp/test"); + + const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); + + expect(blocked).toBe(false); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("allows dispatch when the toggle is absent (undefined defaults to enabled)", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ assignedAgentId: undefined }); + store.getTask.mockResolvedValue(live); + store.getSettings.mockResolvedValue(settings()); + const executor = new TaskExecutor(store, "/tmp/test"); + + expect(await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live)).toBe(false); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + + it("allows a task assigned to a permanent (non-ephemeral) agent through", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ assignedAgentId: "agent-permanent" }); + store.getTask.mockResolvedValue(live); + store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); + const agentStore = { + getAgent: vi.fn().mockResolvedValue({ id: "agent-permanent", name: "reviewer", role: "executor" }), + }; + const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); + + const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); + + expect(blocked).toBe(false); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("blocks a task whose assigned agent is itself ephemeral", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ assignedAgentId: "executor-FN-EPHEMERAL-GATE" }); + store.getTask.mockResolvedValue(live); + store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); + // isEphemeralAgent keys off the runtime-managed task-worker marker. + const agentStore = { + getAgent: vi.fn().mockResolvedValue({ + id: "executor-FN-EPHEMERAL-GATE", + name: "executor-FN-EPHEMERAL-GATE", + role: "executor", + metadata: { agentKind: "task-worker", taskWorker: true }, + }), + }; + const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); + + const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); + + expect(blocked).toBe(true); + expect(store.updateTask).toHaveBeenCalledWith( + live.id, + expect.objectContaining({ status: "queued" }), + undefined, + ); + }); + + /* + FNXC:EphemeralAgents 2026-07-01-00:00: + Surface Enumeration — one gate must cover all three workflow dispatch entry points. + Drive the real execute() and assert none of maybeExecuteWorkflowGraph, + workflowAuthoritativeDispatch, or maybeDispatchWorkflowWorkEngine is reached when the + gate blocks. This is the invariant that prevented the fix from being repro-only. + */ + it("execute() reaches no workflow dispatch path when ephemeral is disabled and task is unassigned", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ assignedAgentId: undefined }); + store.getTask.mockResolvedValue(live); + store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); + + const workflowAuthoritativeDispatch = vi.fn().mockResolvedValue(false); + const executor = new TaskExecutor(store, "/tmp/test", { workflowAuthoritativeDispatch } as any); + + const graphSpy = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(false); + const workEngineSpy = vi + .spyOn(executor as any, "maybeDispatchWorkflowWorkEngine") + .mockResolvedValue(false); + + await executor.execute(live); + + // Every workflow dispatch entry point must be unreachable once the gate blocks. + expect(graphSpy).not.toHaveBeenCalled(); + expect(workflowAuthoritativeDispatch).not.toHaveBeenCalled(); + expect(workEngineSpy).not.toHaveBeenCalled(); + + // And the task is re-queued for the scheduler to assign a permanent agent. + expect(store.updateTask).toHaveBeenCalledWith( + live.id, + expect.objectContaining({ status: "queued" }), + undefined, + ); + }); + + it("execute() still reaches the workflow graph path when ephemeral agents are enabled", async () => { + resetExecutorMocks(); + const store = createMockStore(); + const live = task({ assignedAgentId: undefined }); + store.getTask.mockResolvedValue(live); + store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: true })); + + const executor = new TaskExecutor(store, "/tmp/test"); + // Return true so execute() stops after the first workflow path — we only need + // to prove the gate did NOT short-circuit dispatch when the toggle is on. + const graphSpy = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true); + + await executor.execute(live); + + expect(graphSpy).toHaveBeenCalledTimes(1); + expect(store.updateTask).not.toHaveBeenCalledWith( + live.id, + expect.objectContaining({ status: "queued" }), + undefined, + ); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 6253a16cdf..6f1b9ca20d 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -859,6 +859,11 @@ type AgentTaskCreationOptions = { rootDir?: string; bypassDuplicateCheck?: boolean; acknowledgedDuplicates?: string[]; + /* + FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: + Set true when fn_task_create is registered for an ephemeral/runtime task-worker session (executor-FN-XXXX). The tool then honors the project `ephemeralAgentsCanCreateTasks` toggle and rejects creation when it is disabled. Permanent-agent sessions leave this unset and are never gated. + */ + callerIsEphemeral?: boolean; }; export async function createAgentTask( @@ -963,6 +968,24 @@ export function createTaskCreateTool( parameters: taskCreateParams, execute: async (_id: string, params: Static) => { try { + /* + FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: + Ephemeral task-worker sessions may only create tasks when the project `ephemeralAgentsCanCreateTasks` toggle is on (default true). Fail-open on a settings read error so a store hiccup never blocks creation. + */ + if (options?.callerIsEphemeral) { + const settings = typeof (store as { getSettings?: unknown }).getSettings === "function" + ? await store.getSettings().catch(() => ({} as Settings)) + : ({} as Settings); + if ((settings as Settings).ephemeralAgentsCanCreateTasks === false) { + const message = + "Ephemeral task-worker agents are not allowed to create tasks (ephemeralAgentsCanCreateTasks is disabled for this project)."; + return { + content: [{ type: "text" as const, text: `ERROR: ${message}` }], + details: { error: message, rule: "ephemeral-agents-cannot-create-tasks" }, + isError: true, + }; + } + } const workflowId = params.workflow_id?.trim() || undefined; const { task, wasDuplicate } = await createAgentTask(store, { description: params.description, diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a75bcdf915..48af427225 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -8771,6 +8771,49 @@ export class TaskExecutor { return true; } + /* + FNXC:EphemeralAgents 2026-07-01-00:00: + `ephemeralAgentsEnabled: false` means "never spawn short-lived executor-FN-XXXX workers; only permanent agents run work" (see types.ts ephemeralAgentsEnabled). The legacy spawn refusal lives in EphemeralWorkerManager.onTaskStart (ephemeral-worker-manager.ts), but that runs as a fire-and-forget bookkeeping callback AFTER execution has already begun, so it cannot stop a run. The workflow-engine dispatch paths (maybeExecuteWorkflowGraph, workflowAuthoritativeDispatch, maybeDispatchWorkflowWorkEngine) execute tasks in-process without ever consulting the toggle. Any task that reaches execute() without a permanent assignment via a non-scheduler path (resume-after-restart, heartbeat re-entry, mission/autopilot, work-engine claim) therefore ran despite the operator disabling ephemeral agents. + + This guard is the executor's last line of defense, mirroring the scheduler cutover gate (scheduler.ts:2464) and the spawn refusal (ephemeral-worker-manager.ts:132). It runs once at the top of the outer dispatch — before all three workflow paths — so a single check covers every workflow dispatch entry point. A task explicitly assigned to a permanent (non-ephemeral) agent is exactly how ephemeral-off mode is meant to run, so those are allowed through; everything else is re-queued for the scheduler to auto-assign a permanent agent or hold. + */ + private async blockOuterDispatchWhenEphemeralDisabled(task: Task): Promise { + const settings = await this.store.getSettings(); + if (settings.ephemeralAgentsEnabled !== false) return false; + + // A permanent (non-ephemeral) assignment is the sanctioned executor when + // ephemeral workers are off. `assignedAgentId` is only ever set by permanent + // assignment — default ephemeral mode never sets it — so when we cannot + // resolve the agent (no agentStore) we trust the presence of the id and allow + // the run rather than starving a legitimately-assigned task. + const assignedId = task.assignedAgentId?.trim(); + if (assignedId) { + if (!this.options.agentStore) return false; + const agent = await this.options.agentStore.getAgent(assignedId).catch(() => null); + if (agent && !isEphemeralAgent(agent)) return false; + } + + const liveTask = (await this.store.getTask(task.id).catch(() => null)) ?? task; + if (liveTask.column !== "todo") { + await this.store.moveTask(liveTask.id, "todo", { + preserveProgress: true, + preserveWorktree: true, + preserveResumeState: true, + moveSource: "engine", + recoveryRehome: true, + }); + } + await this.store.updateTask(liveTask.id, { status: "queued" }, this.getRunContextFor(liveTask.id)); + await this.store.logEntry( + liveTask.id, + "queued — ephemeral agents disabled; no permanent executor assigned", + "Executor pre-dispatch ephemeral gate blocked workflow/authoritative execution.", + this.getRunContextFor(liveTask.id), + ); + executorLog.log(`${liveTask.id}: executor dispatch blocked — ephemeralAgentsEnabled=false and no permanent agent assigned`); + return true; + } + async execute(task: Task): Promise { this.completionFinalizedTaskIds.delete(task.id); await this.clearStalePauseAbortBeforeDispatch(task); @@ -8786,6 +8829,12 @@ export class TaskExecutor { return; } if (await this.blockOuterDispatchWhenDependenciesUnmet(task)) return; + // FNXC:EphemeralAgents 2026-07-01-00:00: gate ALL workflow dispatch paths + // (graph/authoritative/work-engine) on ephemeralAgentsEnabled before any of + // them can claim the task. Placed inside the outer-dispatch block so seam + // re-entry (interceptor registered) is unaffected, and ahead of every path + // so the single check covers all three entry points. + if (await this.blockOuterDispatchWhenEphemeralDisabled(task)) return; const graphOwned = await this.maybeExecuteWorkflowGraph(task); if (graphOwned) return; const authoritativeOwned = await this.options.workflowAuthoritativeDispatch?.(task); @@ -9919,7 +9968,7 @@ export class TaskExecutor { const customTools = [ this.createTaskUpdateTool(task.id, codeReviewVerdicts, sessionRef, stepCheckpoints, stuckDetector), this.createTaskLogTool(task.id), - this.createTaskCreateTool(), + this.createTaskCreateTool(!identityAgent || isEphemeralAgent(identityAgent)), this.createTaskAddDepTool(task.id), this.createTaskDoneTool(task.id, worktreePath, detail.prompt ?? "", codeReviewVerdicts, () => { taskDone = true; }, audit), createRunVerificationTool({ @@ -11932,8 +11981,12 @@ export class TaskExecutor { return sharedCreateTaskLogTool(this.store, taskId); } - private createTaskCreateTool(): ToolDefinition { - return sharedCreateTaskCreateTool(this.store, { sourceType: "api" }, { rootDir: this.rootDir }); + /* + FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: + A task-execution session is an ephemeral worker when no permanent identity agent governs it (default executor-FN-XXXX worker) or the governing agent is itself ephemeral. Pass that through so fn_task_create honors the project `ephemeralAgentsCanCreateTasks` toggle; permanent-agent sessions are never gated. + */ + private createTaskCreateTool(callerIsEphemeral: boolean): ToolDefinition { + return sharedCreateTaskCreateTool(this.store, { sourceType: "api" }, { rootDir: this.rootDir, callerIsEphemeral }); } private createTaskDocumentWriteTool(taskId: string): ToolDefinition {