fix(memory): don't let an unreadable store get overwritten with an empty one (#5831)

* fix(memory): don't let an unreadable store get overwritten with an empty one

load_all() answered a failed read the same way it answered an empty store:
with []. Every mutation path is a read-modify-write (load the whole file,
change it, save it back), so a failed read became

    load_all() -> []  ->  [].append(new)  ->  save([new])

and save() is atomic, so the replacement stuck.

The case that actually destroys data is a store that is READABLE but not
parseable - a truncated file, or one holding {} instead of []. Nothing
obstructs the write, so adding a memory returns HTTP 200 and every memory
already stored is gone. Verified end-to-end against a running instance: on the
current code a truncated memory.json plus one add leaves the file holding only
the new entry. Truncation is reachable - core/database.py rewrites memory.json
during migration with a plain open(.., "w") + json.dump, which is not atomic.

A live exclusive lock is not the dangerous case: it blocks the read and the
os.replace alike, so the save fails too and the store survives. That path
currently 500s and loses nothing.

_read_entries() now returns [] only when the file genuinely does not exist and
raises MemoryStoreUnreadable for every other failure, including a store that
parses but is not a JSON array. load_all() keeps the old lenient behaviour so
display, search and context injection still degrade quietly instead of
breaking chat. The read-modify-write callers switch to load_all_for_update(),
which propagates the error: the memory routes turn it into a 503 and change
nothing, backup import refuses rather than saving only the incoming rows, and
auto-extraction and the audit merge skip the write. The audit merge mattered
most - it rebuilds the whole file from one owner's slice plus everyone else's
rows, so an empty read there dropped every other tenant's memories.

The corrupt-JSON path still gets its one shot at the legacy memory.txt
migration before raising, so that recovery is unchanged.

The two updated fakes gained load_all_for_update because the real class has it;
MagicMock would otherwise hand the import path a Mock instead of the seeded list.

Fixes #5673

* fix(memory): fail closed on the remaining read-modify-write add paths

The strict loader landed with the routes, the backup import and the extractor
converted, but three read-modify-write sinks still called load_all(), which
degrades an unreadable store to []. Two of them are the paths users actually
reach, so the data loss in #5673 stayed reproducible:

- src/ai_interaction.py do_manage_memory, action "add" — reached from ordinary
  chat via src/tool_execution.py:793 -> dispatch_ai_tool. "Remember that I
  prefer X" against an unreadable store wrote a one-entry file over it and
  reported success.
- mcp_servers/memory_server.py, action "add" — the same shape through
  _scope_entries(), registered as a built-in in src/builtin_mcp.py.
- src/memory_provider.py NativeMemoryProvider.remember and .delete — wired
  into app state in src/app_initializer.py but not consumed outside tests yet,
  converted here so the pattern is uniform before it goes live.

The MCP server takes _scope_entries(for_update=True) so list keeps the lenient
read. The edit and delete branches on both tool paths were already fail-closed
by accident — an empty view matches nothing and returns before the save — so
they are left alone.

