autoforge-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. package/ui/package.json +57 -0
@@ -0,0 +1,557 @@
1
+ """
2
+ Dev Server Process Manager
3
+ ==========================
4
+
5
+ Manages the lifecycle of dev server subprocesses per project.
6
+ Provides start/stop functionality with cross-platform support via psutil.
7
+
8
+ This is a simplified version of AgentProcessManager, tailored for dev servers:
9
+ - No pause/resume (not needed for dev servers)
10
+ - URL detection from output (regex for http://localhost:XXXX patterns)
11
+ - Simpler status states: stopped, running, crashed
12
+ """
13
+
14
+ import asyncio
15
+ import logging
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Awaitable, Callable, Literal, Set
23
+
24
+ import psutil
25
+
26
+ from registry import list_registered_projects
27
+ from security import extract_commands, get_effective_commands, is_command_allowed
28
+ from server.utils.process_utils import kill_process_tree
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Patterns for sensitive data that should be redacted from output
33
+ SENSITIVE_PATTERNS = [
34
+ r'sk-[a-zA-Z0-9]{20,}', # Anthropic API keys
35
+ r'ANTHROPIC_API_KEY=[^\s]+',
36
+ r'api[_-]?key[=:][^\s]+',
37
+ r'token[=:][^\s]+',
38
+ r'password[=:][^\s]+',
39
+ r'secret[=:][^\s]+',
40
+ r'ghp_[a-zA-Z0-9]{36,}', # GitHub personal access tokens
41
+ r'gho_[a-zA-Z0-9]{36,}', # GitHub OAuth tokens
42
+ r'ghs_[a-zA-Z0-9]{36,}', # GitHub server tokens
43
+ r'ghr_[a-zA-Z0-9]{36,}', # GitHub refresh tokens
44
+ r'aws[_-]?access[_-]?key[=:][^\s]+', # AWS keys
45
+ r'aws[_-]?secret[=:][^\s]+',
46
+ ]
47
+
48
+ # Patterns to detect URLs in dev server output
49
+ # Matches common patterns like:
50
+ # - http://localhost:3000
51
+ # - http://127.0.0.1:5173
52
+ # - https://localhost:8080/
53
+ # - Local: http://localhost:3000
54
+ # - http://localhost:3000/api/docs
55
+ URL_PATTERNS = [
56
+ r'https?://(?:localhost|127\.0\.0\.1):\d+(?:/[^\s]*)?',
57
+ r'https?://\[::1\]:\d+(?:/[^\s]*)?', # IPv6 localhost
58
+ r'https?://0\.0\.0\.0:\d+(?:/[^\s]*)?', # Bound to all interfaces
59
+ ]
60
+
61
+
62
+ def sanitize_output(line: str) -> str:
63
+ """Remove sensitive information from output lines."""
64
+ for pattern in SENSITIVE_PATTERNS:
65
+ line = re.sub(pattern, '[REDACTED]', line, flags=re.IGNORECASE)
66
+ return line
67
+
68
+
69
+ def extract_url(line: str) -> str | None:
70
+ """
71
+ Extract a localhost URL from an output line if present.
72
+
73
+ Returns the first URL found, or None if no URL is detected.
74
+ """
75
+ for pattern in URL_PATTERNS:
76
+ match = re.search(pattern, line)
77
+ if match:
78
+ return match.group(0)
79
+ return None
80
+
81
+
82
+ class DevServerProcessManager:
83
+ """
84
+ Manages dev server subprocess lifecycle for a single project.
85
+
86
+ Provides start/stop with cross-platform support via psutil.
87
+ Supports multiple output callbacks for WebSocket clients.
88
+ Detects and tracks the server URL from output.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ project_name: str,
94
+ project_dir: Path,
95
+ ):
96
+ """
97
+ Initialize the dev server process manager.
98
+
99
+ Args:
100
+ project_name: Name of the project
101
+ project_dir: Absolute path to the project directory
102
+ """
103
+ self.project_name = project_name
104
+ self.project_dir = project_dir
105
+ self.process: subprocess.Popen | None = None
106
+ self._status: Literal["stopped", "running", "crashed"] = "stopped"
107
+ self.started_at: datetime | None = None
108
+ self._output_task: asyncio.Task | None = None
109
+ self._detected_url: str | None = None
110
+ self._command: str | None = None # Store the command used to start
111
+
112
+ # Support multiple callbacks (for multiple WebSocket clients)
113
+ self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
114
+ self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
115
+ self._callbacks_lock = threading.Lock()
116
+
117
+ # Lock file to prevent multiple instances (stored in project directory)
118
+ from autoforge_paths import get_devserver_lock_path
119
+ self.lock_file = get_devserver_lock_path(self.project_dir)
120
+
121
+ @property
122
+ def status(self) -> Literal["stopped", "running", "crashed"]:
123
+ """Current status of the dev server."""
124
+ return self._status
125
+
126
+ @status.setter
127
+ def status(self, value: Literal["stopped", "running", "crashed"]):
128
+ old_status = self._status
129
+ self._status = value
130
+ if old_status != value:
131
+ self._notify_status_change(value)
132
+
133
+ @property
134
+ def detected_url(self) -> str | None:
135
+ """The URL detected from server output, if any."""
136
+ return self._detected_url
137
+
138
+ @property
139
+ def pid(self) -> int | None:
140
+ """Process ID of the running dev server, or None if not running."""
141
+ return self.process.pid if self.process else None
142
+
143
+ def _notify_status_change(self, status: str) -> None:
144
+ """Notify all registered callbacks of status change."""
145
+ with self._callbacks_lock:
146
+ callbacks = list(self._status_callbacks)
147
+
148
+ for callback in callbacks:
149
+ try:
150
+ # Schedule the callback in the event loop
151
+ loop = asyncio.get_running_loop()
152
+ loop.create_task(self._safe_callback(callback, status))
153
+ except RuntimeError:
154
+ # No running event loop
155
+ pass
156
+
157
+ async def _safe_callback(self, callback: Callable, *args) -> None:
158
+ """Safely execute a callback, catching and logging any errors."""
159
+ try:
160
+ await callback(*args)
161
+ except Exception as e:
162
+ logger.warning(f"Callback error: {e}")
163
+
164
+ def add_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
165
+ """Add a callback for output lines."""
166
+ with self._callbacks_lock:
167
+ self._output_callbacks.add(callback)
168
+
169
+ def remove_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
170
+ """Remove an output callback."""
171
+ with self._callbacks_lock:
172
+ self._output_callbacks.discard(callback)
173
+
174
+ def add_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
175
+ """Add a callback for status changes."""
176
+ with self._callbacks_lock:
177
+ self._status_callbacks.add(callback)
178
+
179
+ def remove_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
180
+ """Remove a status callback."""
181
+ with self._callbacks_lock:
182
+ self._status_callbacks.discard(callback)
183
+
184
+ def _check_lock(self) -> bool:
185
+ """
186
+ Check if another dev server is already running for this project.
187
+
188
+ Validates that the PID in the lock file belongs to a process running
189
+ in the same project directory to avoid false positives from PID recycling.
190
+
191
+ Returns:
192
+ True if we can proceed (no other server running), False otherwise.
193
+ """
194
+ if not self.lock_file.exists():
195
+ return True
196
+
197
+ try:
198
+ pid = int(self.lock_file.read_text().strip())
199
+ if psutil.pid_exists(pid):
200
+ try:
201
+ proc = psutil.Process(pid)
202
+ if proc.is_running():
203
+ try:
204
+ # Verify the process is running in our project directory
205
+ # to avoid false positives from PID recycling
206
+ proc_cwd = Path(proc.cwd()).resolve()
207
+ if sys.platform == "win32":
208
+ # Windows paths are case-insensitive
209
+ if proc_cwd.as_posix().lower() == self.project_dir.resolve().as_posix().lower():
210
+ return False # Likely our dev server
211
+ else:
212
+ if proc_cwd == self.project_dir.resolve():
213
+ return False # Likely our dev server
214
+ except (psutil.AccessDenied, OSError):
215
+ # Cannot verify cwd, assume it's our process to be safe
216
+ return False
217
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
218
+ pass
219
+ # Stale lock file - process no longer exists or is in different directory
220
+ self.lock_file.unlink(missing_ok=True)
221
+ return True
222
+ except (ValueError, OSError):
223
+ # Invalid lock file content - remove it
224
+ self.lock_file.unlink(missing_ok=True)
225
+ return True
226
+
227
+ def _create_lock(self) -> None:
228
+ """Create lock file with current process PID."""
229
+ self.lock_file.parent.mkdir(parents=True, exist_ok=True)
230
+ if self.process:
231
+ self.lock_file.write_text(str(self.process.pid))
232
+
233
+ def _remove_lock(self) -> None:
234
+ """Remove lock file."""
235
+ self.lock_file.unlink(missing_ok=True)
236
+
237
+ async def _broadcast_output(self, line: str) -> None:
238
+ """Broadcast output line to all registered callbacks."""
239
+ with self._callbacks_lock:
240
+ callbacks = list(self._output_callbacks)
241
+
242
+ for callback in callbacks:
243
+ await self._safe_callback(callback, line)
244
+
245
+ async def _stream_output(self) -> None:
246
+ """Stream process output to callbacks and detect URL."""
247
+ if not self.process or not self.process.stdout:
248
+ return
249
+
250
+ try:
251
+ loop = asyncio.get_running_loop()
252
+ while True:
253
+ # Use run_in_executor for blocking readline
254
+ line = await loop.run_in_executor(
255
+ None, self.process.stdout.readline
256
+ )
257
+ if not line:
258
+ break
259
+
260
+ decoded = line.decode("utf-8", errors="replace").rstrip()
261
+ sanitized = sanitize_output(decoded)
262
+
263
+ # Try to detect URL from output (only if not already detected)
264
+ if not self._detected_url:
265
+ url = extract_url(decoded)
266
+ if url:
267
+ self._detected_url = url
268
+ logger.info(
269
+ "Dev server URL detected for %s: %s",
270
+ self.project_name, url
271
+ )
272
+
273
+ await self._broadcast_output(sanitized)
274
+
275
+ except asyncio.CancelledError:
276
+ raise
277
+ except Exception as e:
278
+ logger.warning(f"Output streaming error: {e}")
279
+ finally:
280
+ # Check if process ended
281
+ if self.process and self.process.poll() is not None:
282
+ exit_code = self.process.returncode
283
+ if exit_code != 0 and self.status == "running":
284
+ self.status = "crashed"
285
+ elif self.status == "running":
286
+ self.status = "stopped"
287
+ self._remove_lock()
288
+
289
+ async def start(self, command: str) -> tuple[bool, str]:
290
+ """
291
+ Start the dev server as a subprocess.
292
+
293
+ Args:
294
+ command: The shell command to run (e.g., "npm run dev")
295
+
296
+ Returns:
297
+ Tuple of (success, message)
298
+ """
299
+ if self.status == "running":
300
+ return False, "Dev server is already running"
301
+
302
+ if not self._check_lock():
303
+ return False, "Another dev server instance is already running for this project"
304
+
305
+ # Validate that project directory exists
306
+ if not self.project_dir.exists():
307
+ return False, f"Project directory does not exist: {self.project_dir}"
308
+
309
+ # Defense-in-depth: validate command against security allowlist
310
+ commands = extract_commands(command)
311
+ if not commands:
312
+ return False, "Could not parse command for security validation"
313
+
314
+ allowed_commands, blocked_commands = get_effective_commands(self.project_dir)
315
+ for cmd in commands:
316
+ if cmd in blocked_commands:
317
+ logger.warning("Blocked dev server command '%s' (in blocklist) for %s", cmd, self.project_name)
318
+ return False, f"Command '{cmd}' is blocked and cannot be used as a dev server command"
319
+ if not is_command_allowed(cmd, allowed_commands):
320
+ logger.warning("Rejected dev server command '%s' (not in allowlist) for %s", cmd, self.project_name)
321
+ return False, f"Command '{cmd}' is not in the allowed commands list"
322
+
323
+ self._command = command
324
+ self._detected_url = None # Reset URL detection
325
+
326
+ try:
327
+ # Determine shell based on platform
328
+ if sys.platform == "win32":
329
+ # On Windows, use cmd.exe
330
+ shell_cmd = ["cmd", "/c", command]
331
+ else:
332
+ # On Unix-like systems, use sh
333
+ shell_cmd = ["sh", "-c", command]
334
+
335
+ # Start subprocess with piped stdout/stderr
336
+ # stdin=DEVNULL prevents interactive dev servers from blocking on stdin
337
+ # On Windows, use CREATE_NO_WINDOW to prevent console window from flashing
338
+ if sys.platform == "win32":
339
+ self.process = subprocess.Popen(
340
+ shell_cmd,
341
+ stdin=subprocess.DEVNULL,
342
+ stdout=subprocess.PIPE,
343
+ stderr=subprocess.STDOUT,
344
+ cwd=str(self.project_dir),
345
+ creationflags=subprocess.CREATE_NO_WINDOW,
346
+ )
347
+ else:
348
+ self.process = subprocess.Popen(
349
+ shell_cmd,
350
+ stdin=subprocess.DEVNULL,
351
+ stdout=subprocess.PIPE,
352
+ stderr=subprocess.STDOUT,
353
+ cwd=str(self.project_dir),
354
+ )
355
+
356
+ self._create_lock()
357
+ self.started_at = datetime.now()
358
+ self.status = "running"
359
+
360
+ # Start output streaming task
361
+ self._output_task = asyncio.create_task(self._stream_output())
362
+
363
+ return True, f"Dev server started with PID {self.process.pid}"
364
+ except Exception as e:
365
+ logger.exception("Failed to start dev server")
366
+ return False, f"Failed to start dev server: {e}"
367
+
368
+ async def stop(self) -> tuple[bool, str]:
369
+ """
370
+ Stop the dev server (SIGTERM then SIGKILL if needed).
371
+
372
+ Uses psutil to terminate the entire process tree, ensuring
373
+ child processes (like Node.js) are also terminated.
374
+
375
+ Returns:
376
+ Tuple of (success, message)
377
+ """
378
+ if not self.process or self.status == "stopped":
379
+ return False, "Dev server is not running"
380
+
381
+ try:
382
+ # Cancel output streaming
383
+ if self._output_task:
384
+ self._output_task.cancel()
385
+ try:
386
+ await self._output_task
387
+ except asyncio.CancelledError:
388
+ pass
389
+
390
+ # Use shared utility to terminate the entire process tree
391
+ # This is important for dev servers that spawn child processes (like Node.js)
392
+ proc = self.process # Capture reference before async call
393
+ loop = asyncio.get_running_loop()
394
+ result = await loop.run_in_executor(None, kill_process_tree, proc, 5.0)
395
+ logger.debug(
396
+ "Process tree kill result: status=%s, children=%d (terminated=%d, killed=%d)",
397
+ result.status, result.children_found,
398
+ result.children_terminated, result.children_killed
399
+ )
400
+
401
+ self._remove_lock()
402
+ self.status = "stopped"
403
+ self.process = None
404
+ self.started_at = None
405
+ self._detected_url = None
406
+ self._command = None
407
+
408
+ return True, "Dev server stopped"
409
+ except Exception as e:
410
+ logger.exception("Failed to stop dev server")
411
+ return False, f"Failed to stop dev server: {e}"
412
+
413
+ async def healthcheck(self) -> bool:
414
+ """
415
+ Check if the dev server process is still alive.
416
+
417
+ Updates status to 'crashed' if process has died unexpectedly.
418
+
419
+ Returns:
420
+ True if healthy, False otherwise
421
+ """
422
+ if not self.process:
423
+ return self.status == "stopped"
424
+
425
+ poll = self.process.poll()
426
+ if poll is not None:
427
+ # Process has terminated
428
+ if self.status == "running":
429
+ self.status = "crashed"
430
+ self._remove_lock()
431
+ return False
432
+
433
+ return True
434
+
435
+ def get_status_dict(self) -> dict:
436
+ """Get current status as a dictionary."""
437
+ return {
438
+ "status": self.status,
439
+ "pid": self.pid,
440
+ "started_at": self.started_at.isoformat() if self.started_at else None,
441
+ "detected_url": self._detected_url,
442
+ "command": self._command,
443
+ }
444
+
445
+
446
+ # Global registry of dev server managers per project with thread safety
447
+ # Key is (project_name, resolved_project_dir) to prevent cross-project contamination
448
+ # when different projects share the same name but have different paths
449
+ _managers: dict[tuple[str, str], DevServerProcessManager] = {}
450
+ _managers_lock = threading.Lock()
451
+
452
+
453
+ def get_devserver_manager(project_name: str, project_dir: Path) -> DevServerProcessManager:
454
+ """
455
+ Get or create a dev server process manager for a project (thread-safe).
456
+
457
+ Args:
458
+ project_name: Name of the project
459
+ project_dir: Absolute path to the project directory
460
+
461
+ Returns:
462
+ DevServerProcessManager instance for the project
463
+ """
464
+ with _managers_lock:
465
+ # Use composite key to prevent cross-project UI contamination (#71)
466
+ key = (project_name, str(project_dir.resolve()))
467
+ if key not in _managers:
468
+ _managers[key] = DevServerProcessManager(project_name, project_dir)
469
+ return _managers[key]
470
+
471
+
472
+ async def cleanup_all_devservers() -> None:
473
+ """Stop all running dev servers. Called on server shutdown."""
474
+ with _managers_lock:
475
+ managers = list(_managers.values())
476
+
477
+ for manager in managers:
478
+ try:
479
+ if manager.status != "stopped":
480
+ await manager.stop()
481
+ except Exception as e:
482
+ logger.warning(f"Error stopping dev server for {manager.project_name}: {e}")
483
+
484
+ with _managers_lock:
485
+ _managers.clear()
486
+
487
+
488
+ def cleanup_orphaned_devserver_locks() -> int:
489
+ """
490
+ Clean up orphaned dev server lock files from previous server runs.
491
+
492
+ Scans all registered projects for .devserver.lock files and removes them
493
+ if the referenced process is no longer running.
494
+
495
+ Returns:
496
+ Number of orphaned lock files cleaned up
497
+ """
498
+ cleaned = 0
499
+ try:
500
+ projects = list_registered_projects()
501
+ for name, info in projects.items():
502
+ project_path = Path(info.get("path", ""))
503
+ if not project_path.exists():
504
+ continue
505
+
506
+ # Check both legacy and new locations for lock files
507
+ from autoforge_paths import get_autoforge_dir
508
+ lock_locations = [
509
+ project_path / ".devserver.lock",
510
+ get_autoforge_dir(project_path) / ".devserver.lock",
511
+ ]
512
+ lock_file = None
513
+ for candidate in lock_locations:
514
+ if candidate.exists():
515
+ lock_file = candidate
516
+ break
517
+ if lock_file is None:
518
+ continue
519
+
520
+ try:
521
+ pid_str = lock_file.read_text().strip()
522
+ pid = int(pid_str)
523
+
524
+ # Check if process is still running
525
+ if psutil.pid_exists(pid):
526
+ try:
527
+ proc = psutil.Process(pid)
528
+ if proc.is_running():
529
+ # Process is still running, don't remove
530
+ logger.info(
531
+ "Found running dev server for project '%s' (PID %d)",
532
+ name, pid
533
+ )
534
+ continue
535
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
536
+ pass
537
+
538
+ # Process not running - remove stale lock
539
+ lock_file.unlink(missing_ok=True)
540
+ cleaned += 1
541
+ logger.info("Removed orphaned dev server lock file for project '%s'", name)
542
+
543
+ except (ValueError, OSError) as e:
544
+ # Invalid lock file content - remove it
545
+ logger.warning(
546
+ "Removing invalid dev server lock file for project '%s': %s", name, e
547
+ )
548
+ lock_file.unlink(missing_ok=True)
549
+ cleaned += 1
550
+
551
+ except Exception as e:
552
+ logger.error("Error during dev server orphan cleanup: %s", e)
553
+
554
+ if cleaned:
555
+ logger.info("Cleaned up %d orphaned dev server lock file(s)", cleaned)
556
+
557
+ return cleaned