fix(gateway): stop internal bookkeeping writes from advancing the session activity clock

set_session_metadata() and advance_compression_session()'s repoint both
stamped entry.updated_at = now. updated_at is the user-activity clock that
drives idle/daily reset policy and the restart-resume freshness gate
(suspend_recently_active, #85709), so a background metadata write (e.g.
Slack thread watermark) or a background compression repoint on a long-idle
session could make it look freshly active and get it falsely resume_pending
after a gateway restart.

These are the last internal stamp sites after 784f733cf (recover) and
5462f689b (touch_activity gating): drop the stamps, keep the durable save.

Follow-up to #85895 (closed) — credit @GodsBoy for the report-side push and
@chelsealong for the analysis on #85709.
This commit is contained in:
Teknium
2026-08-14 10:54:01 -07:00
parent 1169fb50a4
commit 3885c1096a
3 changed files with 60 additions and 3 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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