fix(update): don't fail desktop rebuild / skills sync on mid-rebuild venv (#38885)

When 'hermes update' rebuilds the project venv (rmtree + uv venv on the
first managed-uv migration), the desktop-rebuild and profile-skills-sync
steps that follow both spawn sys.executable. Firing while the venv is
mid-rewrite makes the child interpreter abort with the bare stderr line
'No pyvenv.cfg file', surfacing as a spurious 'Desktop build failed' /
'default: sync failed' on an update that actually succeeded.

Add _wait_for_interpreter_venv_ready(): resolve the venv hosting
sys.executable and poll briefly for pyvenv.cfg to (re)appear before each
of those subprocess steps. No-op when the interpreter isn't venv-hosted.
The desktop rebuild also retries once after re-waiting, and keeps
streaming its output live (no capture). Best-effort throughout — callers
proceed regardless, so a genuinely broken venv still surfaces the real
error.
This commit is contained in:
Teknium 2026-06-04 02:20:11 -07:00 committed by GitHub
parent bd12b3c232
commit 4ed63170e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 142 additions and 5 deletions

View file

@ -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)

View file

@ -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