FN-8752: move escalation model to project selector

Centralize executor escalation model selection with the project-scoped provider-aware model control.

- Move escalation provider and model fields from Scheduling to Project Models
- Persist and clear escalation model pairs atomically through the shared selector
- Update search metadata, localization, documentation, and mobile coverage

Files changed:
 docs/settings-reference.md                         |  6 +-
 .../__tests__/SettingsModal.mobileClose.test.tsx   | 29 +++++++
 .../__tests__/SettingsModal.models-auth.test.tsx   | 94 ++++++++++++++++++++++
 .../search/__tests__/settings-search-index.test.ts | 17 ++++
 .../app/components/settings/section-keys.ts        |  4 +-
 .../sections/ProjectModelsSection.search.ts        | 10 +++
 .../settings/sections/ProjectModelsSection.tsx     | 39 +++++++++
 .../settings/sections/SchedulingSection.search.ts  | 18 -----
 .../settings/sections/SchedulingSection.tsx        |  2 -
 .../settings-default-descriptions.test.tsx         |  4 +-
 packages/dashboard/src/shared/settings-sections.ts |  9 +++
 packages/i18n/locales/en/app.json                  |  4 +
 packages/i18n/src/resources.d.ts                   |  4 +
 13 files changed, 213 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-8752

Fusion-Task-Lineage: f0f430d7-7976-4b65-89c5-8eab3487d26a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-02 23:12:56 -07:00
parent fb0863f660
commit b2373431e4
13 changed files with 213 additions and 27 deletions

View File

