Skip to content

fix(gateway): prevent session death loop on overloaded fallback#58379

Merged
obviyus merged 2 commits into
openclaw:mainfrom
openperf:fix-58348-session-death-loop
Mar 31, 2026
Merged

fix(gateway): prevent session death loop on overloaded fallback#58379
obviyus merged 2 commits into
openclaw:mainfrom
openperf:fix-58348-session-death-loop

Conversation

@openperf

Copy link
Copy Markdown
Member

Summary

  • Problem: When a provider (e.g., Anthropic) returns overloaded_error, the session enters a "death loop" — a zombie state where it silently stops responding to all new messages without surfacing any error to the user. Observed 97 unbounded retries in production logs over 9 days, with 4+ confirmed incidents. The bot simply goes silent and never recovers.
  • Root Cause: Two compounding defects create the death loop:
    1. Unbounded LiveSessionModelSwitchError retry in src/auto-reply/reply/agent-runner-execution.ts (line 618): The while(true) loop catches LiveSessionModelSwitchError and continues without any retry limit. When the overloaded primary model triggers fallback to a different model, the inner runEmbeddedPiAgent detects a mismatch with the persisted session selection and throws LiveSessionModelSwitchError. The outer loop rewrites followupRun.run.provider/model back to the switched model and retries, but the persisted session keeps pulling back to the overloaded primary, creating an infinite ping-pong.
    2. Wasteful auth-profile rotation for overloaded errors in src/agents/pi-embedded-runner/run.ts (line 1200): When shouldRotate is true for an overloaded error, the runner rotates through all available auth profiles for the same provider. Since overload is a provider-level capacity issue (not per-key), this is futile and only adds unnecessary backoff delays (exponential, up to 30s per rotation) before finally escalating to cross-provider fallback.
  • Fix:
    1. Added MAX_LIVE_SWITCH_RETRIES = 2 guard in agent-runner-execution.ts. When exceeded, the function immediately returns { kind: "final" } with a user-visible error message instead of continuing forever. This directly prevents the zombie state.
    2. Added MAX_OVERLOAD_PROFILE_ROTATIONS = 1 cap in run.ts specifically for assistantFailoverReason === "overloaded". After one probe rotation (to handle transient blips), the runner throws FailoverError to escalate immediately to cross-provider fallback, bypassing remaining same-provider profiles.
      Both guards are scoped exclusively to their respective error types, preserving existing behavior for all other error classes (rate_limit, auth, billing, etc.).
  • What changed:
    • src/auto-reply/reply/agent-runner-execution.ts: Added liveModelSwitchRetries counter and MAX_LIVE_SWITCH_RETRIES guard. On exhaustion, returns kind: "final" with a user-facing error message.
    • src/agents/pi-embedded-runner/run.ts: Added overloadProfileRotations counter. When overloaded rotations exceed MAX_OVERLOAD_PROFILE_ROTATIONS, throws FailoverError to escalate to cross-provider fallback.
    • src/agents/pi-embedded-runner/run/helpers.ts: Exported MAX_OVERLOAD_PROFILE_ROTATIONS = 1 constant with documentation.
    • src/auto-reply/reply/agent-runner-execution.test.ts: Added 2 tests — infinite loop prevention and auth profile state propagation across bounded retries.
    • src/agents/model-fallback.run-embedded.e2e.test.ts: Added e2e test for multi-profile overload rotation capping.
  • What did NOT change (scope boundary):
    • Did not change how overloaded_error is parsed or classified (failover-matches.ts, failover-error.ts).
    • Did not change retry/rotation behavior for non-overloaded errors (rate_limit, auth, billing, context_overflow).
    • Did not modify the cron isolated-agent path (src/cron/isolated-agent/run.ts), which already has its own bounded retry logic.
    • Did not change the OVERLOAD_FAILOVER_BACKOFF_POLICY timing constants.

