diff --git a/gateway/session.py b/gateway/session.py index 74f1242bf2..0121518152 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -2895,6 +2895,12 @@ class SessionStore: Values must be small and JSON-serializable — they are written into the routing index (state.db gateway_routing table + the legacy sessions.json mirror) so they survive gateway restarts. + + Metadata writes are internal bookkeeping and deliberately do NOT + advance ``updated_at``: it is the user-activity clock that drives + idle/daily reset policy and the restart-resume freshness gate + (#85709), and a background write must not make an idle session look + fresh. """ with self._lock: self._ensure_loaded_locked() @@ -2902,7 +2908,6 @@ class SessionStore: if entry is None: return False entry.metadata[key] = value - entry.updated_at = _now() self._save() return True @@ -3349,7 +3354,10 @@ class SessionStore: target_session_id, ): return None - entry.updated_at = _now() + # Compression repoint is store bookkeeping, not user activity — + # leave ``updated_at`` alone so a background compression on an + # idle session cannot make it look fresh to reset policy or the + # restart-resume freshness gate (#85709). self._save() return entry diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index d8e535eeb7..c81aade3f3 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -2,7 +2,7 @@ import json import pytest from dataclasses import replace -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from unittest.mock import patch, MagicMock from hermes_state import SessionDB @@ -1291,6 +1291,34 @@ class TestSessionMetadata: == "123.456" ) + def test_metadata_write_does_not_touch_activity_clock(self, tmp_path): + """set_session_metadata is bookkeeping — it must not bump updated_at. + + updated_at drives idle/daily reset policy and the restart-resume + freshness gate (#85709); a background metadata write on an idle + session must not make it look recently active. + """ + config = GatewayConfig() + store = SessionStore(sessions_dir=tmp_path, config=config) + store._db = None + source = SessionSource( + platform=Platform.SLACK, + chat_id="C123", + chat_type="group", + user_id="U123", + thread_id="123.000", + ) + + entry = store.get_or_create_session(source) + idle = datetime.now() - timedelta(days=21) + with store._lock: + entry.updated_at = idle + + assert store.set_session_metadata(entry.session_key, "k", "v") + assert entry.updated_at == idle + # And the restart freshness gate must still see it as idle. + assert store.suspend_recently_active(max_age_seconds=120) == 0 + class TestRewriteTranscriptPreservesReasoning: """rewrite_transcript must not drop reasoning fields from SQLite.""" diff --git a/tests/gateway/test_session_store_runtime_stale_guard.py b/tests/gateway/test_session_store_runtime_stale_guard.py index e248e811e1..fd5ae50f34 100644 --- a/tests/gateway/test_session_store_runtime_stale_guard.py +++ b/tests/gateway/test_session_store_runtime_stale_guard.py @@ -292,4 +292,25 @@ class TestAdvanceCompressionSession: db.end_session.assert_not_called() db.reopen_session.assert_not_called() + def test_repoint_does_not_touch_activity_clock(self, tmp_path): + """Compression repoint is bookkeeping — it must not bump updated_at. + + A background compression on an idle session must not make it look + fresh to reset policy or the restart-resume freshness gate (#85709). + """ + db = _db_returning({}) + store = _make_store_with_db(tmp_path, db) + source = _source() + key = store._generate_session_key(source) + original = _make_entry(key, "sid_parent") + idle = datetime.now() - timedelta(days=21) + original.updated_at = idle + store._entries[key] = original + + result = store.advance_compression_session(key, "sid_parent", "sid_tip") + + assert result is not None + assert result.updated_at == idle + assert store.suspend_recently_active(max_age_seconds=120) == 0 +