diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index e0d1f02a..ad4b2e56 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -336,18 +336,32 @@ class MemoryStore: session_key = entry.get("session_key") return session_key is None or isinstance(session_key, str) + def _read_cursor_counter(self) -> int | None: + """Return the persisted cursor counter when it is usable.""" + if not self._cursor_file.exists(): + return None + with suppress(ValueError, OSError): + cursor = int(self._cursor_file.read_text(encoding="utf-8").strip()) + if cursor >= 0: + return cursor + return None + def _next_cursor(self) -> int: """Read the current cursor counter and return the next value.""" - if self._cursor_file.exists(): - with suppress(ValueError, OSError): - return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1 + cursor_counter = self._read_cursor_counter() + last = self._read_last_entry() or {} + last_cursor = self._valid_cursor(last.get("cursor")) + if cursor_counter is not None: + if last_cursor is not None: + return max(cursor_counter, last_cursor) + 1 + max_history_cursor = max((c for _, c in self._iter_valid_entries()), default=0) + return max(cursor_counter, max_history_cursor) + 1 + # Fast path: trust the tail when intact. Otherwise scan the whole # file and take ``max`` — that stays correct even if the monotonic # invariant was broken by external writes. - last = self._read_last_entry() or {} - cursor = self._valid_cursor(last.get("cursor")) - if cursor is not None: - return cursor + 1 + if last_cursor is not None: + return last_cursor + 1 return max((c for _, c in self._iter_valid_entries()), default=0) + 1 def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]: diff --git a/tests/agent/test_cursor_recovery.py b/tests/agent/test_cursor_recovery.py index 1963da65..97b99330 100644 --- a/tests/agent/test_cursor_recovery.py +++ b/tests/agent/test_cursor_recovery.py @@ -6,8 +6,6 @@ history.jsonl (e.g. ``"cursor": "abc"``). The original ``_next_cursor`` and ``TypeError`` / ``ValueError``, blocking all subsequent history appends. """ -import json - import pytest from nanobot.agent.memory import MemoryStore @@ -70,6 +68,40 @@ class TestNextCursorRecovery: cursor = store.append_history("after bad cursor file") assert cursor == 11 + def test_stale_cursor_file_does_not_reuse_history_cursor(self, store): + """A stale .cursor file must not allocate a duplicate cursor.""" + store.history_file.write_text( + '{"cursor": 10, "timestamp": "2026-04-01 10:00", "content": "valid"}\n', + encoding="utf-8", + ) + store._cursor_file.write_text("2", encoding="utf-8") + + cursor = store.append_history("after stale cursor file") + + assert cursor == 11 + entries = store.read_unprocessed_history(since_cursor=0) + assert [e["cursor"] for e in entries] == [10, 11] + + def test_cursor_file_stays_ahead_after_history_compaction(self, store): + """A cursor counter ahead of the tail preserves monotonic allocation.""" + store.history_file.write_text( + '{"cursor": 10, "timestamp": "2026-04-01 10:00", "content": "valid"}\n', + encoding="utf-8", + ) + store._cursor_file.write_text("100", encoding="utf-8") + + cursor = store.append_history("after compacted history") + + assert cursor == 101 + + def test_negative_cursor_file_content_falls_back(self, store): + """A negative .cursor value is corrupt and should not produce negative IDs.""" + store._cursor_file.write_text("-5", encoding="utf-8") + + cursor = store.append_history("after negative cursor file") + + assert cursor == 1 + class TestReadUnprocessedWithCorruption: """``read_unprocessed_history`` must skip entries with non-int cursors @@ -159,6 +191,7 @@ class TestCursorValidationInvariant: warning, subsequent reads on the same store stay quiet. Without this, a poisoned file produces one warning per agent turn.""" import logging + from loguru import logger as loguru_logger store.history_file.write_text(