{"repo":"NousResearch/hermes-agent","public_id":"37532","display_label":"Desktop auto-update process conflicts and stuck UI fixes","summary":"These issues and PRs concern bugs where the desktop app's update mechanism kills its own backend processes or gets stuck, along with related fixes and refactoring of the update flow and WebSocket layer.","subsystem":"apps/desktop","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":38158},"resolution":{"state":"partially_resolved","by":"pr-38158,pr-52203"},"freshness":{"state":"verified","last_verified_at":"2026-06-25T00:28:44+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20428571428571426},"fix_edges":[{"issue_number":37532,"pr_number":38158,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44515,"pr_number":52203,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":37532,"title":"bug: desktop auto-update kills its own backend via _kill_stale_dashboard_processes","url":null,"state":"closed"},{"kind":"issue","number":37775,"title":"Desktop app update gets stuck forever; terminal shows hermes:api ECONNREFUSED 127.0.0.1:9120 on Fedora Wayland","url":null,"state":"closed"},{"kind":"issue","number":44515,"title":"[Bug]: Hermes Agent Desktop doesn't update unless hermes background processes are stopped","url":null,"state":"closed"},{"kind":"pr","number":37538,"title":"fix(cli): exclude desktop-managed backend from stale-dashboard kill","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5fca754ee335f6b99ee59481a242e89f5abe8791","number":0},{"kind":"pr","value":"#38158","number":38158}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":37834,"title":"fix(desktop): treat 'done' as terminal update stage to unstick UI on Linux (#37775)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45205","number":45205},{"kind":"pr","value":"#37834","number":37834}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":38158,"title":"fix(desktop): stop in-app update from killing its own backend (#37532)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.cjs","hermes_cli/main.py","tests/hermes_cli/test_update_stale_dashboard.py"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -1412,6 +1412,18 @@ async function applyUpdatesPosixInApp(opts = {}) {\n     PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)\n   }\n \n+  // `hermes update` reaps stale `hermes dashboard` backends (a code update\n+  // leaves the running process serving old Python against the freshly-updated\n+  // JS bundle). But OUR backend is one of those processes, and killing it\n+  // mid-update produces the boot→kill→crash loop in #37532 — the desktop\n+  // already restarts its own backend via the rebuild+relaunch below, so the\n+  // reap must spare it. Hand the live backend's PID to the update process;\n+  // _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes\n+  // it while still reaping any genuinely-orphaned dashboards. (#37532)\n+  if (hermesProcess && Number.isInteger(hermesProcess.pid)) {\n+    env.HERMES_DESKTOP_CHILD_PID = String(hermesProcess.pid)\n+  }\n+\n   // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal\n   // to main when the pinned branch no longer exists on origin).\n   let branchArgs = []"]},{"file":"hermes_cli/main.py","hunks":["@@ -7363,7 +7363,10 @@ def cmd_gui(args: argparse.Namespace):\n     sys.exit(launch_result.returncode)\n \n \n-def _find_stale_dashboard_pids() -> list[int]:\n+def _find_stale_dashboard_pids(\n+    *,\n+    exclude_pids: set[int] | None = None,\n+) -> list[int]:\n     \"\"\"Return PIDs of ``hermes dashboard`` processes other than ourselves.\n \n     ``hermes dashboard`` is a long-lived server process commonly started and","@@ -7378,6 +7381,15 @@ def _find_stale_dashboard_pids() -> list[int]:\n     it.  This helper is just the detection step; see\n     ``_kill_stale_dashboard_processes`` for the kill.\n \n+    *exclude_pids* is an optional set of PIDs that must never be returned.\n+    This is used by the Hermes Desktop Electron app to protect its own\n+    backend child process: when the desktop spawns ``hermes dashboard`` as\n+    a backend and triggers an auto-update, the update must not kill the\n+    dashboard that the desktop itself manages.  The desktop sets the\n+    environment variable ``HERMES_DESKTOP_CHILD_PID`` on the spawned\n+    backend process; ``_kill_stale_dashboard_processes`` reads it and\n+    passes it here.  (#37532)\n+\n     Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).\n     \"\"\"\n     patterns = [","@@ -7453,6 +7465,8 @@ def _find_stale_dashboard_pids() -> list[int]:\n     except (FileNotFoundError, subprocess.TimeoutExpired, OSError):\n         return []\n \n+    if exclude_pids:\n+        dashboard_pids = [p for p in dashboard_pids if p not in exclude_pids]\n     return dashboard_pids\n \n ","@@ -7604,7 +7618,18 @@ def _kill_stale_dashboard_processes(\n     launch args (--host, --port, --insecure, --tui, --no-open).  The user\n     restarts it manually; a hint is printed.\n     \"\"\"\n-    pids = _find_stale_dashboard_pids()\n+    # When the Hermes Desktop Electron app spawns this dashboard as a\n+    # backend child, it sets HERMES_DESKTOP_CHILD_PID so that the update\n+    # path can skip killing the desktop-managed process.  (#37532)\n+    exclude: set[int] | None = None\n+    raw_pid = os.environ.get(\"HERMES_DESKTOP_CHILD_PID\")\n+    if raw_pid:\n+        try:\n+            exclude = {int(raw_pid)}\n+        except (ValueError, TypeError):\n+            pass\n+\n+    pids = _find_stale_dashboard_pids(exclude_pids=exclude)\n     if not pids:\n         return\n "]},{"file":"tests/hermes_cli/test_update_stale_dashboard.py","hunks":["@@ -185,6 +185,48 @@ def test_invalid_pid_lines_skipped(self):\n             pids = _find_stale_dashboard_pids()\n         assert pids == [12345]\n \n+    def test_exclude_pids_filters_specified_pids(self):\n+        \"\"\"exclude_pids removes specific PIDs from the result — used by\n+        the Desktop Electron app to protect its own backend child.  (#37532)\n+        \"\"\"\n+        with patch(\"subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0,\n+                stdout=\"\\n\".join([\n+                    _ps_line(11111, \"hermes dashboard --port 9119\"),\n+                    _ps_line(22222, \"hermes dashboard --port 9120\"),\n+                    _ps_line(33333, \"hermes dashboard --port 9121\"),\n+                ]) + \"\\n\",\n+                stderr=\"\",\n+            )\n+            # Exclude the desktop-managed backend PID\n+            pids = _find_stale_dashboard_pids(exclude_pids={22222})\n+        assert 11111 in pids\n+        assert 22222 not in pids\n+        assert 33333 in pids\n+\n+    def test_exclude_pids_none_is_noop(self):\n+        \"\"\"Passing exclude_pids=None (the default) changes nothing.\"\"\"\n+        with patch(\"subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0,\n+                stdout=_ps_line(12345, \"hermes dashboard --port 9119\") + \"\\n\",\n+                stderr=\"\",\n+            )\n+            pids = _find_stale_dashboard_pids(exclude_pids=None)\n+        assert pids == [12345]\n+\n+    def test_exclude_all_pids_returns_empty(self):\n+        \"\"\"If all matched PIDs are excluded, the result is empty.\"\"\"\n+        with patch(\"subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0,\n+                stdout=_ps_line(12345, \"hermes dashboard --port 9119\") + \"\\n\",\n+                stderr=\"\",\n+            )\n+            pids = _find_stale_dashboard_pids(exclude_pids={12345})\n+        assert pids == []\n+\n \n @pytest.mark.skipif(sys.platform == \"win32\", reason=\"POSIX kill semantics\")\n class TestKillStaleDashboardPosix:\n"]}]},{"kind":"pr","number":44540,"title":"fix(update): announce gateway drain waits so desktop updates don't look hung","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44061","number":44061}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":52203,"title":"fix(update): announce gateway drain waits so desktop updates don't look hung","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway.py","hermes_cli/main.py","tests/hermes_cli/test_gateway_service.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}