fix(desktop): recover stranded session windows when resume fails - #47655
Conversation
Opening a session in a new window (or any routed resume) could latch the thread loader on "session" forever — the reported "stays stuck loading, even after a nap" bug. Two compounding causes: 1. use-session-actions.resumeSession's catch ran the REST transcript fallback OUTSIDE its own try. When session.resume rejected AND the fallback also threw (the common case on a wedged/unreachable backend), the throw skipped setMessages and left activeSessionId null with an empty transcript — exactly the state the loader gates on (messagesEmpty && !activeSessionId), with no terminal/error state. 2. use-route-resume's self-heal could never re-fire: resumeSession sets selectedStoredSessionIdRef synchronously at entry (before failing), so stuckOnRoutedSession stays false, and on an already-open idle window neither pathnameChanged nor gatewayBecameOpen fire again. The window never retried — naps, focus, nothing recovered it. Fix: - Wrap the REST fallback in its own try so a fallback failure can't strand the loader. - Add $resumeFailedSessionId: armed on terminal resume failure, cleared at the next resume's entry (and left clear on success). - use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed. Regression tests cover: fallback-wrap arming the flag without throwing, flag cleared on success, retry fires on backoff, no retry for a non-routed/recovered session, and the retry cap.
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
First entries
run_agent.py:2941: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
✅ Fixed issues (1):
| Rule | Count |
|---|---|
invalid-assignment |
1 |
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
Unchanged: 5791 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
|
Related, not duplicate. This fixes the thread LOADER latching on "session" forever when both |
When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change.
|
Reviewed the diff (didn't check out the branch). The root-cause writeup is excellent and correct — the unwrapped 1. (med/high) The failure latch is armed even when the REST fallback succeeds. In try {
const fallback = await getSessionMessages(...)
if (!isCurrentResume()) return
setMessages(...) // history is now visible
} catch { /* both failed */ }
if (isCurrentResume()) {
setResumeFailedSessionId(storedSessionId) // armed regardless
}This contradicts the 2. (med) The "manual Retry / reconnect / reselect gives the counter a clean cycle" claim doesn't actually hold for the same session. 3. (low) Unrelated dep changes during a backoff window can burn retry attempts. The effect increments 4. (nit) Test coverage is otherwise good and behavior-based (not change-detector), and the i18n strings are complete across all five locale files. #1 is the one I'd consider blocking; #2/#3 are correctness-of-the-recovery-loop and at minimum the comments should match the implemented behavior. |
Follow-up to review on #47655 (PR head 253bfc0). Four issues on the recovery layer: 1. (blocking) Arm $resumeFailedSessionId only when the transcript is still empty after the REST fallback ($messages.get().length === 0), matching the atom's documented contract and the loader's messagesEmpty gate. Previously armed on any resume-RPC reject regardless of fallback outcome, so a window that recovered its history via REST still auto-retried and, on exhaustion, blanked the visible transcript behind the error overlay. 2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId armed->cleared edge so a manual Retry / reconnect / reselect on the SAME stranded session gets a fresh backoff cycle, not a single one-shot attempt that immediately re-arms the error. (Keyed on the exhausted latch rather than the resumeFailedSessionId null->value transition the review suggested: the auto-retry loop itself toggles resumeFailedSessionId every cycle, so keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only resumeSession clears the exhausted latch, making its clear edge the unambiguous manual-retry signal.) 3. Advance retryAttemptRef only when the timer actually dispatches a resume, not at schedule time. Prevents unrelated dep changes during the 1s-8s backoff window (transient gatewayState flip, non-stable resumeSession) from burning attempts and hitting MAX with fewer than 4 real resume attempts. 4. Drop unrelated blank-line-only insertions in store/session.ts and use-session-actions.ts to keep the diff tight. Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle; no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook suite (65) pass; tsc --noEmit clean.
OutThisLife
left a comment
There was a problem hiding this comment.
Re-reviewing against my earlier comments — v2 addresses all of them, each with a dedicated regression test:
- (was med/high) The failure latch is no longer armed when the REST fallback paints history. Arming now happens only under
isCurrentResume() && $messages.get().length === 0, matching$resumeFailedSessionId's documented contract, so a window that recovered via REST won't auto-retry and then blank a readable transcript behind the exhausted overlay. Covered bydoes NOT arm the failure latch when the resume RPC fails but the REST fallback paints history. - (was med) The retry counter now resets on the exhausted-latch armed→cleared edge (
prevResumeExhaustedRef), so a manual Retry / reconnect on the same stranded session gets a fresh bounded cycle instead of one-shot-then-immediately-re-arm. Covered byresets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session). - (was low) The attempt counter is consumed only when the timer actually dispatches a resume, so unrelated dep changes during the 1s–8s backoff window can't burn attempts. Covered by
does not burn retry attempts on unrelated re-renders during the backoff window. - (was nit) The unrelated blank-line insertions are gone — diff is tight.
The core fix — isolating the REST fallback in its own try so a fallback failure can't strand the loader — is correct, the i18n strings are complete across all five locales, and the tests are behavior-based rather than change-detectors. CI green.
LGTM, approving.
…sion-window-resume # Conflicts: # apps/desktop/src/app/chat/index.tsx
…sResearch#47655) * fix(desktop): recover stranded session windows when resume fails Opening a session in a new window (or any routed resume) could latch the thread loader on "session" forever — the reported "stays stuck loading, even after a nap" bug. Two compounding causes: 1. use-session-actions.resumeSession's catch ran the REST transcript fallback OUTSIDE its own try. When session.resume rejected AND the fallback also threw (the common case on a wedged/unreachable backend), the throw skipped setMessages and left activeSessionId null with an empty transcript — exactly the state the loader gates on (messagesEmpty && !activeSessionId), with no terminal/error state. 2. use-route-resume's self-heal could never re-fire: resumeSession sets selectedStoredSessionIdRef synchronously at entry (before failing), so stuckOnRoutedSession stays false, and on an already-open idle window neither pathnameChanged nor gatewayBecameOpen fire again. The window never retried — naps, focus, nothing recovered it. Fix: - Wrap the REST fallback in its own try so a fallback failure can't strand the loader. - Add $resumeFailedSessionId: armed on terminal resume failure, cleared at the next resume's entry (and left clear on success). - use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed. Regression tests cover: fallback-wrap arming the flag without throwing, flag cleared on success, retry fires on backoff, no retry for a non-routed/recovered session, and the retry cap. * feat(desktop): show error + manual Retry when resume retries exhaust When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change. * fix(desktop): address review on stranded-resume recovery layer Follow-up to review on NousResearch#47655 (PR head 253bfc0). Four issues on the recovery layer: 1. (blocking) Arm $resumeFailedSessionId only when the transcript is still empty after the REST fallback ($messages.get().length === 0), matching the atom's documented contract and the loader's messagesEmpty gate. Previously armed on any resume-RPC reject regardless of fallback outcome, so a window that recovered its history via REST still auto-retried and, on exhaustion, blanked the visible transcript behind the error overlay. 2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId armed->cleared edge so a manual Retry / reconnect / reselect on the SAME stranded session gets a fresh backoff cycle, not a single one-shot attempt that immediately re-arms the error. (Keyed on the exhausted latch rather than the resumeFailedSessionId null->value transition the review suggested: the auto-retry loop itself toggles resumeFailedSessionId every cycle, so keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only resumeSession clears the exhausted latch, making its clear edge the unambiguous manual-retry signal.) 3. Advance retryAttemptRef only when the timer actually dispatches a resume, not at schedule time. Prevents unrelated dep changes during the 1s-8s backoff window (transient gatewayState flip, non-stable resumeSession) from burning attempts and hitting MAX with fewer than 4 real resume attempts. 4. Drop unrelated blank-line-only insertions in store/session.ts and use-session-actions.ts to keep the diff tight. Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle; no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook suite (65) pass; tsc --noEmit clean. --------- Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…sResearch#47655) * fix(desktop): recover stranded session windows when resume fails Opening a session in a new window (or any routed resume) could latch the thread loader on "session" forever — the reported "stays stuck loading, even after a nap" bug. Two compounding causes: 1. use-session-actions.resumeSession's catch ran the REST transcript fallback OUTSIDE its own try. When session.resume rejected AND the fallback also threw (the common case on a wedged/unreachable backend), the throw skipped setMessages and left activeSessionId null with an empty transcript — exactly the state the loader gates on (messagesEmpty && !activeSessionId), with no terminal/error state. 2. use-route-resume's self-heal could never re-fire: resumeSession sets selectedStoredSessionIdRef synchronously at entry (before failing), so stuckOnRoutedSession stays false, and on an already-open idle window neither pathnameChanged nor gatewayBecameOpen fire again. The window never retried — naps, focus, nothing recovered it. Fix: - Wrap the REST fallback in its own try so a fallback failure can't strand the loader. - Add $resumeFailedSessionId: armed on terminal resume failure, cleared at the next resume's entry (and left clear on success). - use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed. Regression tests cover: fallback-wrap arming the flag without throwing, flag cleared on success, retry fires on backoff, no retry for a non-routed/recovered session, and the retry cap. * feat(desktop): show error + manual Retry when resume retries exhaust When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change. * fix(desktop): address review on stranded-resume recovery layer Follow-up to review on NousResearch#47655 (PR head 253bfc0). Four issues on the recovery layer: 1. (blocking) Arm $resumeFailedSessionId only when the transcript is still empty after the REST fallback ($messages.get().length === 0), matching the atom's documented contract and the loader's messagesEmpty gate. Previously armed on any resume-RPC reject regardless of fallback outcome, so a window that recovered its history via REST still auto-retried and, on exhaustion, blanked the visible transcript behind the error overlay. 2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId armed->cleared edge so a manual Retry / reconnect / reselect on the SAME stranded session gets a fresh backoff cycle, not a single one-shot attempt that immediately re-arms the error. (Keyed on the exhausted latch rather than the resumeFailedSessionId null->value transition the review suggested: the auto-retry loop itself toggles resumeFailedSessionId every cycle, so keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only resumeSession clears the exhausted latch, making its clear edge the unambiguous manual-retry signal.) 3. Advance retryAttemptRef only when the timer actually dispatches a resume, not at schedule time. Prevents unrelated dep changes during the 1s-8s backoff window (transient gatewayState flip, non-stable resumeSession) from burning attempts and hitting MAX with fewer than 4 real resume attempts. 4. Drop unrelated blank-line-only insertions in store/session.ts and use-session-actions.ts to keep the diff tight. Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle; no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook suite (65) pass; tsc --noEmit clean. --------- Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…sResearch#47655) * fix(desktop): recover stranded session windows when resume fails Opening a session in a new window (or any routed resume) could latch the thread loader on "session" forever — the reported "stays stuck loading, even after a nap" bug. Two compounding causes: 1. use-session-actions.resumeSession's catch ran the REST transcript fallback OUTSIDE its own try. When session.resume rejected AND the fallback also threw (the common case on a wedged/unreachable backend), the throw skipped setMessages and left activeSessionId null with an empty transcript — exactly the state the loader gates on (messagesEmpty && !activeSessionId), with no terminal/error state. 2. use-route-resume's self-heal could never re-fire: resumeSession sets selectedStoredSessionIdRef synchronously at entry (before failing), so stuckOnRoutedSession stays false, and on an already-open idle window neither pathnameChanged nor gatewayBecameOpen fire again. The window never retried — naps, focus, nothing recovered it. Fix: - Wrap the REST fallback in its own try so a fallback failure can't strand the loader. - Add $resumeFailedSessionId: armed on terminal resume failure, cleared at the next resume's entry (and left clear on success). - use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed. Regression tests cover: fallback-wrap arming the flag without throwing, flag cleared on success, retry fires on backoff, no retry for a non-routed/recovered session, and the retry cap. * feat(desktop): show error + manual Retry when resume retries exhaust When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change. * fix(desktop): address review on stranded-resume recovery layer Follow-up to review on NousResearch#47655 (PR head 253bfc0). Four issues on the recovery layer: 1. (blocking) Arm $resumeFailedSessionId only when the transcript is still empty after the REST fallback ($messages.get().length === 0), matching the atom's documented contract and the loader's messagesEmpty gate. Previously armed on any resume-RPC reject regardless of fallback outcome, so a window that recovered its history via REST still auto-retried and, on exhaustion, blanked the visible transcript behind the error overlay. 2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId armed->cleared edge so a manual Retry / reconnect / reselect on the SAME stranded session gets a fresh backoff cycle, not a single one-shot attempt that immediately re-arms the error. (Keyed on the exhausted latch rather than the resumeFailedSessionId null->value transition the review suggested: the auto-retry loop itself toggles resumeFailedSessionId every cycle, so keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only resumeSession clears the exhausted latch, making its clear edge the unambiguous manual-retry signal.) 3. Advance retryAttemptRef only when the timer actually dispatches a resume, not at schedule time. Prevents unrelated dep changes during the 1s-8s backoff window (transient gatewayState flip, non-stable resumeSession) from burning attempts and hitting MAX with fewer than 4 real resume attempts. 4. Drop unrelated blank-line-only insertions in store/session.ts and use-session-actions.ts to keep the diff tight. Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle; no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook suite (65) pass; tsc --noEmit clean. --------- Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…sResearch#47655) * fix(desktop): recover stranded session windows when resume fails Opening a session in a new window (or any routed resume) could latch the thread loader on "session" forever — the reported "stays stuck loading, even after a nap" bug. Two compounding causes: 1. use-session-actions.resumeSession's catch ran the REST transcript fallback OUTSIDE its own try. When session.resume rejected AND the fallback also threw (the common case on a wedged/unreachable backend), the throw skipped setMessages and left activeSessionId null with an empty transcript — exactly the state the loader gates on (messagesEmpty && !activeSessionId), with no terminal/error state. 2. use-route-resume's self-heal could never re-fire: resumeSession sets selectedStoredSessionIdRef synchronously at entry (before failing), so stuckOnRoutedSession stays false, and on an already-open idle window neither pathnameChanged nor gatewayBecameOpen fire again. The window never retried — naps, focus, nothing recovered it. Fix: - Wrap the REST fallback in its own try so a fallback failure can't strand the loader. - Add $resumeFailedSessionId: armed on terminal resume failure, cleared at the next resume's entry (and left clear on success). - use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed. Regression tests cover: fallback-wrap arming the flag without throwing, flag cleared on success, retry fires on backoff, no retry for a non-routed/recovered session, and the retry cap. * feat(desktop): show error + manual Retry when resume retries exhaust When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change. * fix(desktop): address review on stranded-resume recovery layer Follow-up to review on NousResearch#47655 (PR head 253bfc0). Four issues on the recovery layer: 1. (blocking) Arm $resumeFailedSessionId only when the transcript is still empty after the REST fallback ($messages.get().length === 0), matching the atom's documented contract and the loader's messagesEmpty gate. Previously armed on any resume-RPC reject regardless of fallback outcome, so a window that recovered its history via REST still auto-retried and, on exhaustion, blanked the visible transcript behind the error overlay. 2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId armed->cleared edge so a manual Retry / reconnect / reselect on the SAME stranded session gets a fresh backoff cycle, not a single one-shot attempt that immediately re-arms the error. (Keyed on the exhausted latch rather than the resumeFailedSessionId null->value transition the review suggested: the auto-retry loop itself toggles resumeFailedSessionId every cycle, so keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only resumeSession clears the exhausted latch, making its clear edge the unambiguous manual-retry signal.) 3. Advance retryAttemptRef only when the timer actually dispatches a resume, not at schedule time. Prevents unrelated dep changes during the 1s-8s backoff window (transient gatewayState flip, non-stable resumeSession) from burning attempts and hitting MAX with fewer than 4 real resume attempts. 4. Drop unrelated blank-line-only insertions in store/session.ts and use-session-actions.ts to keep the diff tight. Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle; no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook suite (65) pass; tsc --noEmit clean. --------- Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…sResearch#47655) * fix(desktop): recover stranded session windows when resume fails Opening a session in a new window (or any routed resume) could latch the thread loader on "session" forever — the reported "stays stuck loading, even after a nap" bug. Two compounding causes: 1. use-session-actions.resumeSession's catch ran the REST transcript fallback OUTSIDE its own try. When session.resume rejected AND the fallback also threw (the common case on a wedged/unreachable backend), the throw skipped setMessages and left activeSessionId null with an empty transcript — exactly the state the loader gates on (messagesEmpty && !activeSessionId), with no terminal/error state. 2. use-route-resume's self-heal could never re-fire: resumeSession sets selectedStoredSessionIdRef synchronously at entry (before failing), so stuckOnRoutedSession stays false, and on an already-open idle window neither pathnameChanged nor gatewayBecameOpen fire again. The window never retried — naps, focus, nothing recovered it. Fix: - Wrap the REST fallback in its own try so a fallback failure can't strand the loader. - Add $resumeFailedSessionId: armed on terminal resume failure, cleared at the next resume's entry (and left clear on success). - use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed. Regression tests cover: fallback-wrap arming the flag without throwing, flag cleared on success, retry fires on backoff, no retry for a non-routed/recovered session, and the retry cap. * feat(desktop): show error + manual Retry when resume retries exhaust When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change. * fix(desktop): address review on stranded-resume recovery layer Follow-up to review on NousResearch#47655 (PR head 253bfc0). Four issues on the recovery layer: 1. (blocking) Arm $resumeFailedSessionId only when the transcript is still empty after the REST fallback ($messages.get().length === 0), matching the atom's documented contract and the loader's messagesEmpty gate. Previously armed on any resume-RPC reject regardless of fallback outcome, so a window that recovered its history via REST still auto-retried and, on exhaustion, blanked the visible transcript behind the error overlay. 2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId armed->cleared edge so a manual Retry / reconnect / reselect on the SAME stranded session gets a fresh backoff cycle, not a single one-shot attempt that immediately re-arms the error. (Keyed on the exhausted latch rather than the resumeFailedSessionId null->value transition the review suggested: the auto-retry loop itself toggles resumeFailedSessionId every cycle, so keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only resumeSession clears the exhausted latch, making its clear edge the unambiguous manual-retry signal.) 3. Advance retryAttemptRef only when the timer actually dispatches a resume, not at schedule time. Prevents unrelated dep changes during the 1s-8s backoff window (transient gatewayState flip, non-stable resumeSession) from burning attempts and hitting MAX with fewer than 4 real resume attempts. 4. Drop unrelated blank-line-only insertions in store/session.ts and use-session-actions.ts to keep the diff tight. Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle; no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook suite (65) pass; tsc --noEmit clean. --------- Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Problem
Opening a session in a new window (or any routed resume) could latch the thread loader on
"session"forever — the reported "I've tried to load a session in a new window and even when I took a nap it still didn't load; it stays stuck loading" bug. The spinner has no terminal/error state and no recovery path once a resume fails.Root cause (two compounding bugs)
1. The REST fallback wasn't isolated. In
use-session-actions.ts,resumeSession'scatchran the REST transcript fallback outside its own try:When
session.resumerejected andgetSessionMessagesalso threw (the common case on a wedged/unreachable backend), the throw skippedsetMessagesand leftactiveSessionIdnull with an empty transcript — exactly the state the loader gates on (loadingSession = messagesEmpty && !activeSessionId,chat/index.tsx:318).2. The self-heal could never re-fire.
use-route-resume'sstuckOnRoutedSessiontrigger is gated onroutedSessionId !== selectedStoredSessionIdRef.current, butresumeSessionsetsselectedStoredSessionIdRef.current = storedSessionIdsynchronously at entry, before it can fail. So after a failed resume the ref already matches the route → trigger is false. On an idle, already-open windowpathnameChangedandgatewayBecameOpenalso can't fire. The effect has no timer, so leaving it idle — even for hours — never retries.The user's degraded backend (disabled provider key → HTTP 401, a 27-min runaway generation, intermittent DNS failures) is what triggered the failed resume; the frontend dead-end is what made it permanent.
Fix
tryso a fallback failure can't strand the loader.$resumeFailedSessionId(store atom): armed on terminal resume failure, cleared at the next resume's entry, left clear on success.use-route-resume(4 attempts, 1s→8s cap) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed, and a hard cap so a genuinely dead backend can't hot-loop.Verification
Reproduced first against the live worktree (per repo convention), then fixed:
npm run typecheck— cleanuse-route-resume.test.tsx— +4 tests (retry fires on backoff; no retry for non-routed failure; skips if recovered before timer fires; caps at MAX). Original 4 still pass.use-session-actions.test.tsx— +3 tests (flag armed when both RPC + REST fail; resume doesn't throw out of the fallback; flag cleared on success). Original 3 still pass.vitestUI suite: the onlysrc/failures (6) are pre-existing onmain(Windows path separators, env-dependent settings tests, a gateway-boot timing test) — confirmed bygit stash+ re-run on the clean tree.Files
store/session.ts—$resumeFailedSessionIdatom + setteruse-session-actions.ts— isolate REST fallback, arm/clear the flaguse-route-resume.ts— bounded retry effectdesktop-controller.tsx— wire the atom into the hook*.test.tsx— regression coverage