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:
7
.changeset/fn-8821-retire-ephemeral-routing.md
Normal file
7
.changeset/fn-8821-retire-ephemeral-routing.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/*
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
104
packages/dashboard/src/__tests__/mission-start-routing.test.ts
Normal file
104
packages/dashboard/src/__tests__/mission-start-routing.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
125
packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts
Normal file
125
packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user