Skip to content

Commit cea2410

Browse files
phernandezclaude
andcommitted
fix: restart watch service when project configuration changes
Resolves issue where new projects created via MCP tools weren't being watched by the file synchronization service. The watch service now automatically restarts when projects are added, removed, or when the project configuration changes. Implementation: - Add file-based signaling mechanism for watch service restarts - ProjectService signals restart when projects are added/removed - WatchService checks for restart signal on each file change batch - Watch loop recreates service instances to pick up new projects - Add comprehensive tests for the restart signal mechanism Note: New projects will only be picked up for watching after the next file change occurs in any existing project, as the restart signal is checked during file change processing. Fixes #156 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1bf3482 commit cea2410

5 files changed

Lines changed: 216 additions & 14 deletions

File tree

src/basic_memory/services/initialization.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,6 @@ async def initialize_file_sync(
150150
)
151151
project_repository = ProjectRepository(session_maker)
152152

153-
# Initialize watch service
154-
watch_service = WatchService(
155-
app_config=app_config,
156-
project_repository=project_repository,
157-
quiet=True,
158-
)
159-
160153
# Get active projects
161154
active_projects = await project_repository.get_active_projects()
162155

@@ -196,14 +189,26 @@ async def initialize_file_sync(
196189
except Exception as e: # pragma: no cover
197190
logger.warning(f"Could not update migration status: {e}")
198191

199-
# Then start the watch service in the background
192+
# Then start the watch service in the background with restart capability
200193
logger.info("Starting watch service for all projects")
201-
# run the watch service
202-
try:
203-
await watch_service.run()
204-
logger.info("Watch service started")
205-
except Exception as e: # pragma: no cover
206-
logger.error(f"Error starting watch service: {e}")
194+
195+
while True:
196+
try:
197+
# Create a fresh watch service instance to pick up new projects
198+
watch_service = WatchService(
199+
app_config=app_config,
200+
project_repository=project_repository,
201+
quiet=True,
202+
)
203+
204+
await watch_service.run()
205+
logger.info("Watch service exited normally")
206+
break # Normal exit
207+
208+
except Exception as e: # pragma: no cover
209+
logger.error(f"Error in watch service: {e}")
210+
# Don't restart on unexpected errors
211+
break
207212

208213
return None
209214

src/basic_memory/services/project_service.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
108108

109109
logger.info(f"Project '{name}' added at {resolved_path}")
110110

111+
# Signal watch service restart since project list changed
112+
self._signal_watch_service_restart()
113+
111114
async def remove_project(self, name: str) -> None:
112115
"""Remove a project from configuration and database.
113116
@@ -130,6 +133,9 @@ async def remove_project(self, name: str) -> None:
130133

131134
logger.info(f"Project '{name}' removed from configuration and database")
132135

136+
# Signal watch service restart since project list changed
137+
self._signal_watch_service_restart()
138+
133139
async def set_default_project(self, name: str) -> None:
134140
"""Set the default project in configuration and database.
135141
@@ -283,6 +289,14 @@ async def synchronize_projects(self) -> None: # pragma: no cover
283289

284290
logger.info("Project synchronization complete")
285291

292+
# Signal watch service restart if projects changed
293+
projects_changed = len(config_projects) != len(db_projects_by_permalink) or set(
294+
config_projects.keys()
295+
) != set(db_projects_by_permalink.keys())
296+
297+
if projects_changed:
298+
self._signal_watch_service_restart()
299+
286300
# Refresh MCP session to ensure it's in sync with current config
287301
try:
288302
from basic_memory.mcp.project_session import session
@@ -292,6 +306,16 @@ async def synchronize_projects(self) -> None: # pragma: no cover
292306
# MCP components might not be available in all contexts
293307
logger.debug("MCP session not available, skipping session refresh")
294308

309+
def _signal_watch_service_restart(self) -> None:
310+
"""Signal the watch service to restart by creating a restart signal file."""
311+
restart_signal_path = Path.home() / ".basic-memory" / "restart-watch-service"
312+
try:
313+
restart_signal_path.parent.mkdir(parents=True, exist_ok=True)
314+
restart_signal_path.write_text(str(datetime.now()))
315+
logger.info("Signaled watch service restart")
316+
except Exception as e:
317+
logger.warning(f"Failed to signal watch service restart: {e}")
318+
295319
async def update_project( # pragma: no cover
296320
self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
297321
) -> None:

src/basic_memory/sync/watch_service.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ def __init__(
8585
# quiet mode for mcp so it doesn't mess up stdout
8686
self.console = Console(quiet=quiet)
8787

88+
# Restart signal file path
89+
self.restart_signal_path = Path.home() / ".basic-memory" / "restart-watch-service"
90+
8891
async def run(self): # pragma: no cover
8992
"""Watch for file changes and sync them"""
9093

@@ -109,6 +112,11 @@ async def run(self): # pragma: no cover
109112
watch_filter=self.filter_changes,
110113
recursive=True,
111114
):
115+
# Check for restart signal
116+
if await self.check_restart_signal():
117+
logger.info("Restart signal detected, exiting watch service...")
118+
return
119+
112120
# group changes by project
113121
project_changes = defaultdict(list)
114122
for change, path in changes:
@@ -161,6 +169,18 @@ def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
161169

162170
return True
163171

172+
async def check_restart_signal(self) -> bool:
173+
"""Check if a restart signal file exists and remove it if found."""
174+
try:
175+
if self.restart_signal_path.exists():
176+
# Remove the signal file
177+
self.restart_signal_path.unlink()
178+
logger.info("Found and removed restart signal file")
179+
return True
180+
except Exception as e:
181+
logger.warning(f"Error checking restart signal: {e}")
182+
return False
183+
164184
async def write_status(self):
165185
"""Write current state to status file"""
166186
self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))

