fix(update): refresh lazy-installed backends on hermes update (#25766)

Pyproject's [all] extra was slimmed down in May 2026 — ~20 optional
backends moved to tools/lazy_deps.py and only install on first use.
hermes update runs uv pip install -e .[all] which doesn't touch any of
them, so pin bumps in LAZY_DEPS (CVE response, transitive fixes) were
silently ignored on already-activated backends.

Two changes:

1. _is_satisfied() now parses the spec and checks the installed version
   against the constraint via packaging.specifiers. Previously it
   returned True the moment the package name was importable, which made
   ensure() a name-presence gate rather than a version-pin gate.

2. New active_features() / refresh_active_features() pair: lists every
   feature with at least one of its packages currently installed, then
   re-runs ensure() on each. Refresh is invoked at the end of
   _cmd_update_impl, right after the [all] install completes. Cold
   backends (never activated) stay quiet — no churn for them.

Output during update is one summary block:
  → Refreshing 4 active lazy backend(s)...
    ↑ 1 refreshed: provider.anthropic
    ✓ 3 already current
or
    ⚠ memory.honcho failed to refresh: <pip stderr>

Failures never raise out of update — backends keep their previously-
installed version and we tell the user to rerun once upstream is fixed.
security.allow_lazy_installs=false is honored: features get marked
"skipped" with the reason shown.

Tests: 18 new unit tests covering version-aware satisfaction (exact pin,
range, extras blocks, missing package, malformed spec), active feature
discovery, and refresh status reporting. All 61 lazy_deps tests pass.
This commit is contained in:
Teknium 2026-05-14 08:03:40 -07:00 committed by GitHub
parent 436a0a271e
commit 72b5dd8658
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 362 additions and 5 deletions

View file

@ -6827,6 +6827,74 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
pass
def _refresh_active_lazy_features() -> None:
"""Refresh lazy-installed backends after a code update.
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
optional backends moved to ``tools/lazy_deps.py`` and only install on
first use. ``hermes update`` runs ``uv pip install -e .[all]`` which
leaves those packages untouched so if we bump a pin in
:data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already
activated the backend keep the stale version forever.
This function asks lazy_deps which features the user has previously
activated and reinstalls them under the current pins. Features the
user never enabled stay quiet no churn for cold backends.
Never raises. A failure here must not block the rest of the update.
"""
try:
from tools import lazy_deps
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return
if not active:
return
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
try:
results = lazy_deps.refresh_active_features(prompt=False)
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}")
return
refreshed = [f for f, s in results.items() if s == "refreshed"]
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:")]
if refreshed:
print(f"{len(refreshed)} refreshed: {', '.join(refreshed)}")
if current:
print(f"{len(current)} already current")
if skipped:
# Most common reason: security.allow_lazy_installs=false. Show one
# line so the user knows why; not an error.
names = ", ".join(f for f, _ in skipped)
reason = skipped[0][1].split(": ", 1)[-1]
print(f" · {len(skipped)} skipped ({reason}): {names}")
if failed:
for feature, status in failed:
reason = status.split(": ", 1)[-1]
# Clip noisy pip stderr to keep update output legible.
if len(reason) > 200:
reason = reason[:200] + "..."
print(f"{feature} failed to refresh: {reason}")
print(" Backends keep their previously-installed version; rerun")
print(" `hermes update` once the upstream issue is resolved.")
def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
@ -7749,6 +7817,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
_refresh_active_lazy_features()
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")

View file

