fix(dashboard): spawn detached actions from the install's venv interpreter
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user