diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 1b4c2e6f0b..3d4c69a1d1 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -93,9 +93,16 @@ def _builtin_gateway_liveness() -> Optional[bool]: # scan below still decide instead of collapsing the whole # tri-state to None. pass - from hermes_cli.gateway import find_gateway_pids + from hermes_cli.gateway import ( + find_gateway_pids, + named_profile_served_by_running_multiplexer, + ) - return bool(find_gateway_pids()) + if find_gateway_pids(): + return True + # Satellite profile: no local gateway.pid, but the default multiplexer + # ticks this profile's cron store (#97120). + return named_profile_served_by_running_multiplexer() except Exception: return None diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 87fc8d02a1..f8d6c0d153 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -6048,6 +6048,82 @@ def _running_under_gateway_supervisor() -> bool: return is_gateway_supervisor_process() +def named_profile_served_by_running_multiplexer() -> bool: + """True when a live default multiplexer already ticks this named profile. + + Shared by the named-profile start guard and cron liveness: a satellite + profile has no gateway.pid of its own, but the default multiplexer's + ticker still fires its jobs (#97120). + """ + try: + suffix = _profile_suffix() + except Exception: + return False + if not suffix: + return False + + try: + from hermes_constants import get_default_hermes_root + default_root = get_default_hermes_root() + except Exception: + return False + + try: + from gateway.status import _read_pid_record + + default_pid_path = default_root / "gateway.pid" + rec = _read_pid_record(default_pid_path) + if not rec: + return False + from gateway.status import _pid_exists, _pid_from_record + pid = _pid_from_record(rec) + if not pid or not _pid_exists(pid): + return False + + from gateway.config import _env_multiplex_profiles_override + + cfg_path = default_root / "config.yaml" + cfg = {} + if cfg_path.exists(): + from hermes_cli.config import read_user_config_raw + + cfg = read_user_config_raw(cfg_path) + + env_multiplex = _env_multiplex_profiles_override() + if env_multiplex is False: + return False + if env_multiplex is True: + multiplex = True + else: + if not cfg_path.exists(): + return False + multiplex = bool( + cfg.get("multiplex_profiles") + or (cfg.get("gateway", {}) or {}).get("multiplex_profiles") + ) + if not multiplex: + return False + + gateway_cfg = cfg.get("gateway", {}) or {} + if "multiplex_profile_allowlist" in cfg: + raw_allowlist = cfg.get("multiplex_profile_allowlist") + else: + raw_allowlist = gateway_cfg.get("multiplex_profile_allowlist") + from gateway.config import _normalize_multiplex_profile_allowlist + from hermes_cli.profiles import normalize_profile_name + + profile_allowlist = _normalize_multiplex_profile_allowlist(raw_allowlist) + if ( + profile_allowlist is not None + and normalize_profile_name(suffix) not in profile_allowlist + ): + return False + return True + except Exception: + logger.debug("Multiplexer-serving probe failed", exc_info=True) + return False + + def _guard_named_profile_under_multiplexer(force: bool = False) -> None: """Refuse a named-profile gateway when a multiplexer is already serving it. @@ -6063,85 +6139,11 @@ def _guard_named_profile_under_multiplexer(force: bool = False) -> None: """ if force: return - # (a) Are we a named profile? Default/custom-hash homes return "". try: suffix = _profile_suffix() except Exception: return - if not suffix: - return # default profile (or unrecognized) — this guard doesn't apply - - try: - from hermes_constants import get_default_hermes_root - default_root = get_default_hermes_root() - # (b) Is the default-profile gateway running? - from gateway.status import get_running_pid as _default_running_pid # noqa - except Exception: - return - - try: - import yaml as _yaml - from gateway.status import _read_pid_record # type: ignore - - # (b) default gateway PID file present + alive - default_pid_path = default_root / "gateway.pid" - rec = _read_pid_record(default_pid_path) - if not rec: - return - from gateway.status import _pid_exists, _pid_from_record - pid = _pid_from_record(rec) - if not pid or not _pid_exists(pid): - return - - # (c) multiplexing is on for the default gateway. Precedence mirrors - # gateway.config: the GATEWAY_MULTIPLEX_PROFILES env override wins over - # config.yaml when set to a recognized value, so a hosted gateway that - # forces multiplex on via env (with no multiplex_profiles in config.yaml) - # still trips this guard. A blank/unrecognized env value falls through - # to config.yaml. - from gateway.config import _env_multiplex_profiles_override - - cfg_path = default_root / "config.yaml" - cfg = {} - if cfg_path.exists(): - # Raw read of the DEFAULT root's config (not the active profile - # home, so load_config() is the wrong owner here); whole probe is - # fail-open via the enclosing except. - from hermes_cli.config import read_user_config_raw - - cfg = read_user_config_raw(cfg_path) - - env_multiplex = _env_multiplex_profiles_override() - if env_multiplex is False: - return # explicitly forced OFF by the operator env override - if env_multiplex is True: - multiplex = True - else: - if not cfg_path.exists(): - return - multiplex = bool( - cfg.get("multiplex_profiles") - or (cfg.get("gateway", {}) or {}).get("multiplex_profiles") - ) - if not multiplex: - return - - gateway_cfg = cfg.get("gateway", {}) or {} - if "multiplex_profile_allowlist" in cfg: - raw_allowlist = cfg.get("multiplex_profile_allowlist") - else: - raw_allowlist = gateway_cfg.get("multiplex_profile_allowlist") - from gateway.config import _normalize_multiplex_profile_allowlist - from hermes_cli.profiles import normalize_profile_name - - profile_allowlist = _normalize_multiplex_profile_allowlist(raw_allowlist) - if ( - profile_allowlist is not None - and normalize_profile_name(suffix) not in profile_allowlist - ): - return - except Exception: - logger.debug("Multiplexer-conflict probe failed", exc_info=True) + if not named_profile_served_by_running_multiplexer(): return print_error( diff --git a/tests/cron/test_87033_cronjob_gateway_liveness.py b/tests/cron/test_87033_cronjob_gateway_liveness.py index 496083e8d0..0b7cb59d20 100644 --- a/tests/cron/test_87033_cronjob_gateway_liveness.py +++ b/tests/cron/test_87033_cronjob_gateway_liveness.py @@ -199,6 +199,12 @@ class _LivenessPatches: return_value=list(self._pids), ) ) + self._stack.enter_context( + patch( + "hermes_cli.gateway.named_profile_served_by_running_multiplexer", + return_value=False, + ) + ) self._stack.enter_context( patch( "gateway.status.is_gateway_runtime_lock_active", @@ -258,6 +264,10 @@ class TestRuntimeLockFirstLiveness: patch("hermes_cli.cron._active_cron_provider_name", return_value="builtin"), patch("gateway.status.is_gateway_runtime_lock_active", return_value=False), patch("hermes_cli.gateway.find_gateway_pids", return_value=[]), + patch( + "hermes_cli.gateway.named_profile_served_by_running_multiplexer", + return_value=False, + ), ): assert cron_cli._builtin_gateway_liveness() is False @@ -279,6 +289,39 @@ class TestRuntimeLockFirstLiveness: ): assert cron_cli._builtin_gateway_liveness() is True + def test_running_multiplexer_counts_as_alive_for_named_profile(self): + """A satellite profile has no own PID; the default multiplexer ticks it.""" + from unittest.mock import patch + + import hermes_cli.cron as cron_cli + + with ( + patch("hermes_cli.cron._active_cron_provider_name", return_value="builtin"), + patch("gateway.status.is_gateway_runtime_lock_active", return_value=False), + patch("hermes_cli.gateway.find_gateway_pids", return_value=[]), + patch( + "hermes_cli.gateway.named_profile_served_by_running_multiplexer", + return_value=True, + ), + ): + assert cron_cli._builtin_gateway_liveness() is True + + def test_no_multiplexer_and_no_pids_is_still_false(self): + from unittest.mock import patch + + import hermes_cli.cron as cron_cli + + with ( + patch("hermes_cli.cron._active_cron_provider_name", return_value="builtin"), + patch("gateway.status.is_gateway_runtime_lock_active", return_value=False), + patch("hermes_cli.gateway.find_gateway_pids", return_value=[]), + patch( + "hermes_cli.gateway.named_profile_served_by_running_multiplexer", + return_value=False, + ), + ): + assert cron_cli._builtin_gateway_liveness() is False + class TestCronStatusLockFirst: """`hermes cron status` shares the lock-first false-alarm fix (#95947). diff --git a/tests/gateway/test_multiplex_lifecycle.py b/tests/gateway/test_multiplex_lifecycle.py index 1809a337d6..417360ab7a 100644 --- a/tests/gateway/test_multiplex_lifecycle.py +++ b/tests/gateway/test_multiplex_lifecycle.py @@ -122,4 +122,18 @@ class TestNamedProfileMultiplexerGuard: gw._guard_named_profile_under_multiplexer(force=False) + def test_named_profile_served_probe_matches_the_start_guard(self, monkeypatch, tmp_path): + self._fake_running_default_gateway(monkeypatch, tmp_path) + (tmp_path / "config.yaml").write_text( + "gateway:\n multiplex_profiles: true\n", + encoding="utf-8", + ) + + from hermes_cli import gateway as gw + + assert gw.named_profile_served_by_running_multiplexer() is True + + monkeypatch.setattr(gw, "_profile_suffix", lambda: "") + assert gw.named_profile_served_by_running_multiplexer() is False +