From d76d0d61d8bcdeb3f4a849a1468839316c6a5a47 Mon Sep 17 00:00:00 2001 From: izumi0uu Date: Tue, 7 Jul 2026 21:58:32 +0800 Subject: [PATCH] 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. --- hermes_cli/main.py | 57 ++++++++++++++++++----- tests/hermes_cli/test_update_autostash.py | 55 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f8ae45fdf2b..aff5ec07d81 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 94fd27949f1..bd1638fcf65 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -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 = []