mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(memory): keep history cursor monotonic
This commit is contained in:
parent
99e158c062
commit
be058c0922
@ -336,18 +336,32 @@ class MemoryStore:
|
|||||||
session_key = entry.get("session_key")
|
session_key = entry.get("session_key")
|
||||||
return session_key is None or isinstance(session_key, str)
|
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:
|
def _next_cursor(self) -> int:
|
||||||
"""Read the current cursor counter and return the next value."""
|
"""Read the current cursor counter and return the next value."""
|
||||||
if self._cursor_file.exists():
|
cursor_counter = self._read_cursor_counter()
|
||||||
with suppress(ValueError, OSError):
|
last = self._read_last_entry() or {}
|
||||||
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
|
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
|
# Fast path: trust the tail when intact. Otherwise scan the whole
|
||||||
# file and take ``max`` — that stays correct even if the monotonic
|
# file and take ``max`` — that stays correct even if the monotonic
|
||||||
# invariant was broken by external writes.
|
# invariant was broken by external writes.
|
||||||
last = self._read_last_entry() or {}
|
if last_cursor is not None:
|
||||||
cursor = self._valid_cursor(last.get("cursor"))
|
return last_cursor + 1
|
||||||
if cursor is not None:
|
|
||||||
return cursor + 1
|
|
||||||
return max((c for _, c in self._iter_valid_entries()), default=0) + 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]]:
|
def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@ -6,8 +6,6 @@ history.jsonl (e.g. ``"cursor": "abc"``). The original ``_next_cursor`` and
|
|||||||
``TypeError`` / ``ValueError``, blocking all subsequent history appends.
|
``TypeError`` / ``ValueError``, blocking all subsequent history appends.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
@ -70,6 +68,40 @@ class TestNextCursorRecovery:
|
|||||||
cursor = store.append_history("after bad cursor file")
|
cursor = store.append_history("after bad cursor file")
|
||||||
assert cursor == 11
|
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:
|
class TestReadUnprocessedWithCorruption:
|
||||||
"""``read_unprocessed_history`` must skip entries with non-int cursors
|
"""``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
|
warning, subsequent reads on the same store stay quiet. Without
|
||||||
this, a poisoned file produces one warning per agent turn."""
|
this, a poisoned file produces one warning per agent turn."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from loguru import logger as loguru_logger
|
from loguru import logger as loguru_logger
|
||||||
|
|
||||||
store.history_file.write_text(
|
store.history_file.write_text(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user