Files
hermes-agent/hermes_cli/plugins_ledger.py
Teknium 5bd439d3ed refactor(plugins): own dual-kind hook fallback in the ledger mixin
Move the general-vs-memory hook ownership logic out of the memory collector into
PluginLedgerMixin (_drop_fallback_hooks / _register_fallback_hook) so the collector
and the loader each call one manager method instead of reaching into manager privates.
Hoist hashlib to module scope. Trim the new suite to the three invariant cases
(run-once across load orders, distinct sources not suppressed, re-exported register).
2026-09-06 13:36:12 -07:00

302 lines
15 KiB
Python

"""Registration ownership ledger and unload: every plugin registration is recorded with its inverse so force
reload / targeted unload unwind registries in reverse order. Mixed into :class:`hermes_cli.plugins.PluginManager`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union
from registration_lifecycle import replacement_coordinator
from hermes_cli.plugins_loader import _plugin_home_scope
from hermes_cli.plugins_manifest import PluginManifest, manifest_key
if TYPE_CHECKING: # pragma: no cover
from hermes_cli.plugins import LoadedPlugin
logger = logging.getLogger("hermes_cli.plugins")
def _hook_source_of(name: str, module: Any) -> Optional[tuple]:
"""(plugin name, resolved ``__file__``) identity shared by the general and memory loaders."""
source = getattr(module, "__file__", None)
return (name, str(Path(source).resolve())) if source else None
@dataclass
class PluginRegistration:
"""One host-owned registration plus its inverse, so force reload unwinds registries in reverse order
(including restoring the entry an override replaced)."""
kind: str
key: str
release: Callable[[], None]
plugin_key: str = ""
# Process-global host infrastructure (e.g. dashboard-auth providers): kept out of ``_registration_order``
# so unload-all cannot dispose it, but still disposed by a *targeted* unload and evicted on force
# re-discovery when the plugin no longer re-registers it.
# See #91701.
persistent: bool = False
_disposed: bool = field(default=False, init=False, repr=False)
_on_dispose: Optional[Callable[["PluginRegistration"], None]] = field(default=None, init=False, repr=False)
@property
def active(self) -> bool:
"""Whether this handle still owns an active registration."""
return not self._disposed
def dispose(self) -> None:
"""Release this registration once; repeated disposal is harmless."""
if self._disposed:
return
self._disposed = True
try:
self.release()
finally:
if self._on_dispose is not None:
self._on_dispose(self)
class PluginLedgerMixin:
def _track_registration(
self, manifest: PluginManifest, kind: str, key: str, release: Callable[[], None], *,
persistent: bool = False,
) -> PluginRegistration:
"""Record one registration under its canonical plugin key. ``persistent`` ones (process-global host
infrastructure) stay in the ownership ledger for attribution but NOT in ``_registration_order``, so a
routine unload cannot dispose them; the handle still releases on explicit ``dispose()``.
See #91701.
"""
registration = PluginRegistration(
kind=kind, key=key, release=release, plugin_key=manifest_key(manifest), persistent=persistent)
registration._on_dispose = lambda disposed: self._forget_registrations([disposed])
self._ownership_ledger.setdefault(registration.plugin_key, []).append(registration)
if not persistent:
self._registration_order.append(registration)
return registration
def _track_scoped_registration(
self, manifest: PluginManifest, kind: str, name: str, registry: Any, current: Any,
previous: Any, *, finalize: Optional[Callable[[], None]] = None,
) -> PluginRegistration:
"""Lease one ``(kind, scope, name)`` slot of a scope-keyed process-global registry. Unload calls
``registry.restore_registration(name, current, replacement, scope=...)`` — identity-conditional, so a
later generation is never removed by an earlier owner."""
scope = self.scope_key
lease = replacement_coordinator.acquire(
(kind, scope, name), current=current, previous=previous,
restore=lambda replacement: registry.restore_registration(name, current, replacement, scope=scope),
finalize=finalize,
)
return self._track_registration(manifest, kind, name, lease.dispose)
def _active_persistent(self) -> List[PluginRegistration]:
"""Live persistent registrations across every plugin in the ownership ledger."""
return [r for owned in self._ownership_ledger.values() for r in owned if r.persistent and r.active]
def _evict_stale_persistent_registrations(self) -> None:
"""After re-discovery, dispose parked persistent handles whose plugin did not re-register the same
``(kind, key)``. Re-registered ones are dropped WITHOUT disposing — the same object re-registered would
pass the identity check and evict the live entry.
Persistent registrations (process-global host infrastructure such as dashboard-auth providers)
survive an unload-all by design (#91701); ``_unload_scoped`` parks their handles in
``_persistent_carryover``. After a re-discovery pass, three cases exist for each parked handle:
"""
if not self._persistent_carryover:
return
parked, self._persistent_carryover = self._persistent_carryover, []
current = {(r.kind, r.key) for r in self._active_persistent()}
stale = [r for r in parked if r.active and (r.kind, r.key) not in current]
for registration in stale:
logger.info(
"Evicting persistent registration %s/%s: plugin '%s' no "
"longer supplies it after re-discovery", registration.kind, registration.key, registration.plugin_key,
)
self._dispose_registrations(stale)
@staticmethod
def _remove_identity(values: list, target: Any) -> bool:
"""Remove the last exact object match from a registration list."""
index = next((i for i in range(len(values) - 1, -1, -1) if values[i] is target), None)
if index is None:
return False
del values[index]
return True
def _remove_callback(self, mapping: Dict[str, List[Callable]], key: str, callback: Callable) -> None:
callbacks = mapping.get(key)
if callbacks is None:
return
self._remove_identity(callbacks, callback)
if not callbacks:
mapping.pop(key, None)
def _restore_mapping(self, mapping: Dict[str, Any], key: str, current: Any, previous: Optional[Any]) -> bool:
"""Restore a manager-local mapping only when *current* is still present."""
if mapping.get(key) is not current:
return False
if previous is None:
mapping.pop(key, None)
else:
mapping[key] = previous
return True
def _restore_value(self, attribute: str, current: Any, previous: Any) -> bool:
"""Restore a manager-local value only when *current* is still active."""
if getattr(self, attribute) is not current:
return False
setattr(self, attribute, previous)
return True
def _remove_name_if_unowned(self, kind: str, names: Set[str], name: str) -> None:
"""Drop *name* from the manager-local name set once no active ledger entry owns it."""
if not any(r.active and r.kind == kind and r.key == name for r in self._registration_order):
names.discard(name)
def _remove_tool_name_if_unowned(self, name: str) -> None:
self._remove_name_if_unowned("tool", self._plugin_tool_names, name)
def _remove_platform_name_if_unowned(self, name: str) -> None:
self._remove_name_if_unowned("platform", self._plugin_platform_names, name)
def _forget_registrations(self, registrations: List[PluginRegistration]) -> None:
if not registrations:
return
ids = {id(r) for r in registrations}
self._registration_order = [r for r in self._registration_order if id(r) not in ids]
for ledger in (self._ownership_ledger, self._memory_hook_registrations):
for plugin_key, owned in list(ledger.items()):
remaining = [r for r in owned if id(r) not in ids]
if remaining:
ledger[plugin_key] = remaining
else:
ledger.pop(plugin_key, None)
# -- dual-kind hook ownership -------------------------------------------------------------
# A plugin dir loaded by general discovery AND as the configured memory provider calls
# ``register()`` twice against the same manager. General discovery owns the hook group; the
# memory loader's hooks are a fallback group keyed by (name, resolved source path) that is
# dropped once the same source loads through discovery. Groups, not callbacks, are replaced:
# one register() may deliberately create several closures for one hook.
def _drop_fallback_hooks(self, hook_source: Optional[tuple]) -> None:
if hook_source is not None:
for handle in self._memory_hook_registrations.pop(hook_source, []):
handle.dispose()
def _register_fallback_hook(self, context: Any, hook_source: Optional[tuple], hook_name: str,
callback: Callable) -> PluginRegistration:
"""Register ``callback`` unless the same source is already live through general discovery, in
which case return an inert handle so the provider keeps its disposable-handle contract."""
if hook_source is None:
return context.register_hook(hook_name, callback)
owned = any(loaded.enabled and _hook_source_of(loaded.manifest.name, loaded.module) == hook_source
for loaded in self._plugins.values())
handle = context._track("hook", hook_name, lambda: None) if owned else context.register_hook(hook_name, callback)
self._memory_hook_registrations.setdefault(hook_source, []).append(handle)
return handle
def _dispose_registrations(self, registrations: List[PluginRegistration]) -> None:
"""Dispose registrations in reverse acquisition order, best effort."""
from hermes_cli.plugins import _PLUGINS_DEBUG
for registration in reversed(registrations):
try:
registration.dispose()
except Exception as exc: # pragma: no cover - defensive cleanup
logger.warning(
"Failed to unload plugin registration %s/%s: %s", registration.plugin_key,
registration.key, exc, exc_info=_PLUGINS_DEBUG,
)
@staticmethod
def _resolve_plugin_key(plugin: Union[str, PluginManifest, LoadedPlugin]) -> str:
from hermes_cli.plugins import LoadedPlugin
if isinstance(plugin, LoadedPlugin):
return manifest_key(plugin.manifest)
return manifest_key(plugin) if isinstance(plugin, PluginManifest) else str(plugin)
def unload(self, plugin: Union[str, PluginManifest, LoadedPlugin, None] = None) -> bool:
"""Unload registrations while excluding discovery/deferred loading."""
with self._discovery_lock, _plugin_home_scope(self.home_path):
return self._unload_scoped(plugin)
def _unload_scoped(self, plugin: Union[str, PluginManifest, LoadedPlugin, None] = None) -> bool:
"""Unload one plugin (or all when ``plugin=None``, as force rediscovery does). Every ledger registration
— including on_unload callbacks and supervised tasks — is disposed in reverse acquisition order with
identity-conditional inverses. Returns ``True`` when anything was found."""
unload_all = plugin is None
if unload_all:
target_keys = set(self._ownership_ledger) | set(self._plugins)
registrations = list(self._registration_order)
else:
target_keys = self._unload_target_keys(self._resolve_plugin_key(plugin))
registrations = [r for r in self._registration_order if r.plugin_key in target_keys]
# Persistent registrations are absent from _registration_order (unload-all keeps them), but a
# *targeted* unload is the disable/uninstall path: a disabled auth plugin's provider must NOT stay
# live process-wide.
# See #91701.
registrations.extend(
r for key in target_keys for r in self._ownership_ledger.get(key, []) if r.persistent and r.active
)
found = bool(target_keys or registrations)
self._dispose_registrations(registrations)
self._forget_registrations(registrations)
if unload_all:
self._reset_after_unload_all(registrations)
else:
for key in target_keys:
self._plugins.pop(key, None)
return found
def _unload_target_keys(self, requested: str) -> Set[str]:
"""Resolve a targeted-unload request to canonical plugin keys (exact key, else by name)."""
if requested in self._ownership_ledger or requested in self._plugins:
return {requested}
return {key for key, loaded in self._plugins.items() if loaded.manifest.name == requested}
def _reset_after_unload_all(self, registrations: List[PluginRegistration]) -> None:
"""Sweep pre-ledger global state and clear every manager-local container."""
# Handles are authoritative for global registries; names present in the manager-local sets without a
# ledger entry (pre-ledger or manually set state) are swept here so they do not survive a force reload
# as zombies.
from gateway.platform_registry import platform_registry
for platform_name in tuple(self._plugin_platform_names):
platform_registry.unregister(platform_name)
# Ledger-owned tool names are excluded: their handles already restored the previous entry, and blanket
# deregistration would remove what the ledger just restored.
ledger_tool_names = {r.key for r in registrations if r.kind == "tool"}
preledger_tools = tuple(n for n in self._plugin_tool_names if n not in ledger_tool_names)
if preledger_tools:
try:
from tools.registry import registry as tool_registry
except Exception as exc: # pragma: no cover - defensive
logger.debug("unload: tools.registry unavailable: %s", exc)
else:
for tool_name in preledger_tools:
try:
tool_registry.deregister(tool_name)
except Exception as exc:
logger.debug("unload: tool deregister %s failed: %s", tool_name, exc)
# Persistent registrations survive unload-all but must not be orphaned by the ledger clear: carry them
# over so force re-discovery can evict the ones whose plugin does not come back.
carryover_ids = {id(r) for r in self._persistent_carryover}
self._persistent_carryover.extend(r for r in self._active_persistent() if id(r) not in carryover_ids)
for container in (
self._ownership_ledger, self._plugins, self._hooks, self._middleware,
self._plugin_tool_names, self._plugin_platform_names, self._cli_commands,
self._plugin_commands, self._plugin_skills, self._portable_mcp_servers,
self._aux_tasks, self._system_prompt_sections, self._approval_transports,
self._slack_action_handlers, self._predeclared_modules, self._predeclared_tools,
self._platform_handler_factories,
):
container.clear()
self._context_engine = None
with self._hook_timeout_lock:
self._hook_running_callbacks.clear()
self._hook_timeout_suppressed_until.clear()
self._discovered = False