@@ -85,6 +85,7 @@ def __init__(
8585 project_repository : ProjectRepository ,
8686 quiet : bool = False ,
8787 sync_service_factory : Optional [SyncServiceFactory ] = None ,
88+ constrained_project : Optional [str ] = None ,
8889 ):
8990 self .app_config = app_config
9091 self .project_repository = project_repository
@@ -93,6 +94,11 @@ def __init__(
9394 self .status_path .parent .mkdir (parents = True , exist_ok = True )
9495 self ._ignore_patterns_cache : dict [Path , Set [str ]] = {}
9596 self ._sync_service_factory = sync_service_factory
97+ # When set (typically from BASIC_MEMORY_MCP_PROJECT), the watch cycle
98+ # only observes this project. Without it, each `basic-memory mcp --project X`
99+ # process spawns a watcher over every project and racing writers collide
100+ # on the same files.
101+ self .constrained_project = constrained_project
96102
97103 # quiet mode for mcp so it doesn't mess up stdout
98104 self .console = Console (quiet = quiet )
@@ -156,6 +162,35 @@ async def _watch_projects_cycle(self, projects: Sequence[Project], stop_event: a
156162 # process changes
157163 await asyncio .gather (* change_handlers )
158164
165+ async def _select_projects_to_watch (self ) -> list [Project ]:
166+ """Return the set of projects this watch cycle should observe.
167+
168+ Applies two filters in order:
169+ 1. ``constrained_project`` — if the MCP server was started with
170+ ``--project``, only that project is watched. This keeps concurrent
171+ MCP processes from producing duplicate watchers that race on the
172+ same files.
173+ 2. Cloud-only projects without a local bisync copy are skipped so we
174+ don't watch a path that does not exist on disk.
175+ """
176+ projects = await self .project_repository .get_active_projects ()
177+
178+ if self .constrained_project :
179+ projects = [p for p in projects if p .name == self .constrained_project ]
180+
181+ cloud_skip : list [str ] = []
182+ for p in projects :
183+ if self .app_config .get_project_mode (p .name ) == ProjectMode .CLOUD :
184+ entry = self .app_config .projects .get (p .name )
185+ if entry and Path (entry .path ).is_absolute ():
186+ continue # Cloud project with local bisync copy — keep watching
187+ cloud_skip .append (p .name )
188+ if cloud_skip :
189+ projects = [p for p in projects if p .name not in cloud_skip ]
190+ logger .debug (f"Skipping cloud-mode projects in watch cycle: { cloud_skip } " )
191+
192+ return list (projects )
193+
159194 async def run (self ): # pragma: no cover
160195 """Watch for file changes and sync them"""
161196
@@ -174,23 +209,22 @@ async def run(self): # pragma: no cover
174209 # Clear ignore patterns cache to pick up any .gitignore changes
175210 self ._ignore_patterns_cache .clear ()
176211
177- # Reload projects to catch any new/removed projects
178- projects = await self .project_repository .get_active_projects ()
179-
180- # Trigger: project is configured for cloud routing
181- # Why: cloud-only projects (no local directory) should not be watched;
182- # cloud projects with a local bisync copy (absolute path) need watching
183- # Outcome: watch cycle skips cloud projects without a local directory
184- cloud_skip = []
185- for p in projects :
186- if self .app_config .get_project_mode (p .name ) == ProjectMode .CLOUD :
187- entry = self .app_config .projects .get (p .name )
188- if entry and Path (entry .path ).is_absolute ():
189- continue # Cloud project with local bisync copy — keep watching
190- cloud_skip .append (p .name )
191- if cloud_skip :
192- projects = [p for p in projects if p .name not in cloud_skip ]
193- logger .debug (f"Skipping cloud-mode projects in watch cycle: { cloud_skip } " )
212+ projects = await self ._select_projects_to_watch ()
213+
214+ # Trigger: no projects selected (e.g. constrained_project names a
215+ # project not in the DB, or every project was filtered out)
216+ # Why: watchfiles.awatch() requires at least one path. Calling it
217+ # with an empty list raises ValueError, which the outer handler
218+ # catches with a 5s sleep — producing a tight error-log loop.
219+ # Outcome: sleep the configured reload interval before retrying, so
220+ # newly added projects get picked up on the next cycle.
221+ if not projects :
222+ logger .warning (
223+ "No projects to watch; sleeping before retry "
224+ f"(constrained_project={ self .constrained_project !r} )"
225+ )
226+ await asyncio .sleep (self .app_config .watch_project_reload_interval )
227+ continue
194228
195229 project_paths = [project .path for project in projects ]
196230 logger .debug (f"Starting watch cycle for directories: { project_paths } " )
0 commit comments