Skip to content

Commit 6a5a135

Browse files
authored
fix(agents): skip fallback for session coordination errors
Preserve provider fallback metadata when session coordination errors are nested under provider failures. Co-authored-by: luyao618 <[email protected]>
1 parent 220d3ec commit 6a5a135

5 files changed

Lines changed: 205 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
5151
- Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.
5252
- Telegram: keep `/btw` and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.
5353
- Agents: log strict-agentic execution contract diagnostics only when the planning-only retry path actually triggers.
54+
- Agents: stop embedded session takeover and session write-lock errors from consuming model fallbacks while preserving provider fallback metadata. Fixes #83510. Thanks @luyao618.
5455
- Agents/video: hide `video_generate` reference-audio parameters unless a registered video provider supports audio inputs.
5556
- Plugins: fall back to npm for official ClawHub updates when artifact downloads are unavailable, including beta-to-default fallback and dry-run version reporting.
5657
- Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.

src/agents/failover-error.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
coerceToFailoverError,
44
describeFailoverError,
55
FailoverError,
6+
isNonProviderRuntimeCoordinationError,
67
isTimeoutError,
78
resolveFailoverReasonFromError,
89
resolveFailoverStatus,
@@ -1112,4 +1113,54 @@ describe("failover-error", () => {
11121113
expect(err?.lane).toBe("draft");
11131114
expect(err?.provider).toBe("openai");
11141115
});
1116+
1117+
describe("isNonProviderRuntimeCoordinationError", () => {
1118+
const makeSessionLockError = () =>
1119+
new SessionWriteLockTimeoutError({
1120+
timeoutMs: 10_000,
1121+
owner: "pid=37121",
1122+
lockPath: "/tmp/openclaw/session.jsonl.lock",
1123+
});
1124+
const makeEmbeddedTakeoverError = () => {
1125+
const err = new Error(
1126+
"session file changed while embedded prompt lock was released: /tmp/openclaw/session.jsonl",
1127+
);
1128+
err.name = "EmbeddedAttemptSessionTakeoverError";
1129+
return err;
1130+
};
1131+
1132+
it("returns true for direct session write-lock timeout errors", () => {
1133+
expect(isNonProviderRuntimeCoordinationError(makeSessionLockError())).toBe(true);
1134+
});
1135+
1136+
it("returns true for direct embedded attempt session takeover errors", () => {
1137+
expect(isNonProviderRuntimeCoordinationError(makeEmbeddedTakeoverError())).toBe(true);
1138+
});
1139+
1140+
it("returns true when the coordination error is nested via cause", () => {
1141+
const wrapped = new Error("wrapper", { cause: makeSessionLockError() });
1142+
expect(isNonProviderRuntimeCoordinationError(wrapped)).toBe(true);
1143+
1144+
const wrappedTakeover = new Error("wrapper", { cause: makeEmbeddedTakeoverError() });
1145+
expect(isNonProviderRuntimeCoordinationError(wrappedTakeover)).toBe(true);
1146+
});
1147+
1148+
it("returns false for plain timeouts and provider errors", () => {
1149+
const timeoutErr = Object.assign(new Error("operation timed out"), { name: "TimeoutError" });
1150+
expect(isNonProviderRuntimeCoordinationError(timeoutErr)).toBe(false);
1151+
expect(isNonProviderRuntimeCoordinationError({ status: 429, message: "rate limit" })).toBe(
1152+
false,
1153+
);
1154+
expect(
1155+
isNonProviderRuntimeCoordinationError({
1156+
status: 429,
1157+
code: "RESOURCE_EXHAUSTED",
1158+
message: "upstream quota pressure",
1159+
cause: makeSessionLockError(),
1160+
}),
1161+
).toBe(false);
1162+
expect(isNonProviderRuntimeCoordinationError(null)).toBe(false);
1163+
expect(isNonProviderRuntimeCoordinationError(undefined)).toBe(false);
1164+
});
1165+
});
11151166
});

src/agents/failover-error.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,49 @@ function hasSessionWriteLockTimeout(err: unknown, seen: Set<object> = new Set())
234234
);
235235
}
236236

