From dd8e07c11f89fb56bb7b4f391e93a2edb9e5f2cb Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:26:37 -0600 Subject: [PATCH] fix(agents): stop fallback on gateway draining (#118101) --- src/agents/failover-error.test.ts | 15 +++++++++ src/agents/failover-error.ts | 14 +++++++- src/agents/model-fallback.test.ts | 55 +++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 3cf99d119d82..151332cc2827 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it } from "vitest"; import { createAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js"; +import { GatewayDrainingError } from "../process/gateway-work-admission.js"; import { classifyFailoverSignal } from "./embedded-agent-helpers/errors.js"; import { buildFailoverRemediationHint, @@ -1428,6 +1429,20 @@ describe("failover-error", () => { expect(isNonProviderRuntimeCoordinationError(abortWrapper)).toBe(true); }); + it("returns true for direct and nested gateway drain admission failures", () => { + const draining = new GatewayDrainingError(); + const causeWrapper = new Error("session send failed", { cause: draining }); + const aggregateWrapper = new AggregateError( + [new Error("cleanup failed"), { error: draining }], + "agent run failed", + ); + + for (const error of [draining, causeWrapper, aggregateWrapper]) { + expect(isNonProviderRuntimeCoordinationError(error)).toBe(true); + expect(resolveModelFallbackError(error)).toEqual({ kind: "coordination", error }); + } + }); + it("returns true when the coordination error is nested via cause", () => { const wrapped = new Error("wrapper", { cause: makeSessionLockError() }); expect(isNonProviderRuntimeCoordinationError(wrapped)).toBe(true); diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 212609421881..c3efa96b97c4 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -6,7 +6,7 @@ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion"; import { formatCliCommand } from "../cli/command-format.js"; import { isAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js"; -import { readErrorName } from "../infra/errors.js"; +import { collectErrorGraphCandidates, readErrorName } from "../infra/errors.js"; import { classifyFailoverSignal, extractFailoverSignalDetails, @@ -505,6 +505,13 @@ function hasStaleAgentRunLifecycleFailure(err: unknown): boolean { ); } +function hasGatewayDrainingFailure(err: unknown): boolean { + return collectErrorGraphCandidates(err, (candidate) => { + const errors = candidate.errors; + return [candidate.error, candidate.cause, ...(Array.isArray(errors) ? errors : [])]; + }).some((candidate) => readErrorName(candidate) === "GatewayDrainingError"); +} + function hasDirectProviderFailureIdentity(err: unknown): boolean { if (isFailoverError(err)) { return true; @@ -919,6 +926,11 @@ export function resolveModelFallbackError( if (err instanceof AgentHarnessSessionSupersededError) { return { kind: "coordination", error: err }; } + // Gateway admission can fail before any provider turn starts. Preserve that + // identity through wrappers and aggregates so fallback cannot blame a model. + if (hasGatewayDrainingFailure(err)) { + return { kind: "coordination", error: err }; + } const staleLifecycleFailure = hasStaleAgentRunLifecycleFailure(err); if ( staleLifecycleFailure && diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 623eb43eef64..b9250a25b426 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -16,6 +16,7 @@ import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.j import { setCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { clearCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-state.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import { GatewayDrainingError } from "../process/gateway-work-admission.js"; import { AgentRunTerminalOutcomeError } from "./agent-run-terminal-outcome.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; @@ -2080,6 +2081,60 @@ describe("runWithModelFallback", () => { ); }); + it.each([ + ["direct", () => new GatewayDrainingError()], + ["cause", () => new Error("session send failed", { cause: new GatewayDrainingError() })], + [ + "aggregate", + () => + new AggregateError( + [new Error("cleanup failed"), new GatewayDrainingError()], + "agent run failed", + ), + ], + ])("aborts fallback on %s gateway drain failures", async (_label, makeError) => { + const error = makeError(); + const run = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce("too late"); + const onError = vi.fn(); + const onFallbackStep = vi.fn(); + + await expect( + runWithModelFallback({ + cfg: undefined, + provider: "openai", + model: "gpt-5.6-sol", + fallbacksOverride: ["openai/gpt-5.4-mini"], + skipAuthProfileRuntime: true, + run, + onError, + onFallbackStep, + }), + ).rejects.toBe(error); + expect(run).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(onFallbackStep).not.toHaveBeenCalled(); + }); + + it("still advances after a genuine provider rate limit", async () => { + const rateLimit = Object.assign(new Error("rate limit exceeded"), { status: 429 }); + const run = vi.fn().mockRejectedValueOnce(rateLimit).mockResolvedValueOnce("fallback ok"); + + const result = await runWithModelFallback({ + cfg: undefined, + provider: "openai", + model: "gpt-5.6-sol", + fallbacksOverride: ["openai/gpt-5.4-mini"], + skipAuthProfileRuntime: true, + run, + }); + + expect(run).toHaveBeenCalledTimes(2); + expect(result.result).toBe("fallback ok"); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-5.4-mini"); + expect(result.attempts[0]).toMatchObject({ reason: "rate_limit", status: 429 }); + }); + it("aborts the fallback chain on transcript continuation failures without candidate_failed attribution", async () => { const cfg = makeCfg({ agents: {