# NousResearch/hermes-agent · Desktop auto-update process conflicts and stuck UI fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 8 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #38158. Verify it addresses your case.
- **Recommended:** read PR #38158 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-38158,pr-52203`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.20`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #37532: bug: desktop auto-update kills its own backend via _kill_stale_dashboard_processes
- #37775: Desktop app update gets stuck forever; terminal shows hermes:api ECONNREFUSED 127.0.0.1:9120 on Fedora Wayland
- #44515: \[Bug\]: Hermes Agent Desktop doesn't update unless hermes background processes are stopped

## Prior & competing PRs

- **#38158: fix(desktop): stop in-app update from killing its own backend (#37532)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/electron/main.cjs`, `hermes_cli/main.py`, `tests/hermes_cli/test_update_stale_dashboard.py`
  - fix hunks:
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -1412,6 +1412,18 @@ async function applyUpdatesPosixInApp(opts = {}) {
         PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
       }
     
    +  // `hermes update` reaps stale `hermes dashboard` backends (a code update
    +  // leaves the running process serving old Python against the freshly-updated
    +  // JS bundle). But OUR backend is one of those processes, and killing it
    +  // mid-update produces the boot→kill→crash loop in #37532 — the desktop
    +  // already restarts its own backend via the rebuild+relaunch below, so the
    +  // reap must spare it. Hand the live backend's PID to the update process;
    +  // _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
    +  // it while still reaping any genuinely-orphaned dashboards. (#37532)
    +  if (hermesProcess && Number.isInteger(hermesProcess.pid)) {
    +    env.HERMES_DESKTOP_CHILD_PID = String(hermesProcess.pid)
    +  }
    +
       // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal
       // to main when the pinned branch no longer exists on origin).
       let branchArgs = []
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -7363,7 +7363,10 @@ def cmd_gui(args: argparse.Namespace):
         sys.exit(launch_result.returncode)
     
     
    -def _find_stale_dashboard_pids() -> list[int]:
    +def _find_stale_dashboard_pids(
    +    *,
    +    exclude_pids: set[int] | None = None,
    +) -> list[int]:
         """Return PIDs of ``hermes dashboard`` processes other than ourselves.
     
         ``hermes dashboard`` is a long-lived server process commonly started and
    ```

    ```diff
    @@ -7378,6 +7381,15 @@ def _find_stale_dashboard_pids() -> list[int]:
         it.  This helper is just the detection step; see
         ``_kill_stale_dashboard_processes`` for the kill.
     
    +    *exclude_pids* is an optional set of PIDs that must never be returned.
    +    This is used by the Hermes Desktop Electron app to protect its own
    +    backend child process: when the desktop spawns ``hermes dashboard`` as
    +    a backend and triggers an auto-update, the update must not kill the
    +    dashboard that the desktop itself manages.  The desktop sets the
    +    environment variable ``HERMES_DESKTOP_CHILD_PID`` on the spawned
    +    backend process; ``_kill_stale_dashboard_processes`` reads it and
    +    passes it here.  (#37532)
    +
         Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
         """
         patterns = [
    ```

    ```diff
    @@ -7453,6 +7465,8 @@ def _find_stale_dashboard_pids() -> list[int]:
         except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
             return []
     
    +    if exclude_pids:
    +        dashboard_pids = [p for p in dashboard_pids if p not in exclude_pids]
         return dashboard_pids
     
     
    ```

    ```diff
    @@ -7604,7 +7618,18 @@ def _kill_stale_dashboard_processes(
         launch args (--host, --port, --insecure, --tui, --no-open).  The user
         restarts it manually; a hint is printed.
         """
    -    pids = _find_stale_dashboard_pids()
    +    # When the Hermes Desktop Electron app spawns this dashboard as a
    +    # backend child, it sets HERMES_DESKTOP_CHILD_PID so that the update
    +    # path can skip killing the desktop-managed process.  (#37532)
    +    exclude: set[int] | None = None
    +    raw_pid = os.environ.get("HERMES_DESKTOP_CHILD_PID")
    +    if raw_pid:
    +        try:
    +            exclude = {int(raw_pid)}
    +        except (ValueError, TypeError):
    +            pass
    +
    +    pids = _find_stale_dashboard_pids(exclude_pids=exclude)
         if not pids:
             return
     
    ```
  - `tests/hermes_cli/test_update_stale_dashboard.py`

    ```diff
    @@ -185,6 +185,48 @@ def test_invalid_pid_lines_skipped(self):
                 pids = _find_stale_dashboard_pids()
             assert pids == [12345]
     
    +    def test_exclude_pids_filters_specified_pids(self):
    +        """exclude_pids removes specific PIDs from the result — used by
    +        the Desktop Electron app to protect its own backend child.  (#37532)
    +        """
    +        with patch("subprocess.run") as mock_run:
    +            mock_run.return_value = MagicMock(
    +                returncode=0,
    +                stdout="\n".join([
    +                    _ps_line(11111, "hermes dashboard --port 9119"),
    +                    _ps_line(22222, "hermes dashboard --port 9120"),
    +                    _ps_line(33333, "hermes dashboard --port 9121"),
    +                ]) + "\n",
    +                stderr="",
    +            )
    +            # Exclude the desktop-managed backend PID
    +            pids = _find_stale_dashboard_pids(exclude_pids={22222})
    +        assert 11111 in pids
    +        assert 22222 not in pids
    +        assert 33333 in pids
    +
    +    def test_exclude_pids_none_is_noop(self):
    +        """Passing exclude_pids=None (the default) changes nothing."""
    +        with patch("subprocess.run") as mock_run:
    +            mock_run.return_value = MagicMock(
    +                returncode=0,
    +                stdout=_ps_line(12345, "hermes dashboard --port 9119") + "\n",
    +                stderr="",
    +            )
    +            pids = _find_stale_dashboard_pids(exclude_pids=None)
    +        assert pids == [12345]
    +
    +    def test_exclude_all_pids_returns_empty(self):
    +        """If all matched PIDs are excluded, the result is empty."""
    +        with patch("subprocess.run") as mock_run:
    +            mock_run.return_value = MagicMock(
    +                returncode=0,
    +                stdout=_ps_line(12345, "hermes dashboard --port 9119") + "\n",
    +                stderr="",
    +            )
    +            pids = _find_stale_dashboard_pids(exclude_pids={12345})
    +        assert pids == []
    +
     
     @pytest.mark.skipif(sys.platform == "win32", reason="POSIX kill semantics")
     class TestKillStaleDashboardPosix:
    ```
- **#52203: fix(update): announce gateway drain waits so desktop updates don't look hung**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/gateway.py`, `hermes_cli/main.py`, `tests/hermes_cli/test_gateway_service.py`
- **#37538: fix(cli): exclude desktop-managed backend from stale-dashboard kill**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `5fca754ee335f6b99ee59481a242e89f5abe8791`, `#38158`
- **#37834: fix(desktop): treat 'done' as terminal update stage to unstick UI on Linux (#37775)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#45205`, `#37834`
- **#44540: fix(update): announce gateway drain waits so desktop updates don't look hung**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#44061`

## Fix status

- Merged to main in PR #38158 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-25`.
- Subsystem: `apps/desktop`
