Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7610,7 +7610,12 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
pass


def _refresh_active_lazy_features() -> None:
def _refresh_active_lazy_features(
*,
install_cmd_prefix: list[str] | None = None,
env: dict[str, str] | None = None,
group: str = "all",
) -> None:
"""Refresh lazy-installed backends after a code update.

When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
Expand All @@ -7625,6 +7630,9 @@ def _refresh_active_lazy_features() -> None:
user never enabled stay quiet — no churn for cold backends.

Never raises. A failure here must not block the rest of the update.
When a lazy refresh fails after touching the venv, re-run the core
dependency verifier against the same install target so a partially failed
optional backend install cannot leave base packages corrupted.
"""
try:
from tools import lazy_deps
Expand All @@ -7650,6 +7658,13 @@ def _refresh_active_lazy_features() -> None:
# refresh_active_features is documented as never-raise, but defend
# the update flow against future regressions.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
if install_cmd_prefix is not None:
print(" → Verifying core dependencies after lazy refresh failure...")
_verify_core_dependencies_installed(
install_cmd_prefix,
env=env,
group=group,
)
return

refreshed = [f for f, s in results.items() if s == "refreshed"]
Expand All @@ -7674,6 +7689,13 @@ def _refresh_active_lazy_features() -> None:
if len(reason) > 200:
reason = reason[:200] + "..."
print(f" ⚠ {feature} failed to refresh: {reason}")
if install_cmd_prefix is not None:
print(" → Verifying core dependencies after lazy refresh failure...")
_verify_core_dependencies_installed(
install_cmd_prefix,
env=env,
group=group,
)
print(" Backends keep their previously-installed version; rerun")
print(" `hermes update` once the upstream issue is resolved.")

Expand Down Expand Up @@ -10025,9 +10047,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
install_group = "all"
core_install_cmd_prefix = pip_cmd
core_install_env = None

if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
core_install_cmd_prefix = [uv_bin, "pip"]
core_install_env = uv_env
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
Expand Down Expand Up @@ -10072,7 +10098,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
# UI, desktop rebuild) are non-core and can't brick the venv.
_clear_update_incomplete_marker()

_refresh_active_lazy_features()
_refresh_active_lazy_features(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new keyword-argument call breaks the shared update-test stub at tests/hermes_cli/test_update_autostash.py:396 (lambda: None). Update that mock to accept *args, **kwargs or capture and assert these arguments; otherwise tests using _setup_update_mocks() raise TypeError here.

install_cmd_prefix=core_install_cmd_prefix,
env=core_install_env,
group=install_group,
)

_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")
Expand Down
44 changes: 43 additions & 1 deletion tests/hermes_cli/test_update_autostash.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ def git(*args):

def _setup_update_mocks(monkeypatch, tmp_path):
"""Common setup for cmd_update tests."""
lazy_refresh_calls = []
(tmp_path / ".git").mkdir()
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: None)
Expand All @@ -393,7 +394,12 @@ def _setup_update_mocks(monkeypatch, tmp_path):
monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: [])
monkeypatch.setattr(hermes_config, "check_config_version", lambda: (5, 5))
monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []})
monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda: None)
monkeypatch.setattr(
hermes_main,
"_refresh_active_lazy_features",
lambda **kwargs: lazy_refresh_calls.append(kwargs),
)
return lazy_refresh_calls


def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypatch, tmp_path, capsys):
Expand Down Expand Up @@ -568,6 +574,42 @@ def side_effect(cmd, **kwargs):
return side_effect, recorded


def test_cmd_update_forwards_uv_install_target_to_lazy_refresh(monkeypatch, tmp_path):
lazy_refresh_calls = _setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(
"shutil.which",
lambda name: "/usr/bin/uv" if name == "uv" else None,
)
side_effect, _ = _make_update_side_effect()
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)

hermes_main._cmd_update_impl(SimpleNamespace(), gateway_mode=False)

assert len(lazy_refresh_calls) == 1
call = lazy_refresh_calls[0]
assert call["install_cmd_prefix"] == ["/usr/bin/uv", "pip"]
assert call["env"]["VIRTUAL_ENV"] == str(tmp_path / "venv")
assert call["group"] == "all"


