FN-8847: remove retired ephemeral-agent setting
Remove the obsolete ephemeral-agent compatibility setting across configuration, persistence, UI, and localization. - Remove the setting from schemas, scoped settings, persistence helpers, and General settings UI. - Update translations, settings documentation, and parity coverage for the retired option. - Adjust scheduler, executor, dashboard, and PostgreSQL tests for the unconditional behavior. - Add a patch changeset for @runfusion/fusion. Files changed: .changeset/fn-8847-remove-ephemeral-setting.md | 7 +++ docs/settings-reference.md | 3 +- .../postgres/settings-persistence.pg.test.ts | 14 ++++++ .../core/src/__tests__/settings-parity.test.ts | 20 ++++++-- packages/core/src/config/settings-schema.ts | 7 --- packages/core/src/task-store/settings-helpers.ts | 14 ++++-- packages/core/src/task-store/settings-ops.ts | 11 +++-- packages/core/src/types/settings/settings-scope.ts | 6 --- .../__tests__/SettingsModal.general.test.tsx | 41 ++++------------- .../settings/sections/GeneralSection.tsx | 17 ------- .../settings-default-descriptions.test.tsx | 6 --- .../src/__tests__/mission-start-routing.test.ts | 34 ++++++-------- ...executor-ephemeral-disabled-dispatch-gate.test.ts | 53 ++++++++-------------- .../__tests__/scheduler-ephemeral-toggle.test.ts | 30 +++++------- packages/i18n/locales/en/app.json | 2 - packages/i18n/locales/es/app.json | 2 - packages/i18n/locales/fr/app.json | 2 - packages/i18n/locales/ko/app.json | 2 - packages/i18n/locales/zh-CN/app.json | 2 - packages/i18n/locales/zh-TW/app.json | 2 - packages/i18n/src/resources.d.ts | 2 - 21 files changed, 105 insertions(+), 172 deletions(-) Fusion-Task-Id: FN-8847 Fusion-Task-Lineage: 8a067ec6-0ec7-4aef-a48e-604f821f35d2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8847-remove-ephemeral-setting.md
Normal file
7
.changeset/fn-8847-remove-ephemeral-setting.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": major
|
||||
---
|
||||
|
||||
summary: Remove the retired ephemeral-agent compatibility setting and control.
|
||||
category: breaking
|
||||
dev: Stale ephemeralAgentsEnabled values are discarded during settings reads and updates.
|
||||
@@ -623,7 +623,6 @@ 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` | `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. |
|
||||
@@ -1903,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 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.
|
||||
`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. Scheduler release, executor graph admission and re-entry, mission start, and workflow-stage routing are governed by durable workflow principals and their configured capacity.
|
||||
|
||||
@@ -56,6 +56,20 @@ pgTest("VAL-CROSS-004: Settings persistence (PostgreSQL)", () => {
|
||||
expect(settings.autoMerge).toBe(false);
|
||||
});
|
||||
|
||||
it("discards retired ephemeral compatibility patches while preserving active project settings", async () => {
|
||||
const store = h.store();
|
||||
await store.updateSettings({
|
||||
ephemeralAgentsEnabled: false,
|
||||
ephemeralAgentTaskCreationPolicy: "deny",
|
||||
taskPrefix: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings).not.toHaveProperty("ephemeralAgentsEnabled");
|
||||
expect(settings.ephemeralAgentTaskCreationPolicy).toBe("deny");
|
||||
expect(settings.taskPrefix).toBe("ACTIVE");
|
||||
});
|
||||
|
||||
it("keeps the recommendation cap project-scoped when an untyped global patch includes it", async () => {
|
||||
const store = h.store();
|
||||
await store.updateSettings({ maxRecommendationsPerTask: 7 });
|
||||
|
||||
@@ -288,12 +288,22 @@ describe("settings key parity", () => {
|
||||
expect(isGlobalSettingsKey("executorAllowSiblingBranchRename")).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);
|
||||
it("removes the retired ephemeral compatibility input from settings and canonicalizes stale values", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS).not.toHaveProperty("ephemeralAgentsEnabled");
|
||||
expect(isProjectSettingsKey("ephemeralAgentsEnabled")).toBe(false);
|
||||
expect(isGlobalSettingsKey("ephemeralAgentsEnabled")).toBe(false);
|
||||
expect(canonicalizeSettings({ ephemeralAgentsEnabled: false } as import("../types.js").Settings))
|
||||
.toMatchObject({ ephemeralAgentsEnabled: false });
|
||||
|
||||
for (const retiredValue of [true, false]) {
|
||||
const persisted = { ephemeralAgentsEnabled: retiredValue, taskPrefix: "SURVIVES" } as import("../types.js").Settings;
|
||||
const canonical = canonicalizeSettings(persisted);
|
||||
expect(canonical).not.toHaveProperty("ephemeralAgentsEnabled");
|
||||
expect(canonical.taskPrefix).toBe("SURVIVES");
|
||||
expect(persisted).toHaveProperty("ephemeralAgentsEnabled", retiredValue);
|
||||
}
|
||||
|
||||
const absent = { taskPrefix: "UNCHANGED" } as import("../types.js").Settings;
|
||||
expect(canonicalizeSettings(absent)).not.toHaveProperty("ephemeralAgentsEnabled");
|
||||
expect(canonicalizeSettings(absent).taskPrefix).toBe("UNCHANGED");
|
||||
});
|
||||
|
||||
it("defaults ephemeralAgentsCanCreateTasks to true and keeps it project-scoped", () => {
|
||||
|
||||
@@ -632,13 +632,6 @@ 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,12 +15,16 @@ import { validateWorktrunkSettings } from "../config/worktrunk-settings.js";
|
||||
*/
|
||||
export function canonicalizeSettings(settings: Settings): Settings {
|
||||
/*
|
||||
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.
|
||||
FNXC:WorkflowAgentRouting 2026-08-09-01:04:
|
||||
FN-8847 removes the retired ephemeralAgentsEnabled compatibility input. Strip it with the
|
||||
earlier globalMaxConcurrent field at every read boundary so stale configuration payloads cannot
|
||||
reappear or affect durable workflow-principal routing.
|
||||
*/
|
||||
const { globalMaxConcurrent, ...rest } = settings as Settings & { globalMaxConcurrent?: number };
|
||||
const base = globalMaxConcurrent !== undefined ? (rest as Settings) : settings;
|
||||
const { globalMaxConcurrent, ephemeralAgentsEnabled: _ephemeralAgentsEnabled, ...rest } = settings as Settings & {
|
||||
globalMaxConcurrent?: number;
|
||||
ephemeralAgentsEnabled?: boolean;
|
||||
};
|
||||
const base = globalMaxConcurrent !== undefined || _ephemeralAgentsEnabled !== undefined ? (rest as Settings) : settings;
|
||||
|
||||
const canonicalWorktrunk = (() => {
|
||||
try {
|
||||
|
||||
@@ -104,14 +104,15 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settin
|
||||
})()
|
||||
: patch;
|
||||
/*
|
||||
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.
|
||||
FNXC:WorkflowAgentRouting 2026-08-09-01:04:
|
||||
FN-8847 rejects the retired ephemeralAgentsEnabled client patch before the configuration
|
||||
revision transaction. Canonicalization also removes stale stored copies, while active
|
||||
ephemeral task-creation policy fields keep their normal project-setting behavior.
|
||||
*/
|
||||
// Filter out global-only fields — they should go through updateGlobalSettings().
|
||||
// Filter out global-only and retired project fields before writing a configuration revision.
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(guardedPatch)) {
|
||||
if (!isGlobalOnlySettingsKey(key)) {
|
||||
if (!isGlobalOnlySettingsKey(key) && key !== "ephemeralAgentsEnabled") {
|
||||
(projectPatch as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1751,12 +1751,6 @@ export interface ProjectSettings {
|
||||
* FN-7557: default is now "auto-approve-all" (previously deferred to workflow via "workflow"). Unset/new projects bypass the manual awaiting-approval gate by default; projects with an explicit stored value are unaffected.
|
||||
*/
|
||||
planApprovalMode?: "workflow" | "auto-approve-all" | "require-all";
|
||||
/**
|
||||
* 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;
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00:
|
||||
The three-state policy routes ephemeral-worker follow-ups to allow, operator validation, or deny.
|
||||
|
||||
@@ -1402,41 +1402,20 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByRole("option", { name: "Require changelog update (existing changelog)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the enabled default in routing-inert ephemeral agent compatibility help", async () => {
|
||||
it("omits the retired ephemeral compatibility controls while preserving follow-up policy", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const helpTrigger = screen.getByTestId("settings-help-ephemeralAgentsEnabled");
|
||||
const helpId = helpTrigger.getAttribute("aria-describedby");
|
||||
expect(helpId).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Use ephemeral task-worker agents")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("settings-help-ephemeralAgentsEnabled")).not.toBeInTheDocument();
|
||||
expect(document.getElementById("ephemeralAgentsEnabled")).toBeNull();
|
||||
|
||||
const helpNote = document.getElementById(helpId!);
|
||||
expect(helpNote).toHaveAttribute("role", "note");
|
||||
expect(helpNote).toHaveTextContent(/default: enabled/i);
|
||||
expect(helpNote).toHaveTextContent(/does not affect scheduler assignment or admission, executor workflow dispatch or re-entry, mission start, or workflow-stage principal routing/i);
|
||||
});
|
||||
|
||||
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);
|
||||
const policy = screen.getByLabelText("Ephemeral agent follow-up tasks");
|
||||
expect(policy).toBeInTheDocument();
|
||||
await settingsModalUser.selectOptions(policy, "deny");
|
||||
await waitFor(() => expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({
|
||||
ephemeralAgentTaskCreationPolicy: "deny",
|
||||
}));
|
||||
});
|
||||
|
||||
it("reports Quick Chat launcher changes immediately before save", async () => {
|
||||
|
||||
@@ -298,23 +298,6 @@ 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">
|
||||
{/*
|
||||
FNXC:EphemeralAgentCompatibility 2026-08-08-06:12:
|
||||
FN-8831 requires this visible compatibility input to state its canonical enabled default in the shared help tip. It remains routing-inert: the saved value never controls scheduler admission, executor dispatch or re-entry, mission start, or workflow-stage principal routing.
|
||||
*/}
|
||||
<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. Default: enabled. 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.
|
||||
|
||||
@@ -252,12 +252,6 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
worktreeRebaseLocalBase: "worktrees.inAdditionToTheRemoteRebaseAboveAlso",
|
||||
worktrunk: "worktrees.disabledByDefaultOptInWhenEnabledFusion",
|
||||
// GeneralSection (project)
|
||||
/*
|
||||
* FNXC:EphemeralAgentCompatibility 2026-08-08-06:12:
|
||||
* FN-8831 keeps the visible compatibility checkbox in the surfaced-description inventory.
|
||||
* Its English help must state the enabled schema default while preserving that it is routing-inert.
|
||||
*/
|
||||
ephemeralAgentsEnabled: "general.ephemeralAgentsCompatibilityHint",
|
||||
allowAbsoluteFileBrowserPaths: "general.allowAbsoluteFileBrowserPathsHint",
|
||||
capacityRiskBannerEnabled: "general.warnOnTheBoardWhenTodoWorkExceeds",
|
||||
capacityRiskTodoThreshold: "general.bannerFiresWhenTodoCountIsStrictlyGreater",
|
||||
|
||||
@@ -5,8 +5,6 @@ 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";
|
||||
@@ -20,7 +18,7 @@ type Slice = {
|
||||
status: "pending" | "active";
|
||||
};
|
||||
|
||||
function createFixture(ephemeralAgentsEnabled: CompatibilitySetting) {
|
||||
function createFixture() {
|
||||
const mission: Mission = {
|
||||
id: "M-START-1",
|
||||
status: "planning",
|
||||
@@ -28,7 +26,7 @@ function createFixture(ephemeralAgentsEnabled: CompatibilitySetting) {
|
||||
autopilotEnabled: false,
|
||||
};
|
||||
const slice: Slice = { id: "SL-START-1", missionId: mission.id, status: "pending" };
|
||||
const getSettings = vi.fn(async () => ({ ephemeralAgentsEnabled }));
|
||||
const getSettings = vi.fn(async () => ({}));
|
||||
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),
|
||||
@@ -36,8 +34,8 @@ function createFixture(ephemeralAgentsEnabled: CompatibilitySetting) {
|
||||
Object.assign(mission, patch);
|
||||
return mission;
|
||||
}),
|
||||
activateSlice: vi.fn(async (id: string) => {
|
||||
if (id === slice.id) slice.status = "active";
|
||||
tryActivateNextPendingSlice: vi.fn(async (id: string) => {
|
||||
if (id === mission.id && slice.status === "pending") slice.status = "active";
|
||||
return slice;
|
||||
}),
|
||||
getMissionWithHierarchy: vi.fn(async (id: string) => id === mission.id ? { ...mission, milestones: [{ slices: [{ ...slice, features: [] }] }] } : undefined),
|
||||
@@ -56,16 +54,13 @@ function createFixture(ephemeralAgentsEnabled: CompatibilitySetting) {
|
||||
}
|
||||
|
||||
/*
|
||||
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.
|
||||
FNXC:MissionRouting 2026-08-09-01:04:
|
||||
FN-8847 removes the retired compatibility input. Mission start activates a valid planning mission
|
||||
and pending slice without settings reads; durable principals resolve 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);
|
||||
it("activates a planning mission without reading project settings", async () => {
|
||||
const { app, getSettings, mission, missionStore, slice } = createFixture();
|
||||
|
||||
const response = await request(app, "POST", `/api/missions/${mission.id}/start`);
|
||||
|
||||
@@ -75,24 +70,23 @@ describe("mission start routing", () => {
|
||||
expect(mission.autopilotEnabled).toBe(true);
|
||||
expect(slice.status).toBe("active");
|
||||
expect(missionStore.updateMission).toHaveBeenCalledOnce();
|
||||
expect(missionStore.activateSlice).toHaveBeenCalledWith(slice.id);
|
||||
expect(missionStore.tryActivateNextPendingSlice).toHaveBeenCalledWith(mission.id);
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-planning mission without activating its slice", async () => {
|
||||
const { app, mission, missionStore, slice } = createFixture(false);
|
||||
const { app, mission, missionStore, slice } = createFixture();
|
||||
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();
|
||||
expect(missionStore.tryActivateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a planning mission with no pending slice", async () => {
|
||||
const { app, mission, missionStore, slice } = createFixture(false);
|
||||
const { app, mission, missionStore, slice } = createFixture();
|
||||
slice.status = "active";
|
||||
|
||||
const response = await request(app, "POST", `/api/missions/${mission.id}/start`);
|
||||
|
||||
@@ -33,22 +33,20 @@ function task(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
} as TaskDetail;
|
||||
}
|
||||
|
||||
function settings(ephemeralAgentsEnabled: boolean | undefined) {
|
||||
function settings() {
|
||||
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.
|
||||
FNXC:WorkflowAgentRouting 2026-08-09-01:04:
|
||||
FN-8847 removes the retired compatibility input. Every TaskExecutor.execute() entry must use graph
|
||||
admission for durable principal acquisition, unavailable-principal holds, and capacity fencing.
|
||||
*/
|
||||
const graphDefinition = {
|
||||
id: "WF-fn-8821-principal",
|
||||
@@ -78,7 +76,6 @@ function durableExecutorAgent(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
function createProductionGraphHarness(
|
||||
ephemeralAgentsEnabled: boolean | undefined,
|
||||
agents: unknown[] = [durableExecutorAgent()],
|
||||
taskOverrides: Partial<TaskDetail> = {},
|
||||
semaphore?: AgentSemaphore,
|
||||
@@ -87,7 +84,7 @@ function createProductionGraphHarness(
|
||||
const live = task(taskOverrides);
|
||||
store.getTask.mockResolvedValue(live);
|
||||
store.getRootDir = vi.fn(() => "/tmp/fn-8821-project");
|
||||
store.getSettings.mockResolvedValue(settings(ephemeralAgentsEnabled));
|
||||
store.getSettings.mockResolvedValue(settings());
|
||||
store.getTaskWorkflowSelectionAsync.mockResolvedValue({ workflowId: graphDefinition.id, stepIds: [] });
|
||||
store.getTaskWorkflowSelection.mockReturnValue({ workflowId: graphDefinition.id, stepIds: [] });
|
||||
store.getWorkflowDefinition = vi.fn(async () => graphDefinition);
|
||||
@@ -102,17 +99,15 @@ function createProductionGraphHarness(
|
||||
return { store, live, executor, agentStore, acquire, release };
|
||||
}
|
||||
|
||||
describe("executor compatibility setting is routing-inert", () => {
|
||||
describe("executor routes workflow stages through durable principals", () => {
|
||||
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) => {
|
||||
it("runs real graph principal admission for unassigned direct re-entry", async () => {
|
||||
resetExecutorMocks();
|
||||
const { store, live, executor, agentStore, acquire, release } = createProductionGraphHarness(ephemeralAgentsEnabled);
|
||||
const { store, live, executor, agentStore, acquire, release } = createProductionGraphHarness();
|
||||
|
||||
await executor.execute(live);
|
||||
|
||||
@@ -140,17 +135,13 @@ describe("executor compatibility setting is routing-inert", () => {
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, true, false])(
|
||||
"preserves assigned workflow principals through scheduler-held graph dispatch for compatibility input %s",
|
||||
async (ephemeralAgentsEnabled) => {
|
||||
it("preserves assigned workflow principals through scheduler-held graph dispatch", async () => {
|
||||
resetExecutorMocks();
|
||||
const semaphore = new AgentSemaphore(1);
|
||||
expect(semaphore.tryAcquire()).toBe(true);
|
||||
const { store, live, executor, acquire, release } = createProductionGraphHarness(
|
||||
ephemeralAgentsEnabled,
|
||||
[durableExecutorAgent()],
|
||||
{ assignedAgentId: "workflow-executor" },
|
||||
semaphore,
|
||||
@@ -171,13 +162,11 @@ describe("executor compatibility setting is routing-inert", () => {
|
||||
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 () => {
|
||||
it("keeps direct re-entry behind the real unmet-dependency gate", async () => {
|
||||
resetExecutorMocks();
|
||||
const { store, live, executor, agentStore, acquire } = createProductionGraphHarness(
|
||||
false,
|
||||
[durableExecutorAgent()],
|
||||
{ dependencies: ["FN-8821-PARENT"] },
|
||||
);
|
||||
@@ -199,11 +188,9 @@ describe("executor compatibility setting is routing-inert", () => {
|
||||
expect(store.upsertWorkflowWorkItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([undefined, true, false])(
|
||||
"holds unavailable workflow principals through their graph owner for compatibility input %s",
|
||||
async (ephemeralAgentsEnabled) => {
|
||||
it("holds unavailable workflow principals through their graph owner", async () => {
|
||||
resetExecutorMocks();
|
||||
const { store, live, executor, agentStore, acquire } = createProductionGraphHarness(ephemeralAgentsEnabled, []);
|
||||
const { store, live, executor, agentStore, acquire } = createProductionGraphHarness([]);
|
||||
|
||||
await executor.execute(live);
|
||||
|
||||
@@ -217,14 +204,11 @@ describe("executor compatibility setting is routing-inert", () => {
|
||||
}));
|
||||
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) => {
|
||||
it("holds saturated workflow principals through graph capacity", async () => {
|
||||
resetExecutorMocks();
|
||||
const { store, live, executor, acquire } = createProductionGraphHarness(ephemeralAgentsEnabled);
|
||||
const { store, live, executor, acquire } = createProductionGraphHarness();
|
||||
acquire.mockResolvedValue({ status: "held", reason: "project-capacity" });
|
||||
|
||||
await executor.execute(live);
|
||||
@@ -238,6 +222,5 @@ describe("executor compatibility setting is routing-inert", () => {
|
||||
}));
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ function task(overrides: Partial<Task> = {}): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function storeWith(ready: Task, ephemeralAgentsEnabled: boolean | undefined): TaskStore {
|
||||
function storeWith(ready: Task): TaskStore {
|
||||
const updateTask = vi.fn(async (_id: string, patch: Partial<Task>) => Object.assign(ready, patch));
|
||||
return {
|
||||
listTasks: vi.fn(async () => [ready]),
|
||||
@@ -47,7 +47,6 @@ function storeWith(ready: Task, ephemeralAgentsEnabled: boolean | undefined): Ta
|
||||
getSettings: vi.fn(async () => ({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
ephemeralAgentsEnabled,
|
||||
})),
|
||||
updateSettings: vi.fn(async () => undefined),
|
||||
updateTask,
|
||||
@@ -74,23 +73,20 @@ function storeWith(ready: Task, ephemeralAgentsEnabled: boolean | undefined): Ta
|
||||
}
|
||||
|
||||
/*
|
||||
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.
|
||||
FNXC:WorkflowScheduling 2026-08-09-01:04:
|
||||
FN-8847 removes the retired compatibility toggle. Scheduler release must hand workflow tasks to
|
||||
graph admission without legacy assignment or queueing; durable principals resolve that authority later.
|
||||
*/
|
||||
describe("scheduler compatibility setting is routing-inert", () => {
|
||||
describe("scheduler releases workflow tasks for durable principal routing", () => {
|
||||
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) => {
|
||||
it("releases an unassigned workflow task without legacy assignment or queueing", async () => {
|
||||
const ready = task();
|
||||
const store = storeWith(ready, ephemeralAgentsEnabled);
|
||||
const store = storeWith(ready);
|
||||
const onSchedule = vi.fn();
|
||||
const scheduler = new Scheduler(store, { onSchedule });
|
||||
(scheduler as unknown as { running: boolean }).running = true;
|
||||
@@ -102,14 +98,11 @@ describe("scheduler compatibility setting is routing-inert", () => {
|
||||
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) => {
|
||||
it("preserves an assigned task's normal release", async () => {
|
||||
const ready = task({ id: "FN-8821-SCHEDULER-ASSIGNED", assignedAgentId: "durable-owner" });
|
||||
const store = storeWith(ready, ephemeralAgentsEnabled);
|
||||
const store = storeWith(ready);
|
||||
const onSchedule = vi.fn();
|
||||
const scheduler = new Scheduler(store, { onSchedule });
|
||||
(scheduler as unknown as { running: boolean }).running = true;
|
||||
@@ -120,6 +113,5 @@ describe("scheduler compatibility setting is routing-inert", () => {
|
||||
expect(ready.assignedAgentId).toBe("durable-owner");
|
||||
expect(onSchedule).toHaveBeenCalledWith(expect.objectContaining({ id: ready.id, column: "in-progress" }));
|
||||
expect(store.transitionQueuedEpisode).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5967,8 +5967,6 @@
|
||||
"updating": "Updating…",
|
||||
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
|
||||
"upToDate": "You're up to date ✓",
|
||||
"useEphemeralTaskWorkerAgents": " Use ephemeral task-worker agents ",
|
||||
"ephemeralAgentsCompatibilityHint": "Retained for configuration compatibility. Default: enabled. This setting does not affect scheduler assignment or admission, executor workflow dispatch or re-entry, mission start, or workflow-stage principal routing.",
|
||||
"warnOnTheBoardWhenTodoWorkExceeds": "Warn on the board when todo work exceeds the threshold and no idle agents are available. Default: disabled.",
|
||||
"whenEnabledDefaultFusionSpawnsShortLived": " When enabled (default), Fusion spawns short-lived ",
|
||||
"whenEnabledFusionChecksOpenAndClosedIssues": " When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. Default: enabled. ",
|
||||
|
||||
@@ -5930,7 +5930,6 @@
|
||||
"updating": "",
|
||||
"upperBoundOnMessagesFetchedFromTheRoom": "",
|
||||
"upToDate": "Estás actualizado ✓",
|
||||
"useEphemeralTaskWorkerAgents": "",
|
||||
"warnOnTheBoardWhenTodoWorkExceeds": "",
|
||||
"whenEnabledDefaultFusionSpawnsShortLived": "",
|
||||
"whenEnabledFusionChecksOpenAndClosedIssues": "",
|
||||
@@ -6002,7 +6001,6 @@
|
||||
"taskCreateWorkflowHelp": "",
|
||||
"refinementTaskWorkflow": "",
|
||||
"refinementTaskWorkflowHelp": "",
|
||||
"ephemeralAgentsCompatibilityHint": "",
|
||||
"maxRecommendationsPerTask": "",
|
||||
"maxRecommendationsPerTaskHelp": ""
|
||||
},
|
||||
|
||||
@@ -5930,7 +5930,6 @@
|
||||
"updating": "",
|
||||
"upperBoundOnMessagesFetchedFromTheRoom": "",
|
||||
"upToDate": "Vous êtes à jour ✓",
|
||||
"useEphemeralTaskWorkerAgents": "",
|
||||
"warnOnTheBoardWhenTodoWorkExceeds": "",
|
||||
"whenEnabledDefaultFusionSpawnsShortLived": "",
|
||||
"whenEnabledFusionChecksOpenAndClosedIssues": "",
|
||||
@@ -6002,7 +6001,6 @@
|
||||
"taskCreateWorkflowHelp": "",
|
||||
"refinementTaskWorkflow": "",
|
||||
"refinementTaskWorkflowHelp": "",
|
||||
"ephemeralAgentsCompatibilityHint": "",
|
||||
"maxRecommendationsPerTask": "",
|
||||
"maxRecommendationsPerTaskHelp": ""
|
||||
},
|
||||
|
||||
@@ -5930,7 +5930,6 @@
|
||||
"updating": "",
|
||||
"upperBoundOnMessagesFetchedFromTheRoom": "",
|
||||
"upToDate": "최신 버전입니다 ✓",
|
||||
"useEphemeralTaskWorkerAgents": "",
|
||||
"warnOnTheBoardWhenTodoWorkExceeds": "",
|
||||
"whenEnabledDefaultFusionSpawnsShortLived": "",
|
||||
"whenEnabledFusionChecksOpenAndClosedIssues": "",
|
||||
@@ -6002,7 +6001,6 @@
|
||||
"taskCreateWorkflowHelp": "",
|
||||
"refinementTaskWorkflow": "",
|
||||
"refinementTaskWorkflowHelp": "",
|
||||
"ephemeralAgentsCompatibilityHint": "",
|
||||
"maxRecommendationsPerTask": "",
|
||||
"maxRecommendationsPerTaskHelp": ""
|
||||
},
|
||||
|
||||
@@ -5930,7 +5930,6 @@
|
||||
"updating": "",
|
||||
"upperBoundOnMessagesFetchedFromTheRoom": "",
|
||||
"upToDate": "您已是最新版本 ✓",
|
||||
"useEphemeralTaskWorkerAgents": "",
|
||||
"warnOnTheBoardWhenTodoWorkExceeds": "",
|
||||
"whenEnabledDefaultFusionSpawnsShortLived": "",
|
||||
"whenEnabledFusionChecksOpenAndClosedIssues": "",
|
||||
@@ -6002,7 +6001,6 @@
|
||||
"taskCreateWorkflowHelp": "",
|
||||
"refinementTaskWorkflow": "",
|
||||
"refinementTaskWorkflowHelp": "",
|
||||
"ephemeralAgentsCompatibilityHint": "",
|
||||
"maxRecommendationsPerTask": "",
|
||||
"maxRecommendationsPerTaskHelp": ""
|
||||
},
|
||||
|
||||
@@ -5930,7 +5930,6 @@
|
||||
"updating": "",
|
||||
"upperBoundOnMessagesFetchedFromTheRoom": "",
|
||||
"upToDate": "您已是最新版本 ✓",
|
||||
"useEphemeralTaskWorkerAgents": "",
|
||||
"warnOnTheBoardWhenTodoWorkExceeds": "",
|
||||
"whenEnabledDefaultFusionSpawnsShortLived": "",
|
||||
"whenEnabledFusionChecksOpenAndClosedIssues": "",
|
||||
@@ -6002,7 +6001,6 @@
|
||||
"taskCreateWorkflowHelp": "",
|
||||
"refinementTaskWorkflow": "",
|
||||
"refinementTaskWorkflowHelp": "",
|
||||
"ephemeralAgentsCompatibilityHint": "",
|
||||
"maxRecommendationsPerTask": "",
|
||||
"maxRecommendationsPerTaskHelp": ""
|
||||
},
|
||||
|
||||
2
packages/i18n/src/resources.d.ts
vendored
2
packages/i18n/src/resources.d.ts
vendored
@@ -5954,7 +5954,6 @@ export default interface Resources {
|
||||
"ephemeralAgentTaskCreationPolicyDeny": "Deny",
|
||||
"ephemeralAgentTaskCreationPolicyHint": "No default — unset policy falls back to Allow. Upon validation sends a proposal to your mailbox for one-click approval; Deny rejects follow-up task creation.",
|
||||
"ephemeralAgentTaskCreationPolicyUponValidation": "Upon validation",
|
||||
"ephemeralAgentsCompatibilityHint": "Retained for configuration compatibility. Default: enabled. This setting does not affect scheduler assignment or admission, executor workflow dispatch or re-entry, mission start, or workflow-stage principal routing.",
|
||||
"fN": "FN",
|
||||
"followDashboardLanguage": "Follow dashboard language",
|
||||
"general": "General",
|
||||
@@ -6047,7 +6046,6 @@ export default interface Resources {
|
||||
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
|
||||
"updating": "Updating…",
|
||||
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
|
||||
"useEphemeralTaskWorkerAgents": " Use ephemeral task-worker agents ",
|
||||
"warnOnTheBoardWhenTodoWorkExceeds": "Warn on the board when todo work exceeds the threshold and no idle agents are available. Default: disabled.",
|
||||
"whenEnabledDefaultFusionSpawnsShortLived": " When enabled (default), Fusion spawns short-lived ",
|
||||
"whenEnabledFusionChecksOpenAndClosedIssues": " When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. Default: enabled. ",
|
||||
|
||||
Reference in New Issue
Block a user