Files
hermes-agent/hermes_cli/plugins_ledger.py

308 lines
14 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 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")
@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.
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()``.
"""
plugin_key = manifest_key(manifest)
registration = PluginRegistration(
kind=kind, key=key, release=release, plugin_key=plugin_key, persistent=persistent,
)
registration._on_dispose = lambda disposed: self._forget_registrations([disposed])
self._ownership_ledger.setdefault(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 _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."""
if not self._persistent_carryover:
return
parked = self._persistent_carryover
self._persistent_carryover = []
current = {
(registration.kind, registration.key) for owned in self._ownership_ledger.values()
for registration in owned if registration.persistent and registration.active
}
stale = [
registration for registration in parked if registration.active
and (registration.kind, registration.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."""
for index in range(len(values) - 1, -1, -1):
if values[index] is target:
del values[index]
return True
return False
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(
registration.active and registration.kind == kind and registration.key == name
for registration 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
registration_ids = {id(registration) for registration in registrations}
self._registration_order = [
registration for registration in self._registration_order
if id(registration) not in registration_ids
]
for plugin_key, owned in list(self._ownership_ledger.items()):
remaining = [
registration for registration in owned if id(registration) not in registration_ids
]
if remaining:
self._ownership_ledger[plugin_key] = remaining
else:
self._ownership_ledger.pop(plugin_key, None)
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)
if isinstance(plugin, PluginManifest):
return plugin.key or plugin.name
return 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 = [
registration for registration in self._registration_order
if registration.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.
registrations.extend(
registration for key in target_keys
for registration in self._ownership_ledger.get(key, [])
if registration.persistent and registration.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 = {
registration.key for registration in registrations if registration.kind == "tool"
}
preledger_tools = tuple(
name for name in self._plugin_tool_names if name 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(registration) for registration in self._persistent_carryover}
self._persistent_carryover.extend(
registration for owned in self._ownership_ledger.values() for registration in owned
if registration.persistent and registration.active
and id(registration) 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