diff --git a/.changeset/fn-8821-retire-ephemeral-routing.md b/.changeset/fn-8821-retire-ephemeral-routing.md new file mode 100644 index 0000000000..da3b4151f4 --- /dev/null +++ b/.changeset/fn-8821-retire-ephemeral-routing.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4ab283e1f2..513409a9ad 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 7cf2dc3dd1..6ed6e79fb2 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -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", () => { diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index 22a7c2059f..ecbca18541 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -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; +type ProjectSettingsSchema = Omit; /** * 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. */ diff --git a/packages/core/src/task-store/settings-helpers.ts b/packages/core/src/task-store/settings-helpers.ts index 98e6e39950..436d5b0579 100644 --- a/packages/core/src/task-store/settings-helpers.ts +++ b/packages/core/src/task-store/settings-helpers.ts @@ -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 { diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index 21d7af6229..099cf2fcbc 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -89,15 +89,13 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial = {}; - for (const [key, value] of Object.entries(workflowPrincipalPatch)) { + for (const [key, value] of Object.entries(guardedPatch)) { if (!isGlobalOnlySettingsKey(key)) { (projectPatch as Record)[key] = value; } diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index 78d9cc03bb..319747c08d 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -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; /* diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 99ab07825e..5f800c8ec8 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -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 }); diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 3a232188db..973e97e7c1 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -284,6 +284,19 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError {!isKnownSelectableWorkflow(refinementTaskWorkflowValue) && ()} +
+
+ + {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. ")} +
+
+ {/* + 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. diff --git a/packages/dashboard/src/__tests__/mission-start-routing.test.ts b/packages/dashboard/src/__tests__/mission-start-routing.test.ts new file mode 100644 index 0000000000..d35f406c7b --- /dev/null +++ b/packages/dashboard/src/__tests__/mission-start-routing.test.ts @@ -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) => { + 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([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(); + }); +}); diff --git a/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts b/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts new file mode 100644 index 0000000000..771355c68f --- /dev/null +++ b/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts @@ -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 { + 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 = {}) { + 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 = {}, + 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) => ({ 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 }).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 }).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(); + }, + ); +}); diff --git a/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts b/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts new file mode 100644 index 0000000000..d0b9bc6f96 --- /dev/null +++ b/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts @@ -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(); + return { ...actual, existsSync: vi.fn() }; +}); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + 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 { + 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) => 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) => { + 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(); + }, + ); +});