tests/services/test_project_service.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,3 +601,96 @@ async def test_synchronize_projects_handles_case_sensitivity_bug(
601601
db_project = await project_service.repository.get_by_name(name)
602602
if db_project:
603603
await project_service.repository.delete(db_project.id)
604+
605+
606+
@pytest.mark.asyncio
607+
async def test_signal_watch_service_restart(project_service: ProjectService, tmp_path):
608+
"""Test that _signal_watch_service_restart creates the correct signal file."""
609+
# Call the signal method
610+
project_service._signal_watch_service_restart()
611+
612+
# Check that the signal file was created
613+
restart_signal_path = tmp_path.parent / ".basic-memory" / "restart-watch-service"
614+
# Since it uses Path.home(), we need to check the actual location
615+
from pathlib import Path
616+
617+
actual_signal_path = Path.home() / ".basic-memory" / "restart-watch-service"
618+
619+
assert actual_signal_path.exists()
620+
621+
# Verify it contains a timestamp
622+
content = actual_signal_path.read_text()
623+
assert content # Should have some content (timestamp)
624+
625+
# Clean up
626+
actual_signal_path.unlink()
627+
628+
629+
@pytest.mark.asyncio
630+
async def test_add_project_signals_restart(project_service: ProjectService, tmp_path):
631+
"""Test that adding a project signals watch service restart."""
632+
test_project_name = f"test-signal-restart-{os.urandom(4).hex()}"
633+
test_project_path = str(tmp_path / "test-signal-restart")
634+
635+
# Make sure the test directory exists
636+
os.makedirs(test_project_path, exist_ok=True)
637+
638+
# Remove any existing signal file
639+
from pathlib import Path
640+
641+
signal_path = Path.home() / ".basic-memory" / "restart-watch-service"
642+
if signal_path.exists():
643+
signal_path.unlink()
644+
645+
try:
646+
# Add the project - this should create the signal file
647+
await project_service.add_project(test_project_name, test_project_path)
648+
649+
# Verify signal file was created
650+
assert signal_path.exists()
651+
652+
finally:
653+
# Clean up
654+
if signal_path.exists():
655+
signal_path.unlink()
656+
if test_project_name in project_service.projects:
657+
await project_service.remove_project(test_project_name)
658+
659+
660+
@pytest.mark.asyncio
661+
async def test_remove_project_signals_restart(project_service: ProjectService, tmp_path):
662+
"""Test that removing a project signals watch service restart."""
663+
test_project_name = f"test-remove-signal-{os.urandom(4).hex()}"
664+
test_project_path = str(tmp_path / "test-remove-signal")
665+
666+
# Make sure the test directory exists
667+
os.makedirs(test_project_path, exist_ok=True)
668+
669+
from pathlib import Path
670+
671+
signal_path = Path.home() / ".basic-memory" / "restart-watch-service"
672+
673+
try:
674+
# Add the project first
675+
await project_service.add_project(test_project_name, test_project_path)
676+
677+
# Remove any existing signal file (from the add)
678+
if signal_path.exists():
679+
signal_path.unlink()
680+
681+
# Remove the project - this should create the signal file
682+
await project_service.remove_project(test_project_name)
683+
684+
# Verify signal file was created
685+
assert signal_path.exists()
686+
687+
finally:
688+
# Clean up
689+
if signal_path.exists():
690+
signal_path.unlink()
691+
# Project should already be removed, but double-check
692+
if test_project_name in project_service.projects:
693+
try:
694+
await project_service.remove_project(test_project_name)
695+
except:
696+
pass

tests/sync/test_watch_service.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,63 @@ def test_is_project_path(watch_service, tmp_path):
448448

449449
# Test the project path itself
450450
assert watch_service.is_project_path(project, project_path) is False
451+
452+
453+
@pytest.mark.asyncio
454+
async def test_check_restart_signal_no_signal(app_config, project_repository):
455+
"""Test check_restart_signal when no signal file exists."""
456+
watch_service = WatchService(app_config, project_repository)
457+
458+
# Ensure no signal file exists
459+
if watch_service.restart_signal_path.exists():
460+
watch_service.restart_signal_path.unlink()
461+
462+
# Should return False when no signal file exists
463+
result = await watch_service.check_restart_signal()
464+
assert result is False
465+
466+
467+
@pytest.mark.asyncio
468+
async def test_check_restart_signal_with_signal(app_config, project_repository):
469+
"""Test check_restart_signal when signal file exists."""
470+
watch_service = WatchService(app_config, project_repository)
471+
472+
try:
473+
# Create signal file
474+
watch_service.restart_signal_path.parent.mkdir(parents=True, exist_ok=True)
475+
watch_service.restart_signal_path.write_text("test signal")
476+
477+
# Should return True and remove the signal file
478+
result = await watch_service.check_restart_signal()
479+
assert result is True
480+
assert not watch_service.restart_signal_path.exists()
481+
482+
finally:
483+
# Cleanup
484+
if watch_service.restart_signal_path.exists():
485+
watch_service.restart_signal_path.unlink()
486+
487+
488+
@pytest.mark.asyncio
489+
async def test_check_restart_signal_error_handling(app_config, project_repository):
490+
"""Test check_restart_signal handles errors gracefully."""
491+
watch_service = WatchService(app_config, project_repository)
492+
493+
# Create a directory where the signal file should be (to cause an error)
494+
watch_service.restart_signal_path.parent.mkdir(parents=True, exist_ok=True)
495+
if watch_service.restart_signal_path.exists():
496+
watch_service.restart_signal_path.unlink()
497+
watch_service.restart_signal_path.mkdir()
498+
499+
try:
500+
# Should handle the error and return False
501+
result = await watch_service.check_restart_signal()
502+
assert result is False
503+
504+
finally:
505+
# Cleanup
506+
if watch_service.restart_signal_path.exists():
507+
if watch_service.restart_signal_path.is_dir():
508+
watch_service.restart_signal_path.rmdir()
509+
else:
510+
watch_service.restart_signal_path.unlink()

0 commit comments

Comments
 (0)