fix(update): survive a hermes_constants cached from before the update

`hermes update` aborted its managed-Python runtime repair with an error
that reads like a contradiction:

    ⚠ Managed Python runtime repair skipped: cannot import name
      'venv_python_path' from 'hermes_constants'
      (/home/teknium/.hermes/hermes-agent/hermes_constants.py)

The named file does contain the symbol. The module in memory does not.
main.py imports hermes_constants from the OLD checkout, `git pull` then
replaces that file on disk, and the freshly-pulled managed_uv runs its
lazy `from hermes_constants import venv_python_path` against the module
object Python already cached in sys.modules — the pre-upgrade one. The
ImportError reports the path of the new file, so it looks like the symbol
is missing from a file that plainly has it.

Same update-boundary class already documented on `_UvResult` for the
ensure_uv() arity skew. It fires on the first update from any release
older than 83314ca38, which introduced the symbol.

Both lazy call sites now reload the module from disk on ImportError:

- hermes_cli/managed_uv.py::_venv_python — the reported path
- hermes_cli/gateway.py::get_python_path — same flaw, same fix; a gateway
  restarted mid-update hits it identically

Reload rather than a local fallback on purpose. Recomputing the layout
inline would hand-roll `Scripts`/`bin` a second time — exactly what #76105
deduped into venv_bin_dir()/venv_python_path(), and what
test_no_open_coded_venv_layout_remains_in_hermes_cli bans. Reloading fixes
the actual problem (a stale module) and keeps one owner for the layout.

hermes_cli/update_cmd.py imports the symbol at module scope, which is a
different failure mode (the module fails to import at all) and is already
covered by the installer's retry-once for the update-boundary crash.

Tests reproduce the stale-module state by deleting the attribute from the
imported hermes_constants: recovery resolves through the reloaded shared
helper (asserted via a sentinel, so an open-coded copy cannot pass), and
the normal path never reloads. Against origin/main's managed_uv they fail
with the exact reported ImportError.
This commit is contained in:
Brooklyn Nicholson
2026-08-01 23:04:24 -05:00
parent 8e08a4a16e
commit c51c92a218
3 changed files with 112 additions and 4 deletions
+9 -1
View File
@@ -2525,7 +2525,15 @@ def _detect_venv_dir() -> Path | None:
def get_python_path() -> str:
venv = _detect_venv_dir()
if venv is not None:
from hermes_constants import venv_python_path
try:
from hermes_constants import venv_python_path
except ImportError:
# Update-boundary: a gateway restarted mid-update can hold a
# hermes_constants cached from before this symbol existed. See
# _reload_hermes_constants() in hermes_cli/managed_uv.py.
from hermes_cli.managed_uv import _reload_hermes_constants
venv_python_path = _reload_hermes_constants().venv_python_path
venv_python = venv_python_path(venv, windows=is_windows())
if venv_python.exists():
+29 -3
View File
@@ -19,6 +19,7 @@ releases it.
from __future__ import annotations
import importlib
import json
import logging
import os
@@ -383,10 +384,35 @@ def update_managed_uv(
# ---------------------------------------------------------------------------
def _venv_python(venv_dir: Path) -> Path:
from hermes_constants import venv_python_path
def _reload_hermes_constants():
"""Re-execute ``hermes_constants`` from disk and return the fresh module.
return venv_python_path(venv_dir, windows=platform.system() == "Windows")
``hermes update`` imports ``hermes_constants`` from the OLD checkout,
``git pull`` then replaces that file, and this freshly-pulled module runs
its lazy imports against the module object Python already cached in
``sys.modules`` — the pre-upgrade one. A symbol added by the update is
absent there while the file named in the resulting ``ImportError`` plainly
contains it, which is what made this read as a contradiction:
cannot import name 'venv_python_path' from 'hermes_constants'
(~/.hermes/hermes-agent/hermes_constants.py)
Reloading picks up the definitions actually on disk, so callers keep using
the shared helper instead of hand-rolling a second copy of its logic. Same
update-boundary class as the ``ensure_uv()`` arity skew on :class:`_UvResult`.
"""
import hermes_constants
return importlib.reload(hermes_constants)
def _venv_python(venv_dir: Path) -> Path:
windows = platform.system() == "Windows"
try:
from hermes_constants import venv_python_path
except ImportError:
venv_python_path = _reload_hermes_constants().venv_python_path
return venv_python_path(venv_dir, windows=windows)
def _remove_tree(path: Path, *, boundary: Path) -> None:
+74
View File
@@ -1026,3 +1026,77 @@ class TestDefaultLiveVenv:
result = repair_vulnerable_runtime("uv", project_root=root)
assert result.status == "not-applicable"
class TestVenvPythonUpdateBoundary:
"""``_venv_python`` must survive a hermes_constants predating its symbol.
``hermes update`` imports hermes_constants from the OLD checkout, ``git
pull`` replaces that file, and the freshly-pulled managed_uv then runs its
lazy ``from hermes_constants import venv_python_path`` against the module
object already cached in ``sys.modules``. That cached module has no such
symbol, so the import raises — while naming the NEW file on disk, which
plainly contains it, which is what made the error so confusing:
cannot import name 'venv_python_path' from 'hermes_constants'
(~/.hermes/hermes-agent/hermes_constants.py)
It aborted the managed-Python runtime repair on the first update from any
release older than the symbol. Same class as the ``ensure_uv()`` arity skew
documented on ``_UvResult``.
"""
def test_recovers_when_the_cached_module_predates_the_symbol(self, monkeypatch):
import hermes_constants
from hermes_cli.managed_uv import _venv_python
# The stale in-memory module: the symbol the new code wants is absent,
# exactly as on an install that booted the pre-upgrade checkout. The
# file on disk is the current one, so a reload recovers the real helper.
monkeypatch.delattr(hermes_constants, "venv_python_path", raising=False)
monkeypatch.setattr("platform.system", lambda: "Linux")
assert _venv_python(Path("/opt/hermes/venv")) == Path(
"/opt/hermes/venv/bin/python"
)
def test_recovery_uses_the_shared_helper_not_a_second_copy(self, monkeypatch):
"""The reload must resolve through hermes_constants, not open-code it.
Hand-rolling `Scripts`/`bin` here is what #76105 deduped away and what
`test_no_open_coded_venv_layout_remains_in_hermes_cli` bans.
"""
import hermes_constants
from hermes_cli.managed_uv import _venv_python
monkeypatch.delattr(hermes_constants, "venv_python_path", raising=False)
monkeypatch.setattr("platform.system", lambda: "Linux")
sentinel = Path("/sentinel/from/shared/helper")
real_reload = __import__("importlib").reload
def _reload_with_marker(module):
fresh = real_reload(module)
monkeypatch.setattr(
fresh, "venv_python_path", lambda *a, **k: sentinel, raising=False
)
return fresh
monkeypatch.setattr("importlib.reload", _reload_with_marker)
assert _venv_python(Path("/opt/hermes/venv")) == sentinel
def test_uses_the_real_helper_when_it_is_importable(self, monkeypatch):
"""The normal path never reloads — recovery stays a fallback."""
from hermes_cli.managed_uv import _venv_python
def _no_reload(module): # pragma: no cover - must not run
raise AssertionError("reload must not run when the import succeeds")
monkeypatch.setattr("importlib.reload", _no_reload)
monkeypatch.setattr("platform.system", lambda: "Linux")
assert _venv_python(Path("/opt/hermes/venv")) == Path(
"/opt/hermes/venv/bin/python"
)