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:
7
.changeset/ephemeral-agents-can-create-tasks.md
Normal file
7
.changeset/ephemeral-agents-can-create-tasks.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user