feat(FN-4153): complete Step 4 — block ephemeral spawn when disabled

Fusion-Task-Id: FN-4153
Fusion-Task-Lineage: 6ec09467-c7a0-4a9d-9a09-16d5ef6f7a86
This commit is contained in:
Fusion
2026-05-13 01:15:33 -07:00
committed by gsxdsm
parent efd5a8a4aa
commit 750645bcd8
3 changed files with 40 additions and 1 deletions

View File

@@ -19,7 +19,7 @@
* lookup on creation, the on-disk fallback on completion, and the startup
* sweep here close that gap.
*/
import type { AgentStore, AgentState, Agent, TaskStore, Task } from "@fusion/core";
import type { AgentStore, AgentState, Agent, TaskStore, Task, Settings } from "@fusion/core";
import { isEphemeralAgent } from "@fusion/core";
export interface TaskOwner {
@@ -43,6 +43,7 @@ export interface EphemeralWorkerManagerOptions {
* don't race the executor on the same agentId.
*/
isDeletionPendingExternal?: (agentId: string) => boolean;
getSettings?: () => Promise<Pick<Settings, "ephemeralAgentsEnabled">>;
}
const TERMINAL_TASK_COLUMNS = new Set<Task["column"]>(["done", "archived"]);
@@ -52,6 +53,7 @@ export class EphemeralWorkerManager {
private readonly taskStore: TaskStore;
private readonly log: EphemeralWorkerLogger;
private readonly isDeletionPendingExternal: (agentId: string) => boolean;
private readonly getSettings: () => Promise<Pick<Settings, "ephemeralAgentsEnabled">>;
/** taskId → owner. In-memory only; on-disk fallback covers restart gaps. */
private readonly taskAgentMap = new Map<string, TaskOwner>();
@@ -65,6 +67,7 @@ export class EphemeralWorkerManager {
this.taskStore = options.taskStore;
this.log = options.logger;
this.isDeletionPendingExternal = options.isDeletionPendingExternal ?? (() => false);
this.getSettings = options.getSettings ?? (async () => ({ ephemeralAgentsEnabled: true }));
}
// ── public surface ───────────────────────────────────────────────────────
@@ -125,6 +128,14 @@ export class EphemeralWorkerManager {
}
}
const settings = await this.getSettings();
if (settings.ephemeralAgentsEnabled === false) {
this.log.warn(
`Task ${task.id} has no permanent agent assignment; ephemeralAgentsEnabled=false — refusing to spawn ephemeral worker`,
);
return null;
}
const agent = await this.agentStore.createAgent({
name: `executor-${task.id}`,
role: "executor",

View File

@@ -754,6 +754,30 @@ describe("InProcessRuntime", () => {
expect(assignTaskSpy.mock.invocationCallOrder[0]).toBeLessThan(updateStateSpy.mock.invocationCallOrder[0]);
}, 30000);
it("does not spawn runtime task-worker agents when ephemeral agents are disabled", async () => {
mockTaskStoreSettings.ephemeralAgentsEnabled = false;
await runtime.start();
const store = getAgentStore(runtime);
const createAgentSpy = vi.spyOn(store, "createAgent");
const warnSpy = vi.spyOn(runtimeLog, "warn");
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
};
executorOptions.onStart?.({ id: "FN-1663" } as Task, join(testDir, "worktree-FN-1663"));
await vi.waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Task FN-1663 has no permanent agent assignment; ephemeralAgentsEnabled=false"),
);
});
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents.some((agent: Agent) => agent.name === "executor-FN-1663")).toBe(false);
expect(createAgentSpy).not.toHaveBeenCalledWith(expect.objectContaining({ name: "executor-FN-1663" }));
}, 30000);
it("falls back to runtime task-worker when assignedAgentId points to ephemeral agent", async () => {
await runtime.start();

View File

@@ -530,6 +530,10 @@ export class InProcessRuntime
taskStore: this.taskStore,
logger: runtimeLog,
isDeletionPendingExternal: (agentId) => this.executor?.isEphemeralDeletionPending(agentId) ?? false,
getSettings: async () => {
const settings = await this.taskStore.getSettings();
return { ephemeralAgentsEnabled: settings.ephemeralAgentsEnabled };
},
});
}
if (this.workerManager) {