@ -226,3 +226,182 @@ class TestIsAvailable:
monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
assert ld.is_available("test.miss") is False
# ---------------------------------------------------------------------------
# Version-aware _is_satisfied (Piece B — "stale pin" detection)
#
# The original implementation returned True the moment the package name
# was importable, ignoring the spec's version range. That meant pin bumps
# in LAZY_DEPS never propagated to users who already lazy-installed the
# backend at an older version. _is_satisfied now parses the spec and
# checks the installed version against the constraint.
# ---------------------------------------------------------------------------
class TestIsSatisfiedVersionAware:
def _fake_version(self, monkeypatch, installed_versions: dict):
"""Patch importlib.metadata.version() inside lazy_deps."""
from importlib.metadata import PackageNotFoundError
def _version(pkg):
if pkg in installed_versions:
return installed_versions[pkg]
raise PackageNotFoundError(pkg)
# Patch at the import site lazy_deps uses (inside the function).
import importlib.metadata as _md
monkeypatch.setattr(_md, "version", _version)
def test_exact_pin_match_returns_true(self, monkeypatch):
self._fake_version(monkeypatch, {"honcho-ai": "2.0.1"})
assert ld._is_satisfied("honcho-ai==2.0.1") is True
def test_exact_pin_mismatch_returns_false(self, monkeypatch):
# Installed 2.0.0, spec requires 2.0.1 → False (needs upgrade).
self._fake_version(monkeypatch, {"honcho-ai": "2.0.0"})
assert ld._is_satisfied("honcho-ai==2.0.1") is False
def test_range_within_returns_true(self, monkeypatch):
self._fake_version(monkeypatch, {"slack-bolt": "1.27.0"})
assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is True
def test_range_above_returns_false(self, monkeypatch):
# Installed too new for the upper bound.
self._fake_version(monkeypatch, {"slack-bolt": "2.0.0"})
assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False
def test_range_below_returns_false(self, monkeypatch):
self._fake_version(monkeypatch, {"slack-bolt": "1.0.0"})
assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False
def test_package_not_installed_returns_false(self, monkeypatch):
self._fake_version(monkeypatch, {})
assert ld._is_satisfied("anthropic==0.86.0") is False
def test_bare_package_name_presence_is_enough(self, monkeypatch):
# No version constraint — presence alone counts as satisfied.
self._fake_version(monkeypatch, {"somepkg": "1.0.0"})
assert ld._is_satisfied("somepkg") is True
def test_extras_block_in_spec_is_stripped(self, monkeypatch):
# mautrix[encryption]==0.21.0 — the [encryption] block must not
# confuse the specifier parser.
self._fake_version(monkeypatch, {"mautrix": "0.21.0"})
assert ld._is_satisfied("mautrix[encryption]==0.21.0") is True
def test_extras_block_mismatch_returns_false(self, monkeypatch):
self._fake_version(monkeypatch, {"mautrix": "0.20.0"})
assert ld._is_satisfied("mautrix[encryption]==0.21.0") is False
# ---------------------------------------------------------------------------
# active_features + refresh_active_features (Piece A — hermes update wiring)
# ---------------------------------------------------------------------------
class TestActiveFeatures:
def test_no_packages_installed_returns_empty(self, monkeypatch):
monkeypatch.setattr(ld, "_is_present", lambda spec: False)
assert ld.active_features() == []
def test_finds_features_with_at_least_one_package_installed(self, monkeypatch):
# Pretend only honcho-ai is installed; nothing else.
monkeypatch.setattr(
ld, "_is_present",
lambda spec: ld._pkg_name_from_spec(spec) == "honcho-ai",
)
active = ld.active_features()
assert "memory.honcho" in active
# Backends the user never enabled stay quiet.
assert "memory.hindsight" not in active
assert "platform.slack" not in active
def test_multi_package_feature_active_if_any_present(self, monkeypatch):
# platform.slack has 3 packages; only one needs to be present
# for the feature to count as active (user activated it before,
# one transitive may have been uninstalled separately).
monkeypatch.setattr(
ld, "_is_present",
lambda spec: ld._pkg_name_from_spec(spec) == "slack-bolt",
)
assert "platform.slack" in ld.active_features()
class TestRefreshActiveFeatures:
def test_no_active_features_returns_empty(self, monkeypatch):
monkeypatch.setattr(ld, "active_features", lambda: [])
assert ld.refresh_active_features() == {}
def test_already_current_is_noop(self, monkeypatch):
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==1.0.0",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
# If pip were called, this would fail loudly.
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
result = ld.refresh_active_features()
assert result == {"test.feat": "current"}
def test_stale_pin_triggers_reinstall(self, monkeypatch):
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",))
# First _is_satisfied check (in feature_missing) says no; after
# install, post-install check says yes.
states = iter([False, True])
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states))
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
)
result = ld.refresh_active_features()
assert result == {"test.feat": "refreshed"}
def test_install_failure_recorded_not_raised(self, monkeypatch):
# A failed refresh must NOT raise out of hermes update.
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(
False, "", "ERROR: PyPI 404 quarantine"
),
)
result = ld.refresh_active_features()
assert "test.feat" in result
assert result["test.feat"].startswith("failed:")
assert "404 quarantine" in result["test.feat"]
def test_lazy_installs_disabled_marked_skipped(self, monkeypatch):
# security.allow_lazy_installs=false → don't error, mark skipped
# so hermes update can render "respecting your config" message.
monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"])
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",))
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False)
result = ld.refresh_active_features()
assert "test.feat" in result
assert result["test.feat"].startswith("skipped:")
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",))
monkeypatch.setitem(ld.LAZY_DEPS, "b.fail", ("pkgb==1.0",))
# a.ok: already satisfied → "current"
# b.fail: missing + install fails → "failed:"
def fake_satisfied(spec):
return ld._pkg_name_from_spec(spec) == "pkga"
monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied)
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(False, "", "nope"),
)
result = ld.refresh_active_features()
assert result["a.ok"] == "current"
assert result["b.fail"].startswith("failed:")

