Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions devagent/session/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import threading
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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(
Expand Down
49 changes: 49 additions & 0 deletions tests/test_phase12.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading