fix: reconcile stale session statuses on boot; name the tool blocking a send - #305
Merged
Merged
Conversation
… a send Two fixes for the same confusing experience — returning to a session and being told it is busy or blocked, with nothing to act on. 1. Non-terminal statuses survived daemon restarts. `thinking` / `tool_running` / `waiting_approval` all describe work owned by a LIVE process: the provider's turn loop, and for approvals the in-memory `#pendingApprovals` resolvers. None of it survives a restart, and a resumed Session starts `idle` (`#status` is initialised, never restored from the row). The row was never corrected, so the session list advertised work that no longer existed. Observed on a live daemon: rows stuck in `tool_running` and `waiting_approval` for 11-19 days across many restarts. `Store.reconcileStaleSessionStatuses()` clears them at boot, before resumeSessions, so rows and live sessions agree from the first broadcast. `error` is deliberately preserved — a terminal state a human may still want to see, not an artifact of the process dying. 2. The mid-turn send rejection named nothing. Sending while an approval is pending correctly throws (falling through would close the live turn queue and auto-deny the approval), but the message only said "A tool approval is pending" — which tells the user something is waiting, not what, and offers no way to act. After stepping away it reads as the session having gone silent. It now names the tool, via the same maps the approval bar already uses (`#approvalIdToMessageId` -> `#toolCallMessages`), so there is no extra bookkeeping to keep in sync, and states plainly that the message was saved. Falls back to the old phrasing when no name resolves: the status is authoritative, the label is best-effort, and a missing name must never turn a clear refusal into a crash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
KunalJavelin
approved these changes
Aug 26, 2026
abhijitjavelin
approved these changes
Aug 26, 2026
rsharath
approved these changes
Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two fixes for the same confusing experience: returning to a session and being told it's busy or blocked, with nothing to act on.
1. Non-terminal statuses survived daemon restarts
thinking/tool_running/waiting_approvalall describe work owned by a live process — the provider's turn loop, and for approvals the in-memory#pendingApprovalsresolvers. None of it survives a restart, and a resumedSessionstartsidle(#statusis initialised, never restored from the row).The row was never corrected, so the session list advertised work that no longer existed. Observed on a live daemon that had restarted many times:
Store.reconcileStaleSessionStatuses()clears them at boot, beforeresumeSessions, so rows and live sessions agree from the first broadcast.erroris deliberately preserved — a terminal state a human may still want to see, not an artifact of the process dying.2. The mid-turn send rejection named nothing
Sending while an approval is pending correctly throws — falling through would close the live turn queue and auto-deny the approval. But the message only said:
which tells you something is waiting, not what, and gives you nothing to act on. After stepping away from a session it reads as the model having simply gone silent. Now:
Names come from the same maps the approval bar already uses (
#approvalIdToMessageId→#toolCallMessages), so there's no new bookkeeping to drift. It falls back to the original phrasing when no name resolves: the status is authoritative, the label is best-effort, and a missing name must never turn a clear refusal into a crash.Scope note
This does not change when the guard fires — that behaviour is correct and unchanged. It only makes the refusal legible. Whether a pending approval should also be re-surfaced to the sending client (so it can be answered in place rather than requiring the user to find it) is a real follow-up, but it's a protocol/UI change rather than a message fix, so it's deliberately not bundled here.
Verification
lint+typecheckclean, 2382 pass / 0 fail. Four new reconcile tests: all three non-terminal statuses cleared,errorpreserved, idle rows untouched (reports zero), and idempotence across a second boot. The two existing tests asserting on the rejection text still pass — the fallback keeps them honest.🤖 Generated with Claude Code