fix(windows): unbreak install + update on Windows (#23394)

Three issues hit during a fresh Windows install + first `hermes update`:

1. `pyproject.toml` re-introduced the invalid `exclude-newer = "7 days"`
   under [tool.uv]. uv requires an RFC 3339 / ISO date — relative-duration
   strings parse-fail. The line was removed in PR #21221 on May 7 and
   accidentally added back in the v0.13.0 release commit (498bfc7bc1)
   the same day. Every uv invocation throughout install logged a TOML
   parse error, confusing users into thinking the install was broken.
   Fix: remove the line (and the now-empty [tool.uv] section).

2. `hermes update` failed on Windows with
   `Access is denied. (os error 5)` when uv tried to overwrite
   `venv\\Scripts\\hermes.exe` — the running entry-point shim. Windows
   blocks REPLACE on a mapped/loaded executable but allows RENAME (kernel
   tracks the file by handle, not path; same trick Chrome/Firefox use for
   self-update). Pre-rename live shims to `hermes.exe.old.<unix-ms>`
   before each `uv pip install -e .`; uv writes a fresh shim at the
   original path; the .old files are swept on the next hermes invocation.
   Wraps every install attempt (primary, base-only fallback, and
   per-extra retries). Restores shims if uv fails before writing
   replacements.

3. Tools post-setup hooks (ddgs, piper-tts, kittentts, langfuse,
   tinker-atropos) shelled out to `[sys.executable, '-m', 'pip', ...]`
   and died with `No module named pip` on every fresh Windows install.
   install.ps1 creates the venv via `uv venv` which doesn't seed pip;
   install.ps1 bootstraps pip later, but only inside the platform-SDK
   verify block — by then the wizard's post-setup hooks have already
   run and failed.

   New `_pip_install` helper tries uv pip first (works in pip-less
   venvs), then python -m pip, then ensurepip-bootstrap-then-pip. All
   five post-setup sites now route through it.

E2E:
- uv pip compile pyproject.toml — no parse warning
- quarantine + cleanup with simulated Windows scripts dir; rollback
  works when uv install fails before writing replacement shim
- _pip_install in a real `uv venv`-created (pip-less) venv: bootstraps
  pip via ensurepip and completes the install

Tests: tests/hermes_cli/ — 4135 pass, 8 pre-existing failures on main
unrelated to this PR (kanban_boards, openclaw_migration,
update_gateway_restart, web_server PluginAPIAuth).
This commit is contained in:
Teknium 2026-05-10 13:07:08 -07:00 committed by GitHub
parent 00ce5f04d9
commit 4d9dcbc47a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 213 additions and 57 deletions

View file

@ -6603,6 +6603,103 @@ def _run_install_with_heartbeat(
t.join(timeout=0.2)
def _is_windows() -> bool:
return sys.platform == "win32"
def _venv_scripts_dir() -> Path | None:
"""Return the venv Scripts directory if we're running inside the project venv."""
venv_dir = PROJECT_ROOT / "venv"
if not venv_dir.is_dir():
return None
scripts = venv_dir / ("Scripts" if _is_windows() else "bin")
return scripts if scripts.is_dir() else None
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
"""Entry-point shims that uv may try to rewrite during ``pip install -e .``.
On Windows these are .exe launchers generated by setuptools/uv. On POSIX
they're regular Python scripts which can be replaced atomically — no
self-replacement hazard exists outside Windows.
"""
if not _is_windows():
return []
return [
scripts_dir / "hermes.exe",
scripts_dir / "hermes-gateway.exe",
]
def _quarantine_running_hermes_exe(scripts_dir: Path) -> list[tuple[Path, Path]]:
"""Pre-empt Windows file lock on the running ``hermes.exe``.
Windows allows RENAMING a mapped/running executable (the kernel tracks the
file by handle, not path), but blocks DELETE/REPLACE while it's loaded. uv
needs to overwrite the entry-point shims during ``pip install -e .``;
when ``hermes update`` runs, ``hermes.exe`` IS the live process, and uv
fails with ``Access is denied. (os error 5)``.
We rename live shims to ``hermes.exe.old.<unix-ms>`` first. uv then writes
fresh shims at the original paths. The ``.old`` files are cleaned up on
the next hermes invocation by ``_cleanup_quarantined_exes``.
Returns the list of (original, quarantined) pairs so the caller can roll
back if the install itself fails before uv writes a replacement.
"""
moved: list[tuple[Path, Path]] = []
if not _is_windows():
return moved
import time
stamp = int(time.time() * 1000)
for shim in _hermes_exe_shims(scripts_dir):
if not shim.exists():
continue
target = shim.with_suffix(shim.suffix + f".old.{stamp}")
try:
shim.rename(target)
moved.append((shim, target))
except OSError as e:
# Best-effort: keep going. uv's failure later will surface the
# real error; this is a heuristic, not a hard guarantee.
print(f" ⚠ Could not quarantine {shim.name}: {e}")
return moved
def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
"""Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements."""
for original, quarantined in moved:
try:
if not original.exists() and quarantined.exists():
quarantined.rename(original)
except OSError:
pass
def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
"""Sweep ``hermes.exe.old.*`` left by prior updates.
Called early on every hermes invocation. The .old files are unlocked once
their owning process exited, so deletion succeeds the next run. Silent
no-op when nothing's there or on file-locked / permission errors.
"""
if not _is_windows():
return
if scripts_dir is None:
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return
try:
for stale in scripts_dir.glob("*.exe.old.*"):
try:
stale.unlink()
except OSError:
pass # still locked or in use — try again next run
except OSError:
pass
def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
@ -6613,31 +6710,42 @@ def _install_python_dependencies_with_optional_fallback(
By default this targets ``.[all]``; Termux callers can pass
``group='termux-all'`` to use the curated Android-compatible profile.
On Windows, pre-renames live ``hermes.exe`` / ``hermes-gateway.exe`` shims
in the venv Scripts dir before each install attempt so uv can write fresh
copies (Windows blocks REPLACE on a running .exe but allows RENAME). See
``_quarantine_running_hermes_exe`` for the rationale.
"""
scripts_dir = _venv_scripts_dir() if _is_windows() else None
def _install(args: list[str]) -> None:
moved: list[tuple[Path, Path]] = []
if scripts_dir is not None:
moved = _quarantine_running_hermes_exe(scripts_dir)
try:
_run_install_with_heartbeat(install_cmd_prefix + args, env=env)
except BaseException:
# Restore shims if uv didn't write replacements (e.g. install
# failed before the entry-points step). Don't swallow the error.
if scripts_dir is not None:
_restore_quarantined_exes(moved)
raise
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "-e", f".[{group}]"],
env=env,
)
_install(["install", "-e", f".[{group}]"])
return
except subprocess.CalledProcessError:
print(
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
)
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "-e", "."],
env=env,
)
_install(["install", "-e", "."])
failed_extras: list[str] = []
installed_extras: list[str] = []
for extra in _load_installable_optional_extras(group=group):
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "-e", f".[{extra}]"],
env=env,
)
_install(["install", "-e", f".[{extra}]"])
installed_extras.append(extra)
except subprocess.CalledProcessError:
failed_extras.append(extra)
@ -9150,6 +9258,14 @@ def main():
except Exception:
pass
# Sweep stale ``hermes.exe.old.*`` quarantine files left by previous
# ``hermes update`` runs on Windows. Silent no-op on non-Windows or when
# there's nothing to clean. See ``_quarantine_running_hermes_exe``.
try:
_cleanup_quarantined_exes()
except Exception:
pass
from hermes_cli._parser import build_top_level_parser
parser, subparsers, chat_parser = build_top_level_parser()