fix(gateway): honor the reset policy when recovering a session from state.db (#66255) - #66264
Conversation
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved (LGTM)
Overview
Fixes gateway reset policy handling when recovering sessions from state.db. +233/0, 2 files.
Security
- No hardcoded secrets or credentials
Code Quality
- Clean fix for session recovery edge case
- Proper reset policy application
Looks Good
- Well-scoped bug fix
Reviewed by Hermes Agent
|
Thanks for isolating the DB-only recovery gap. Current Problems
Suggested changes
Automated hermes-sweeper review. |
…tate.db (NousResearch#66255) get_or_create_session() only evaluated _should_reset() for sessions still present in the in-memory _entries map. A session recovered from state.db after a gateway restart (session_key missing from _entries) skipped the check entirely and was adopted unconditionally — with updated_at stamped at the recovery moment instead of the session's real last activity, which also re-armed the idle clock from zero. A session that should have expired hours or days ago could resume indefinitely, carrying its full history across restarts. Two changes: - _create_entry_from_recovered_row() now derives updated_at from the recovered row's real last message timestamp (new HermesStateDB.get_last_message_timestamp()), falling back to created_at when no messages exist. - _query_recoverable_session() evaluates _should_reset() against the recovered entry before publishing it. If the row is past its reset deadline, it's ended in state.db (end_reason='session_reset') and the caller falls through to the existing fresh-session path, reusing the same was_auto_reset/reset_had_activity notice a live in-memory expiry produces. mode="none" (the default) is unaffected — _should_reset() is a no-op for that policy, so recovery behavior is unchanged unless session_reset is explicitly configured.
…eal-DB tests Follow-up to the previous commit per maintainer review on PR NousResearch#66264: - Share the recovered-row reset-policy resolution between _query_recoverable_session() and _recover_session_from_db() via a new _resolve_recovered_session_row() helper, instead of only guarding the runtime get_or_create_session() path. The startup self-heal path (_prune_stale_sessions_locked -> _recover_session_from_db) now honors the reset policy too. - Stop reopening the row before checking policy. The policy check now runs first; only a valid (non-expired) resume candidate is reopened, and an expired row is promoted via promote_to_session_reset() (with an end_session() fallback for DBs predating that API) instead of being reopened and immediately re-ended. - Add SessionDB.reopen_recoverable_session(): a conditional reopen (WHERE ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap')) mirroring promote_to_session_reset()'s recoverable set. The policy check runs without holding a lock, so another thread could finalize the same row with an explicit boundary (session_reset/session_switch/compression) in between; an unconditional reopen would silently erase that boundary. When the conditional update affects no rows, recovery is declined instead of resurrecting the session. - Add two real-SessionDB regression tests (not mock-only): an expired agent_close row is durably promoted and no longer resurfaces via find_latest_gateway_session_for_peer, and get_last_message_timestamp drives the recovered entry's updated_at from actual message data. - Add a race-loss regression test: reopen_recoverable_session() returning False must decline recovery, not publish the row anyway. - Update existing mock-based tests in test_session_store_stale_prune.py and test_session_store_runtime_stale_guard.py to assert against reopen_recoverable_session (the method the recovery path now calls) instead of the now-bypassed plain reopen_session.
e2c6db2 to
d1e34e0
Compare
SummaryNine PRs address three linked session-recovery causes: #61242, #61489, #62672, #63068, and merged #65783 persist expiry boundaries; #66264 and #71530 apply reset policy using durable activity before DB recovery; #68617 and #68795 prevent recovery from crossing newer boundaries. The merged #65783 is the reference for #61220, while #66255 and #68539 each retain competing open implementations. Related pull requests
Duplicates#61242, #61489, #62672, and #63068 are variants of the #61220 expiry-finalization fix now represented by merged #65783. #66264 and #71530 are competing implementations of the #66255 recovery contract rather than exact duplicates at their current heads; #68795 substantially overlaps #68617 on #68539 but has narrower boundary semantics and unrelated bundled work. Suggested consolidationClose #61242 as duplicate of merged #65783; #61489, #62672, and #63068 are already closed members of that chain. Keep #66264 open with a salvage path for its shared last-activity, pre-reopen policy, conditional-reopen, and real-DB mechanism, while author action on #71530 should rebase onto main or split out the SQL-projection/reset-metadata implementation for maintainers to choose one #66255 design without discarding either recorded best-fix verdict. Keep #68617 open with a salvage path for focused newest-row-first boundary selection; close #68795 as duplicate of #68617 after splitting its unrelated profile change—despite #68795's keep_open verdict, its keyed-fallback gap and failure to make every newer durable row authoritative are shown by the diff. Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I61220(["issue #61220 (closed)"])
I66255(["issue #66255 (open)"])
I68539(["issue #68539 (open)"])
P66264["PR #66264 (open)"]
P66264 -.->|partial| I61220
P66264 -->|best fix| I66255
P66264 -.->|partial| I68539
class I61220 closed
class I66255 open
class I68539 open
class P66264 open
class P66264 best
class P66264 target
click I61220 "https://github.com/NousResearch/hermes-agent/issues/61220"
click I66255 "https://github.com/NousResearch/hermes-agent/issues/66255"
click I68539 "https://github.com/NousResearch/hermes-agent/issues/68539"
click P66264 "https://github.com/NousResearch/hermes-agent/pull/66264"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 9 pull requests and 3 issues in this complex. Each diff was read against this issue; Assessment working set: 287 kB of PR diffs, 40 kB of issue/PR text, 13 kB of discussion (22 comments), 18 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
…uperation depuis state.db Une session recuperee depuis state.db etait reconstruite avec updated_at=now et n'etait JAMAIS evaluee contre la politique de reset. Consequence: une conversation vieille de plusieurs jours etait ressuscitee intacte et son horloge d'inactivite reamorcee, annulant silencieusement session_reset. Observe en prod: 232 lignes ended_at IS NULL, dont une session courriel de 13 jours / 2,4M tokens. La politique elle-meme et le watcher d'expiration fonctionnent; c'est le chemin de recuperation DB qui les contournait. - _policy_expired(): extrait de _is_session_expired pour que le watcher et la barriere partagent une seule implementation et ne divergent pas. - _recovered_row_last_activity(): vraie derniere activite d'une ligne (dernier message -> ended_at -> started_at). ended_at est NULL precisement sur les lignes qui fuient, d'ou le recours au timestamp du message. - _recovered_row_is_expired(): la barriere, appelee AVANT reopen_session sur les DEUX chemins (_recover_session_from_db et _query_recoverable_session). Gater apres le reopen aurait re-orpheline la ligne a chaque refus. Echoue en mode ouvert: une erreur ici recupere comme avant plutot que de creer une session neuve a chaque tour. - _create_entry_from_recovered_row(): updated_at = vraie derniere activite au lieu de now, ce qui reamorcait l'horloge idle et repoussait indefiniment la branche quotidienne (at_hour). - SessionDB.get_last_message_timestamp() (hermes_state.py). N'affecte pas la reprise apres redemarrage: mark_resume_pending recupere quelques secondes/minutes apres l'interruption, tres en deca de idle_minutes (1440 par defaut). Ne pas resserrer ce seuil. Le cron horaire hermes-session-cleanup reste necessaire: il traite l'autre moitie (lignes orphelinees quand le gateway meurt en vol), que ce correctif ne couvre pas. Reference amont: PR NousResearch#66264 (non mergee) — a reconcilier plus tard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What does this PR do?
get_or_create_session()only evaluates_should_reset()when the session key is already present in the in-memorySessionStore._entriesmap. When a session must be recovered fromstate.dbinstead — which happens on every routing lookup after a gateway restart, since_entriesstarts empty — the reset-policy check is skipped entirely:_get_or_create_session_impl()'s Phase 1b only computes_reset_reasoninsideif _entry_for_checks is not None and _stale_session_id is not None:— both areNoneon the recovery path._create_entry_from_recovered_row()additionally stampsupdated_at=now(the recovery moment) rather than the row's real last activity, which re-arms the idle clock from zero even if the check were reached later.Net effect: with
session_reset: {mode: idle/daily/both, ...}configured, a session that should have expired hours or days ago is silently resumed after a restart and can stay alive indefinitely, carrying its full history — including stale skill content injected earlier in the conversation that no longer reflects the on-diskSKILL.md.Related Issue
Fixes #66255
Type of Change
Changes Made
hermes_state.py: addHermesStateDB.get_last_message_timestamp(session_id)—MAX(timestamp)overmessagesfor a session.gateway/session.py:_create_entry_from_recovered_row()now derivesupdated_atfrom the recovered row's real last message timestamp (falling back tocreated_atwhen there are no messages), instead of unconditionally using the recovery moment._query_recoverable_session()now evaluates_should_reset()against the recovered entry before publishing it. If the row is past its reset deadline, it's ended instate.db(end_reason='session_reset') and the function returns(None, reset_reason, had_activity)so the caller falls through to the existing fresh-session-creation path — reusing the samewas_auto_reset/reset_had_activityplumbing a live in-memory expiry already produces, so the user gets the same "your session was reset" notice either way.tests/gateway/test_session_recovery_reset_policy.py(new): 4 regression tests — idle-expired recovered row is ended not resumed, a recently-active recovered row is still resumed,updated_atreflects the last message timestamp rather than the recovery moment, andmode="none"(the default) never rejects a recovered row.mode="none"— the default reset policy — is unaffected:_should_reset()is a no-op for that mode, so recovery behavior only changes for deployments that explicitly configuresession_reset.How to Test
Manual repro of the original bug (matches the issue):
What platforms you tested on
macOS (Darwin 25.5.0, arm64). No platform-specific code paths touched (pure SQLite + in-memory session-store logic).
Checklist
fix(gateway): ...)scripts/run_tests.sh— 572/572 relevant tests passruff checkclean on changed files