{"repo":"NousResearch/hermes-agent","public_id":"61220","display_label":"Gateway stale session routing and expiry finalization bugs","summary":"These issues and fixes center on stale sessions.json entries causing silent message loss, and on expired or reset sessions being incorrectly resurrected during recovery due to missing or incorrect end_reason, ended_at, and reset boundary handling.","subsystem":"gateway","counts":{"members":8,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65783,"merged_at":"2026-07-17T11:51:39+00:00"},"resolution":{"state":"partially_resolved","by":"pr-65783"},"freshness":{"state":"verified","last_verified_at":"2026-07-17T11:51:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285715},"fix_edges":[{"issue_number":28746,"pr_number":65783,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-17T11:51:39+00:00"},{"issue_number":61220,"pr_number":65783,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-17T11:51:39+00:00"}],"members":[{"kind":"issue","number":61220,"title":"Bug: Session expiry finalization doesn't set end_reason='session_reset' — stale recovery reopens expired sessions with full history (#54878)","url":null,"state":"closed"},{"kind":"issue","number":67781,"title":"Bug: Daily 4AM session_reset resurrected by stale-route recovery — session ran 23h, ~2 cost from context that should have been cleared","url":null,"state":"open"},{"kind":"pr","number":61242,"title":"fix(gateway): set end_reason='session_reset' when finalizing expired sessions","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61489,"title":"fix(session): write end_reason='session_reset' to state.db on expiry finalization (#61220)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#61242","number":61242}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":62672,"title":"fix(gateway): set end_reason='session_reset' on expiry finalization to prevent recovery loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61242","number":61242}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63068,"title":"fix(gateway): end finalized expired sessions as reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61242","number":61242}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":65783,"title":"fix(gateway): unify session reset boundaries vs recovery — stop resurrecting reset sessions","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["docs/session-lifecycle.md","gateway/platforms/api_server.py","gateway/platforms/base.py","gateway/platforms/webhook.py","gateway/run.py","gateway/session.py","hermes_state.py","scripts/release.py","tests/gateway/test_clean_shutdown_marker.py","tests/gateway/test_restart_resume_pending.py","tests/gateway/test_session_reset_notify.py","tests/gateway/test_session_store_expiry_finalized.py","tests/gateway/test_session_store_lock_io.py","tests/gateway/test_session_store_runtime_stale_guard.py","tests/gateway/test_session_store_stale_prune.py"],"fix_hunks":[{"file":"docs/session-lifecycle.md","hunks":["@@ -548,7 +548,13 @@ The `_session_expiry_watcher` task runs in the gateway event loop every 300 seco\n    - Clean up cached AIAgent resources (close tool resources, shut down memory provider).\n    - Evict the cached agent entry.\n    - Clear per-session overrides (`_session_model_overrides`, reasoning overrides, etc.).\n-   - Mark `expiry_finalized=True` and persist.\n+   - Mark `expiry_finalized=True` and persist (sessions.json + state.db).\n+   - Promote the state.db session row to `end_reason='session_reset'` via\n+     `promote_to_session_reset()` — conditional: only live rows or rows ended with a\n+     recoverable accidental reason (`agent_close`, `ws_orphan_reap`) are promoted, so\n+     explicit boundaries (`compression`, `session_switch`, …) are never overwritten. This\n+     durably records the reset so stale-route recovery cannot resurrect the expired\n+     session with its full history (#61220, #61993, #63539).\n \n 2. **Sweep idle cached agents** — Calls `_sweep_idle_cached_agents()` to evict agents that\n    have been idle beyond `_AGENT_CACHE_IDLE_TTL_SECS` (3600s / 1h), regardless of session"]},{"file":"gateway/platforms/api_server.py","hunks":["@@ -932,6 +932,11 @@ class APIServerAdapter(BasePlatformAdapter):\n     # ``async_delivery_supported()``.\n     supports_async_delivery: bool = False\n \n+    # Same statelessness applies to the startup auto-resume prompt: no client\n+    # is waiting to answer \"session restored — what next?\", so a resumed turn\n+    # should complete the interrupted work rather than acknowledge (#57056).\n+    interactive_resume: bool = False\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.API_SERVER)\n         extra = config.extra or {}"]},{"file":"gateway/platforms/base.py","hunks":["@@ -2365,6 +2365,19 @@ class BasePlatformAdapter(ABC):\n     # generic seam; Slack is merely the first consumer).\n     supports_inchannel_continuable: bool = False\n \n+    # Whether a human is interactively present on this platform to answer a\n+    # \"session restored — what next?\" prompt.  The startup auto-resume turn\n+    # (``_schedule_resume_pending_sessions`` → the ``_is_resume_pending``\n+    # branch in ``_handle_message_with_agent``) reads this to pick its\n+    # guidance: interactive platforms (Telegram, Slack, Discord DMs, …) get\n+    # \"report the restore and ask what the user wants next\"; non-interactive\n+    # event platforms (webhook) get \"finish the interrupted work\" because\n+    # nobody is there to answer, and an acknowledgement would silently\n+    # abandon the task (#57056).  Read generically via ``getattr(adapter,\n+    # \"interactive_resume\", True)`` — no per-platform branching at the call\n+    # site.\n+    interactive_resume: bool = True\n+\n     # Back-reference to the running ``GatewayRunner``, injected by\n     # ``gateway/run.py`` after the adapter is created. Adapters consume it via\n     # ``getattr(self, \"gateway_runner\", None)`` for cross-platform delivery and"]},{"file":"gateway/platforms/webhook.py","hunks":["@@ -147,6 +147,12 @@ def check_webhook_requirements() -> bool:\n class WebhookAdapter(BasePlatformAdapter):\n     \"\"\"Generic webhook receiver that triggers agent runs from HTTP POSTs.\"\"\"\n \n+    # No human is present to answer a \"session restored — what next?\" prompt:\n+    # webhook runs are event-triggered.  The startup auto-resume turn must\n+    # instruct the model to FINISH the interrupted work instead of emitting an\n+    # interactive acknowledgement that abandons the task (#57056).\n+    interactive_resume: bool = False\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.WEBHOOK)\n         # ``host`` may be None (dual-stack default) or a user-pinned string."]},{"file":"gateway/run.py","hunks":["@@ -712,6 +712,75 @@ def _is_fresh_gateway_interruption(\n     return current - timestamp <= window\n \n \n+def build_resume_recovery_note(\n+    reason: Optional[str],\n+    message: str = \"\",\n+    *,\n+    interactive: bool = True,\n+) -> str:\n+    \"\"\"Build the resume-pending recovery system note for an interrupted turn.\n+\n+    ``reason`` is the session's ``resume_reason`` (``restart_timeout``,\n+    ``shutdown_timeout``, or anything else → generic interruption phrasing).\n+    ``message`` is the user's NEW message text; empty means this is the\n+    startup auto-resume turn synthesized by\n+    ``_schedule_resume_pending_sessions`` with no human message attached.\n+\n+    ``interactive`` selects the empty-message guidance: on interactive\n+    platforms a human is present, so \"report the restore and ask what next\"\n+    is right.  On non-interactive event platforms (webhook, API server —\n+    adapters with ``interactive_resume = False``) nobody can answer; the\n+    resumed turn must instead complete the interrupted work, or the task is\n+    silently abandoned behind a \"restored\" acknowledgement that goes\n+    nowhere (#57056).\n+    \"\"\"\n+    reason_phrase = (\n+        \"a gateway restart\"\n+        if reason == \"restart_timeout\"\n+        else \"a gateway shutdown\"\n+        if reason == \"shutdown_timeout\"\n+        else \"a gateway interruption\"\n+    )\n+    if message:\n+        resume_guidance = (\n+            \"Address the user's NEW message below FIRST and focus \"\n+            \"on what the user is asking now.\"\n+        )\n+        tail_guidance = (\n+            \"Do NOT re-execute old tool calls — skip any \"\n+            \"unfinished work from the conversation history.\"\n+        )\n+    elif interactive:\n+        resume_guidance = (\n+            \"Report to the user that the session was restored \"\n+            \"successfully and ask what they would like to do next.\"\n+        )\n+        tail_guidance = (\n+            \"Do NOT re-execute old tool calls — skip any \"\n+            \"unfinished work from the conversation history.\"\n+        )\n+    else:\n+        resume_guidance = (\n+            \"No user is present on this non-interactive platform, \"\n+            \"so do NOT emit a 'session restored' acknowledgement \"\n+            \"or ask questions. Review the conversation history and \"\n+            \"CONTINUE the interrupted task to completion.\"\n+        )\n+        tail_guidance = (\n+            \"Do NOT re-run tool calls whose results already \"\n+            \"appear in the history — resume from the first step \"\n+            \"that has no recorded result.\"\n+        )\n+    return (\n+        f\"[System note: The previous turn was interrupted by \"\n+        f\"{reason_phrase}; the gateway is now back online. \"\n+        f\"Any restart/shutdown command in the history has already \"\n+        f\"run — do NOT re-execute or verify it. {resume_guidance} \"\n+        f\"{tail_guidance}]\"\n+        + (f\"\\n\\n{message}\" if message else \"\")\n+    )\n+\n+\n # Assistant-message fields that must survive transcript replay so multi-turn\n # reasoning context, prefix-cache hits, and provider-specific echo\n # requirements all behave the same on the gateway as they do in the CLI.","@@ -6186,7 +6255,7 @@ async def _cleanup_agent_resources_off_loop(\n         \"\"\"\n         if agent is None:\n             return\n-        if context.startswith(\"shutdown\"):\n+        if context.startswith(\"shutdown\") or context == \"session expiry\":\n             try:\n                 agent._end_session_on_close = False\n             except Exception:","@@ -11317,6 +11386,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 context_note = \"[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]\"\n             elif reset_reason == \"daily\":\n                 context_note = \"[System note: The user's session was automatically reset by the daily schedule. This is a fresh conversation with no prior context.]\"\n+            elif reset_reason == \"resume_pending_expired\":\n+                context_note = \"[System note: The previous gateway session could not be recovered after a restart (API recovery timed out). This is a fresh conversation — use /resume to restore history if needed.]\"\n             else:\n                 context_note = \"[System note: The user's previous session expired due to inactivity. This is a fresh conversation with no prior context.]\"\n             context_prompt = context_note + \"\\n\\n\" + context_prompt","@@ -11332,9 +11403,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 )\n                 platform_name = source.platform.value if source.platform else \"\"\n                 had_activity = getattr(session_entry, 'reset_had_activity', False)\n-                # Suspended sessions always notify (they were explicitly stopped\n-                # or crashed mid-operation) — skip the policy check.\n-                should_notify = reset_reason == \"suspended\" or (\n+                # Suspended and restart-recovery-expired sessions always notify\n+                # regardless of policy.notify — the user had an active session\n+                # that was silently replaced, so they need to know they can\n+                # /resume it.  Idle/daily resets respect the policy flag.\n+                should_notify = reset_reason in {\"suspended\", \"resume_pending_expired\"} or (\n                     policy.notify\n                     and had_activity\n                     and platform_name not in policy.notify_exclude_platforms","@@ -11344,6 +11417,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     if adapter:\n                         if reset_reason == \"suspended\":\n                             reason_text = \"previous session was stopped or interrupted\"\n+                        elif reset_reason == \"resume_pending_expired\":\n+                            reason_text = \"gateway restart recovery timed out\"\n                         elif reset_reason == \"daily\":\n                             reason_text = f\"daily schedule at {policy.at_hour}:00\"\n                         else:","@@ -19514,36 +19589,21 @@ def _approval_notify_sync(approval_data: dict) -> None:\n \n             if _is_resume_pending:\n                 _reason = getattr(_resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n-                _reason_phrase = (\n-                    \"a gateway restart\"\n-                    if _reason == \"restart_timeout\"\n-                    else \"a gateway shutdown\"\n-                    if _reason == \"shutdown_timeout\"\n-                    else \"a gateway interruption\"\n-                )\n                 _persist_user_message_override = message\n                 # The empty-message case is the auto-resume startup turn\n                 # synthesized by _schedule_resume_pending_sessions — there is\n-                # no NEW user message to address, so tell the model to report\n-                # recovery instead of the (nonexistent) \"new message\".\n-                if message:\n-                    _resume_guidance = (\n-                        \"Address the user's NEW message below FIRST and focus \"\n-                        \"on what the user is asking now.\"\n-                    )\n-                else:\n-                    _resume_guidance = (\n-                        \"Report to the user that the session was restored \"\n-                        \"successfully and ask what they would like to do next.\"\n-                    )\n-                message = (\n-                    f\"[System note: The previous turn was interrupted by \"\n-                    f\"{_reason_phrase}; the gateway is now back online. \"\n-                    f\"Any restart/shutdown command in the history has already \"\n-                    f\"run — do NOT re-execute or verify it. {_resume_guidance} \"\n-                    f\"Do NOT re-execute old tool calls — skip any unfinished \"\n-                    f\"work from the conversation history.]\"\n-                    + (f\"\\n\\n{message}\" if message else \"\")\n+                # no NEW user message to address.  Guidance is adapter-aware:\n+                # interactive platforms report the restore and ask what next;\n+                # non-interactive event platforms (webhook, API server)\n+                # continue the interrupted work instead, because nobody is\n+                # present to answer and an acknowledgement would silently\n+                # abandon the task (#57056).\n+                _resume_adapter = self._adapter_for_source(source)\n+                _interactive_resume = bool(\n+                    getattr(_resume_adapter, \"interactive_resume\", True)\n+                )\n+                message = build_resume_recovery_note(\n+                    _reason, message, interactive=_interactive_resume,\n                 )\n             elif _has_fresh_tool_tail:\n                 _persist_user_message_override = message","@@ -19584,22 +19644,13 @@ def _approval_notify_sync(approval_data: dict) -> None:\n                 _sn_reason = (\n                     getattr(_resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n                 )\n-                _sn_reason_phrase = (\n-                    \"a gateway restart\"\n-                    if _sn_reason == \"restart_timeout\"\n-                    else \"a gateway shutdown\"\n-                    if _sn_reason == \"shutdown_timeout\"\n-                    else \"a gateway interruption\"\n-                )\n-                message = (\n-                    f\"[System note: The previous turn was interrupted by \"\n-                    f\"{_sn_reason_phrase}; the gateway is now back online. \"\n-                    f\"Any restart/shutdown command in the history has already \"\n-                    f\"run — do NOT re-execute or verify it. Report to the user \"\n-                    f\"that the session was restored successfully and ask what \"\n-                    f\"they would like to do next. Do NOT re-execute old tool \"\n-                    f\"calls — skip any unfinished work from the conversation \"\n-                    f\"history.]\"\n+                _sn_adapter = self._adapter_for_source(source)\n+                message = build_resume_recovery_note(\n+                    _sn_reason,\n+                    \"\",\n+                    interactive=bool(\n+                        getattr(_sn_adapter, \"interactive_resume\", True)\n+                    ),\n                 )\n \n             _approval_session_key = session_key or \"\""]},{"file":"gateway/session.py","hunks":["@@ -1043,6 +1043,21 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig,\n             self._db = SessionDB()\n         except Exception as e:\n             print(f\"[gateway] Warning: SQLite session store unavailable, falling back to JSONL: {e}\")\n+\n+    def _has_active_processes_safe(self, session_key: str, *, context: str) -> bool:\n+        \"\"\"Return whether a session has active work, failing closed on registry errors.\"\"\"\n+        if self._has_active_processes_fn is None:\n+            return False\n+        try:\n+            return bool(self._has_active_processes_fn(session_key))\n+        except Exception as exc:\n+            logger.warning(\n+                \"has_active_processes_fn raised during %s for %s; keeping session alive: %s\",\n+                context,\n+                session_key,\n+                exc,\n+            )\n+            return True\n     \n     def _ensure_loaded(self) -> None:\n         \"\"\"Load sessions index from disk if not already loaded.\"\"\"","@@ -1178,12 +1193,14 @@ def _prune_stale_sessions_locked(self) -> None:\n                 # end_reason not None -> session ended — prune\n                 if row is not None and row.get(\"end_reason\") is not None:\n                     recovered_entry = None\n+                    recovery_lookup_failed = False\n                     if entry.origin is not None:\n                         try:\n                             recovered_entry = self._recover_session_from_db(\n                                 session_key=key,\n                                 source=entry.origin,\n                                 now=_now(),\n+                                raise_on_lookup_error=True,\n                             )\n                         except Exception as exc:\n                             logger.debug(","@@ -1193,6 +1210,10 @@ def _prune_stale_sessions_locked(self) -> None:\n                                 entry.session_id,\n                                 exc,\n                             )\n+                            recovery_lookup_failed = True\n+\n+                    if recovery_lookup_failed:\n+                        continue\n \n                     # If the stale entry points at a compression-ended parent but\n                     # a newer live child session exists for the exact same gateway","@@ -1416,6 +1437,7 @@ def _recover_session_from_db(\n         session_key: str,\n         source: SessionSource,\n         now: datetime,\n+        raise_on_lookup_error: bool = False,\n     ) -> Optional[SessionEntry]:\n         \"\"\"Rebuild a missing session-key mapping from durable state.db data.\"\"\"\n         if not self._db:","@@ -1434,6 +1456,8 @@ def _recover_session_from_db(\n             )\n         except Exception as exc:\n             logger.debug(\"Gateway session DB recovery failed for %s: %s\", session_key, exc)\n+            if raise_on_lookup_error:\n+                raise\n             return None\n         if not recovered:\n             return None","@@ -1582,6 +1606,22 @@ def set_expiry_finalized(\n                         \"Session DB expiry_finalized write failed for %s: %s\",\n                         entry.session_id, exc,\n                     )\n+            try:\n+                # Expiry finalization is a real conversation boundary. Without\n+                # a durable ``session_reset`` end_reason, later agent cleanup can\n+                # close the row as ``agent_close``; stale-route recovery treats\n+                # that as resumable and resurrects the expired full history.\n+                #\n+                # promote_to_session_reset is conditional: it only promotes\n+                # live rows or rows ended with ``agent_close``.  Explicit\n+                # boundaries (compression, session_reset, new_command, etc.)\n+                # are preserved — the first writer wins.\n+                self._db.promote_to_session_reset(entry.session_id)\n+            except Exception as exc:\n+                logger.debug(\n+                    \"Session DB promote_to_session_reset failed for %s: %s\",\n+                    entry.session_id, exc,\n+                )\n     \n     def _is_session_expired(self, entry: SessionEntry) -> bool:\n         \"\"\"Check if a session has expired based on its reset policy.","@@ -1590,13 +1630,12 @@ def _is_session_expired(self, entry: SessionEntry) -> bool:\n         Used by the background expiry watcher to proactively flush memories.\n         Sessions with active background processes are never considered expired.\n         \"\"\"\n-        if self._has_active_processes_fn:\n-            if self._has_active_processes_fn(entry.session_key):\n-                logger.debug(\n-                    \"Session %s not expired — active background processes\",\n-                    entry.session_key,\n-                )\n-                return False\n+        if self._has_active_processes_safe(entry.session_key, context=\"expiry\"):\n+            logger.debug(\n+                \"Session %s not expired — active background processes\",\n+                entry.session_key,\n+            )\n+            return False\n \n         policy = self.config.get_reset_policy(\n             platform=entry.platform,","@@ -1691,14 +1730,13 @@ def _should_reset(self, entry: SessionEntry, source: SessionSource) -> Optional[\n         \n         Sessions with active background processes are never reset.\n         \"\"\"\n-        if self._has_active_processes_fn:\n-            session_key = self._generate_session_key(source)\n-            if self._has_active_processes_fn(session_key):\n-                logger.debug(\n-                    \"Session reset skipped for %s — active background processes\",\n-                    session_key,\n-                )\n-                return None\n+        session_key = self._generate_session_key(source)\n+        if self._has_active_processes_safe(session_key, context=\"reset\"):\n+            logger.debug(\n+                \"Session reset skipped for %s — active background processes\",\n+                session_key,\n+            )\n+            return None\n \n         policy = self.config.get_reset_policy(\n             platform=source.platform,","@@ -1895,13 +1933,24 @@ def _get_or_create_session_impl(\n             elif _entry_for_checks.resume_pending:\n                 _reset_reason = self._should_reset(_entry_for_checks, source)\n                 if not _reset_reason:\n-                    _fw = auto_continue_freshness_window()\n-                    _ref_time = (\n-                        _entry_for_checks.last_resume_marked_at\n-                        or _entry_for_checks.updated_at\n+                    # Freshness-gate stale resume_pending zombies (#46934) —\n+                    # but honor an explicit ``session_reset.mode: none``: the\n+                    # user opted out of ALL automatic resets, so an expired\n+                    # resume marker must fall through to a normal resume of\n+                    # the preserved transcript, never a silent fresh session\n+                    # (#61052).\n+                    _policy = self.config.get_reset_policy(\n+                        platform=source.platform,\n+                        session_type=source.chat_type,\n                     )\n-                    if _fw > 0 and (now - _ref_time).total_seconds() > _fw:\n-                        _reset_reason = \"resume_pending_expired\"\n+                    if _policy.mode != \"none\":\n+                        _fw = auto_continue_freshness_window()\n+                        _ref_time = (\n+                            _entry_for_checks.last_resume_marked_at\n+                            or _entry_for_checks.updated_at\n+                        )\n+                        if _fw > 0 and (now - _ref_time).total_seconds() > _fw:\n+                            _reset_reason = \"resume_pending_expired\"\n             else:\n                 _reset_reason = self._should_reset(_entry_for_checks, source)\n ","@@ -1938,6 +1987,14 @@ def _get_or_create_session_impl(\n                         session_key, entry.session_id,\n                     )\n                     self._entries.pop(session_key, None)\n+                    # If an expiry watcher (daily/idle reset) already finalized\n+                    # this session, honour the reset decision instead of silently\n+                    # reopening it via recovery.\n+                    if _reset_reason:\n+                        was_auto_reset = True\n+                        auto_reset_reason = _reset_reason\n+                        reset_had_activity = entry.last_prompt_tokens > 0\n+                        db_end_session_id = entry.session_id\n                     entry = None\n                     _needs_recover = True\n                 elif entry.session_id != _stale_session_id:","@@ -2023,8 +2080,21 @@ def _get_or_create_session_impl(\n \n         # SQLite operations outside the lock (unchanged).\n         if self._db and db_end_session_id:\n+            # Use the specific reset reason so state.db is auditable (e.g.\n+            # \"resume_pending_expired\" is distinguishable from a normal\n+            # \"session_reset\" caused by idle/daily expiry).\n+            _db_end_reason = auto_reset_reason if auto_reset_reason else \"session_reset\"\n             try:\n-                self._db.end_session(db_end_session_id, \"session_reset\")\n+                # promote_to_session_reset, not end_session: the row may\n+                # already be ended with a recoverable accidental reason\n+                # (agent_close / ws_orphan_reap), which first-reason-wins\n+                # end_session would preserve — leaving the reset session\n+                # resurrectable by stale-route recovery (#61220, #61993).\n+                _promote = getattr(self._db, \"promote_to_session_reset\", None)\n+                if callable(_promote):\n+                    _promote(db_end_session_id, _db_end_reason)\n+                else:\n+                    self._db.end_session(db_end_session_id, _db_end_reason)\n             except Exception as e:\n                 logger.debug(\"Session DB operation failed: %s\", e)\n ","@@ -2193,19 +2263,8 @@ def prune_old_entries(self, max_age_days: int) -> int:\n                 # The callback is keyed by session_key (see process_registry.\n                 # has_active_for_session); passing session_id here used to\n                 # never match, so active sessions got pruned anyway.\n-                if self._has_active_processes_fn is not None:\n-                    try:\n-                        if self._has_active_processes_fn(entry.session_key):\n-                            continue\n-                    except Exception as exc:\n-                        logger.debug(\n-                            \"has_active_processes_fn raised during prune for %s: %s\",\n-                            entry.session_key, exc,\n-                        )\n-                        # Fail safe: if we can't tell whether a background\n-                        # process is attached, keep the entry rather than\n-                        # risk orphaning live work.\n-                        continue\n+                if self._has_active_processes_safe(entry.session_key, context=\"prune\"):\n+                    continue\n                 if entry.updated_at < cutoff:\n                     removed_keys.append(key)\n             for key in removed_keys:","@@ -2301,7 +2360,15 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->\n \n         if self._db and db_end_session_id:\n             try:\n-                self._db.end_session(db_end_session_id, \"session_reset\")\n+                # Promote (not plain end_session): an accidental\n+                # agent_close/ws_orphan_reap end must not survive an explicit\n+                # user reset, or recovery resurrects the reset session\n+                # (#61993 — the user's /new was silently undone).\n+                _promote = getattr(self._db, \"promote_to_session_reset\", None)\n+                if callable(_promote):\n+                    _promote(db_end_session_id, \"session_reset\")\n+                else:\n+                    self._db.end_session(db_end_session_id, \"session_reset\")\n             except Exception as e:\n                 logger.debug(\"Session DB operation failed: %s\", e)\n ","@@ -2362,7 +2429,15 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S\n \n         if self._db and db_end_session_id:\n             try:\n-                self._db.end_session(db_end_session_id, \"session_switch\")\n+                # Promote (not plain end_session): a stale agent_close /\n+                # ws_orphan_reap end on the outgoing session must be upgraded\n+                # to the explicit switch boundary, or recovery can resurrect\n+                # it over the user's /resume choice (#61220 bug class).\n+                _promote = getattr(self._db, \"promote_to_session_reset\", None)\n+                if callable(_promote):\n+                    _promote(db_end_session_id, \"session_switch\")\n+                else:\n+                    self._db.end_session(db_end_session_id, \"session_switch\")\n             except Exception as e:\n                 logger.debug(\"Session DB end_session failed: %s\", e)\n "]},{"file":"hermes_state.py","hunks":["@@ -2286,6 +2286,55 @@ def _do(conn):\n             )\n         self._execute_write(_do)\n \n+    def promote_to_session_reset(\n+        self, session_id: str, reason: str = \"session_reset\"\n+    ) -> bool:\n+        \"\"\"Durably mark a session as ended by an intentional reset boundary.\n+\n+        Promotes *only* live rows (``ended_at IS NULL``) or rows carrying an\n+        accidental end_reason that the recovery query\n+        (``find_latest_gateway_session_for_peer``) treats as recoverable:\n+        ``agent_close`` (older gateway cleanup bug) and ``ws_orphan_reap``\n+        (mistaken TUI reaper).  Explicit conversation boundaries such as\n+        ``compression``, ``session_reset``, ``session_switch``, etc. are\n+        preserved — the first writer wins for those, and a later expiry\n+        finalization must not silently overwrite them.\n+\n+        Plain ``end_session()`` is NOT sufficient for reset boundaries: it\n+        no-ops on an already-ended row, so a row that agent cleanup already\n+        closed as ``agent_close`` would stay recoverable and stale-route\n+        recovery would resurrect the reset session with its full history\n+        (#61220, #61993, #63539).\n+\n+        Keep this promotion set in sync with the recoverable set in\n+        ``find_latest_gateway_session_for_peer`` — any reason recovery would\n+        reopen must be promotable here.\n+\n+        ``reason`` lets reset paths keep their auditable specific reasons\n+        (``idle``, ``daily``, ``suspended``, ``resume_pending_expired``).\n+\n+        Returns ``True`` when the row was promoted, ``False`` when skipped\n+        (already has a different explicit end_reason, or row not found).\n+        \"\"\"\n+        if not session_id:\n+            return False\n+        now = time.time()\n+\n+        def _do(conn):\n+            cursor = conn.execute(\n+                \"UPDATE sessions SET ended_at = ?, end_reason = ? \"\n+                \"WHERE id = ? AND (ended_at IS NULL \"\n+                \"OR end_reason IN ('agent_close', 'ws_orphan_reap'))\",\n+                (now, reason, session_id),\n+            )\n+            return cursor.rowcount\n+\n+        try:\n+            rows = self._execute_write(_do)\n+            return bool(rows)\n+        except Exception:\n+            return False\n+\n     def update_session_cwd(\n         self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None\n     ) -> None:"]},{"file":"scripts/release.py","hunks":["@@ -314,6 +314,8 @@\n     \"[redacted:email]\": \"Diyoncrz18\",\n     \"[redacted:email]\": \"ali-nld\",\n     \"[redacted:email]\": \"necoweb3\",\n+    \"[redacted:email]\": \"DNAlec\",  # PR #61743 salvage (honor reset policy in #54878 stale-heal recovery)\n+    \"[redacted:email]\": \"joelbrilliant\",  # PR #58486 salvage (session-expiry cleanup must not end row as agent_close)\n     \"[redacted:email]\": \"hydracoco7\",  # PR #61382 salvage (id-less cron job freeze)\n     \"AlexFucuson9@users.noreply.github.com\": \"AlexFucuson9\",  # PR #61209 salvage (hygiene compression data loss)\n     \"[redacted:email]\": \"adambiggs\",  # PR #43819 salvage (holographic shared SQLite connection)"]},{"file":"tests/gateway/test_clean_shutdown_marker.py","hunks":["@@ -245,6 +245,33 @@ async def _run():\n         assert agent._end_session_on_close is False\n         agent.close.assert_called_once()\n \n+    def test_session_expiry_cleanup_preserves_lazy_reset_boundary(self, tmp_path, monkeypatch):\n+        \"\"\"Session expiry cleanup must not turn an expired chat into an agent_close row.\n+\n+        The expiry watcher only tears down cached resources. The next inbound\n+        message owns the reset boundary, creating a fresh session with the\n+        normal auto-reset notice. If cleanup lets ``agent.close()`` end the\n+        SQLite row as ``agent_close``, stale-route recovery treats it as\n+        recoverable and resurrects the expired session instead.\n+        \"\"\"\n+        monkeypatch.setattr(\"gateway.run._hermes_home\", tmp_path)\n+        from gateway.run import GatewayRunner\n+\n+        runner = object.__new__(GatewayRunner)\n+        agent = MagicMock()\n+        agent._end_session_on_close = True\n+\n+        async def _run():\n+            await GatewayRunner._cleanup_agent_resources_off_loop(\n+                runner, agent, context=\"session expiry\"\n+            )\n+\n+        import asyncio\n+        asyncio.get_event_loop().run_until_complete(_run())\n+\n+        assert agent._end_session_on_close is False\n+        agent.close.assert_called_once()\n+\n \n # ---------------------------------------------------------------------------\n # resume_pending freshness gate (#46934)","@@ -285,7 +312,12 @@ def test_fresh_resume_pending_returns_same_session(self, tmp_path):\n \n     def test_stale_resume_pending_falls_through_to_reset(self, tmp_path, monkeypatch):\n         monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n-        store = _make_store(tmp_path)\n+        # The freshness gate only applies when the user has opted into\n+        # automatic resets — session_reset.mode: none disables it (#61052).\n+        from gateway.config import SessionResetPolicy\n+        store = _make_store(\n+            tmp_path, policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999)\n+        )\n         source = _make_source()\n         entry = self._mark_resume_pending(store, source)\n ","@@ -302,6 +334,25 @@ def test_stale_resume_pending_falls_through_to_reset(self, tmp_path, monkeypatch\n         assert fresh.session_id != entry.session_id\n         assert not fresh.resume_pending\n \n+    def test_reset_mode_none_disables_freshness_gate(self, tmp_path, monkeypatch):\n+        \"\"\"session_reset.mode: none opts out of ALL automatic resets —\n+        including the resume_pending freshness gate (#61052).\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        from gateway.config import SessionResetPolicy\n+        store = _make_store(tmp_path, policy=SessionResetPolicy(mode=\"none\"))\n+        source = _make_source()\n+        entry = self._mark_resume_pending(store, source)\n+\n+        with store._lock:\n+            entry.last_resume_marked_at = datetime.now() - timedelta(seconds=7200)\n+            entry.updated_at = datetime.now()\n+            store._save()\n+\n+        refreshed = store.get_or_create_session(source)\n+        # Explicit opt-out honored: same session back, transcript preserved.\n+        assert refreshed.session_id == entry.session_id\n+        assert refreshed.resume_pending\n+\n     def test_freshness_gate_disabled_returns_stale_session(self, tmp_path, monkeypatch):\n         # Opt-out: window <= 0 restores the pre-fix \"always fresh\" behaviour.\n         monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"0\")"]},{"file":"tests/gateway/test_restart_resume_pending.py","hunks":["@@ -41,6 +41,7 @@\n     _is_fresh_gateway_interruption,\n     _last_transcript_timestamp,\n     _should_clear_resume_pending_after_turn,\n+    build_resume_recovery_note,\n )\n from gateway.session import SessionEntry, SessionSource, SessionStore\n from tests.gateway.restart_test_helpers import (","@@ -152,32 +153,9 @@ def _simulate_note_injection(\n \n     if is_resume_pending:\n         reason = getattr(resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n-        reason_phrase = (\n-            \"a gateway restart\"\n-            if reason == \"restart_timeout\"\n-            else \"a gateway shutdown\"\n-            if reason == \"shutdown_timeout\"\n-            else \"a gateway interruption\"\n-        )\n-        if message:\n-            resume_guidance = (\n-                \"Address the user's NEW message below FIRST and focus \"\n-                \"on what the user is asking now.\"\n-            )\n-        else:\n-            resume_guidance = (\n-                \"Report to the user that the session was restored \"\n-                \"successfully and ask what they would like to do next.\"\n-            )\n-        message = (\n-            f\"[System note: The previous turn was interrupted by \"\n-            f\"{reason_phrase}; the gateway is now back online. \"\n-            f\"Any restart/shutdown command in the history has already \"\n-            f\"run — do NOT re-execute or verify it. {resume_guidance} \"\n-            f\"Do NOT re-execute old tool calls — skip any unfinished \"\n-            f\"work from the conversation history.]\"\n-            + (f\"\\n\\n{message}\" if message else \"\")\n-        )\n+        # Real production note builder — extracted to module scope in\n+        # gateway/run.py so tests exercise the actual strings.\n+        message = build_resume_recovery_note(reason, message)\n     elif has_fresh_tool_tail:\n         message = (\n             \"[System note: A new message has arrived. The conversation \"","@@ -196,23 +174,7 @@ def _simulate_note_injection(\n         and getattr(resume_entry, \"resume_pending\", False)\n     ):\n         sn_reason = getattr(resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n-        sn_reason_phrase = (\n-            \"a gateway restart\"\n-            if sn_reason == \"restart_timeout\"\n-            else \"a gateway shutdown\"\n-            if sn_reason == \"shutdown_timeout\"\n-            else \"a gateway interruption\"\n-        )\n-        message = (\n-            f\"[System note: The previous turn was interrupted by \"\n-            f\"{sn_reason_phrase}; the gateway is now back online. \"\n-            f\"Any restart/shutdown command in the history has already \"\n-            f\"run — do NOT re-execute or verify it. Report to the user \"\n-            f\"that the session was restored successfully and ask what \"\n-            f\"they would like to do next. Do NOT re-execute old tool \"\n-            f\"calls — skip any unfinished work from the conversation \"\n-            f\"history.]\"\n-        )\n+        message = build_resume_recovery_note(sn_reason, \"\")\n     return message\n \n ","@@ -521,6 +483,34 @@ def test_resume_pending_shutdown_note_mentions_shutdown(self):\n         )\n         assert \"gateway shutdown\" in result\n \n+    def test_empty_message_interactive_note_asks_what_next(self):\n+        \"\"\"Interactive platforms: the startup auto-resume turn reports the\n+        restore and asks the (present) human what to do next.\"\"\"\n+        note = build_resume_recovery_note(\"restart_timeout\", \"\", interactive=True)\n+        assert \"session was restored\" in note\n+        assert \"ask what they would like to do next\" in note\n+        assert \"skip any unfinished work\" in note\n+\n+    def test_empty_message_noninteractive_note_continues_task(self):\n+        \"\"\"Non-interactive platforms (webhook, API server): nobody can answer\n+        'what next?', so the resumed turn must complete the interrupted work\n+        instead of acknowledging (#57056).\"\"\"\n+        note = build_resume_recovery_note(\"restart_timeout\", \"\", interactive=False)\n+        assert \"CONTINUE the interrupted task\" in note\n+        assert \"session was restored\" not in note\n+        assert \"ask what they would like to do next\" not in note\n+        # Must not tell the model to skip the unfinished work it should finish.\n+        assert \"skip any unfinished work\" not in note\n+        # But still guards against re-running already-recorded tool calls.\n+        assert \"already appear in the history\" in note\n+\n+    def test_new_message_guidance_identical_regardless_of_interactivity(self):\n+        \"\"\"A real NEW user message always wins — same guidance either way.\"\"\"\n+        a = build_resume_recovery_note(\"restart_timeout\", \"do the thing\", interactive=True)\n+        b = build_resume_recovery_note(\"restart_timeout\", \"do the thing\", interactive=False)\n+        assert a == b\n+        assert \"NEW message\" in a\n+\n     def test_resume_pending_fires_without_tool_tail(self):\n         \"\"\"Key improvement over PR #9934: the restart-resume note fires\n         even when the transcript's last role is NOT ``tool``.\"\"\""]},{"file":"tests/gateway/test_session_reset_notify.py","hunks":["@@ -5,10 +5,11 @@\n - SessionEntry captures auto_reset_reason\n - SessionResetPolicy.notify controls whether notifications are sent\n - notify_exclude_platforms skips notifications for excluded platforms\n+- resume_pending_expired auto-reset sets the correct reason and DB end_reason\n \"\"\"\n \n from datetime import datetime, timedelta\n-\n+from unittest.mock import MagicMock, patch\n \n from gateway.config import (\n     GatewayConfig,","@@ -30,11 +31,15 @@ def _make_source(platform=Platform.TELEGRAM, chat_id=\"123\", user_id=\"u1\"):\n     )\n \n \n-def _make_store(policy=None, tmp_path=None):\n+def _make_store(policy=None, tmp_path=None, has_active_processes_fn=None):\n     config = GatewayConfig()\n     if policy:\n         config.default_reset_policy = policy\n-    store = SessionStore(sessions_dir=tmp_path or \"/tmp/test-sessions\", config=config)\n+    store = SessionStore(\n+        sessions_dir=tmp_path or \"/tmp/test-sessions\",\n+        config=config,\n+        has_active_processes_fn=has_active_processes_fn,\n+    )\n     return store\n \n ","@@ -100,6 +105,45 @@ def test_returns_none_when_mode_is_none(self, tmp_path):\n         source = _make_source()\n         assert store._should_reset(entry, source) is None\n \n+    def test_returns_none_when_active_process_check_raises(self, tmp_path):\n+        def _raise(_session_key):\n+            raise RuntimeError(\"process registry unavailable\")\n+\n+        store = _make_store(\n+            SessionResetPolicy(mode=\"idle\", idle_minutes=30),\n+            tmp_path,\n+            has_active_processes_fn=_raise,\n+        )\n+        entry = SessionEntry(\n+            session_key=\"test\",\n+            session_id=\"s1\",\n+            created_at=datetime.now() - timedelta(hours=2),\n+            updated_at=datetime.now() - timedelta(hours=1),\n+        )\n+        source = _make_source()\n+\n+        assert store._should_reset(entry, source) is None\n+\n+    def test_is_session_expired_fails_closed_when_active_process_check_raises(self, tmp_path):\n+        def _raise(_session_key):\n+            raise RuntimeError(\"process registry unavailable\")\n+\n+        store = _make_store(\n+            SessionResetPolicy(mode=\"idle\", idle_minutes=30),\n+            tmp_path,\n+            has_active_processes_fn=_raise,\n+        )\n+        entry = SessionEntry(\n+            session_key=\"test\",\n+            session_id=\"s1\",\n+            platform=Platform.TELEGRAM,\n+            chat_type=\"dm\",\n+            created_at=datetime.now() - timedelta(hours=2),\n+            updated_at=datetime.now() - timedelta(hours=1),\n+        )\n+\n+        assert store._is_session_expired(entry) is False\n+\n \n # ---------------------------------------------------------------------------\n # SessionEntry captures reason","@@ -278,3 +322,161 @@ def test_auto_reset_reason_none_roundtrip(self, tmp_path):\n         assert reloaded.was_auto_reset is False\n         assert reloaded.auto_reset_reason is None\n         assert reloaded.reset_had_activity is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# resume_pending_expired: auto_reset_reason and DB end_reason (#58933)\n+# ---------------------------------------------------------------------------\n+\n+def _make_db_mock() -> MagicMock:\n+    \"\"\"Return a SessionDB mock with safe defaults for all lookup methods.\"\"\"\n+    db = MagicMock()\n+    db.get_session.return_value = None\n+    db.get_compression_tip.return_value = None  # avoids MagicMock leaking into session_id\n+    db.find_latest_gateway_session_for_peer.return_value = None\n+    db.reopen_session.return_value = None\n+    db.create_session.return_value = None\n+    return db\n+\n+\n+def _make_store_with_db(tmp_path, db_mock=None, policy=None) -> SessionStore:\n+    \"\"\"Build a SessionStore with a mock SessionDB, bypassing disk load.\"\"\"\n+    cfg_policy = policy or SessionResetPolicy(mode=\"none\")\n+    config = GatewayConfig(default_reset_policy=cfg_policy)\n+    with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+        store = SessionStore(sessions_dir=tmp_path, config=config)\n+    store._db = db_mock if db_mock is not None else _make_db_mock()\n+    store._loaded = True\n+    return store\n+\n+\n+class TestResumePendingExpiredAutoReset:\n+    \"\"\"resume_pending sessions past the freshness window should fire\n+    was_auto_reset=True with auto_reset_reason='resume_pending_expired' and\n+    persist that reason to state.db (#58933).\"\"\"\n+\n+    def _seed_stale_resume_pending(self, store, source, freshness_seconds=3600):\n+        \"\"\"Create a session, mark it resume_pending, then backdate the mark\n+        past the freshness window so get_or_create_session treats it as a\n+        zombie.\"\"\"\n+        entry = store.get_or_create_session(source)\n+        store.mark_resume_pending(entry.session_key)\n+        with store._lock:\n+            entry = store._entries[entry.session_key]\n+            entry.last_resume_marked_at = (\n+                datetime.now() - timedelta(seconds=freshness_seconds + 60)\n+            )\n+            entry.updated_at = datetime.now()  # keep updated_at fresh\n+            store._save()\n+        return entry\n+\n+    def test_stale_resume_pending_sets_auto_reset_reason(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"Stale resume_pending triggers was_auto_reset=True with reason\n+        'resume_pending_expired', NOT 'idle'.\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        # The freshness gate requires an opted-in reset policy — mode \"none\"\n+        # disables it entirely (#61052). Use a huge idle window so only the\n+        # freshness gate (not the idle policy) can fire.\n+        store = _make_store_with_db(\n+            tmp_path,\n+            policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999),\n+        )\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source)\n+\n+        new = store.get_or_create_session(source)\n+\n+        assert new.session_id != old.session_id, \"should have created a new session\"\n+        assert new.was_auto_reset is True\n+        assert new.auto_reset_reason == \"resume_pending_expired\"\n+\n+    def test_stale_resume_pending_had_activity_flag(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"reset_had_activity reflects whether the old session was used.\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        store = _make_store_with_db(\n+            tmp_path,\n+            policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999),\n+        )\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source)\n+        # Simulate some conversation on the old session.\n+        with store._lock:\n+            old.last_prompt_tokens = [redacted:secret-assignment]\n+            store._save()\n+\n+        new = store.get_or_create_session(source)\n+        assert new.reset_had_activity is True\n+\n+    def test_stale_resume_pending_db_end_reason_is_specific(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"state.db must record end_reason='resume_pending_expired', NOT the\n+        generic 'session_reset', so the event is auditable (#58933 fix).\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        db = _make_db_mock()\n+        store = _make_store_with_db(\n+            tmp_path, db,\n+            policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999),\n+        )\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source)\n+        store.get_or_create_session(source)\n+\n+        # Auto-reset now writes through promote_to_session_reset so an\n+        # accidental agent_close end can't shadow the reset boundary.\n+        db.promote_to_session_reset.assert_called_once()\n+        ended_id, ended_reason = db.promote_to_session_reset.call_args.args\n+        assert ended_id == old.session_id\n+        assert ended_reason == \"resume_pending_expired\", (\n+            f\"expected 'resume_pending_expired', got {ended_reason!r} — \"\n+            \"the DB end_reason must not be the generic 'session_reset'\"\n+        )\n+\n+    def test_idle_reset_db_end_reason_reflects_idle(\n+        self, tmp_path\n+    ):\n+        \"\"\"Regular idle auto-reset persists 'idle' as end_reason so that all\n+        auto-reset paths are auditable (#58933 should not regress the common\n+        idle/daily path).\"\"\"\n+        db = _make_db_mock()\n+        store = _make_store_with_db(\n+            tmp_path, db, policy=SessionResetPolicy(mode=\"idle\", idle_minutes=1)\n+        )\n+        source = _make_source()\n+\n+        entry = store.get_or_create_session(source)\n+        # Age past idle threshold.\n+        with store._lock:\n+            entry.updated_at = datetime.now() - timedelta(minutes=5)\n+            store._save()\n+\n+        store.get_or_create_session(source)\n+\n+        db.promote_to_session_reset.assert_called_once()\n+        _, ended_reason = db.promote_to_session_reset.call_args.args\n+        assert ended_reason == \"idle\"\n+\n+    def test_freshness_disabled_skips_resume_pending_expired(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"When gateway_auto_continue_freshness=0, resume_pending is never\n+        expired — the same session is returned regardless of age.\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"0\")\n+        db = _make_db_mock()\n+        store = _make_store_with_db(tmp_path, db)\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source, freshness_seconds=999_999)\n+\n+        refreshed = store.get_or_create_session(source)\n+        # Freshness disabled → same session, no DB end_session call.\n+        assert refreshed.session_id == old.session_id\n+        db.end_session.assert_not_called()\n+        db.promote_to_session_reset.assert_not_called()"]},{"file":"tests/gateway/test_session_store_expiry_finalized.py","hunks":["@@ -0,0 +1,184 @@\n+\"\"\"Session expiry finalization closes sessions as session_reset.\n+\n+Regression coverage for #61220: the expiry watcher marks a session expired,\n+then agent cleanup can close it as ``agent_close``. Stale routing recovery treats\n+``agent_close`` as recoverable, so expired sessions were reopened with full\n+history unless expiry finalization also persisted the real conversation boundary\n+as ``end_reason='session_reset'``.\n+\n+These tests use a real ``SessionDB`` (in-memory) to verify the actual recovery\n+contract in ``find_latest_gateway_session_for_peer`` — not just call counts on\n+a MagicMock.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import time\n+from pathlib import Path\n+\n+import pytest\n+\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]\n+def db(tmp_path: Path) -> SessionDB:\n+    return SessionDB(tmp_path / \"state.db\")\n+\n+\n+_SESSION_KEY = \"agent:main:telegram:dm:8494508720\"\n+_SOURCE = \"telegram\"\n+_USER_ID = \"8494508720\"\n+\n+\n+# ------------------------------------------------------------------\n+# promote_to_session_reset — unit tests on real DB\n+# ------------------------------------------------------------------\n+\n+class TestPromoteToSessionReset:\n+    \"\"\"promote_to_session_reset promotes only safe rows.\"\"\"\n+\n+    def test_promotes_live_row(self, db: SessionDB) -> None:\n+        \"\"\"A live row (ended_at IS NULL) is promoted to session_reset.\"\"\"\n+        db.create_session(\n+            \"sid-live\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        # Seed a message so recovery would match\n+        db.append_message(\"sid-live\", \"user\", \"hello\")\n+\n+        assert db.promote_to_session_reset(\"sid-live\") is True\n+        row = db.get_session(\"sid-live\")\n+        assert row[\"end_reason\"] == \"session_reset\"\n+        assert row[\"ended_at\"] is not None\n+\n+    def test_promotes_agent_close_row(self, db: SessionDB) -> None:\n+        \"\"\"A row ended with agent_close is promoted to session_reset.\"\"\"\n+        db.create_session(\n+            \"sid-ac\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-ac\", \"user\", \"hello\")\n+        db.end_session(\"sid-ac\", \"agent_close\")\n+\n+        assert db.promote_to_session_reset(\"sid-ac\") is True\n+        row = db.get_session(\"sid-ac\")\n+        assert row[\"end_reason\"] == \"session_reset\"\n+\n+    def test_does_not_overwrite_compression(self, db: SessionDB) -> None:\n+        \"\"\"An existing compression boundary must not be overwritten.\"\"\"\n+        db.create_session(\n+            \"sid-comp\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.end_session(\"sid-comp\", \"compression\")\n+\n+        assert db.promote_to_session_reset(\"sid-comp\") is False\n+        row = db.get_session(\"sid-comp\")\n+        assert row[\"end_reason\"] == \"compression\"\n+\n+    def test_does_not_overwrite_existing_session_reset(self, db: SessionDB) -> None:\n+        \"\"\"Already-promoted rows are idempotently skipped.\"\"\"\n+        db.create_session(\n+            \"sid-reset\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.end_session(\"sid-reset\", \"session_reset\")\n+\n+        # Should be a no-op (rowcount = 0)\n+        assert db.promote_to_session_reset(\"sid-reset\") is False\n+        row = db.get_session(\"sid-reset\")\n+        assert row[\"end_reason\"] == \"session_reset\"\n+\n+    def test_does_not_overwrite_new_command(self, db: SessionDB) -> None:\n+        \"\"\"A /new-command boundary is preserved.\"\"\"\n+        db.create_session(\n+            \"sid-new\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.end_session(\"sid-new\", \"new_command\")\n+\n+        assert db.promote_to_session_reset(\"sid-new\") is False\n+        row = db.get_session(\"sid-new\")\n+        assert row[\"end_reason\"] == \"new_command\"\n+\n+    def test_noop_on_missing_session(self, db: SessionDB) -> None:\n+        \"\"\"Non-existent session_id returns False without error.\"\"\"\n+        assert db.promote_to_session_reset(\"nonexistent\") is False\n+\n+    def test_noop_on_empty_session_id(self, db: SessionDB) -> None:\n+        assert db.promote_to_session_reset(\"\") is False\n+\n+\n+# ------------------------------------------------------------------\n+# Integration: promotion blocks stale-route recovery\n+# ------------------------------------------------------------------\n+\n+class TestPromotionBlocksRecovery:\n+    \"\"\"After promotion, find_latest_gateway_session_for_peer must NOT\n+    recover the session — it is now ended with session_reset, which\n+    the recovery query excludes (only live/agent_close are recoverable).\n+    \"\"\"\n+\n+    def test_live_session_recoverable_before_promotion(self, db: SessionDB) -> None:\n+        db.create_session(\n+            \"sid-pre\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-pre\", \"user\", \"hello\")\n+\n+        recovered = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        assert recovered is not None\n+        assert recovered[\"id\"] == \"sid-pre\"\n+\n+    def test_promoted_session_not_recoverable(self, db: SessionDB) -> None:\n+        db.create_session(\n+            \"sid-post\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-post\", \"user\", \"hello\")\n+        db.promote_to_session_reset(\"sid-post\")\n+\n+        recovered = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        # session_reset rows are not in the recovery set\n+        assert recovered is None\n+\n+    def test_agent_close_session_recoverable_but_not_after_promotion(\n+        self, db: SessionDB\n+    ) -> None:\n+        db.create_session(\n+            \"sid-ac-rec\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-ac-rec\", \"user\", \"hello\")\n+        db.end_session(\"sid-ac-rec\", \"agent_close\")\n+\n+        # agent_close is recoverable\n+        recovered = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        assert recovered is not None\n+        assert recovered[\"id\"] == \"sid-ac-rec\"\n+\n+        # After promotion, it is no longer recoverable\n+        db.promote_to_session_reset(\"sid-ac-rec\")\n+        recovered2 = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        assert recovered2 is None"]},{"file":"tests/gateway/test_session_store_lock_io.py","hunks":["@@ -272,7 +272,13 @@ def test_auto_reset_does_not_recover_session_being_ended(tmp_path):\n     assert entry.session_id != old.session_id\n     assert entry.was_auto_reset is True\n     db.reopen_session.assert_not_called()\n-    db.end_session.assert_called_once_with(old.session_id, \"session_reset\")\n+    # Auto-reset now writes through promote_to_session_reset (upgrades\n+    # accidental agent_close/ws_orphan_reap ends) with the specific\n+    # auditable reason — a suspended session resets as \"suspended\".\n+    db.promote_to_session_reset.assert_called_once_with(\n+        old.session_id, \"suspended\"\n+    )\n+    db.end_session.assert_not_called()\n \n \n def test_legacy_and_off_lock_saves_share_one_serialization_lock(tmp_path):"]},{"file":"tests/gateway/test_session_store_runtime_stale_guard.py","hunks":["@@ -220,3 +220,50 @@ def test_force_new_skips_stale_check(self, tmp_path):\n \n         assert result.session_id != \"sid_old\"\n         db.get_session.assert_not_called()\n+\n+    def test_stale_agent_close_overdue_policy_creates_fresh_session(\n+        self, tmp_path,\n+    ):\n+        \"\"\"Stale `agent_close` entry + overdue reset policy → fresh session.\n+\n+        The #54878 self-healing path popped the stale sessions.json entry and\n+        recovered the same session_id from the DB without checking whether a\n+        daily/idle reset was actually due.  This test guards the fix at\n+        gateway/session.py:1765 — when the session is overdue under the\n+        configured reset policy, we must create a fresh session (new id,\n+        auto-reset metadata set, reopen_session NOT called).\n+        \"\"\"\n+        source = _source()\n+        # Idle policy: reset after 60 minutes of inactivity.\n+        config = GatewayConfig(\n+            default_reset_policy=SessionResetPolicy(mode=\"idle\", idle_minutes=60),\n+        )\n+        db = _db_returning({\"sid_stale\": {\"end_reason\": \"agent_close\", \"id\": \"sid_stale\"}})\n+        # Recovery would normally reopen this row — but it shouldn't, because\n+        # the reset policy says this session is overdue.\n+        db.find_latest_gateway_session_for_peer.return_value = {\n+            \"id\": \"sid_stale\",\n+            \"started_at\": (datetime.now() - timedelta(hours=3)).timestamp(),\n+        }\n+\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = db\n+        store._loaded = True\n+\n+        key = store._generate_session_key(source)\n+        # Entry last updated 2 hours ago → well past the 60-minute idle window.\n+        store._entries[key] = _make_entry(key, \"sid_stale\")\n+        store._entries[key].updated_at = datetime.now() - timedelta(hours=2)\n+\n+        result = store.get_or_create_session(source)\n+\n+        # Fresh session — NOT the stale session_id.\n+        assert result.session_id != \"sid_stale\"\n+        # Auto-reset metadata is set.\n+        assert result.was_auto_reset is True\n+        assert result.auto_reset_reason == \"idle\"\n+        # reopen_session must NOT have been called (we skipped recovery).\n+        db.reopen_session.assert_not_called()\n+        # A brand-new session row was created.\n+        db.create_session.assert_called_once()"]},{"file":"tests/gateway/test_session_store_stale_prune.py","hunks":["@@ -151,6 +151,25 @@ def test_prunes_stale_entry_when_recovery_only_finds_same_ended_session(self, tm\n \n         assert key not in store._entries\n \n+    def test_keeps_stale_entry_when_recovery_lookup_raises(self, tmp_path):\n+        \"\"\"Indeterminate recovery must not delete the only routing handle.\n+\n+        Startup pruning sees an ended parent and tries to repoint it to the\n+        latest live gateway child.  If that recovery query raises, deleting the\n+        sessions.json entry loses the routing key entirely; keeping it lets the\n+        runtime stale guard retry recovery on the next message.\n+        \"\"\"\n+        key = \"agent:main:telegram:dm:5140768830\"\n+        db = _db_returning({\"sid_parent\": {\"end_reason\": \"compression\", \"id\": \"sid_parent\"}})\n+        db.find_latest_gateway_session_for_peer.side_effect = RuntimeError(\"db busy\")\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[key] = _make_entry_with_origin(key, \"sid_parent\")\n+\n+        store._prune_stale_sessions_locked()\n+\n+        assert key in store._entries\n+        assert store._entries[key].session_id == \"sid_parent\"\n+\n     def test_noop_when_db_is_none(self, tmp_path):\n         config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n         with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n"]}]},{"kind":"pr","number":67937,"title":"fix(state): exclude expiry-finalized sessions from stale-route recovery (#67781)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"79283ec16203","number":0},{"kind":"pr","value":"#67781","number":67781},{"kind":"pr","value":"#67937","number":67937},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#374151","number":374151}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}