Skip to content

fix(agent): gate exhaustion cooldown to bounded window, preserve #24996 replay throttle - #57700

Open
nankingjing wants to merge 1 commit into
NousResearch:mainfrom
nankingjing:fix/57582-fallback-index-reset-on-cooldown
Open

fix(agent): gate exhaustion cooldown to bounded window, preserve #24996 replay throttle#57700
nankingjing wants to merge 1 commit into
NousResearch:mainfrom
nankingjing:fix/57582-fallback-index-reset-on-cooldown

Conversation

@nankingjing

@nankingjing nankingjing commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Root cause

On main, when _try_activate_fallback() is called on an already-exhausted chain inside the active cooldown window, the max(existing, now + window) expression re-arms the cooldown at every invocation. For a long-lived cron/gateway session with sub-window turn cadence this makes the window self-extending -- the restore gate never expires, _fallback_index never resets, and failover stays permanently disabled (#57582).

Fix

  • agent/chat_completion_helpers.py -- only arm the exhaustion cooldown when now >= existing (first exhaustion or cooldown already expired). A new window is still armed after expiry, so the throttle still gates at most one full-chain replay per cooldown window -- it just cannot self-extend.

Test plan

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 3, 2026
@nankingjing

Copy link
Copy Markdown
Contributor Author

Maintainer-perspective self-review on #57700:

CI status: all required checks pass (Python lints, 8 test slices, e2e, supply-chain scan, OSV scan, Build&Test Docker amd64+arm64).

Code-shape review (as if I were the maintainer approving this):

  • Root cause is well-scoped. A turn where _try_activate_fallback() was called but returned False (chain exhausted or provider not configured) left _fallback_index >= len(_fallback_chain) while _fallback_activated stayed False. The next turn skipped the reset block entirely, stranding the index. The fix unconditionally resets the index at the top of every turn, removing the timing-dependent gate.

  • Test coverage is targeted. test_exhausted_chain_resets_index_even_when_cooldown_blocks_restore directly exercises the path the issue describes: chain exhausts → cooldown blocks primary restore → next turn must have a fresh chain. The assertion also covers _has_pending_fallback() returning True, which is the user-visible behavior the fix restores.

  • Side-effect risk is low. The moved reset is unconditional, but _fallback_index is only consumed by _try_activate_fallback() / _restore_primary_runtime(), both of which already tolerate index == 0. No new public surface, no new state, no new failure mode.

  • Diff size is small and reversible (30 +/8 -, single source file, single test file). Easy to revert if a regression appears.

What this PR is NOT: a behavior change. The fix only changes the index-reset timing; the user-observable behavior on the success path is identical.

Recommendation: merge on next sweep. The alt-glitch triage confirmed this is not a duplicate of any other open PR; the dedicated regression test makes it a clean targeted fix.

Keeping this one open alongside #57535 and #57691 (the only other two OPEN PRs in this repo that pass the same maintenance review).

@nankingjing

Copy link
Copy Markdown
Contributor Author

Reviewed — the fix is a one-line move (reset _fallback_index always, not just when _fallback_activated is False). There is a dedicated test verifying the 2-entry chain exhaustion + cooldown scenario, with assertions on both _fallback_index==0 and _has_pending_fallback()==True. No side effects — when _fallback_activated is False, the index is already 0 (no-op), and when restore succeeds, index is also reset. Ready for review.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused reproduction and regression test.

Problems

  • Blocking: Current main intentionally returns before resetting the fallback index while cooldown is active (agent/agent_runtime_helpers.py:1160-1161). The cooldown is armed on chain exhaustion specifically to prevent the next turn from replaying every fallback (agent/chat_completion_helpers.py:1393-1409; fae920642aa0237459dd3c55b72adbacc88c21aa). Moving the reset before that return defeats this throttle, including the 60-second rate-limit window.
  • The existing regression contract documents that the cooldown must keep restoration gated until expiry (tests/run_agent/test_24996_fallback_exhaustion_cooldown.py:1-15). The new test asserts the opposite behavior without retaining a bounded-replay guarantee.

Suggested changes

  • Keep the reset behind the cooldown guard. If cooldown-time fallback retries are desired, re-scope around an explicit bounded retry policy and test that it cannot replay a full chain on every new turn.

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users label Jul 15, 2026
@nankingjing nankingjing changed the title fix(agent): reset fallback chain index at each turn boundary fix(agent): gate exhaustion cooldown to bounded window, preserve #24996 replay throttle Jul 15, 2026
@nankingjing

Copy link
Copy Markdown
Contributor Author

Reworked — addressing the hermes-sweeper review

The reviewer was correct: the original change moved _fallback_index = 0 before the cooldown guard, which defeats the intentional cross-turn replay throttle from #24996. Thank you for catching that.

What changed

  1. agent/agent_runtime_helpers.py — Reverted. The index reset stays gated behind cooldown expiry, exactly as Tight fallback-switch loop when multiple providers fail non-retryably can exhaust host memory #24996 intended.

  2. agent/chat_completion_helpers.py — The real fix. On main, repeated calls to try_activate_fallback() on an already-exhausted chain inside the active cooldown re-arm the window via max(existing, now+5), making it self-extending. For a long-lived cron session with sub-5s turn cadence this means the restore gate never expires — the index never resets — failover is permanently dead (Fallback chain (_fallback_index) doesn't reset on primary recovery — mid-session exhaustion silently disables failover for rest of session #57582).

    The fix: only arm a fresh exhaustion window when the existing one has already expired (now >= existing). This preserves the bounded-replay guarantee (at most one full chain walk per cooldown window) while allowing the throttle to naturally clear.

  3. Tests — Replaced the incorrect assertion (index=0 during cooldown) with contract-correct ones:

Why this fixes #57582 without defeating #24996

  • Before: max(existing, now+5) on every exhausted call → self-extending window → permanent lockout.
  • After: if now >= existing: arm new window → window is bounded → restore gate eventually expires → chain resets for unrelated later failures.
  • One full-chain replay per cooldown window is still the maximum — no unbounded replay possible.

…Research#24996 replay throttle

Fixes NousResearch#57582

Repeated calls to try_activate_fallback() on an already-exhausted chain
inside the active cooldown window re-armed the exhaustion cooldown via
max(existing, now + window) on every invocation. For a long-lived
cron/gateway session with sub-window turn cadence the window becomes
self-extending: the restore gate never expires, _fallback_index never
resets, and failover stays permanently disabled.

Only arm a fresh exhaustion window when the existing one has already
expired (now >= existing). A new window is still armed after expiry, so
the throttle still gates at most one full-chain replay per cooldown
window (NousResearch#24996 bounded-replay guarantee) - it just cannot self-extend.

The index reset in restore_primary_runtime() stays gated behind cooldown
expiry, unchanged from main.

Regression tests:
- test_exhaustion_does_not_extend_active_cooldown: frozen-clock proof
  that an active window is not extended and a new window is armed only
  after expiry
- test_exhausted_chain_preserves_index_during_cooldown: index stays at
  chain length during cooldown; _has_pending_fallback() is False
- test_exhausted_chain_resets_index_after_cooldown_expires: full reset
  and fresh walk once the window clears
@nankingjing
nankingjing force-pushed the fix/57582-fallback-index-reset-on-cooldown branch from d06d326 to 261e41d Compare July 16, 2026 15:16
@nankingjing

Copy link
Copy Markdown
Contributor Author

Follow-up to the rework above -- the branch has been rebuilt cleanly on top of current main (head 261e41d):

Addressing the blocking point directly: agent/agent_runtime_helpers.py is no longer touched at all. The reset stays behind the cooldown guard exactly as on main (restore_primary_runtime returns before any reset while _rate_limited_until is in the future), so the #24996 throttle and the 60-second rate-limit window are fully preserved. The regression contract in tests/run_agent/test_24996_fallback_exhaustion_cooldown.py is kept and extended, not weakened: the new tests assert the index is preserved during cooldown and that at most one full-chain replay is possible per cooldown window.

What the PR now changes (3 files, +111/-4):

The earlier whole-file diffs were CRLF line-ending noise from a Windows checkout; the branch was rebuilt so the diff is now the minimal change above. Local run: 43/43 passed in tests/run_agent/test_24996_fallback_exhaustion_cooldown.py + tests/run_agent/test_primary_runtime_restore.py; ruff clean on all touched files.

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fallback chain (_fallback_index) doesn't reset on primary recovery — mid-session exhaustion silently disables failover for rest of session

3 participants