Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 .``.

Expand Down Expand Up @@ -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)")

Expand Down Expand Up @@ -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)
Expand Down
83 changes: 83 additions & 0 deletions tests/hermes_cli/test_update_venv_ready.py
Original file line number Diff line number Diff line change
@@ -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
Loading