fix(agents): defer session suspension across fallback (#93789)

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
OfflynAI
2026-06-16 18:04:48 -07:00
committed by GitHub
parent 891dd037b5
commit 9fd9aa5fcd
6 changed files with 556 additions and 58 deletions
+19 -5
View File
@@ -121,7 +121,12 @@ import { runAgentCleanupStep } from "../run-cleanup-timeout.js";
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
import { buildAgentRuntimePlan } from "../runtime-plan/build.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { resolveSessionSuspensionReason, suspendSession } from "../session-suspension.js";
import {
resolveSessionSuspensionReason,
resolveSessionSuspensionTarget,
suspendSession,
type SessionSuspensionParams,
} from "../session-suspension.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
@@ -619,6 +624,17 @@ async function runEmbeddedAgentInternal(
}
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
// Outer fallback attempts defer session suspension only while another
// candidate remains. Direct and final-candidate runs suspend normally.
const failureSuspension = resolveSessionSuspensionTarget();
const suspendForFailure = (suspensionParams: Omit<SessionSuspensionParams, "laneId">) => {
const suspension = { ...suspensionParams, laneId: globalLane };
if (failureSuspension.mode === "defer") {
failureSuspension.defer(suspension);
return;
}
void suspendSession(suspension);
};
const sessionQueuePriority = resolveEmbeddedRunSessionQueuePriority(params.trigger);
const laneTaskTimeoutMs = resolveEmbeddedRunLaneTimeoutMs(params.timeoutMs);
let laneTaskProgressAtMs = Date.now();
@@ -2784,11 +2800,10 @@ async function runEmbeddedAgentInternal(
? describeFailoverError(normalizedPromptFailover)
: describeFailoverError(promptError);
if (normalizedPromptFailover?.suspend) {
void suspendSession({
suspendForFailure({
cfg: params.config,
agentDir,
sessionId: activeSessionId ?? params.sessionId,
laneId: globalLane,
reason: resolveSessionSuspensionReason(normalizedPromptFailover.reason),
failedProvider: normalizedPromptFailover.provider ?? provider,
failedModel: normalizedPromptFailover.model ?? modelId,
@@ -3258,11 +3273,10 @@ async function runEmbeddedAgentInternal(
: {}),
});
if (assistantFailoverOutcome.error.suspend) {
void suspendSession({
suspendForFailure({
cfg: params.config,
agentDir,
sessionId: activeSessionId ?? params.sessionId,
laneId: globalLane,
reason: resolveSessionSuspensionReason(assistantFailoverOutcome.error.reason),
failedProvider: assistantFailoverOutcome.error.provider ?? provider,
failedModel: assistantFailoverOutcome.error.model ?? modelId,
+265
View File
@@ -6,6 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
import type { OpenClawConfig } from "../config/config.js";
import { createDiagnosticLogRecordCapture } from "../logging/test-helpers/diagnostic-log-capture.js";
import type { AuthProfileStore } from "./auth-profiles.js";
import type { SessionSuspensionParams } from "./session-suspension.js";
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
// Mock auth-profile submodules before importing model-fallback so the module
@@ -29,6 +30,34 @@ vi.mock("./provider-model-normalization.runtime.js", () => ({
normalizeProviderModelIdWithRuntime: () => undefined,
}));
const sessionSuspensionMocks = vi.hoisted(() => ({
suspendSession: vi.fn().mockResolvedValue(undefined),
runWithDeferredSessionSuspension: vi.fn(
(run: () => Promise<unknown>, onDeferred?: (params: SessionSuspensionParams) => void) => {
onDeferred?.({
cfg: {},
sessionId: "test-session",
laneId: "main",
reason: "quota_exhausted",
failedProvider: "openai",
failedModel: "gpt-4.1-mini",
});
return run();
},
),
resolveSessionSuspensionReason: vi.fn((reason: string) => {
if (reason === "billing") {
return "manual";
}
if (reason === "rate_limit") {
return "quota_exhausted";
}
return "circuit_open";
}),
}));
vi.mock("./session-suspension.js", () => sessionSuspensionMocks);
const emptyPluginMetadataSnapshot = vi.hoisted(() => ({
policyHash: "model-fallback-probe-test-empty-plugin-policy",
configFingerprint: "model-fallback-probe-test-empty-plugin-metadata",
@@ -341,6 +370,8 @@ describe("runWithModelFallback probe logic", () => {
cleanupLogCapture = undefined;
setLoggerOverride(null);
resetLogger();
sessionSuspensionMocks.suspendSession.mockClear();
sessionSuspensionMocks.runWithDeferredSessionSuspension.mockClear();
vi.restoreAllMocks();
});
@@ -793,4 +824,238 @@ describe("runWithModelFallback probe logic", () => {
"billing",
);
});
it("does not lock lane when fallback candidates remain after suspend_lanes decision", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
} as Partial<OpenClawConfig>);
// Put only OpenAI into cooldown; Anthropic is available
mockedIsProfileInCooldown.mockImplementation((_store: AuthProfileStore, profileId: string) =>
profileId.startsWith("openai"),
);
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 60 * 1000);
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
const run = vi.fn().mockResolvedValue("fallback-ok");
await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
sessionId: "test-session",
lane: "main",
});
expect(sessionSuspensionMocks.suspendSession).not.toHaveBeenCalled();
});
it("defers embedded lane suspension only while another candidate remains", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
} as Partial<OpenClawConfig>);
mockedIsProfileInCooldown.mockReturnValue(false);
const run = vi
.fn()
.mockRejectedValueOnce(new Error("primary failed"))
.mockResolvedValueOnce("fallback-ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
sessionId: "test-session",
lane: "main",
});
expect(result.result).toBe("fallback-ok");
expect(run).toHaveBeenCalledTimes(2);
expect(sessionSuspensionMocks.runWithDeferredSessionSuspension).toHaveBeenCalledOnce();
});
it("discards deferred suspension when the outer run is aborted", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
} as Partial<OpenClawConfig>);
mockedIsProfileInCooldown.mockReturnValue(false);
const controller = new AbortController();
const disconnect = new Error("client disconnected");
disconnect.name = "ClientDisconnectError";
const run = vi.fn().mockImplementation(async () => {
controller.abort(disconnect);
throw disconnect;
});
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
sessionId: "test-session",
lane: "main",
abortSignal: controller.signal,
}),
).rejects.toBe(disconnect);
expect(run).toHaveBeenCalledOnce();
expect(sessionSuspensionMocks.runWithDeferredSessionSuspension).toHaveBeenCalledOnce();
expect(sessionSuspensionMocks.suspendSession).not.toHaveBeenCalled();
});
it("keeps generic no-lane terminal suspension unbound", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
} as Partial<OpenClawConfig>);
// Both providers in cooldown
mockedIsProfileInCooldown.mockReturnValue(true);
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 60 * 1000);
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => {
if (provider === "openai") {
return ["openai-profile-1"];
}
if (provider === "anthropic") {
return ["anthropic-profile-1"];
}
return [];
});
// Throttle primary probe so billing goes to suspend_lanes
probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 10_000);
const run = vi.fn().mockResolvedValue("should-not-run");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
sessionId: "test-session",
}),
).rejects.toThrow();
expect(sessionSuspensionMocks.suspendSession).toHaveBeenCalledWith(
expect.objectContaining({
laneId: undefined,
failedProvider: "anthropic",
}),
);
expect(sessionSuspensionMocks.suspendSession).not.toHaveBeenCalledWith(
expect.objectContaining({ failedProvider: "openai" }),
);
expect(
sessionSuspensionMocks.suspendSession.mock.calls.every(
([params]) => params.laneId === undefined,
),
).toBe(true);
});
it("restores a deferred embedded lane when later candidates cannot run", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
} as Partial<OpenClawConfig>);
mockedIsProfileInCooldown.mockImplementation((_store: AuthProfileStore, profileId: string) =>
profileId.startsWith("anthropic"),
);
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 60 * 1000);
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => [
`${provider}-profile-1`,
]);
const run = vi.fn().mockRejectedValueOnce(new Error("primary failed"));
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
sessionId: "test-session",
}),
).rejects.toThrow();
expect(run).toHaveBeenCalledOnce();
expect(sessionSuspensionMocks.suspendSession).toHaveBeenCalledWith(
expect.objectContaining({
laneId: "main",
failedProvider: "anthropic",
}),
);
});
it("restores deferred suspension when a later harness precheck fails", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
} as Partial<OpenClawConfig>);
mockedIsProfileInCooldown.mockReturnValue(false);
const run = vi.fn().mockRejectedValueOnce(new Error("primary failed"));
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
sessionId: "test-session",
resolveAgentHarnessRuntimeOverride: (provider) =>
provider === "anthropic" ? "missing-strict-harness" : undefined,
prepareAgentHarnessRuntime: () => undefined,
run,
}),
).rejects.toThrow('Requested agent harness "missing-strict-harness" is not registered.');
expect(run).toHaveBeenCalledOnce();
expect(sessionSuspensionMocks.suspendSession).toHaveBeenCalledWith(
expect.objectContaining({
laneId: "main",
failedProvider: "openai",
}),
);
});
});
@@ -8,7 +8,7 @@ import type { AuthProfileFailureReason } from "./auth-profiles.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./auth-profiles/store.js";
import { classifyEmbeddedAgentRunResultForModelFallback } from "./embedded-agent-runner/result-fallback-classifier.js";
import type { EmbeddedRunAttemptResult } from "./embedded-agent-runner/run/types.js";
import { runWithModelFallback } from "./model-fallback.js";
import { FailoverError } from "./failover-error.js";
import {
buildEmbeddedRunnerAssistant,
createResolvedEmbeddedRunnerModel,
@@ -21,6 +21,7 @@ import {
} from "./test-helpers/embedded-agent-runner-e2e-mocks.js";
const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise<EmbeddedRunAttemptResult>>();
const suspendSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({
computeBackoffMock: vi.fn(
(
@@ -54,18 +55,26 @@ const installRunEmbeddedMocks = () => {
resolveModelAsync: async (provider: string, modelId: string) =>
createResolvedEmbeddedRunnerModel(provider, modelId),
}));
vi.doMock("./session-suspension.js", async () => {
const actual =
await vi.importActual<typeof import("./session-suspension.js")>("./session-suspension.js");
return { ...actual, suspendSession: suspendSessionMock };
});
};
let runEmbeddedAgent: typeof import("./embedded-agent-runner/run.js").runEmbeddedAgent;
let runWithModelFallback: typeof import("./model-fallback.js").runWithModelFallback;
beforeAll(async () => {
vi.resetModules();
installRunEmbeddedMocks();
({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js"));
({ runWithModelFallback } = await import("./model-fallback.js"));
});
beforeEach(() => {
runEmbeddedAttemptMock.mockReset();
suspendSessionMock.mockClear();
computeBackoffMock.mockClear();
sleepWithAbortMock.mockClear();
});
@@ -219,21 +228,26 @@ async function runEmbeddedFallback(params: {
workspaceDir: string;
sessionKey: string;
runId: string;
sessionId?: string;
lane?: string;
abortSignal?: AbortSignal;
config?: OpenClawConfig;
}) {
// Runs the same embedded-agent entrypoint that production fallback uses while
// keeping provider/model attempts deterministic through mocks.
const cfg = params.config ?? makeConfig();
const sessionId = params.sessionId ?? `session:${params.runId}`;
return await runWithModelFallback({
cfg,
provider: "openai",
model: "mock-1",
runId: params.runId,
sessionId: params.sessionId,
lane: params.lane,
agentDir: params.agentDir,
run: (provider, model, options) =>
runEmbeddedAgent({
sessionId: `session:${params.runId}`,
sessionId,
sessionKey: params.sessionKey,
sessionFile: path.join(params.workspaceDir, `${params.runId}.jsonl`),
workspaceDir: params.workspaceDir,
@@ -242,6 +256,7 @@ async function runEmbeddedFallback(params: {
prompt: "hello",
provider,
model,
lane: params.lane,
authProfileIdSource: "auto",
allowTransientCooldownProbe: options?.allowTransientCooldownProbe,
timeoutMs: 5_000,
@@ -293,6 +308,20 @@ function mockPrimaryPromptErrorThenFallbackSuccess(errorMessage: string) {
);
}
function mockPrimarySuspendingPromptErrorThenFallbackSuccess(sessionId: string) {
mockPrimaryFailureThenFallbackSuccess(() =>
makeEmbeddedRunnerAttempt({
sessionIdUsed: sessionId,
promptError: new FailoverError(RATE_LIMIT_ERROR_MESSAGE, {
reason: "rate_limit",
provider: "openai",
model: "mock-1",
suspend: true,
}),
}),
);
}
function mockPrimaryErrorThenFallbackSuccess(errorMessage: string) {
mockPrimaryFailureThenFallbackSuccess(() =>
makeEmbeddedRunnerAttempt({
@@ -529,6 +558,64 @@ describe("runWithModelFallback + runEmbeddedAgent failover behavior", () => {
}
});
it("keeps direct embedded-run lane suspension outside the outer fallback loop", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);
const sessionId = "session:direct-embedded-suspension";
mockPrimarySuspendingPromptErrorThenFallbackSuccess(sessionId);
await expect(
runEmbeddedAgent({
sessionId,
sessionKey: "agent:test:direct-embedded-suspension",
sessionFile: path.join(workspaceDir, "direct-embedded-suspension.jsonl"),
workspaceDir,
agentDir,
config: {
...makeConfig(),
auth: { cooldowns: { rateLimitedProfileRotations: 0 } },
},
prompt: "hello",
provider: "openai",
model: "mock-1",
lane: "direct-lane",
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:direct-embedded-suspension",
enqueue: async (task) => await task(),
}),
).rejects.toThrow();
expect(suspendSessionMock).toHaveBeenCalledWith(
expect.objectContaining({ laneId: "direct-lane" }),
);
});
});
it("does not suspend the session while an outer fallback candidate remains", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);
const sessionId = "session:outer-fallback-suspension";
mockPrimarySuspendingPromptErrorThenFallbackSuccess(sessionId);
const result = await runEmbeddedFallback({
agentDir,
workspaceDir,
sessionId,
sessionKey: "agent:test:outer-fallback-suspension",
lane: "outer-fallback-lane",
runId: "run:outer-fallback-suspension",
config: {
...makeConfig(),
auth: { cooldowns: { rateLimitedProfileRotations: 0 } },
},
});
expect(result.provider).toBe("groq");
expect(suspendSessionMock).not.toHaveBeenCalled();
});
});
it("falls back across providers after a bare leading 402 quota-refresh assistant error", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);
+119 -40
View File
@@ -77,7 +77,12 @@ import {
resolveModelRefFromString,
} from "./model-selection-resolve.js";
import { isAgentRunRestartAbortReason } from "./run-termination.js";
import { resolveSessionSuspensionReason, suspendSession } from "./session-suspension.js";
import {
resolveSessionSuspensionReason,
runWithDeferredSessionSuspension,
suspendSession,
type SessionSuspensionParams,
} from "./session-suspension.js";
const log = createSubsystemLogger("model-fallback");
@@ -337,13 +342,19 @@ async function runFallbackCandidate<T>(params: {
provider: string;
model: string;
options?: ModelFallbackRunOptions;
deferSessionSuspension?: boolean;
onDeferredSessionSuspension?: (params: SessionSuspensionParams) => void;
attribution?: FailoverAttribution;
abortSignal?: AbortSignal;
}): Promise<{ ok: true; result: T } | { ok: false; error: unknown }> {
try {
const result = params.options
? await params.run(params.provider, params.model, params.options)
: await params.run(params.provider, params.model);
const run = () =>
params.options
? params.run(params.provider, params.model, params.options)
: params.run(params.provider, params.model);
const result = params.deferSessionSuspension
? await runWithDeferredSessionSuspension(run, params.onDeferredSessionSuspension)
: await run();
return {
ok: true,
result,
@@ -379,6 +390,8 @@ async function runFallbackAttempt<T>(params: {
model: string;
attempts: FallbackAttempt[];
options?: ModelFallbackRunOptions;
deferSessionSuspension?: boolean;
onDeferredSessionSuspension?: (params: SessionSuspensionParams) => void;
classifyResult?: ModelFallbackResultClassifier<T>;
attempt: number;
total: number;
@@ -397,6 +410,8 @@ async function runFallbackAttempt<T>(params: {
provider: params.provider,
model: params.model,
options: params.options,
deferSessionSuspension: params.deferSessionSuspension,
onDeferredSessionSuspension: params.onDeferredSessionSuspension,
attribution: params.attribution,
abortSignal: params.abortSignal,
});
@@ -1259,33 +1274,80 @@ function resolveCooldownDecision(params: {
};
}
export async function runWithModelFallback<T>(
params: {
cfg: OpenClawConfig | undefined;
type RunWithModelFallbackParams<T> = {
cfg: OpenClawConfig | undefined;
provider: string;
model: string;
runId?: string;
sessionId?: string;
agentId?: string;
sessionKey?: string;
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareAgentHarnessRuntime?: (params: {
provider: string;
model: string;
runId?: string;
sessionId?: string;
agentId?: string;
sessionKey?: string;
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareAgentHarnessRuntime?: (params: {
provider: string;
model: string;
agentHarnessRuntimeOverride?: string;
}) => Promise<void> | void;
lane?: string;
agentDir?: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: ModelFallbackRunFn<T>;
onError?: ModelFallbackErrorHandler;
onFallbackStep?: ModelFallbackStepHandler;
classifyResult?: ModelFallbackResultClassifier<T>;
mergeExhaustedResult?: (params: { latestResult: T; preferredResult: T }) => T;
skipAuthProfileRuntime?: boolean;
abortSignal?: AbortSignal;
} & ModelManifestNormalizationContext,
agentHarnessRuntimeOverride?: string;
}) => Promise<void> | void;
lane?: string;
agentDir?: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: ModelFallbackRunFn<T>;
onError?: ModelFallbackErrorHandler;
onFallbackStep?: ModelFallbackStepHandler;
classifyResult?: ModelFallbackResultClassifier<T>;
mergeExhaustedResult?: (params: { latestResult: T; preferredResult: T }) => T;
skipAuthProfileRuntime?: boolean;
abortSignal?: AbortSignal;
} & ModelManifestNormalizationContext;
type DeferredSessionSuspensionState = {
pending?: SessionSuspensionParams;
};
function flushDeferredSessionSuspension(state: DeferredSessionSuspensionState): void {
const pending = state.pending;
if (!pending) {
return;
}
state.pending = undefined;
void suspendSession(pending);
}
function shouldDiscardDeferredSessionSuspension(params: {
error: unknown;
abortSignal?: AbortSignal;
}): boolean {
return (
isTerminalAbort(params.abortSignal) ||
shouldRethrowAbort(params.error) ||
isCommandLaneTaskTimeoutError(params.error) ||
isNonProviderRuntimeCoordinationError(params.error) ||
isLikelyContextOverflowError(formatErrorMessage(params.error))
);
}
export async function runWithModelFallback<T>(
params: RunWithModelFallbackParams<T>,
): Promise<ModelFallbackRunResult<T>> {
const deferredSuspension: DeferredSessionSuspensionState = {};
try {
const result = await runWithModelFallbackInternal(params, deferredSuspension);
if (result.outcome === "exhausted") {
flushDeferredSessionSuspension(deferredSuspension);
}
return result;
} catch (err) {
if (!shouldDiscardDeferredSessionSuspension({ error: err, abortSignal: params.abortSignal })) {
flushDeferredSessionSuspension(deferredSuspension);
}
throw err;
}
}
async function runWithModelFallbackInternal<T>(
params: RunWithModelFallbackParams<T>,
deferredSuspension: DeferredSessionSuspensionState,
): Promise<ModelFallbackRunResult<T>> {
const candidates = resolveModelCandidateChain({
cfg: params.cfg,
@@ -1311,6 +1373,8 @@ export async function runWithModelFallback<T>(
let latestClassifiedResult: ModelFallbackClassifiedResult<T> | undefined;
let exhaustionResult: ModelFallbackExhaustionResult<T> | undefined;
const cooldownProbeUsedProviders = new Set<string>();
const resolveTerminalSuspensionLane = () =>
deferredSuspension.pending ? deferredSuspension.pending.laneId : params.lane;
const observeDecision = async (decision: ModelFallbackDecisionParams) => {
if (!params.onFallbackStep && !isModelFallbackDecisionLogEnabled()) {
return;
@@ -1445,6 +1509,10 @@ export async function runWithModelFallback<T>(
authMode,
});
// Only lock the lane when no remaining candidates can serve as
// fallbacks. Per-provider cooldown state already prevents
// re-attempting the failed provider on subsequent turns.
const hasRemainingCandidates = i + 1 < candidates.length;
if (params.sessionId) {
emitFailoverEvent({
sessionId: params.sessionId,
@@ -1452,17 +1520,21 @@ export async function runWithModelFallback<T>(
fromProvider: candidate.provider,
fromModel: candidate.model,
reason: decision.reason,
suspended: true,
});
void suspendSession({
cfg: params.cfg,
agentDir: params.agentDir,
sessionId: params.sessionId,
laneId: params.lane,
reason: resolveSessionSuspensionReason(decision.reason),
failedProvider: candidate.provider,
failedModel: candidate.model,
suspended: !hasRemainingCandidates,
});
if (!hasRemainingCandidates) {
const laneId = resolveTerminalSuspensionLane();
deferredSuspension.pending = undefined;
void suspendSession({
cfg: params.cfg,
agentDir: params.agentDir,
sessionId: params.sessionId,
laneId,
reason: resolveSessionSuspensionReason(decision.reason),
failedProvider: candidate.provider,
failedModel: candidate.model,
});
}
}
await observeDecision({
@@ -1584,6 +1656,13 @@ export async function runWithModelFallback<T>(
...candidate,
attempts,
options: runOptions,
// Only the outer fallback loop knows another candidate remains. Carry
// that fact through this attempt so the embedded runner does not freeze
// the shared lane before the next candidate can run.
deferSessionSuspension: i + 1 < candidates.length,
onDeferredSessionSuspension: (suspension) => {
deferredSuspension.pending = suspension;
},
classifyResult: params.classifyResult,
attempt: i + 1,
total: candidates.length,
@@ -1795,7 +1874,7 @@ export async function runWithModelFallback<T>(
cfg: params.cfg,
candidates,
}),
attribution: { sessionId: params.sessionId, lane: params.lane },
attribution: { sessionId: params.sessionId, lane: resolveTerminalSuspensionLane() },
cfg: params.cfg,
agentDir: params.agentDir,
});
+26
View File
@@ -120,6 +120,32 @@ describe("session suspension", () => {
expect(patch.quotaSuspension?.expectedResumeBy).toBe(1_000 + MAX_TIMER_TIMEOUT_MS);
});
it("defers session suspension only for the outer fallback candidate run", async () => {
const { resolveSessionSuspensionTarget, runWithDeferredSessionSuspension } =
await import("./session-suspension.js");
const onDeferred = vi.fn();
expect(resolveSessionSuspensionTarget()).toEqual({ mode: "suspend" });
await runWithDeferredSessionSuspension(async () => {
const target = resolveSessionSuspensionTarget();
expect(target.mode).toBe("defer");
if (target.mode === "defer") {
target.defer({
cfg: {},
sessionId: "session-1",
laneId: CommandLane.Main,
reason: "quota_exhausted",
failedProvider: "openai",
failedModel: "gpt-5.5",
});
}
expect(resolveSessionSuspensionTarget()).toEqual({ mode: "suspend" });
}, onDeferred);
expect(onDeferred).toHaveBeenCalledOnce();
expect(onDeferred).toHaveBeenCalledWith(expect.objectContaining({ laneId: CommandLane.Main }));
expect(resolveSessionSuspensionTarget()).toEqual({ mode: "suspend" });
});
it("maps failover reasons to persisted suspension reasons", async () => {
const { testing } = await import("./session-suspension.js");
+38 -11
View File
@@ -3,6 +3,7 @@
*
* Records quota/manual/circuit suspensions and temporarily lowers command-lane concurrency.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import { resolveAgentMaxConcurrent, resolveSubagentMaxConcurrent } from "../config/agent-limits.js";
import { resolveCronMaxConcurrentRuns } from "../config/cron-limits.js";
@@ -23,8 +24,26 @@ const DEFAULT_CUSTOM_LANE_RESUME_CONCURRENCY = 1;
export const DEFAULT_QUOTA_SUSPENSION_RESUME_MS = 30 * 60 * 1000; // 30 min
const laneResumeTimers = new Map<string, ReturnType<typeof setTimeout>>();
const deferredSessionSuspension = new AsyncLocalStorage<{
claimed: boolean;
onDeferred?: (params: SessionSuspensionParams) => void;
}>();
export type SessionSuspensionReason = "quota_exhausted" | "manual" | "circuit_open";
export type SessionSuspensionTarget =
| { mode: "defer"; defer: (params: SessionSuspensionParams) => void }
| { mode: "suspend" };
export type SessionSuspensionParams = {
cfg: OpenClawConfig | undefined;
agentDir?: string;
sessionId: string;
laneId?: string;
reason: SessionSuspensionReason;
failedProvider: string;
failedModel: string;
summary?: string;
ttlMs?: number;
};
function resolveLaneResumeConcurrency(cfg: OpenClawConfig | undefined, laneId: string): number {
switch (laneId) {
@@ -50,6 +69,24 @@ export function resolveSessionSuspensionReason(reason: FailoverReason): SessionS
return "circuit_open";
}
export function runWithDeferredSessionSuspension<T>(
run: () => Promise<T>,
onDeferred?: (params: SessionSuspensionParams) => void,
): Promise<T> {
return deferredSessionSuspension.run({ claimed: false, onDeferred }, run);
}
export function resolveSessionSuspensionTarget(): SessionSuspensionTarget {
const scope = deferredSessionSuspension.getStore();
if (!scope || scope.claimed) {
return { mode: "suspend" };
}
// One candidate callback may launch nested direct embedded runs. Only its
// first embedded run inherits the outer fallback's remaining-candidate fact.
scope.claimed = true;
return { mode: "defer", defer: (params) => scope.onDeferred?.(params) };
}
function scheduleLaneAutoResume(laneId: string, delayMs: number, resumeConcurrency: number) {
const existing = laneResumeTimers.get(laneId);
if (existing) {
@@ -78,17 +115,7 @@ export function cancelLaneAutoResume(laneId: string) {
}
}
export async function suspendSession(params: {
cfg: OpenClawConfig | undefined;
agentDir?: string;
sessionId: string;
laneId?: string;
reason: SessionSuspensionReason;
failedProvider: string;
failedModel: string;
summary?: string;
ttlMs?: number;
}) {
export async function suspendSession(params: SessionSuspensionParams) {
if (!params.cfg) {
return;
}