nesquena/hermes-webui
Stop/Cancel can leave sessions stuck when ACTIVE_RUNS stays 'cancelling'
Closed
#6,623 opened on Jul 30, 2026
bughelp wantedprioritysessionsprint-candidatestreaming
Repository metrics
- Stars
- (17,368 stars)
- PR merge metrics
- (Avg merge 14h 31m) (314 merged PRs in 30d)
Description
Summary
In Hermes WebUI, clicking Stop triggers /api/chat/cancel?stream_id=... (HTTP 200) but the session can remain stuck in running/thinking indefinitely (often after a refresh).
Repro
- Start a chat stream that blocks for a long time (e.g. tool/MCP network I/O or reconnect loop).
- Click Stop.
- Observe
/api/chat/cancelreturns 200 and a JSON body like{ ok: true, cancelled: true, stream_id: ... }. - Refresh: the session keeps reconnecting/polling and shows busy;
active_stream_idpersists.
Expected
Stop should clear the session active_stream_id / pending state within a bounded time, even if the backend worker thread never reaches its finally (e.g. stuck in C-level network I/O and never observes interrupt()).
Root cause (code path)
api.streaming.cancel_stream()sets cancel flags, callsagent.interrupt(), and updatesACTIVE_RUNS[stream_id].phase = "cancelling"._clear_stale_stream_state()(inapi.routes) refuses to clearsession.active_stream_idwhen thestream_idis still present inACTIVE_RUNS(worker_alive=True).- If the agent thread doesn't unwind,
ACTIVE_RUNSnever gets cleared by the workerfinally→ the session becomes permanently stuck.
Suggested patch (minimal)
Record when cancelling begins, and treat long-cancelling runs as stale.
diff --git a/api/streaming.py b/api/streaming.py
@@ def cancel_stream(stream_id: str) -> bool:
- update_active_run(stream_id, phase="cancelling")
+ update_active_run(stream_id, phase="cancelling", cancelled_at=time.time())
diff --git a/api/routes.py b/api/routes.py
@@ def _clear_stale_stream_state(session) -> bool:
- with _live_config.ACTIVE_RUNS_LOCK:
- worker_alive = stream_id in (_live_config.ACTIVE_RUNS or {})
+ with _live_config.ACTIVE_RUNS_LOCK:
+ active_entry = (_live_config.ACTIVE_RUNS or {}).get(stream_id)
+ worker_alive = active_entry is not None
@@
- if worker_alive:
- return False
+ if worker_alive:
+ # If stuck cancelling for too long, force-clean bookkeeping and continue
+ # stale repair so the UI/session doesn't remain stuck forever.
+ if str((active_entry or {}).get("phase") or "").strip().lower() == "cancelling":
+ cancelled_at = (active_entry or {}).get("cancelled_at")
+ try:
+ age = time.time() - float(cancelled_at) if cancelled_at else None
+ except Exception:
+ age = None
+ if age is not None and age > 30.0:
+ with _live_config.ACTIVE_RUNS_LOCK:
+ _live_config.ACTIVE_RUNS.pop(stream_id, None)
+ worker_alive = False
+ if worker_alive:
+ return False
Notes:
- This does not kill the worker thread; it just prevents the UI/session state from being stuck forever.
- The timeout can be configurable.
Environment
- Hermes WebUI: observed on
v0.52.76(patch applies cleanly tov0.52.106) - Hermes Agent:
v0.19.0(v2026.7.20) - OS: macOS (darwin 25.5.0)