Skip to content

Commit 5471548

Browse files
authored
Fix: live session model switch no longer blocks failover (Resolves #58466) (#58589)
* fix: prevent infinite retry loop when live session model switch blocks failover (#58466) * fix: remove unused resolveOllamaBaseUrlForRun import after rebase
1 parent 350fe63 commit 5471548

4 files changed

Lines changed: 121 additions & 0 deletions

File tree

src/agents/model-fallback.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { AuthProfileStore } from "./auth-profiles.js";
99
import { saveAuthProfileStore } from "./auth-profiles.js";
1010
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
1111
import { isAnthropicBillingError } from "./live-auth-keys.js";
12+
import { LiveSessionModelSwitchError } from "./live-model-switch.js";
1213
import { runWithImageModelFallback, runWithModelFallback } from "./model-fallback.js";
1314
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
1415

@@ -263,6 +264,50 @@ describe("runWithModelFallback", () => {
263264
expect(run).toHaveBeenCalledTimes(1);
264265
});
265266

267+
it("treats LiveSessionModelSwitchError as failover on last candidate (#58466)", async () => {
268+
const cfg = makeCfg();
269+
const switchError = new LiveSessionModelSwitchError({
270+
provider: "anthropic",
271+
model: "claude-sonnet-4-6",
272+
});
273+
const run = vi.fn().mockRejectedValue(switchError);
274+
275+
// With no fallbacks, the single candidate is also the last one.
276+
// Previously this would re-throw LiveSessionModelSwitchError, causing
277+
// the outer retry loop to restart with the overloaded model indefinitely.
278+
// Now it should surface as a FailoverError instead.
279+
const err = await runWithModelFallback({
280+
cfg,
281+
provider: "anthropic",
282+
model: "claude-sonnet-4-6",
283+
run,
284+
fallbacksOverride: [],
285+
}).catch((e: unknown) => e);
286+
expect(err).toBeInstanceOf(Error);
287+
// Should NOT be a LiveSessionModelSwitchError — the outer retry loop must
288+
// not restart with the conflicting model.
289+
expect(err).not.toBeInstanceOf(LiveSessionModelSwitchError);
290+
expect(run).toHaveBeenCalledTimes(1);
291+
});
292+
293+
it("continues fallback chain past LiveSessionModelSwitchError to next candidate (#58466)", async () => {
294+
const cfg = makeCfg();
295+
const switchError = new LiveSessionModelSwitchError({
296+
provider: "anthropic",
297+
model: "claude-sonnet-4-6",
298+
});
299+
const run = vi.fn().mockRejectedValueOnce(switchError).mockResolvedValueOnce("ok");
300+
301+
const result = await runWithModelFallback({
302+
cfg,
303+
provider: "openai",
304+
model: "gpt-4.1-mini",
305+
run,
306+
});
307+
expect(result.result).toBe("ok");
308+
expect(run).toHaveBeenCalledTimes(2);
309+
});
310+
266311
it("falls back on auth errors", async () => {
267312
await expectFallsBackToHaiku({
268313
provider: "openai",

src/agents/model-fallback.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "./auth-profiles.js";
1616
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
1717
import {
18+
FailoverError,
1819
coerceToFailoverError,
1920
describeFailoverError,
2021
isFailoverError,
@@ -25,6 +26,7 @@ import {
2526
shouldPreserveTransientCooldownProbeSlot,
2627
shouldUseTransientCooldownProbeSlot,
2728
} from "./failover-policy.js";
29+
import { LiveSessionModelSwitchError } from "./live-model-switch.js";
2830
import { logModelFallbackDecision } from "./model-fallback-observation.js";
2931
import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
3032
import {
@@ -781,6 +783,48 @@ export async function runWithModelFallback<T>(params: {
781783
model: candidate.model,
782784
}) ?? err;
783785

786+
// LiveSessionModelSwitchError during fallback means the session's
787+
// persisted model conflicts with this fallback candidate. Treat it
788+
// as a known failover so the chain continues to the next candidate
789+
// instead of re-throwing and triggering infinite retry loops in the
790+
// outer runner. (#58466)
791+
if (err instanceof LiveSessionModelSwitchError) {
792+
const switchMsg = err.message;
793+
const switchNormalized = new FailoverError(switchMsg, {
794+
reason: "overloaded",
795+
provider: candidate.provider,
796+
model: candidate.model,
797+
});
798+
lastError = switchNormalized;
799+
const described = describeFailoverError(switchNormalized);
800+
attempts.push({
801+
provider: candidate.provider,
802+
model: candidate.model,
803+
error: described.message,
804+
reason: described.reason ?? "unknown",
805+
status: described.status,
806+
code: described.code,
807+
});
808+
logModelFallbackDecision({
809+
decision: "candidate_failed",
810+
runId: params.runId,
811+
requestedProvider: params.provider,
812+
requestedModel: params.model,
813+
candidate,
814+
attempt: i + 1,
815+
total: candidates.length,
816+
reason: described.reason,
817+
status: described.status,
818+
code: described.code,
819+
error: described.message,
820+
nextCandidate: candidates[i + 1],
821+
isPrimary,
822+
requestedModelMatched: requestedModel,
823+
fallbackConfigured: hasFallbackCandidates,
824+
});
825+
continue;
826+
}
827+
784828
// Even unrecognized errors should not abort the fallback loop when
785829
// there are remaining candidates. Only abort/context-overflow errors
786830
// (handled above) are truly non-retryable.

src/cron/isolated-agent/run.live-session-model-switch.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,26 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206
249249
expect(callCount).toBe(2);
250250
});
251251

252+
it("aborts after exceeding LiveSessionModelSwitchError retry limit (#58466)", async () => {
253+
const switchError = new LiveSessionModelSwitchError({
254+
provider: "anthropic",
255+
model: "claude-sonnet-4-6",
256+
});
257+
258+
let callCount = 0;
259+
runWithModelFallbackMock.mockImplementation(async () => {
260+
callCount++;
261+
throw switchError;
262+
});
263+
264+
const result = await runCronIsolatedAgentTurn(makeParams());
265+
266+
expect(result.status).toBe("error");
267+
// Circuit breaker: max 2 retries → 3 total attempts (initial + 2 retries)
268+
expect(callCount).toBe(3);
269+
expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("retry limit reached"));
270+
});
271+
252272
it("does not retry when the thrown error is not a LiveSessionModelSwitchError", async () => {
253273
let callCount = 0;
254274
runWithModelFallbackMock.mockImplementation(async () => {

src/cron/isolated-agent/run.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,12 +592,24 @@ export async function runCronIsolatedAgentTurn(params: {
592592
// in the main agent runner (agent-runner-execution.ts). Without this, cron
593593
// jobs that specify a model different from the agent primary always fail.
594594
// See: https://github.com/openclaw/openclaw/issues/57206
595+
//
596+
// Circuit breaker: cap retries to prevent infinite loops when the live
597+
// session model switch guard fires repeatedly during failover (#58466).
598+
const MAX_MODEL_SWITCH_RETRIES = 2;
599+
let modelSwitchRetries = 0;
595600
while (true) {
596601
try {
597602
await runPrompt(commandBody);
598603
break;
599604
} catch (err) {
600605
if (err instanceof LiveSessionModelSwitchError) {
606+
modelSwitchRetries += 1;
607+
if (modelSwitchRetries > MAX_MODEL_SWITCH_RETRIES) {
608+
logWarn(
609+
`[cron:${params.job.id}] LiveSessionModelSwitchError retry limit reached (${MAX_MODEL_SWITCH_RETRIES}); aborting`,
610+
);
611+
throw err;
612+
}
601613
liveSelection = {
602614
provider: err.provider,
603615
model: err.model,

0 commit comments

Comments
 (0)