From ad8f995bcfd39595e2d3254fc9737c26871df69b Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Wed, 19 Aug 2026 21:29:43 +0800 Subject: [PATCH] fix(dashboard): spawn detached actions from the install's venv interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under an SSH remote backend the web server is launched by running the uv BASE interpreter with the venv's site-packages injected into sys.path at startup, so sys.executable is a dependency-less python. Detached dashboard actions spawned from it (Update now, restart, anything routed through _spawn_hermes_action) inherited neither the injected path nor a PYTHONPATH and died on the first third-party import — 'Update now' always failed instantly with ModuleNotFoundError: No module named 'yaml' while 'hermes update' from the venv worked (#90026). _dashboard_spawn_executable now prefers the install's own venv interpreter (venv/bin/python, venv/Scripts/python.exe) when it differs from sys.executable, resolving the same dependency set the venv launcher provides. Same-interpreter launches return sys.executable unchanged, preserving the Windows console-ownership behavior verbatim, and layouts without an install venv keep the old fallback. --- hermes_cli/web_server.py | 31 ++++++++- .../test_dashboard_spawn_executable.py | 69 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_dashboard_spawn_executable.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f0198f2ded..df2985bdee 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4580,13 +4580,38 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non def _dashboard_spawn_executable() -> str: """Interpreter for detached dashboard actions. - Returns ``sys.executable`` on every platform. On Windows the spawn - below carries ``windows_detach_flags()`` (CREATE_NO_WINDOW), so the - console python owns a single hidden console that its own subprocess + Prefers the install's own venv interpreter over ``sys.executable`` when + they differ. Under an SSH remote backend the web server is launched by + running the **uv base interpreter** with the venv's site-packages + injected into ``sys.path`` at startup (``-c "sys.path[:0]=[...]; + runpy.run_module('hermes_cli.main', ...)"``) — so ``sys.executable`` is + the dependency-less base python and a detached action spawned from it + dies on the first third-party import (``ModuleNotFoundError: yaml``), + because the injected path is a startup artifact of the parent and is + not inherited (#90026). The venv launcher resolves the same dependency + set on its own. + + Falls back to ``sys.executable`` when no venv interpreter exists next + to the install (in-process dev runs, exotic layouts). On Windows the + spawn below carries ``windows_detach_flags()`` (CREATE_NO_WINDOW), so + the console python owns a single hidden console that its own subprocess spawns inherit — the action stays invisible without resorting to console-less pythonw.exe, which would make every console-subsystem descendant flash its own conhost (#54220/#56747). """ + exe = Path(sys.executable) + try: + for rel in ("venv/bin/python", "venv/Scripts/python.exe"): + candidate = PROJECT_ROOT / rel + if candidate.is_file(): + resolved = candidate.resolve() + # Same interpreter → keep sys.executable (preserves the + # docstring's console-ownership behavior verbatim). + if resolved == exe.resolve(): + return sys.executable + return str(resolved) + except OSError: + pass return sys.executable diff --git a/tests/hermes_cli/test_dashboard_spawn_executable.py b/tests/hermes_cli/test_dashboard_spawn_executable.py new file mode 100644 index 0000000000..a9780e8743 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_spawn_executable.py @@ -0,0 +1,69 @@ +"""Tests for the detached-dashboard-action interpreter choice (#90026). + +Under an SSH remote backend the web server runs on the **uv base +interpreter** with the venv's site-packages injected into ``sys.path`` at +startup. A detached action spawned from ``sys.executable`` (the base +interpreter) inherits no injected path and no PYTHONPATH, so it dies on the +first third-party import. The spawner must prefer the install's own venv +interpreter when it differs from ``sys.executable``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import hermes_cli.web_server as web_server + + +class TestDashboardSpawnExecutable: + def test_same_interpreter_returns_sys_executable(self, tmp_path): + """When sys.executable IS the venv python (normal launch), the + behavior is unchanged — same path back, verbatim.""" + fake_venv = tmp_path / "venv" / "bin" / "python" + fake_venv.parent.mkdir(parents=True) + fake_venv.touch() + with ( + patch.object(web_server, "PROJECT_ROOT", tmp_path), + patch.object(sys, "executable", str(fake_venv)), + ): + assert web_server._dashboard_spawn_executable() == str(fake_venv) + + def test_base_interpreter_replaced_by_venv_python(self, tmp_path): + """sys.executable pointing at the dependency-less uv base + interpreter (SSH remote backend) resolves to the install's venv + python instead (#90026).""" + fake_venv = tmp_path / "venv" / "bin" / "python" + fake_venv.parent.mkdir(parents=True) + fake_venv.touch() + base_interp = tmp_path / "uv-base" / "python" + with ( + patch.object(web_server, "PROJECT_ROOT", tmp_path), + patch.object(sys, "executable", str(base_interp)), + ): + chosen = web_server._dashboard_spawn_executable() + assert chosen == str(fake_venv) + + def test_windows_layout_resolved(self, tmp_path): + """The Windows venv layout (Scripts/python.exe) is honored.""" + fake_venv = tmp_path / "venv" / "Scripts" / "python.exe" + fake_venv.parent.mkdir(parents=True) + fake_venv.touch() + base_interp = tmp_path / "uv-base" / "python.exe" + with ( + patch.object(web_server, "PROJECT_ROOT", tmp_path), + patch.object(sys, "executable", str(base_interp)), + ): + chosen = web_server._dashboard_spawn_executable() + assert Path(chosen).name == "python.exe" + assert "Scripts" in chosen + + def test_no_venv_falls_back_to_sys_executable(self, tmp_path): + """Exotic layouts without an install venv keep the old behavior.""" + base_interp = tmp_path / "uv-base" / "python" + with ( + patch.object(web_server, "PROJECT_ROOT", tmp_path), + patch.object(sys, "executable", str(base_interp)), + ): + assert web_server._dashboard_spawn_executable() == str(base_interp)