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
49 changes: 41 additions & 8 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9651,10 +9651,22 @@ def _repair_venv_via_import_probes(
return "failed"


def _capture_active_lazy_features() -> list[str]:
"""Snapshot active lazy backends before a managed runtime is replaced."""
try:
from tools import lazy_deps

return lazy_deps.active_features()
except Exception as exc:
logger.debug("Could not snapshot active lazy features: %s", exc)
return []


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

Expand Down Expand Up @@ -9682,11 +9694,14 @@ def _refresh_active_lazy_features(
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return True

try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return True
if features is None:
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return True
else:
active = features

if not active:
return True
Expand All @@ -9696,15 +9711,18 @@ def _refresh_active_lazy_features(

unexpected_failure = False
try:
results = lazy_deps.refresh_active_features(prompt=False)
if features is None:
results = lazy_deps.refresh_active_features(prompt=False)
else:
results = lazy_deps.restore_features(active)
except Exception as exc:
# refresh_active_features is documented as never-raise, but defend
# the update flow against future regressions.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
results = {}
unexpected_failure = True

refreshed = [f for f, s in results.items() if s == "refreshed"]
refreshed = [f for f, s in results.items() if s in {"refreshed", "restored"}]
current = [f for f, s in results.items() if s == "current"]
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
Expand Down Expand Up @@ -11901,6 +11919,11 @@ def cmd_update(args):
def _cmd_update_impl(args, gateway_mode: bool):
"""Body of ``cmd_update`` — kept separate so the wrapper can always
restore stdio even on ``sys.exit``."""
# A managed-runtime refresh can replace site-packages before the normal
# ``.[all]`` install runs. Snapshot while the old environment can still
# prove which optional backends the user had activated.
active_lazy_features = _capture_active_lazy_features()

# In gateway mode, use file-based IPC for prompts instead of stdin
gw_input_fn = (
(lambda prompt, default="": _gateway_prompt(prompt, default))
Expand Down Expand Up @@ -12228,10 +12251,18 @@ def _cmd_update_impl(args, gateway_mode: bool):
_install_python_dependencies_with_optional_fallback(
[repair_uv, "pip"], env=repair_env, group="all"
)
_refresh_active_lazy_features(
[repair_uv, "pip"], env=repair_env,
features=active_lazy_features,
)
else:
_install_python_dependencies_with_optional_fallback(
[sys.executable, "-m", "pip"], group="all"
)
_refresh_active_lazy_features(
[sys.executable, "-m", "pip"],
features=active_lazy_features,
)
_clear_update_incomplete_marker()
healthy_after, detail_after = _venv_core_imports_healthy()
if healthy_after:
Expand Down Expand Up @@ -12478,7 +12509,9 @@ def _cmd_update_impl(args, gateway_mode: bool):

# Lazy refresh can corrupt the venv when a backend install fails.
# Clear the lazy marker only when refresh/repair is confirmed healthy.
lazy_ok = _refresh_active_lazy_features(install_prefix, env=lazy_env)
lazy_ok = _refresh_active_lazy_features(
install_prefix, env=lazy_env, features=active_lazy_features
)
if lazy_ok:
_clear_lazy_refresh_incomplete_marker()
else:
Expand Down
23 changes: 23 additions & 0 deletions tests/hermes_cli/test_lazy_refresh_venv_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from unittest.mock import MagicMock, patch

import hermes_cli.main as m
import pytest


def test_detect_broken_imports_returns_repair_package_names(
Expand Down Expand Up @@ -165,6 +166,28 @@ def fake_repair(prefix, packages, *, env=None):
assert "Backends keep their previously-installed version" not in out


def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch):
"""Replacement runtimes must not re-detect features after packages vanish."""
import tools.lazy_deps as lazy_deps_mod

monkeypatch.setattr(
lazy_deps_mod,
"active_features",
lambda: pytest.fail("post-rebuild detection must not run"),
)
restored = []
monkeypatch.setattr(
lazy_deps_mod,
"restore_features",
lambda features: restored.append(features) or {"platform.telegram": "restored"},
)

assert m._refresh_active_lazy_features(
["uv", "pip"], features=["platform.telegram"]
) is True
assert restored == [["platform.telegram"]]


def test_refresh_returns_false_when_repair_fails(tmp_path, monkeypatch, capsys):
import tools.lazy_deps as lazy_deps_mod

Expand Down
36 changes: 36 additions & 0 deletions tests/tools/test_lazy_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,42 @@ def test_lazy_installs_disabled_marked_skipped(self, monkeypatch):
assert "test.feat" in result
assert result["test.feat"].startswith("skipped:")

def test_restore_snapshot_reinstalls_telegram_with_lazy_installs_disabled(
self, monkeypatch
):
"""An update may restore a captured feature without opening runtime installs."""
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False)
satisfied = iter([False, True])
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(satisfied))
installs = []
monkeypatch.setattr(
ld,
"_venv_pip_install",
lambda specs, **kw: installs.append(specs) or ld._InstallResult(True, "", ""),
)

result = ld.restore_features(["platform.telegram"])

assert result == {"platform.telegram": "restored"}
assert installs == [("python-telegram-bot[webhooks]==22.6",)]
assert ld._allow_lazy_installs() is False
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
ld.ensure("platform.telegram", prompt=False)

def test_restore_snapshot_does_not_install_never_activated_features(
self, monkeypatch
):
monkeypatch.setattr(
ld,
"_venv_pip_install",
lambda *args, **kwargs: pytest.fail(
"cold features must stay uninstalled"
),
)

assert ld.restore_features([]) == {}

def test_mixed_results_returns_per_feature_status(self, monkeypatch):
monkeypatch.setattr(ld, "active_features", lambda: ["a.ok", "b.fail"])
monkeypatch.setitem(ld.LAZY_DEPS, "a.ok", ("pkga==1.0",))
Expand Down
46 changes: 43 additions & 3 deletions tools/lazy_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,8 +908,32 @@ def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
Intended for ``hermes update``. Never raises; lazy-install failures
here must not block the rest of the update flow.
"""
return _refresh_features(active_features(), prompt=prompt, restoring=False)


def restore_features(features: list[str]) -> dict[str, str]:
"""Restore features captured before an explicit managed-runtime rebuild.

``security.allow_lazy_installs`` gates installs initiated at feature-use
time. A runtime rebuild is different: the updater is deliberately
recreating the environment and may restore only features that were
already present before that rebuild. The feature names are still checked
against :data:`LAZY_DEPS`, so this never accepts arbitrary package specs.

This does not change the security setting. Subsequent runtime calls to
:func:`ensure` remain subject to the normal lazy-install gate.
"""
return _refresh_features(features, prompt=False, restoring=True)


def _refresh_features(
features: list[str], *, prompt: bool, restoring: bool
) -> dict[str, str]:
"""Refresh or restore a known set of allowlisted lazy features."""
results: dict[str, str] = {}
for feature in active_features():
for feature in features:
if feature not in LAZY_DEPS:
continue
missing = feature_missing(feature)
if not missing:
results[feature] = "current"
Expand All @@ -921,8 +945,24 @@ def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
continue

try:
ensure(feature, prompt=prompt)
results[feature] = "refreshed"
if restoring:
result = _venv_pip_install(missing)
if not result.success:
snippet = (result.stderr or result.stdout or "").strip()
raise FeatureUnavailable(
feature, missing,
f"pip install failed: {snippet or 'no error output'}",
)
if feature_missing(feature):
raise FeatureUnavailable(
feature, missing,
"install reported success but packages are still missing "
"(may require Python restart)",
)
results[feature] = "restored"
else:
ensure(feature, prompt=prompt)
results[feature] = "refreshed"
except FeatureUnavailable as e:
# Distinguish "user opted out" or platform-incompatible features
# from install failures so the update command can render the
Expand Down