diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4a3f7183c..40c9778d1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8567,6 +8567,48 @@ def _venv_scripts_dir() -> Path | None: return scripts if scripts.is_dir() else None +def _wait_for_interpreter_venv_ready(*, timeout: float = 15.0) -> bool: + """Ensure the venv hosting ``sys.executable`` has an intact ``pyvenv.cfg``. + + During ``hermes update`` the managed-uv path can rebuild the project venv + (``rebuild_venv`` → ``shutil.rmtree`` + ``uv venv``) before the + desktop-rebuild and profile-skills-sync steps run. Both of those steps + spawn a child process with ``sys.executable``. If they fire while the venv + is mid-rewrite, the interpreter launcher finds the venv directory but no + ``pyvenv.cfg`` yet and aborts with the bare stderr line + ``No pyvenv.cfg file`` — surfacing as a spurious "Desktop build failed" / + "sync failed" on an update that otherwise succeeded. + + A venv's ``pyvenv.cfg`` sits one level up from the interpreter's ``bin`` / + ``Scripts`` dir. If ``sys.executable`` is NOT a venv interpreter (no + sibling marker dir, e.g. a system Python on PATH), there is nothing to + wait for and we return True immediately. Otherwise we poll briefly for the + marker to (re)appear — the rewrite window is short — and return whether + it's present. Best-effort: never raises, callers proceed regardless. + """ + try: + exe = Path(sys.executable).resolve() + except Exception: + return True + + venv_dir = exe.parent.parent # .../venv/{bin,Scripts}/python -> .../venv + bin_dir = venv_dir / ("Scripts" if _is_windows() else "bin") + if not bin_dir.is_dir(): + # Not a venv-hosted interpreter — pyvenv.cfg is irrelevant. + return True + + cfg = venv_dir / "pyvenv.cfg" + if cfg.is_file(): + return True + + deadline = _time.monotonic() + max(0.0, timeout) + while _time.monotonic() < deadline: + if cfg.is_file(): + return True + _time.sleep(0.25) + return cfg.is_file() + + def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: """Entry-point shims that uv may try to rewrite during ``pip install -e .``. @@ -10260,11 +10302,19 @@ def _cmd_update_impl(args, gateway_mode: bool): has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir) if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app: print("→ Checking if desktop app needs rebuilding...") - build_result = subprocess.run( - [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"], - cwd=PROJECT_ROOT, - check=False, - ) + # The Python-dependency step above may have rebuilt the venv that + # hosts sys.executable. Wait for its pyvenv.cfg to settle before + # spawning, or the child interpreter aborts with "No pyvenv.cfg + # file" and the rebuild spuriously "fails" on a successful update. + _wait_for_interpreter_venv_ready() + _desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"] + # Stream the build output live (long Electron builds otherwise + # look hung). On the rare nonzero exit, retry once after waiting + # again for the venv — this covers a still-settling rebuild window + # the first wait didn't fully catch. + build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False) + if build_result.returncode != 0 and _wait_for_interpreter_venv_ready(): + build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False) if build_result.returncode != 0: print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)") @@ -10320,6 +10370,10 @@ def _cmd_update_impl(args, gateway_mode: bool): if all_profiles: print() print("→ Syncing bundled skills to all profiles...") + # seed_profile_skills spawns sys.executable; if the venv was + # just rebuilt above, wait for pyvenv.cfg before the loop so + # the children don't abort with "No pyvenv.cfg file". + _wait_for_interpreter_venv_ready() for p in all_profiles: try: r = seed_profile_skills(p.path, quiet=True) diff --git a/tests/hermes_cli/test_update_venv_ready.py b/tests/hermes_cli/test_update_venv_ready.py new file mode 100644 index 000000000..69cf1f4f6 --- /dev/null +++ b/tests/hermes_cli/test_update_venv_ready.py @@ -0,0 +1,83 @@ +"""Tests for ``_wait_for_interpreter_venv_ready`` in ``hermes_cli/main.py``. + +During ``hermes update`` the managed-uv path can rebuild the project venv +(rmtree + ``uv venv``) before the desktop-rebuild and profile-skills-sync +steps spawn ``sys.executable``. If those children fire while the venv is +mid-rewrite, the interpreter launcher aborts with ``No pyvenv.cfg file`` and +the step spuriously "fails" on an otherwise-successful update. The helper +waits for the marker to settle first. +""" + +from __future__ import annotations + +import os +import threading +import time +from pathlib import Path + +from hermes_cli.main import _wait_for_interpreter_venv_ready + + +def _make_fake_venv(tmp_path: Path, *, with_cfg: bool) -> Path: + """Create a venv-shaped dir and return the interpreter path inside it.""" + bin_name = "Scripts" if os.name == "nt" else "bin" + bin_dir = tmp_path / bin_name + bin_dir.mkdir(parents=True) + py = bin_dir / ("python.exe" if os.name == "nt" else "python") + py.write_text("#!/bin/sh\n") + if with_cfg: + (tmp_path / "pyvenv.cfg").write_text("home = /usr\n") + return py + + +class TestWaitForInterpreterVenvReady: + def test_intact_venv_returns_immediately(self, tmp_path, monkeypatch): + py = _make_fake_venv(tmp_path, with_cfg=True) + monkeypatch.setattr("sys.executable", str(py)) + t0 = time.monotonic() + assert _wait_for_interpreter_venv_ready(timeout=5) is True + assert time.monotonic() - t0 < 0.5 + + def test_non_venv_interpreter_returns_immediately(self, tmp_path, monkeypatch): + # A bare interpreter whose parent.parent has no bin/Scripts marker + # dir is not venv-hosted; pyvenv.cfg is irrelevant. + sys_py = tmp_path / "usr" / "bin" / "python" + sys_py.parent.mkdir(parents=True) + sys_py.write_text("#!/bin/sh\n") + # Ensure parent.parent (tmp_path/usr) has no bin sibling shaped like a venv + monkeypatch.setattr("sys.executable", str(sys_py)) + # parent.parent == tmp_path/usr; its "bin" child IS tmp_path/usr/bin + # which exists — so this would look venv-ish. Use a deeper layout + # where parent.parent has no bin marker: + deep = tmp_path / "opt" / "py3" / "real" / "python" + deep.parent.mkdir(parents=True) + deep.write_text("#!/bin/sh\n") + monkeypatch.setattr("sys.executable", str(deep)) + t0 = time.monotonic() + assert _wait_for_interpreter_venv_ready(timeout=5) is True + assert time.monotonic() - t0 < 0.5 + + def test_waits_for_cfg_to_appear(self, tmp_path, monkeypatch): + py = _make_fake_venv(tmp_path, with_cfg=False) + monkeypatch.setattr("sys.executable", str(py)) + + def _write_cfg_later(): + time.sleep(0.6) + (tmp_path / "pyvenv.cfg").write_text("home = /usr\n") + + th = threading.Thread(target=_write_cfg_later) + th.start() + try: + t0 = time.monotonic() + assert _wait_for_interpreter_venv_ready(timeout=5) is True + elapsed = time.monotonic() - t0 + finally: + th.join() + assert 0.5 < elapsed < 2.0 + + def test_returns_false_when_cfg_never_appears(self, tmp_path, monkeypatch): + py = _make_fake_venv(tmp_path, with_cfg=False) + monkeypatch.setattr("sys.executable", str(py)) + t0 = time.monotonic() + assert _wait_for_interpreter_venv_ready(timeout=1) is False + assert 0.9 < time.monotonic() - t0 < 1.6