308 lines
14 KiB
Python
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
|