Skip to content

fix(gateway): honor session_reset policy when recovering sessions - #78618

Closed
hillimited wants to merge 1 commit into
NousResearch:mainfrom
hillimited:fix/session-reset-survives-recovery
Closed

hillimited wants to merge 1 commit into
NousResearch:mainfrom
hillimited:fix/session-reset-survives-recovery

Conversation

@hillimited

Copy link
Copy Markdown
Contributor

The bug

The opt-in session_reset policy (idle/daily) is silently dead across any gateway
restart, because both session recovery paths resurrect sessions as freshly active:

  • SessionStore._create_entry_from_recovered_row stamps the rebuilt routing entry
    with updated_at=now (and falls back to created_at=now when the durable
    started_at is missing or invalid).
  • Neither recovery path consults _should_reset:
    • the startup path (_prune_stale_sessions_locked
      _recover_session_from_db) repoints stale sessions.json entries to the
      recovered row and reopens it unconditionally;
    • the lazy in-message path (get_or_create_session phase 3 →
      _query_recoverable_session) reopens and publishes the recovered entry
      unconditionally.

Because _should_reset measures idleness against entry.updated_at, a recovered
session always looks zero-seconds idle. And since every subsequent message bumps
updated_at again on the healthy path, a session recovered stale can then never
age out — the miss is self-perpetuating, not just delayed by one restart.

The existing runtime stale-guard fix (see
test_stale_agent_close_overdue_policy_creates_fresh_session) only covers the case
where a stale in-memory entry with a real updated_at exists. The pure recovery
paths — startup repoint, and a lost mapping rebuilt from state.db — still bypassed
the policy entirely.

The fix

  1. SessionDB.get_last_activity(session_id) -> Optional[float] (new, placed
    after get_session): SELECT MAX(timestamp) FROM messages WHERE session_id = ?
    through the standard _read_ctx() read path.
  2. _create_entry_from_recovered_row derives updated_at from that durable
    last message timestamp, falling back to created_at. An invalid or missing
    started_at now maps to epoch 0 instead of now — an invalid durable timestamp
    must look old, never freshly active. reset_had_activity is set from the durable
    transcript so the channel-continuity hint stays accurate for recovered resets.
    The lookup is getattr-guarded, so an older/mocked SessionDB without the new
    method degrades to the created_at fallback.
  3. Startup path (_recover_session_from_db): build the entry first, then
    evaluate _should_reset(entry, source). On a reset reason the row is durably
    promoted to a reset boundary (promote_to_session_reset, falling back to
    end_session) and None is returned, so the pruner drops the stale mapping
    instead of repointing it. Otherwise the row is reopened and the entry returned
    exactly as before (including the migrated-legacy peer rewrite).
  4. _query_recoverable_session no longer reopens the row — it returns the
    candidate un-reopened so the caller decides reset vs resume. (The
    migrated-legacy peer rewrite stays here; it is key-mapping bookkeeping, already
    performed today even when the publish race is lost.)
  5. Phase 3 of get_or_create_session evaluates _should_reset(recovered, source). On a reason it feeds the existing auto-reset locals
    (was_auto_reset=True, auto_reset_reason,
    reset_had_activity=recovered.reset_had_activity,
    db_end_session_id=recovered.session_id, prev_session_id), so the ordinary
    create+promote tail runs — reset notice, continuity hint, and durable promotion
    all behave exactly like an in-memory expiry. Otherwise it reopens and publishes
    the recovered entry as before.

Why this is behavior-preserving by default

Upstream's default session_reset mode is "none"_should_reset returns
None there, so recovery still resumes every recoverable row with the same
session_id, the same reopen call, and the same peer bookkeeping. Only users who
opted into idle/daily resets see a change: the policy they configured now actually
applies across restarts. The recovery query stays lock-free on the message path
(TestRecoverOutsideLock passes unmodified), and evaluating _should_reset inside
the startup pruner matches the existing lock discipline
(prune_old_entries already calls _has_active_processes_safe with the lock held).

