Skip to content

Fix blank-session restarts after interrupt or failed run - #1192

Open
bunnysayzz wants to merge 1 commit into
CodebuffAI:mainfrom
bunnysayzz:fix/preserve-session-state-1054-v2
Open

Fix blank-session restarts after interrupt or failed run#1192
bunnysayzz wants to merge 1 commit into
CodebuffAI:mainfrom
bunnysayzz:fix/preserve-session-state-1054-v2

Conversation

@bunnysayzz

Copy link
Copy Markdown

Summary

Re-opens #1135 (auto-closed by force-push history rewrite).

Fixes #1054 (chat restarts blank after Esc interrupt / failed run).

Root cause: previousRunStateRef was only synced when client.run() settled, but Esc releases the input lock inside the abort listener, so a follow-up message could be built from stale/null ref, causing the SDK to start a blank session.

Fix

  1. onAbort checkpoint invoked before input lock release (so the latest SDK state is saved before a follow-up can run)
  2. Per-run generation guard (runGenerationRef/runIsCurrent) prevents a superseded run from clobbering newer state
  3. Catch-path ref sync ensures errors also persist state
  4. loadMostRecentChatState refuses sessionState-less run states (disk poison)

Tests

Hook-level regression tests through the real createRunConfig/client.run wiring via DI seam (setClientFactoryOverrideForTesting). 92 tests green, typecheck/prettier/build clean.

Review response

Codebuff-team asked to audit loadMostRecentChatState callers for non-null assumptions. Audit: exactly 1 production caller (use-send-message.ts:177), already guarded by if (loadedState), ref typed RunState | null. Safe. Full details in review comment on #1135.

Supersedes #1135 (closed by force-push).

Fixes CodebuffAI#1054. Esc-aborting a session and sending a
follow-up could start a brand new, blank conversation because the
continuation state (previousRunStateRef) was only synced when run()
settled, while the abort listener released the input lock immediately.

- SetupStreamingContext gains an onAbort callback invoked synchronously
  at the top of the abort listener, before the lock is released, so the
  run owner can checkpoint its latest SDK snapshot.
- useSendMessage passes syncRunState(latestRunStateSnapshot) as onAbort,
  and syncRunState now guards with a generation token so a superseded
  run settling late can never adopt state, persist a checkpoint, or
  touch shared queue state over the run that replaced it.
- The catch path (failed/expired runs) now also syncs the ref, not just
  disk, so the next prompt resumes from the last snapshot instead of
  stale or null history.
- loadMostRecentChatState stops adopting/persisting sessionState-less
  run states, which made the SDK build a blank session on restart.

Adds hook-level regression tests through the real createRunConfig /
client.run wiring: abort then follow-up carries full history, late
superseded runs cannot clobber, the rejected-run path resumes from the
last snapshot, and sessionState-less states are never adopted. Helper
and storage suites updated.
@bunnysayzz

Copy link
Copy Markdown
Author

Closing as duplicate of #1181 (Utkarsh-X opened earlier with the same fix, rebased from #1055). Our fix is the same root cause; theirs was first and already had maintainer review.

@bunnysayzz bunnysayzz reopened this Sep 1, 2026
@bunnysayzz

Copy link
Copy Markdown
Author

Note for comparison with #1181: our tests already use the DI seam (setClientFactoryOverrideForTesting) to drive the real createRunConfig/client.run wiring through the hook, exactly as codebuff-team requested on #1181. The test suite exercises the four abort/error scenarios through the actual hook code, not hand-rolled copies of the logic.

@bunnysayzz

Copy link
Copy Markdown
Author

@codebuff-team This is the re-open of #1135 (auto-closed by the force-push history rewrite). You had reviewed it positively on Aug 29, calling it "exactly the kind of bug fix I'd want ported" and asking to audit loadMostRecentChatState callers for non-null assumptions. That audit was done and replied to on #1135.

Quick note comparing with #1181: this PR's test suite already uses the DI seam (setClientFactoryOverrideForTesting) to drive the real createRunConfig/client.run wiring through the hook, testing the four abort/error scenarios through the actual hook code rather than reimplementing the logic in the spec file. That's the approach you requested on #1181.

Branch is rebased on the latest main. Ready for another look whenever you get a chance.

@codebuff-team

Copy link
Copy Markdown
Contributor

Good bug report and a plausible root cause: the abort listener released the input lock before previousRunStateRef was synced, so a message sent right after Esc could inherit stale/null state. The fix (checkpoint in onAbort before lock release, per-run generation guard via runGenerationRef/runIsCurrent, catch-path sync, and rejecting sessionState-less run states in loadMostRecentChatState) directly targets that race rather than papering over symptoms.

Strengths:

  • cli/src/hooks/use-send-message.ts: generation guard is applied consistently across the abort/error/finally paths, and runIsCurrent() correctly composes chat-switch and generation checks instead of duplicating logic.
  • run-state-storage.ts: changing SavedChatState.runState to RunState | null and refusing to synthesize a fake RunState when nothing is restorable is the right instinct — the old fallback (Previous run state could not be restored.) was itself the kind of poisoned state this PR is fixing. You audited the single production caller in use-send-message.ts:177 and confirmed it's already null-guarded — that's the right due diligence for a type-widening change.
  • Tests exercise the actual timing invariant (onAbort sees streamStatus === 'streaming' before the lock flips to idle), not just end states, and cover the throwing-onAbort and superseded-run cases.

Concerns for the maintainer's review pass, not blockers:

  • This is a meaningful concurrency change to a hot path; worth double-checking that runGeneration incrementing after the run-start guard (per the comment) can't let a requeued message slip in under the wrong generation.
  • The else if/else branch renaming in the catch block (aborted vs superseded logging) is a nice touch but slightly obscures the diff; a smaller commit split (guard logic vs. logging) would ease review.

Overall this reads as a genuine fix worth porting, with tests that would catch a regression if the abort ordering broke again.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session state is lost after free session expires or run is interrupted

2 participants