mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
00ce5f04d9
commit
4d9dcbc47a
3 changed files with 213 additions and 57 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import json as _json
|
|||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
|
@ -521,6 +522,75 @@ TOOLSET_ENV_REQUIREMENTS = {
|
|||
|
||||
# ─── 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):
|
||||
"""Run post-setup hooks for tools that need extra installation steps."""
|
||||
import shutil
|
||||
|
|
@ -712,51 +782,43 @@ def _run_post_setup(post_setup_key: str):
|
|||
return
|
||||
except ImportError:
|
||||
pass
|
||||
import subprocess
|
||||
_print_info(" Installing kittentts (~25-80MB model, CPU-only)...")
|
||||
wheel_url = (
|
||||
"https://github.com/KittenML/KittenTTS/releases/download/"
|
||||
"0.8.1/kittentts-0.8.1-py3-none-any.whl"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" kittentts installed")
|
||||
_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)")
|
||||
else:
|
||||
_print_warning(" kittentts install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile")
|
||||
_print_info(f" {(result.stderr or '').strip()[:300]}")
|
||||
_print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile")
|
||||
except subprocess.TimeoutExpired:
|
||||
_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":
|
||||
try:
|
||||
__import__("piper")
|
||||
_print_success(" piper-tts is already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" piper-tts installed")
|
||||
else:
|
||||
_print_warning(" piper-tts install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(" Run manually: python -m pip install -U piper-tts")
|
||||
_print_info(f" {(result.stderr or '').strip()[:300]}")
|
||||
_print_info(" Run manually: uv pip install -U piper-tts")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_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
|
||||
_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")
|
||||
|
|
@ -767,23 +829,19 @@ def _run_post_setup(post_setup_key: str):
|
|||
__import__("ddgs")
|
||||
_print_success(" ddgs is already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing ddgs (DuckDuckGo search package)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "ddgs", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" ddgs installed")
|
||||
else:
|
||||
_print_warning(" ddgs install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(" Run manually: python -m pip install -U ddgs")
|
||||
_print_info(f" {(result.stderr or '').strip()[:300]}")
|
||||
_print_info(" Run manually: uv pip install -U ddgs")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_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
|
||||
_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.")
|
||||
|
|
@ -824,18 +882,7 @@ def _run_post_setup(post_setup_key: str):
|
|||
tinker_dir = PROJECT_ROOT / "tinker-atropos"
|
||||
if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
|
||||
_print_info(" Installing tinker-atropos submodule...")
|
||||
import subprocess
|
||||
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
|
||||
)
|
||||
result = _pip_install(["-e", str(tinker_dir)])
|
||||
if result.returncode == 0:
|
||||
_print_success(" tinker-atropos installed")
|
||||
else:
|
||||
|
|
@ -852,16 +899,12 @@ def _run_post_setup(post_setup_key: str):
|
|||
__import__("langfuse")
|
||||
_print_success(" langfuse SDK already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing langfuse SDK...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
result = _pip_install(["langfuse", "--quiet"], timeout=120)
|
||||
if result.returncode == 0:
|
||||
_print_success(" langfuse SDK installed")
|
||||
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.
|
||||
# The plugin ships in the repo but doesn't load until the user enables
|
||||
# it (standalone plugins are opt-in).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue