FN-8821: retire ephemeral-agent routing branches

Keep the legacy ephemeral-agent setting compatible while making all workflow routing durable-principal-only.

- Preserve and expose the legacy setting as a routing-inert compatibility field.
- Remove scheduler, executor, and mission-start branches that honored the deprecated toggle.
- Add coverage for settings persistence and durable routing invariants.

Files changed:
 .changeset/fn-8821-retire-ephemeral-routing.md     |   7 +
 docs/settings-reference.md                         |   4 +-
 .../core/src/__tests__/settings-parity.test.ts     |   8 +-
 packages/core/src/config/settings-schema.ts        |  11 +-
 packages/core/src/task-store/settings-helpers.ts   |  15 +-
 packages/core/src/task-store/settings-ops.ts       |  12 +-
 packages/core/src/types/settings/settings-scope.ts |   5 +-
 .../__tests__/SettingsModal.general.test.tsx       |  23 ++
 .../settings/sections/GeneralSection.tsx           |  13 ++
 .../src/__tests__/mission-start-routing.test.ts    | 104 +++++++++
 ...ecutor-ephemeral-disabled-dispatch-gate.test.ts | 243 +++++++++++++++++++++
 .../__tests__/scheduler-ephemeral-toggle.test.ts   | 125 +++++++++++
 12 files changed, 542 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-8821

Fusion-Task-Lineage: e81ec9c0-f2b0-48f9-ae2b-9bbde4491447

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-07 02:33:35 -07:00
parent eaadd153b1
commit fc2040ca84
12 changed files with 542 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep legacy agent setting input from changing mission or workflow routing.
category: fix
dev: Retires residual scheduler, executor, and mission-start compatibility routing authority.

View File

@@ -622,7 +622,7 @@ Default notes:
| `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). |
| `verificationCommandTimeoutMs` | `number` | `undefined` | Optional project-scoped default timeout in milliseconds for executor `fn_run_verification` and configured deterministic test/build verification commands. When unset, `fn_run_verification` keeps its scope defaults (300s package, 900s workspace); when set to a positive value, it overrides both scope defaults while all verification still respects the 1800s hard cap. Set `0` or leave unset to use the legacy scope defaults. Marathon command shapes (`pnpm test`, `pnpm test:full`, `pnpm verify:workspace`, whole-package tests without file filters, and repeat loops) are soft-capped unless the agent explicitly passes `allowFullSuite: true`; opt-in full-suite runs still emit progress heartbeats and obey the hard cap. Project settings override global/default settings via the normal project settings precedence. |
| `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. |
| `ephemeralAgentsEnabled` | `boolean` | legacy compatibility | Legacy input is accepted for existing settings records but no longer appears in Settings or controls workflow-stage routing. Classified workflow sessions always route through durable multi-role principals; operators configure workflow-session capacity on agents instead. |
| `ephemeralAgentsEnabled` | `boolean` | `true` | Persisted project compatibility input retained for existing configurations and clients. It cannot control scheduler admission or assignment, executor dispatch/re-entry, mission start, or workflow-stage principal routing. Classified workflow sessions always route through durable multi-role principals; operators configure workflow-session capacity on agents instead. |
| `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). |
| `sandboxProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; autoApproveBackendIds?: string[] }` | `{}` | Approval policy for sandbox host-bootstrap operations (backend install/pull/probe during `SandboxBackend.prepare()`). Default posture is strict: `approvalMode` resolves to `always`; `autoApproveBackendIds` defaults to `["native"]`. |
| `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. |
@@ -1902,4 +1902,4 @@ Settings → Authentication can hold multiple named credential accounts for each
### Workflow principal limits
`runtimeConfig.maxWorkflowSessions` is an optional per-agent cap for durable workflow sessions. It is independent of heartbeat `maxConcurrentRuns`: enabling a built-in agent heartbeat neither consumes nor changes workflow-session capacity. The former `ephemeralAgentsEnabled` value is accepted only as legacy configuration compatibility and no longer controls workflow-stage routing.
`runtimeConfig.maxWorkflowSessions` is an optional per-agent cap for durable workflow sessions. It is independent of heartbeat `maxConcurrentRuns`: enabling a built-in agent heartbeat neither consumes nor changes workflow-session capacity. The persisted `ephemeralAgentsEnabled` compatibility input defaults to `true`; it cannot control scheduler admission or assignment, executor dispatch/re-entry, mission start, or workflow-stage principal routing.

