fix(gateway): emit session:end from idle-expiry watcher + auto-reset paths#28750
Open
mbs-vhs wants to merge 3 commits into
Open
fix(gateway): emit session:end from idle-expiry watcher + auto-reset paths#28750mbs-vhs wants to merge 3 commits into
mbs-vhs wants to merge 3 commits into
Conversation
…paths The gateway ``session:end`` hook only fired from the explicit ``/new``/``/reset`` command handler. Two other paths that terminate a session — the background idle-expiry watcher and the auto-reset branch in ``SessionStore.get_or_create_session`` — fired only the plugin-level ``on_session_finalize`` hook and closed the session locally without notifying gateway hook subscribers. External hooks under ``~/.hermes/hooks/`` that subscribe to ``session:end`` (to close mirror rows in an external DB, finalize logging, etc.) silently missed every idle-expiry- and auto-reset-driven close — leaving stale state forever. This change makes ``session:end`` symmetric with ``session:start``: - ``gateway/run.py:_session_expiry_watcher`` now emits ``session:end`` with ``reason="idle_expiry"`` right after marking ``entry.expiry_finalized = True``. Wrapped in ``try/except`` so a misbehaving subscriber can't break the watcher loop. - ``gateway/run.py:_handle_message_with_agent`` now emits ``session:end`` for the OLD session_id (with ``reason="auto_reset"``) in the ``_is_new_session`` branch — BEFORE emitting ``session:start`` for the new one — when ``SessionEntry.auto_reset_prior_session_id`` is set. - ``gateway/session.py`` adds a transient ``auto_reset_prior_session_id`` field on ``SessionEntry`` (default ``None``, not persisted to ``sessions.json``) so ``get_or_create_session`` can carry the prior id forward to the emit pipeline. - ``gateway/hooks.py`` docstring updated to document the new firing contract (``reason`` keys for the new emit paths). Existing behavior is preserved: the explicit ``/new``/``/reset`` emit shape at the original call site is unchanged, and existing subscribers keep receiving the same events for the same paths. The new emits include ``session_id`` (the explicit ``/new`` emit currently doesn't — that asymmetry is left for a separate change because it's load-bearing for the new emit paths only). Closes NousResearch#28746 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 tasks
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?
Fix the gateway
session:endhook so it fires from every session-close path, not just/new//reset. Without this, external hook subscribers (under~/.hermes/hooks/) silently miss every idle-expiry- and auto-reset-driven close — leaving stale mirror state forever.The gateway emits the lifecycle event
session:endfrom exactly one call site, the/new//resethandler. Two other paths close sessions but don't emit:Idle-expiry watcher (
gateway/run.py:_session_expiry_watcher) — fires the plugin-levelon_session_finalize(per [Bug]: The on_session_finalize hook is not being fired when gateway sessions expire due to configured idle time. #14981), evicts the cached agent, marksentry.expiry_finalized = True. But neverself.hooks.emit("session:end", ...).Auto-reset branch (
gateway/session.py:get_or_create_session) — when a stale entry is rolled over for a newsession_id, the local SQLite session ends but no gateway event fires for the oldsession_id.This PR makes
session:endsymmetric withsession:start— every close path now emits, every subscriber sees the close.Related Issue
Fixes #28746
Type of Change
Changes Made
gateway/run.py:_session_expiry_watcher— emitsession:endwithreason="idle_expiry"right afterentry.expiry_finalized = True. Wrapped intry/except logger.debug(..., exc_info=True)so a misbehaving subscriber can't break the watcher loop. (platformis pulled from the session_key just like the existingon_session_finalizeinvocation;user_idfromentry.originwith the sameor ""guard pattern used elsewhere in this file.)gateway/run.py:_handle_message_with_agent— in the_is_new_sessionbranch, before emittingsession:start, checkgetattr(session_entry, "auto_reset_prior_session_id", None). If set, emitsession:endwithreason="auto_reset", then clear the field so a follow-up turn cannot re-emit for the same close.gateway/session.py— add a transient fieldauto_reset_prior_session_id: Optional[str] = NonetoSessionEntry.get_or_create_sessionpopulates it (with the priorsession_id) whenever the auto-reset branch fires. Not added toto_dict()— this is a transient signal consumed by the next emit pass, never persisted tosessions.json.gateway/hooks.py— docstring update to reflect the new firing contract:session:endnow lists the three close paths and notes thereasonkey on the new emit paths.tests/gateway/test_session_boundary_hooks.py— three new tests:test_idle_expiry_emits_session_end— runs_session_expiry_watcheragainst a mocked-out store with one expired entry; asserts the emit fires with the expired session_id,session_key, andreason="idle_expiry". Regression guard for the watcher path.test_auto_reset_emits_session_end_for_prior_session— exercises the emit fragment in_handle_message_with_agentagainst aSessionEntrythat was just produced by auto-reset (withauto_reset_prior_session_id="sess-old-prior"); assertssession:endfires beforesession:start, with the right session_id +reason="auto_reset", and that the transient field is cleared after consume.test_session_entry_has_auto_reset_prior_session_id_field— dataclass-level sanity check (defaultNone, writable, not into_dict()).The existing 6 tests in this file (
test_reset_*,test_finalize_before_reset,test_shutdown_*,test_hook_error_*,test_idle_expiry_fires_finalize_hook) all still pass — no existing call site changes behavior.How to Test
Automated
pytest tests/gateway/test_session_boundary_hooks.py -v --override-ini="addopts="All 9 tests pass (6 pre-existing + 3 new).
Manual reproduction
Install a gateway hook in
~/.hermes/hooks/test-session-end/:Start the gateway. Open a Telegram DM session. Send one message. Confirm
session:startlands in~/session_end_test.log.Stop messaging. Wait for the configured idle-reset window to pass plus one 5-min watcher tick.
Before this fix: the log shows only
session:start(nosession:end).~/.hermes/sessions/sessions.jsonshowsexpiry_finalized: truefor the session.After this fix: the log shows
session:endwithreason: "idle_expiry",session_id,session_key,platform,user_id.sessions.jsonstill showsexpiry_finalized: true.Repeat for the auto-reset path: start a fresh session, idle past the reset window, then send a NEW message (not
/new). The handler logssession:endfor the OLDsession_id(withreason: "auto_reset"), thensession:startfor the NEW one.Note on Hermes version
Tested against the fork's
mainbranch (currently aligned withhermes-agent 0.14.0perpyproject.toml). The patched code paths exist identically in0.13.0and earlier — the line numbers in the original issue reference the v0.13.0 layout. The semantic anchors (function names, code shapes) are unchanged.Checklist
Code
fix(gateway): ...)chat_idto the existing emit sites — different bug, cleanly compatible with this change)pytest tests/gateway/test_session_boundary_hooks.py -vand all tests pass (9/9)test_session_boundary_hooks.py)Documentation & Housekeeping
gateway/hooks.pyEvents docstring reflects the new firing contract forsession:endcli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A