fix(gateway): restart --all stamps the marker after the old host is gone

Written before the stop, the marker was visible to the still-draining old
host: a platform reconnecting in that window replayed "Gateway online"
and unlinked it, so the new host booted silent. restart_all_verb_scope now
yields the stamp and _restart_all_as_host runs it once the old host has
exited and before the start, the same order the Windows restart uses.

The Windows restart test runs on Windows (platforms marker) instead of
patching out the host check.
This commit is contained in:
Teknium committed 2026-10-24 16:00:35 -07:00
1 parent bc8e9107b6
commit 8e5ea80dfb
4 files changed
+14 -13

No files matched your search

+5 -5
View File
@@ -5402,11 +5402,11 @@ def _restart_all(system: bool, detach: bool = False) -> None:
sys.exit(GATEWAY_FATAL_CONFIG_EXIT_CODE)
# No live owner: stop/kill/start as the host root, not as the invoking profile (whose own
# gateway the run-side guard would refuse — the host stayed down after a Desktop update).
with restart_all_verb_scope(owner):
_restart_all_as_host(owner, system, detach)
with restart_all_verb_scope(owner) as owe_online_notice:
_restart_all_as_host(owner, system, detach, owe_online_notice)
def _restart_all_as_host(owner, system: bool, detach: bool = False) -> None:
def _restart_all_as_host(owner, system: bool, detach: bool = False, owe_online_notice=lambda: None) -> None:
service_stopped = _stop_installed_service(system)
if owner is not None:
# Stop ONLY the host process: its served set comes back with it, and any per-profile
@@ -5426,8 +5426,8 @@ def _restart_all_as_host(owner, system: bool, detach: bool = False) -> None:
# Retract the stopped owner's rendezvous record. Leaving it made this very function a silent
# no-op: the re-entered `gateway run` below read the corpse's record and ATTACHED to it.
_discard_dead_host_record()
# The retraction only invalidates when it removed a record; the pre-stop memo must go either way.
_forget_host_gateway_probe()
_forget_host_gateway_probe() # the retraction invalidates only a removed record; drop the memo anyway
owe_online_notice() # old host gone, new one not started: only the new boot can see it
print("Starting gateway...")
# Even without a registered task, gateway_windows.start() uses the detached launcher.
+4 -3
View File
@@ -51,12 +51,13 @@ def restart_all_verb_scope(owner):
The verb stops the host with a service call or a SIGTERM, so the old process never runs the
in-band restart path that stamps the planned-restart marker; without it the next boot reads a
plain stop and every home channel stays silent — #142515's gap, on every OS.
plain stop and every home channel stays silent — #142515's gap, on every OS. Yields the stamp
for the caller to run once the old host is gone and before the start: a marker written while the
old host still drains can be replayed and unlinked by it, and the new host then boots silent.
"""
from gateway.restart_notice import write_planned_restart_marker
with host_scope_for_all_verb(owner):
write_planned_restart_marker()
yield
yield write_planned_restart_marker
def profile_lifecycle(command: str, args) -> bool:
@@ -22,7 +22,7 @@ def hermes_home(tmp_path, monkeypatch):
return tmp_path
def test_restart_all_stamps_the_marker_before_the_host_is_stopped(hermes_home, monkeypatch):
def test_restart_all_stamps_the_marker_after_the_host_is_stopped(hermes_home, monkeypatch):
from hermes_cli import gateway as gw
marker = hermes_home / ".restart_pending.json"
@@ -37,7 +37,7 @@ def test_restart_all_stamps_the_marker_before_the_host_is_stopped(hermes_home, m
monkeypatch.setattr(gw, "_discard_dead_host_record", lambda: None)
monkeypatch.setattr(gw, "_forget_host_gateway_probe", lambda: None)
monkeypatch.setattr(gw, "_installed_service_kind_for", lambda windows: "systemd")
monkeypatch.setattr(gw, "_service_call", lambda kind, verb, system: None)
monkeypatch.setattr(gw, "_service_call", lambda kind, verb, system: seen.setdefault("marker_at_start", marker.exists()))
monkeypatch.setattr(gw, "is_windows", False)
owner = SimpleNamespace(pid=4242, profiles=["default"], home=hermes_home, describe=lambda: "host")
@@ -47,5 +47,5 @@ def test_restart_all_stamps_the_marker_before_the_host_is_stopped(hermes_home, m
# The verb itself, exactly as the CLI dispatches it.
gw._restart_all(system=False)
assert seen["marker_at_stop"] is True, "the host must be stopped with the notice already owed"
assert marker.exists(), "the restarted host's boot pass reads this marker"
assert seen["marker_at_stop"] is False, "the still-draining old host must not see the marker"
assert seen["marker_at_start"] is True, "the restarted host's boot pass reads this marker"
@@ -23,13 +23,13 @@ def hermes_home(tmp_path, monkeypatch):
return tmp_path
@pytest.mark.platforms("windows")
def test_restart_stamps_the_marker_after_the_old_gateway_is_gone(hermes_home, monkeypatch):
from hermes_cli import gateway as gw_facade
from hermes_cli import gateway_windows as gw
marker = hermes_home / MARKER
seen: dict[str, bool] = {}
monkeypatch.setattr(gw, "_assert_windows", lambda: None)
monkeypatch.setattr(gw, "_wait_for_gateway_absent", lambda timeout_s=30.0: True)
monkeypatch.setattr(gw_facade, "_wait_for_api_server_port_free", lambda: None)
monkeypatch.setattr(gw, "stop", lambda: seen.setdefault("marker_at_stop", marker.exists()))