From c9c9b17d982d3bd736c41026441a16325fe7afda Mon Sep 17 00:00:00 2001 From: webtecnica <75556242+webtecnica@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:25:44 -0700 Subject: [PATCH] fix(web): resolve per-profile gateway state for ?profile= in /api/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ?profile= was passed to /api/status, the handler used _config_profile_scope to set the HERMES_HOME contextvar override, but the gateway liveness check (get_running_pid_cached) and runtime status read (read_runtime_status) both resolve _get_process_hermes_home(), which deliberately ignores contextvar overrides (issue #56986) — it always reads os.environ['HERMES_HOME'] or the platform default. A named profile's gateway identity files (~/.hermes/profiles//gateway.pid, gateway_state.json) were therefore never found and the endpoint always reported the profile's gateway as stopped. Fix: when ?profile= is requested, resolve the profile directory and pass explicit profile-scoped paths: - get_running_pid_cached(pid_path=profile_dir / 'gateway.pid') - read_runtime_status(path=profile_dir / 'gateway_state.json') - get_runtime_status_running_pid(..., expected_home=profile_dir) This is the same explicit-path pattern _collect_profile_gateway_topology already uses for per-profile gateway state, and it works within the #56986 constraint (no HERMES_HOME env mutation; read-only cross-profile access). Plain /api/status without ?profile= keeps the exact zero-arg calls, so its behavior — including the pid-cache signature and runtime-status fallback — is byte-for-byte unchanged. Fixes #69143 --- hermes_cli/web_server.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f346078ff81ab..1e5cf16551782 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3049,7 +3049,9 @@ async def get_status(profile: Optional[str] = None): # skills-module attributes that a concurrent request would cross-restore # across that await. Status only resolves get_hermes_home() at call time # (config/env/gateway state), which the task-local contextvar covers. + profile_dir: Optional[Path] = None if requested_profile and requested_profile.lower() != "current": + profile_dir = _resolve_profile_dir(requested_profile) status_scope = _config_profile_scope(requested_profile) status_scope.__enter__() @@ -3059,7 +3061,17 @@ async def get_status(profile: Optional[str] = None): # Try local PID check first (same-host). If that fails and a remote # GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the # dashboard works when the gateway runs in a separate container. - gateway_pid = get_running_pid_cached() + # + # When ?profile= was given, scope PID and state reads to that + # profile's directory — gateway identity files (PID, lock, runtime + # status) are written to the per-profile home, not the process-level + # HERMES_HOME (see issue #69143). Plain /api/status keeps the exact + # zero-arg call so its behavior (and cache signature) is unchanged. + gateway_pid = ( + get_running_pid_cached(pid_path=profile_dir / "gateway.pid") + if profile_dir + else get_running_pid_cached() + ) gateway_running = gateway_pid is not None remote_health_body: dict | None = None @@ -3099,7 +3111,17 @@ async def get_status(profile: Optional[str] = None): # Prefer the detailed health endpoint response (has full state) when the # local runtime status file is absent or stale (cross-container). - local_runtime = read_runtime_status() + # + # When ?profile= was given, read from the profile's directory so + # the state file resolves to the per-profile gateway_state.json, not + # the fixed process-level HERMES_HOME (see issue #69143). Plain + # /api/status keeps the exact zero-arg call so existing behavior + # (and monkeypatched call shapes) are unchanged. + local_runtime = ( + read_runtime_status(path=profile_dir / "gateway_state.json") + if profile_dir + else read_runtime_status() + ) runtime = local_runtime if runtime is None and remote_health_body and remote_health_body.get("gateway_state"): runtime = remote_health_body @@ -3109,7 +3131,16 @@ async def get_status(profile: Optional[str] = None): # is display-only. (Running os.kill on a remote PID is both wrong and # trips the test live-system guard.) if not gateway_running and local_runtime is not None: - runtime_pid = get_runtime_status_running_pid(local_runtime) + # expected_home scopes the OS-identity check to the requested + # profile so a recycled PID belonging to a different profile's + # live gateway is not reported running for this one. + runtime_pid = ( + get_runtime_status_running_pid( + local_runtime, expected_home=profile_dir + ) + if profile_dir + else get_runtime_status_running_pid(local_runtime) + ) if runtime_pid is not None: gateway_running = True gateway_pid = runtime_pid