The three new tests drive the real entry points rather than replaying the
shape, and use a truncated store, which is the case that reads back fine so
nothing stops the save. Each asserts memory.json is byte-identical afterwards;
all three fail on the previous commit with the store overwritten.
This commit is contained in:
Ashvin
2026-08-06 02:33:50 -06:00
committed by GitHub
parent 20e7fc0164
commit c8a012d4d2
12 changed files with 451 additions and 28 deletions
+10 -1
View File
@@ -22,6 +22,7 @@ import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.constants import GENERATED_IMAGES_DIR
from src.memory import MemoryStoreUnreadable
logger = logging.getLogger(__name__)
@@ -384,7 +385,15 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
return {"error": "Memory text cannot be empty"}
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
memories = _memory_manager.load_all()
# Strict load: this is a read-modify-write, and it is the path an
# ordinary "remember that I prefer X" takes. Degrading to [] here would
# save just this one entry over a store we only failed to read,
# atomically destroying every memory in it (issue #5673).
try:
memories = _memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Refusing to add memory, store unreadable: %s", e)
return {"error": "Memory store is temporarily unreadable — nothing was saved."}
memories.append(entry)
_memory_manager.save(memories)
+80 -10
View File
@@ -10,6 +10,18 @@ from datetime import datetime
logger = logging.getLogger(__name__)
class MemoryStoreUnreadable(RuntimeError):
"""memory.json exists on disk but could not be read or parsed.
"The contents are unknown" is categorically different from "there are no
memories". A read-modify-write caller that conflates the two appends to an
empty view and then persists it, destroying the whole store — the writes
are atomic, so the loss is durable. Raised by
:meth:`MemoryManager.load_all_for_update` so those callers fail closed.
"""
def tokenize(text: str) -> List[str]:
"""Simple tokenizer that splits on whitespace and removes punctuation."""
return [word.strip('.,!?";') for word in text.split()]
@@ -110,21 +122,69 @@ class MemoryManager:
with open(self.memory_file, 'w', encoding='utf-8') as f:
json.dump([], f, ensure_ascii=False, indent=2)
def load_all(self) -> List[Dict]:
"""Load all memory entries from JSON file (unfiltered)."""
def _read_entries(self) -> List[Dict]:
"""Parse the store, or raise :class:`MemoryStoreUnreadable`.
Returns ``[]`` only when the file genuinely does not exist. Every other
failure mode raises, so callers can tell "no memories" apart from
"couldn't read the memories".
"""
if not os.path.exists(self.memory_file):
return []
try:
with open(self.memory_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
return self._validate_entries(data)
except (json.JSONDecodeError, PermissionError) as e:
logger.error("Error loading memory.json: %s", e)
return self._migrate_from_legacy()
except OSError as e:
# PermissionError is an OSError (a scanner holding the file, a
# permissions problem, bad media).
raise MemoryStoreUnreadable(
f"cannot read {self.memory_file}: {e}"
) from e
except json.JSONDecodeError as e:
# This is the branch that actually destroyed stores: the file reads
# back fine, so nothing stops the save that follows. A truncated
# memory.json is reachable because core/database.py rewrites it with
# a plain open(..,"w") + json.dump during migration.
#
# Preserved behaviour: a corrupt store still gets one shot at the
# pre-JSON memory.txt migration. Only raise when that finds nothing,
# so we never report "empty" for a store we simply failed to parse.
legacy = self._migrate_from_legacy()
if legacy:
return legacy
raise MemoryStoreUnreadable(
f"{self.memory_file} is not valid JSON: {e}"
) from e
return []
if not isinstance(data, list):
raise MemoryStoreUnreadable(
f"{self.memory_file} is not a JSON array (got {type(data).__name__})"
)
return self._validate_entries(data)
def load_all(self) -> List[Dict]:
"""Load all memory entries from JSON file (unfiltered).
Lenient by design: this feeds display, search, and context-injection
paths, so an unreadable store degrades to an empty list rather than
breaking chat. Never build a value from this that you intend to save
back — use :meth:`load_all_for_update` for that.
"""
try:
return self._read_entries()
except MemoryStoreUnreadable as e:
logger.error("Error loading memory.json: %s", e)
return []
def load_all_for_update(self) -> List[Dict]:
"""Load for a read-modify-write cycle.
Propagates :class:`MemoryStoreUnreadable` instead of degrading to ``[]``
so a caller can never append to an empty view and persist it over a
store that was only temporarily unreadable (issue #5673).
"""
return self._read_entries()
def load(self, owner: str = None) -> List[Dict]:
"""Load memory entries, optionally filtered by owner."""
@@ -135,7 +195,12 @@ class MemoryManager:
def claim_ownerless(self, owner: str):
"""Assign all ownerless memory entries to the given owner."""
entries = self.load_all()
try:
entries = self.load_all_for_update()
except MemoryStoreUnreadable as e:
# Skip the sweep rather than rewrite the store from an unknown view.
logger.error("Skipping ownerless claim, memory store unreadable: %s", e)
return
changed = False
claimed = 0
for entry in entries:
@@ -235,7 +300,12 @@ class MemoryManager:
if not ids:
return
id_set = set(ids)
entries = self.load_all()
try:
entries = self.load_all_for_update()
except MemoryStoreUnreadable as e:
# Best-effort counter; never worth rewriting the store blind.
logger.error("Skipping uses bump, memory store unreadable: %s", e)
return
changed = False
for e in entries:
if e.get("id") in id_set:
+9 -2
View File
@@ -157,7 +157,11 @@ class NativeMemoryProvider(MemoryProvider):
if metadata:
entry["metadata"] = dict(metadata)
memories = self.memory_manager.load_all()
# Strict load: read-modify-write. `load_all` degrades an unreadable
# store to [], which would save this single entry over everything
# already stored (issue #5673). The provider API has no error channel,
# so MemoryStoreUnreadable propagates to the caller.
memories = self.memory_manager.load_all_for_update()
memories.append(entry)
self.memory_manager.save(memories)
@@ -223,7 +227,10 @@ class NativeMemoryProvider(MemoryProvider):
]
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
memories = self.memory_manager.load_all()
# Strict load for the same reason: `remaining` is derived from this
# list and saved back, so it must never be built from a store we
# failed to read.
memories = self.memory_manager.load_all_for_update()
remaining = []
deleted_id = None