diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index dd6c41af14..c9861506dd 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -272,21 +272,18 @@ def _live_cache_path() -> Path: return get_hermes_home() / "cache" / "plugin-catalog.json" -# Last failed live fetch: without it, a dead catalog host costs one full request -# timeout PER CALLER (the plugins hub alone asks once per installed plugin), so -# the dashboard event loop stalls for minutes. A remembered failure keeps those -# callers on the in-tree copy until the TTL lets one fresh attempt through. -_live_fetch_failed_until: Dict[str, float] = {} +# Wall-clock deadline of the last failed live fetch. Without it a dead catalog host costs one +# full request timeout PER CALL (the plugins hub and ``plugins list`` used to ask once per +# installed plugin), so the dashboard event loop stalled for minutes. +_live_fetch_failed_until = 0.0 -def _live_fetch_failure_recent() -> bool: - failed_until = _live_fetch_failed_until.get(LIVE_CATALOG_URL) - return failed_until is not None and time.time() < failed_until - - -def _remember_live_fetch_failure() -> None: - failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS - _live_fetch_failed_until[LIVE_CATALOG_URL] = failed_until +def _stale_live_cache(cache: Path) -> Optional[Dict[str, Any]]: + """A previously fetched copy still beats the in-tree one when the network is down.""" + try: + return json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None + except Exception: + return None def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]: @@ -294,21 +291,16 @@ def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]: ``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall back to the in-tree catalog. A failed network attempt is remembered for :data:`LIVE_CATALOG_FAILURE_TTL_SECONDS` so a dead host costs one timeout per TTL window, not one - per caller (``force`` bypasses both caches).""" - if not force and _live_fetch_failure_recent(): - try: # stale cache still beats the in-tree copy when the network is down - cache = _live_cache_path() - if cache.is_file(): - return json.loads(cache.read_text(encoding="utf-8")) - except Exception: - pass - return None + per call (``force`` bypasses both caches).""" + global _live_fetch_failed_until cache = _live_cache_path() try: if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS: return json.loads(cache.read_text(encoding="utf-8")) except Exception as exc: logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc) + if not force and time.time() < _live_fetch_failed_until: + return _stale_live_cache(cache) try: import httpx from hermes_constants import mkdir_under_hermes_home @@ -325,11 +317,8 @@ def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]: return data except Exception as exc: logger.debug("Plugin catalog: live fetch failed: %s", exc) - _remember_live_fetch_failure() - try: # stale cache still beats the in-tree copy when the network is down - return json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None - except Exception: - return None + _live_fetch_failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS + return _stale_live_cache(cache) def load_catalog_live() -> List[PluginCatalogEntry]: diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 58e6d07c85..d4e663d576 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1434,16 +1434,11 @@ def cmd_list(args: Any | None = None) -> None: pins = _read_install_metadata() # One kill-list resolution for the whole listing: resolving per row costs a live-catalog # fetch per installed plugin when the catalog host is slow or unreachable. - resolved_removed = catalog.resolved_removed_entries() + removed_entries = catalog.resolved_removed_entries() rows = [ - ( - name, - _plugin_status(name, enabled, disabled, key=key), - str(version), - description, - catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source, - catalog.removed_annotation(name, _dir, removed_entries=resolved_removed), - ) + (name, _plugin_status(name, enabled, disabled, key=key), str(version), description, + catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source, + catalog.removed_annotation(name, _dir, removed_entries)) for name, version, description, source, _dir, key in entries ] diff --git a/hermes_cli/plugins_cmd_catalog.py b/hermes_cli/plugins_cmd_catalog.py index c29c719326..e99057d559 100644 --- a/hermes_cli/plugins_cmd_catalog.py +++ b/hermes_cli/plugins_cmd_catalog.py @@ -15,10 +15,10 @@ from pathlib import Path from typing import Any, Dict, List, Optional from hermes_cli.plugin_catalog import ( - PluginCatalogEntry, entry_capability_summary, filter_entries, find_removed, get_live_catalog_entry, - load_catalog_live, load_removed_list, _NAME_RE, + PluginCatalogEntry, RemovedEntry, entry_capability_summary, filter_entries, find_removed, + get_live_catalog_entry, load_catalog_live, load_removed_list, match_removed, resolved_removed_entries, + _NAME_RE, ) -from hermes_cli.plugin_catalog import match_removed, resolved_removed_entries logger = logging.getLogger(__name__) @@ -94,47 +94,23 @@ def catalog_annotation(dir_path) -> Optional[str]: return f"catalog:{sidecar.get('tier') or 'community'}@{str(sidecar.get('sha') or '')[:8]}" -def removed_annotation(name: str, dir_path, *, removed_entries=None) -> Optional[str]: +def removed_annotation(name: str, dir_path, removed_entries: Optional[List[RemovedEntry]] = None) -> Optional[str]: """Kill-list reason when an INSTALLED plugin matches by name, catalog name or repo, else ``None``. - ``removed_entries`` reuses one pre-resolved kill list (``resolved_removed_entries()``) across - many rows; resolving per row costs a live-catalog fetch per installed plugin. + Callers annotating many rows (``plugins list``, the dashboard hub) resolve the kill list once + with :func:`plugin_catalog.resolved_removed_entries` and pass it as ``removed_entries``: + resolving per row cost one live-catalog fetch — one network timeout, offline — per plugin. """ - if removed_entries is not None: - return removed_annotation_batch([(name, dir_path)], removed_entries).get(name) + if removed_entries is None: + removed_entries = resolved_removed_entries() sidecar = read_catalog_sidecar(dir_path) or {} for candidate in (name, sidecar.get("catalog_name"), sidecar.get("repo")): - removed = find_removed(str(candidate)) if candidate else None + removed = match_removed(str(candidate), removed_entries) if candidate else None if removed is not None: return removed.reason or "no reason recorded" return None -def removed_annotation_batch( - names_and_dirs, removed_entries -) -> Dict[str, Optional[str]]: - """``removed_annotation`` for many plugins against ONE pre-resolved kill list. - - The dashboard plugins hub annotates every installed plugin on every rebuild; resolving the kill - list per plugin turns the hub into one live-catalog fetch per row. Callers resolve the list once - (:func:`plugin_catalog.resolved_removed_entries`) and pass it here. - """ - annotations: Dict[str, Optional[str]] = {} - for name, dir_path in names_and_dirs: - sidecar = read_catalog_sidecar(dir_path) or {} - candidates = (name, sidecar.get("catalog_name"), sidecar.get("repo")) - for candidate in candidates: - if not candidate: - continue - removed = match_removed(str(candidate), removed_entries) - if removed is not None: - annotations[name] = removed.reason or "no reason recorded" - break - else: - annotations[name] = None - return annotations - - # ── Catalog-aware install / update ─────────────────────────────────────────── def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None, diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index 702a295b30..7e602279b0 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -674,7 +674,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]: _get_enabled_set, _read_manifest as _read_plugin_manifest_at, ) - from hermes_cli.plugins_cmd_catalog import removed_annotation_batch + from hermes_cli.plugins_cmd_catalog import removed_annotation from hermes_cli.plugin_catalog import resolved_removed_entries dashboard_list = _get_dashboard_plugins() @@ -685,15 +685,11 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]: plugins_root_resolved = (get_hermes_home() / "plugins").resolve() rows: List[Dict[str, Any]] = [] - discovered = _discover_all_plugins() # One kill-list resolution for the whole rebuild: resolving per row costs a live-catalog # fetch per installed plugin when the catalog host is slow or unreachable. - removed_annotations = removed_annotation_batch( - ((name, dir_str) for name, _v, _d, _s, dir_str, _k in discovered), - resolved_removed_entries(), - ) + removed_entries = resolved_removed_entries() - for name, version, description, source, dir_str, key in discovered: + for name, version, description, source, dir_str, key in _discover_all_plugins(): # Both the path-derived key (nested category plugins) and the bare manifest name # count for enabled/disabled state, matching the runtime loader's back-compat lookup. aliases = {name, key} if key else {name} @@ -725,7 +721,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]: "auth_required": auth_required, "auth_command": auth_command, "user_hidden": name in hidden_plugins, - "removed_reason": removed_annotations.get(name), + "removed_reason": removed_annotation(name, dir_str, removed_entries), }) agent_names = {r["name"] for r in rows} diff --git a/tests/hermes_cli/test_plugins_hub_live_catalog.py b/tests/hermes_cli/test_plugins_hub_live_catalog.py index 94885b11a4..02199cfabd 100644 --- a/tests/hermes_cli/test_plugins_hub_live_catalog.py +++ b/tests/hermes_cli/test_plugins_hub_live_catalog.py @@ -1,48 +1,38 @@ -"""Plugins hub must not multiply live-catalog fetches (issue #113677 fix contract). +"""Live plugin-catalog lookups must not multiply per installed plugin. -A hub rebuild annotates every installed plugin with the kill-list reason; resolving the kill list -per row cost one synchronous catalog HTTPS request per candidate, so a slow/unreachable catalog -host stalled the dashboard event loop for minutes. These tests pin the two halves of the fix: -one kill-list resolution per rebuild, and a remembered failed fetch within a short TTL window. +A slow or unreachable catalog host used to cost one request timeout per plugin row, on the +dashboard plugins-hub rebuild (inline on the event loop) and on ``hermes plugins list``. """ from __future__ import annotations +import argparse import json -import threading +import os from pathlib import Path from types import SimpleNamespace import pytest from hermes_cli import plugin_catalog as pc -from hermes_cli import plugins_cmd_catalog as pcc +from hermes_cli import plugins_cmd from hermes_cli import web_server import hermes_cli.config as _cfg_mod import hermes_cli.web_server_dashboard as _web_server_dashboard import hermes_cli.web_server_memory as _web_server_memory -from hermes_cli import plugins_cmd from tools import registry as tools_registry -_PLUGIN_ROWS = [ - ("demo", "1.0.0", "demo plugin", "user", "/tmp/demo-plugin", "demo"), - ("second", "0.2.0", "second plugin", "user", "/tmp/second-plugin", "second"), -] - - @pytest.fixture(autouse=True) -def _reset_live_fetch_state(monkeypatch, tmp_path): - monkeypatch.setattr(pc, "_live_fetch_failed_until", {}) - monkeypatch.setattr( - pc, "_live_cache_path", lambda: tmp_path / "cache" / "plugin-catalog.json" - ) +def _isolated_live_catalog(monkeypatch, tmp_path): + monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0, raising=False) + monkeypatch.setattr(pc, "_live_cache_path", lambda: tmp_path / "cache" / "plugin-catalog.json") tools_registry.invalidate_check_fn_cache() _web_server_dashboard._invalidate_plugins_hub_cache() class _UnreachableCatalog: - """Counts network attempts; every attempt blocks like a real timeout, then fails.""" + """Counts network attempts; every one fails like a dead host.""" def __init__(self): self.attempts = 0 @@ -52,200 +42,67 @@ class _UnreachableCatalog: raise OSError("catalog host unreachable") -def _patch_hub_dependencies(monkeypatch, *, rows=None): - rows = rows if rows is not None else list(_PLUGIN_ROWS) - monkeypatch.setattr( - web_server, "_get_dashboard_plugins", lambda force_rescan=False: [] - ) - monkeypatch.setattr( - _web_server_memory, "_discover_memory_provider_statuses", lambda: [] - ) +def test_failed_live_fetch_is_remembered_until_the_failure_ttl(monkeypatch, tmp_path): + """One network attempt per failure window; inside it a stale on-disk copy still answers + (removals published before the outage keep blocking) and the network is left alone.""" + clock = {"now": 1_000_000.0} + monkeypatch.setattr(pc.time, "time", lambda: clock["now"]) + unreachable = _UnreachableCatalog() + monkeypatch.setattr("httpx.get", unreachable) + + assert pc.fetch_live_catalog() is None + assert pc.fetch_live_catalog() is None + assert unreachable.attempts == 1 + + cache = pc._live_cache_path() + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps({"entries": [], "removed": [{"name": "pulled-live", "reason": "cve"}]})) + stale = clock["now"] - pc.LIVE_CATALOG_TTL_SECONDS - 1 # older than the success TTL under the fake clock + os.utime(cache, (stale, stale)) + assert pc.fetch_live_catalog()["removed"][0]["name"] == "pulled-live" + assert unreachable.attempts == 1 + + clock["now"] += pc.LIVE_CATALOG_FAILURE_TTL_SECONDS + 1 + pc.fetch_live_catalog() + assert unreachable.attempts == 2 # window over: exactly one fresh attempt + + +_PLUGIN_ROWS = [ + ("demo", "1.0.0", "demo plugin", "user", "/tmp/demo-plugin", "demo"), + ("second", "0.2.0", "second plugin", "user", "/tmp/second-plugin", "second"), + ("third", "0.3.0", "third plugin", "user", "/tmp/third-plugin", "third"), +] + + +def test_hub_rebuild_and_plugins_list_resolve_the_kill_list_once(monkeypatch, tmp_path, capsys): + """Both listing surfaces cost at most ONE network attempt with the catalog unreachable — not + one per installed plugin — and still report an in-tree removal for every affected row.""" + unreachable = _UnreachableCatalog() + monkeypatch.setattr("httpx.get", unreachable) + monkeypatch.setattr(pc, "get_catalog_dir", lambda: tmp_path) + (tmp_path / "removed.yaml").write_text("removed:\n- name: demo\n reason: exfiltrated env vars\n") + + monkeypatch.setattr(web_server, "_get_dashboard_plugins", lambda force_rescan=False: []) + monkeypatch.setattr(_web_server_memory, "_discover_memory_provider_statuses", lambda: []) monkeypatch.setattr(_cfg_mod, "get_hermes_home", lambda: Path("/tmp/hermes-home")) - monkeypatch.setattr( - _cfg_mod, "load_config", lambda: {"dashboard": {"hidden_plugins": []}} - ) - monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: rows) - monkeypatch.setattr( - plugins_cmd, "_get_current_context_engine", lambda: "compressor" - ) + monkeypatch.setattr(_cfg_mod, "load_config", lambda: {"dashboard": {"hidden_plugins": []}}) + monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: list(_PLUGIN_ROWS)) + monkeypatch.setattr(plugins_cmd, "_get_current_context_engine", lambda: "compressor") monkeypatch.setattr(plugins_cmd, "_get_current_memory_provider", lambda: "") monkeypatch.setattr(plugins_cmd, "_discover_context_engines", lambda: []) monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set()) monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"demo"}) - monkeypatch.setattr( - plugins_cmd, "_read_manifest", lambda _path: {"provides_tools": []} - ) - monkeypatch.setattr( - tools_registry.registry, - "get_entry", - lambda _name: SimpleNamespace(check_fn=None), - ) - - -def test_hub_rebuild_issues_one_network_attempt_then_none(monkeypatch): - """An unreachable catalog host costs ONE timeout per rebuild (not one per installed plugin), - and rebuilds inside the failure window cost none at all.""" - unreachable = _UnreachableCatalog() - monkeypatch.setattr("httpx.get", unreachable) - - _patch_hub_dependencies(monkeypatch) + monkeypatch.setattr(plugins_cmd, "_read_manifest", lambda _path: {"provides_tools": []}) + monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {}) + monkeypatch.setattr(tools_registry.registry, "get_entry", lambda _name: SimpleNamespace(check_fn=None)) payload = _web_server_dashboard._merged_plugins_hub(force_refresh=True) - assert len(payload["plugins"]) == len(_PLUGIN_ROWS) - assert all(row["removed_reason"] is None for row in payload["plugins"]) + by_name = {row["name"]: row["removed_reason"] for row in payload["plugins"]} + assert by_name == {"demo": "exfiltrated env vars", "second": None, "third": None} assert unreachable.attempts == 1 - _web_server_dashboard._invalidate_plugins_hub_cache() - _web_server_dashboard._merged_plugins_hub(force_refresh=True) - assert unreachable.attempts == 1 # remembered failure: no second timeout - - -def test_failed_fetch_is_remembered_only_within_ttl(monkeypatch): - clock = {"now": 1_000_000.0} - attempts = {"count": 0} - - def fake_get(url, **kwargs): - attempts["count"] += 1 - raise OSError("catalog host unreachable") - - monkeypatch.setattr(pc.time, "time", lambda: clock["now"]) - monkeypatch.setattr("httpx.get", fake_get) - - assert pc.fetch_live_catalog() is None - pc.fetch_live_catalog() # remembered failure: served without a second attempt - assert attempts["count"] == 1 - - clock["now"] += pc.LIVE_CATALOG_FAILURE_TTL_SECONDS + 1 - assert ( - pc.fetch_live_catalog() is None - ) # TTL expired: one fresh attempt reaches the network - assert attempts["count"] == 2 - - -def test_failure_window_prefers_stale_cache_over_in_tree(monkeypatch, tmp_path): - """Inside the failure window a previously fetched on-disk cache still answers — removals - published before the outage keep blocking without any network traffic.""" - cache = tmp_path / "cache" / "plugin-catalog.json" - cache.parent.mkdir(parents=True) - cache.write_text( - json.dumps({ - "entries": [], - "removed": [{"name": "pulled-live", "reason": "cve"}], - }) - ) - monkeypatch.setattr("httpx.get", _UnreachableCatalog()) - - data = pc.fetch_live_catalog() - assert data is not None and data["removed"][0]["name"] == "pulled-live" - assert pc.fetch_live_catalog() is not None # still served from the stale cache - - -def test_batch_annotation_matches_per_plugin_lookup(monkeypatch, tmp_path): - """The batch resolver returns exactly what per-plugin ``removed_annotation`` returns, across - name matches, sidecar catalog names and repo URLs.""" - kill_list = [ - pc.RemovedEntry( - name="evil", repo="https://github.com/x/evil.git", reason="malware" - ), - pc.RemovedEntry(name="pulled-live", reason="cve"), - ] - monkeypatch.setattr(pc, "load_removed_list", lambda *a, **k: kill_list) - monkeypatch.setattr(pc, "live_removed_list", lambda: []) - - sidecar_dir = tmp_path / "installed-from-catalog" - sidecar_dir.mkdir() - (sidecar_dir / pcc.CATALOG_SIDECAR).write_text( - json.dumps({ - "catalog_name": "pulled-live", - "repo": "https://github.com/x/other", - "sha": "0" * 40, - "tier": "community", - }) - ) - repo_dir = tmp_path / "installed-from-repo" - repo_dir.mkdir() - (repo_dir / pcc.CATALOG_SIDECAR).write_text( - json.dumps({ - "catalog_name": "unknown", - "repo": "https://github.com/x/EVIL/", - "sha": "0" * 40, - "tier": "community", - }) - ) - plain_dir = tmp_path / "plain" - plain_dir.mkdir() - - plugins = [ - ("evil", plain_dir), - ("catalog-install", sidecar_dir), - ("repo-install", repo_dir), - ("fine", plain_dir), - ] - batch = pcc.removed_annotation_batch(plugins, pc.resolved_removed_entries()) - - for name, dir_path in plugins: - assert batch[name] == pcc.removed_annotation(name, dir_path) - assert batch["evil"] == "malware" - assert batch["catalog-install"] == "cve" - assert ( - batch["repo-install"] == "malware" - ) # repo match is .git/trailing-slash insensitive - assert batch["fine"] is None - - -def test_hub_rebuild_annotates_from_preloaded_kill_list(monkeypatch, tmp_path): - """The hub surfaces removal reasons from ONE resolved kill list even when the live catalog - is unreachable — an installed removed plugin is still reported, at hub-rebuild speed.""" - monkeypatch.setattr("httpx.get", _UnreachableCatalog()) - monkeypatch.setattr(pc, "get_catalog_dir", lambda: tmp_path) - (tmp_path / "removed.yaml").write_text( - "removed:\n- name: demo\n reason: exfiltrated env vars\n" - ) - - _patch_hub_dependencies(monkeypatch) - payload = _web_server_dashboard._merged_plugins_hub(force_refresh=True) - - by_name = {row["name"]: row["removed_reason"] for row in payload["plugins"]} - assert by_name["demo"] == "exfiltrated env vars" - assert by_name["second"] is None - - -def test_cmd_list_resolves_kill_list_once(monkeypatch, tmp_path, capsys): - """``hermes plugins list`` resolves the kill list ONCE per listing, not per row: a slow or - unreachable live catalog must cost one resolution (one network attempt at most) regardless - of how many plugins are installed.""" - import argparse - - calls = {"load_removed_list": 0, "live_removed_list": 0} - kill_list = [pc.RemovedEntry(name="pulled-plugin", reason="security review")] - - def counted_load_removed(*args, **kwargs): - calls["load_removed_list"] += 1 - return kill_list - - def counted_live_removed(): - calls["live_removed_list"] += 1 - return [] - - monkeypatch.setattr(pc, "load_removed_list", counted_load_removed) - monkeypatch.setattr(pc, "live_removed_list", counted_live_removed) - monkeypatch.setattr( - plugins_cmd, "_discover_all_plugins", - lambda: [(f"plugin-{i}", "1.0", "", "user", tmp_path / str(i), f"plugin-{i}") - for i in range(3)] - + [("pulled-plugin", "1.0", "", "user", tmp_path / "pulled", "pulled-plugin")], - ) - monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"plugin-0"}) - monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set()) - monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {}) - monkeypatch.setattr(pc, "_live_cache_path", lambda: tmp_path / "cache" / "plugin-catalog.json") - - plugins_cmd.cmd_list(argparse.Namespace( - enabled=False, user=False, no_bundled=False, plain=False, json=True)) - - assert calls["load_removed_list"] == 1 - assert calls["live_removed_list"] == 1 - rows = json.loads(capsys.readouterr().out) - by_name = {row["name"]: row["removed"] for row in rows} - assert by_name["pulled-plugin"] == "security review" - assert by_name["plugin-0"] is None + monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0) # forget the failure: a fresh window + plugins_cmd.cmd_list(argparse.Namespace(enabled=False, user=False, no_bundled=False, plain=False, json=True)) + rows = {row["name"]: row["removed"] for row in json.loads(capsys.readouterr().out)} + assert rows == {"demo": "exfiltrated env vars", "second": None, "third": None} + assert unreachable.attempts == 2 # one more for the whole listing, not one per row diff --git a/tests/hermes_cli/test_plugins_hub_perf_guard.py b/tests/hermes_cli/test_plugins_hub_perf_guard.py index 254a6862ec..6bfd8a2155 100644 --- a/tests/hermes_cli/test_plugins_hub_perf_guard.py +++ b/tests/hermes_cli/test_plugins_hub_perf_guard.py @@ -11,6 +11,7 @@ import hermes_cli.web_routers.dashboard_ui as _rt_dashboard_ui import hermes_cli.web_server_dashboard as _web_server_dashboard import hermes_cli.web_server_memory as _web_server_memory from hermes_cli import plugins_cmd +from hermes_cli import plugin_catalog from hermes_cli import plugins_cmd_catalog from tools import registry as tools_registry @@ -156,11 +157,12 @@ def test_plugins_hub_route_builds_catalog_annotations_off_event_loop(monkeypatch _patch_minimal_hub_dependencies(monkeypatch, check_fn=lambda: True) monkeypatch.setattr(web_server, "_require_token", lambda _request: None) - def removed_annotation(name, _dir_path): + def removed_annotation(name, _dir_path, _removed_entries=None): annotation_threads.append(threading.current_thread()) return "withdrawn by catalog" if name == "demo" else None monkeypatch.setattr(plugins_cmd_catalog, "removed_annotation", removed_annotation) + monkeypatch.setattr(plugin_catalog, "resolved_removed_entries", lambda: []) payload = asyncio.run(_rt_dashboard_ui.get_plugins_hub(object())) diff --git a/website/docs/user-guide/features/plugin-catalog.md b/website/docs/user-guide/features/plugin-catalog.md index 1972c934f7..ac306de8ec 100644 --- a/website/docs/user-guide/features/plugin-catalog.md +++ b/website/docs/user-guide/features/plugin-catalog.md @@ -175,7 +175,9 @@ The docs build publishes the catalog as one JSON document (`https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json`). `search`/`install`/`update` fetch it at most every six hours and cache it under `~/.hermes/cache/`, so new entries and removals reach installed clients without -updating Hermes. Offline, the copy shipped with your checkout is used. Removals +updating Hermes. Offline, the copy shipped with your checkout is used (a failed +fetch is remembered for a minute, so `plugins list` and the dashboard's Plugins +page pay at most one connection timeout, not one per installed plugin). Removals from the in-tree list and the live list are always both enforced. ### Custom git URLs are different