mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 21:59:07 +08:00
Compare commits
1 Commits
claude-cod
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76fa55240d |
@@ -612,13 +612,54 @@ def find_profile_gateway_processes(
|
||||
|
||||
|
||||
def _gateway_run_args_for_profile(profile: str) -> list[str]:
|
||||
args = [get_python_path(), "-m", "hermes_cli.main"]
|
||||
python_exe = get_python_path()
|
||||
if is_windows():
|
||||
# uv-created venv launchers are a trap here: ``venv\Scripts\pythonw.exe``
|
||||
# starts hidden but then re-execs the *base* interpreter as a console
|
||||
# ``python.exe`` — and that re-exec is a fresh CreateProcess that does
|
||||
# NOT inherit our CREATE_NO_WINDOW flag, so a blank console window pops
|
||||
# up. That's exactly what users hit when the gateway is respawned after
|
||||
# a Desktop-GUI ``hermes update``. Resolve the base ``pythonw.exe``
|
||||
# directly — the same path ``_spawn_detached`` / ``_build_gateway_argv``
|
||||
# take for ``hermes gateway start`` — so the post-update respawn is
|
||||
# windowless. The matching VIRTUAL_ENV / PYTHONPATH overlay is applied
|
||||
# to the spawn env in ``launch_detached_profile_gateway_restart`` so
|
||||
# imports still resolve without the venv launcher shim.
|
||||
from hermes_cli.gateway_windows import _resolve_detached_python
|
||||
|
||||
python_exe, _venv_dir, _extra_pythonpath = _resolve_detached_python(python_exe)
|
||||
args = [python_exe, "-m", "hermes_cli.main"]
|
||||
if profile != "default":
|
||||
args.extend(["--profile", profile])
|
||||
args.extend(["gateway", "run", "--replace"])
|
||||
return args
|
||||
|
||||
|
||||
def _gateway_respawn_env(spawn_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Overlay VIRTUAL_ENV / PYTHONPATH so a base-``pythonw.exe`` respawn can
|
||||
import ``hermes_cli`` without the venv launcher shim.
|
||||
|
||||
Returns ``spawn_env`` unchanged on non-Windows so the POSIX spawn path is
|
||||
byte-for-byte identical to the pre-fix behaviour (it inherits ``os.environ``
|
||||
exactly as before). On Windows it mirrors what ``_build_gateway_argv`` does
|
||||
for ``hermes gateway start``: point VIRTUAL_ENV at the venv and prepend the
|
||||
repo root plus base-interpreter site-packages to PYTHONPATH.
|
||||
"""
|
||||
if not is_windows():
|
||||
return spawn_env
|
||||
from hermes_cli.gateway_windows import (
|
||||
_prepend_pythonpath,
|
||||
_resolve_detached_python,
|
||||
)
|
||||
|
||||
_python, venv_dir, extra_pythonpath = _resolve_detached_python(get_python_path())
|
||||
spawn_env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
spawn_env["PYTHONIOENCODING"] = "utf-8"
|
||||
spawn_env["HERMES_GATEWAY_DETACHED"] = "1"
|
||||
_prepend_pythonpath(spawn_env, [str(PROJECT_ROOT), *extra_pythonpath])
|
||||
return spawn_env
|
||||
|
||||
|
||||
def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
"""Relaunch a manually-run profile gateway after its current PID exits."""
|
||||
if old_pid <= 0:
|
||||
@@ -703,14 +744,33 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
"""
|
||||
).strip()
|
||||
|
||||
# Resolve the watcher interpreter to the windowless base ``pythonw.exe``
|
||||
# on Windows for the same reason as the respawned gateway (see
|
||||
# ``_gateway_run_args_for_profile``): ``sys.executable`` during a
|
||||
# GUI-driven ``hermes update`` is a console ``python.exe`` whose uv venv
|
||||
# launcher re-execs a visible console. ``_resolve_detached_python`` is a
|
||||
# no-op shape on non-Windows callers because we only consult it under the
|
||||
# ``is_windows()`` guard below.
|
||||
watcher_python = sys.executable
|
||||
if is_windows():
|
||||
from hermes_cli.gateway_windows import _resolve_detached_python
|
||||
|
||||
watcher_python, _wv, _wpp = _resolve_detached_python(sys.executable)
|
||||
|
||||
watcher_argv = [
|
||||
sys.executable,
|
||||
watcher_python,
|
||||
"-c",
|
||||
watcher,
|
||||
str(old_pid),
|
||||
*_gateway_run_args_for_profile(profile),
|
||||
]
|
||||
|
||||
# The watcher inherits this env and the respawned gateway inherits it from
|
||||
# the watcher, so the base-``pythonw.exe`` legs can import ``hermes_cli``
|
||||
# without the venv launcher shim. No-op on POSIX (returns os.environ copy
|
||||
# unchanged), preserving the pre-fix spawn behaviour bit-for-bit there.
|
||||
spawn_env = _gateway_respawn_env(dict(os.environ))
|
||||
|
||||
# Same platform-aware detach for the watcher process itself — so
|
||||
# closing the user's terminal doesn't kill the watcher.
|
||||
try:
|
||||
@@ -718,6 +778,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=spawn_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
except OSError:
|
||||
@@ -736,6 +797,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=spawn_env,
|
||||
**fallback_kwargs,
|
||||
)
|
||||
except OSError:
|
||||
|
||||
163
tests/hermes_cli/test_gateway_update_respawn_pythonw.py
Normal file
163
tests/hermes_cli/test_gateway_update_respawn_pythonw.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Regression tests for the post-update gateway respawn interpreter on Windows.
|
||||
|
||||
Background
|
||||
----------
|
||||
When the Desktop GUI runs ``hermes update``, it spawns the post-update
|
||||
respawn watcher via
|
||||
``hermes_cli.gateway.launch_detached_profile_gateway_restart``. That watcher
|
||||
polls the old gateway PID and, once it exits, respawns the gateway using the
|
||||
argv built by ``_gateway_run_args_for_profile``.
|
||||
|
||||
The bug: that argv used ``get_python_path()`` — the *console* ``python.exe``.
|
||||
For uv-created venvs, even ``venv\\Scripts\\pythonw.exe`` re-execs the base
|
||||
interpreter as a console ``python.exe`` (the re-exec is a fresh CreateProcess
|
||||
that does NOT inherit ``CREATE_NO_WINDOW``), so a blank console window pops up
|
||||
after every GUI-driven update. ``hermes gateway start`` avoided this by going
|
||||
through ``_resolve_detached_python`` to get the *base* ``pythonw.exe`` plus a
|
||||
``VIRTUAL_ENV`` / ``PYTHONPATH`` overlay; the post-update respawn path never
|
||||
got the same treatment.
|
||||
|
||||
These tests lock in:
|
||||
* Windows respawn argv resolves to the base ``pythonw.exe`` (not the venv
|
||||
``Scripts`` shim, not console ``python.exe``).
|
||||
* The respawn env carries the matching ``VIRTUAL_ENV`` / ``PYTHONPATH`` so a
|
||||
base-interpreter respawn can still import ``hermes_cli``.
|
||||
* POSIX behaviour is byte-for-byte unchanged (argv keeps ``sys.executable``'s
|
||||
resolved path; env overlay is a no-op).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gateway as gateway
|
||||
import hermes_cli.gateway_windows as gateway_windows
|
||||
|
||||
|
||||
def _make_uv_venv(tmp_path: Path) -> dict[str, Path]:
|
||||
"""Fabricate a uv-style venv layout: venv Scripts python(w).exe + a base
|
||||
interpreter referenced by pyvenv.cfg's ``home`` with its own pythonw.exe."""
|
||||
project = tmp_path / "project"
|
||||
scripts = project / "venv" / "Scripts"
|
||||
site_packages = project / "venv" / "Lib" / "site-packages"
|
||||
base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
|
||||
for directory in (scripts, site_packages, base):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
venv_python = scripts / "python.exe"
|
||||
venv_pythonw = scripts / "pythonw.exe"
|
||||
base_pythonw = base / "pythonw.exe"
|
||||
for exe in (venv_python, venv_pythonw, base_pythonw):
|
||||
exe.write_text("", encoding="utf-8")
|
||||
(project / "venv" / "pyvenv.cfg").write_text(
|
||||
f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {
|
||||
"project": project,
|
||||
"venv_python": venv_python,
|
||||
"venv_pythonw": venv_pythonw,
|
||||
"base_pythonw": base_pythonw,
|
||||
"site_packages": site_packages,
|
||||
}
|
||||
|
||||
|
||||
class TestRespawnArgvUsesBasePythonw:
|
||||
def test_windows_uv_venv_resolves_base_pythonw(self, tmp_path, monkeypatch):
|
||||
"""The respawn argv must use the base pythonw.exe for a uv venv,
|
||||
never the venv Scripts shim or console python.exe."""
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
|
||||
argv = gateway._gateway_run_args_for_profile("default")
|
||||
|
||||
assert argv[0] == str(layout["base_pythonw"])
|
||||
assert argv[0] != str(layout["venv_python"])
|
||||
assert argv[0] != str(layout["venv_pythonw"])
|
||||
assert argv[1:] == ["-m", "hermes_cli.main", "gateway", "run", "--replace"]
|
||||
|
||||
def test_windows_non_default_profile_keeps_profile_arg(self, tmp_path, monkeypatch):
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
|
||||
argv = gateway._gateway_run_args_for_profile("work")
|
||||
|
||||
assert argv[0] == str(layout["base_pythonw"])
|
||||
assert argv[1:] == [
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"--profile",
|
||||
"work",
|
||||
"gateway",
|
||||
"run",
|
||||
"--replace",
|
||||
]
|
||||
|
||||
|
||||
class TestRespawnEnvOverlay:
|
||||
def test_windows_overlay_sets_virtualenv_and_pythonpath(self, tmp_path, monkeypatch):
|
||||
"""A base-pythonw respawn needs VIRTUAL_ENV + PYTHONPATH so imports
|
||||
resolve without the venv launcher shim."""
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
|
||||
env = gateway._gateway_respawn_env({})
|
||||
|
||||
assert env["VIRTUAL_ENV"] == str(layout["project"] / "venv")
|
||||
assert env["HERMES_GATEWAY_DETACHED"] == "1"
|
||||
assert "PYTHONPATH" in env
|
||||
# Repo root and the base-interpreter site-packages must both be on it.
|
||||
assert str(gateway.PROJECT_ROOT) in env["PYTHONPATH"]
|
||||
assert str(layout["site_packages"]) in env["PYTHONPATH"]
|
||||
|
||||
def test_windows_overlay_prepends_to_existing_pythonpath(self, tmp_path, monkeypatch):
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
monkeypatch.setenv("PYTHONPATH", "/preexisting/entry")
|
||||
|
||||
env = gateway._gateway_respawn_env({})
|
||||
|
||||
assert env["PYTHONPATH"].endswith("/preexisting/entry")
|
||||
assert str(gateway.PROJECT_ROOT) in env["PYTHONPATH"]
|
||||
|
||||
|
||||
class TestPosixUnchanged:
|
||||
"""POSIX must be byte-for-byte identical to the pre-fix behaviour."""
|
||||
|
||||
def test_posix_argv_uses_get_python_path_verbatim(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: "/usr/bin/python3")
|
||||
|
||||
argv = gateway._gateway_run_args_for_profile("default")
|
||||
|
||||
assert argv == [
|
||||
"/usr/bin/python3",
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"gateway",
|
||||
"run",
|
||||
"--replace",
|
||||
]
|
||||
|
||||
def test_posix_env_overlay_is_noop(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
original = {"PATH": "/usr/bin", "FOO": "bar"}
|
||||
|
||||
result = gateway._gateway_respawn_env(dict(original))
|
||||
|
||||
assert result == original
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")
|
||||
class TestLiveWindowsRespawn:
|
||||
"""On a real Windows host the resolver should pick a *windowed* interpreter
|
||||
(pythonw) for the running gateway's own venv, with no console flag needed."""
|
||||
|
||||
def test_resolved_interpreter_is_windowless(self):
|
||||
argv = gateway._gateway_run_args_for_profile("default")
|
||||
assert argv[0].lower().endswith("pythonw.exe")
|
||||
Reference in New Issue
Block a user