View file

@ -248,12 +248,69 @@ def _pkg_name_from_spec(spec: str) -> str:
return m.group(1) if m else spec
def _is_satisfied(spec: str) -> bool:
"""Best-effort check: is ``spec`` already satisfied in the current env?
def _specifier_from_spec(spec: str) -> str:
"""Extract just the version-specifier portion of a pip spec.
We don't enforce the version range — if the package is importable
we assume the user knows what they're doing. This matches how the
lazy-import sites already behave.
``"honcho-ai==2.0.1"`` ``"==2.0.1"``
``"mautrix[encryption]>=0.20,<1"`` ``">=0.20,<1"``
``"package"`` ``""`` (no version constraint)
"""
# Strip the package name + optional [extras] block.
m = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*(?:\[[A-Za-z0-9_,\-]+\])?", spec)
if not m:
return ""
return spec[m.end():]
def _is_satisfied(spec: str) -> bool:
"""Is ``spec`` already satisfied in the current env?
Checks both presence AND version. If the package is installed at a
version outside the spec's range, returns False so the caller will
upgrade/downgrade to the pinned version. This is what makes
``hermes update`` propagate pin bumps in :data:`LAZY_DEPS` to already-
installed backends instead of silently leaving stale versions in place.
If ``packaging`` is unavailable for any reason (it's a transitive of
pip so this should never happen), we fall back to a presence-only check
so we err on the side of "don't churn".
"""
pkg = _pkg_name_from_spec(spec)
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
return False
try:
installed = version(pkg)
except PackageNotFoundError:
return False
except Exception:
return False
spec_tail = _specifier_from_spec(spec)
if not spec_tail:
# Bare ``"package"`` — no version constraint, presence is enough.
return True
try:
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
except ImportError:
# packaging unavailable — fall back to "installed counts as satisfied".
return True
try:
return Version(installed) in SpecifierSet(spec_tail)
except (InvalidSpecifier, InvalidVersion, Exception):
# Malformed spec or installed version we can't parse — don't churn.
return True
def _is_present(spec: str) -> bool:
"""Cheap presence-only check (package name installed at any version).
Used by :func:`active_features` to detect backends the user has
previously activated, regardless of whether the version pin moved.
"""
pkg = _pkg_name_from_spec(spec)
try:
@ -442,6 +499,57 @@ def feature_install_command(feature: str) -> Optional[str]:
return "uv pip install " + " ".join(repr(s) for s in specs)
def active_features() -> list[str]:
"""Return the list of features the user has ever lazy-installed.
A feature counts as "active" if at least one of its declared packages
is currently installed in the venv (presence check, ignoring version).
Features the user has never enabled stay quiet.
Used by ``hermes update`` to figure out which lazy backends need a
refresh pass when pins move in :data:`LAZY_DEPS`.
"""
active = []
for feature, specs in LAZY_DEPS.items():
if any(_is_present(s) for s in specs):
active.append(feature)
return active
def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
"""Re-run ``ensure`` for every feature the user has previously activated.
Returns a ``{feature: status}`` map where status is one of:
``"current"`` pins already satisfied, no install run
``"refreshed"`` pins were stale, reinstall succeeded
``"failed: <reason>"`` install attempt failed; caller decides
whether to surface it (we don't raise)
``"skipped: <reason>"`` gated off (config flag, user decline)
Intended for ``hermes update``. Never raises; lazy-install failures
here must not block the rest of the update flow.
"""
results: dict[str, str] = {}
for feature in active_features():
missing = feature_missing(feature)
if not missing:
results[feature] = "current"
continue
try:
ensure(feature, prompt=prompt)
results[feature] = "refreshed"
except FeatureUnavailable as e:
# Distinguish "user opted out" from "install failed" so the
# update command can render the right message.
if "lazy installs disabled" in str(e) or "declined" in str(e):
results[feature] = f"skipped: {e.reason}"
else:
results[feature] = f"failed: {e.reason}"
except Exception as e:
results[feature] = f"failed: {e}"
return results
def ensure_and_bind(
feature: str,
importer: Callable[[], dict[str, Any]],