def test_cmd_update_forwards_pip_install_target_to_lazy_refresh(monkeypatch, tmp_path):
lazy_refresh_calls = _setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr(hermes_main, "_ensure_uv_for_termux", lambda pip_cmd: None)
side_effect, _ = _make_update_side_effect()
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)

hermes_main._cmd_update_impl(SimpleNamespace(), gateway_mode=False)

assert lazy_refresh_calls == [
{
"install_cmd_prefix": [hermes_main.sys.executable, "-m", "pip"],
"env": None,
"group": "all",
}
]


def test_cmd_update_falls_back_to_reset_when_ff_only_fails(monkeypatch, tmp_path, capsys):
"""When --ff-only fails (diverged history), update resets to origin/{branch}."""
_setup_update_mocks(monkeypatch, tmp_path)
Expand Down
82 changes: 82 additions & 0 deletions tests/hermes_cli/test_verify_core_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,85 @@ def test_returns_none_when_venv_python_missing(self, tmp_path):
["uv", "pip"], env={"VIRTUAL_ENV": str(tmp_path / "does_not_exist")}
)
assert result is None


class TestRefreshActiveLazyFeaturesVerification:
def test_failed_lazy_refresh_verifies_core_deps(self, monkeypatch, capsys):
"""A failed optional backend refresh may have partially touched the venv.

Run the existing core dependency verifier immediately so base-package
corruption is repaired before the update continues into Node/UI steps.
"""
from tools import lazy_deps
from hermes_cli.main import _refresh_active_lazy_features

captured = {}
monkeypatch.setattr(lazy_deps, "active_features", lambda: ["platform.matrix"])
monkeypatch.setattr(
lazy_deps,
"refresh_active_features",
lambda prompt=False: {"platform.matrix": "failed: build backend exploded"},
)

def fake_verify(install_cmd_prefix, *, env=None, group="all"):
captured["prefix"] = install_cmd_prefix
captured["env"] = env
captured["group"] = group

monkeypatch.setattr("hermes_cli.main._verify_core_dependencies_installed", fake_verify)

env = {"VIRTUAL_ENV": "/tmp/hermes-venv"}
_refresh_active_lazy_features(
install_cmd_prefix=["uv", "pip"],
env=env,
group="termux-all",
)

out = capsys.readouterr().out
assert "platform.matrix failed to refresh" in out
assert "Verifying core dependencies after lazy refresh failure" in out
assert captured == {
"prefix": ["uv", "pip"],
"env": env,
"group": "termux-all",
}

def test_successful_lazy_refresh_does_not_verify_core_deps(self, monkeypatch):
from tools import lazy_deps
from hermes_cli.main import _refresh_active_lazy_features

monkeypatch.setattr(lazy_deps, "active_features", lambda: ["platform.slack"])
monkeypatch.setattr(
lazy_deps,
"refresh_active_features",
lambda prompt=False: {"platform.slack": "refreshed"},
)
mock_verify = MagicMock()
monkeypatch.setattr("hermes_cli.main._verify_core_dependencies_installed", mock_verify)

_refresh_active_lazy_features(install_cmd_prefix=["uv", "pip"], env={})

mock_verify.assert_not_called()

def test_unexpected_lazy_refresh_exception_verifies_core_deps(self, monkeypatch, capsys):
from tools import lazy_deps
from hermes_cli.main import _refresh_active_lazy_features

monkeypatch.setattr(lazy_deps, "active_features", lambda: ["platform.matrix"])

def boom(prompt=False):
raise RuntimeError("refresh registry broke")

monkeypatch.setattr(lazy_deps, "refresh_active_features", boom)
mock_verify = MagicMock()
monkeypatch.setattr("hermes_cli.main._verify_core_dependencies_installed", mock_verify)

_refresh_active_lazy_features(install_cmd_prefix=["uv", "pip"], env={"VIRTUAL_ENV": "v"})

out = capsys.readouterr().out
assert "Lazy refresh failed unexpectedly" in out
mock_verify.assert_called_once_with(
["uv", "pip"],
env={"VIRTUAL_ENV": "v"},
group="all",
)
Loading