fix(session): tolerate invalid idle-compaction timestamps

This commit is contained in:
KDB 2026-07-27 19:10:24 +08:00 committed by Xubin Ren
parent 07c2677eed
commit e633f867e8
2 changed files with 63 additions and 3 deletions

View File

@ -31,9 +31,19 @@ class AutoCompact:
now: datetime | None = None) -> bool:
if self._ttl <= 0 or not ts:
return False
if isinstance(ts, str):
ts = datetime.fromisoformat(ts)
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
try:
if isinstance(ts, str):
ts = datetime.fromisoformat(ts)
current = now or datetime.now()
if getattr(ts, "tzinfo", None) is not None or current.tzinfo is not None:
idle_seconds = current.timestamp() - ts.timestamp()
else:
idle_seconds = (current - ts).total_seconds()
except (OSError, OverflowError, TypeError, ValueError):
# list_sessions() forwards raw persisted metadata; an unusable value
# must not escape the idle scan and stop the agent loop.
return False
return idle_seconds >= self._ttl * 60
def _has_compactable_idle_tail(self, key: str) -> bool:
session = self.sessions.get_or_create(key)

View File

@ -154,6 +154,26 @@ class TestIsExpired:
now_over = datetime(2026, 1, 1, 10, 10, 0)
assert ac._is_expired(ts, now=now_over) is True
def test_unparseable_string_timestamp_returns_false(self):
"""A persisted timestamp that no longer parses must not raise.
list_sessions() forwards the raw persisted updated_at string, and
SessionManager._load already tolerates a malformed value through its
recovery path. The idle scan must mirror that tolerance instead of crashing.
"""
ac = _make_autocompact(ttl=15)
assert ac._is_expired("not-a-timestamp") is False
def test_tz_aware_string_timestamp_is_compared_by_instant(self):
"""A valid timestamp with an offset remains eligible for expiry."""
ac = _make_autocompact(ttl=15)
now = datetime(2026, 1, 1, 12, 0, 0)
recent = (now - timedelta(minutes=10)).astimezone().isoformat()
expired = (now - timedelta(minutes=20)).astimezone().isoformat()
assert ac._is_expired(recent, now=now) is False
assert ac._is_expired(expired, now=now) is True
# ---------------------------------------------------------------------------
# _format_summary
@ -221,6 +241,36 @@ class TestCheckExpired:
assert len(scheduled) == 1
assert "cli:old" in ac._archiving
def test_unparseable_updated_at_does_not_stop_scan(self):
"""A malformed timestamp is skipped without hiding later sessions.
The idle scan runs from the agent loop's inbound-timeout branch, so a
raised exception here would tear down the loop. list_sessions() forwards
the raw string, so check_expired must tolerate it like SessionManager
does when loading.
"""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
old_dt = datetime.now() - timedelta(minutes=20)
session = _make_session("cli:old", updated_at=old_dt)
_add_turns(session, 5)
mock_sm.list_sessions.return_value = [
{"key": "cli:corrupt", "updated_at": "not-a-timestamp"},
{"key": "cli:old", "updated_at": old_dt.isoformat()},
]
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
scheduled = []
def scheduler(coro):
scheduled.append(coro)
coro.close()
ac.check_expired(scheduler, _runtime)
assert len(scheduled) == 1
assert ac._archiving == {"cli:old"}
@pytest.mark.asyncio
async def test_runtime_is_captured_before_background_starts(self):
ac = _make_autocompact(ttl=15)