Skip to content

fix(desktop): stop sidebar sessions from lying about whether they're running - #72303

Merged
OutThisLife merged 3 commits into
mainfrom
bb/desktop-session-status
Jul 27, 2026
Merged

fix(desktop): stop sidebar sessions from lying about whether they're running#72303
OutThisLife merged 3 commits into
mainfrom
bb/desktop-session-status

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

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 main before fixing:

  • session.active_list absence was ignored. The renderer read the rows the
    snapshot returned but never noticed the ones that stopped appearing. A turn
    that ends while the socket is degraded drops out of the gateway's _sessions
    without Desktop seeing the running=false edge, so the row spins forever —
    and because the green unread dot is painted by the busy → idle transition,
    the "your turn" signal never fires either. One missing signal, two symptoms.
    Vanished runtimes are now settled through publishSessionState so the real
    transition 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.

  • preserveReasoningParts was gated on exact text equality. 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 threads and back stripped the reasoning and every tool call. Now
    carries tool calls alongside reasoning, dedupes on toolCallId, and matches
    on 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:

  • starting is deliberately not treated as 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 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 main first — 3 of 4 for the
reap, 1 of 2 for the structural parts. The spinner tests are characterization,
and passed on main by design.

Automated:

  • live-status-reap.test.ts — a working session vanishing from the snapshot
    clears $workingSessionIds
  • live-status-reap.test.ts — a vanished background session fires the
    unread "your turn" marker
  • live-status-reap.test.ts — a blocked (waiting) session vanishing
    clears $attentionSessionIds
  • live-status-reap.test.ts — runtimes seeded by a different profile are
    left alone by this profile's poll
  • resume-structural-parts.test.ts — tool calls and reasoning survive when
    the authoritative row has advanced past the cached text
  • resume-structural-parts.test.ts — tool calls the authoritative row
    already carries are not duplicated
  • live-status-spinner.test.ts — a turn with no stream events still lights
    the row; the spinner persists across polls; a recycled runtime id follows
    to its new stored session; starting stays idle
  • working-ids-stored-id.test.ts — a busy runtime with no stored id is
    unreachable from the sidebar; one with a stored id is not
  • Existing use-background-sync.test.ts (3) and utils.test.ts (38) pass
    unchanged
  • npx vitest run src/app src/store src/lib — 216 files, 1880 tests green
  • npx tsc --noEmit clean; prettier + eslint clean

Manual, against a remote gateway:

  • Start a turn, switch to another thread, switch back mid-turn — thinking
    trace and tool calls are still there and the turn still reads as running
  • Same switch, but across two profiles rather than two threads (the
    weakest spot in this change — the swap is its own switch shape and has no
    dedicated test)
  • Start a long turn, background the window until it completes — the row
    stops spinning and shows the green unread dot without being clicked
  • Start a turn and confirm the arc appears on the thread name promptly
  • Interrupt the network mid-turn and let it recover — the row settles rather
    than spinning indefinitely
  • With two profiles both running turns, confirm neither profile's poll
    clears the other's rows
  • Answer a clarify prompt, then let the session end — the amber needs-input
    dot clears rather than sticking

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
OutThisLife enabled auto-merge July 26, 2026 23:47
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 26, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran 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.
@OutThisLife
OutThisLife merged commit e2fbd0d into main Jul 27, 2026
31 checks passed
@OutThisLife
OutThisLife deleted the bb/desktop-session-status branch July 27, 2026 00:19
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have 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.

2 participants