Files
hermes-agent/tests/hermes_cli/test_update_gateway_restart_fallback.py
T
Jack Lau dfcef70061 fix(update): stop a gateway we cannot relaunch instead of leaving it on stale code
Fixes #88654.

After an in-place update, the manual-gateway leg of the restart phase did
this for every profile-mapped gateway:

    restart_mode = _prepare_profile_gateway_update_restart(proc.profile, pid)
    if restart_mode is None:
        continue

A None means no relaunch could be armed. The bare continue skipped the
drain and the stop, and the unmapped sweep immediately below skips any
pid already in profile_processes, so the process was never killed and
never counted into the "Stopped N manual gateway process(es)" summary.
The gateway kept running with its pre-update modules resident while the
new code sat on disk, and every lazy import from that point mixed
versions:

    cannot import name '_MAX_TOOL_ERROR_CHARS' from 'tools.registry'

with no operator signal of any kind.

Two changes.

_prepare_profile_gateway_update_restart now falls back to replaying the
process's own captured command line when the profile-derived relaunch
cannot be armed. launch_detached_gateway_restart_by_cmdline already
exists for exactly this case and documents itself as the companion for
gateways with no profile mapping; the Windows post-update path already
uses it the same way. The argv is captured a few lines earlier for the
external-supervisor check, so the fallback costs nothing extra. The
external-supervisor branch still short-circuits first, because replaying
argv there would escape the manager and race its replacement process.

When neither mechanism can arm a relaunch, the update path no longer
falls through silently. It says so, naming the profile and pid, and hands
the process to the existing unmapped sweep so it is stopped and reported
through the established "Restart manually: hermes gateway run" contract.
Leaving it running was the actual harm: a gateway on stale modules fails
every lazy import for as long as it lives.
2026-08-23 04:25:18 -07:00

121 lines
4.3 KiB
Python

"""Regression coverage for #88654.
``hermes update`` relaunches manually-run profile gateways through
``_prepare_profile_gateway_update_restart``. When the profile-derived
relaunch could not be armed the helper returned ``None``, and the update
path's response to ``None`` was a bare ``continue`` -- so the gateway was
neither relaunched, nor stopped, nor mentioned. It kept serving from
pre-update modules while the new code sat on disk, and every lazy import
from that point mixed versions.
The helper now falls back to replaying the process's own captured command
line via ``launch_detached_gateway_restart_by_cmdline`` -- the companion
that already exists for gateways with no profile mapping, and that the
Windows post-update path already uses for exactly this case.
"""
import pytest
import hermes_cli.gateway as gateway
_ARGV = ["python", "-m", "hermes_cli.main", "gateway", "run"]
def _stub_argv(monkeypatch, argv):
monkeypatch.setattr(gateway, "_capture_gateway_argv", lambda _pid: argv)
def test_profile_relaunch_wins_and_skips_the_cmdline_replay(monkeypatch):
"""The existing path is unchanged: a profile relaunch short-circuits."""
_stub_argv(monkeypatch, list(_ARGV))
monkeypatch.setattr(
gateway, "launch_detached_profile_gateway_restart", lambda *_a: True
)
monkeypatch.setattr(
gateway,
"launch_detached_gateway_restart_by_cmdline",
lambda *_a: pytest.fail("cmdline replay must not run when the profile path works"),
)
assert gateway._prepare_profile_gateway_update_restart("fitness", 4242) == "detached"
def test_falls_back_to_cmdline_replay_when_profile_relaunch_fails(monkeypatch):
"""The #88654 fix: an unarmable profile relaunch still gets the gateway back."""
_stub_argv(monkeypatch, list(_ARGV))
monkeypatch.setattr(
gateway, "launch_detached_profile_gateway_restart", lambda *_a: False
)
seen = []
def _by_cmdline(pid, argv):
seen.append((pid, argv))
return True
monkeypatch.setattr(
gateway, "launch_detached_gateway_restart_by_cmdline", _by_cmdline
)
assert (
gateway._prepare_profile_gateway_update_restart("fitness", 4242)
== "detached-cmdline"
)
# Replays the process's OWN argv, which is the whole point: the profile
# could not be mapped back to a run argv, so the captured one is the only
# faithful description of how to restart it.
assert seen == [(4242, _ARGV)]
def test_returns_none_when_there_is_no_argv_to_replay(monkeypatch):
"""No captured argv means no honest way to relaunch; caller must be told."""
_stub_argv(monkeypatch, [])
monkeypatch.setattr(
gateway, "launch_detached_profile_gateway_restart", lambda *_a: False
)
monkeypatch.setattr(
gateway,
"launch_detached_gateway_restart_by_cmdline",
lambda *_a: pytest.fail("must not replay an empty argv"),
)
assert gateway._prepare_profile_gateway_update_restart("fitness", 4242) is None
def test_returns_none_when_both_relaunch_paths_fail(monkeypatch):
"""Both mechanisms failing is still reported as None, not a false success."""
_stub_argv(monkeypatch, list(_ARGV))
monkeypatch.setattr(
gateway, "launch_detached_profile_gateway_restart", lambda *_a: False
)
monkeypatch.setattr(
gateway, "launch_detached_gateway_restart_by_cmdline", lambda *_a: False
)
assert gateway._prepare_profile_gateway_update_restart("fitness", 4242) is None
def test_external_supervisor_still_short_circuits_before_any_replay(monkeypatch):
"""Guardrail: the supervisor hand-back must not gain a replay behind it.
Replaying the argv for an externally supervised gateway would escape the
manager and race its replacement process, which is the exact hazard the
supervisor branch exists to avoid.
"""
_stub_argv(monkeypatch, _ARGV + ["--external-supervisor"])
monkeypatch.setattr(
gateway,
"launch_detached_profile_gateway_restart",
lambda *_a: pytest.fail("detached watcher must not be launched"),
)
monkeypatch.setattr(
gateway,
"launch_detached_gateway_restart_by_cmdline",
lambda *_a: pytest.fail("cmdline replay must not be launched"),
)
assert (
gateway._prepare_profile_gateway_update_restart("fitness", 4242)
== "external-supervisor"
)