diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 0607387b85..cc7f835f7e 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -15,7 +15,14 @@ Usage:: # 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. -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 logging diff --git a/batch_runner.py b/batch_runner.py index 11626d98b8..713a1febab 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -22,7 +22,14 @@ Usage: # 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. -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 logging diff --git a/cli.py b/cli.py index 8ffa1df800..064eb3618a 100644 --- a/cli.py +++ b/cli.py @@ -14,7 +14,14 @@ Usage: # 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. -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 os diff --git a/gateway/run.py b/gateway/run.py index 8b4c40c90b..13d57c46d0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15,7 +15,14 @@ Usage: # 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. -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 dataclasses diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1f88a60530..703d383485 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -46,7 +46,20 @@ Usage: # IMPORTANT: hermes_bootstrap must be the very first import — it sets up # UTF-8 stdio on Windows so print()/subprocess children don't hit # 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 json diff --git a/run_agent.py b/run_agent.py index d748d52b65..255f9a6bd2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -22,7 +22,14 @@ Usage: # 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. -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 base64 diff --git a/tests/test_hermes_bootstrap.py b/tests/test_hermes_bootstrap.py index 84e9ec81ad..a044d644ab 100644 --- a/tests/test_hermes_bootstrap.py +++ b/tests/test_hermes_bootstrap.py @@ -257,6 +257,15 @@ class TestEntryPointsImportBootstrap: We're lenient about the docstring (can be arbitrarily long) and about comment lines — just need to verify the first import 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 # 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") # Find the first non-comment, non-blank line that starts with - # 'import ' or 'from '. It must be 'import hermes_bootstrap'. - import tokenize + # 'import ' or 'from ', or a Try block whose body is the import. import ast tree = ast.parse(source) @@ -279,6 +287,15 @@ class TestEntryPointsImportBootstrap: if isinstance(node, (ast.Import, ast.ImportFrom)): first_import_node = node 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, ( f"{path}: no top-level imports found at all"