From 2af9aea8d6294d461e9757098d2e85becea1a5c3 Mon Sep 17 00:00:00 2001 From: Chirag Jangir <184008125+chiragjangiir@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:13:10 +0530 Subject: [PATCH] fix(memory): prevent race condition in MemoryBlock._ensure_loaded() (#102) --- devagent/session/memory.py | 30 +++++++++++++---------- tests/test_phase12.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/devagent/session/memory.py b/devagent/session/memory.py index 1d19189..fb9151c 100644 --- a/devagent/session/memory.py +++ b/devagent/session/memory.py @@ -6,6 +6,7 @@ from __future__ import annotations +import threading from pathlib import Path from typing import Any @@ -33,22 +34,25 @@ def __init__( self._project_memory = project_memory self._cache: dict[str, Any] = {} self._loaded = False + self._load_lock = threading.Lock() def _ensure_loaded(self) -> None: if not self._loaded: - self._cache = store.get_memory( - self.session_id, scope="session", db_path=self.db_path - ) - # Merge project-level facts (file wins for keys not already in SQLite) - if self._project_memory is not None: - for key, value in self._project_memory.load().items(): - if key not in self._cache: - store.upsert_memory( - self.session_id, key, value, - scope="session", db_path=self.db_path - ) - self._cache[key] = value - self._loaded = True + with self._load_lock: + if not self._loaded: + self._cache = store.get_memory( + self.session_id, scope="session", db_path=self.db_path + ) + # Merge project-level facts (file wins for keys not already in SQLite) + if self._project_memory is not None: + for key, value in self._project_memory.load().items(): + if key not in self._cache: + store.upsert_memory( + self.session_id, key, value, + scope="session", db_path=self.db_path + ) + self._cache[key] = value + self._loaded = True def set(self, key: str, value: Any, item_type: str = "fact") -> None: store.upsert_memory( diff --git a/tests/test_phase12.py b/tests/test_phase12.py index 4c6debd..20ea0e7 100644 --- a/tests/test_phase12.py +++ b/tests/test_phase12.py @@ -225,3 +225,52 @@ def test_as_prompt_block_includes_project_facts(self, pm: ProjectMemory, db: Pat block = mb.as_prompt_block() assert "framework" in block assert "FastAPI" in block + + def test_ensure_loaded_concurrent_single_load( + self, pm: ProjectMemory, db: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Concurrent calls to _ensure_loaded() only load memory once (issue #102).""" + import threading + import time + + pm.save({"project_key": "val"}) + sid = _new_session_id() + mb = MemoryBlock(sid, db_path=db, project_memory=pm) + + real_get_memory = store.get_memory + get_memory_calls = 0 + load_calls = 0 + + def slow_get_memory(*args, **kwargs): + nonlocal get_memory_calls + get_memory_calls += 1 + time.sleep(0.05) + return real_get_memory(*args, **kwargs) + + real_pm_load = pm.load + + def slow_pm_load(*args, **kwargs): + nonlocal load_calls + load_calls += 1 + time.sleep(0.05) + return real_pm_load(*args, **kwargs) + + monkeypatch.setattr(store, "get_memory", slow_get_memory) + monkeypatch.setattr(pm, "load", slow_pm_load) + + barrier = threading.Barrier(2) + + def worker() -> None: + barrier.wait() + mb.get("project_key") + + t1 = threading.Thread(target=worker) + t2 = threading.Thread(target=worker) + t1.start() + t2.start() + t1.join() + t2.join() + + assert mb.get("project_key") == "val" + assert get_memory_calls == 1 + assert load_calls == 1