Skip to content

Commit 49897a6

Browse files
groksrcclaude
andcommitted
Invalidate config cache when file is modified by another process
Add mtime-based cache validation so long-lived processes (like the MCP stdio server) detect when the config file has been modified externally (e.g. by `bm project set-cloud` in a separate terminal). This is a cheap os.stat() call per config access that only triggers a re-read when the file mtime has actually changed. Fixes #660 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 6e4bb72 commit 49897a6

File tree

11 files changed

+171
-5
lines changed

11 files changed

+171
-5
lines changed

src/basic_memory/config.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,10 @@ def data_dir_path(self) -> Path:
629629

630630
# Module-level cache for configuration
631631
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
632+
# Track config file mtime so cross-process changes (e.g. `bm project set-cloud`
633+
# in a separate terminal) invalidate the cache in long-lived processes like the
634+
# MCP stdio server.
635+
_CONFIG_MTIME: Optional[float] = None
632636

633637

634638
class ConfigManager:
@@ -662,13 +666,30 @@ def load_config(self) -> BasicMemoryConfig:
662666
Environment variables take precedence over file config values,
663667
following Pydantic Settings best practices.
664668
665-
Uses module-level cache for performance across ConfigManager instances.
669+
Uses module-level cache with file mtime validation so that
670+
cross-process config changes (e.g. `bm project set-cloud` in a
671+
separate terminal) are picked up by long-lived processes like
672+
the MCP stdio server.
666673
"""
667-
global _CONFIG_CACHE
674+
global _CONFIG_CACHE, _CONFIG_MTIME
668675

669-
# Return cached config if available
676+
# Trigger: cached config exists but the on-disk file may have been
677+
# modified by another process (CLI command in a different terminal).
678+
# Why: the MCP server is long-lived; without this check it would
679+
# serve stale project routing forever.
680+
# Outcome: cheap os.stat() per access; re-read only when mtime differs.
670681
if _CONFIG_CACHE is not None:
671-
return _CONFIG_CACHE
682+
try:
683+
current_mtime = self.config_file.stat().st_mtime
684+
except OSError:
685+
current_mtime = None
686+
687+
if current_mtime is not None and current_mtime == _CONFIG_MTIME:
688+
return _CONFIG_CACHE
689+
690+
# mtime changed or file gone — invalidate and fall through to re-read
691+
_CONFIG_CACHE = None
692+
_CONFIG_MTIME = None
672693

673694
if self.config_file.exists():
674695
try:
@@ -723,6 +744,12 @@ def load_config(self) -> BasicMemoryConfig:
723744

724745
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
725746

747+
# Record mtime so subsequent calls detect cross-process changes
748+
try:
749+
_CONFIG_MTIME = self.config_file.stat().st_mtime
750+
except OSError:
751+
_CONFIG_MTIME = None
752+
726753
# Re-save to normalize legacy config into current format
727754
if needs_resave:
728755
# Create backup before overwriting so users can revert if needed
@@ -753,10 +780,11 @@ def load_config(self) -> BasicMemoryConfig:
753780

754781
def save_config(self, config: BasicMemoryConfig) -> None:
755782
"""Save configuration to file and invalidate cache."""
756-
global _CONFIG_CACHE
783+
global _CONFIG_CACHE, _CONFIG_MTIME
757784
save_basic_memory_config(self.config_file, config)
758785
# Invalidate cache so next load_config() reads fresh data
759786
_CONFIG_CACHE = None
787+
_CONFIG_MTIME = None
760788

761789
@property
762790
def projects(self) -> Dict[str, str]:

test-int/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
258258
from basic_memory import config as config_module
259259

260260
config_module._CONFIG_CACHE = None
261+
config_module._CONFIG_MTIME = None
261262

262263
config_manager = ConfigManager()
263264
# Update its paths to use the test directory

tests/cli/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
2525
from basic_memory import config as config_module
2626

2727
config_module._CONFIG_CACHE = None
28+
config_module._CONFIG_MTIME = None
2829

2930
monkeypatch.setenv("HOME", str(tmp_path))
3031
if os.name == "nt":

tests/cli/test_json_output.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ def _write(config_data: dict):
350350
from basic_memory import config as config_module
351351

352352
config_module._CONFIG_CACHE = None
353+
config_module._CONFIG_MTIME = None
353354

354355
config_dir = tmp_path / ".basic-memory"
355356
config_dir.mkdir(parents=True, exist_ok=True)

tests/cli/test_project_add_with_local_path.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def mock_config(tmp_path, monkeypatch):
2727
from basic_memory import config as config_module
2828

2929
config_module._CONFIG_CACHE = None
30+
config_module._CONFIG_MTIME = None
3031

3132
config_dir = tmp_path / ".basic-memory"
3233
config_dir.mkdir(parents=True, exist_ok=True)

tests/cli/test_project_list_and_ls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def _write(config_data: dict) -> Path:
2929
from basic_memory import config as config_module
3030

3131
config_module._CONFIG_CACHE = None
32+
config_module._CONFIG_MTIME = None
3233

3334
config_dir = tmp_path / ".basic-memory"
3435
config_dir.mkdir(parents=True, exist_ok=True)

tests/cli/test_project_set_cloud_local.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def mock_config(tmp_path, monkeypatch):
2222
from basic_memory import config as config_module
2323

2424
config_module._CONFIG_CACHE = None
25+
config_module._CONFIG_MTIME = None
2526

2627
config_dir = tmp_path / ".basic-memory"
2728
config_dir.mkdir(parents=True, exist_ok=True)
@@ -68,6 +69,7 @@ def test_set_cloud_no_credentials(self, runner, tmp_path, monkeypatch):
6869
from basic_memory import config as config_module
6970

7071
config_module._CONFIG_CACHE = None
72+
config_module._CONFIG_MTIME = None
7173

7274
config_dir = tmp_path / ".basic-memory"
7375
config_dir.mkdir(parents=True, exist_ok=True)
@@ -91,6 +93,7 @@ def test_set_cloud_with_oauth_session(self, runner, tmp_path, monkeypatch):
9193
from basic_memory import config as config_module
9294

9395
config_module._CONFIG_CACHE = None
96+
config_module._CONFIG_MTIME = None
9497

9598
config_dir = tmp_path / ".basic-memory"
9699
config_dir.mkdir(parents=True, exist_ok=True)
@@ -161,18 +164,21 @@ def test_set_local_clears_workspace_id(self, runner, mock_config):
161164

162165
# Manually set workspace_id on the project
163166
config_module._CONFIG_CACHE = None
167+
config_module._CONFIG_MTIME = None
164168
config_data = json.loads(mock_config.read_text())
165169
config_data["projects"]["research"]["mode"] = "cloud"
166170
config_data["projects"]["research"]["workspace_id"] = "11111111-1111-1111-1111-111111111111"
167171
mock_config.write_text(json.dumps(config_data, indent=2))
168172
config_module._CONFIG_CACHE = None
173+
config_module._CONFIG_MTIME = None
169174

170175
# Set back to local
171176
result = runner.invoke(app, ["project", "set-local", "research"])
172177
assert result.exit_code == 0
173178

174179
# Verify workspace_id was cleared
175180
config_module._CONFIG_CACHE = None
181+
config_module._CONFIG_MTIME = None
176182
updated_data = json.loads(mock_config.read_text())
177183
assert updated_data["projects"]["research"]["workspace_id"] is None
178184
assert updated_data["projects"]["research"]["mode"] == "local"
@@ -187,6 +193,7 @@ def test_set_cloud_with_workspace_stores_workspace_id(self, runner, mock_config,
187193
from basic_memory.schemas.cloud import WorkspaceInfo
188194

189195
config_module._CONFIG_CACHE = None
196+
config_module._CONFIG_MTIME = None
190197

191198
async def fake_get_available_workspaces():
192199
return [
@@ -210,6 +217,7 @@ async def fake_get_available_workspaces():
210217

211218
# Verify workspace_id was persisted
212219
config_module._CONFIG_CACHE = None
220+
config_module._CONFIG_MTIME = None
213221
updated_data = json.loads(mock_config.read_text())
214222
assert (
215223
updated_data["projects"]["research"]["workspace_id"]
@@ -222,6 +230,7 @@ def test_set_cloud_with_workspace_not_found(self, runner, mock_config, monkeypat
222230
from basic_memory.schemas.cloud import WorkspaceInfo
223231

224232
config_module._CONFIG_CACHE = None
233+
config_module._CONFIG_MTIME = None
225234

226235
async def fake_get_available_workspaces():
227236
return [
@@ -249,17 +258,20 @@ def test_set_cloud_uses_default_workspace_when_no_flag(self, runner, mock_config
249258
from basic_memory import config as config_module
250259

251260
config_module._CONFIG_CACHE = None
261+
config_module._CONFIG_MTIME = None
252262

253263
# Set default_workspace in config
254264
config_data = json.loads(mock_config.read_text())
255265
config_data["default_workspace"] = "global-default-tenant-id"
256266
mock_config.write_text(json.dumps(config_data, indent=2))
257267
config_module._CONFIG_CACHE = None
268+
config_module._CONFIG_MTIME = None
258269

259270
result = runner.invoke(app, ["project", "set-cloud", "research"])
260271
assert result.exit_code == 0
261272

262273
# Verify workspace_id was set from default
263274
config_module._CONFIG_CACHE = None
275+
config_module._CONFIG_MTIME = None
264276
updated_data = json.loads(mock_config.read_text())
265277
assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id"

tests/cli/test_workspace_commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def _setup_config(self, monkeypatch):
7676
monkeypatch.setenv("HOME", str(temp_path))
7777
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
7878
basic_memory.config._CONFIG_CACHE = None
79+
basic_memory.config._CONFIG_MTIME = None
7980

8081
config_manager = ConfigManager()
8182
test_config = BasicMemoryConfig(
@@ -106,6 +107,7 @@ async def fake_get_available_workspaces(context=None):
106107

107108
# Verify config was updated
108109
basic_memory.config._CONFIG_CACHE = None
110+
basic_memory.config._CONFIG_MTIME = None
109111
config = ConfigManager().config
110112
assert config.default_workspace == "11111111-1111-1111-1111-111111111111"
111113

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch
138138
from basic_memory import config as config_module
139139

140140
config_module._CONFIG_CACHE = None
141+
config_module._CONFIG_MTIME = None
141142

142143
# Create a new ConfigManager that uses the test home directory
143144
config_manager = ConfigManager()

tests/services/test_project_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ async def test_add_project_with_project_root_sanitizes_paths(
778778
from basic_memory import config as config_module
779779

780780
config_module._CONFIG_CACHE = None
781+
config_module._CONFIG_MTIME = None
781782

782783
test_cases = [
783784
# (project_name, user_path, expected_sanitized_name)
@@ -845,6 +846,7 @@ async def test_add_project_with_project_root_rejects_escape_attempts(
845846
from basic_memory import config as config_module
846847

847848
config_module._CONFIG_CACHE = None
849+
config_module._CONFIG_MTIME = None
848850

849851
# All of these should succeed by being sanitized to paths under project_root
850852
# The sanitization removes dangerous patterns, so they don't escape
@@ -931,6 +933,7 @@ async def test_add_project_with_project_root_normalizes_case(
931933
from basic_memory import config as config_module
932934

933935
config_module._CONFIG_CACHE = None
936+
config_module._CONFIG_MTIME = None
934937

935938
test_cases = [
936939
# (input_path, expected_normalized_path)
@@ -985,6 +988,7 @@ async def test_add_project_with_project_root_detects_case_collisions(
985988
from basic_memory import config as config_module
986989

987990
config_module._CONFIG_CACHE = None
991+
config_module._CONFIG_MTIME = None
988992

989993
# First, create a project with lowercase path
990994
first_project = "documents-project"
@@ -1159,6 +1163,7 @@ async def test_add_project_nested_validation_with_project_root(
11591163
from basic_memory import config as config_module
11601164

11611165
config_module._CONFIG_CACHE = None
1166+
config_module._CONFIG_MTIME = None
11621167

11631168
parent_project_name = f"cloud-parent-{os.urandom(4).hex()}"
11641169
child_project_name = f"cloud-child-{os.urandom(4).hex()}"

0 commit comments

Comments
 (0)