From e633f867e800a5bf63563a3b3e16a87e1b90c348 Mon Sep 17 00:00:00 2001 From: KDB <937925477@qq.com> Date: Mon, 27 Jul 2026 19:10:24 +0800 Subject: [PATCH] fix(session): tolerate invalid idle-compaction timestamps --- nanobot/agent/autocompact.py | 16 +++++++-- tests/agent/test_autocompact_unit.py | 50 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/autocompact.py b/nanobot/agent/autocompact.py index ba1f629d2..6af614d35 100644 --- a/nanobot/agent/autocompact.py +++ b/nanobot/agent/autocompact.py @@ -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) diff --git a/tests/agent/test_autocompact_unit.py b/tests/agent/test_autocompact_unit.py index 75565368a..e73daa452 100644 --- a/tests/agent/test_autocompact_unit.py +++ b/tests/agent/test_autocompact_unit.py @@ -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)