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