fix(entry-points): guard hermes_bootstrap import so partial updates don't brick hermes (#22091)

teknium1 hit ModuleNotFoundError: No module named 'hermes_bootstrap' after
a code update, on both his Windows machine AND his Linux workstation.  The
failure mode is real and affects every user who updates hermes by any path
OTHER than a fully-successful ``hermes update``.

## What happens

hermes_bootstrap.py is a top-level module registered via pyproject.toml's
``py-modules`` list (added by Brooklyn's Windows UTF-8 stdio work).  It
must be registered in the venv's editable-install .pth file before Python
can find it as a bare ``import hermes_bootstrap``.

``hermes update`` handles this correctly: (1) git reset --hard, (2) clear
__pycache__, (3) uv pip install -e . (re-registers the package including
the new py-modules list), (4) restart.

BUT if any step AFTER (1) fails — network blip during pip install, PEP 668
on a system Python, venv locked, uv not in PATH, a crash mid-update — the
user is left with new code that references hermes_bootstrap and a venv
that doesn't know about it.  Every hermes invocation after that crashes
with ModuleNotFoundError, including ``hermes update`` itself.  No recovery
path without manual `uv pip install -e .`.

Also affects users who ``git pull`` the repo directly without running
hermes update — relatively common for developers.

## Fix

Wrap ``import hermes_bootstrap`` in a try/except ModuleNotFoundError
across all 6 entry points (hermes_cli/main, run_agent, gateway/run,
acp_adapter/entry, cli, batch_runner).  On Windows, missing bootstrap
means the UTF-8 stdio setup doesn't run — degraded behavior (Unicode
chars may fail to print) but NOT a crash.  POSIX is unaffected either way
since the bootstrap is a no-op there.

Once hermes is running again, the user can ``hermes update`` to fully
recover.

## Test update

tests/test_hermes_bootstrap.py::test_entry_point_imports_bootstrap
scans for the first top-level import in each entry point and asserts it
is hermes_bootstrap.  Extended the check to accept a Try block whose body
is a lone Import of hermes_bootstrap — that's the recovery-friendly form
we just introduced.

Verified behavior by ``mv hermes_bootstrap.py hermes_bootstrap.py.bak``
and confirming ``python -c "import hermes_cli.main"`` succeeds.  82/82
tests pass (hermes_bootstrap + windows-native + windows-compat).
This commit is contained in:
Teknium 2026-05-08 14:43:13 -07:00 committed by GitHub
parent 3299be6bdb
commit 26bac67ef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 73 additions and 8 deletions

View file

@ -15,7 +15,14 @@ Usage::
# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio # IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. # on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale.
import hermes_bootstrap # noqa: F401 try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError:
# Graceful fallback when hermes_bootstrap isn't registered in the venv
# yet — happens during partial ``hermes update`` where git-reset landed
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
import asyncio import asyncio
import logging import logging

View file

@ -22,7 +22,14 @@ Usage:
# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio # IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. # on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale.
import hermes_bootstrap # noqa: F401 try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError:
# Graceful fallback when hermes_bootstrap isn't registered in the venv
# yet — happens during partial ``hermes update`` where git-reset landed
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
import json import json
import logging import logging

9
cli.py
View file

@ -14,7 +14,14 @@ Usage:
# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio # IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. # on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale.
import hermes_bootstrap # noqa: F401 try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError:
# Graceful fallback when hermes_bootstrap isn't registered in the venv
# yet — happens during partial ``hermes update`` where git-reset landed
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
import logging import logging
import os import os

View file

@ -15,7 +15,14 @@ Usage:
# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio # IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. # on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale.
import hermes_bootstrap # noqa: F401 try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError:
# Graceful fallback when hermes_bootstrap isn't registered in the venv
# yet — happens during partial ``hermes update`` where git-reset landed
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
import asyncio import asyncio
import dataclasses import dataclasses

