fix(goals): make the goal store's launch-home scope deliberate, not accidental - #320
Merged
Merged
Conversation
…ccidental
`goals._get_session_db` cached one SessionDB per `get_hermes_home()` path and
its docstring claimed "we cache one instance per hermes_home path so profile
switches still pick up the right DB." That claim was false. It built
`SessionDB()` with no argument, and `SessionDB.__init__` falls back to
`hermes_state.DEFAULT_DB_PATH` — a module-level constant evaluated at import
time. Every SessionDB the goal store handed out pointed at whichever home was
active when `hermes_state` first got imported, so the cache key was dead code
documenting behavior the process did not have:
os.environ["HERMES_HOME"] = tempfile.mkdtemp()
db1 = goals._get_session_db()
os.environ["HERMES_HOME"] = tempfile.mkdtemp()
goals._DB_CACHE.clear()
db2 = goals._get_session_db()
# db1.db_path == db2.db_path
The tempting fix — follow the active home — is the wrong one, and would break
the goal loop. A goal's writers and its reader sit on opposite sides of the
per-turn HERMES_HOME binding. In `tui_gateway/server.py` the desktop gateway
writes goals from `session.create` (`spawn --goal`, which computes
`profile_home` but never binds it) and from `command.dispatch` (`/goal`, which
binds no profile home either, and which `/goal` reaches deliberately rather
than the slash-worker subprocess — see `_PENDING_INPUT_COMMANDS`). It reads
them back in the post-turn continuation hook, which DOES run inside
`set_hermes_home_override(profile_home)`. Make the store follow the active home
and, for any session under a non-launch profile, `/goal` writes one database
while the hook reads another, `is_active()` returns False, and the Ralph loop
silently never fires — the same stranded-loop failure #319 just fixed, arrived
at from the other direction. Verified: flipping this function to
`get_hermes_home()` fails the new `test_reader_under_profile_override_still_
sees_the_goal` with `loaded is None`.
So the scope stays process-global — but stated rather than inherited by
accident. `_get_session_db` now resolves `get_process_hermes_home() /
"state.db"` and passes it explicitly. `get_process_hermes_home` is the existing
accessor for exactly this: it reads the process env and ignores the
context-local override. Production behavior is unchanged, because both gateways
bind the profile through a contextvar and never through `os.environ`.
Two things improve as a result. The cache key is now the resolved path, so it
is live code instead of a comment about behavior that did not exist. And the
store no longer depends on *when* `hermes_state` was first imported — a real
hazard: measured across a multi-file pytest run, goal rows landed in the FIRST
test's tmp home rather than the current test's, so per-test `HERMES_HOME`
monkeypatching silently did not isolate them.
Goals cannot collide across profiles either way, because `session_id` (the
session key) is globally unique.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OmarB97
added a commit
that referenced
this pull request
Aug 2, 2026
…equest (#331) Plugin discovery is lazy, and several tools call `_ensure_plugins_discovered()` from inside an agent turn — `tts_tool`, `web_tools`, `video_generation_tool`, `browser_tool`. The gateway's turn handler binds `set_hermes_home_override(session_profile_home)` for the duration of a turn, and `discover_and_load` resolved its user plugin dir with `get_hermes_home()`. So whichever session happened to trigger the FIRST discovery decided the whole process's plugin registry, permanently — the singleton never rescans. In a backend serving several profiles the launch profile could lose its own plugins AND inherit another profile's, depending only on which chat ran a plugin-touching tool first. Reproduced with two homes: discovery fired under a profile override, and afterwards the launch profile's own `/launch-cmd` was gone while the other profile's `/profile-cmd` answered for it. Bind the process home around the sweep. That covers the user plugin dir, the enabled/disabled allow-lists (`load_config` follows the override too, so changing only the directory would have scanned one home while gating on another), and anything a plugin's own `register()` reads — one consistent scope instead of a mix. The process home is the only coherent scope here: plugins register process-global capabilities (tools, hooks, middleware, platforms, the context engine) that every session on the backend shares, and `PluginManager` has no lock, so re-scoping per request would mean clearing registrations out from under live turns. Same rationale as the dashboard's user plugin dir in `hermes_cli/web_server.py` and the goal store in #320. This does NOT give non-launch profiles their own plugins when one backend serves many — that needs a per-profile registry and is left alone deliberately. It makes the existing scope deterministic and correct for the per-profile deployments that are the norm: a dedicated backend or slash worker exports HERMES_HOME at spawn, so the process home IS that profile's home there. Co-authored-by: Omar Baradei <omar@kostudios.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
hermes_cli/goals.py::_get_session_dbcached oneSessionDBperget_hermes_home()path, and its docstring claimed "We cache one instance perhermes_homepath so profile switches still pick up the right DB."That claim was false. It constructed
SessionDB()with no argument, andSessionDB.__init__(hermes_state.py:1121) falls back toDEFAULT_DB_PATH— a module-level constant evaluated at import time (hermes_state.py:206). EverySessionDBthe goal store handed out pointed at whichever home was active whenhermes_statewas first imported, regardless of the liveHERMES_HOME. The_DB_CACHEkeying was dead code documenting behavior the process did not have.The tempting fix — make the store follow the active home — is the wrong one, and would break the goal loop.
A goal's writers and its reader sit on opposite sides of the per-turn
HERMES_HOMEbinding:session.create(spawn --goal, server.py:6639)profile_homeat :6477, never binds itcommand.dispatch(/goal, server.py:13905)set_hermes_home_override/_profile_scopedin its bodyset_hermes_home_override(profile_home)(bound :10801, reset :11301)All three key on the same
session_key. Today they agree only because the store ignores the home entirely. Make it follow the active home and, for any session running under a non-launch profile,/goalwrites one database while the hook reads another —is_active()returns False and the Ralph loop silently never fires. That is the same stranded-loop failure mode #319 just fixed, arrived at from the other direction.This is verified, not argued: temporarily flipping this function to
get_hermes_home()fails the newtest_reader_under_profile_override_still_sees_the_goalwithAssertionError: goal store followed the per-turn override.gateway/run.pyhas the same shape —_post_turn_goal_continuation(:14210) runs under_profile_runtime_scope, while_goal_still_active_for_session(:4930) does a "best-effort fresh DB check" from queue-drain code that does not.So the scope stays process-global — but stated rather than inherited by accident.
_get_session_dbnow resolvesget_process_hermes_home() / "state.db"and passes it explicitly. That accessor exists for precisely this case: it reads the process env and deliberately ignores the context-local override. Production behavior is unchanged, because both gateways bind the profile through a contextvar, never throughos.environ.Two things improve as a result:
_DB_CACHEis live code instead of a comment about behavior that did not exist.hermes_statewas first imported. That was a real hazard, not a theoretical one — measured across a multi-file pytest run, goal rows landed in the first test's tmp home rather than the current test's, so per-testHERMES_HOMEmonkeypatching silently did not isolate them. Thehermes_homefixture's comment intest_goals.py("Bust the goal-module's DB cache for each test so it re-resolves HERMES_HOME") is now true.Goals cannot collide across profiles either way, because
session_id(the session key) is globally unique — which is why per-profile isolation buys nothing here.Related Issue
No issue filed — found by inspection of the
_DB_CACHEdocstring.Type of Change
Changes Made
hermes_cli/goals.py—_get_session_dbresolvesget_process_hermes_home() / "state.db"and passes it toSessionDB(db_path=...)instead of relying on the import-timeDEFAULT_DB_PATHfallback._DB_CACHEis keyed by resolved path. The docstring now states the process-global scope, names the writer/reader split that requires it, and records why the explicit path replaces the import-time constant.tests/hermes_cli/test_goals.py— newTestGoalStoreHomeScope:test_reader_under_profile_override_still_sees_the_goal— writes a goal unbound (assession.create/command.dispatchdo), reads it back insideset_hermes_home_override(other_profile), asserts the goal survives. This is the guard: it fails if anyone makes the store follow the active home.test_db_path_is_the_process_home_not_an_import_time_constant— the DB follows the processHERMES_HOMErather than freezing at first import.How to Test
main— the two paths print identical. On this branch they differ.get_process_hermes_hometoget_hermes_homeon both lines in_get_session_db, then runpytest tests/hermes_cli/test_goals.py::TestGoalStoreHomeScope -q→test_reader_under_profile_override_still_sees_the_goalfails withassert None is not None. Revert.→
11 files, 241 tests passed, 0 failed.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — not run in full. The 11 goal-related files above are green (241/241). The full suite is not green on this macOS host onmaineither (Linux-only tooling), so a full-suite claim would be noise.Documentation & Housekeeping
docs/, docstrings) — the_get_session_dbdocstring is the substance of this changecli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Aget_process_hermes_homealready handles the platform-native default; no path handling addedScreenshots / Logs
Guard proven to bite (implementation temporarily flipped to
get_hermes_home()):Follow-up (not in this PR)
command.dispatchbinds no profile home at all, so in app-global remote mode every slash command resolves config, plugins, skills and quick-commands against the gateway's launch profile even for sessions running under another one. Some of that may be intentional (cf.get_process_hermes_home's "dashboard-owned assets" doctrine); some may not. It needs per-command judgment and has wide blast radius, so it is deliberately out of scope here. Note that/goalmust be excluded from any such change — this store depends oncommand.dispatchstaying unbound, which is whyTestGoalStoreHomeScopeexists.🤖 Generated with Claude Code