View File

@@ -286,12 +286,12 @@ describe("settings key parity", () => {
expect(isGlobalSettingsKey("executorAllowSiblingBranchRename")).toBe(false);
});
it("retires the ephemeral workflow-stage setting while accepting stale input", () => {
expect(DEFAULT_PROJECT_SETTINGS).not.toHaveProperty("ephemeralAgentsEnabled");
expect(isProjectSettingsKey("ephemeralAgentsEnabled")).toBe(false);
it("defaults the routing-inert ephemeral compatibility input and keeps it project-scoped", () => {
expect(DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled).toBe(true);
expect(isProjectSettingsKey("ephemeralAgentsEnabled")).toBe(true);
expect(isGlobalSettingsKey("ephemeralAgentsEnabled")).toBe(false);
expect(canonicalizeSettings({ ephemeralAgentsEnabled: false } as import("../types.js").Settings))
.not.toHaveProperty("ephemeralAgentsEnabled");
.toMatchObject({ ephemeralAgentsEnabled: false });
});
it("defaults ephemeralAgentsCanCreateTasks to true and keeps it project-scoped", () => {

View File

@@ -66,9 +66,7 @@ type MovedProjectSettingsKey =
| "validatorFallbackThinkingLevel";
type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy" | "selectedWorkflowModelLanes";
/** Legacy inputs that remain typed only long enough for read/write compatibility stripping. */
type RetiredProjectSettingsKey = "ephemeralAgentsEnabled";
type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey | NonDefaultProjectSettingsKey | RetiredProjectSettingsKey>;
type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey | NonDefaultProjectSettingsKey>;
/**
* Settings schema source of truth.
@@ -632,6 +630,13 @@ export const DEFAULT_PROJECT_SETTINGS = {
// coverage. Falls back to package/explicit command when no tests resolve.
scopeVerificationToChangedFiles: true,
/*
FNXC:WorkflowAgentRouting 2026-08-07-08:45:
Keep the legacy project setting defaulted and persistable for existing clients and
configuration records. Workflow-stage routing must ignore its value; durable role
principals own scheduler, executor, and mission-stage authority.
*/
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.
*/

View File

@@ -15,17 +15,12 @@ import { validateWorktrunkSettings } from "../config/worktrunk-settings.js";
*/
export function canonicalizeSettings(settings: Settings): Settings {
/*
FNXC:WorkflowAgentRouting 2026-08-07-06:08:
FN-8764 retires the ephemeral workflow-stage switch. Existing settings and stale clients
may still send it, but it must be discarded rather than influence durable principal routing.
FNXC:WorkflowAgentRouting 2026-08-07-08:45:
Strip only the obsolete globalMaxConcurrent key. ephemeralAgentsEnabled remains a persisted
compatibility input; scheduler, executor, and mission routing deliberately ignore its value.
*/
const { globalMaxConcurrent, ephemeralAgentsEnabled: _retiredEphemeralAgentsEnabled, ...rest } = settings as Settings & {
globalMaxConcurrent?: number;
ephemeralAgentsEnabled?: boolean;
};
const base = globalMaxConcurrent !== undefined || _retiredEphemeralAgentsEnabled !== undefined
? (rest as Settings)
: settings;
const { globalMaxConcurrent, ...rest } = settings as Settings & { globalMaxConcurrent?: number };
const base = globalMaxConcurrent !== undefined ? (rest as Settings) : settings;
const canonicalWorktrunk = (() => {
try {

View File

@@ -89,15 +89,13 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settin
})()
: patch;
/*
FNXC:WorkflowAgentRouting 2026-08-07-06:08:
The removed ephemeral-stage setting remains accepted at this boundary for stale clients,
then is stripped before the atomic settings snapshot and revision are persisted.
FNXC:WorkflowAgentRouting 2026-08-07-08:45:
Preserve ephemeralAgentsEnabled in project updates for configuration compatibility. Its
routing-inert behavior is enforced exclusively by scheduler, executor, and mission consumers.
*/
const { ephemeralAgentsEnabled: _retiredEphemeralAgentsEnabled, ...workflowPrincipalPatch } = guardedPatch;
// Filter out global-only fields — they should go through updateGlobalSettings()
// Filter out global-only fields — they should go through updateGlobalSettings().
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(workflowPrincipalPatch)) {
for (const [key, value] of Object.entries(guardedPatch)) {
if (!isGlobalOnlySettingsKey(key)) {
(projectPatch as Record<string, unknown>)[key] = value;
}

View File

@@ -1746,8 +1746,9 @@ export interface ProjectSettings {
*/
planApprovalMode?: "workflow" | "auto-approve-all" | "require-all";
/**
* @deprecated FN-8764 accepts this legacy input only so upgrades can discard it.
* Workflow stages always route through durable multi-role principals; this flag has no effect.
* FNXC:WorkflowAgentRouting 2026-08-07-08:57:
* Retain this legacy input for persisted settings and client compatibility. Workflow stages
* always route through durable multi-role principals; this flag cannot affect that routing.
*/
ephemeralAgentsEnabled?: boolean;
/*

View File

@@ -1392,6 +1392,29 @@ describe("SettingsModal", () => {
expect(screen.getByRole("option", { name: "Require changelog update (existing changelog)" })).toBeInTheDocument();
});
it("persists the routing-inert ephemeral agent compatibility input", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const toggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
expect(toggle.checked).toBe(true);
await settingsModalUser.click(toggle);
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled());
expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({ ephemeralAgentsEnabled: false });
});
it("defaults the ephemeral agent compatibility input when an upgraded record omits it", async () => {
const { ephemeralAgentsEnabled: _omitted, ...upgradeSettings } = defaultSettings;
mockFetchSettings.mockResolvedValueOnce(upgradeSettings);
mockFetchSettingsByScope.mockResolvedValueOnce({ global: defaultSettings, project: {} });
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
expect((screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement).checked).toBe(true);
});
it("reports Quick Chat launcher changes immediately before save", async () => {
const onQuickChatButtonModeChange = vi.fn();
renderModal({ initialSection: "general", onQuickChatButtonModeChange });

View File

@@ -284,6 +284,19 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
{!isKnownSelectableWorkflow(refinementTaskWorkflowValue) && (<option value={refinementTaskWorkflowValue}>{refinementTaskWorkflowValue}</option>)}
</select>
</div>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="ephemeralAgentsEnabled" className="checkbox-label">
<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>
<SettingsHelpTip settingKey="ephemeralAgentsEnabled">{t("settings.general.ephemeralAgentsCompatibilityHint", " Retained for configuration compatibility. This setting does not affect scheduler assignment or admission, executor workflow dispatch or re-entry, mission start, or workflow-stage principal routing. ")}</SettingsHelpTip>
</div>
</div>
{/*
FNXC:WorkflowAgentRouting 2026-08-07-08:45:
Retain this project setting's UI round trip for operators with existing configuration.
Its value is intentionally routing-inert: durable workflow principals decide all stages.
*/}
{/*
FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00:
Operators choose free creation, an operator-mailbox proposal, or denial for ephemeral worker follow-ups.

View File

@@ -0,0 +1,104 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
import { request } from "../test-request.js";
type CompatibilitySetting = boolean | undefined;
type Mission = {
id: string;
status: "planning" | "active" | "blocked";
autoAdvance: boolean;
autopilotEnabled: boolean;
};
type Slice = {
id: string;
missionId: string;
status: "pending" | "active";
};
function createFixture(ephemeralAgentsEnabled: CompatibilitySetting) {
const mission: Mission = {
id: "M-START-1",
status: "planning",
autoAdvance: false,
autopilotEnabled: false,
};
const slice: Slice = { id: "SL-START-1", missionId: mission.id, status: "pending" };
const getSettings = vi.fn(async () => ({ ephemeralAgentsEnabled }));
const missionStore = {
getMission: vi.fn(async (id: string) => id === mission.id ? mission : undefined),
findNextPendingSlice: vi.fn(async (id: string) => id === mission.id && slice.status === "pending" ? slice : undefined),
updateMission: vi.fn(async (_id: string, patch: Partial<Mission>) => {
Object.assign(mission, patch);
return mission;
}),
activateSlice: vi.fn(async (id: string) => {
if (id === slice.id) slice.status = "active";
return slice;
}),
getMissionWithHierarchy: vi.fn(async (id: string) => id === mission.id ? { ...mission, milestones: [{ slices: [{ ...slice, features: [] }] }] } : undefined),
};
const store = {
getMissionStore: () => missionStore,
getGoalStore: () => ({}),
getSettings,
getRootDir: () => "/tmp/mission-start-routing",
backendMode: true,
} as unknown as TaskStore;
const app = express();
app.use(express.json());
app.use("/api/missions", createMissionRouter(store));
return { app, getSettings, mission, missionStore, slice };
}
/*
FNXC:MissionRouting 2026-08-07-08:36:
`ephemeralAgentsEnabled` is a retired client-compatibility input, not mission admission policy.
Mission start must activate a valid planning mission and its pending slice without reading settings or
preflighting legacy executor inventory; durable workflow principals are resolved only at workflow execution.
*/
describe("mission start routing", () => {
it.each<CompatibilitySetting>([undefined, true, false])(
"activates a planning mission independently of compatibility input %s",
async (ephemeralAgentsEnabled) => {
const { app, getSettings, mission, missionStore, slice } = createFixture(ephemeralAgentsEnabled);
const response = await request(app, "POST", `/api/missions/${mission.id}/start`);
expect(response.status).toBe(200);
expect(mission.status).toBe("active");
expect(mission.autoAdvance).toBe(true);
expect(mission.autopilotEnabled).toBe(true);
expect(slice.status).toBe("active");
expect(missionStore.updateMission).toHaveBeenCalledOnce();
expect(missionStore.activateSlice).toHaveBeenCalledWith(slice.id);
expect(getSettings).not.toHaveBeenCalled();
},
);
it("rejects a non-planning mission without activating its slice", async () => {
const { app, mission, missionStore, slice } = createFixture(false);
mission.status = "active";
const response = await request(app, "POST", `/api/missions/${mission.id}/start`);
expect(response.status).toBe(409);
expect(slice.status).toBe("pending");
expect(missionStore.activateSlice).not.toHaveBeenCalled();
});
it("rejects a planning mission with no pending slice", async () => {
const { app, mission, missionStore, slice } = createFixture(false);
slice.status = "active";
const response = await request(app, "POST", `/api/missions/${mission.id}/start`);
expect(response.status).toBe(400);
expect(mission.status).toBe("planning");
expect(missionStore.updateMission).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,243 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import {
AgentSemaphore,
clearPreHeldExecutorSlotsForTests,
hasPreHeldExecutorSlot,
registerPreHeldExecutorSlot,
} from "../concurrency/concurrency.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
const now = "2026-08-07T00:00:00.000Z";
function task(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-8821-EXECUTOR",
title: "Executor compatibility regression",
description: "Dispatch through the workflow graph",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
status: null,
paused: false,
userPaused: false,
autoMerge: true,
mergeRetries: 0,
createdAt: now,
updatedAt: now,
...overrides,
} as TaskDetail;
}
function settings(ephemeralAgentsEnabled: boolean | undefined) {
return {
autoMerge: true,
maxAutoMergeRetries: 3,
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15_000,
ephemeralAgentsEnabled,
};
}
/*
FNXC:WorkflowAgentRouting 2026-08-07-09:11:
A non-scheduler TaskExecutor.execute() entry must run the real graph admission hook regardless
of the persisted compatibility value. The graph owns durable principal acquisition, unavailable
principal holds, and capacity fencing; outer dispatch must not rebound or queue work under this setting.
*/
const graphDefinition = {
id: "WF-fn-8821-principal",
name: "FN-8821 principal fixture",
ir: {
version: "v1",
name: "FN-8821 principal fixture",
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "script", config: { seam: "execute", scriptName: "noop" } },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "execute" }, { from: "execute", to: "end" }],
},
};
function durableExecutorAgent(overrides: Record<string, unknown> = {}) {
return {
id: "workflow-executor",
name: "Workflow Executor",
state: "active",
roles: ["executor"],
createdAt: now,
runtimeConfig: {},
...overrides,
};
}
function createProductionGraphHarness(
ephemeralAgentsEnabled: boolean | undefined,
agents: unknown[] = [durableExecutorAgent()],
taskOverrides: Partial<TaskDetail> = {},
semaphore?: AgentSemaphore,
) {
const store = createMockStore();
const live = task(taskOverrides);
store.getTask.mockResolvedValue(live);
store.getRootDir = vi.fn(() => "/tmp/fn-8821-project");
store.getSettings.mockResolvedValue(settings(ephemeralAgentsEnabled));
store.getTaskWorkflowSelectionAsync.mockResolvedValue({ workflowId: graphDefinition.id, stepIds: [] });
store.getTaskWorkflowSelection.mockReturnValue({ workflowId: graphDefinition.id, stepIds: [] });
store.getWorkflowDefinition = vi.fn(async () => graphDefinition);
store.upsertWorkflowWorkItem = vi.fn(async (input: Record<string, unknown>) => ({ id: "work-item-1", ...input }));
const agentStore = {
workflowProjectId: "project-fn-8821",
listAgents: vi.fn(async () => agents),
};
const executor = new TaskExecutor(store, "/tmp/fn-8821-executor", { agentStore, semaphore } as any);
const acquire = vi.spyOn((executor as any).workflowAgentCapacity, "acquire").mockResolvedValue({ status: "acquired" });
const release = vi.spyOn((executor as any).workflowAgentCapacity, "release").mockResolvedValue(undefined);
return { store, live, executor, agentStore, acquire, release };
}
describe("executor compatibility setting is routing-inert", () => {
afterEach(() => {
clearPreHeldExecutorSlotsForTests();
(TaskExecutor as unknown as { processWideGraphRouting: Set<string> }).processWideGraphRouting.clear();
});
it.each([undefined, true, false])(
"runs real graph principal admission for unassigned direct re-entry with compatibility input %s",
async (ephemeralAgentsEnabled) => {
resetExecutorMocks();
const { store, live, executor, agentStore, acquire, release } = createProductionGraphHarness(ephemeralAgentsEnabled);
await executor.execute(live);
expect(agentStore.listAgents).toHaveBeenCalledWith({ includeEphemeral: true });
expect(store.upsertWorkflowWorkItem).toHaveBeenCalled();
expect(acquire).toHaveBeenCalledWith(expect.objectContaining({
projectId: "project-fn-8821",
agent: expect.objectContaining({ id: "workflow-executor" }),
maxProjectSessions: 2,
}));
expect(release).toHaveBeenCalledOnce();
expect((TaskExecutor as unknown as { processWideGraphRouting: Set<string> }).processWideGraphRouting).not.toContain(live.id);
expect(store.upsertWorkflowWorkItem).toHaveBeenCalledWith(expect.objectContaining({
state: "running",
principalAgentId: "workflow-executor",
workflowRole: "executor",
authorityKind: "role-pool",
}));
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith(live.id, expect.objectContaining({ status: "queued" }), undefined);
expect(store.logEntry).not.toHaveBeenCalledWith(
live.id,
expect.stringContaining("ephemeral agents disabled"),
expect.anything(),
expect.anything(),
);
},
);
it.each([undefined, true, false])(
"preserves assigned workflow principals through scheduler-held graph dispatch for compatibility input %s",
async (ephemeralAgentsEnabled) => {
resetExecutorMocks();
const semaphore = new AgentSemaphore(1);
expect(semaphore.tryAcquire()).toBe(true);
const { store, live, executor, acquire, release } = createProductionGraphHarness(
ephemeralAgentsEnabled,
[durableExecutorAgent()],
{ assignedAgentId: "workflow-executor" },
semaphore,
);
registerPreHeldExecutorSlot(live.id);
await executor.execute(live);
expect(store.upsertWorkflowWorkItem).toHaveBeenCalledWith(expect.objectContaining({
state: "running",
principalAgentId: "workflow-executor",
workflowRole: "executor",
authorityKind: "task-assignee",
}));
expect(acquire).toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(acquire.mock.calls.length);
expect(hasPreHeldExecutorSlot(live.id)).toBe(false);
expect(semaphore.activeCount).toBe(0);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
},
);
it("keeps an explicit-false direct re-entry behind the real unmet-dependency gate", async () => {
resetExecutorMocks();
const { store, live, executor, agentStore, acquire } = createProductionGraphHarness(
false,
[durableExecutorAgent()],
{ dependencies: ["FN-8821-PARENT"] },
);
const parent = task({
id: "FN-8821-PARENT",
dependencies: [],
column: "in-progress",
});
store.listTasks.mockResolvedValue([live, parent]);
await executor.execute(live);
expect(agentStore.listAgents).not.toHaveBeenCalled();
expect(acquire).not.toHaveBeenCalled();
expect(store.transitionQueuedEpisode).toHaveBeenCalledWith(live.id, expect.objectContaining({
signature: "dependency:FN-8821-PARENT",
blockedBy: parent.id,
}));
expect(store.upsertWorkflowWorkItem).not.toHaveBeenCalled();
});
it.each([undefined, true, false])(
"holds unavailable workflow principals through their graph owner for compatibility input %s",
async (ephemeralAgentsEnabled) => {
resetExecutorMocks();
const { store, live, executor, agentStore, acquire } = createProductionGraphHarness(ephemeralAgentsEnabled, []);
await executor.execute(live);
expect(agentStore.listAgents).toHaveBeenCalledOnce();
expect(acquire).not.toHaveBeenCalled();
expect(store.upsertWorkflowWorkItem).toHaveBeenCalledWith(expect.objectContaining({
state: "held",
blockedReason: "workflow-principal-role-pool-exhausted:executor",
workflowRole: "executor",
authorityKind: null,
}));
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
},
);
it.each([undefined, true, false])(
"holds saturated workflow principals through graph capacity for compatibility input %s",
async (ephemeralAgentsEnabled) => {
resetExecutorMocks();
const { store, live, executor, acquire } = createProductionGraphHarness(ephemeralAgentsEnabled);
acquire.mockResolvedValue({ status: "held", reason: "project-capacity" });
await executor.execute(live);
expect(acquire).toHaveBeenCalledOnce();
expect(store.upsertWorkflowWorkItem).toHaveBeenCalledWith(expect.objectContaining({
state: "held",
blockedReason: "workflow-principal-project-capacity:executor",
principalAgentId: "workflow-executor",
authorityKind: "role-pool",
}));
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
},
);
});

View File

@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { Scheduler } from "../scheduler.js";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return { ...actual, existsSync: vi.fn() };
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, readFile: vi.fn() };
});
const PASSED_PLAN_REVIEW = {
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "passed" as const,
source: "node" as const,
phase: "pre-merge" as const,
};
function task(overrides: Partial<Task> = {}): Task {
return {
id: "FN-8821-SCHEDULER",
title: "Scheduler compatibility regression",
description: "A dispatchable workflow task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
workflowStepResults: [PASSED_PLAN_REVIEW],
createdAt: "2026-08-07T00:00:00.000Z",
updatedAt: "2026-08-07T00:00:00.000Z",
...overrides,
} as Task;
}
function storeWith(ready: Task, ephemeralAgentsEnabled: boolean | undefined): TaskStore {
const updateTask = vi.fn(async (_id: string, patch: Partial<Task>) => Object.assign(ready, patch));
return {
listTasks: vi.fn(async () => [ready]),
getTask: vi.fn(async () => ready),
getSettings: vi.fn(async () => ({
maxConcurrent: 2,
maxWorktrees: 4,
ephemeralAgentsEnabled,
})),
updateSettings: vi.fn(async () => undefined),
updateTask,
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
ready.column = column;
return ready;
}),
moveTaskIf: vi.fn(async (_id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>) => {
if (!await predicate(ready) || ready.column === column) return { task: ready, moved: false };
ready.column = column;
return { task: ready, moved: true };
}),
parseFileScopeFromPrompt: vi.fn(async () => []),
logEntry: vi.fn(async () => undefined),
transitionQueuedEpisode: vi.fn(async () => ({ appended: true, task: ready })),
getRootDir: vi.fn(() => "/tmp/fn-8821-scheduler"),
getTasksDir: vi.fn(() => "/tmp/fn-8821-scheduler/.fusion/tasks"),
on: vi.fn(),
off: vi.fn(),
recordRunAuditEvent: vi.fn(async () => undefined),
renewSymbolLocks: vi.fn(async () => ({ renewed: [], lost: [] })),
getMissionStore: vi.fn(() => ({ listMissions: () => [], listGoalIdsForMission: () => [] })),
} as unknown as TaskStore;
}
/*
FNXC:WorkflowScheduling 2026-08-07-09:01:
`ephemeralAgentsEnabled` remains an accepted persisted compatibility input, but scheduler release
must not inspect it to assign, queue, or reject workflow work. Principal selection and capacity
belong to graph admission after this production scheduler handoff.
*/
describe("scheduler compatibility setting is routing-inert", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nBody");
});
it.each([undefined, true, false])(
"releases an unassigned workflow task with compatibility input %s without legacy assignment or queueing",
async (ephemeralAgentsEnabled) => {
const ready = task();
const store = storeWith(ready, ephemeralAgentsEnabled);
const onSchedule = vi.fn();
const scheduler = new Scheduler(store, { onSchedule });
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
expect(ready.column).toBe("in-progress");
expect(onSchedule).toHaveBeenCalledWith(expect.objectContaining({ id: ready.id, column: "in-progress" }));
expect(store.updateTask).not.toHaveBeenCalledWith(ready.id, expect.objectContaining({ assignedAgentId: expect.any(String) }));
expect(store.updateTask).not.toHaveBeenCalledWith(ready.id, expect.objectContaining({ status: "queued" }));
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
},
);
it.each([undefined, true, false])(
"preserves an assigned task's normal release for compatibility input %s",
async (ephemeralAgentsEnabled) => {
const ready = task({ id: "FN-8821-SCHEDULER-ASSIGNED", assignedAgentId: "durable-owner" });
const store = storeWith(ready, ephemeralAgentsEnabled);
const onSchedule = vi.fn();
const scheduler = new Scheduler(store, { onSchedule });
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
expect(ready.column).toBe("in-progress");
expect(ready.assignedAgentId).toBe("durable-owner");
expect(onSchedule).toHaveBeenCalledWith(expect.objectContaining({ id: ready.id, column: "in-progress" }));
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
},
);
});