feat(gsd): implement auto-mode fallback model rotation on network errors (#386)

This commit is contained in:
Kassie Povinelli 2026-03-14 14:13:22 -05:00 committed by GitHub
parent 9794c6aed3
commit a883008be6
3 changed files with 131 additions and 0 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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");
});