docs/ADR.md
Status: Accepted
Context:
Hermes supports multiple profiles via different Hermes home directories.
Homes are switched two ways in a running process: the HERMES_HOME
environment variable (single-profile CLI/gateway processes), and the
context-local set_hermes_home_override() (hermes_constants.py), which
the multiplexed gateway worker (gateway/run.py's _profile_scope) and
subagent/embedded callers use to serve several profiles from one
long-lived process. The override is a ContextVar and deliberately does
not mutate os.environ, since that would leak one profile's home
into every other concurrent task in the same process.
The plugin manager was a process-global single-slot singleton
(_plugin_manager). User-installed plugins are discovered from
get_hermes_home() / "plugins", and context-engine plugins (e.g.
hermes-lcm) capture profile-scoped state — such as the LCM database
path — at registration time. A single-slot cache meant:
set_hermes_home_override() was invisible to a
naive "did HERMES_HOME change" check, so the singleton silently kept
serving the first profile's manager to every other profile in the
process.PluginManager was created for a new home, plugin
modules are imported into sys.modules as hermes_plugins.<slug> by
_load_directory_module, and only that top-level module was ever
replaced. A same-slug plugin's relative imports
(from . import state) are cached separately under
hermes_plugins.<slug>.<submodule>, and Python's import machinery
resolves those from sys.modules first — so a profile switch could
silently keep serving a previous profile's already-imported submodule
code/state instead of re-executing the new profile's plugin.Decision:
_plugin_managers_by_home: Dict[Path, PluginManager]).
get_plugin_manager() resolves the current home via get_hermes_home()
(which itself already consults get_hermes_home_override() before
os.environ), so both the env-var and context-local override paths are
covered uniformly._plugin_manager (the old single-slot name) is kept as a thin "last
manager returned" pointer purely for backward compatibility with
existing test code that does
monkeypatch.setattr(plugins_mod, "_plugin_manager", some_manager).
When that name is monkeypatched to a manager the keyed cache doesn't
know about, get_plugin_manager() treats it as an explicit injection
and adopts it into the cache under the current resolved home, rather
than discarding it.PluginManager._load_directory_module (initial/force=True
reload within the same home) and the shared _clear_plugin_submodules
helper (profile switch / test teardown) evict sys.modules[module_name]
and every name prefixed with module_name + "." before a plugin
slug is (re-)imported, so relative-import submodules can never survive
a reload or a home switch.tests/conftest.py's _hermetic_environment fixture)
calls a new _reset_plugin_managers_for_tests() helper that drops the
entire keyed cache and purges every plugin submodule from sys.modules
between tests, instead of only resetting the single-slot pointer.Consequences:
{home}/lcm.db regardless of whether the profile switch went
through HERMES_HOME or set_hermes_home_override().set_hermes_home_override()) rather than only the env-var path, and
includes a dedicated relative-import leak test.