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) 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( def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str], 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 By default this targets ``.[all]``; Termux callers can pass
``group='termux-all'`` to use the curated Android-compatible profile. ``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: try:
_run_install_with_heartbeat( _install(["install", "-e", f".[{group}]"])
install_cmd_prefix + ["install", "-e", f".[{group}]"],
env=env,
)
return return
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print( print(
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
) )
_run_install_with_heartbeat( _install(["install", "-e", "."])
install_cmd_prefix + ["install", "-e", "."],
env=env,
)
failed_extras: list[str] = [] failed_extras: list[str] = []
installed_extras: list[str] = [] installed_extras: list[str] = []
for extra in _load_installable_optional_extras(group=group): for extra in _load_installable_optional_extras(group=group):
try: try:
_run_install_with_heartbeat( _install(["install", "-e", f".[{extra}]"])
install_cmd_prefix + ["install", "-e", f".[{extra}]"],
env=env,
)
installed_extras.append(extra) installed_extras.append(extra)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
failed_extras.append(extra) failed_extras.append(extra)
@ -9150,6 +9258,14 @@ def main():
except Exception: except Exception:
pass 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 from hermes_cli._parser import build_top_level_parser
parser, subparsers, chat_parser = build_top_level_parser() parser, subparsers, chat_parser = build_top_level_parser()

View file