View file

@ -46,7 +46,20 @@ Usage:
# IMPORTANT: hermes_bootstrap must be the very first import — it sets up # IMPORTANT: hermes_bootstrap must be the very first import — it sets up
# UTF-8 stdio on Windows so print()/subprocess children don't hit # UTF-8 stdio on Windows so print()/subprocess children don't hit
# UnicodeEncodeError with non-ASCII characters. No-op on POSIX. # UnicodeEncodeError with non-ASCII characters. No-op on POSIX.
import hermes_bootstrap # noqa: F401 #
# Guarded against ModuleNotFoundError because ``hermes_bootstrap`` is a
# top-level module registered via pyproject.toml's ``py-modules`` list.
# When the user upgrades code via ``git pull`` (or ``hermes update``
# crashes between ``git reset --hard`` and ``uv pip install -e .``), the
# new code references ``hermes_bootstrap`` but the editable install's
# ``.pth`` file still points at the old set of top-level modules. Without
# this guard, hermes crashes on import and the user can't run
# ``hermes update`` to recover. Missing the bootstrap means UTF-8 stdio
# setup is skipped on Windows — degraded, not broken. POSIX is unaffected.
try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError:
pass
import argparse import argparse
import json import json

View file

@ -22,7 +22,14 @@ Usage:
# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio # IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. # on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale.
import hermes_bootstrap # noqa: F401 try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError:
# Graceful fallback when hermes_bootstrap isn't registered in the venv
# yet — happens during partial ``hermes update`` where git-reset landed
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
import asyncio import asyncio
import base64 import base64

View file

@ -257,6 +257,15 @@ class TestEntryPointsImportBootstrap:
We're lenient about the docstring (can be arbitrarily long) and We're lenient about the docstring (can be arbitrarily long) and
about comment lines just need to verify the first import about comment lines just need to verify the first import
statement is the bootstrap. statement is the bootstrap.
Also lenient about a try/except wrapper around the import: entry
points may guard the import against ``ModuleNotFoundError`` so a
half-finished ``hermes update`` (git-reset landed new code but
``uv pip install -e .`` didn't finish re-registering
``hermes_bootstrap`` as a top-level module) leaves hermes
recoverable instead of crashing on every invocation. When the
first top-level node is such a guarded-import block, we peek
inside it to verify bootstrap is the imported module.
""" """
# Resolve relative to the hermes-agent repo root. Tests live # Resolve relative to the hermes-agent repo root. Tests live
# at tests/test_hermes_bootstrap.py, so go up one dir. # at tests/test_hermes_bootstrap.py, so go up one dir.
@ -269,8 +278,7 @@ class TestEntryPointsImportBootstrap:
source = full_path.read_text(encoding="utf-8") source = full_path.read_text(encoding="utf-8")
# Find the first non-comment, non-blank line that starts with # Find the first non-comment, non-blank line that starts with
# 'import ' or 'from '. It must be 'import hermes_bootstrap'. # 'import ' or 'from ', or a Try block whose body is the import.
import tokenize
import ast import ast
tree = ast.parse(source) tree = ast.parse(source)
@ -279,6 +287,15 @@ class TestEntryPointsImportBootstrap:
if isinstance(node, (ast.Import, ast.ImportFrom)): if isinstance(node, (ast.Import, ast.ImportFrom)):
first_import_node = node first_import_node = node
break break
# Accept a guarded-import Try block where the body is a lone
# Import node — this is the recovery-friendly form that lets
# hermes start even when hermes_bootstrap hasn't been
# re-registered in the venv yet.
if isinstance(node, ast.Try) and len(node.body) == 1 and isinstance(
node.body[0], (ast.Import, ast.ImportFrom)
):
first_import_node = node.body[0]
break
assert first_import_node is not None, ( assert first_import_node is not None, (
f"{path}: no top-level imports found at all" f"{path}: no top-level imports found at all"