fix(install): scrap rebuild venv

This commit is contained in:
ethernet
2026-06-04 23:20:29 -04:00
parent 96cd37e212
commit fb853a1783
7 changed files with 23 additions and 433 deletions
+6 -73
View File
@@ -8029,20 +8029,12 @@ def _update_via_zip(args):
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
raise RuntimeError(
"venv rebuild failed; aborting update before dependency install"
)
uv_bin = ensure_uv()
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
@@ -8697,48 +8689,6 @@ 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 .``.
@@ -10133,7 +10083,7 @@ def _cmd_update_pip(args):
# Keep managed uv current before using it.
update_managed_uv()
uv, _fresh_bootstrap = ensure_uv()
uv = ensure_uv()
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
@@ -10575,20 +10525,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
raise RuntimeError(
"venv rebuild failed; aborting update before dependency install"
)
uv_bin = ensure_uv()
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
@@ -10651,18 +10593,13 @@ 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...")
# 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():
if build_result.returncode != 0:
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)")
@@ -10719,10 +10656,6 @@ 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)
+6 -104
View File
@@ -6,11 +6,6 @@ If the binary is missing, ``ensure_uv()`` bootstraps it via the official
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
probing, no conda guards, no multi-location resolution chains.
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
managed uv before), it returns ``(path, True)`` instead of just ``path``.
Callers in the update path use that signal to nuke and recreate the venv
with the now-current managed uv, guaranteeing a Python with FTS5.
"""
from __future__ import annotations
@@ -22,7 +17,7 @@ import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, Tuple
from typing import Optional
from hermes_constants import get_hermes_home
@@ -56,20 +51,15 @@ def resolve_uv() -> Optional[str]:
return None
def ensure_uv() -> Tuple[Optional[str], bool]:
def ensure_uv() -> Optional[str]:
"""Return the managed uv path, installing it first if necessary.
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
``True`` when we just installed managed uv for the first time (there was
no managed uv before this call). Callers can use that signal to rebuild
the venv so Python is guaranteed to have FTS5.
On failure returns ``(None, False)`` (never raises) so callers can fall
On failure returns ``None`` (never raises) so callers can fall
back to pip gracefully.
"""
existing = resolve_uv()
if existing:
return (existing, False)
return existing
target = managed_uv_path()
target.parent.mkdir(parents=True, exist_ok=True)
@@ -81,7 +71,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]:
except Exception as exc:
logger.warning("Managed uv install failed: %s", exc)
print(f" ✗ Failed to install managed uv: {exc}")
return (None, False)
return None
# Verify
result = resolve_uv()
@@ -95,95 +85,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]:
print(f" ✓ Managed uv installed ({version})")
else:
print(" ✗ Managed uv install appeared to succeed but binary not found")
return (result, result is not None)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
"""Nuke and recreate the venv with managed uv.
Called when managed uv is first bootstrapped on an existing install — the
old venv may point to a Python without FTS5, so we rebuild it with a
fresh interpreter from the current managed uv. Returns ``True`` on
success.
The old venv is moved aside *atomically* (``os.replace`` to ``<venv>.old``)
before recreating — never deleted in place. On Windows a still-running
``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open;
``shutil.rmtree(ignore_errors=True)`` would delete everything it *can*
(site-packages, certifi's cert bundle) and silently leave a half-gutted
venv that the following ``uv venv`` then refuses to overwrite ("directory
already exists") — bricking the install with no recovery (every later HTTPS
call dies with ``FileNotFoundError`` for the missing cert bundle).
``--clear`` alone does not fix this: when the locked interpreter is *inside*
the venv being rebuilt, neither ``rmtree`` nor ``uv venv --clear`` can
delete the held ``python.exe``. ``os.replace`` of the parent directory *is*
allowed (Windows tracks a running ``.exe`` by handle, not path), so the
rebuild completes while the running process keeps using the moved-aside copy
until it restarts. If the venv genuinely cannot be moved, we abort cleanly
and leave it fully intact; and if the rebuild itself fails we move the old
venv back so Hermes is never left with no venv at all.
"""
backup: Optional[Path] = None
if venv_dir.exists():
print(f" → Rebuilding venv (old Python may lack FTS5)...")
backup = venv_dir.with_name(venv_dir.name + ".old")
shutil.rmtree(backup, ignore_errors=True) # clear any stale backup
try:
# Atomic move — fails (without partial deletion) if a process still
# holds files inside the venv, which is exactly the Windows
# file-lock case that previously bricked the install.
os.replace(venv_dir, backup)
except OSError as exc:
logger.warning("venv rebuild aborted — venv in use: %s", exc)
print(
" ✗ venv rebuild aborted — the venv is in use; stop the "
f"gateway/desktop and retry ({exc})"
)
return False
result = subprocess.run(
[uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"],
capture_output=True,
text=True,
check=False,
)
def _restore_backup() -> None:
if backup is not None and backup.exists():
shutil.rmtree(venv_dir, ignore_errors=True)
try:
os.replace(backup, venv_dir)
print(" ↩ Restored previous venv after failed rebuild.")
except OSError:
pass
if result.returncode == 0:
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
# uv can exit 0 yet leave no usable interpreter (e.g. a half-written
# venv). Don't report success on a venv that has no python — restore the
# moved-aside copy so the caller can abort without losing a working env.
if not venv_python.exists():
logger.warning("venv rebuild reported success but %s is missing", venv_python)
print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}")
_restore_backup()
return False
if backup is not None:
shutil.rmtree(backup, ignore_errors=True)
py_ver = subprocess.run(
[str(venv_python), "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ venv rebuilt ({py_ver})")
return True
else:
# Rebuild failed — restore the old venv so we never leave Hermes with no
# venv (the bricked-install failure mode this function exists to avoid).
_restore_backup()
logger.warning("venv rebuild failed: %s", result.stderr)
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
return False
return result
def update_managed_uv() -> Optional[str]: