fix(desktop): stop sidebar sessions from lying about whether they're running - #72303
Merged
Conversation
session.active_list is authoritative about absence, but the renderer only read the rows it returned. A turn that ends while the websocket is degraded — a remote gateway on a flaky link, a reconnect, a profile swap — drops out of the gateway's _sessions without Desktop ever seeing the running=false edge, so the row spins forever and the busy->idle transition that paints the green unread dot never fires. Track live runtime ids per gateway profile and settle anything that disappears between polls through publishSessionState so the real transition fires. Profile scoping is load-bearing: background profiles are served by other gateways and never appear in this profile's snapshot, so an unscoped reap would dark out every other profile's running rows.
…witch preserveReasoningParts was gated on exact text equality with the cached row. Mid-turn the authoritative text has advanced by a delta or two, so the guard fails and the row is rebuilt from the gateway's inflight projection — which is text-only. The renderer's cache is the sole carrier of a running turn's structure, so switching away and back stripped the reasoning and every tool call, leaving the turn looking inert. Carry tool calls alongside reasoning, dedupe them on toolCallId, and match on same-turn (identical text, or authoritative text extending the cached text) rather than strict equality. Attachment refs and image re-appending stay on the strict path: those reconcile a settled row, and a growing row is by definition not settled.
OutThisLife
enabled auto-merge
July 26, 2026 23:47
Contributor
૮ >ﻌ< ა ci reviewran on 19d84d1 all good! |
Investigating the missing-spinner report turned up no defect in the seeding path: the active_list poll already lights a row for a turn the renderer never saw start, holds it across polls, and follows a recycled runtime id onto its new stored session. Pin all three so the reap change can't silently regress turn-start while fixing turn-end. Two boundaries worth naming rather than rediscovering: - `starting` is deliberately NOT working. It means agent_build_started without agent_ready, and _start_agent_build runs on any incidental RPC that needs the agent — not just a prompt — so treating it as a turn would spin the row on merely opening a session. - $workingSessionIds is keyed by STORED id and drops entries whose storedSessionId is null, while message.start flips busy without carrying one. A runtime that was never seeded with a stored id therefore goes busy invisibly. That is the remaining path by which a spinner can go missing.
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…ssion-status fix(desktop): stop sidebar sessions from lying about whether they're running
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.
Summary
Desktop's session sidebar could tell you a session was running when it had
finished, and could show a running turn as inert. Both were reported from a
Windows 11 client talking to a remote gateway, where the websocket is far more
likely to be degraded than it is locally.
Two defects, each reproduced against
mainbefore fixing:session.active_listabsence was ignored. The renderer read the rows thesnapshot returned but never noticed the ones that stopped appearing. A turn
that ends while the socket is degraded drops out of the gateway's
_sessionswithout Desktop seeing the
running=falseedge, so the row spins forever —and because the green unread dot is painted by the
busy → idletransition,the "your turn" signal never fires either. One missing signal, two symptoms.
Vanished runtimes are now settled through
publishSessionStateso the realtransition fires. Tracking is keyed per gateway profile: background profiles
are served by other gateways and never appear in this profile's snapshot, so
an unscoped reap would dark out every other profile's running rows.
preserveReasoningPartswas gated on exact text equality. Mid-turn theauthoritative text has advanced by a delta or two, so the guard fails and the
row is rebuilt from the gateway's
inflightprojection — which is text-only.The renderer's cache is the sole carrier of a running turn's structure, so
switching threads and back stripped the reasoning and every tool call. Now
carries tool calls alongside reasoning, dedupes on
toolCallId, and matcheson same-turn (identical text, or authoritative text extending the cached
text). Attachment refs and image re-appending stay on the strict path: those
reconcile a settled row, and a growing row is by definition not settled.
Reported by @Samuelimfh as three items (A/B/C); B and C are the same missing
transition seen from two sides.
On the missing spinner (C), specifically
The turn-start path turned out to be healthy — the poll already lights a row
for a turn the renderer never saw begin, holds it across polls, and follows a
recycled runtime id onto its new stored session. Those are now pinned as tests
so the turn-end change can't silently regress turn-start, but no production code
was needed. The reported symptom is explained by the turn-end defect above:
a row that never settles is also a row that can't visibly start the next turn.
Two boundaries are documented in that test rather than left to be rediscovered:
startingis deliberately not treated as working. It meansagent_build_startedwithoutagent_ready, and_start_agent_buildruns onany incidental RPC that needs the agent — not just a prompt — so treating it
as a turn would spin the row on merely opening a session.
$workingSessionIdsis keyed by stored id and drops entries whosestoredSessionIdis null, whilemessage.startflipsbusywithout carryingone. A runtime never seeded with a stored id therefore goes busy invisibly.
That is the one remaining path by which a spinner can legitimately go missing,
and it is out of scope here.
Test plan
Each new test for a fix was confirmed red on
mainfirst — 3 of 4 for thereap, 1 of 2 for the structural parts. The spinner tests are characterization,
and passed on
mainby design.Automated:
live-status-reap.test.ts— a working session vanishing from the snapshotclears
$workingSessionIdslive-status-reap.test.ts— a vanished background session fires theunread "your turn" marker
live-status-reap.test.ts— a blocked (waiting) session vanishingclears
$attentionSessionIdslive-status-reap.test.ts— runtimes seeded by a different profile areleft alone by this profile's poll
resume-structural-parts.test.ts— tool calls and reasoning survive whenthe authoritative row has advanced past the cached text
resume-structural-parts.test.ts— tool calls the authoritative rowalready carries are not duplicated
live-status-spinner.test.ts— a turn with no stream events still lightsthe row; the spinner persists across polls; a recycled runtime id follows
to its new stored session;
startingstays idleworking-ids-stored-id.test.ts— a busy runtime with no stored id isunreachable from the sidebar; one with a stored id is not
use-background-sync.test.ts(3) andutils.test.ts(38) passunchanged
npx vitest run src/app src/store src/lib— 216 files, 1880 tests greennpx tsc --noEmitclean; prettier + eslint cleanManual, against a remote gateway:
trace and tool calls are still there and the turn still reads as running
weakest spot in this change — the swap is its own switch shape and has no
dedicated test)
stops spinning and shows the green unread dot without being clicked
than spinning indefinitely
clears the other's rows
dot clears rather than sticking