diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a798463912..1056deadc7 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx index 3055d64fd9..28d0d4e126 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index 546ee8dcb7..ffa321fd79 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -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, diff --git a/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts b/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts index 640d1233be..4f58a718b3 100644 --- a/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts +++ b/packages/dashboard/app/components/settings/search/__tests__/settings-search-index.test.ts @@ -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. diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index e8eb485c31..ffe972aa3e 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -140,8 +140,6 @@ export const PROJECT_SECTION_KEYS: Readonly> = "executorToolFailureRetryBackoffMs", "executorToolFailureThreshold", "executorModelEscalationEnabled", - "executorEscalationProvider", - "executorEscalationModelId", "executorEscalationNodeId", "groupOverlappingFiles", "heartbeatScopeDiscipline", @@ -204,6 +202,8 @@ export const PROJECT_SECTION_KEYS: Readonly> = "prTitlePromptInstructions", "tokenCap", "useAiMergeCommitSummary", + "executorEscalationProvider", + "executorEscalationModelId", ...MODEL_LANE_KEYS, ], }; diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.search.ts b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.search.ts index d8addd81dd..f3e941390f 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.search.ts @@ -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", diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index b4ceba85a2..68085ac14b 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -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. + */} + + { + 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" + /> + {/* 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. */}

{t("settings.projectModels.modelLanes", "Model Lanes")}

diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts b/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts index 095f7618ea..4af4c6ade9 100644 --- a/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.search.ts @@ -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", diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx index 63d855aab4..2e6d958096 100644 --- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx +++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx @@ -40,8 +40,6 @@ export function SchedulingSection({ form, setForm, concurrencyLoading = false, o 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. */} setForm((f) => ({ ...f, executorModelEscalationEnabled: value === true } as SettingsFormState))} /> - setForm((f) => ({ ...f, executorEscalationProvider: value ?? "" } as SettingsFormState))} /> - setForm((f) => ({ ...f, executorEscalationModelId: value ?? "" } as SettingsFormState))} /> setForm((f) => ({ ...f, executorEscalationNodeId: value ?? "" } as SettingsFormState))} /> = { 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", diff --git a/packages/dashboard/src/shared/settings-sections.ts b/packages/dashboard/src/shared/settings-sections.ts index b1e4130055..faf10a24e3 100644 --- a/packages/dashboard/src/shared/settings-sections.ts +++ b/packages/dashboard/src/shared/settings-sections.ts @@ -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", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index b64191e68a..45adac2784 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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…", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 804f7734f4..9b3c970c6b 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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…",