cron namespace collision causing gateway startup crashes

NousResearch/hermes-agent · cluster 49410

Data as of

Nothing open on GitHub. Fix merged in #49913. Check it covers your case.

The short answer

0open issues
0open PRs
10items grouped
Known?
Yes. 10 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #49913. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #49913, #51702. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #49913 fix(gateway): correct sys.path insertion in all migrated platform adapters (cron namespace collision)

    merged to main

    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

    Show the diff
    • plugins/platforms/discord/adapter.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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

    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

    Show the diff
    • cron/scheduler_provider.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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(("_", ".")):
      @@ -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.
       
      @@ -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():
      @@ -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":
      @@ -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
      @@ -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)
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
       
      @@ -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
      @@ -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"}),
           )
       
      @@ -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
           )
       
      @@ -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"}),
           )
       
      @@ -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
      @@ -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")
      @@ -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)
      @@ -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)
      @@ -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
      @@ -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):
      @@ -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
       
      @@ -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",
      @@ -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
      @@ -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())
      @@ -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"))
      @@ -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()
      @@ -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"))
      @@ -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())
      @@ -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"))
      @@ -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)
      @@ -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())
      @@ -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
      @@ -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())
      @@ -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

4 PRs closed without merging

From the close comments: merged via another change 3 · duplicate 1

  • #49424 fix(gateway): move cron.scheduler_provider import to module level · duplicate → #49414, #49410
  • #49431 fix(gateway): correct sys.path insertion in plugins to prevent cron namespace collision (#49410) · merged via another change → 4c206b9, #49913
  • #50958 fix(cron): avoid provider package shadowing core cron · merged via another change → #51702
  • #51162 fix(cron): rename plugins/cron to plugins/cron_providers to resolve WebUI import collision · merged via another change → #50958, #51702
Closed issues · 4
  • Closed#49410[Bug]: Gateway crashes on startup with ModuleNotFoundError: No module named 'cron.scheduler_provider'
  • Closed#49824[Bug] v0.17.0 gateway fails to start: ModuleNotFoundError cron.scheduler_provider
  • Closed#50872plugins/cron/ package name collision with cron/ — ModuleNotFoundError in WebUI
  • Closed#51405[Bug] Missing cron.scheduler_provider in 0.17.0 - Gateway crashes on startup with Feishu
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#49913, #51702
Closed issues
4
Attempts
4 PRs closed without merging
Fix link checked
24 Jun 2026

For your agent

The same facts, typed.