@@ -1858,11 +1858,11 @@ Values are project-scoped and finite values are floored; count/backoff must be a
| Setting | Type/default | Behavior |
| --- | --- | --- |
| `executorModelEscalationEnabled` | boolean, `false` | Opt in to one alternate attempt after same-model retries exhaust. |
| `executorEscalationProvider` | string, unset | Provider for an alternate model; requires `executorEscalationModelId`. |
| `executorEscalationModelId` | string, unset | Alternate model ID; requires `executorEscalationProvider`. |
| `executorEscalationProvider` | string, unset | Provider portion of the alternate model selected in **Settings → Models · Project**. |
| `executorEscalationModelId` | string, unset | Model portion of that provider-aware selector. |
| `executorEscalationNodeId` | string, unset | Optional configured node target. |
Escalation is enabled only when the toggle is true and either a complete provider/model pair or a node ID is configured. It is single-shot: after FN-7996 exhausts same-model retries, Fusion persists the override and tries once before the existing terminal park. The alternate model enters the [model-selection hierarchy](#model-selection-hierarchy) as a task-level override; a node target enters `resolveEffectiveNode` as a task-level routing override and is requeued so scheduler routing is recalculated. This remains opt-in by default to avoid unexpected model cost or execution behavior. Column-agent overrides still govern their sessions and can supersede a task-level model target.
Choose the alternate model with the standard provider-aware selector in **Settings → Models · Project**; clearing it removes both persisted pair keys, and incomplete legacy pairs display as unset. **Settings → Scheduling** retains the enable toggle, optional node target, and retry policy. Escalation is enabled only when the toggle is true and either a complete provider/model pair or a node ID is configured. It is single-shot: after FN-7996 exhausts same-model retries, Fusion persists the override and tries once before the existing terminal park. The alternate model enters the [model-selection hierarchy](#model-selection-hierarchy) as a task-level override; a node target enters `resolveEffectiveNode` as a task-level routing override and is requeued so scheduler routing is recalculated. This remains opt-in by default to avoid unexpected model cost or execution behavior. Column-agent overrides still govern their sessions and can supersede a task-level model target.
| `triageDuplicateResolution` | `"prompt" \| "keep" \| "delete"` | `"prompt"` | Controls `DUPLICATE: FN-NNNN` markers emitted during triage. **prompt** flags and system-pauses the task for an operator Keep/Delete decision; the existing decision banner links to the canonical task. **keep** dismisses the marker and replans a real task. **delete** restores legacy auto-delete behavior. |

View File

@@ -132,6 +132,35 @@ describe("SettingsModal mobile embedded close button (FN-7627)", () => {
expect(toggle).toBeChecked();
});
/*
FNXC:ExecutorEscalation 2026-08-03-05:43:
Mobile Settings uses a section picker rather than the desktop rail. The one escalation model selector must remain in Models · Project after mobile navigation, while Scheduling retains policy and node routing without duplicate provider/model input shells.
*/
it("keeps the sole escalation selector in Project Models when the mobile section picker changes", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchSettings.mockResolvedValue({
...defaultSettings,
executorEscalationProvider: "anthropic",
executorEscalationModelId: "claude-sonnet-4-5",
});
mockFetchModels.mockResolvedValue({
models: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
favoriteProviders: [],
favoriteModels: [],
});
renderModal({ presentation: "embedded", projectId: "proj-1", initialSection: "project-models" });
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
expect(await screen.findByLabelText("Executor Escalation Model")).toHaveTextContent("Claude Sonnet 4.5");
fireEvent.change(screen.getByLabelText("Settings Section"), { target: { value: "scheduling" } });
expect(await screen.findByRole("checkbox", { name: "Escalate after tool-failure retries" })).toBeVisible();
expect(screen.getByLabelText("Escalation node ID")).toBeVisible();
expect(screen.queryByLabelText("Escalation provider")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Escalation model ID")).not.toBeInTheDocument();
});
it("still renders and calls onClose in embedded+mobile when opened without a selected projectId (overview entry)", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const onClose = vi.fn();

View File

@@ -239,6 +239,100 @@ describe("SettingsModal", () => {
});
});
/*
FNXC:ExecutorEscalation 2026-08-03-05:43:
The original Scheduling text fields accepted incomplete provider/model pairs. The project-scoped picker must instead present one provider-qualified selection, save both legacy keys together, and clear both keys together.
*/
it("selects and clears the executor escalation model as one project-scoped pair", async () => {
mockFetchModels.mockResolvedValue({
models: MODEL_FIXTURE,
favoriteProviders: [],
favoriteModels: [],
});
mockFetchSettings.mockResolvedValue({
...defaultSettings,
executorEscalationProvider: "anthropic",
executorEscalationModelId: "claude-sonnet-4-5",
});
mockFetchSettingsByScope.mockResolvedValue({
global: defaultSettings,
project: {
executorEscalationProvider: "anthropic",
executorEscalationModelId: "claude-sonnet-4-5",
},
});
renderModal({ initialSection: "project-models" });
await waitForSettingsModalReady();
const selector = screen.getByLabelText("Executor Escalation Model");
expect(selector).toHaveTextContent("Claude Sonnet 4.5");
await settingsModalUser.click(selector);
await settingsModalUser.click(await screen.findByText("GPT-4o"));
fireEvent.click(document.querySelector(".modal-close") as HTMLButtonElement);
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
executorEscalationProvider: "openai",
executorEscalationModelId: "gpt-4o",
}),
undefined,
));
cleanup();
mockUpdateSettings.mockClear();
renderModal({ initialSection: "project-models" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByLabelText("Executor Escalation Model"));
await settingsModalUser.click(await screen.findByText("No escalation model"));
fireEvent.click(document.querySelector(".modal-close") as HTMLButtonElement);
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
executorEscalationProvider: null,
executorEscalationModelId: null,
}),
undefined,
));
});
it("disables the escalation selector while the catalog is loading or empty", async () => {
let resolveModels: ((value: { models: typeof MODEL_FIXTURE; favoriteProviders: string[]; favoriteModels: string[] }) => void) | undefined;
mockFetchModels.mockImplementation(() => new Promise((resolve) => {
resolveModels = resolve;
}));
renderModal({ initialSection: "project-models" });
await waitForSettingsModalReady();
expect(screen.getByLabelText("Executor Escalation Model")).toBeDisabled();
resolveModels?.({ models: MODEL_FIXTURE, favoriteProviders: [], favoriteModels: [] });
await waitFor(() => expect(screen.getByLabelText("Executor Escalation Model")).not.toBeDisabled());
});
it("treats incomplete legacy escalation pairs as unset and removes Scheduling model inputs", async () => {
mockFetchModels.mockResolvedValue({
models: MODEL_FIXTURE,
favoriteProviders: [],
favoriteModels: [],
});
mockFetchSettings.mockResolvedValue({
...defaultSettings,
executorEscalationProvider: "anthropic",
});
renderModal({ initialSection: "project-models" });
await waitForSettingsModalReady();
expect(screen.getByLabelText("Executor Escalation Model")).toHaveTextContent("No escalation model");
await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling" }));
expect(screen.queryByLabelText("Escalation provider")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Escalation model ID")).not.toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Escalate after tool-failure retries" })).toBeInTheDocument();
expect(screen.getByLabelText("Escalation node ID")).toBeInTheDocument();
});
it("renders and saves OpenRouter advanced settings", async () => {
mockFetchModels.mockResolvedValue({
models: MODEL_FIXTURE,

View File

@@ -203,6 +203,23 @@ describe("settings search ranking", () => {
expect(results.map((r) => r.key)).toEqual(["aCostThing", "showCostBadgeOnCards"]);
});
it("routes escalation model discovery only to Project Models while keeping policy routing in Scheduling", () => {
const escalationResults = rankSettingsSearchResults(SETTINGS_SEARCH_ENTRIES, "alternate model", resolveEnglish);
expect(escalationResults.map((result) => `${result.sectionId}:${result.key}`)).toContain(
"project-models:executorEscalationModel",
);
expect(escalationResults.some((result) => result.sectionId === "scheduling" && /Escalation (provider|model ID)/.test(result.label))).toBe(false);
const policyResults = rankSettingsSearchResults(SETTINGS_SEARCH_ENTRIES, "escalate after tool-failure retries", resolveEnglish);
expect(policyResults.map((result) => `${result.sectionId}:${result.key}`)).toContain(
"scheduling:executorModelEscalationEnabled",
);
const nodeResults = rankSettingsSearchResults(SETTINGS_SEARCH_ENTRIES, "escalation node", resolveEnglish);
expect(nodeResults.map((result) => `${result.sectionId}:${result.key}`)).toContain(
"scheduling:executorEscalationNodeId",
);
});
it("finds the real 'summarize' miss that motivated the rewrite", () => {
// FN-7907 / 2026-07-14: operators searched "summarize"; the section's
// keyword list did not carry it, so Project Models did not surface.

View File

@@ -140,8 +140,6 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
"executorToolFailureRetryBackoffMs",
"executorToolFailureThreshold",
"executorModelEscalationEnabled",
"executorEscalationProvider",
"executorEscalationModelId",
"executorEscalationNodeId",
"groupOverlappingFiles",
"heartbeatScopeDiscipline",
@@ -204,6 +202,8 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
"prTitlePromptInstructions",
"tokenCap",
"useAiMergeCommitSummary",
"executorEscalationProvider",
"executorEscalationModelId",
...MODEL_LANE_KEYS,
],
};

View File

@@ -9,6 +9,16 @@
import type { SettingsSearchEntry } from "../search/types";
export const projectModelsSearchEntries: SettingsSearchEntry[] = [
{
sectionId: "project-models",
key: "executorEscalationModel",
labelKey: "settings.projectModels.executorEscalationModel",
labelFallback: "Executor Escalation Model",
helpKey: "settings.projectModels.executorEscalationModelHelp",
helpFallback:
"Alternate model used once tool-failure retries are exhausted. No default — unset means no alternate model; configure escalation policy and an optional node target in Scheduling.",
keywords: ["tool failure", "alternate model", "retry model", "provider", "executor"],
},
{
sectionId: "project-models",
key: "tokenCap",

View File

@@ -7,6 +7,7 @@ import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsSelectRow } from "../SettingsSelectRow";
import { SettingsNumberRow } from "../SettingsNumberRow";
import { SettingsTextareaRow } from "../SettingsTextareaRow";
import { SettingsFieldRow } from "../SettingsFieldRow";
import { SettingsHelpTip } from "../SettingsHelpTip";
import { applyPresetToSelection } from "../../../utils/modelPresets";
import type { ToastType } from "../../../hooks/useToast";
@@ -547,6 +548,44 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
/>
{/* --- Project Model Lanes --- */}
{/*
FNXC:ExecutorEscalation 2026-08-03-05:43:
The alternate executor target is a project model choice, while Scheduling owns only the retry policy and optional node routing. Use the shared provider-aware dropdown so complete persisted pairs hydrate together, selecting a model updates both existing keys, and the default choice clears both without accepting arbitrary text.
*/}
<SettingsFieldRow
htmlFor="executorEscalationModel"
label={t("settings.projectModels.executorEscalationModel", "Executor Escalation Model")}
help={t("settings.projectModels.executorEscalationModelHelp", "Alternate model used once tool-failure retries are exhausted. No default — unset means no alternate model; configure escalation policy and an optional node target in Scheduling.")}
scope="project"
>
<CustomModelDropdown
id="executorEscalationModel"
label={t("settings.projectModels.executorEscalationModel", "Executor Escalation Model")}
models={availableModels}
disabled={modelsLoading || availableModels.length === 0}
value={form.executorEscalationProvider && form.executorEscalationModelId ? `${form.executorEscalationProvider}/${form.executorEscalationModelId}` : ""}
onChange={(value) => {
if (!value) {
setForm((current) => ({ ...current, executorEscalationProvider: undefined, executorEscalationModelId: undefined } as SettingsFormState));
return;
}
const slashIdx = value.indexOf("/");
if (slashIdx <= 0) return;
setForm((current) => ({
...current,
executorEscalationProvider: value.slice(0, slashIdx),
executorEscalationModelId: value.slice(slashIdx + 1),
} as SettingsFormState));
}}
placeholder={t("settings.projectModels.selectExecutorEscalationModel", "Select an escalation model")}
defaultOptionLabel={t("settings.projectModels.noExecutorEscalationModel", "No escalation model")}
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
menuWidth="readable"
/>
</SettingsFieldRow>
{/* FNXC:SettingsHelp 2026-07-16-12:45: Section description moved behind the shared "?" affordance beside the heading — operator requirement: no inline description paragraphs in Settings. */}
<div className="settings-field-label-row">
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.modelLanes", "Model Lanes")}</h4>

View File

@@ -66,24 +66,6 @@ export const schedulingSearchEntries: SettingsSearchEntry[] = [
helpFallback: "After same-model retries are exhausted, try one configured alternate model or node. Disabled by default.",
keywords: ["executor", "model", "node", "escalation", "tool error"],
},
{
sectionId: "scheduling",
key: "executorEscalationProvider",
labelKey: "settings.scheduling.executorEscalationProvider",
labelFallback: "Escalation provider",
helpKey: "settings.scheduling.executorEscalationProviderHelp",
helpFallback: "Provider for the alternate model. Requires an alternate model ID.",
keywords: ["executor", "model", "provider", "escalation"],
},
{
sectionId: "scheduling",
key: "executorEscalationModelId",
labelKey: "settings.scheduling.executorEscalationModelId",
labelFallback: "Escalation model ID",
helpKey: "settings.scheduling.executorEscalationModelIdHelp",
helpFallback: "Alternate model ID. Requires an escalation provider.",
keywords: ["executor", "model", "escalation"],
},
{
sectionId: "scheduling",
key: "executorEscalationNodeId",

View File

@@ -40,8 +40,6 @@ export function SchedulingSection({ form, setForm, concurrencyLoading = false, o
<SettingsNumberRow descriptor={{ key: "executorToolFailureThreshold", label: t("settings.scheduling.executorToolFailureThreshold", "Consecutive tool failures"), help: t("settings.scheduling.executorToolFailureThresholdHelp", "Terminal tool errors required before retrying. Default: 3."), scope: "project", min: 1, step: 1 }} value={form.executorToolFailureThreshold ?? 3} onChange={(v) => setForm((f) => ({ ...f, executorToolFailureThreshold: Math.max(1, Math.floor(v ?? 3)) } as SettingsFormState))} />
{/* FNXC:ExecutorEscalation 2026-07-16-21:00: Keep alternate model/node escalation opt-in and adjacent to its FN-7996 retry policy; a complete model pair or node id is required before the executor consumes its one extra attempt. */}
<SettingsToggleRow descriptor={{ key: "executorModelEscalationEnabled", label: t("settings.scheduling.executorModelEscalationEnabled", "Escalate after tool-failure retries"), help: t("settings.scheduling.executorModelEscalationEnabledHelp", "After same-model retries are exhausted, try one configured alternate model or node. Disabled by default."), scope: "project" }} value={form.executorModelEscalationEnabled === true} onChange={(value) => setForm((f) => ({ ...f, executorModelEscalationEnabled: value === true } as SettingsFormState))} />
<SettingsTextRow descriptor={{ key: "executorEscalationProvider", label: t("settings.scheduling.executorEscalationProvider", "Escalation provider"), help: t("settings.scheduling.executorEscalationProviderHelp", "Provider for the alternate model. Requires an alternate model ID."), scope: "project" }} value={form.executorEscalationProvider ?? ""} onChange={(value) => setForm((f) => ({ ...f, executorEscalationProvider: value ?? "" } as SettingsFormState))} />
<SettingsTextRow descriptor={{ key: "executorEscalationModelId", label: t("settings.scheduling.executorEscalationModelId", "Escalation model ID"), help: t("settings.scheduling.executorEscalationModelIdHelp", "Alternate model ID. Requires an escalation provider."), scope: "project" }} value={form.executorEscalationModelId ?? ""} onChange={(value) => setForm((f) => ({ ...f, executorEscalationModelId: value ?? "" } as SettingsFormState))} />
<SettingsTextRow descriptor={{ key: "executorEscalationNodeId", label: t("settings.scheduling.executorEscalationNodeId", "Escalation node ID"), help: t("settings.scheduling.executorEscalationNodeIdHelp", "Optional configured node; a node target re-enters scheduler routing."), scope: "project" }} value={form.executorEscalationNodeId ?? ""} onChange={(value) => setForm((f) => ({ ...f, executorEscalationNodeId: value ?? "" } as SettingsFormState))} />
<SettingsNumberRow
descriptor={{

View File

@@ -220,8 +220,8 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
executorToolFailureRetryBackoffMs: "scheduling.executorToolFailureRetryBackoffMsHelp",
executorToolFailureThreshold: "scheduling.executorToolFailureThresholdHelp",
executorModelEscalationEnabled: "scheduling.executorModelEscalationEnabledHelp",
executorEscalationProvider: "scheduling.executorEscalationProviderHelp",
executorEscalationModelId: "scheduling.executorEscalationModelIdHelp",
executorEscalationProvider: "projectModels.executorEscalationModelHelp",
executorEscalationModelId: "projectModels.executorEscalationModelHelp",
executorEscalationNodeId: "scheduling.executorEscalationNodeIdHelp",
taskStuckTimeoutMs: "scheduling.timeoutInMinutesForDetectingStuckTasksWhen",
staleHighFanoutBlockerAgeThresholdMs: "scheduling.escalateHighFanOutBlockersOnlyAfterThey",

View File

@@ -91,6 +91,9 @@ const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] = [
* FNXC:SettingsNavigation 2026-07-14-20:15:
* Title auto-summarization lives under Project Models but operators search for "summarize", "auto summarize", "title summarization", and related phrases that did not match the prior chat-only/summarization-model index. Advertise those terms and the control's i18n keys so Settings search finds this section.
*
* FNXC:ExecutorEscalation 2026-08-03-05:43:
* The executor alternate model is a project model selector, not a Scheduling text setting. Keep its discovery terms and label/help keys here so desktop navigation, the mobile picker, and external Settings metadata name the one canonical destination.
*/
{
id: "project-models",
@@ -101,6 +104,10 @@ const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] = [
"default provider",
"default model",
"workflow model lanes",
"executor escalation model",
"escalation model",
"alternate model",
"tool-failure retry model",
"Plan/Triage",
"Executor",
"Reviewer",
@@ -149,6 +156,8 @@ const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] = [
"settings.projectModels.chatDefaultModel",
"settings.projectModels.chatDefaultAgent",
"settings.projectModels.aITitleAndGitCommitMessageSummarization",
"settings.projectModels.executorEscalationModel",
"settings.projectModels.executorEscalationModelHelp",
"settings.projectModels.autoSummarizeLongDescriptionsAsTitles",
"settings.projectModels.whenEnabledTasksCreatedWithoutATitleBut",
"settings.projectModels.aIMergeCommitSummaries",

View File

@@ -6515,6 +6515,10 @@
"delete": " Delete ",
"edit": " Edit ",
"executorModel": "Executor model",
"executorEscalationModel": "Executor Escalation Model",
"executorEscalationModelHelp": "Alternate model used once tool-failure retries are exhausted. No default — unset means no alternate model; configure escalation policy and an optional node target in Scheduling.",
"selectExecutorEscalationModel": "Select an escalation model",
"noExecutorEscalationModel": "No escalation model",
"fallsBackTo": " Falls back to: ",
"loadingAvailableModels": "Loading available models…",
"loadingWorkflowModelLanes": "Loading workflow model lanes…",

View File

@@ -6564,6 +6564,10 @@ export default interface Resources {
"delete": " Delete ",
"edit": " Edit ",
"executorModel": "Executor model",
"executorEscalationModel": "Executor Escalation Model",
"executorEscalationModelHelp": "Alternate model used once tool-failure retries are exhausted. No default — unset means no alternate model; configure escalation policy and an optional node target in Scheduling.",
"selectExecutorEscalationModel": "Select an escalation model",
"noExecutorEscalationModel": "No escalation model",
"fallsBackTo": " Falls back to: ",
"loadingAgents": "Loading agents…",
"loadingAvailableModels": "Loading available models…",