Reproduction

  1. Configure an agent with primary model anthropic/claude-opus and fallback chain [openai/gpt-5.4, groq/llama].
  2. Configure multiple auth profiles for the Anthropic provider.
  3. Start a session and pin it to anthropic/claude-opus.
  4. Simulate persistent overloaded_error from all Anthropic endpoints.
  5. Without fix: The agent enters an infinite LiveSessionModelSwitchError loop, never replies, and the session becomes a zombie. Logs show 97+ retries with no termination.
  6. With fix: After 2 switch retries, the agent returns a user-visible error message. At the inner runner level, after 1 profile rotation, it escalates to the next provider in the fallback chain.

Risk / Mitigation

  • Risk: The MAX_LIVE_SWITCH_RETRIES = 2 limit could prematurely terminate legitimate rapid model switches if a user triggers multiple valid switches in quick succession.
  • Mitigation: Legitimate model switches typically require at most one retry to align session state. The limit of 2 provides sufficient buffer. The MAX_OVERLOAD_PROFILE_ROTATIONS = 1 cap only applies to overloaded errors and still allows one probe attempt for transient blips. Both limits are tested with dedicated unit and e2e tests that verify exact retry counts and state propagation.

Change Type (select all)

  • Bug fix

Scope (select all touched areas)

  • Gateway
  • Agent Runner
  • Fallback Policy

Linked Issue/PR

Fixes #58348

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M labels Mar 31, 2026
@greptile-apps

greptile-apps Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a confirmed production "death loop" where sessions would silently stop responding when a provider returned overloaded_error, with 97 unbounded retries observed over 9 days. Two targeted guards are added:

  • agent-runner-execution.ts: A liveModelSwitchRetries counter with MAX_LIVE_SWITCH_RETRIES = 2 breaks the unbounded while(true) loop that was ping-ponging between the overloaded primary model and the fallback model. On exhaustion, a user-visible error is returned instead of entering a zombie state.
  • run.ts / run/helpers.ts: An overloadProfileRotations counter with MAX_OVERLOAD_PROFILE_ROTATIONS = 1 prevents pointless same-provider auth-profile cycling for overloaded errors (which are provider-level, not key-level). After one probe rotation, a FailoverError is thrown to escalate immediately to cross-provider fallback.

Both guards are correctly scoped to their respective error types and don't disturb existing retry/rotation behavior for rate limit, auth, billing, or context overflow errors. The logic is sound and the fix directly addresses the root cause. Three minor style/observability nits are noted below.

Confidence Score: 5/5

Safe to merge — all remaining findings are P2 style suggestions that do not affect runtime behavior or correctness.

The core fix is logically correct: both retry counters use consistent off-by-one semantics (strict > check), the overload rotation cap applies only when fallbackConfigured = true (gracefully degrades with no fallback), auth-profile state propagation across retries is preserved, and the new unit and e2e tests cover the critical paths. The three findings are all P2: a misleading log label before FailoverError, a locally-scoped constant that should be exported for consistency, and weak toBeLessThanOrEqual assertions that should be toBe. None affect actual failover behavior.

No files require special attention — all issues are non-blocking style/observability concerns.

Important Files Changed

Filename Overview
src/auto-reply/reply/agent-runner-execution.ts Adds liveModelSwitchRetries counter and MAX_LIVE_SWITCH_RETRIES=2 guard to break the unbounded while(true) loop on repeated LiveSessionModelSwitchError; returns a user-visible final error on exhaustion.
src/agents/pi-embedded-runner/run.ts Adds overloadProfileRotations counter; when cap exceeded and fallback is configured, throws FailoverError immediately instead of cycling through remaining same-provider profiles.
src/agents/pi-embedded-runner/run/helpers.ts Exports MAX_OVERLOAD_PROFILE_ROTATIONS=1 constant with documentation linking to the issue.
src/auto-reply/reply/agent-runner-execution.test.ts Adds two unit tests: infinite-loop prevention (asserts kind=final after retry exhaustion) and auth-profile state propagation across bounded retries (asserts kind=success with correct profile on 3rd attempt).
src/agents/model-fallback.run-embedded.e2e.test.ts Adds e2e test verifying that with 3 OpenAI profiles and persistent overloaded errors, the runner escalates to the groq fallback after at most 2 OpenAI attempts instead of exhausting all profiles.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run.ts
Line: 1213