All pre-existing session recovery suites pass unmodified.

One user-visible nuance worth stating: a session reset at startup recovery
returns None and the mapping is pruned, so the next inbound message creates a
fresh session without the was_auto_reset notice (there is no message context at
startup). The message-path reset keeps the full notice/continuity behavior.

New tests

  • tests/gateway/test_session_store_runtime_stale_guard.py
    TestRecoveredSessionResetPolicy:
    • recovered entry carries durable last-activity updated_at/created_at and
      reset_had_activity;
    • lost mapping + overdue recoverable row under an idle policy → fresh session,
      auto-reset metadata, promote_to_session_reset(sid, "idle"), no reopen;
    • default mode="none" recovery resumes unchanged (reopen called, no
      promote/end, same session_id);
    • SessionDB without get_last_activity falls back to created_at.
  • tests/gateway/test_session_store_stale_prune.py
    TestStartupRecoveryResetPolicy: overdue recovered session at startup is
    promoted to reset and the mapping pruned; mode="none" startup repoint
    unchanged and now carries the durable updated_at.
  • tests/test_hermes_state.pytest_get_last_activity (empty/missing session,
    max-timestamp semantics against the real schema).

Test plan

python -m pytest tests/gateway/test_session.py \
  tests/gateway/test_session_store_lock_io.py \
  tests/gateway/test_session_store_stale_prune.py \
  tests/gateway/test_session_store_runtime_stale_guard.py \
  tests/gateway/test_multiplex_phase0.py \
  tests/test_hermes_state.py -q

Results on this branch (Python 3.12.13, venv with .[dev,messaging] extras):
271 passed — that is all pre-existing tests in those suites unmodified plus the
9 new ones. Wider sweep (test_session_store_prune.py,
test_channel_continuity_hint.py, test_10710/48031/35809/73297,
test_clean_shutdown_marker.py, test_session_load_bool.py,
test_async_session_store.py, test_fresh_reset_skill_injection.py): 34 passed,
1 failed — test_session_store_default_db_uses_runtime_hermes_home — which fails
identically on the unpatched base commit when run in that same batch (passes in
isolation on both): a pre-existing test-order isolation issue upstream, unrelated
to this change.

Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- SessionDB.get_last_activity(session_id) returns the last stored
  message timestamp, and _create_entry_from_recovered_row derives
  updated_at from it (falling back to created_at). An invalid or missing
  started_at now maps to epoch 0 instead of now — an invalid durable
  timestamp must look old, never freshly active. reset_had_activity is
  set from the durable transcript so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts. Recovery stays lock-free on
the message path (TestRecoverOutsideLock), and the pre-existing session
recovery suites pass unmodified.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery area/config Config system, migrations, profiles sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 4, 2026
teknium1 pushed a commit that referenced this pull request Aug 9, 2026
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f7)
teknium1 pushed a commit that referenced this pull request Aug 9, 2026
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f7)
@teknium1

teknium1 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Merged via #82743 (rebase-merge — your commit with your authorship preserved). Thank you @hillimited — recovered sessions now keep their real timestamps instead of updated_at=now, and both recovery paths consult reset policy before reopening. One simplification during salvage: your get_last_activity DB method became unnecessary because #82633's rewritten finder already returns last_activity_at on the row — the policy check now reads it directly. Clean diagnosis and tests; both carried over.

@teknium1 teknium1 closed this Aug 9, 2026
ma1138569845 pushed a commit to ma1138569845/dechnicAuditor-agent that referenced this pull request Aug 10, 2026
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-NousResearch#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from NousResearch#78618 and adapted to the NousResearch#82633 finder.
(cherry picked from commit 31c71f7)
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-NousResearch#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from NousResearch#78618 and adapted to the NousResearch#82633 finder.
(cherry picked from commit 31c71f7)