fix(update): skip Windows gateway cold-start when Desktop owns lifecycle

Vestigial 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 is not
treated as gateway-equivalent.
This commit is contained in:
686f6c61
2026-08-22 09:38:58 +02:00
committed by Teknium
parent 87b645f52c
commit 4ccc4b6931
3 changed files with 197 additions and 0 deletions
+1
View File
@@ -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",
+95
View File
@@ -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:
@@ -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 == []