Comment:
**Misleading decision label before FailoverError**

`logAssistantFailoverDecision("rotate_profile")` is called here, but the very next action is throwing a `FailoverError` that escalates to cross-provider fallback — not a profile rotation. When this code path fires, any observability tooling (metrics, dashboards, audit logs) that reads decision events will record `"rotate_profile"` for what is actually a `"fallback_model"` decision. The normal fallback escalation path at line ~1261 correctly logs `"fallback_model"`. Consider removing this call entirely (the `log.warn` on line 1216 already documents the decision) or replacing it with the correct label to keep the signal accurate.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/auto-reply/reply/agent-runner-execution.ts
Line: 183

Comment:
**`MAX_LIVE_SWITCH_RETRIES` should be a module-level export**

`MAX_OVERLOAD_PROFILE_ROTATIONS` is exported from `run/helpers.ts` so it can be referenced precisely in tests. `MAX_LIVE_SWITCH_RETRIES` is defined locally inside the function body, which means tests must use indirect assertions (`toBeLessThanOrEqual(3)`) rather than deriving the expected count from the constant. Exporting it alongside `MAX_OVERLOAD_PROFILE_ROTATIONS` would make the limit discoverable, testable, and consistent with the established pattern.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/model-fallback.run-embedded.e2e.test.ts
Line: 563

Comment:
**Weak assertion doesn't enforce the exact rotation count**

The comment above documents the exact expected behavior: 1 initial attempt + 1 rotation = **exactly 2** OpenAI calls. Using `toBeLessThanOrEqual(2)` would still pass if the fix accidentally collapsed to only 1 openai attempt (a regression to over-aggressive capping). A precise assertion would catch that:

```suggestion
      expect(openaiAttempts.length).toBe(2);
```

Similarly, `expect(switchCallCount).toBeLessThanOrEqual(3)` in `agent-runner-execution.test.ts` (line 395) should be `toBe(3)` to pin the exact retry count at the outer loop level.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(gateway): prevent session death loop..." | Re-trigger Greptile

Comment thread src/agents/pi-embedded-runner/run.ts Outdated
Comment thread src/auto-reply/reply/agent-runner-execution.ts Outdated
Comment thread src/agents/model-fallback.run-embedded.e2e.test.ts Outdated
@aisle-research-bot

aisle-research-bot Bot commented Mar 31, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Persistent LiveSessionModelSwitchError state can cause repeated per-turn retry loops (DoS risk)
1. 🟡 Persistent LiveSessionModelSwitchError state can cause repeated per-turn retry loops (DoS risk)
Property Value
Severity Medium
CWE CWE-400
Location src/auto-reply/reply/agent-runner-execution.ts:624-648

Description

In runAgentTurnWithFallback, when LiveSessionModelSwitchError is thrown repeatedly, the new retry cap returns a final error before updating params.followupRun.run to the last requested {provider, model, authProfileId}.

This creates a persistent bad state across turns:

  • Input / trigger: a persisted live session model selection (from the session store) conflicts with the runner’s current followupRun.run.provider/model (or live-switch state keeps changing).
  • Behavior: each user message/turn re-enters runAgentTurnWithFallback with liveModelSwitchRetries reset to 0, performs 1 + MAX_LIVE_SWITCH_RETRIES expensive attempts, then returns a final error.
  • State not healed: on the capped path, the code does not update followupRun.run.* (nor clear/reset any persisted selection), so the next turn repeats the same cycle.

Impact:

  • A user (or attacker who can influence session model selection) can trigger repeated bounded retry bursts each turn, amplifying compute and log volume and causing avoidable service degradation (availability impact).

Vulnerable code (early return bypasses state update):

if (liveModelSwitchRetries > MAX_LIVE_SWITCH_RETRIES) {// ... log ...
  return {
    kind: "final",
    payload: { text: switchErrorText },
  };
}
params.followupRun.run.provider = err.provider;
params.followupRun.run.model = err.model;
params.followupRun.run.authProfileId = err.authProfileId;

Recommendation

Ensure the session/model selection state is healed even when the retry cap is exceeded.

