{"repo":"NousResearch/hermes-agent","public_id":"46934","display_label":"Gateway session reset and resume_pending bug fixes","summary":"This cluster centers on bugs and fixes related to gateway session recovery, where resume_pending markers, idle reset policies, and DB state restoration interact incorrectly — causing stale sessions, policy bypass, misleading notices, and context bleed. Multiple fixes address reset boundary enforcement, freshness gating, and user notification during resume_pending expiry.","subsystem":"gateway","counts":{"members":13,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":2,"merged_prs":1,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56264},"resolution":{"state":"partially_resolved","by":"pr-56264"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T10:17:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12923076923076923},"fix_edges":[{"issue_number":46934,"pr_number":56264,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":46934,"title":"Bug: stale resume_pending sessions bypass idle reset, causing context bleed after gateway restart","url":null,"state":"closed"},{"kind":"issue","number":58933,"title":"[Bug]: resume_pending_expired silently creates new session without notifying user or properly closing the old one","url":null,"state":"closed"},{"kind":"issue","number":61052,"title":"gateway: resume_pending_expired resets despite session_reset.mode=none and shows misleading 'inactive for 24h' notice","url":null,"state":"open"},{"kind":"issue","number":66255,"title":"Gateway: DB session recovery bypasses the reset policy and re-arms the idle clock — sessions become immortal across restarts","url":null,"state":"open"},{"kind":"pr","number":46963,"title":"fix(gateway): add freshness gate to resume_pending sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a1f62f477","number":0},{"kind":"pr","value":"#56264","number":56264},{"kind":"pr","value":"#54442","number":54442}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":52637,"title":"fix(gateway): clear stale resume_pending markers in scheduler","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":52818,"title":"fix(gateway): clear stale resume_pending markers in scheduler","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":53031,"title":"fix(gateway): clear stale resume_pending markers in scheduler","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52818","number":52818},{"kind":"pr","value":"#53031","number":53031}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":56264,"title":"fix(gateway): freshness-gate resume_pending against per-message zombies","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/session.py","tests/gateway/test_clean_shutdown_marker.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -618,20 +618,19 @@ def _coerce_gateway_timestamp(value: Any) -> Optional[float]:\n def _auto_continue_freshness_window() -> float:\n     \"\"\"Return the configured auto-continue freshness window in seconds.\n \n-    Reads ``HERMES_AUTO_CONTINUE_FRESHNESS`` (bridged from\n-    ``config.yaml`` ``agent.gateway_auto_continue_freshness`` at gateway\n-    startup, same pattern as ``HERMES_AGENT_TIMEOUT``).  Falls back to the\n-    module default when unset or malformed.  Non-positive values disable\n-    the freshness gate (restores the pre-fix \"always fresh\" behaviour for\n-    users who want to opt out).\n+    Thin wrapper that delegates to the canonical implementation in\n+    ``gateway.session`` (the single source of truth shared with the\n+    routing-time zombie gate in ``get_or_create_session``).  Reads\n+    ``HERMES_AUTO_CONTINUE_FRESHNESS`` (bridged from ``config.yaml``\n+    ``agent.gateway_auto_continue_freshness`` at gateway startup, same\n+    pattern as ``HERMES_AGENT_TIMEOUT``).  Falls back to the module default\n+    when unset or malformed.  Non-positive values disable the freshness gate\n+    (restores the pre-fix \"always fresh\" behaviour for users who want to opt\n+    out).  Kept here so existing call sites and test patches importing it\n+    from ``gateway.run`` continue to work.\n     \"\"\"\n-    raw = os.environ.get(\"HERMES_AUTO_CONTINUE_FRESHNESS\")\n-    if raw is None or raw == \"\":\n-        return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)\n-    try:\n-        return float(raw)\n-    except (TypeError, ValueError):\n-        return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)\n+    from gateway.session import auto_continue_freshness_window\n+    return auto_continue_freshness_window()\n \n \n def _float_env(name: str, default: float) -> float:"]},{"file":"gateway/session.py","hunks":["@@ -27,6 +27,35 @@ def _now() -> datetime:\n     return datetime.now()\n \n \n+# Default auto-continue freshness window in seconds (1 hour).  A session\n+# interrupted by a restart is only auto-resumed — and only returned by\n+# ``get_or_create_session`` — while it stays within this window of when\n+# ``resume_pending`` was marked.  ``gateway/run.py`` bridges\n+# ``config.yaml`` ``agent.gateway_auto_continue_freshness`` into\n+# ``HERMES_AUTO_CONTINUE_FRESHNESS`` at startup.\n+_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60\n+\n+\n+def auto_continue_freshness_window() -> float:\n+    \"\"\"Return the configured auto-continue freshness window in seconds.\n+\n+    Single source of truth for both the resume scheduler (``gateway/run.py``)\n+    and the routing-time zombie gate in ``get_or_create_session``.  Reads\n+    ``HERMES_AUTO_CONTINUE_FRESHNESS`` (bridged from ``config.yaml``\n+    ``agent.gateway_auto_continue_freshness`` at gateway startup) and falls\n+    back to the module default when unset or malformed.  A non-positive value\n+    disables the freshness gate (restores the pre-fix \"always fresh\" behaviour\n+    for users who want to opt out).\n+    \"\"\"\n+    raw = os.environ.get(\"HERMES_AUTO_CONTINUE_FRESHNESS\")\n+    if raw is None or raw == \"\":\n+        return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)\n+    try:\n+        return float(raw)\n+    except (TypeError, ValueError):\n+        return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)\n+\n+\n # ---------------------------------------------------------------------------\n # PII redaction helpers\n # ---------------------------------------------------------------------------","@@ -1325,11 +1354,27 @@ def get_or_create_session(\n                         # Restart-interrupted session: preserve the session_id\n                         # and return the existing entry so the transcript reloads\n                         # intact, but still honour normal daily/idle reset policy.\n+                        #\n+                        # Freshness gate (#46934): the idle/daily policy checks\n+                        # ``updated_at``, which is bumped to ``now`` on every\n+                        # message — so a zombie session that keeps receiving\n+                        # messages never trips it and would resume stale context\n+                        # forever.  ``last_resume_marked_at`` is set once when\n+                        # resume was marked and never bumped per-message, so it\n+                        # correctly measures how long resume has been pending.\n+                        # If that exceeds the auto-continue freshness window, the\n+                        # recovery turn either never ran or failed — treat the\n+                        # session as a zombie and fall through to auto-reset.\n                         reset_reason = self._should_reset(entry, source)\n                         if not reset_reason:\n-                            entry.updated_at = now\n-                            self._save()\n-                            return entry\n+                            _fw = auto_continue_freshness_window()\n+                            _ref_time = entry.last_resume_marked_at or entry.updated_at\n+                            if _fw > 0 and (now - _ref_time).total_seconds() > _fw:\n+                                reset_reason = \"resume_pending_expired\"\n+                            else:\n+                                entry.updated_at = now\n+                                self._save()\n+                                return entry\n                     else:\n                         reset_reason = self._should_reset(entry, source)\n                     if not reset_reason:"]},{"file":"tests/gateway/test_clean_shutdown_marker.py","hunks":["@@ -244,3 +244,76 @@ async def _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)\n+# ---------------------------------------------------------------------------\n+\n+class TestResumePendingFreshnessGate:\n+    \"\"\"A resume_pending session is only returned while it is still fresh.\n+\n+    ``get_or_create_session`` returns a ``resume_pending`` session so its\n+    transcript reloads intact after a restart.  But the idle/daily reset\n+    policy keys on ``updated_at``, which is bumped to ``now`` on every\n+    message — so a zombie session that keeps receiving messages never trips\n+    it and would resume stale context forever.  The freshness gate keys on\n+    ``last_resume_marked_at`` (set once at resume-mark, never bumped) so it\n+    catches that case.\n+    \"\"\"\n+\n+    def _mark_resume_pending(self, store, source):\n+        \"\"\"Put the session into resume_pending and return the entry.\"\"\"\n+        store.get_or_create_session(source)\n+        count = store.suspend_recently_active()\n+        assert count == 1\n+        with store._lock:\n+            entry = store._entries[store._generate_session_key(source)]\n+        assert entry.resume_pending\n+        assert entry.last_resume_marked_at is not None\n+        return entry\n+\n+    def test_fresh_resume_pending_returns_same_session(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = self._mark_resume_pending(store, source)\n+\n+        # Within the freshness window (marked just now) → same session back.\n+        refreshed = store.get_or_create_session(source)\n+        assert refreshed.session_id == entry.session_id\n+        assert refreshed.resume_pending\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+        source = _make_source()\n+        entry = self._mark_resume_pending(store, source)\n+\n+        # Backdate the resume mark past the freshness window. Keep updated_at\n+        # fresh (as a per-message zombie would have) so the idle/daily policy\n+        # would NOT fire — only the freshness gate should catch this.\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+        fresh = store.get_or_create_session(source)\n+        # Zombie detected → brand-new session, not the stale transcript.\n+        assert fresh.session_id != entry.session_id\n+        assert not fresh.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\")\n+        store = _make_store(tmp_path)\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=999999)\n+            entry.updated_at = datetime.now()\n+            store._save()\n+\n+        refreshed = store.get_or_create_session(source)\n+        assert refreshed.session_id == entry.session_id\n+        assert refreshed.resume_pending\n"]}]},{"kind":"pr","number":58935,"title":"fix(gateway): notify user and write correct end_reason for resume_pending_expired resets","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61092,"title":"fix(gateway): respect session_reset.mode and fix misleading reset notice","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61071","number":61071},{"kind":"pr","value":"#61071","number":61071}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":61154,"title":"fix(gateway): honor session_reset.mode none in resume_pending freshness gate","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":66264,"title":"fix(gateway): honor the reset policy when recovering a session from state.db (#66255)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4ddd2d868cb2","number":0},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#66264","number":66264}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}