mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-06 05:28:41 -04:00
c8a012d4d2
* 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.
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""A memory store that cannot be READ must never be overwritten (issue #5673).
|
|
|
|
`MemoryManager.save` is atomic, and the add/import/extract paths are all
|
|
read-modify-write: load the whole store, append, save it back. `load_all`
|
|
used to answer a *failed read* with `[]` — indistinguishable from "no
|
|
memories" — so a failed read turned into
|
|
|
|
load_all() -> [] -> [].append(new) -> save([new])
|
|
|
|
which atomically replaced the entire store with one entry.
|
|
|
|
The trigger that actually bites is a store that is **readable but not
|
|
parseable** — a truncated file, or one holding `{}` instead of `[]`. Nothing
|
|
obstructs the write, so the request succeeds with HTTP 200 and every existing
|
|
memory is destroyed silently. 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 (verified
|
|
end-to-end — clean dev returns 500 there and loses nothing).
|
|
|
|
`load_all_for_update` is the strict loader those callers now use: it raises
|
|
`MemoryStoreUnreadable` rather than reporting an empty store.
|
|
"""
|
|
|
|
import asyncio
|
|
import builtins
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from src.memory import MemoryManager, MemoryStoreUnreadable
|
|
|
|
_SEED = [
|
|
{"id": "m1", "text": "user prefers dark mode", "owner": "alice"},
|
|
{"id": "m2", "text": "user lives in Berlin", "owner": "alice"},
|
|
{"id": "m3", "text": "bob's cat is called Mila", "owner": "bob"},
|
|
]
|
|
|
|
|
|
def _seeded(tmp_path):
|
|
m = MemoryManager(str(tmp_path))
|
|
m.save([dict(e) for e in _SEED])
|
|
return m
|
|
|
|
|
|
def _break_reads_of(monkeypatch, target, exc):
|
|
"""Make open() raise `exc` for `target` only, leaving every other path alone."""
|
|
real_open = builtins.open
|
|
|
|
def fake_open(file, mode="r", *args, **kwargs):
|
|
if os.path.abspath(str(file)) == os.path.abspath(target) and "r" in mode:
|
|
raise exc
|
|
return real_open(file, mode, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "open", fake_open)
|
|
|
|
|
|
# ── the strict loader signals, rather than reporting "empty" ──────────────
|
|
|
|
def test_strict_load_raises_on_permission_error(tmp_path, monkeypatch):
|
|
m = _seeded(tmp_path)
|
|
_break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
|
|
with pytest.raises(MemoryStoreUnreadable):
|
|
m.load_all_for_update()
|
|
|
|
|
|
def test_strict_load_raises_on_corrupt_json(tmp_path):
|
|
m = _seeded(tmp_path)
|
|
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
f.write('[{"id": "m1", "text": "truncated mid-writ')
|
|
with pytest.raises(MemoryStoreUnreadable):
|
|
m.load_all_for_update()
|
|
|
|
|
|
def test_strict_load_raises_when_store_is_not_a_list(tmp_path):
|
|
# A file holding `{}` or `null` is not an empty store, it is a broken one.
|
|
m = _seeded(tmp_path)
|
|
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
json.dump({}, f)
|
|
with pytest.raises(MemoryStoreUnreadable):
|
|
m.load_all_for_update()
|
|
|
|
|
|
def test_strict_load_returns_entries_when_healthy(tmp_path):
|
|
m = _seeded(tmp_path)
|
|
assert {e["id"] for e in m.load_all_for_update()} == {"m1", "m2", "m3"}
|
|
|
|
|
|
def test_strict_load_returns_empty_when_file_genuinely_absent(tmp_path):
|
|
m = _seeded(tmp_path)
|
|
os.remove(m.memory_file)
|
|
# Absent is the one case that legitimately means "no memories yet".
|
|
assert m.load_all_for_update() == []
|
|
|
|
|
|
# ── read paths stay lenient, so an unreadable store can't break chat ──────
|
|
|
|
def test_read_path_still_degrades_to_empty(tmp_path, monkeypatch):
|
|
m = _seeded(tmp_path)
|
|
_break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
|
|
# Context injection / search must not raise; they just see nothing.
|
|
assert m.load_all() == []
|
|
assert m.load(owner="alice") == []
|
|
|
|
|
|
# ── the actual #5673 regression: the store survives ───────────────────────
|
|
|
|
def test_add_cycle_under_transient_read_error_does_not_wipe(tmp_path, monkeypatch):
|
|
"""Mirrors routes/memory/memory_routes.py api_add_memory exactly."""
|
|
m = _seeded(tmp_path)
|
|
new_entry = m.add_entry("a brand new fact", owner="alice")
|
|
|
|
with monkeypatch.context() as mp:
|
|
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
with pytest.raises(MemoryStoreUnreadable):
|
|
all_mem = m.load_all_for_update()
|
|
all_mem.append(new_entry)
|
|
m.save(all_mem)
|
|
|
|
# Reads work again; every original memory is still there and the file was
|
|
# never replaced by the single new entry.
|
|
assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
|
|
|
|
|
|
def test_audit_merge_cannot_drop_other_tenants(tmp_path, monkeypatch):
|
|
"""The audit path rebuilds the whole file from load_all + one owner's slice.
|
|
|
|
Reading [] there would save only the audited owner's entries and destroy
|
|
every other tenant's memories, so it has to fail closed too.
|
|
"""
|
|
m = _seeded(tmp_path)
|
|
alice_slice = [e for e in _SEED if e["owner"] == "alice"]
|
|
|
|
with monkeypatch.context() as mp:
|
|
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
with pytest.raises(MemoryStoreUnreadable):
|
|
all_entries = m.load_all_for_update()
|
|
others = [e for e in all_entries if e.get("owner") != "alice"]
|
|
m.save(alice_slice + others)
|
|
|
|
assert any(e["id"] == "m3" for e in m.load_all()), "bob's memory was destroyed"
|
|
|
|
|
|
def test_uses_bump_skips_write_when_unreadable(tmp_path, monkeypatch):
|
|
m = _seeded(tmp_path)
|
|
with monkeypatch.context() as mp:
|
|
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
m.increment_uses(["m1"]) # must not raise, must not write
|
|
assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
|
|
|
|
|
|
def test_claim_ownerless_skips_write_when_unreadable(tmp_path, monkeypatch):
|
|
m = _seeded(tmp_path)
|
|
with monkeypatch.context() as mp:
|
|
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
m.claim_ownerless("alice")
|
|
assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
|
|
|
|
|
|
# ── the add sinks users actually reach ────────────────────────────────────
|
|
#
|
|
# The tests above replay the read-modify-write shape. These drive the real
|
|
# entry points end to end, because those are what #5673 reports: "remember
|
|
# that I prefer X" in ordinary chat (src/ai_interaction.py do_manage_memory,
|
|
# routed from src/tool_execution.py) and the built-in memory MCP server
|
|
# (mcp_servers/memory_server.py, registered in src/builtin_mcp.py).
|
|
#
|
|
# They use a truncated store rather than a read error on purpose: it reads
|
|
# fine, so nothing stops the save, which is the case that silently destroyed
|
|
# stores. The assertion is that the file is left byte-identical — still broken,
|
|
# but still holding the user's memories, so it can be repaired by hand.
|
|
|
|
|
|
def _truncated_store(tmp_path):
|
|
"""Seed a store that reads back fine but no longer parses."""
|
|
m = _seeded(tmp_path)
|
|
good = json.dumps([dict(e) for e in _SEED], indent=2)
|
|
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
f.write(good[:good.rindex("]")]) # drop the closing bracket only
|
|
with open(m.memory_file, "rb") as f:
|
|
return m, f.read()
|
|
|
|
|
|
def _on_disk(manager) -> bytes:
|
|
with open(manager.memory_file, "rb") as f:
|
|
return f.read()
|
|
|
|
|
|
def test_agent_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
|
|
"""src/ai_interaction.py do_manage_memory, action "add"."""
|
|
from src import ai_interaction
|
|
|
|
manager, before = _truncated_store(tmp_path)
|
|
monkeypatch.setattr(ai_interaction, "_memory_manager", manager)
|
|
monkeypatch.setattr(ai_interaction, "_memory_vector", None)
|
|
|
|
result = asyncio.run(ai_interaction.do_manage_memory("add\nuser prefers tabs"))
|
|
|
|
assert _on_disk(manager) == before, "the unreadable store was overwritten"
|
|
assert b"m3" in _on_disk(manager)
|
|
assert "error" in result, "the add reported success over an unreadable store"
|
|
|
|
|
|
def test_mcp_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
|
|
"""mcp_servers/memory_server.py, action "add"."""
|
|
import mcp_servers.memory_server as memory_server
|
|
|
|
manager, before = _truncated_store(tmp_path)
|
|
monkeypatch.setattr(memory_server, "_memory_manager", manager)
|
|
monkeypatch.setattr(memory_server, "_memory_vector", None)
|
|
monkeypatch.setattr(memory_server, "_initialized", True)
|
|
for key in memory_server._OWNER_ENV_KEYS:
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
result = asyncio.run(memory_server.call_tool(
|
|
"manage_memory", {"action": "add", "text": "user prefers tabs"}
|
|
))
|
|
|
|
assert _on_disk(manager) == before, "the unreadable store was overwritten"
|
|
assert b"m3" in _on_disk(manager)
|
|
assert result[0].text.startswith("Error:")
|
|
|
|
|
|
def test_native_provider_remember_does_not_overwrite_unreadable_store(tmp_path):
|
|
"""src/memory_provider.py NativeMemoryProvider.remember.
|
|
|
|
Registered into app state in src/app_initializer.py but not yet consumed
|
|
outside tests, so this is the pattern held in place before it goes live.
|
|
"""
|
|
from src.memory_provider import NativeMemoryProvider
|
|
|
|
manager, before = _truncated_store(tmp_path)
|
|
provider = NativeMemoryProvider(manager)
|
|
|
|
with pytest.raises(MemoryStoreUnreadable):
|
|
asyncio.run(provider.remember("user prefers tabs", owner="alice"))
|
|
|
|
assert _on_disk(manager) == before
|
|
|
|
|
|
# ── the legacy memory.txt migration is preserved ──────────────────────────
|
|
|
|
def test_corrupt_store_still_migrates_from_legacy_txt(tmp_path):
|
|
m = _seeded(tmp_path)
|
|
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
f.write("{ not json")
|
|
legacy = os.path.join(str(tmp_path), "memory.txt")
|
|
with open(legacy, "w", encoding="utf-8") as f:
|
|
f.write("recovered fact one\nrecovered fact two\n")
|
|
|
|
entries = m.load_all_for_update()
|
|
assert [e["text"] for e in entries] == ["recovered fact one", "recovered fact two"]
|