Options (pick one consistent with intended behavior):

  1. Persist the last requested selection before returning so the next turn starts with the model that the session store requires:
if (liveModelSwitchRetries > MAX_LIVE_SWITCH_RETRIES) {// Heal state so next turn doesn't repeat the conflict
  params.followupRun.run.provider = err.provider;
  params.followupRun.run.model = err.model;
  params.followupRun.run.authProfileId = err.authProfileId;
  params.followupRun.run.authProfileIdSource = err.authProfileId
    ? err.authProfileIdSource
    : undefined;

  return {
    kind: "final",
    payload: { text: switchErrorText },
  };
}
  1. Alternatively, clear/reset the persisted live selection (or mark the session entry invalid) when the cap is hit, so subsequent turns do not keep requesting a conflicting selection.

Also consider incrementing a metric/counter for this path (e.g., live_model_switch_retry_exhausted) to detect abuse or systemic misconfiguration.


Analyzed PR: #58379 at commit 2c41439

Last updated on: 2026-03-31T14:56:46Z

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d79676fcde

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/pi-embedded-runner/run.ts Outdated
@openperf

Copy link
Copy Markdown
Member Author

Quick update on where things stand after the two commits:

What the second commit (882de4c) addressed:

  • CWE-209 (Medium) — No longer leaking raw provider error text to end users. Replaced with a static safe message: "The AI service is temporarily overloaded. Please try again in a moment." Internal details are only surfaced on CLI/internal channels via shouldSurfaceToControlUi.
  • CWE-117 (Low) — Added sanitizeForLog calls on err.provider and err.model before interpolating into log output.
  • Misleading decision label — Fixed the log label from rotate_profile to fallback_model so monitoring dashboards track the right metric.
  • Overload cap ordering — Moved the cap check before advanceAuthProfile() to skip the unnecessary auth setup when we're about to bail anyway (per codex-connector's suggestion).
  • Exported MAX_LIVE_SWITCH_RETRIES — Pulled it out to module-level so tests reference the constant directly instead of hardcoding magic numbers.
  • Tightened test assertions — Replaced all toBeLessThanOrEqual() with exact toBe() assertions.

CI failures — Both checks-node-test-1 and checks-windows-node-test-4 failed on test/scripts/test-parallel.test.ts:356 with expected 99 to be 100. That's a race condition in the test runner infrastructure, nothing to do with the changes here. All other 40+ checks passed.

C1 control character sanitization (Medium) — The flag about sanitizeForLog not stripping C1 characters (U+0080U+009F) is valid, but it's a pre-existing gap in the utility function in src/terminal/ansi.ts. This PR only calls it — we didn't author or modify it. I'll open a separate follow-up PR to harden it so the scope here stays focused on the session death loop fix.

Everything from the review rounds has been addressed. Ready for review .

- Add MAX_LIVE_SWITCH_RETRIES=2 guard in agent-runner-execution.ts
- Add MAX_OVERLOAD_PROFILE_ROTATIONS=1 cap in run.ts for overloaded errors
- Return kind:final with user-visible error on retry exhaustion
- Escalate to cross-provider fallback instead of exhausting same-provider profiles

Fixes openclaw#58348
- Fix CWE-209: use static safe message instead of raw provider error text
- Fix CWE-117: sanitize provider/model in logs via sanitizeForLog
- Hide CLI hints from external channels via shouldSurfaceToControlUi
- Move overload cap check before advanceAuthProfile to save setup latency
- Export MAX_LIVE_SWITCH_RETRIES as module-level constant
- Use exact toBe() assertions in tests
- Correct failover decision label to fallback_model
@obviyus
obviyus force-pushed the fix-58348-session-death-loop branch from 882de4c to 2c41439 Compare March 31, 2026 14:54

@obviyus obviyus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed latest changes; landing now.

@obviyus
obviyus merged commit 56b5ba0 into openclaw:main Mar 31, 2026
24 checks passed
@obviyus

obviyus commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Landed on main.

Thanks @openperf.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session death loop on Anthropic overloaded_error — no fallback, no retry limit, zombie state

2 participants