mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
e6d6094e7f
When multiple `basic-memory mcp --project <name>` processes run concurrently,
each process previously spawned a file watcher over every active project instead
of just the constrained one. This caused N overlapping watchers racing on the same
files, producing cascade-delete crashes, atomic-rename collisions, and phantom
"entity not found" errors.
Changes:
- WatchService now accepts `constrained_project` and filters the per-cycle
project reload to that value when set, so N concurrent MCP processes produce
N disjoint watchers instead of N overlapping ones
- initialize_file_sync reads BASIC_MEMORY_MCP_PROJECT once and wires it to both
the one-shot background sync filter and the new WatchService kwarg
- Relation.permalink guards from_entity for None (same pattern as to_entity) to
defend against the AttributeError that surfaces when a concurrent watcher
cascade-deletes an entity while another process is resolving relation permalinks
- write_file_atomic uses a PID-unique temp suffix ({stem}.{pid}.tmp) so
concurrent processes writing the same file no longer collide on the temp name
Fixes #758
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
71 lines
2.2 KiB
Python
71 lines
2.2 KiB
Python
"""Unit tests for knowledge model properties."""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from basic_memory.models.knowledge import Relation
|
|
|
|
|
|
def _entity(permalink: str | None, file_path: str) -> SimpleNamespace:
|
|
return SimpleNamespace(permalink=permalink, file_path=file_path)
|
|
|
|
|
|
def _relation(**kwargs) -> Relation:
|
|
"""Build a Relation with required fields; relationship attrs set to None by default."""
|
|
defaults = dict(
|
|
id=1,
|
|
project_id=1,
|
|
from_id=42,
|
|
to_id=None,
|
|
to_name="Target",
|
|
relation_type="implements",
|
|
context=None,
|
|
)
|
|
defaults.update(kwargs)
|
|
r = Relation(**defaults)
|
|
# Simulate unloaded lazy relationships (no active session)
|
|
r.from_entity = None
|
|
r.to_entity = None
|
|
return r
|
|
|
|
|
|
def test_relation_permalink_with_both_entities():
|
|
"""Normal case: both from_entity and to_entity are loaded."""
|
|
r = _relation()
|
|
r.from_entity = _entity(permalink="specs/source", file_path="specs/source.md")
|
|
r.to_entity = _entity(permalink="features/target", file_path="features/target.md")
|
|
|
|
assert r.permalink == "specs/source/implements/features/target"
|
|
|
|
|
|
def test_relation_permalink_no_to_entity():
|
|
"""to_entity is None — falls back to to_name."""
|
|
r = _relation()
|
|
r.from_entity = _entity(permalink="specs/source", file_path="specs/source.md")
|
|
|
|
assert r.permalink == "specs/source/implements/target"
|
|
|
|
|
|
def test_relation_permalink_from_entity_none():
|
|
"""from_entity is None (race condition: entity deleted mid-resolution).
|
|
|
|
Should fall back to str(from_id) rather than raising AttributeError.
|
|
This is the guard added to fix the concurrent-watcher crash described in issue #758.
|
|
"""
|
|
r = _relation(from_id=42)
|
|
# from_entity stays None
|
|
|
|
result = r.permalink
|
|
assert "42" in result
|
|
assert "implements" in result
|
|
|
|
|
|
def test_relation_permalink_from_entity_none_with_to_entity():
|
|
"""from_entity is None but to_entity is loaded — both guards compose correctly."""
|
|
r = _relation(from_id=7)
|
|
r.to_entity = _entity(permalink="features/resolved", file_path="features/resolved.md")
|
|
|
|
result = r.permalink
|
|
assert "7" in result
|
|
assert "implements" in result
|
|
assert "features/resolved" in result
|