{"repo":"NousResearch/hermes-agent","public_id":"49410","display_label":"cron namespace collision causing gateway startup crashes","summary":"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.","subsystem":null,"counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":49913},"resolution":{"state":"partially_resolved","by":"pr-49913,pr-51702"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T06:39:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.23999999999999996},"fix_edges":[{"issue_number":49410,"pr_number":49913,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":50872,"pr_number":51702,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":49410,"title":"[Bug]: Gateway crashes on startup with ModuleNotFoundError: No module named 'cron.scheduler_provider'","url":null,"state":"closed"},{"kind":"issue","number":49824,"title":"[Bug] v0.17.0 gateway fails to start: ModuleNotFoundError cron.scheduler_provider","url":null,"state":"closed"},{"kind":"issue","number":50872,"title":"plugins/cron/ package name collision with cron/ — ModuleNotFoundError in WebUI","url":null,"state":"closed"},{"kind":"issue","number":51405,"title":"[Bug] Missing cron.scheduler_provider in 0.17.0 - Gateway crashes on startup with Feishu","url":null,"state":"closed"},{"kind":"pr","number":49424,"title":"fix(gateway): move cron.scheduler_provider import to module level","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49414","number":49414},{"kind":"pr","value":"#49410","number":49410},{"kind":"pr","value":"#49414","number":49414},{"kind":"pr","value":"#49414","number":49414}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":49431,"title":"fix(gateway): correct sys.path insertion in plugins to prevent cron namespace collision (#49410)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4c206b972d","number":0},{"kind":"pr","value":"#49913","number":49913}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49913,"title":"fix(gateway): correct sys.path insertion in all migrated platform adapters (cron namespace collision)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -98,7 +98,7 @@ def __init__(self, id: int) -> None:  # noqa: A002 - matches discord API\n \n import sys\n from pathlib import Path as _Path\n-sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))\n+sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))\n \n from gateway.config import Platform, PlatformConfig\n "]},{"file":"plugins/platforms/raft/adapter.py","hunks":["@@ -36,7 +36,7 @@\n \n import sys\n from pathlib import Path as _Path\n-sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))\n+sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))\n \n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.base import ("]},{"file":"plugins/platforms/slack/adapter.py","hunks":["@@ -34,7 +34,7 @@\n import sys\n from pathlib import Path as _Path\n \n-sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))\n+sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))\n \n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.helpers import MessageDeduplicator"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -63,7 +63,7 @@ class _MockContextTypes:\n \n import sys\n from pathlib import Path as _Path\n-sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))\n+sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))\n \n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.base import ("]},{"file":"plugins/platforms/whatsapp/adapter.py","hunks":["@@ -178,7 +178,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None:\n         return\n \n import sys\n-sys.path.insert(0, str(Path(__file__).resolve().parents[2]))\n+sys.path.insert(0, str(Path(__file__).resolve().parents[3]))\n \n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin\n"]}]},{"kind":"pr","number":50958,"title":"fix(cron): avoid provider package shadowing core cron","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51702","number":51702}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":51162,"title":"fix(cron): rename plugins/cron to plugins/cron_providers to resolve WebUI import collision","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50958","number":50958},{"kind":"pr","value":"#51702","number":51702}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":51702,"title":"fix(cron): rename plugins/cron → plugins/cron_providers to kill the core-cron shadowing class (#50872)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"cron/scheduler_provider.py","hunks":["@@ -14,7 +14,7 @@\n \n The built-in InProcessCronScheduler runs the historical 60s daemon-thread\n ticker. Alternative providers (e.g. Chronos, a NAS-mediated managed-cron\n-provider for scale-to-zero deployments) live under plugins/cron/<name>/ and are\n+provider for scale-to-zero deployments) live under plugins/cron_providers/<name>/ and are\n selected via the `cron.provider` config key (empty = built-in).\n \"\"\"\n from __future__ import annotations","@@ -134,7 +134,7 @@ def resolve_cron_scheduler() -> \"CronScheduler\":\n         return InProcessCronScheduler()\n \n     try:\n-        from plugins.cron import load_cron_scheduler\n+        from plugins.cron_providers import load_cron_scheduler\n         provider = load_cron_scheduler(name)\n         if provider is None:\n             logger.warning(\"cron.provider '%s' not found; using built-in ticker\", name)"]},{"file":"gateway/platforms/api_server.py","hunks":["@@ -3448,7 +3448,7 @@ async def _handle_cron_fire(self, request: \"web.Request\") -> \"web.Response\":\n         against double-fire on a NAS/scheduler retry.\n         \"\"\"\n         from hermes_cli.config import cfg_get, load_config\n-        from plugins.cron.chronos.verify import get_fire_verifier\n+        from plugins.cron_providers.chronos.verify import get_fire_verifier\n \n         auth = request.headers.get(\"Authorization\", \"\")\n         token = [redacted:secret-assignment] if auth.startswith(\"Bearer \") else \"\""]},{"file":"hermes_cli/config.py","hunks":["@@ -2340,7 +2340,7 @@ def _ensure_hermes_home_managed(home: Path):\n     \"cron\": {\n         # Active cron SCHEDULER provider (Axis B — the trigger that decides\n         # WHEN a due job fires). Empty string = the built-in in-process 60s\n-        # ticker (default). Name an installed provider (plugins/cron/<name>/ or\n+        # ticker (default). Name an installed provider (plugins/cron_providers/<name>/ or\n         # $HERMES_HOME/plugins/<name>/) to relocate the trigger — e.g. \"chronos\",\n         # the NAS-mediated managed-cron provider for scale-to-zero deployments.\n         # An unknown or unavailable provider falls back to the built-in, so cron"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -7916,7 +7916,7 @@ async def cron_fire_webhook(request: Request):\n     Returns 202 immediately and runs the job in the background so a long agent\n     turn never trips NAS's HTTP timeout.\n     \"\"\"\n-    from plugins.cron.chronos.verify import get_fire_verifier\n+    from plugins.cron_providers.chronos.verify import get_fire_verifier\n \n     auth = request.headers.get(\"Authorization\", \"\")\n     token = [redacted:secret-assignment] if auth.startswith(\"Bearer \") else \"\""]},{"file":"plugins/cron_providers/__init__.py","hunks":["@@ -2,7 +2,7 @@\n \n Scans two directories for cron scheduler provider plugins:\n \n-1. Bundled providers: ``plugins/cron/<name>/`` (shipped with hermes-agent)\n+1. Bundled providers: ``plugins/cron_providers/<name>/`` (shipped with hermes-agent)\n 2. User-installed providers: ``$HERMES_HOME/plugins/<name>/``\n \n Each subdirectory must contain ``__init__.py`` with a class implementing the","@@ -19,7 +19,7 @@\n config.yaml (empty = built-in). See ``cron.scheduler_provider.resolve_cron_scheduler``.\n \n Usage:\n-    from plugins.cron import discover_cron_schedulers, load_cron_scheduler\n+    from plugins.cron_providers import discover_cron_schedulers, load_cron_scheduler\n \n     available = discover_cron_schedulers()   # [(name, desc, available), ...]\n     provider = load_cron_scheduler(\"chronos\")  # CronScheduler instance","@@ -52,7 +52,7 @@ def _register_synthetic_package(name: str, search_locations: List[str]) -> None:\n     in ``sys.modules``, any relative import inside the plugin\n     (``from . import config``) fails with\n     ``ModuleNotFoundError: No module named '_hermes_user_cron'`` — the same\n-    reason the loader already registers ``plugins`` and ``plugins.cron`` for\n+    reason the loader already registers ``plugins`` and ``plugins.cron_providers`` for\n     bundled providers.\n     \"\"\"\n     if name in sys.modules:","@@ -101,7 +101,7 @@ def _iter_provider_dirs() -> List[Tuple[str, Path]]:\n     seen: set = set()\n     dirs: List[Tuple[str, Path]] = []\n \n-    # 1. Bundled providers (plugins/cron/<name>/)\n+    # 1. Bundled providers (plugins/cron_providers/<name>/)\n     if _CRON_PLUGINS_DIR.is_dir():\n         for child in sorted(_CRON_PLUGINS_DIR.iterdir()):\n             if not child.is_dir() or child.name.startswith((\"_\", \".\")):","@@ -190,7 +190,7 @@ def discover_cron_schedulers() -> List[Tuple[str, str, bool]]:\n def load_cron_scheduler(name: str) -> Optional[\"CronScheduler\"]:  # noqa: F821\n     \"\"\"Load and return a CronScheduler instance by name.\n \n-    Checks both bundled (``plugins/cron/<name>/``) and user-installed\n+    Checks both bundled (``plugins/cron_providers/<name>/``) and user-installed\n     (``$HERMES_HOME/plugins/<name>/``) directories. Bundled takes precedence\n     on name collisions.\n ","@@ -223,7 +223,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional[\"CronScheduler\"]:  #\n     # Use a separate namespace for user-installed plugins so they don't\n     # collide with bundled providers in sys.modules.\n     _is_bundled = _CRON_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _CRON_PLUGINS_DIR\n-    module_name = f\"plugins.cron.{name}\" if _is_bundled else f\"{_USER_NAMESPACE}.{name}\"\n+    module_name = f\"plugins.cron_providers.{name}\" if _is_bundled else f\"{_USER_NAMESPACE}.{name}\"\n     init_file = provider_dir / \"__init__.py\"\n \n     if not init_file.exists():","@@ -236,7 +236,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional[\"CronScheduler\"]:  #\n         mod = cached\n     else:\n         # Ensure the parent packages are registered (for relative imports)\n-        for parent in (\"plugins\", \"plugins.cron\"):\n+        for parent in (\"plugins\", \"plugins.cron_providers\"):\n             if parent not in sys.modules:\n                 parent_path = Path(__file__).parent\n                 if parent == \"plugins\":","@@ -270,6 +270,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional[\"CronScheduler\"]:  #\n \n         mod = importlib.util.module_from_spec(spec)\n         sys.modules[module_name] = mod\n+        loaded_submodules = []\n \n         # Register submodules so relative imports work\n         # e.g., \"from ._nas_client import NasCronClient\" in the chronos plugin","@@ -287,6 +288,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional[\"CronScheduler\"]:  #\n                     sys.modules[full_sub_name] = sub_mod\n                     try:\n                         sub_spec.loader.exec_module(sub_mod)\n+                        loaded_submodules.append((sub_name, sub_mod))\n                     except Exception as e:\n                         logger.debug(\"Failed to load submodule %s: %s\", full_sub_name, e)\n ","@@ -297,6 +299,16 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional[\"CronScheduler\"]:  #\n             sys.modules.pop(module_name, None)\n             return None\n \n+        # Manual importlib loading bypasses the normal import machinery that\n+        # binds child modules onto their parent packages. Restore that shape so\n+        # later dotted imports and pytest monkeypatch paths resolve normally.\n+        parent_name, child_name = module_name.rsplit(\".\", 1)\n+        parent_mod = sys.modules.get(parent_name)\n+        if parent_mod is not None:\n+            setattr(parent_mod, child_name, mod)\n+        for sub_name, sub_mod in loaded_submodules:\n+            setattr(mod, sub_name, sub_mod)\n+\n     # Try register(ctx) pattern first (how our plugins are written)\n     if hasattr(mod, \"register\"):\n         collector = _ProviderCollector()"]},{"file":"plugins/cron_providers/chronos/__init__.py","hunks":["@@ -235,7 +235,7 @@ def fire_due(self, job_id: str, *, adapters: Any = None, loop: Any = None) -> bo\n def register(ctx) -> None:\n     \"\"\"Plugin entrypoint — register the Chronos provider with the loader.\n \n-    Mirrors the memory-plugin shape; plugins/cron discovery calls this and\n+    Mirrors the memory-plugin shape; plugins/cron_providers discovery calls this and\n     collects the provider via register_cron_scheduler.\n     \"\"\"\n     ctx.register_cron_scheduler(ChronosCronScheduler())"]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"wgu9\",  # PR #51468 salvage (WSL/no-systemd orphan gateway tracking, #51325)\n+    \"[redacted:email]\": \"uperLu\",  # PR #50958 salvage (rename plugins/cron → plugins/cron_providers; #50872)\n     \"[redacted:email]\": \"s010mn\",  # PR #29221 salvage (ollama-cloud reasoning_effort xhigh→max)\n     \"[redacted:email]\": \"jeeves-assistant\",  # PR #50771 (computer-use CuaDriver vision capture routing)\n     \"[redacted:email]\": \"ScotterMonk\",  # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126)"]},{"file":"tests/cron/test_scheduler_provider.py","hunks":["@@ -172,20 +172,42 @@ def test_default_config_cron_provider_is_empty():\n \n \n def test_discover_cron_schedulers_returns_list():\n-    \"\"\"Discovery returns a list. May be empty — the built-in is core, not\n-    discovered, and no bundled non-default provider ships yet.\"\"\"\n-    from plugins.cron import discover_cron_schedulers\n+    \"\"\"Discovery returns bundled non-default providers.\n+\n+    The built-in is core, not discovered here.\n+    \"\"\"\n+    from plugins.cron_providers import discover_cron_schedulers\n \n     result = discover_cron_schedulers()\n     assert isinstance(result, list)\n+    assert any(name == \"chronos\" for name, _desc, _available in result)\n \n \n def test_load_unknown_cron_scheduler_returns_none():\n-    from plugins.cron import load_cron_scheduler\n+    from plugins.cron_providers import load_cron_scheduler\n \n     assert load_cron_scheduler(\"does-not-exist-xyz\") is None\n \n \n+def test_cron_provider_package_does_not_shadow_core_cron_package(monkeypatch):\n+    \"\"\"Putting plugins/ first on sys.path must not hide the core cron package.\"\"\"\n+    from importlib.machinery import PathFinder\n+    from pathlib import Path\n+\n+    repo_root = Path(__file__).resolve().parents[2]\n+\n+    monkeypatch.syspath_prepend(str(repo_root))\n+    monkeypatch.syspath_prepend(str(repo_root / \"plugins\"))\n+\n+    cron_spec = PathFinder.find_spec(\"cron\")\n+    assert cron_spec is not None\n+    assert Path(cron_spec.origin).resolve() == repo_root / \"cron\" / \"__init__.py\"\n+\n+    jobs_spec = PathFinder.find_spec(\"cron.jobs\", [str(repo_root / \"cron\")])\n+    assert jobs_spec is not None\n+    assert Path(jobs_spec.origin).resolve() == repo_root / \"cron\" / \"jobs.py\"\n+\n+\n def test_resolve_defaults_to_builtin(monkeypatch):\n     \"\"\"Empty cron.provider → built-in.\"\"\"\n     import hermes_cli.config as cfg","@@ -219,7 +241,7 @@ def test_resolve_unknown_provider_falls_back_to_builtin(monkeypatch):\n def test_resolve_unavailable_provider_falls_back(monkeypatch):\n     \"\"\"A provider that loads but reports is_available()==False → built-in.\"\"\"\n     import hermes_cli.config as cfg\n-    import plugins.cron as pc\n+    import plugins.cron_providers as pc\n     from cron import scheduler_provider as sp\n     from cron.scheduler_provider import CronScheduler\n ","@@ -243,7 +265,7 @@ def start(self, stop_event, **kw):\n def test_resolve_available_provider_is_used(monkeypatch):\n     \"\"\"A provider that loads and is available is returned (not the fallback).\"\"\"\n     import hermes_cli.config as cfg\n-    import plugins.cron as pc\n+    import plugins.cron_providers as pc\n     from cron import scheduler_provider as sp\n     from cron.scheduler_provider import CronScheduler\n "]},{"file":"tests/gateway/test_cron_fire_webhook.py","hunks":["@@ -53,7 +53,7 @@ async def test_valid_token_accepts_and_fires(adapter, monkeypatch):\n     monkeypatch.setattr(\"cron.scheduler_provider.resolve_cron_scheduler\", lambda: spy)\n     # verifier returns claims (valid token)\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: {\"purpose\": \"cron_fire\", \"aud\": \"agent:x\"}),\n     )\n ","@@ -80,7 +80,7 @@ async def test_invalid_token_401_and_no_fire(adapter, monkeypatch):\n     spy = _SpyProvider()\n     monkeypatch.setattr(\"cron.scheduler_provider.resolve_cron_scheduler\", lambda: spy)\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: None),  # verification fails\n     )\n ","@@ -114,7 +114,7 @@ async def test_missing_job_id_400(adapter, monkeypatch):\n     spy = _SpyProvider()\n     monkeypatch.setattr(\"cron.scheduler_provider.resolve_cron_scheduler\", lambda: spy)\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: {\"purpose\": \"cron_fire\"}),\n     )\n ","@@ -134,7 +134,7 @@ async def test_fire_does_not_require_api_server_key(adapter, monkeypatch):\n     spy = _SpyProvider()\n     monkeypatch.setattr(\"cron.scheduler_provider.resolve_cron_scheduler\", lambda: spy)\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: {\"purpose\": \"cron_fire\"}),\n     )\n "]},{"file":"tests/hermes_cli/test_cron_fire_dashboard.py","hunks":["@@ -61,7 +61,7 @@ def test_bad_token_401(monkeypatch):\n     gate). fire_due must NOT run.\"\"\"\n     fired = []\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: None),  # verification fails\n     )\n     monkeypatch.setattr(web_server, \"_find_cron_job_profile\", lambda jid: \"default\")","@@ -82,7 +82,7 @@ def test_bad_token_401(monkeypatch):\n \n def test_missing_job_id_400(monkeypatch):\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: {\"purpose\": \"cron_fire\"}),\n     )\n     client, pa, ph = _client(auth_required=False)","@@ -100,7 +100,7 @@ def test_unknown_job_200_gone(monkeypatch):\n     \"\"\"Valid token but the job isn't found in any profile -> 200 'gone'\n     (NAS shouldn't retry a fire for a cancelled/completed job).\"\"\"\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: {\"purpose\": \"cron_fire\"}),\n     )\n     monkeypatch.setattr(web_server, \"_find_cron_job_profile\", lambda jid: None)","@@ -121,7 +121,7 @@ def test_valid_token_accepts_and_fires(monkeypatch):\n     profile.\"\"\"\n     fired = []\n     monkeypatch.setattr(\n-        \"plugins.cron.chronos.verify.get_fire_verifier\",\n+        \"plugins.cron_providers.chronos.verify.get_fire_verifier\",\n         lambda: (lambda **kw: {\"purpose\": \"cron_fire\", \"aud\": \"agent:x\"}),\n     )\n     monkeypatch.setattr(web_server, \"_find_cron_job_profile\", lambda jid: \"default\")"]},{"file":"tests/plugins/test_chronos_cron.py","hunks":["@@ -21,7 +21,7 @@ def temp_home(tmp_path, monkeypatch):\n @pytest.fixture\n def chronos(monkeypatch):\n     \"\"\"A ChronosCronScheduler with a fake NAS client capturing calls.\"\"\"\n-    from plugins.cron.chronos import ChronosCronScheduler\n+    from plugins.cron_providers.chronos import ChronosCronScheduler\n \n     class FakeClient:\n         def __init__(self):","@@ -47,7 +47,7 @@ def list_armed(self):\n     fake = FakeClient()\n     prov._client = fake\n     # callback_url is read via _cfg; patch the module helper to avoid config.\n-    monkeypatch.setattr(\"plugins.cron.chronos._cfg\",\n+    monkeypatch.setattr(\"plugins.cron_providers.chronos._cfg\",\n                         lambda *k, default=\"\": \"https://agent.example/\" if k[-1] == \"callback_url\" else \"https://portal.test\")\n     return prov, fake\n ","@@ -55,15 +55,15 @@ def list_armed(self):\n # -- is_available -------------------------------------------------------------\n \n def test_is_available_false_without_config(temp_home, monkeypatch):\n-    from plugins.cron.chronos import ChronosCronScheduler\n+    from plugins.cron_providers.chronos import ChronosCronScheduler\n \n-    monkeypatch.setattr(\"plugins.cron.chronos._cfg\", lambda *k, default=\"\": \"\")\n+    monkeypatch.setattr(\"plugins.cron_providers.chronos._cfg\", lambda *k, default=\"\": \"\")\n     assert ChronosCronScheduler().is_available() is False\n \n \n def test_is_available_true_with_config_and_token(temp_home, monkeypatch):\n-    import plugins.cron.chronos as mod\n-    from plugins.cron.chronos import ChronosCronScheduler\n+    import plugins.cron_providers.chronos as mod\n+    from plugins.cron_providers.chronos import ChronosCronScheduler\n \n     monkeypatch.setattr(mod, \"_cfg\", lambda *k, default=\"\": \"https://x\" )\n     monkeypatch.setattr(\"hermes_cli.auth.get_provider_auth_state\",","@@ -73,8 +73,8 @@ def test_is_available_true_with_config_and_token(temp_home, monkeypatch):\n \n def test_is_available_makes_no_network(temp_home, monkeypatch):\n     \"\"\"is_available must not construct the NAS client / hit network.\"\"\"\n-    import plugins.cron.chronos as mod\n-    from plugins.cron.chronos import ChronosCronScheduler\n+    import plugins.cron_providers.chronos as mod\n+    from plugins.cron_providers.chronos import ChronosCronScheduler\n \n     monkeypatch.setattr(mod, \"_cfg\", lambda *k, default=\"\": \"https://x\")\n     monkeypatch.setattr(\"hermes_cli.auth.get_provider_auth_state\","]},{"file":"tests/plugins/test_chronos_verify.py","hunks":["@@ -54,7 +54,7 @@ def _base_claims(**over):\n \n \n def test_valid_token_returns_claims(rsa_keys):\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, pub = rsa_keys\n     token = [redacted:secret-assignment] _base_claims())","@@ -66,7 +66,7 @@ def test_valid_token_returns_claims(rsa_keys):\n \n \n def test_wrong_audience_rejected(rsa_keys):\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, pub = rsa_keys\n     token = [redacted:secret-assignment] _base_claims(aud=\"agent:someone-else\"))","@@ -76,7 +76,7 @@ def test_wrong_audience_rejected(rsa_keys):\n \n def test_missing_purpose_rejected(rsa_keys):\n     \"\"\"A general agent JWT (no purpose=cron_fire) can't fire jobs.\"\"\"\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, pub = rsa_keys\n     claims = _base_claims()","@@ -87,7 +87,7 @@ def test_missing_purpose_rejected(rsa_keys):\n \n \n def test_wrong_purpose_rejected(rsa_keys):\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, pub = rsa_keys\n     token = [redacted:secret-assignment] _base_claims(purpose=\"inference\"))","@@ -96,7 +96,7 @@ def test_wrong_purpose_rejected(rsa_keys):\n \n \n def test_expired_token_rejected(rsa_keys):\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, pub = rsa_keys\n     now = int(time.time())","@@ -106,7 +106,7 @@ def test_expired_token_rejected(rsa_keys):\n \n \n def test_wrong_issuer_rejected(rsa_keys):\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, pub = rsa_keys\n     token = [redacted:secret-assignment] _base_claims(iss=\"https://evil.example\"))","@@ -118,7 +118,7 @@ def test_tampered_signature_rejected(rsa_keys):\n     \"\"\"A token signed by a DIFFERENT key must fail signature verification.\"\"\"\n     from cryptography.hazmat.primitives import serialization\n     from cryptography.hazmat.primitives.asymmetric import rsa\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     _, pub = rsa_keys\n     attacker = rsa.generate_private_key(public_exponent=65537, key_size=2048)","@@ -135,7 +135,7 @@ def test_tampered_signature_rejected(rsa_keys):\n \n def test_no_key_configured_refuses(rsa_keys):\n     \"\"\"No JWKS/key configured → refuse (never fall back to unsigned decode).\"\"\"\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, _ = rsa_keys\n     token = [redacted:secret-assignment] _base_claims())","@@ -144,7 +144,7 @@ def test_no_key_configured_refuses(rsa_keys):\n \n \n def test_empty_token_refused(rsa_keys):\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     _, pub = rsa_keys\n     assert verify_nas_fire_token(token=\"\", expected_audience=AUD, jwks_or_key=pub) is None","@@ -152,7 +152,7 @@ def test_empty_token_refused(rsa_keys):\n \n def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch):\n     \"\"\"The JWKS-URL branch resolves the signing key via PyJWKClient.\"\"\"\n-    from plugins.cron.chronos.verify import verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import verify_nas_fire_token\n \n     priv, pub = rsa_keys\n     token = [redacted:secret-assignment] _base_claims())","@@ -177,6 +177,6 @@ def get_signing_key_from_jwt(self, tok):\n \n \n def test_get_fire_verifier_returns_nas_verifier():\n-    from plugins.cron.chronos.verify import get_fire_verifier, verify_nas_fire_token\n+    from plugins.cron_providers.chronos.verify import get_fire_verifier, verify_nas_fire_token\n \n     assert get_fire_verifier() is verify_nas_fire_token\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}