fix(update): refresh runtime modules before lazy backends

Refresh update-sensitive modules before lazy backend refresh so an in-place git update does not keep using pre-pull module objects when newly pulled code imports fresh helpers.

Constraint: Issue #60242 reports post-update lazy backend refresh importing stale hermes_constants after a large Windows update.

Rejected: Only clearing __pycache__ earlier | the update process can still hold old modules in sys.modules.

Confidence: high

Scope-risk: narrow

Directive: Keep update-time lazy refresh guarded against in-process code skew after git pull.

Tested: ./.venv/bin/python -m pytest tests/hermes_cli/test_update_autostash.py::test_cmd_update_reloads_runtime_modules_before_lazy_refresh tests/hermes_cli/test_update_autostash.py::test_reload_updated_runtime_modules_restores_new_hermes_constants_symbol -q

Tested: ./.venv/bin/python -m pytest tests/hermes_cli/test_update_autostash.py -q

Tested: ./.venv/bin/python -m ruff check hermes_cli/main.py tests/hermes_cli/test_update_autostash.py

Tested: ./.venv/bin/python -m py_compile hermes_cli/main.py tests/hermes_cli/test_update_autostash.py

Tested: git diff --check

Not-tested: Windows v0.14.0 to v0.18.0 end-to-end update.
This commit is contained in:
izumi0uu 2026-07-07 21:58:32 +08:00 committed by Teknium
parent 0543078e97
commit d76d0d61d8
2 changed files with 100 additions and 12 deletions

View file

@ -4760,6 +4760,38 @@ def _clear_bytecode_cache(root: Path) -> int:
return removed
_UPDATE_RUNTIME_RELOAD_MODULES = (
"hermes_constants",
"tools.environments.local",
"tools.lazy_deps",
)
def _reload_updated_runtime_modules() -> None:
"""Reload update-sensitive modules after the checkout changes in-place.
``hermes update`` keeps running in the pre-pull Python process. After a
large update, modules already present in ``sys.modules`` can still expose
old symbols even though their source files on disk are new. Refresh the
small module set used by lazy-backend refresh before that step imports
newly-updated code paths.
"""
try:
import importlib
importlib.invalidate_caches()
for module_name in _UPDATE_RUNTIME_RELOAD_MODULES:
module = sys.modules.get(module_name)
if module is None:
continue
try:
importlib.reload(module)
except Exception as exc:
logger.debug("Could not reload updated module %s: %s", module_name, exc)
except Exception as exc:
logger.debug("Could not refresh update runtime modules: %s", exc)
# Critical files that Hermes must be able to import immediately after an
# update/install. Most are imported on every CLI startup; ``web_server.py``
# is the desktop/dashboard backend path that a fresh Windows install launches
@ -12353,6 +12385,19 @@ def _cmd_update_impl(args, gateway_mode: bool):
# based on a narrow 7-package import probe (#58004 review).
_clear_update_incomplete_marker()
# The update process is still the old Python interpreter process. Run
# one final cache/module refresh immediately before lazy backend
# refresh, which imports newly-pulled modules that may depend on fresh
# symbols in hermes_constants or lazy_deps. The dependency install
# above may also have regenerated bytecode from build-cache copies —
# this second sweep catches those stragglers (#60242, #65240).
removed = _clear_bytecode_cache(PROJECT_ROOT)
if removed:
print(
f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}"
)
_reload_updated_runtime_modules()
# Upgrade pip before lazy refreshes — stale pip can fail source builds
# and leave partially-written packages (#57828).
_write_lazy_refresh_incomplete_marker()
@ -12511,18 +12556,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("Model catalog seed during update failed: %s", e)
# After git pull, source files on disk are newer than cached Python
# modules in this process. Reload hermes_constants so that any lazy
# import executed below (skills sync, gateway restart) sees new
# attributes like display_hermes_home() added since the last release.
try:
import importlib
import hermes_constants as _hc
importlib.reload(_hc)
except Exception:
pass # non-fatal — worst case a lazy import fails gracefully
# Sync bundled skills (copies new, updates changed, respects user deletions)
try:
from tools.skills_sync import sync_skills

View file

@ -568,6 +568,61 @@ def test_cmd_update_refreshes_active_memory_provider_dependencies(monkeypatch, t
assert refresh_calls == [True]
def test_cmd_update_reloads_runtime_modules_before_lazy_refresh(monkeypatch, tmp_path):
"""Lazy refresh must not see pre-pull modules cached in this process."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
events = []
def fake_run(cmd, **kwargs):
if cmd == ["git", "fetch", "origin", "main"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]:
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if cmd == ["git", "pull", "--ff-only", "origin", "main"]:
events.append("pull")
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
if "pip" in cmd and "install" in cmd:
events.append("install")
return SimpleNamespace(returncode=0, stdout="", stderr="")
def fake_reload_runtime_modules():
events.append("reload")
def fake_refresh_lazy_features(install_prefix=None, env=None):
events.append("lazy-refresh")
return True
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
monkeypatch.setattr(hermes_main, "_reload_updated_runtime_modules", fake_reload_runtime_modules)
monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", fake_refresh_lazy_features)
hermes_main.cmd_update(SimpleNamespace())
assert (
events.index("pull")
< events.index("install")
< events.index("reload")
< events.index("lazy-refresh")
)
def test_reload_updated_runtime_modules_restores_new_hermes_constants_symbol(monkeypatch):
"""A pre-pull module object missing a new helper is repaired by reload."""
import hermes_constants
monkeypatch.delattr(hermes_constants, "apply_subprocess_home_env", raising=False)
assert not hasattr(hermes_constants, "apply_subprocess_home_env")
hermes_main._reload_updated_runtime_modules()
assert callable(hermes_constants.apply_subprocess_home_env)
def test_install_with_optional_fallback_honors_custom_group(monkeypatch):
"""Termux update path should target .[termux-all] when requested."""
calls = []