From b5d89fd4dae5f842cc01763ab4f7daed5a089804 Mon Sep 17 00:00:00 2001 From: Allan Ditzel Date: Wed, 22 Apr 2026 01:53:36 -0400 Subject: [PATCH 1/2] fix(gateway): remove stale json pid records --- gateway/status.py | 5 +---- tests/gateway/test_status.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/gateway/status.py b/gateway/status.py index 74763332c8..7e80fa0b83 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -216,10 +216,7 @@ def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None: if not cleanup_stale: return try: - if pid_path == _get_pid_path(): - remove_pid_file() - else: - pid_path.unlink(missing_ok=True) + pid_path.unlink(missing_ok=True) except Exception: pass diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 6c371cfbea..e5e5d2c133 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -51,6 +51,24 @@ class TestGatewayPidState: assert status.get_running_pid() is None assert not pid_path.exists() + def test_get_running_pid_removes_stale_json_pid_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + pid_path = tmp_path / "gateway.pid" + pid_path.write_text(json.dumps({ + "pid": 99999, + "kind": "hermes-gateway", + "argv": ["python", "-m", "hermes_cli.main", "gateway"], + "start_time": None, + })) + + def _raise_missing(pid, sig): + raise ProcessLookupError + + monkeypatch.setattr(status.os, "kill", _raise_missing) + + assert status.get_running_pid() is None + assert not pid_path.exists() + def test_get_running_pid_accepts_gateway_metadata_when_cmdline_unavailable(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) pid_path = tmp_path / "gateway.pid" From 56ed019a1a0ce1a8bbf1aceefffe28fb0c617ffd Mon Sep 17 00:00:00 2001 From: Allan Ditzel Date: Wed, 22 Apr 2026 01:53:50 -0400 Subject: [PATCH 2/2] fix(plugins): classify wrapper memory providers as exclusive --- hermes_cli/plugins.py | 68 +++++++++++++++++++++++++++----- tests/hermes_cli/test_plugins.py | 35 ++++++++++++++++ 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 11f18f0716..18d442e7c3 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -136,6 +136,46 @@ def _get_enabled_plugins() -> Optional[set]: _VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"} +def _looks_like_exclusive_plugin(plugin_dir: Path) -> bool: + """Heuristic: does *plugin_dir* implement an exclusive provider contract? + + Today this is primarily used to keep memory provider plugins discovered under + ``$HERMES_HOME/plugins`` out of the general plugin loader. Those plugins are + selected via ``memory.provider`` and register via + ``ctx.register_memory_provider(...)`` rather than generic tool/hook APIs. + + We scan a small set of Python files under the plugin root so wrapper-style + packages that re-export their real implementation from ``src/`` still get + classified correctly. + """ + py_files = [] + init_file = plugin_dir / "__init__.py" + if init_file.exists(): + py_files.append(init_file) + try: + for path in sorted(plugin_dir.rglob("*.py")): + if path == init_file: + continue + py_files.append(path) + if len(py_files) >= 12: + break + except Exception: + pass + + scanned = 0 + for path in py_files: + try: + source = path.read_text(errors="replace") + except Exception: + continue + scanned += len(source) + if "register_memory_provider" in source or "MemoryProvider" in source: + return True + if scanned >= 32768: + break + return False + + @dataclass class PluginManifest: """Parsed representation of a plugin.yaml manifest.""" @@ -723,16 +763,24 @@ class PluginManager: name = data.get("name", plugin_dir.name) key = f"{prefix}/{plugin_dir.name}" if prefix else name - raw_kind = data.get("kind", "standalone") - if not isinstance(raw_kind, str): - raw_kind = "standalone" - kind = raw_kind.strip().lower() - if kind not in _VALID_PLUGIN_KINDS: - logger.warning( - "Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'", - key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)), - ) - kind = "standalone" + raw_kind = data.get("kind") + if raw_kind is None: + kind = "exclusive" if _looks_like_exclusive_plugin(plugin_dir) else "standalone" + if kind == "exclusive": + logger.debug( + "Auto-classifying plugin %s as exclusive based on provider-style register() contract", + key, + ) + else: + if not isinstance(raw_kind, str): + raw_kind = "standalone" + kind = raw_kind.strip().lower() + if kind not in _VALID_PLUGIN_KINDS: + logger.warning( + "Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'", + key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)), + ) + kind = "standalone" return PluginManifest( name=name, diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 9433ecdca8..8320c475ab 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -100,6 +100,41 @@ class TestPluginDiscovery: assert "hello_plugin" in mgr._plugins assert mgr._plugins["hello_plugin"].enabled + def test_user_memory_provider_plugins_are_classified_exclusive(self, tmp_path, monkeypatch): + """Wrapper-style user memory providers should bypass the general loader.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + plugin_dir = plugins_dir / "memory_plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.yaml").write_text( + yaml.safe_dump({ + "name": "memory_plugin", + "version": "0.1.0", + "description": "Wrapper-style memory provider plugin", + }) + ) + (plugin_dir / "__init__.py").write_text( + 'from .src.memory_plugin_impl import register\n' + ) + impl_dir = plugin_dir / "src" / "memory_plugin_impl" + impl_dir.mkdir(parents=True, exist_ok=True) + (impl_dir / "__init__.py").write_text( + 'def register(ctx):\n ctx.register_memory_provider(object())\n' + ) + hermes_home = tmp_path / "hermes_test" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"enabled": ["memory_plugin"]}}) + ) + + mgr = PluginManager() + mgr.discover_and_load() + + assert "memory_plugin" in mgr._plugins + loaded = mgr._plugins["memory_plugin"] + assert loaded.manifest.kind == "exclusive" + assert not loaded.enabled + assert "exclusive plugin" in (loaded.error or "") + def test_discover_project_plugins(self, tmp_path, monkeypatch): """Plugins in ./.hermes/plugins/ are discovered.""" project_dir = tmp_path / "project"