@ -13,6 +13,7 @@ import json as _json
import logging import logging
import os import os
import shutil import shutil
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Set from typing import Dict, List, Optional, Set
@ -521,6 +522,75 @@ TOOLSET_ENV_REQUIREMENTS = {
# ─── Post-Setup Hooks ───────────────────────────────────────────────────────── # ─── Post-Setup Hooks ─────────────────────────────────────────────────────────
def _pip_install(
args: List[str],
*,
timeout: int = 300,
capture_output: bool = True,
):
"""Install Python packages from a post-setup hook.
Strategy (in order):
1. ``uv pip install`` if uv is on PATH fast, doesn't need pip in the venv.
2. ``python -m pip install`` works on stdlib venvs.
3. ``python -m ensurepip --upgrade`` then retry pip covers ``uv venv``
which creates a venv WITHOUT pip.
Why this exists: the Windows installer creates the venv via ``uv venv``,
which doesn't seed pip. Post-setup hooks that shelled out to
``[sys.executable, '-m', 'pip', 'install', ...]`` failed with
``No module named pip`` on every fresh install. uv-first sidesteps that.
Returns the ``subprocess.CompletedProcess`` from whichever tier succeeded
(or the last failure for the caller to inspect).
"""
venv_root = Path(sys.executable).parent.parent
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
uv_bin = shutil.which("uv")
if uv_bin:
try:
result = subprocess.run(
[uv_bin, "pip", "install", *args],
capture_output=capture_output, text=True, timeout=timeout,
env=uv_env,
)
if result.returncode == 0:
return result
# Fall through to pip — uv may have failed for an unrelated reason
# (resolution conflict, network), and pip might handle it.
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
pip_cmd = [sys.executable, "-m", "pip"]
try:
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Synthesize a result so callers see a clean failure path.
return subprocess.CompletedProcess(
pip_cmd, returncode=1, stdout="",
stderr=f"pip not available and ensurepip failed: {e}",
)
return subprocess.run(
pip_cmd + ["install", *args],
capture_output=capture_output, text=True, timeout=timeout,
)
def _run_post_setup(post_setup_key: str): def _run_post_setup(post_setup_key: str):
"""Run post-setup hooks for tools that need extra installation steps.""" """Run post-setup hooks for tools that need extra installation steps."""
import shutil import shutil
@ -712,51 +782,43 @@ def _run_post_setup(post_setup_key: str):
return return
except ImportError: except ImportError:
pass pass
import subprocess
_print_info(" Installing kittentts (~25-80MB model, CPU-only)...") _print_info(" Installing kittentts (~25-80MB model, CPU-only)...")
wheel_url = ( wheel_url = (
"https://github.com/KittenML/KittenTTS/releases/download/" "https://github.com/KittenML/KittenTTS/releases/download/"
"0.8.1/kittentts-0.8.1-py3-none-any.whl" "0.8.1/kittentts-0.8.1-py3-none-any.whl"
) )
try: try:
result = subprocess.run( result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
[sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"],
capture_output=True, text=True, timeout=300,
)
if result.returncode == 0: if result.returncode == 0:
_print_success(" kittentts installed") _print_success(" kittentts installed")
_print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo") _print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo")
_print_info(" Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)") _print_info(" Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)")
else: else:
_print_warning(" kittentts install failed:") _print_warning(" kittentts install failed:")
_print_info(f" {result.stderr.strip()[:300]}") _print_info(f" {(result.stderr or '').strip()[:300]}")
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile") _print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile")
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_print_warning(" kittentts install timed out (>5min)") _print_warning(" kittentts install timed out (>5min)")
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile") _print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile")
elif post_setup_key == "piper": elif post_setup_key == "piper":
try: try:
__import__("piper") __import__("piper")
_print_success(" piper-tts is already installed") _print_success(" piper-tts is already installed")
except ImportError: except ImportError:
import subprocess
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...") _print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
try: try:
result = subprocess.run( result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300)
[sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
capture_output=True, text=True, timeout=300,
)
if result.returncode == 0: if result.returncode == 0:
_print_success(" piper-tts installed") _print_success(" piper-tts installed")
else: else:
_print_warning(" piper-tts install failed:") _print_warning(" piper-tts install failed:")
_print_info(f" {result.stderr.strip()[:300]}") _print_info(f" {(result.stderr or '').strip()[:300]}")
_print_info(" Run manually: python -m pip install -U piper-tts") _print_info(" Run manually: uv pip install -U piper-tts")
return return
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_print_warning(" piper-tts install timed out (>5min)") _print_warning(" piper-tts install timed out (>5min)")
_print_info(" Run manually: python -m pip install -U piper-tts") _print_info(" Run manually: uv pip install -U piper-tts")
return return
_print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)") _print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)")
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md") _print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
@ -767,23 +829,19 @@ def _run_post_setup(post_setup_key: str):
__import__("ddgs") __import__("ddgs")
_print_success(" ddgs is already installed") _print_success(" ddgs is already installed")
except ImportError: except ImportError:
import subprocess
_print_info(" Installing ddgs (DuckDuckGo search package)...") _print_info(" Installing ddgs (DuckDuckGo search package)...")
try: try:
result = subprocess.run( result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300)
[sys.executable, "-m", "pip", "install", "-U", "ddgs", "--quiet"],
capture_output=True, text=True, timeout=300,
)
if result.returncode == 0: if result.returncode == 0:
_print_success(" ddgs installed") _print_success(" ddgs installed")
else: else:
_print_warning(" ddgs install failed:") _print_warning(" ddgs install failed:")
_print_info(f" {result.stderr.strip()[:300]}") _print_info(f" {(result.stderr or '').strip()[:300]}")
_print_info(" Run manually: python -m pip install -U ddgs") _print_info(" Run manually: uv pip install -U ddgs")
return return
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_print_warning(" ddgs install timed out (>5min)") _print_warning(" ddgs install timed out (>5min)")
_print_info(" Run manually: python -m pip install -U ddgs") _print_info(" Run manually: uv pip install -U ddgs")
return return
_print_info(" No API key required. DuckDuckGo enforces server-side rate limits.") _print_info(" No API key required. DuckDuckGo enforces server-side rate limits.")
_print_info(" Pair with an extract provider if you also need web_extract.") _print_info(" Pair with an extract provider if you also need web_extract.")
@ -824,18 +882,7 @@ def _run_post_setup(post_setup_key: str):
tinker_dir = PROJECT_ROOT / "tinker-atropos" tinker_dir = PROJECT_ROOT / "tinker-atropos"
if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
_print_info(" Installing tinker-atropos submodule...") _print_info(" Installing tinker-atropos submodule...")
import subprocess result = _pip_install(["-e", str(tinker_dir)])
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, "-e", str(tinker_dir)],
capture_output=True, text=True
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)],
capture_output=True, text=True
)
if result.returncode == 0: if result.returncode == 0:
_print_success(" tinker-atropos installed") _print_success(" tinker-atropos installed")
else: else:
@ -852,16 +899,12 @@ def _run_post_setup(post_setup_key: str):
__import__("langfuse") __import__("langfuse")
_print_success(" langfuse SDK already installed") _print_success(" langfuse SDK already installed")
except ImportError: except ImportError:
import subprocess
_print_info(" Installing langfuse SDK...") _print_info(" Installing langfuse SDK...")
result = subprocess.run( result = _pip_install(["langfuse", "--quiet"], timeout=120)
[sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
capture_output=True, text=True, timeout=120,
)
if result.returncode == 0: if result.returncode == 0:
_print_success(" langfuse SDK installed") _print_success(" langfuse SDK installed")
else: else:
_print_warning(" langfuse SDK install failed — run manually: pip install langfuse") _print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse")
# Opt the bundled observability/langfuse plugin into plugins.enabled. # Opt the bundled observability/langfuse plugin into plugins.enabled.
# The plugin ships in the repo but doesn't load until the user enables # The plugin ships in the repo but doesn't load until the user enables
# it (standalone plugins are opt-in). # it (standalone plugins are opt-in).

View file

@ -227,6 +227,3 @@ select = ["PLW1514"]
"skills/**" = ["PLW1514"] "skills/**" = ["PLW1514"]
"optional-skills/**" = ["PLW1514"] "optional-skills/**" = ["PLW1514"]
"plugins/**" = ["PLW1514"] "plugins/**" = ["PLW1514"]
[tool.uv]
exclude-newer = "7 days"