diff --git a/gateway/status.py b/gateway/status.py index 9b8a1b6f83c..d041193fdf0 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -20,7 +20,7 @@ import subprocess import sys from datetime import datetime, timezone from pathlib import Path -from hermes_constants import get_hermes_home +from hermes_constants import get_hermes_home, _get_platform_default_hermes_home from typing import Any, Optional from utils import atomic_json_write @@ -42,9 +42,25 @@ _gateway_lock_handle = None _WINDOWS_LOCK_OFFSET = 1024 * 1024 +def _get_process_hermes_home() -> Path: + """Return the process-level HERMES_HOME, skipping context-local overrides. + + Gateway identity files (PID, lock, runtime status, takeover/stop markers) + must always live in the directory the gateway process was launched with. + ``get_hermes_home()`` honors ``_HERMES_HOME_OVERRIDE`` contextvar used for + per-session profile dispatch, which would route these files into the wrong + profile directory when a profile-context task happens to be active at write + time. See issue #56986. + """ + val = os.environ.get("HERMES_HOME", "").strip() + if val: + return Path(val) + return _get_platform_default_hermes_home() + + def _get_pid_path() -> Path: """Return the path to the gateway PID file, respecting HERMES_HOME.""" - home = get_hermes_home() + home = _get_process_hermes_home() return home / "gateway.pid" @@ -52,7 +68,7 @@ def _get_gateway_lock_path(pid_path: Optional[Path] = None) -> Path: """Return the path to the runtime gateway lock file.""" if pid_path is not None: return pid_path.with_name(_GATEWAY_LOCK_FILENAME) - home = get_hermes_home() + home = _get_process_hermes_home() return home / _GATEWAY_LOCK_FILENAME @@ -1136,13 +1152,13 @@ _PLANNED_STOP_MARKER_TTL_S = 60 def _get_takeover_marker_path() -> Path: """Return the path to the --replace takeover marker file.""" - home = get_hermes_home() + home = _get_process_hermes_home() return home / _TAKEOVER_MARKER_FILENAME def _get_planned_stop_marker_path() -> Path: """Return the path to the intentional gateway stop marker file.""" - home = get_hermes_home() + home = _get_process_hermes_home() return home / _PLANNED_STOP_MARKER_FILENAME @@ -1197,7 +1213,7 @@ def _consume_pid_marker_for_self( # unaffected. Leave a mismatched marker in place so the correct # profile can still consume it. replacer_home = record.get("replacer_hermes_home") - if replacer_home is not None and replacer_home != str(get_hermes_home()): + if replacer_home is not None and replacer_home != str(_get_process_hermes_home()): return False our_pid = os.getpid() @@ -1246,7 +1262,7 @@ def write_takeover_marker(target_pid: int) -> bool: "target_pid": target_pid, "target_start_time": target_start_time, "replacer_pid": os.getpid(), - "replacer_hermes_home": str(get_hermes_home()), + "replacer_hermes_home": str(_get_process_hermes_home()), "written_at": _utc_now_iso(), } _write_json_file(_get_takeover_marker_path(), record) diff --git a/scripts/release.py b/scripts/release.py index 5537aafdb84..ba098a9f10f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,6 +53,7 @@ AUTHOR_MAP = { "allenliang2022@users.noreply.github.com": "allenliang2022", # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout) "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) "austin@openvm067.space": "austinlaw076", # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set) + "sunsky.lau@gmail.com": "liuhao1024", # PR #56993 salvage (gateway: process-level HERMES_HOME for pid/lock/status identity files; #56986) "blueirobin02@gmail.com": "irresi", # PR #59048 salvage (gateway: scope reset banners' session info to the serving profile; #59003) "jashlee+microsoft@microsoft.com": "s905060", # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169) "lohinth25@proton.me": "l0h1nth", # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184) diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index ab4c9415743..780bf8efc51 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -293,6 +293,41 @@ class TestGatewayPidState: finally: status.release_gateway_runtime_lock() + def test_gateway_identity_files_use_process_home_not_context_override( + self, tmp_path, monkeypatch + ): + """Regression: pid/lock/state files must use process-level HERMES_HOME. + + When a profile context override is active (e.g., during session dispatch + for a named profile), gateway identity files should still be written to + the process-level HERMES_HOME, not the profile's directory. See #56986. + """ + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + + process_home = tmp_path / "default" + process_home.mkdir() + profile_home = tmp_path / "profiles" / "cfo" + profile_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(process_home)) + + # Simulate a profile context override being active during write. + token = set_hermes_home_override(str(profile_home)) + try: + status.write_pid_file() + finally: + reset_hermes_home_override(token) + + # PID file must land in the process-level home, not the profile home. + assert (process_home / "gateway.pid").exists() + assert not (profile_home / "gateway.pid").exists() + + payload = json.loads((process_home / "gateway.pid").read_text()) + assert payload["pid"] == os.getpid() + + # Cleanup for atexit hooks. + monkeypatch.setenv("HERMES_HOME", str(process_home)) + (process_home / "gateway.pid").unlink(missing_ok=True) + class TestGatewayRuntimeStatus: def test_write_json_file_uses_atomic_json_write(self, tmp_path, monkeypatch):