fix(memory): harden cursor recovery against non-integer corruption

_next_cursor now checks isinstance(cursor, int) before arithmetic,
falling back to a reverse scan of all entries when the last entry's
cursor is corrupted. read_unprocessed_history skips entries with
non-int cursors instead of crashing on comparison.

Root cause: external callers (cron jobs, plugins) occasionally wrote
string cursors to history.jsonl, which blocked all subsequent
append_history calls with TypeError/ValueError.

Includes 7 regression tests covering string, float, null, and list
cursor types.
This commit is contained in:
Muata Kamdibe
2026-04-21 14:02:53 +08:00
committed by Xubin Ren
parent 409afe1a3d
commit c0a11c7cf4
2 changed files with 125 additions and 2 deletions
+15 -2
View File
@@ -256,12 +256,25 @@ class MemoryStore:
# Fallback: read last line's cursor from the JSONL file.
last = self._read_last_entry()
if last and last.get("cursor"):
return last["cursor"] + 1
cursor = last["cursor"]
if isinstance(cursor, int):
return cursor + 1
# Corrupted (non-int) cursor — scan all entries for the highest valid one.
entries = self._read_entries()
for entry in reversed(entries):
c = entry.get("cursor")
if isinstance(c, int):
return c + 1
return 1
return 1
def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
"""Return history entries with cursor > *since_cursor*."""
return [e for e in self._read_entries() if e.get("cursor", 0) > since_cursor]
return [
e
for e in self._read_entries()
if isinstance(e.get("cursor"), int) and e["cursor"] > since_cursor
]
def compact_history(self) -> None:
"""Drop oldest entries if the file exceeds *max_history_entries*."""