fix(sync): constrain watch service to --project scope (#759)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-04-20 13:46:13 -05:00
committed by GitHub
parent f3e46d7984
commit 2fe4488eda
5 changed files with 233 additions and 23 deletions
+11 -3
View File
@@ -99,18 +99,23 @@ async def initialize_file_sync(
)
project_repository = ProjectRepository(session_maker)
# Filter to constrained project if MCP server was started with --project.
# Applied to both the initial background sync and the watch service so that
# running multiple `basic-memory mcp --project X` processes does not produce
# duplicate watchers fighting over the same files.
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
# Initialize watch service
watch_service = WatchService(
app_config=app_config,
project_repository=project_repository,
quiet=quiet,
constrained_project=constrained_project,
)
# Get active projects
active_projects = await project_repository.get_active_projects()
# Filter to constrained project if MCP server was started with --project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
active_projects = [p for p in active_projects if p.name == constrained_project]
logger.info(f"Background sync constrained to project: {constrained_project}")
@@ -154,7 +159,10 @@ async def initialize_file_sync(
# Don't await the tasks - let them run in background while we continue
# Then start the watch service in the background
logger.info("Starting watch service for all projects")
if constrained_project:
logger.info(f"Starting watch service constrained to project: {constrained_project}")
else:
logger.info("Starting watch service for all projects")
# run the watch service
await watch_service.run()
+50 -16
View File
@@ -85,6 +85,7 @@ class WatchService:
project_repository: ProjectRepository,
quiet: bool = False,
sync_service_factory: Optional[SyncServiceFactory] = None,
constrained_project: Optional[str] = None,
):
self.app_config = app_config
self.project_repository = project_repository
@@ -93,6 +94,11 @@ class WatchService:
self.status_path.parent.mkdir(parents=True, exist_ok=True)
self._ignore_patterns_cache: dict[Path, Set[str]] = {}
self._sync_service_factory = sync_service_factory
# When set (typically from BASIC_MEMORY_MCP_PROJECT), the watch cycle
# only observes this project. Without it, each `basic-memory mcp --project X`
# process spawns a watcher over every project and racing writers collide
# on the same files.
self.constrained_project = constrained_project
# quiet mode for mcp so it doesn't mess up stdout
self.console = Console(quiet=quiet)
@@ -156,6 +162,35 @@ class WatchService:
# process changes
await asyncio.gather(*change_handlers)
async def _select_projects_to_watch(self) -> list[Project]:
"""Return the set of projects this watch cycle should observe.
Applies two filters in order:
1. ``constrained_project`` — if the MCP server was started with
``--project``, only that project is watched. This keeps concurrent
MCP processes from producing duplicate watchers that race on the
same files.
2. Cloud-only projects without a local bisync copy are skipped so we
don't watch a path that does not exist on disk.
"""
projects = await self.project_repository.get_active_projects()
if self.constrained_project:
projects = [p for p in projects if p.name == self.constrained_project]
cloud_skip: list[str] = []
for p in projects:
if self.app_config.get_project_mode(p.name) == ProjectMode.CLOUD:
entry = self.app_config.projects.get(p.name)
if entry and Path(entry.path).is_absolute():
continue # Cloud project with local bisync copy — keep watching
cloud_skip.append(p.name)
if cloud_skip:
projects = [p for p in projects if p.name not in cloud_skip]
logger.debug(f"Skipping cloud-mode projects in watch cycle: {cloud_skip}")
return list(projects)
async def run(self): # pragma: no cover
"""Watch for file changes and sync them"""
@@ -174,23 +209,22 @@ class WatchService:
# Clear ignore patterns cache to pick up any .gitignore changes
self._ignore_patterns_cache.clear()
# Reload projects to catch any new/removed projects
projects = await self.project_repository.get_active_projects()
projects = await self._select_projects_to_watch()
# Trigger: project is configured for cloud routing
# Why: cloud-only projects (no local directory) should not be watched;
# cloud projects with a local bisync copy (absolute path) need watching
# Outcome: watch cycle skips cloud projects without a local directory
cloud_skip = []
for p in projects:
if self.app_config.get_project_mode(p.name) == ProjectMode.CLOUD:
entry = self.app_config.projects.get(p.name)
if entry and Path(entry.path).is_absolute():
continue # Cloud project with local bisync copy — keep watching
cloud_skip.append(p.name)
if cloud_skip:
projects = [p for p in projects if p.name not in cloud_skip]
logger.debug(f"Skipping cloud-mode projects in watch cycle: {cloud_skip}")
# Trigger: no projects selected (e.g. constrained_project names a
# project not in the DB, or every project was filtered out)
# Why: watchfiles.awatch() requires at least one path. Calling it
# with an empty list raises ValueError, which the outer handler
# catches with a 5s sleep — producing a tight error-log loop.
# Outcome: sleep the configured reload interval before retrying, so
# newly added projects get picked up on the next cycle.
if not projects:
logger.warning(
"No projects to watch; sleeping before retry "
f"(constrained_project={self.constrained_project!r})"
)
await asyncio.sleep(self.app_config.watch_project_reload_interval)
continue
project_paths = [project.path for project in projects]
logger.debug(f"Starting watch cycle for directories: {project_paths}")