nesquena/hermes-webui

Stop/Cancel can leave sessions stuck when ACTIVE_RUNS stays 'cancelling'

已关闭

#6,623 创建于 2026年7月30日

 (3 条评论) (0 个反应) (0 位负责人)Python (2,386 个派生)github user discovery
bughelp wantedprioritysessionsprint-candidatestreaming

仓库指标

星标
 (17,368 个星标)
PR 合并指标
 (平均合并 14小时 31分钟) (30 天内合并 314 个 PR)

描述

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

  1. Start a chat stream that blocks for a long time (e.g. tool/MCP network I/O or reconnect loop).
  2. Click Stop.
  3. Observe /api/chat/cancel returns 200 and a JSON body like { ok: true, cancelled: true, stream_id: ... }.
  4. Refresh: the session keeps reconnecting/polling and shows busy; active_stream_id persists.

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, calls agent.interrupt(), and updates ACTIVE_RUNS[stream_id].phase = "cancelling".
  • _clear_stale_stream_state() (in api.routes) refuses to clear session.active_stream_id when the stream_id is still present in ACTIVE_RUNS (worker_alive=True).
  • If the agent thread doesn't unwind, ACTIVE_RUNS never gets cleared by the worker finally → 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 to v0.52.106)
  • Hermes Agent: v0.19.0 (v2026.7.20)
  • OS: macOS (darwin 25.5.0)

贡献者指南