diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ea289aa5d9..3d6242f807 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4893,6 +4893,7 @@ _LAZY_COMMAND_EXPORTS = { "_dependency_sync_would_rewrite", "_detect_self_loaded_native_modules", "_detect_venv_python_processes", + "_desktop_owns_gateway_lifecycle", "_defer_update_for_self_lock", "_discard_lockfile_churn", "_discard_stashed_changes", diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 475eee5afd..836aba3818 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -4674,6 +4674,71 @@ def _stop_process_trees(pids: list[int]) -> None: logger.debug("Could not stop process tree %s: %s", pid, exc) +def _looks_like_desktop_control_plane(cmdline: str) -> bool: + """True for this-install ``hermes serve`` / ``hermes dashboard`` argv. + + That is the Desktop control plane, not the messaging gateway. Serve and + dashboard do not host platform adapters (#92091); do not feed this into + ``looks_like_gateway_command_line``. + """ + low = (cmdline or "").lower() + return "hermes_cli.main" in low and (" serve" in low or " dashboard" in low) + + +def _desktop_owns_gateway_lifecycle() -> bool: + """True when Desktop currently supervises this install's control plane. + + The updater must not steal gateway start in that case: Desktop owns + start/stop via ``/api/gateway/*``. This is *not* proof messaging is + already served — a live serve process is the control plane, and the + gateway is a detached sibling (#76129 / #92091). + + Prefer the spawn ledger (owned identity). Fall back to the install-scoped + venv-holder scan already used by the lock guard; an orphaned control-plane + process (supervisor gone) does not count. + """ + try: + from hermes_cli.process_identity import ledger_entries, spawner_is_dead + + for entry in ledger_entries(): + if entry.get("purpose") not in ("serve", "dashboard"): + continue + if spawner_is_dead(entry) is False: + return True + except Exception as exc: + logger.debug("Desktop-lifecycle ledger probe failed: %s", exc) + + try: + import psutil + except Exception: + psutil = None + + try: + holders = _m()._detect_venv_python_processes() + except Exception as exc: + logger.debug("Desktop-lifecycle holder scan failed: %s", exc) + return False + + for pid, _name, cmdline in holders: + if not _looks_like_desktop_control_plane(cmdline): + continue + if psutil is None: + # Cannot prove orphanhood; a live this-install control plane is + # enough to refuse stealing gateway start. + return True + try: + proc = psutil.Process(int(pid)) + parent = proc.parent() + if parent is None or not parent.is_running(): + continue + if parent.create_time() > proc.create_time(): + continue + return True + except Exception: + continue + return False + + def _pause_windows_gateways_for_update() -> dict | None: """Stop running Windows gateways before mutating the checkout or venv. @@ -4712,6 +4777,24 @@ def _pause_windows_gateways_for_update() -> dict | None: # gateways that were running when the update began. Cold-start one after # the update so an installed gateway is actually up post-update. Users # who run gateway-less (no autostart entry) get nothing forced on them. + # + # Exception: Desktop currently owns this install's gateway lifecycle + # (live supervised serve/dashboard). A vestigial Startup/Scheduled + # Task is not the owner — spawning ``gateway run`` beside Desktop + # races ports/state (#76129). Serve is the control plane, not proof + # messaging is served; the skip is ownership, not liveness (#92091). + try: + if _desktop_owns_gateway_lifecycle(): + logger.debug( + "Skipping Windows gateway cold-start plan: " + "Desktop owns gateway lifecycle" + ) + return None + except Exception as exc: + logger.debug( + "Could not check Desktop gateway-lifecycle ownership before update: %s", + exc, + ) try: from hermes_cli import gateway_windows @@ -4864,6 +4947,18 @@ def _cold_start_windows_gateway_after_update() -> None: logger.debug("Could not re-check gateway liveness before cold-start: %s", exc) return + try: + if _desktop_owns_gateway_lifecycle(): + logger.debug( + "Skipping Windows gateway cold-start: Desktop owns gateway lifecycle" + ) + return + except Exception as exc: + logger.debug( + "Could not re-check Desktop gateway-lifecycle ownership before cold-start: %s", + exc, + ) + try: pid = gateway_windows._spawn_detached() except Exception as exc: diff --git a/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py new file mode 100644 index 0000000000..d3d129e094 --- /dev/null +++ b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py @@ -0,0 +1,101 @@ +"""#76129: post-update Windows cold-start must not steal Desktop-owned lifecycle. + +A vestigial Startup/Scheduled-Task autostart is not proof the user wants a +standalone ``gateway run``. When Desktop currently supervises this install's +control plane, the updater must not spawn a competing messaging daemon. + +Serve/dashboard are the control plane, not the messaging gateway (#92091). +``looks_like_gateway_command_line`` stays strict; ownership is a separate +predicate. +""" + +from __future__ import annotations + +from hermes_cli import gateway as hermes_gateway +from hermes_cli import gateway_windows +from hermes_cli import main as cli_main +from hermes_cli import process_identity +from hermes_cli import update_cmd + + +def _live_serve_ledger_entry() -> dict: + return { + "pid": 111, + "create_time": 1.0, + "purpose": "serve", + "install": "abc", + "spawner_pid": 99, + "spawner_create": 0.5, + } + + +def test_control_plane_argv_is_not_a_gateway(): + from gateway.status import looks_like_gateway_command_line + + serve = "C:\\Hermes\\.venv\\Scripts\\python.exe -m hermes_cli.main serve --host 127.0.0.1" + run = "C:\\Hermes\\.venv\\Scripts\\python.exe -m hermes_cli.main gateway run" + + assert update_cmd._looks_like_desktop_control_plane(serve) is True + assert looks_like_gateway_command_line(serve) is False + assert update_cmd._looks_like_desktop_control_plane(run) is False + assert looks_like_gateway_command_line(run) is True + + +def test_ledger_live_serve_with_live_spawner_owns_lifecycle(monkeypatch): + monkeypatch.setattr( + process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()] + ) + monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: False) + monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + + assert update_cmd._desktop_owns_gateway_lifecycle() is True + + +def test_orphaned_control_plane_does_not_own_lifecycle(monkeypatch): + monkeypatch.setattr( + process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()] + ) + monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: True) + monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + + assert update_cmd._desktop_owns_gateway_lifecycle() is False + + +def test_pause_skips_cold_start_plan_when_desktop_owns_lifecycle(monkeypatch): + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: []) + monkeypatch.setattr(gateway_windows, "is_installed", lambda: True) + monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True) + + assert update_cmd._pause_windows_gateways_for_update() is None + + +def test_pause_still_cold_starts_when_autostart_and_no_desktop_owner(monkeypatch): + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: []) + monkeypatch.setattr(gateway_windows, "is_installed", lambda: True) + monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: False) + + token = update_cmd._pause_windows_gateways_for_update() + + assert token == { + "resume_needed": True, + "profiles": {}, + "unmapped_pids": [], + "unmapped": [], + "cold_start_if_installed": True, + } + + +def test_cold_start_aborts_when_desktop_owns_lifecycle(monkeypatch): + spawned = [] + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: []) + monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True) + monkeypatch.setattr( + gateway_windows, "_spawn_detached", lambda: spawned.append(1) or 4242 + ) + + update_cmd._cold_start_windows_gateway_after_update() + + assert spawned == []