Skip to content

fix(clarify): SSE-notify on clear_pending + treat 409 as terminal so expired prompts stop bricking the session (#4504) - #4524

Closed
Sanjays2402 wants to merge 3 commits into
nesquena:masterfrom
Sanjays2402:fix/4504-clarify-card-stuck-on-expiry
Closed

fix(clarify): SSE-notify on clear_pending + treat 409 as terminal so expired prompts stop bricking the session (#4504)#4524
Sanjays2402 wants to merge 3 commits into
nesquena:masterfrom
Sanjays2402:fix/4504-clarify-card-stuck-on-expiry

Conversation

@Sanjays2402

Copy link
Copy Markdown
Contributor

Closes #4504

What Problem This Solves

When the clarify prompt's countdown hits zero, the agent-side _clarify_callback_impl calls clear_pending(sid) and returns its fallback string. Until now clear_pending cleared server state but emitted no SSE notify, and the 3000 ms fallback poller had already been stopped by the turn-end handler — so the browser never knew the prompt was gone. The card stayed docked, the composer stayed locked, and the user's only "escape" was to submit a response, which _handle_clarify_respond then rejected with 409 {stale: true}. The previous client catch-block treated 409 as retryable, leaving the card + draft visible with controls re-enabled, but every retry returned 409 forever. The user was effectively bricked in the UI — switching sessions and returning re-rendered the same stuck card from the client cache.

Per @b3n.w in Discord #report-bugs (2026-06-19): "the box gets stuck, the input area also gets locked, and you can't clear it. Even if you switch sessions, when you return you're locked in this question box which can't be submitted, and text you can't edit or submit."

Why This Change Was Made

The maintainer-authored issue body laid out a 3-phase fix. This PR ships Phase A and Phase B (the user-facing escape hatches); Phase C (UI dismiss button + countdown-zero proactive reconcile) is left as a follow-up since the first two already remove the dead-end.

Phase A — server pushes a "cleared" event on clear_pending.
api/clarify.py::clear_pending now calls _clarify_sse_notify(session_key, None, 0) from inside _lock whenever it actually clears something. That matches the ordering contract submit_pending and resolve_clarify* already follow (notify under _lock, then publish_session_list_changed and event.set() outside). The browser's existing _handleClarifyEvent branch on pending=null already routes through _hideClarifyCardIfOwner(sid, false, 'expired')_stashClarifyDraft('expired'), so the card comes down and any partial draft moves into the now-unlocked composer with a "Clarification timed out. Your draft was kept in the composer." notice.

Phase B — client treats 409 as terminal in respondClarify's catch.
static/messages.js::respondClarify now branches on e.status === 409 to call hideClarifyCard(true, 'expired') and early-return, instead of re-enabling the controls for an impossible retry. The "next prompt already loaded" case from #2639 isn't lost: when a fresh clarify event arrives, the SSE/poll path's showClarifyCard() re-renders the card from scratch with the new clarify_id. The non-409 catch branch (true network / transient errors) keeps the existing keep-card-and-draft behavior so genuine retries still work once connectivity returns.

The no-entries case in clear_pending is a deliberate no-op (no spurious notify if nothing was actually cleared), and the .event.set() agent-side unblock is preserved so _clarify_callback_impl's timeout branch still returns its fallback string on time.

Non-goals (left as Phase C / follow-ups):

  • Dismiss/close ("×") affordance on the clarify card.
  • Client-side proactive reconcile against /api/clarify/pending when the countdown reaches zero.
  • Distinguishing "expired" vs "wrong-session" 409 via a server-returned current_clarify_id field — current behavior dismisses on any 409, and the next SSE event recovers the wrong-session case naturally.

User Impact

Expired clarify prompts no longer brick the session. The card comes down via the SSE notify the moment the server clears the pending entry, the composer unlocks, and any draft the user typed is preserved in the composer. If the SSE message races a user click (the user hits Send during the clear window), the 409 response now dismisses the card the same way instead of leaving them stuck.

Evidence

New regression suite (tests/test_4504_clarify_stuck_on_expiry.py) — 7 cases:

$ pytest tests/test_4504_clarify_stuck_on_expiry.py -v
collected 7 items
  TestClearPendingNotifiesSSE
    ✓ test_clear_pending_pushes_none_head_to_subscriber
    ✓ test_clear_pending_no_op_does_not_notify
    ✓ test_clear_pending_unblocks_caller_event
  TestClarifyClearPendingSourceMarkers
    ✓ test_clear_pending_calls_notify
  TestRespondClarify409Terminal
    ✓ test_409_routes_to_hide_clarify_card_expired
    ✓ test_409_does_not_re_enable_controls
    ✓ test_non_409_still_keeps_card_visible

============================== 7 passed in 2.68s ===============================

Broader clarify suite (50 cases) still passes:

$ pytest tests/test_clarify_sse.py tests/test_clarify_unblock.py \
         tests/test_1466_sidebar_cancel_clarify.py tests/test_issue2883_clarify_padding.py
collected 50 items
tests/test_clarify_sse.py ............................   [ 56%]
tests/test_clarify_unblock.py .............              [ 82%]
tests/test_1466_sidebar_cancel_clarify.py .....          [ 92%]
tests/test_issue2883_clarify_padding.py ....             [100%]
============================== 50 passed in 1.22s ===============================

Lint:

$ ruff check api/clarify.py tests/test_4504_clarify_stuck_on_expiry.py
All checks passed!

$ node --check static/messages.js
(parse-clean)

Diff: 3 files, +209 / −5. Two surgical commits — server-side notify, then client-side 409 handling — split so reviewers can flip Phase A vs Phase B independently if scope-cutting is preferred.

…owser (nesquena#4504)

The agent-side clarify timeout path in api/streaming.py::_clarify_callback_impl
calls clear_pending(sid) after the prompt's remaining time hits zero. That
removes the server-side _gateway_queues / _pending entries but until now never
emitted an SSE notify, so any browser subscribed to the clarify stream had
no idea the prompt was gone:

  - The clarify card stayed visible (no pending=null event arrived).
  - The composer stayed locked (lockComposerForClarify never released).
  - The 3000 ms fallback poller had already been stopped by the turn-end
    handler before the timeout fired, so nothing reconciled the card against
    the now-empty server state.

The session was effectively bricked from the UI side — switching sessions
and returning re-rendered the stuck card from the client-side cache, and
typing into the clarify input failed with 409 on every submit.

Make clear_pending emit _clarify_sse_notify(session_key, None, 0) from
inside _lock whenever it actually clears something. That matches the
ordering contract submit_pending and resolve_clarify* already follow
(notify inside _lock, then publish_session_list_changed + event.set()
outside the lock). The browser's existing _handleClarifyEvent branch on
pending=null already routes through _hideClarifyCardIfOwner(sid, false,
'expired') — which in turn calls _stashClarifyDraft('expired') so any
draft the user typed is preserved in the now-unlocked composer.

The no-entries case is a no-op: if nothing was actually cleared we do not
push a spurious notify. The .event.set() unblock of the agent-side wait()
is unchanged, so the _clarify_callback_impl timeout branch still returns
its fallback string on time.

7 new tests in tests/test_4504_clarify_stuck_on_expiry.py cover both the
SSE-notify behavior and the no-op case. 50/50 broader clarify tests still
pass.
…tryable (nesquena#4504)

When the clarify server returns 409 with {stale: true}, the prior client
behavior re-enabled the card controls and kept the draft + card visible —
on the assumption from nesquena#2639 that the user could simply retry. But the
server-side _pending entry is gone in the 409 case, so every retry got
409 forever, and the composer remained locked behind the card. The user
had zero affordance to dismiss it short of a full page reload.

Route the 409 catch branch to hideClarifyCard(true, 'expired'), which
flows through _stashClarifyDraft('expired'):

  - The current draft is moved into the unlocked composer.
  - sessionStorage saves the draft as a hermes-clarify-draft-* entry.
  - A 'Clarification timed out. Your draft was kept in the composer.'
    notice surfaces.

The 'next prompt already loaded' case from nesquena#2639 is not lost: when a
new clarify event is queued, the SSE/poll path's showClarifyCard()
re-renders the card from scratch with the fresh clarify_id. We simply
stop pretending the *current* card is recoverable when the server has
already discarded it.

The non-409 catch branch (true network / transient errors) keeps the
existing 'keep card visible + re-enable controls' behavior so genuine
retries still work once connectivity returns. The else branch (server
returned ok=false without throwing) is left untouched — _handle_clarify_respond
always returns 409 for the failure case today, so that branch is dead
code-path but left for forward compatibility.

3 of the 7 tests in tests/test_4504_clarify_stuck_on_expiry.py pin this
client-side behavior; the other 4 pin the server-side companion change.
@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a session-bricking bug (#4504) where an expired clarify prompt left the browser with a stuck card and a locked composer that 409'd on every submit attempt. Two targeted fixes are shipped:

  • Phase A (server): clear_pending now emits _clarify_sse_notify(session_key, None, 0) inside its lock, consistent with the pattern already used by submit_pending and resolve_clarify*. The browser's existing pending=null SSE branch takes the card down and unlocks the composer.
  • Phase B (client): respondClarify's catch block branches on e.status === 409. The same-id arm calls _clarifySetControlsDisabled(false, false) before hideClarifyCard(true, \"expired\") — removing the loading class so _stashClarifyDraft's guard doesn't bail and the user's typed draft is correctly moved to the composer. The different-id arm (newer prompt racing in) re-enables controls without dismissing the new card.

Confidence Score: 5/5

Safe to merge — both changes are surgical and confined to the clarify expiry path; no shared state outside that path is touched.

The server-side change follows the exact notify-under-lock pattern already established by submit_pending and resolve_clarify*, and the no-entry guard ensures no spurious notifications. The client-side change correctly orders _clarifySetControlsDisabled(false, false) before hideClarifyCard so the _stashClarifyDraft loading-class guard does not block draft rescue. The _clarifyId === clarifyId guard mirrors the success-path contract and prevents a late 409 from dismissing a concurrent newer prompt. Regression coverage for both phases is included in the new test file.

No files require special attention.

Important Files Changed

Filename Overview
api/clarify.py Adds _clarify_sse_notify(session_key, None, 0) inside clear_pending's lock block, consistent with the ordering contract already used by submit_pending and resolve_clarify*; the no-entry guard and post-lock event.set() are preserved.
static/messages.js Splits the catch block in respondClarify into a terminal 409 path (same-id and different-id arms) and a keep-card transient-error path; correctly calls _clarifySetControlsDisabled(false, false) before hideClarifyCard to clear the loading class so _stashClarifyDraft's guard does not bail.
tests/test_4504_clarify_stuck_on_expiry.py New regression suite with 11 test methods covering Phase A (SSE notify on clear), Phase B (409 terminal handling, same-id guard, loading-clear ordering, session cache clear, non-409 keep-card path).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent as Agent (_clarify_callback_impl)
    participant Server as api/clarify.py
    participant SSE as SSE Subscriber Queue
    participant Browser as Browser (messages.js)

    Note over Agent,Browser: Timeout path (Phase A fix)
    Agent->>Server: clear_pending(sid)
    activate Server
    Server->>Server: _clear_queue_locked(sid) [inside _lock]
    Server->>SSE: _clarify_sse_notify(sid, None, 0) [inside _lock]
    deactivate Server
    Server->>Server: publish_session_list_changed("attention_cleared")
    Server->>Agent: entry.event.set() — unblocks fallback return
    SSE-->>Browser: "{pending: null, pending_count: 0}"
    Browser->>Browser: _handleClarifyEvent → _hideClarifyCardIfOwner(sid, false, "expired")
    Browser->>Browser: hideClarifyCard → _stashClarifyDraft("expired") → composer unlocked

    Note over Browser: Race: user clicks Submit during clear window (Phase B fix)
    Browser->>Server: "POST /api/clarify/respond {clarify_id: A}"
    Server-->>Browser: "409 {stale: true}"
    alt "_clarifyId === clarifyId (same card still showing)"
        Browser->>Browser: _clarifySetControlsDisabled(false, false) [clears loading class]
        Browser->>Browser: "_clarifySessionId=null, _clarifyId=null"
        Browser->>Browser: _clearClarifyPendingForSession(sid)
        Browser->>Browser: hideClarifyCard(true, "expired") → _stashClarifyDraft rescues draft
    else "_clarifyId !== clarifyId (newer prompt B showing)"
        Browser->>Browser: _clarifySetControlsDisabled(false, false)
        Browser->>Browser: setStatus("previous prompt expired — a newer one is showing")
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent as Agent (_clarify_callback_impl)
    participant Server as api/clarify.py
    participant SSE as SSE Subscriber Queue
    participant Browser as Browser (messages.js)

    Note over Agent,Browser: Timeout path (Phase A fix)
    Agent->>Server: clear_pending(sid)
    activate Server
    Server->>Server: _clear_queue_locked(sid) [inside _lock]
    Server->>SSE: _clarify_sse_notify(sid, None, 0) [inside _lock]
    deactivate Server
    Server->>Server: publish_session_list_changed("attention_cleared")
    Server->>Agent: entry.event.set() — unblocks fallback return
    SSE-->>Browser: "{pending: null, pending_count: 0}"
    Browser->>Browser: _handleClarifyEvent → _hideClarifyCardIfOwner(sid, false, "expired")
    Browser->>Browser: hideClarifyCard → _stashClarifyDraft("expired") → composer unlocked

    Note over Browser: Race: user clicks Submit during clear window (Phase B fix)
    Browser->>Server: "POST /api/clarify/respond {clarify_id: A}"
    Server-->>Browser: "409 {stale: true}"
    alt "_clarifyId === clarifyId (same card still showing)"
        Browser->>Browser: _clarifySetControlsDisabled(false, false) [clears loading class]
        Browser->>Browser: "_clarifySessionId=null, _clarifyId=null"
        Browser->>Browser: _clearClarifyPendingForSession(sid)
        Browser->>Browser: hideClarifyCard(true, "expired") → _stashClarifyDraft rescues draft
    else "_clarifyId !== clarifyId (newer prompt B showing)"
        Browser->>Browser: _clarifySetControlsDisabled(false, false)
        Browser->>Browser: setStatus("previous prompt expired — a newer one is showing")
    end
Loading

Reviews (2): Last reviewed commit: "fix(clarify): guard 409-terminal branch ..." | Re-trigger Greptile

Comment thread static/messages.js
@cutter-sh

cutter-sh Bot commented Jun 20, 2026

Copy link
Copy Markdown

🎬 Cutter preview — PR #4524

Submit to an expired clarify prompt
Submit to an expired clarify prompt — Composer unlocks and accepts new input after an expired clarify prompt is dismissed.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Thanks @Sanjays2402 — this is a real, well-diagnosed bug (#4504) and your fix is on the right track: it does un-brick the session. I ran it through a full Codex + Opus review and there are three things to address before it can ship, all on the client 409-terminal path (which, per the analysis below, is the only path that actually un-bricks the UI — so getting it exactly right matters).

1. The typed answer is silently dropped (must-fix)

respondClarify sets _clarifySetControlsDisabled(true, true) (adds the loading class to #clarifySubmit) before the try. The new 409 branch then calls hideClarifyCard(true, "expired")_stashClarifyDraft("expired"), but _stashClarifyDraft bails immediately on submit.classList.contains("loading") (messages.js:~4972). So for a free-text / "Other" answer the user typed, the draft is not moved to the composer and no toast fires (the branch suppresses its own toast trusting _stashClarifyDraft to show it). Net: the card vanishes, the typed answer is lost, no feedback — contradicting the PR's own comment.
Fix: clear the loading/disabled state (_clarifySetControlsDisabled(false, false)) before hideClarifyCard(true, "expired") in the 409 branch, so the draft actually reaches the unlocked composer and the "draft kept" toast fires.

2. A late 409 can wipe the next prompt's card — #2639 regression (must-fix)

The 200/success path guards with if (_clarifyId === clarifyId) (messages.js:5188) specifically so a parallel poll that already rendered the next queued prompt B isn't clobbered (that was the #2639 fix). The new 409 branch has no such guard — it unconditionally tears down the visible card. If prompt B rendered while A's response was in flight, A's late 409 dismisses B.
Fix: gate the 409-terminal handling on _clarifyId === clarifyId. If it differs, a newer prompt is showing — leave it, just re-enable controls and return. Recommend also clearing the stale cached prompt for the session (_clearClarifyPendingForSession(sid)) in the same-id case so it can't re-render.

3. The server-side SSE notify has no browser consumer (not blocking — keep it, but adjust the framing)

clear_pending's new _clarify_sse_notify(...) is ordering-correct and well-tested, but no client opens /api/clarify/stream — the WebUI switched clarify to HTTP polling (/api/clarify/pending) in v0.51.340 to avoid connection-pool exhaustion (_clarifyEventSource is declared but never new'd; both clarify/stream mentions in static/*.js are comments). So the notify doesn't take down any card in the live UI — the un-bricking comes entirely from the client 409-terminal path. Keeping the notify is fine (it's correct, and it already drives the sessions-list attention badge via the pre-existing publish_session_list_changed), but the PR description's "subscribed browsers take down the card" claim isn't accurate for the current architecture. No code change strictly required here; just don't rely on it for the fix.

Tests

The new test_4504_clarify_stuck_on_expiry.py asserts the 409 branch strings are present but checks neither the loading-guard interaction nor the id-guard — both defects above pass CI uncaught. Please add coverage that (a) a typed draft survives a 409 expiry into the composer, and (b) a 409 for prompt A does not dismiss a rendered prompt B.

Once 1 + 2 (+ their tests) land, this is a strong fix and I'll fast-track the re-gate. Really appreciate the clear root-cause writeup in the PR body.

@nesquena-hermes nesquena-hermes added the changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address label Jun 20, 2026
…ng before draft rescue (nesquena#4504)

Addresses two reviewer P1 defects in the original PR nesquena#4524 fix:

1. Loading-guard bug (typed answer silently dropped on expiry):
   respondClarify sets _clarifySetControlsDisabled(true, true)
   (loading class on #clarifySubmit) at the top, before the try. The
   prior 409 branch called hideClarifyCard(true, 'expired') →
   _stashClarifyDraft('expired') — but _stashClarifyDraft bails
   immediately on submit.classList.contains('loading')
   (messages.js:~4972). Net: for a free-text/'Other' answer the user
   typed, the card vanished, the draft was NOT moved to the composer,
   and no toast fired — directly contradicting the PR's own commentary.

   Fix: call _clarifySetControlsDisabled(false, false) *before*
   hideClarifyCard in the same-id branch so the draft actually
   reaches the unlocked composer and the 'draft kept' toast surfaces.

2. nesquena#2639 regression (late 409 wipes the next prompt's card):
   The 200/success path is guarded by if (_clarifyId === clarifyId)
   (messages.js:5188) specifically so a parallel poll that already
   rendered the next queued prompt B isn't clobbered. The prior 409
   branch had no such guard — it unconditionally tore down the visible
   card. If prompt B rendered while A's response was in flight, A's late
   409 dismissed B.

   Fix: gate the 409-terminal handling on _clarifyId === clarifyId.
   If it differs, a newer prompt is showing — re-enable controls,
   surface a 'previous prompt expired — a newer one is showing' status
   line, and return without touching the visible card. The same-id arm
   additionally calls _clearClarifyPendingForSession(sid) so the
   cached pending entry cannot re-render the just-dismissed card,
   mirroring the success-path contract.

Tests expanded from 7 → 11 cases. New coverage:

  - test_409_clears_loading_before_hide_so_draft_is_rescued — pins the
    order so _stashClarifyDraft's loading-class guard does not bail.
  - test_409_is_guarded_by_clarify_id_match — pins the nesquena#2639 guard.
  - test_409_same_id_branch_clears_session_cache — pins the
    _clearClarifyPendingForSession(sid) call.
  - test_409_different_id_branch_does_not_dismiss — structurally pins
    that exactly one arm dismisses (the same-id arm), and both arms
    re-enable controls.

50/50 broader clarify tests still pass. ruff clean. node --check clean.
@Sanjays2402

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review @nesquena-hermes — both P1s landed and are now pinned by tests. Pushed in e6910e5.

1. Loading-guard fixed — typed draft now reaches the composer

The 409 same-id branch now calls _clarifySetControlsDisabled(false, false) before hideClarifyCard(true, "expired"), so _stashClarifyDraft's submit.classList.contains("loading") check no longer bails and the typed answer + "Clarification timed out. Your draft was kept in the composer." toast both fire. New test test_409_clears_loading_before_hide_so_draft_is_rescued pins the call order with a positional assertion (clear_idx < hide_idx) so this can't regress.

2. _clarifyId === clarifyId guard added — late 409 won't wipe prompt B

Mirroring the success path's contract, the 409 catch now branches:

  • Same-id arm (_clarifyId === clarifyId): clear loading → null out session/id → _clearClarifyPendingForSession(sid)hideClarifyCard(true, "expired") → early return. Pinned by test_409_same_id_branch_clears_session_cache.
  • Different-id arm: re-enable controls, set status "Clarify: previous prompt expired — a newer one is showing.", return. No hideClarifyCard, no cache clear. Pinned by test_409_different_id_branch_does_not_dismiss (structural: exactly one hideClarifyCard(true, "expired") in the branch, both arms call _clarifySetControlsDisabled(false, false)).

Test count: 7 → 11. All 11 pass + 50/50 broader clarify suite still passes + ruff clean + node --check static/messages.js clean.

3. PR-body framing correction (no code change)

You're right that the WebUI clarify transport switched to HTTP polling in v0.51.340 — new EventSource is never called for /api/clarify/stream, and the two remaining string mentions in static/*.js are inside comments. The Phase A _clarify_sse_notify(...) in clear_pending isn't what un-bricks the live UI today; the un-bricking comes entirely from the client 409-terminal path. Keeping the notify is still useful — it's ordering-correct, it drives the sessions-list attention badge via the pre-existing publish_session_list_changed call, and it's a no-cost reattachment hook if SSE ever comes back — but the PR title/body framing should not promise live-UI takedown via SSE. I've updated the test file's docstring to call this out so future readers don't repeat the mistake; happy to also amend the PR description once you've had a chance to look at the new commit.

Appreciate the time on this one — the reviewer P1s were both real and I'd missed them in the initial pass.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.534 via the rebased release branch (credited in CHANGELOG + release notes). Thanks @Sanjays2402 — clean convergence on all 3 fix-spec points (loading cleared before stash so the typed draft survives; clarify_id-guarded dismissal preserving #2639; same-id stale clear), with the reasoning in the comments and a 261-line test covering each case. Codex SAFE + Opus SAFE + full suite green (9707). This un-bricks the session on clarify-prompt expiry — nice fix.

pull Bot pushed a commit to Mu-L/hermes-webui that referenced this pull request Jun 20, 2026
…d) + SSE-notify on expiry (nesquena#4504) + v0.51.534 CHANGELOG
rzyns pushed a commit to hermegeddon/hermes-webui that referenced this pull request Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(clarify): expired clarify prompt leaves card stuck + composer locked with no way to clear (409 on submit)

2 participants