Skip to content

Commit d6befed

Browse files
fix: prevent database recreation for detached projects
- Add validate_project_not_detached dependency in server/dependencies.py - Block features/schedules/review/agent APIs for detached projects (409 Conflict) - Add auto-cleanup of orphaned db files at reattach - Add detach check in scheduler_service (7 methods) - Add 93 tests for detach functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c087eb8 commit d6befed

File tree

8 files changed

+815
-82
lines changed

8 files changed

+815
-82
lines changed

detach.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,62 @@ def detach_project(
907907
release_detach_lock(project_dir)
908908

909909

910+
def _cleanup_orphaned_db_files(project_dir: Path, manifest: Manifest) -> list[str]:
911+
"""Remove database files that were recreated after detach.
912+
913+
When the UI/API accesses a detached project, it may recreate empty
914+
database files. This function detects and removes them before restore.
915+
916+
Heuristic: If root file is smaller than backup file, it was recreated empty.
917+
918+
Args:
919+
project_dir: Path to the project directory
920+
manifest: Backup manifest containing original file info
921+
922+
Returns:
923+
List of files that were cleaned up
924+
"""
925+
cleaned = []
926+
927+
# Build map of backup database files with their sizes
928+
backup_db_files = {}
929+
for entry in manifest.get("files", []):
930+
path = entry.get("path", "")
931+
if path in ("features.db", "assistant.db"):
932+
backup_db_files[path] = entry.get("size", 0)
933+
934+
for db_name in ["features.db", "assistant.db"]:
935+
root_file = project_dir / db_name
936+
937+
# If root file exists but backup also has it, check if recreated
938+
if root_file.exists() and db_name in backup_db_files:
939+
root_size = root_file.stat().st_size
940+
backup_size = backup_db_files[db_name]
941+
942+
# If root is much smaller than backup, it was likely recreated empty
943+
# Empty SQLite DB is typically 4-8KB, real DB with features is much larger
944+
if backup_size > 0 and root_size < backup_size:
945+
try:
946+
root_file.unlink()
947+
cleaned.append(db_name)
948+
logger.info(f"Removed recreated {db_name} ({root_size}B < {backup_size}B backup)")
949+
except OSError as e:
950+
logger.warning(f"Failed to remove orphaned {db_name}: {e}")
951+
952+
# Always clean WAL/SHM files at root - they should be in backup if needed
953+
for ext in ["-shm", "-wal"]:
954+
wal_file = project_dir / f"{db_name}{ext}"
955+
if wal_file.exists():
956+
try:
957+
wal_file.unlink()
958+
cleaned.append(f"{db_name}{ext}")
959+
logger.debug(f"Removed orphaned {db_name}{ext}")
960+
except OSError as e:
961+
logger.warning(f"Failed to remove {db_name}{ext}: {e}")
962+
963+
return cleaned
964+
965+
910966
def reattach_project(name_or_path: str) -> tuple[bool, str, int, list[str]]:
911967
"""
912968
Reattach a project by restoring Autocoder files from backup.
@@ -945,6 +1001,16 @@ def reattach_project(name_or_path: str) -> tuple[bool, str, int, list[str]]:
9451001
return False, "Another detach operation is in progress.", 0, []
9461002

9471003
try:
1004+
# Read manifest for cleanup decision
1005+
manifest = get_backup_info(project_dir)
1006+
1007+
# Clean up orphaned database files that may have been recreated
1008+
# by the UI/API accessing the detached project
1009+
if manifest:
1010+
cleaned = _cleanup_orphaned_db_files(project_dir, manifest)
1011+
if cleaned:
1012+
logger.info(f"Cleaned up {len(cleaned)} orphaned files before restore: {cleaned}")
1013+
9481014
success, files_restored, conflicts = restore_backup(project_dir)
9491015
if success:
9501016
if conflicts:

server/dependencies.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Server Dependencies
3+
===================
4+
5+
FastAPI dependencies for common validation patterns.
6+
"""
7+
8+
import sys
9+
from pathlib import Path
10+
11+
from fastapi import HTTPException
12+
13+
14+
def _get_detach_module():
15+
"""Lazy import of detach module."""
16+
root = Path(__file__).parent.parent
17+
if str(root) not in sys.path:
18+
sys.path.insert(0, str(root))
19+
import detach
20+
return detach
21+
22+
23+
def _get_registry_module():
24+
"""Lazy import of registry module."""
25+
root = Path(__file__).parent.parent
26+
if str(root) not in sys.path:
27+
sys.path.insert(0, str(root))
28+
from registry import get_project_path
29+
return get_project_path
30+
31+
32+
def validate_project_not_detached(project_name: str) -> Path:
33+
"""Validate that a project is not detached.
34+
35+
This dependency ensures that database operations are not performed
36+
on detached projects, which would cause empty database recreation.
37+
38+
Args:
39+
project_name: The project name to validate
40+
41+
Returns:
42+
Path to the project directory if accessible
43+
44+
Raises:
45+
HTTPException 404: If project not found in registry
46+
HTTPException 409: If project is detached (Conflict)
47+
"""
48+
get_project_path = _get_registry_module()
49+
detach = _get_detach_module()
50+
51+
project_dir = get_project_path(project_name)
52+
if project_dir is None:
53+
raise HTTPException(
54+
status_code=404,
55+
detail=f"Project '{project_name}' not found in registry"
56+
)
57+
58+
project_dir = Path(project_dir)
59+
if not project_dir.exists():
60+
raise HTTPException(
61+
status_code=404,
62+
detail=f"Project directory not found: {project_dir}"
63+
)
64+
65+
if detach.is_project_detached(project_dir):
66+
raise HTTPException(
67+
status_code=409,
68+
detail=f"Project '{project_name}' is detached. Reattach to access features."
69+
)
70+
71+
return project_dir
72+
73+
74+
def check_project_detached_for_background(project_dir: Path) -> bool:
75+
"""Check if a project is detached (for background services).
76+
77+
Unlike validate_project_not_detached, this doesn't raise exceptions.
78+
It's meant for background services that should silently skip detached projects.
79+
80+
Args:
81+
project_dir: Path to the project directory
82+
83+
Returns:
84+
True if project is detached, False otherwise
85+
"""
86+
detach = _get_detach_module()
87+
return bool(detach.is_project_detached(project_dir))

server/routers/agent.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import re
10+
import sys
1011
from pathlib import Path
1112

1213
from fastapi import APIRouter, HTTPException
@@ -15,9 +16,17 @@
1516
from ..services.process_manager import get_manager
1617

1718

18-
def _get_project_path(project_name: str) -> Path:
19+
def _get_detach_module():
20+
"""Lazy import of detach module."""
21+
root = Path(__file__).parent.parent.parent
22+
if str(root) not in sys.path:
23+
sys.path.insert(0, str(root))
24+
import detach
25+
return detach
26+
27+
28+
def _get_project_path(project_name: str) -> Path | None:
1929
"""Get project path from registry."""
20-
import sys
2130
root = Path(__file__).parent.parent.parent
2231
if str(root) not in sys.path:
2332
sys.path.insert(0, str(root))
@@ -32,7 +41,6 @@ def _get_settings_defaults() -> tuple[bool, str, int]:
3241
Returns:
3342
Tuple of (yolo_mode, model, testing_agent_ratio)
3443
"""
35-
import sys
3644
root = Path(__file__).parent.parent.parent
3745
if str(root) not in sys.path:
3846
sys.path.insert(0, str(root))
@@ -108,6 +116,16 @@ async def start_agent(
108116
request: AgentStartRequest = AgentStartRequest(),
109117
):
110118
"""Start the agent for a project."""
119+
# Check detach status before starting agent
120+
project_dir = _get_project_path(project_name)
121+
if project_dir:
122+
detach = _get_detach_module()
123+
if detach.is_project_detached(project_dir):
124+
raise HTTPException(
125+
status_code=409,
126+
detail=f"Project '{project_name}' is detached. Reattach to start agent."
127+
)
128+
111129
manager = get_project_manager(project_name)
112130

113131
# Get defaults from global settings if not provided in request

0 commit comments

Comments
 (0)