237+
function isEmbeddedAttemptSessionTakeover(err: unknown): boolean {
238+
// Match by name to avoid importing pi-embedded-runner here (would create a cycle).
239+
return Boolean(
240+
err && typeof err === "object" && readErrorName(err) === "EmbeddedAttemptSessionTakeoverError",
241+
);
242+
}
243+
244+
function hasEmbeddedAttemptSessionTakeover(err: unknown, seen: Set<object> = new Set()): boolean {
245+
if (isEmbeddedAttemptSessionTakeover(err)) {
246+
return true;
247+
}
248+
if (!err || typeof err !== "object") {
249+
return false;
250+
}
251+
if (seen.has(err)) {
252+
return false;
253+
}
254+
seen.add(err);
255+
const candidate = err as { error?: unknown; cause?: unknown; reason?: unknown };
256+
return (
257+
hasEmbeddedAttemptSessionTakeover(candidate.error, seen) ||
258+
hasEmbeddedAttemptSessionTakeover(candidate.cause, seen) ||
259+
hasEmbeddedAttemptSessionTakeover(candidate.reason, seen)
260+
);
261+
}
262+
263+
/**
264+
* True when the error is a local runtime coordination error (session write-lock
265+
* timeout or embedded attempt session takeover) rather than a provider/model
266+
* failure. The model fallback chain must abort on these instead of consuming
267+
* candidate slots — retrying any model would hit the same local condition.
268+
* See #83510.
269+
*/
270+
export function isNonProviderRuntimeCoordinationError(err: unknown): boolean {
271+
if (!hasSessionWriteLockTimeout(err) && !hasEmbeddedAttemptSessionTakeover(err)) {
272+
return false;
273+
}
274+
if (isFailoverError(err)) {
275+
return false;
276+
}
277+
return resolveFailoverClassificationFromError(err) === null;
278+
}
279+
237280
function hasTimeoutHint(err: unknown): boolean {
238281
if (!err) {
239282
return false;

src/agents/model-fallback.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "./model-fallback.js";
2424
import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js";
2525
import type { EmbeddedPiRunResult } from "./pi-embedded-runner/types.js";
26+
import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js";
2627
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
2728

2829
vi.mock("../infra/file-lock.js", () => ({
@@ -768,6 +769,106 @@ describe("runWithModelFallback", () => {
768769
expect(run).toHaveBeenCalledTimes(1);
769770
});
770771

772+
it("aborts the fallback chain on embedded session takeover instead of trying every model (#83510)", async () => {
773+
const cfg = makeCfg({
774+
agents: {
775+
defaults: {
776+
model: {
777+
primary: "openai/gpt-5.4",
778+
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-4.1-mini"],
779+
},
780+
},
781+
},
782+
});
783+
const takeoverError = new Error(
784+
"session file changed while embedded prompt lock was released: /tmp/session.jsonl",
785+
);
786+
takeoverError.name = "EmbeddedAttemptSessionTakeoverError";
787+
const run = vi.fn().mockRejectedValue(takeoverError);
788+
789+
await expect(
790+
runWithModelFallback({
791+
cfg,
792+
provider: "openai",
793+
model: "gpt-5.4",
794+
run,
795+
}),
796+
).rejects.toBe(takeoverError);
797+
expect(run).toHaveBeenCalledTimes(1);
798+
});
799+
800+
it("aborts the fallback chain on session write-lock timeout instead of trying every model (#83510)", async () => {
801+
const cfg = makeCfg({
802+
agents: {
803+
defaults: {
804+
model: {
805+
primary: "openai/gpt-5.4",
806+
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-4.1-mini"],
807+
},
808+
},
809+
},
810+
});
811+
const lockError = new SessionWriteLockTimeoutError({
812+
timeoutMs: 10_000,
813+
owner: "pid=37121",
814+
lockPath: "/tmp/openclaw/session.jsonl.lock",
815+
});
816+
const run = vi.fn().mockRejectedValue(lockError);
817+
818+
await expect(
819+
runWithModelFallback({
820+
cfg,
821+
provider: "openai",
822+
model: "gpt-5.4",
823+
run,
824+
}),
825+
).rejects.toBe(lockError);
826+
expect(run).toHaveBeenCalledTimes(1);
827+
});
828+
829+
it("keeps provider failover metadata authoritative over nested session locks", async () => {
830+
const cfg = makeCfg({
831+
agents: {
832+
defaults: {
833+
model: {
834+
primary: "openai/gpt-5.4",
835+
fallbacks: ["anthropic/claude-sonnet-4-6"],
836+
},
837+
},
838+
},
839+
});
840+
const lockError = new SessionWriteLockTimeoutError({
841+
timeoutMs: 10_000,
842+
owner: "pid=37121",
843+
lockPath: "/tmp/openclaw/session.jsonl.lock",
844+
});
845+
const providerError = {
846+
status: 429,
847+
code: "RESOURCE_EXHAUSTED",
848+
message: "upstream quota pressure",
849+
cause: lockError,
850+
};
851+
const run = vi.fn().mockRejectedValueOnce(providerError).mockResolvedValueOnce("fallback ok");
852+
853+
const result = await runWithModelFallback({
854+
cfg,
855+
provider: "openai",
856+
model: "gpt-5.4",
857+
run,
858+
});
859+
860+
expect(result.result).toBe("fallback ok");
861+
expect(result.provider).toBe("anthropic");
862+
expect(run).toHaveBeenCalledTimes(2);
863+
expect(result.attempts[0]).toMatchObject({
864+
provider: "openai",
865+
model: "gpt-5.4",
866+
reason: "rate_limit",
867+
status: 429,
868+
code: "RESOURCE_EXHAUSTED",
869+
});
870+
});
871+
771872
it("keeps raw provider schema errors in fallback summaries", async () => {
772873
const cfg = makeCfg({
773874
agents: {

src/agents/model-fallback.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
coerceToFailoverError,
2020
describeFailoverError,
2121
isFailoverError,
22+
isNonProviderRuntimeCoordinationError,
2223
isTimeoutError,
2324
} from "./failover-error.js";
2425
import {
@@ -1197,6 +1198,14 @@ export async function runWithModelFallback<T>(
11971198
}
11981199
const err = attemptRun.error;
11991200
{
1201+
// Local runtime coordination errors (session write-lock timeout, embedded
1202+
// attempt session takeover) are not provider/model failures. Aborting
1203+
// here prevents the fallback chain from consuming candidates retrying
1204+
// the same local condition and surfacing a misleading "All models
1205+
// failed" summary. See #83510.
1206+
if (isNonProviderRuntimeCoordinationError(err)) {
1207+
throw err;
1208+
}
12001209
if (transientProbeProviderForAttempt) {
12011210
const probeFailureReason = describeFailoverError(err).reason;
12021211
if (!shouldPreserveTransientCooldownProbeSlot(probeFailureReason)) {

0 commit comments

Comments
 (0)