fix: enforce ephemeralAgentsEnabled across the workflow engine

Add TaskExecutor.blockOuterDispatchWhenEphemeralDisabled, gating all three
workflow dispatch paths (graph / authoritative / work-engine) on
ephemeralAgentsEnabled at the top of execute(). Previously the toggle was
enforced only on the legacy scheduler/EphemeralWorkerManager path — whose
onTaskStart spawn refusal is a fire-and-forget callback that runs after
execution begins — so unassigned tasks reaching execute() off a non-scheduler
path still ran. Unassigned tasks are now re-queued for permanent-agent
assignment; permanent-agent-bound tasks still run. Adds regression coverage
across all three entry points.

Also includes the ephemeralAgentsCanCreateTasks project setting (default on)
gating fn_task_create for ephemeral callers in both the pi extension and the
executor task-worker tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-01 21:31:34 -07:00
parent ca88a6c6a2
commit 40b44e40b9
11 changed files with 419 additions and 5 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<boolean> {
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;

View File

@@ -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");
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -96,6 +96,15 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
<input id="ephemeralAgentsEnabled" type="checkbox" checked={form.ephemeralAgentsEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))}/>{t("settings.general.useEphemeralTaskWorkerAgents", " Use ephemeral task-worker agents ")}</label>
<small>{t("settings.general.whenEnabledDefaultFusionSpawnsShortLived", " When enabled (default), Fusion spawns short-lived ")}<code>executor-FN-XXXX</code>{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. ")}</small>
</div>
{/*
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.
*/}
<div className="form-group">
<label htmlFor="ephemeralAgentsCanCreateTasks" className="checkbox-label">
<input id="ephemeralAgentsCanCreateTasks" type="checkbox" checked={form.ephemeralAgentsCanCreateTasks !== false} onChange={(e) => setForm((f) => ({ ...f, ephemeralAgentsCanCreateTasks: e.target.checked }))}/>{t("settings.general.allowEphemeralAgentsToCreateTasks", " Allow ephemeral agents to create tasks ")}</label>
<small>{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.")}</small>
</div>
{/*
FNXC:Workspace 2026-06-24-16:00:
Workspace mode toggle: when enabled, the project root is treated as a workspace parent

View File

@@ -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 }),

View File

@@ -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> = {}): 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<string, unknown> = {}) {
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,
);
});
});

View File

@@ -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<typeof taskCreateParams>) => {
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,

View File

@@ -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<boolean> {
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<void> {
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 {