Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 57c83b0072 fix: invalidate config cache on file mtime change for MCP stdio server
The module-level _CONFIG_CACHE was never refreshed in long-lived processes
(e.g. MCP stdio server used by Claude Desktop), so bm project set-cloud
or set-local had no effect until the server was restarted.

Add a companion _CONFIG_CACHE_MTIME variable that records the config
file's mtime when the cache is populated.  load_config() now does a
cheap stat() call on each invocation and drops the cache when the mtime
has changed, allowing the updated routing mode to take effect immediately
without a server restart.

save_config() clears both cache variables so in-process saves still work
correctly.

Fixes #660

Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-03-10 17:48:02 +00:00
2 changed files with 171 additions and 4 deletions
+32 -4
View File
@@ -629,6 +629,10 @@ class BasicMemoryConfig(BaseSettings):
# Module-level cache for configuration
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
# mtime of the config file when the cache was last populated.
# Used to detect out-of-process changes (e.g. CLI set-cloud/set-local while
# the MCP stdio server is running) so the long-lived process picks up updates.
_CONFIG_CACHE_MTIME: Optional[float] = None
class ConfigManager:
@@ -663,10 +667,29 @@ class ConfigManager:
following Pydantic Settings best practices.
Uses module-level cache for performance across ConfigManager instances.
The cache is validated against the config file's mtime on every call so
that long-lived processes (MCP stdio server) automatically pick up
changes made by external commands like `bm project set-cloud`.
"""
global _CONFIG_CACHE
global _CONFIG_CACHE, _CONFIG_CACHE_MTIME
# Return cached config if available
# --- Detect out-of-process config changes via mtime ---
# Trigger: another process (CLI set-cloud/set-local) wrote a new config
# while this process was running and has a cached copy.
# Why: _CONFIG_CACHE is never invalidated across process boundaries;
# a cheap stat() call lets us detect the change without polling.
# Outcome: stale cache is dropped and the file is re-read below.
if _CONFIG_CACHE is not None and self.config_file.exists():
current_mtime = self.config_file.stat().st_mtime
if current_mtime != _CONFIG_CACHE_MTIME:
logger.debug(
f"Config file modified since last load (mtime changed), "
f"invalidating cache: {self.config_file}"
)
_CONFIG_CACHE = None
_CONFIG_CACHE_MTIME = None
# Return cached config if still valid
if _CONFIG_CACHE is not None:
return _CONFIG_CACHE
@@ -722,6 +745,8 @@ class ConfigManager:
merged_data[field_name] = env_dict[field_name]
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
# Record mtime so we can detect future out-of-process changes.
_CONFIG_CACHE_MTIME = self.config_file.stat().st_mtime
# Re-save to normalize legacy config into current format
if needs_resave:
@@ -753,10 +778,13 @@ class ConfigManager:
def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file and invalidate cache."""
global _CONFIG_CACHE
global _CONFIG_CACHE, _CONFIG_CACHE_MTIME
save_basic_memory_config(self.config_file, config)
# Invalidate cache so next load_config() reads fresh data
# Invalidate cache so next load_config() reads fresh data.
# Also reset mtime so the in-process re-read after save picks up the
# new file rather than seeing the newly-written mtime as "unchanged".
_CONFIG_CACHE = None
_CONFIG_CACHE_MTIME = None
@property
def projects(self) -> Dict[str, str]:
+139
View File
@@ -1237,3 +1237,142 @@ class TestLocalSyncPathMigration:
assert result["projects"]["local-proj"]["path"] == local_path
assert result["projects"]["cloud-only"]["path"] == "cloud-only"
assert result["projects"]["cloud-bisync"]["path"] == bisync_path
class TestConfigCacheMtimeInvalidation:
"""Test that the config cache is invalidated when the file changes on disk.
This covers the MCP stdio server scenario where a long-lived process must
pick up `bm project set-cloud` / `set-local` changes made by a separate
CLI process without requiring a server restart.
"""
@pytest.fixture(autouse=True)
def reset_cache(self):
"""Ensure the module-level cache is clean before and after each test."""
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_CACHE_MTIME = None
yield
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_CACHE_MTIME = None
def _make_manager(self, tmp_path):
"""Create a ConfigManager pointing at a temp directory."""
manager = ConfigManager()
manager.config_dir = tmp_path / ".basic-memory"
manager.config_file = manager.config_dir / "config.json"
manager.config_dir.mkdir(parents=True, exist_ok=True)
return manager
def test_cache_is_populated_after_first_load(self, tmp_path, monkeypatch):
"""First load should populate the cache and record the file mtime."""
import basic_memory.config
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
manager = self._make_manager(tmp_path)
project_path = tmp_path / "main"
import json
manager.config_file.write_text(
json.dumps({"projects": {"main": {"path": str(project_path)}}})
)
manager.load_config()
assert basic_memory.config._CONFIG_CACHE is not None
assert basic_memory.config._CONFIG_CACHE_MTIME == manager.config_file.stat().st_mtime
def test_unchanged_file_returns_same_cached_object(self, tmp_path, monkeypatch):
"""Repeated loads with no file change should return the identical cached object."""
import json
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
manager = self._make_manager(tmp_path)
project_path = tmp_path / "main"
manager.config_file.write_text(
json.dumps({"projects": {"main": {"path": str(project_path)}}})
)
first = manager.load_config()
second = manager.load_config()
assert first is second
def test_modified_file_invalidates_cache(self, tmp_path, monkeypatch):
"""When the config file mtime changes, load_config() must reload from disk.
This is the core scenario: a CLI command (set-cloud / set-local) runs in
a separate process, writes a new config.json, and the long-lived MCP
stdio server should detect the mtime change and return updated routing.
"""
import json
import os
import basic_memory.config
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
manager = self._make_manager(tmp_path)
project_path = tmp_path / "main"
# --- Initial state: project is LOCAL ---
manager.config_file.write_text(
json.dumps(
{
"projects": {
"main": {"path": str(project_path), "mode": "local"},
}
}
)
)
first = manager.load_config()
assert first.get_project_mode("main") == ProjectMode.LOCAL
# --- Simulate external process writing updated config (set-cloud) ---
# Advance mtime by 1 second so the stat() check detects the change even
# on filesystems with 1-second mtime resolution.
manager.config_file.write_text(
json.dumps(
{
"projects": {
"main": {"path": str(project_path), "mode": "cloud"},
}
}
)
)
current_mtime = manager.config_file.stat().st_mtime
os.utime(manager.config_file, (current_mtime + 1.0, current_mtime + 1.0))
# Re-load — must detect the mtime change and reload from disk
second = manager.load_config()
assert second is not first
assert second.get_project_mode("main") == ProjectMode.CLOUD
assert basic_memory.config._CONFIG_CACHE_MTIME == manager.config_file.stat().st_mtime
def test_save_config_resets_mtime_cache(self, tmp_path, monkeypatch):
"""save_config() must clear both _CONFIG_CACHE and _CONFIG_CACHE_MTIME."""
import json
import basic_memory.config
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
manager = self._make_manager(tmp_path)
project_path = tmp_path / "main"
manager.config_file.write_text(
json.dumps({"projects": {"main": {"path": str(project_path)}}})
)
manager.load_config()
# Both should be populated after a load
assert basic_memory.config._CONFIG_CACHE is not None
assert basic_memory.config._CONFIG_CACHE_MTIME is not None
# save_config() should clear both
config = manager.load_config()
manager.save_config(config)
assert basic_memory.config._CONFIG_CACHE is None
assert basic_memory.config._CONFIG_CACHE_MTIME is None