# NousResearch/hermes-agent · cron namespace collision causing gateway startup crashes

## Open now

- Nothing open on GitHub.

A package naming conflict between plugins/cron and the core cron module caused ModuleNotFoundError on gateway startup in v0.17.0. Multiple fixes address the collision via renaming, sys.path corrections, and import adjustments.

## TL;DR

- 10 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #49913. Verify it addresses your case.
- **Recommended:** read PR #49913 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-49913,pr-51702`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.24`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #49410: \[Bug\]: Gateway crashes on startup with ModuleNotFoundError: No module named 'cron.scheduler_provider'
- #49824: \[Bug\] v0.17.0 gateway fails to start: ModuleNotFoundError cron.scheduler_provider
- #50872: plugins/cron/ package name collision with cron/ — ModuleNotFoundError in WebUI
- #51405: \[Bug\] Missing cron.scheduler_provider in 0.17.0 - Gateway crashes on startup with Feishu

## Prior & competing PRs

- **#49913: fix(gateway): correct sys.path insertion in all migrated platform adapters (cron namespace collision)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/platforms/discord/adapter.py`, `plugins/platforms/raft/adapter.py`, `plugins/platforms/slack/adapter.py`, `plugins/platforms/telegram/adapter.py`, `plugins/platforms/whatsapp/adapter.py`
  - fix hunks:
  - `plugins/platforms/discord/adapter.py`

    ```diff
    @@ -98,7 +98,7 @@ def __init__(self, id: int) -> None:  # noqa: A002 - matches discord API
     
     import sys
     from pathlib import Path as _Path
    -sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
    +sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
     
     from gateway.config import Platform, PlatformConfig
     
    ```
  - `plugins/platforms/raft/adapter.py`

    ```diff
    @@ -36,7 +36,7 @@
     
     import sys
     from pathlib import Path as _Path
    -sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
    +sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
     
     from gateway.config import Platform, PlatformConfig
     from gateway.platforms.base import (
    ```
  - `plugins/platforms/slack/adapter.py`

    ```diff
    @@ -34,7 +34,7 @@
     import sys
     from pathlib import Path as _Path
     
    -sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
    +sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
     
     from gateway.config import Platform, PlatformConfig
     from gateway.platforms.helpers import MessageDeduplicator
    ```
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -63,7 +63,7 @@ class _MockContextTypes:
     
     import sys
     from pathlib import Path as _Path
    -sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
    +sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
     
     from gateway.config import Platform, PlatformConfig
     from gateway.platforms.base import (
    ```
  - `plugins/platforms/whatsapp/adapter.py`

    ```diff
    @@ -178,7 +178,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None:
             return
     
     import sys
    -sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
    +sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
     
     from gateway.config import Platform, PlatformConfig
     from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
    ```
- **#51702: fix(cron): rename plugins/cron → plugins/cron_providers to kill the core-cron shadowing class (#50872)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/scheduler_provider.py`, `gateway/platforms/api_server.py`, `hermes_cli/config.py`, `hermes_cli/web_server.py`, `plugins/cron_providers/__init__.py`, `plugins/cron_providers/chronos/__init__.py`, `plugins/cron_providers/chronos/_nas_client.py`, `plugins/cron_providers/chronos/plugin.yaml`, `plugins/cron_providers/chronos/verify.py`, `scripts/release.py`, `tests/cron/test_scheduler_provider.py`, `tests/gateway/test_cron_fire_webhook.py`, `tests/hermes_cli/test_cron_fire_dashboard.py`, `tests/plugins/test_chronos_cron.py`, `tests/plugins/test_chronos_verify.py`
  - fix hunks:
  - `cron/scheduler_provider.py`

    ```diff
    @@ -14,7 +14,7 @@
     
     The built-in InProcessCronScheduler runs the historical 60s daemon-thread
     ticker. Alternative providers (e.g. Chronos, a NAS-mediated managed-cron
    -provider for scale-to-zero deployments) live under plugins/cron/<name>/ and are
    +provider for scale-to-zero deployments) live under plugins/cron_providers/<name>/ and are
     selected via the `cron.provider` config key (empty = built-in).
     """
     from __future__ import annotations
    ```

    ```diff
    @@ -134,7 +134,7 @@ def resolve_cron_scheduler() -> "CronScheduler":
             return InProcessCronScheduler()
     
         try:
    -        from plugins.cron import load_cron_scheduler
    +        from plugins.cron_providers import load_cron_scheduler
             provider = load_cron_scheduler(name)
             if provider is None:
                 logger.warning("cron.provider '%s' not found; using built-in ticker", name)
    ```
  - `gateway/platforms/api_server.py`

    ```diff
    @@ -3448,7 +3448,7 @@ async def _handle_cron_fire(self, request: "web.Request") -> "web.Response":
             against double-fire on a NAS/scheduler retry.
             """
             from hermes_cli.config import cfg_get, load_config
    -        from plugins.cron.chronos.verify import get_fire_verifier
    +        from plugins.cron_providers.chronos.verify import get_fire_verifier
     
             auth = request.headers.get("Authorization", "")
             token = [redacted:secret-assignment] if auth.startswith("Bearer ") else ""
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -2340,7 +2340,7 @@ def _ensure_hermes_home_managed(home: Path):
         "cron": {
             # Active cron SCHEDULER provider (Axis B — the trigger that decides
             # WHEN a due job fires). Empty string = the built-in in-process 60s
    -        # ticker (default). Name an installed provider (plugins/cron/<name>/ or
    +        # ticker (default). Name an installed provider (plugins/cron_providers/<name>/ or
             # $HERMES_HOME/plugins/<name>/) to relocate the trigger — e.g. "chronos",
             # the NAS-mediated managed-cron provider for scale-to-zero deployments.
             # An unknown or unavailable provider falls back to the built-in, so cron
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -7916,7 +7916,7 @@ async def cron_fire_webhook(request: Request):
         Returns 202 immediately and runs the job in the background so a long agent
         turn never trips NAS's HTTP timeout.
         """
    -    from plugins.cron.chronos.verify import get_fire_verifier
    +    from plugins.cron_providers.chronos.verify import get_fire_verifier
     
         auth = request.headers.get("Authorization", "")
         token = [redacted:secret-assignment] if auth.startswith("Bearer ") else ""
    ```
  - `plugins/cron_providers/__init__.py`

    ```diff
    @@ -2,7 +2,7 @@
     
     Scans two directories for cron scheduler provider plugins:
     
    -1. Bundled providers: ``plugins/cron/<name>/`` (shipped with hermes-agent)
    +1. Bundled providers: ``plugins/cron_providers/<name>/`` (shipped with hermes-agent)
     2. User-installed providers: ``$HERMES_HOME/plugins/<name>/``
     
     Each subdirectory must contain ``__init__.py`` with a class implementing the
    ```

    ```diff
    @@ -19,7 +19,7 @@
     config.yaml (empty = built-in). See ``cron.scheduler_provider.resolve_cron_scheduler``.
     
     Usage:
    -    from plugins.cron import discover_cron_schedulers, load_cron_scheduler
    +    from plugins.cron_providers import discover_cron_schedulers, load_cron_scheduler
     
         available = discover_cron_schedulers()   # [(name, desc, available), ...]
         provider = load_cron_scheduler("chronos")  # CronScheduler instance
    ```

    ```diff
    @@ -52,7 +52,7 @@ def _register_synthetic_package(name: str, search_locations: List[str]) -> None:
         in ``sys.modules``, any relative import inside the plugin
         (``from . import config``) fails with
         ``ModuleNotFoundError: No module named '_hermes_user_cron'`` — the same
    -    reason the loader already registers ``plugins`` and ``plugins.cron`` for
    +    reason the loader already registers ``plugins`` and ``plugins.cron_providers`` for
         bundled providers.
         """
         if name in sys.modules:
    ```

    ```diff
    @@ -101,7 +101,7 @@ def _iter_provider_dirs() -> List[Tuple[str, Path]]:
         seen: set = set()
         dirs: List[Tuple[str, Path]] = []
     
    -    # 1. Bundled providers (plugins/cron/<name>/)
    +    # 1. Bundled providers (plugins/cron_providers/<name>/)
         if _CRON_PLUGINS_DIR.is_dir():
             for child in sorted(_CRON_PLUGINS_DIR.iterdir()):
                 if not child.is_dir() or child.name.startswith(("_", ".")):
    ```

    ```diff
    @@ -190,7 +190,7 @@ def discover_cron_schedulers() -> List[Tuple[str, str, bool]]:
     def load_cron_scheduler(name: str) -> Optional["CronScheduler"]:  # noqa: F821
         """Load and return a CronScheduler instance by name.
     
    -    Checks both bundled (``plugins/cron/<name>/``) and user-installed
    +    Checks both bundled (``plugins/cron_providers/<name>/``) and user-installed
         (``$HERMES_HOME/plugins/<name>/``) directories. Bundled takes precedence
         on name collisions.
     
    ```

    ```diff
    @@ -223,7 +223,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]:  #
         # Use a separate namespace for user-installed plugins so they don't
         # collide with bundled providers in sys.modules.
         _is_bundled = _CRON_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _CRON_PLUGINS_DIR
    -    module_name = f"plugins.cron.{name}" if _is_bundled else f"{_USER_NAMESPACE}.{name}"
    +    module_name = f"plugins.cron_providers.{name}" if _is_bundled else f"{_USER_NAMESPACE}.{name}"
         init_file = provider_dir / "__init__.py"
     
         if not init_file.exists():
    ```

    ```diff
    @@ -236,7 +236,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]:  #
             mod = cached
         else:
             # Ensure the parent packages are registered (for relative imports)
    -        for parent in ("plugins", "plugins.cron"):
    +        for parent in ("plugins", "plugins.cron_providers"):
                 if parent not in sys.modules:
                     parent_path = Path(__file__).parent
                     if parent == "plugins":
    ```

    ```diff
    @@ -270,6 +270,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]:  #
     
             mod = importlib.util.module_from_spec(spec)
             sys.modules[module_name] = mod
    +        loaded_submodules = []
     
             # Register submodules so relative imports work
             # e.g., "from ._nas_client import NasCronClient" in the chronos plugin
    ```

    ```diff
    @@ -287,6 +288,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]:  #
                         sys.modules[full_sub_name] = sub_mod
                         try:
                             sub_spec.loader.exec_module(sub_mod)
    +                        loaded_submodules.append((sub_name, sub_mod))
                         except Exception as e:
                             logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
     
    ```

    ```diff
    @@ -297,6 +299,16 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]:  #
                 sys.modules.pop(module_name, None)
                 return None
     
    +        # Manual importlib loading bypasses the normal import machinery that
    +        # binds child modules onto their parent packages. Restore that shape so
    +        # later dotted imports and pytest monkeypatch paths resolve normally.
    +        parent_name, child_name = module_name.rsplit(".", 1)
    +        parent_mod = sys.modules.get(parent_name)
    +        if parent_mod is not None:
    +            setattr(parent_mod, child_name, mod)
    +        for sub_name, sub_mod in loaded_submodules:
    +            setattr(mod, sub_name, sub_mod)
    +
         # Try register(ctx) pattern first (how our plugins are written)
         if hasattr(mod, "register"):
             collector = _ProviderCollector()
    ```
  - `plugins/cron_providers/chronos/__init__.py`

    ```diff
    @@ -235,7 +235,7 @@ def fire_due(self, job_id: str, *, adapters: Any = None, loop: Any = None) -> bo
     def register(ctx) -> None:
         """Plugin entrypoint — register the Chronos provider with the loader.
     
    -    Mirrors the memory-plugin shape; plugins/cron discovery calls this and
    +    Mirrors the memory-plugin shape; plugins/cron_providers discovery calls this and
         collects the provider via register_cron_scheduler.
         """
         ctx.register_cron_scheduler(ChronosCronScheduler())
    ```
  - `scripts/release.py`

    ```diff
    @@ -46,6 +46,7 @@
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
         "[redacted:email]": "wgu9",  # PR #51468 salvage (WSL/no-systemd orphan gateway tracking, #51325)
    +    "[redacted:email]": "uperLu",  # PR #50958 salvage (rename plugins/cron → plugins/cron_providers; #50872)
         "[redacted:email]": "s010mn",  # PR #29221 salvage (ollama-cloud reasoning_effort xhigh→max)
         "[redacted:email]": "jeeves-assistant",  # PR #50771 (computer-use CuaDriver vision capture routing)
         "[redacted:email]": "ScotterMonk",  # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126)
    ```
  - `tests/cron/test_scheduler_provider.py`

    ```diff
    @@ -172,20 +172,42 @@ def test_default_config_cron_provider_is_empty():
     
     
     def test_discover_cron_schedulers_returns_list():
    -    """Discovery returns a list. May be empty — the built-in is core, not
    -    discovered, and no bundled non-default provider ships yet."""
    -    from plugins.cron import discover_cron_schedulers
    +    """Discovery returns bundled non-default providers.
    +
    +    The built-in is core, not discovered here.
    +    """
    +    from plugins.cron_providers import discover_cron_schedulers
     
         result = discover_cron_schedulers()
         assert isinstance(result, list)
    +    assert any(name == "chronos" for name, _desc, _available in result)
     
     
     def test_load_unknown_cron_scheduler_returns_none():
    -    from plugins.cron import load_cron_scheduler
    +    from plugins.cron_providers import load_cron_scheduler
     
         assert load_cron_scheduler("does-not-exist-xyz") is None
     
     
    +def test_cron_provider_package_does_not_shadow_core_cron_package(monkeypatch):
    +    """Putting plugins/ first on sys.path must not hide the core cron package."""
    +    from importlib.machinery import PathFinder
    +    from pathlib import Path
    +
    +    repo_root = Path(__file__).resolve().parents[2]
    +
    +    monkeypatch.syspath_prepend(str(repo_root))
    +    monkeypatch.syspath_prepend(str(repo_root / "plugins"))
    +
    +    cron_spec = PathFinder.find_spec("cron")
    +    assert cron_spec is not None
    +    assert Path(cron_spec.origin).resolve() == repo_root / "cron" / "__init__.py"
    +
    +    jobs_spec = PathFinder.find_spec("cron.jobs", [str(repo_root / "cron")])
    +    assert jobs_spec is not None
    +    assert Path(jobs_spec.origin).resolve() == repo_root / "cron" / "jobs.py"
    +
    +
     def test_resolve_defaults_to_builtin(monkeypatch):
         """Empty cron.provider → built-in."""
         import hermes_cli.config as cfg
    ```

    ```diff
    @@ -219,7 +241,7 @@ def test_resolve_unknown_provider_falls_back_to_builtin(monkeypatch):
     def test_resolve_unavailable_provider_falls_back(monkeypatch):
         """A provider that loads but reports is_available()==False → built-in."""
         import hermes_cli.config as cfg
    -    import plugins.cron as pc
    +    import plugins.cron_providers as pc
         from cron import scheduler_provider as sp
         from cron.scheduler_provider import CronScheduler
     
    ```

    ```diff
    @@ -243,7 +265,7 @@ def start(self, stop_event, **kw):
     def test_resolve_available_provider_is_used(monkeypatch):
         """A provider that loads and is available is returned (not the fallback)."""
         import hermes_cli.config as cfg
    -    import plugins.cron as pc
    +    import plugins.cron_providers as pc
         from cron import scheduler_provider as sp
         from cron.scheduler_provider import CronScheduler
     
    ```
  - `tests/gateway/test_cron_fire_webhook.py`

    ```diff
    @@ -53,7 +53,7 @@ async def test_valid_token_accepts_and_fires(adapter, monkeypatch):
         monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy)
         # verifier returns claims (valid token)
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: {"purpose": "cron_fire", "aud": "agent:x"}),
         )
     
    ```

    ```diff
    @@ -80,7 +80,7 @@ async def test_invalid_token_401_and_no_fire(adapter, monkeypatch):
         spy = _SpyProvider()
         monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy)
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: None),  # verification fails
         )
     
    ```

    ```diff
    @@ -114,7 +114,7 @@ async def test_missing_job_id_400(adapter, monkeypatch):
         spy = _SpyProvider()
         monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy)
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: {"purpose": "cron_fire"}),
         )
     
    ```

    ```diff
    @@ -134,7 +134,7 @@ async def test_fire_does_not_require_api_server_key(adapter, monkeypatch):
         spy = _SpyProvider()
         monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy)
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: {"purpose": "cron_fire"}),
         )
     
    ```
  - `tests/hermes_cli/test_cron_fire_dashboard.py`

    ```diff
    @@ -61,7 +61,7 @@ def test_bad_token_401(monkeypatch):
         gate). fire_due must NOT run."""
         fired = []
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: None),  # verification fails
         )
         monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
    ```

    ```diff
    @@ -82,7 +82,7 @@ def test_bad_token_401(monkeypatch):
     
     def test_missing_job_id_400(monkeypatch):
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: {"purpose": "cron_fire"}),
         )
         client, pa, ph = _client(auth_required=False)
    ```

    ```diff
    @@ -100,7 +100,7 @@ def test_unknown_job_200_gone(monkeypatch):
         """Valid token but the job isn't found in any profile -> 200 'gone'
         (NAS shouldn't retry a fire for a cancelled/completed job)."""
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: {"purpose": "cron_fire"}),
         )
         monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: None)
    ```

    ```diff
    @@ -121,7 +121,7 @@ def test_valid_token_accepts_and_fires(monkeypatch):
         profile."""
         fired = []
         monkeypatch.setattr(
    -        "plugins.cron.chronos.verify.get_fire_verifier",
    +        "plugins.cron_providers.chronos.verify.get_fire_verifier",
             lambda: (lambda **kw: {"purpose": "cron_fire", "aud": "agent:x"}),
         )
         monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
    ```
  - `tests/plugins/test_chronos_cron.py`

    ```diff
    @@ -21,7 +21,7 @@ def temp_home(tmp_path, monkeypatch):
     @pytest.fixture
     def chronos(monkeypatch):
         """A ChronosCronScheduler with a fake NAS client capturing calls."""
    -    from plugins.cron.chronos import ChronosCronScheduler
    +    from plugins.cron_providers.chronos import ChronosCronScheduler
     
         class FakeClient:
             def __init__(self):
    ```

    ```diff
    @@ -47,7 +47,7 @@ def list_armed(self):
         fake = FakeClient()
         prov._client = fake
         # callback_url is read via _cfg; patch the module helper to avoid config.
    -    monkeypatch.setattr("plugins.cron.chronos._cfg",
    +    monkeypatch.setattr("plugins.cron_providers.chronos._cfg",
                             lambda *k, default="": "https://agent.example/" if k[-1] == "callback_url" else "https://portal.test")
         return prov, fake
     
    ```

    ```diff
    @@ -55,15 +55,15 @@ def list_armed(self):
     # -- is_available -------------------------------------------------------------
     
     def test_is_available_false_without_config(temp_home, monkeypatch):
    -    from plugins.cron.chronos import ChronosCronScheduler
    +    from plugins.cron_providers.chronos import ChronosCronScheduler
     
    -    monkeypatch.setattr("plugins.cron.chronos._cfg", lambda *k, default="": "")
    +    monkeypatch.setattr("plugins.cron_providers.chronos._cfg", lambda *k, default="": "")
         assert ChronosCronScheduler().is_available() is False
     
     
     def test_is_available_true_with_config_and_token(temp_home, monkeypatch):
    -    import plugins.cron.chronos as mod
    -    from plugins.cron.chronos import ChronosCronScheduler
    +    import plugins.cron_providers.chronos as mod
    +    from plugins.cron_providers.chronos import ChronosCronScheduler
     
         monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x" )
         monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state",
    ```

    ```diff
    @@ -73,8 +73,8 @@ def test_is_available_true_with_config_and_token(temp_home, monkeypatch):
     
     def test_is_available_makes_no_network(temp_home, monkeypatch):
         """is_available must not construct the NAS client / hit network."""
    -    import plugins.cron.chronos as mod
    -    from plugins.cron.chronos import ChronosCronScheduler
    +    import plugins.cron_providers.chronos as mod
    +    from plugins.cron_providers.chronos import ChronosCronScheduler
     
         monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x")
         monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state",
    ```
  - `tests/plugins/test_chronos_verify.py`

    ```diff
    @@ -54,7 +54,7 @@ def _base_claims(**over):
     
     
     def test_valid_token_returns_claims(rsa_keys):
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, pub = rsa_keys
         token = [redacted:secret-assignment] _base_claims())
    ```

    ```diff
    @@ -66,7 +66,7 @@ def test_valid_token_returns_claims(rsa_keys):
     
     
     def test_wrong_audience_rejected(rsa_keys):
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, pub = rsa_keys
         token = [redacted:secret-assignment] _base_claims(aud="agent:someone-else"))
    ```

    ```diff
    @@ -76,7 +76,7 @@ def test_wrong_audience_rejected(rsa_keys):
     
     def test_missing_purpose_rejected(rsa_keys):
         """A general agent JWT (no purpose=cron_fire) can't fire jobs."""
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, pub = rsa_keys
         claims = _base_claims()
    ```

    ```diff
    @@ -87,7 +87,7 @@ def test_missing_purpose_rejected(rsa_keys):
     
     
     def test_wrong_purpose_rejected(rsa_keys):
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, pub = rsa_keys
         token = [redacted:secret-assignment] _base_claims(purpose="inference"))
    ```

    ```diff
    @@ -96,7 +96,7 @@ def test_wrong_purpose_rejected(rsa_keys):
     
     
     def test_expired_token_rejected(rsa_keys):
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, pub = rsa_keys
         now = int(time.time())
    ```

    ```diff
    @@ -106,7 +106,7 @@ def test_expired_token_rejected(rsa_keys):
     
     
     def test_wrong_issuer_rejected(rsa_keys):
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, pub = rsa_keys
         token = [redacted:secret-assignment] _base_claims(iss="https://evil.example"))
    ```

    ```diff
    @@ -118,7 +118,7 @@ def test_tampered_signature_rejected(rsa_keys):
         """A token signed by a DIFFERENT key must fail signature verification."""
         from cryptography.hazmat.primitives import serialization
         from cryptography.hazmat.primitives.asymmetric import rsa
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         _, pub = rsa_keys
         attacker = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    ```

    ```diff
    @@ -135,7 +135,7 @@ def test_tampered_signature_rejected(rsa_keys):
     
     def test_no_key_configured_refuses(rsa_keys):
         """No JWKS/key configured → refuse (never fall back to unsigned decode)."""
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, _ = rsa_keys
         token = [redacted:secret-assignment] _base_claims())
    ```

    ```diff
    @@ -144,7 +144,7 @@ def test_no_key_configured_refuses(rsa_keys):
     
     
     def test_empty_token_refused(rsa_keys):
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         _, pub = rsa_keys
         assert verify_nas_fire_token(token="", expected_audience=AUD, jwks_or_key=pub) is None
    ```

    ```diff
    @@ -152,7 +152,7 @@ def test_empty_token_refused(rsa_keys):
     
     def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch):
         """The JWKS-URL branch resolves the signing key via PyJWKClient."""
    -    from plugins.cron.chronos.verify import verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import verify_nas_fire_token
     
         priv, pub = rsa_keys
         token = [redacted:secret-assignment] _base_claims())
    ```

    ```diff
    @@ -177,6 +177,6 @@ def get_signing_key_from_jwt(self, tok):
     
     
     def test_get_fire_verifier_returns_nas_verifier():
    -    from plugins.cron.chronos.verify import get_fire_verifier, verify_nas_fire_token
    +    from plugins.cron_providers.chronos.verify import get_fire_verifier, verify_nas_fire_token
     
         assert get_fire_verifier() is verify_nas_fire_token
    ```
- **#49424: fix(gateway): move cron.scheduler_provider import to module level**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#49414`, `#49410`, `#49414`, `#49414`
- **#49431: fix(gateway): correct sys.path insertion in plugins to prevent cron namespace collision (#49410)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `4c206b972d`, `#49913`
- **#50958: fix(cron): avoid provider package shadowing core cron**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#51702`
- **#51162: fix(cron): rename plugins/cron to plugins/cron_providers to resolve WebUI import collision**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#50958`, `#51702`

## Fix status

- Merged to main in PR #49913 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-24`.
