From a883008be63cf74b8ba2f0f6b0f1cfc8a6c8aec7 Mon Sep 17 00:00:00 2001 From: Kassie Povinelli <59930829+kassieclaire@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:13:22 -0500 Subject: [PATCH] feat(gsd): implement auto-mode fallback model rotation on network errors (#386) --- src/resources/extensions/gsd/index.ts | 46 ++++++++++++++++ src/resources/extensions/gsd/preferences.ts | 31 +++++++++++ .../gsd/tests/network-error-fallback.test.ts | 54 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/network-error-fallback.test.ts diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 719356cc1..d51b59125 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -39,6 +39,8 @@ import { loadEffectiveGSDPreferences, renderPreferencesForSystemPrompt, resolveAllSkillReferences, + resolveModelWithFallbacksForUnit, + getNextFallbackModel, } from "./preferences.js"; import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "./skill-discovery.js"; import { @@ -339,6 +341,50 @@ export default function (pi: ExtensionAPI) { "errorMessage" in lastMsg && lastMsg.errorMessage ? `: ${lastMsg.errorMessage}` : ""; + + const dash = getAutoDashboardData(); + if (dash.currentUnit) { + const modelConfig = resolveModelWithFallbacksForUnit(dash.currentUnit.type); + if (modelConfig && modelConfig.fallbacks.length > 0) { + const availableModels = ctx.modelRegistry.getAvailable(); + const currentModelId = ctx.model?.id; + + const nextModelId = getNextFallbackModel(currentModelId, modelConfig); + + if (nextModelId) { + let modelToSet; + const slashIdx = nextModelId.indexOf("/"); + if (slashIdx !== -1) { + const provider = nextModelId.substring(0, slashIdx); + const id = nextModelId.substring(slashIdx + 1); + modelToSet = availableModels.find( + m => m.provider.toLowerCase() === provider.toLowerCase() + && m.id.toLowerCase() === id.toLowerCase() + ); + } else { + const currentProvider = ctx.model?.provider; + const exactProviderMatch = availableModels.find( + m => m.id === nextModelId && m.provider === currentProvider + ); + modelToSet = exactProviderMatch ?? availableModels.find(m => m.id === nextModelId); + } + + if (modelToSet) { + const ok = await pi.setModel(modelToSet, { persist: false }); + if (ok) { + ctx.ui.notify(`Model error${errorDetail}. Switched to fallback: ${nextModelId} and resuming.`, "warning"); + // Trigger a generic "Continue execution" to resume the task since the previous attempt failed + pi.sendMessage( + { customType: "gsd-auto-timeout-recovery", content: "Continue execution.", display: false }, + { triggerTurn: true } + ); + return; + } + } + } + } + } + (ctx as any).log(`Auto-mode paused due to provider error${errorDetail}`); await pauseAuto(ctx, pi); return; diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index e1862850d..7d0a91902 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -515,6 +515,37 @@ export function resolveModelForUnit(unitType: string): string | undefined { * - Legacy: `planning: claude-opus-4-6` * - Extended: `planning: { model: claude-opus-4-6, fallbacks: [glm-5, minimax-m2.5] }` */ +/** + * Determines the next fallback model to try when the current model fails. + * If the current model is not in the configured list, returns the primary model. + * If the current model is the last in the list, returns undefined (exhausted). + */ +export function getNextFallbackModel( + currentModelId: string | undefined, + modelConfig: ResolvedModelConfig, +): string | undefined { + const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks]; + + if (!currentModelId) { + return modelsToTry[0]; + } + + let foundCurrent = false; + for (let i = 0; i < modelsToTry.length; i++) { + const mId = modelsToTry[i]; + // Check for exact match or provider/model suffix match + if (mId === currentModelId || (mId.includes("/") && mId.endsWith(`/${currentModelId}`))) { + foundCurrent = true; + return modelsToTry[i + 1]; // Return the next one, or undefined if at the end + } + } + + // If the current model wasn't in our preference list, default to starting the sequence + if (!foundCurrent) { + return modelsToTry[0]; + } +} + export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedModelConfig | undefined { const prefs = loadEffectiveGSDPreferences(); if (!prefs?.preferences.models) return undefined; diff --git a/src/resources/extensions/gsd/tests/network-error-fallback.test.ts b/src/resources/extensions/gsd/tests/network-error-fallback.test.ts new file mode 100644 index 000000000..9a5708df0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/network-error-fallback.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Instead of trying to mock out the entire `index.ts` extension initialization which touches +// the disk and parses files, we test the logic via the standard test methods, or we can +// just test that `resolveModelWithFallbacksForUnit` returns the correct format since +// the fallback rotation logic itself was verified manually. + +import { getNextFallbackModel } from "../preferences.ts"; + +test("getNextFallbackModel selects next fallback if current is a fallback", () => { + const modelConfig = { primary: "model-a", fallbacks: ["model-b", "model-c"] }; + const currentModelId = "model-b"; + + const nextModelId = getNextFallbackModel(currentModelId, modelConfig); + + assert.equal(nextModelId, "model-c", "should select next model after current fallback"); +}); + +test("getNextFallbackModel returns undefined if fallbacks exhausted", () => { + const modelConfig = { primary: "model-a", fallbacks: ["model-b", "model-c"] }; + const currentModelId = "model-c"; + + const nextModelId = getNextFallbackModel(currentModelId, modelConfig); + + assert.equal(nextModelId, undefined, "should return undefined when exhausted"); +}); + +test("getNextFallbackModel finds current model when formatted with provider", () => { + const modelConfig = { primary: "p/model-a", fallbacks: ["p/model-b"] }; + const currentModelId = "model-a"; // context model doesn't always have provider in ID + + const nextModelId = getNextFallbackModel(currentModelId, modelConfig); + + assert.equal(nextModelId, "p/model-b", "should select next model after current with provider format"); +}); + +test("getNextFallbackModel returns primary if current model is not in the list", () => { + const modelConfig = { primary: "model-a", fallbacks: ["model-b", "model-c"] }; + const currentModelId = "model-x"; // completely different model manually selected + + const nextModelId = getNextFallbackModel(currentModelId, modelConfig); + + assert.equal(nextModelId, "model-a", "should default to primary if current is unknown"); +}); + +test("getNextFallbackModel returns primary if current model is undefined", () => { + const modelConfig = { primary: "model-a", fallbacks: ["model-b", "model-c"] }; + const currentModelId = undefined; + + const nextModelId = getNextFallbackModel(currentModelId, modelConfig); + + assert.equal(nextModelId, "model-a", "should default to primary if current is undefined"); +});