Files
hermes-agent/plugins/cron_providers/__init__.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

97 lines
4.1 KiB
Python

"""Cron scheduler provider discovery: bundled ``plugins/cron_providers/<name>/`` then user
``$HERMES_HOME/plugins/<name>/`` (bundled wins on collision). The built-in InProcessCronScheduler
is core, not discovered here, so the fallback can't be removed; one provider is active
(``cron.provider`` in config.yaml, empty = built-in)."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import List, Optional, Tuple
from plugins import plugin_loader as _loader
logger = logging.getLogger(__name__)
_CRON_PLUGINS_DIR = Path(__file__).parent
# Synthetic parent package for user-installed providers (keeps them out of the bundled namespace).
_USER_NAMESPACE = "_hermes_user_cron"
def _is_cron_provider_dir(path: Path) -> bool:
"""Cheap text heuristic: ``__init__.py`` mentions the cron scheduler contract."""
init_file = path / "__init__.py"
if not init_file.exists():
return False
try:
source = init_file.read_text(errors="replace", encoding="utf-8-sig")[:8192]
return "register_cron_scheduler" in source or "CronScheduler" in source
except Exception:
return False
def _user_provider_dirs() -> List[Path]:
"""User-installed ``$HERMES_HOME/plugins/<name>/`` dirs that look like cron providers."""
user_dir = _loader.user_plugins_dir()
return [c for c in _loader.iter_plugin_dirs(user_dir) if _is_cron_provider_dir(c)] if user_dir else []
def _iter_provider_dirs() -> List[Tuple[str, Path]]:
"""``(name, path)`` for bundled then user providers; bundled wins on collisions."""
dirs = [(child.name, child) for child in _loader.iter_plugin_dirs(_CRON_PLUGINS_DIR)]
seen = {name for name, _ in dirs}
dirs.extend((child.name, child) for child in _user_provider_dirs() if child.name not in seen)
return dirs
def find_provider_dir(name: str) -> Optional[Path]:
"""Resolve a provider name to its directory (bundled first, then user-installed)."""
bundled = _CRON_PLUGINS_DIR / name
if bundled.is_dir() and (bundled / "__init__.py").exists():
return bundled
user_dir = _loader.user_plugins_dir()
user = user_dir / name if user_dir else None
return user if user and user.is_dir() and _is_cron_provider_dir(user) else None
def discover_cron_schedulers() -> List[Tuple[str, str, bool]]:
"""Return ``[(name, description, is_available), ...]`` for all discovered providers."""
return [(name, _loader.read_plugin_description(child),
_loader.probe_availability(lambda c=child: _load_provider_from_dir(c)))
for name, child in _iter_provider_dirs()]
def load_cron_scheduler(name: str) -> Optional["CronScheduler"]: # noqa: F821
"""Load a CronScheduler instance by name; None if not found or it fails to load."""
provider_dir = find_provider_dir(name)
if not provider_dir:
logger.debug("Cron provider '%s' not found in bundled or user plugins", name)
return None
return _loader.load_named(
name, provider_dir, _load_provider_from_dir,
kind="Cron provider", noun="provider", logger=logger)
def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]: # noqa: F821
"""Import a provider module and extract its CronScheduler (register(ctx) or subclass)."""
from cron.scheduler_provider import CronScheduler
name = provider_dir.name
is_bundled = _CRON_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _CRON_PLUGINS_DIR
module_name = f"plugins.cron_providers.{name}" if is_bundled else f"{_USER_NAMESPACE}.{name}"
mod = _loader.load_plugin_module(
module_name, provider_dir, parents=("plugins", "plugins.cron_providers"), logger=logger,
synthetic_namespace=None if is_bundled else _USER_NAMESPACE)
return mod and _loader.instance_from_module(
mod, collector=_ProviderCollector(), collected_attr="provider",
base_cls=CronScheduler, name=name, logger=logger)
class _ProviderCollector(_loader.NoopPluginContext):
"""Fake plugin context that captures register_cron_scheduler calls."""
def __init__(self):
self.provider = None
def register_cron_scheduler(self, provider):
self.provider = provider