From e3064f541b516e6d07945ab3c20614f7ce44a35d Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Sun, 4 Oct 2026 08:29:56 +0900 Subject: [PATCH] fix(memory): sign DeerMem reload before reading the document (#6238) * fix(memory): sign DeerMem reload before reading the document reload() read the document before computing its cache signature, so a write committed in between cached the old document under the new signature and later load() calls served stale memory until the next write. Compute the signature first, matching load(). * test(memory): cover markdown storage and agent scope in reload race test * docs(changelog): reference #6238 for the DeerMem reload fix --- CHANGELOG.md | 8 ++++ CHANGELOG_zh.md | 6 +++ .../harness/deerflow/agents/memory/AGENTS.md | 1 + .../backends/deermem/deermem/core/storage.py | 5 ++- backend/tests/test_memory_storage.py | 39 +++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4158979b58..3440b9f50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -466,6 +466,13 @@ This release closes that milestone with **301 merged pull requests**. ### Fixed +- **memory:** A DeerMem memory reload no longer pins an older document in the + cache. `reload()` read the document before computing its cache signature, so + a write committed in between (for example by the background memory updater) + cached the old document under the new signature, and every later `load()` + returned the outdated memory until the next write. `reload()` now computes + the signature first, as `load()` already did, so a racing write forces a + re-read instead. ([#6238]) - **channels:** Discord now runs its channel-connection database work on the Gateway event loop. discord.py delivers messages on a private loop in the client thread, and the Discord adapter awaited the connection repository there @@ -7679,3 +7686,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6214]: https://github.com/bytedance/deer-flow/pull/6214 [#6229]: https://github.com/bytedance/deer-flow/pull/6229 [#6230]: https://github.com/bytedance/deer-flow/pull/6230 +[#6238]: https://github.com/bytedance/deer-flow/pull/6238 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 0b591fe67d..8b0c1ce0e6 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -423,6 +423,11 @@ ### 修复 +- **记忆:** DeerMem 记忆重新加载不再把旧文档固定在缓存中。`reload()` + 此前先读取文档、后计算缓存签名,若两者之间有写入提交(例如后台记忆更 + 新器),旧文档就会以新签名写入缓存,之后每次 `load()` 都返回过时的记 + 忆,直到下一次写入。`reload()` 现在与 `load()` 一样先计算签名,竞争写 + 入只会触发重新读取。([#6238]) - **渠道:** Discord 的渠道连接数据库操作现在在 Gateway 事件循环上执行。 discord.py 在客户端线程的私有事件循环上投递消息,而 Discord 适配器此前就在该 循环上 await 连接仓库,但仓库的 SQLAlchemy 引擎与连接池属于 Gateway 循环。在 @@ -6414,3 +6419,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6214]: https://github.com/bytedance/deer-flow/pull/6214 [#6229]: https://github.com/bytedance/deer-flow/pull/6229 [#6230]: https://github.com/bytedance/deer-flow/pull/6230 +[#6238]: https://github.com/bytedance/deer-flow/pull/6238 diff --git a/backend/packages/harness/deerflow/agents/memory/AGENTS.md b/backend/packages/harness/deerflow/agents/memory/AGENTS.md index 13c8b1d82f..e4f5f31c9e 100644 --- a/backend/packages/harness/deerflow/agents/memory/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/memory/AGENTS.md @@ -134,6 +134,7 @@ Use the typed conflict classes instead of matching exception text. The weak lock cache must not retain inactive user scopes. Cache validation uses the manifest metadata and persisted revision. +Unlocked `load()`/`reload()` compute that signature before reading the document. Out-of-band Markdown edits require `reload()`. POSIX atomic replacement must sync the parent directory. diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py index 490beb971c..b41a731371 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py @@ -1396,8 +1396,11 @@ class FileMemoryStorage(MemoryStorage): migration_notifications = self._run_read_migrations_locked(path, agent_name, user_id=user_id) for notification_agent, notifications in migration_notifications: self._dispatch_retrieval_notifications(notifications, user_id=user_id, agent_name=notification_agent) - document = self._read_document(path, agent_name, user_id=user_id) + # Sign before reading, as load() does: a write landing in between then + # leaves a stale signature that forces a re-read, never a stale + # document cached under the new signature. signature = self._scope_signature(path, agent_name) + document = self._read_document(path, agent_name, user_id=user_id) with self._cache_lock: self._memory_cache[key] = (copy.deepcopy(document), signature) if _rebuild_retrieval and agent_name is not None and self._retrieval is not None: diff --git a/backend/tests/test_memory_storage.py b/backend/tests/test_memory_storage.py index 00c3a4c298..6b534329de 100644 --- a/backend/tests/test_memory_storage.py +++ b/backend/tests/test_memory_storage.py @@ -191,6 +191,45 @@ class TestFileMemoryStorage: memory2 = storage.reload() assert memory2["user"]["workContext"]["summary"] == "updated" + @pytest.mark.parametrize("storage_class", [FileMemoryStorage, MarkdownMemoryStorage]) + @pytest.mark.parametrize("agent_name", [None, "test-agent"]) + def test_reload_does_not_pin_a_document_older_than_its_signature(self, tmp_path, storage_class, agent_name): + """A write committed while reload() reads must not be hidden from later loads.""" + storage = storage_class(DeerMemConfig(storage_path=str(tmp_path))) + + def memory_with(text: str) -> dict: + memory = create_empty_memory() + if agent_name is None: + memory["user"]["workContext"]["summary"] = text + else: + memory["facts"] = [{"id": "fact_1", "content": text, "category": "context", "confidence": 0.9}] + return memory + + def text_of(memory: dict) -> str: + if agent_name is None: + return memory["user"]["workContext"]["summary"] + return memory["facts"][0]["content"] + + assert storage.save(memory_with("old"), agent_name, user_id="alice") + read_document = storage._read_document + raced = False + + def read_then_concurrent_write(*args, **kwargs): + nonlocal raced + document = read_document(*args, **kwargs) + if not raced: + raced = True + # A background updater commits after reload() has read the + # document but before it caches the result. + assert storage.save(memory_with("new"), agent_name, user_id="alice") + return document + + with patch.object(storage, "_read_document", side_effect=read_then_concurrent_write): + storage.reload(agent_name, user_id="alice") + + assert raced + assert text_of(storage.load(agent_name, user_id="alice")) == "new" + class TestCreateStorage: """Test create_storage(config) (replaces the old get_memory_storage() singleton)."""