autoforge-ai 0.1.2 → 0.1.4

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.
@@ -25,7 +25,7 @@ from .assistant_database import (
25
25
  create_conversation,
26
26
  get_messages,
27
27
  )
28
- from .chat_constants import API_ENV_VARS, ROOT_DIR
28
+ from .chat_constants import ROOT_DIR
29
29
 
30
30
  # Load environment variables from .env file if present
31
31
  load_dotenv()
@@ -258,15 +258,11 @@ class AssistantChatSession:
258
258
  system_cli = shutil.which("claude")
259
259
 
260
260
  # Build environment overrides for API configuration
261
- sdk_env: dict[str, str] = {}
262
- for var in API_ENV_VARS:
263
- value = os.getenv(var)
264
- if value:
265
- sdk_env[var] = value
266
-
267
- # Determine model from environment or use default
268
- # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
269
- model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
261
+ from registry import get_effective_sdk_env
262
+ sdk_env = get_effective_sdk_env()
263
+
264
+ # Determine model from SDK env (provider-aware) or fallback to env/default
265
+ model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
270
266
 
271
267
  try:
272
268
  logger.info("Creating ClaudeSDKClient...")
@@ -22,7 +22,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
22
22
  from dotenv import load_dotenv
23
23
 
24
24
  from ..schemas import ImageAttachment
25
- from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
25
+ from .chat_constants import ROOT_DIR, make_multimodal_message
26
26
 
27
27
  # Load environment variables from .env file if present
28
28
  load_dotenv()
@@ -154,16 +154,11 @@ class ExpandChatSession:
154
154
  system_prompt = skill_content.replace("$ARGUMENTS", project_path)
155
155
 
156
156
  # Build environment overrides for API configuration
157
- # Filter to only include vars that are actually set (non-None)
158
- sdk_env: dict[str, str] = {}
159
- for var in API_ENV_VARS:
160
- value = os.getenv(var)
161
- if value:
162
- sdk_env[var] = value
163
-
164
- # Determine model from environment or use default
165
- # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
166
- model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
157
+ from registry import get_effective_sdk_env
158
+ sdk_env = get_effective_sdk_env()
159
+
160
+ # Determine model from SDK env (provider-aware) or fallback to env/default
161
+ model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
167
162
 
168
163
  # Build MCP servers config for feature creation
169
164
  mcp_servers = {
@@ -227,6 +227,46 @@ class AgentProcessManager:
227
227
  """Remove lock file."""
228
228
  self.lock_file.unlink(missing_ok=True)
229
229
 
230
+ def _cleanup_stale_features(self) -> None:
231
+ """Clear in_progress flag for all features when agent stops/crashes.
232
+
233
+ When the agent process exits (normally or crash), any features left
234
+ with in_progress=True were being worked on and didn't complete.
235
+ Reset them so they can be picked up on next agent start.
236
+ """
237
+ try:
238
+ from autoforge_paths import get_features_db_path
239
+ features_db = get_features_db_path(self.project_dir)
240
+ if not features_db.exists():
241
+ return
242
+
243
+ from sqlalchemy import create_engine
244
+ from sqlalchemy.orm import sessionmaker
245
+
246
+ from api.database import Feature
247
+
248
+ engine = create_engine(f"sqlite:///{features_db}")
249
+ Session = sessionmaker(bind=engine)
250
+ session = Session()
251
+ try:
252
+ stuck = session.query(Feature).filter(
253
+ Feature.in_progress == True, # noqa: E712
254
+ Feature.passes == False, # noqa: E712
255
+ ).all()
256
+ if stuck:
257
+ for f in stuck:
258
+ f.in_progress = False
259
+ session.commit()
260
+ logger.info(
261
+ "Cleaned up %d stuck feature(s) for %s",
262
+ len(stuck), self.project_name,
263
+ )
264
+ finally:
265
+ session.close()
266
+ engine.dispose()
267
+ except Exception as e:
268
+ logger.warning("Failed to cleanup features for %s: %s", self.project_name, e)
269
+
230
270
  async def _broadcast_output(self, line: str) -> None:
231
271
  """Broadcast output line to all registered callbacks."""
232
272
  with self._callbacks_lock:
@@ -288,6 +328,7 @@ class AgentProcessManager:
288
328
  self.status = "crashed"
289
329
  elif self.status == "running":
290
330
  self.status = "stopped"
331
+ self._cleanup_stale_features()
291
332
  self._remove_lock()
292
333
 
293
334
  async def start(
@@ -320,6 +361,9 @@ class AgentProcessManager:
320
361
  if not self._check_lock():
321
362
  return False, "Another agent instance is already running for this project"
322
363
 
364
+ # Clean up features stuck from a previous crash/stop
365
+ self._cleanup_stale_features()
366
+
323
367
  # Store for status queries
324
368
  self.yolo_mode = yolo_mode
325
369
  self.model = model
@@ -359,12 +403,22 @@ class AgentProcessManager:
359
403
  # stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
360
404
  # CREATE_NO_WINDOW on Windows prevents console window pop-ups
361
405
  # PYTHONUNBUFFERED ensures output isn't delayed
406
+ # Build subprocess environment with API provider settings
407
+ from registry import get_effective_sdk_env
408
+ api_env = get_effective_sdk_env()
409
+ subprocess_env = {
410
+ **os.environ,
411
+ "PYTHONUNBUFFERED": "1",
412
+ "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false",
413
+ **api_env,
414
+ }
415
+
362
416
  popen_kwargs: dict[str, Any] = {
363
417
  "stdin": subprocess.DEVNULL,
364
418
  "stdout": subprocess.PIPE,
365
419
  "stderr": subprocess.STDOUT,
366
420
  "cwd": str(self.project_dir),
367
- "env": {**os.environ, "PYTHONUNBUFFERED": "1", "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false"},
421
+ "env": subprocess_env,
368
422
  }
369
423
  if sys.platform == "win32":
370
424
  popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
@@ -425,6 +479,7 @@ class AgentProcessManager:
425
479
  result.children_terminated, result.children_killed
426
480
  )
427
481
 
482
+ self._cleanup_stale_features()
428
483
  self._remove_lock()
429
484
  self.status = "stopped"
430
485
  self.process = None
@@ -502,6 +557,7 @@ class AgentProcessManager:
502
557
  if poll is not None:
503
558
  # Process has terminated
504
559
  if self.status in ("running", "paused"):
560
+ self._cleanup_stale_features()
505
561
  self.status = "crashed"
506
562
  self._remove_lock()
507
563
  return False
@@ -19,7 +19,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
19
19
  from dotenv import load_dotenv
20
20
 
21
21
  from ..schemas import ImageAttachment
22
- from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
22
+ from .chat_constants import ROOT_DIR, make_multimodal_message
23
23
 
24
24
  # Load environment variables from .env file if present
25
25
  load_dotenv()
@@ -140,16 +140,11 @@ class SpecChatSession:
140
140
  system_cli = shutil.which("claude")
141
141
 
142
142
  # Build environment overrides for API configuration
143
- # Filter to only include vars that are actually set (non-None)
144
- sdk_env: dict[str, str] = {}
145
- for var in API_ENV_VARS:
146
- value = os.getenv(var)
147
- if value:
148
- sdk_env[var] = value
149
-
150
- # Determine model from environment or use default
151
- # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
152
- model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
143
+ from registry import get_effective_sdk_env
144
+ sdk_env = get_effective_sdk_env()
145
+
146
+ # Determine model from SDK env (provider-aware) or fallback to env/default
147
+ model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
153
148
 
154
149
  try:
155
150
  self.client = ClaudeSDKClient(
@@ -640,9 +640,7 @@ class ConnectionManager:
640
640
  self._lock = asyncio.Lock()
641
641
 
642
642
  async def connect(self, websocket: WebSocket, project_name: str):
643
- """Accept a WebSocket connection for a project."""
644
- await websocket.accept()
645
-
643
+ """Register a WebSocket connection for a project (must already be accepted)."""
646
644
  async with self._lock:
647
645
  if project_name not in self.active_connections:
648
646
  self.active_connections[project_name] = set()
@@ -727,16 +725,22 @@ async def project_websocket(websocket: WebSocket, project_name: str):
727
725
  - Agent status changes
728
726
  - Agent stdout/stderr lines
729
727
  """
728
+ # Always accept WebSocket first to avoid opaque 403 errors
729
+ await websocket.accept()
730
+
730
731
  if not validate_project_name(project_name):
732
+ await websocket.send_json({"type": "error", "content": "Invalid project name"})
731
733
  await websocket.close(code=4000, reason="Invalid project name")
732
734
  return
733
735
 
734
736
  project_dir = _get_project_path(project_name)
735
737
  if not project_dir:
738
+ await websocket.send_json({"type": "error", "content": "Project not found in registry"})
736
739
  await websocket.close(code=4004, reason="Project not found in registry")
737
740
  return
738
741
 
739
742
  if not project_dir.exists():
743
+ await websocket.send_json({"type": "error", "content": "Project directory not found"})
740
744
  await websocket.close(code=4004, reason="Project directory not found")
741
745
  return
742
746
 
@@ -879,8 +883,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
879
883
  break
880
884
  except json.JSONDecodeError:
881
885
  logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
882
- except Exception as e:
883
- logger.warning(f"WebSocket error: {e}")
886
+ except Exception:
884
887
  break
885
888
 
886
889
  finally:
@@ -0,0 +1,148 @@
1
+ """
2
+ Temp Cleanup Module
3
+ ===================
4
+
5
+ Cleans up stale temporary files and directories created by AutoForge agents,
6
+ Playwright, Node.js, and other development tools.
7
+
8
+ Called at Maestro (orchestrator) startup to prevent temp folder bloat.
9
+
10
+ Why this exists:
11
+ - Playwright creates browser profiles and artifacts in %TEMP%
12
+ - Node.js creates .node cache files (~7MB each, can accumulate to GBs)
13
+ - MongoDB Memory Server downloads binaries to temp
14
+ - These are never cleaned up automatically
15
+
16
+ When cleanup runs:
17
+ - At Maestro startup (when you click Play or auto-restart after rate limits)
18
+ - Only files/folders older than 1 hour are deleted (safe for running processes)
19
+ """
20
+
21
+ import logging
22
+ import shutil
23
+ import tempfile
24
+ import time
25
+ from pathlib import Path
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Max age in seconds before a temp item is considered stale (1 hour)
30
+ MAX_AGE_SECONDS = 3600
31
+
32
+ # Directory patterns to clean up (glob patterns)
33
+ DIR_PATTERNS = [
34
+ "playwright_firefoxdev_profile-*", # Playwright Firefox profiles
35
+ "playwright-artifacts-*", # Playwright test artifacts
36
+ "playwright-transform-cache", # Playwright transform cache
37
+ "mongodb-memory-server*", # MongoDB Memory Server binaries
38
+ "ng-*", # Angular CLI temp directories
39
+ "scoped_dir*", # Chrome/Chromium temp directories
40
+ ]
41
+
42
+ # File patterns to clean up (glob patterns)
43
+ FILE_PATTERNS = [
44
+ ".78912*.node", # Node.js native module cache (major space consumer, ~7MB each)
45
+ "claude-*-cwd", # Claude CLI working directory temp files
46
+ "mat-debug-*.log", # Material/Angular debug logs
47
+ ]
48
+
49
+
50
+ def cleanup_stale_temp(max_age_seconds: int = MAX_AGE_SECONDS) -> dict:
51
+ """
52
+ Clean up stale temporary files and directories.
53
+
54
+ Only deletes items older than max_age_seconds to avoid
55
+ interfering with currently running processes.
56
+
57
+ Args:
58
+ max_age_seconds: Maximum age in seconds before an item is deleted.
59
+ Defaults to 1 hour (3600 seconds).
60
+
61
+ Returns:
62
+ Dictionary with cleanup statistics:
63
+ - dirs_deleted: Number of directories deleted
64
+ - files_deleted: Number of files deleted
65
+ - bytes_freed: Approximate bytes freed
66
+ - errors: List of error messages (for debugging, not fatal)
67
+ """
68
+ temp_dir = Path(tempfile.gettempdir())
69
+ cutoff_time = time.time() - max_age_seconds
70
+
71
+ stats = {
72
+ "dirs_deleted": 0,
73
+ "files_deleted": 0,
74
+ "bytes_freed": 0,
75
+ "errors": [],
76
+ }
77
+
78
+ # Clean up directories
79
+ for pattern in DIR_PATTERNS:
80
+ for item in temp_dir.glob(pattern):
81
+ if not item.is_dir():
82
+ continue
83
+ try:
84
+ mtime = item.stat().st_mtime
85
+ if mtime < cutoff_time:
86
+ size = _get_dir_size(item)
87
+ shutil.rmtree(item, ignore_errors=True)
88
+ if not item.exists():
89
+ stats["dirs_deleted"] += 1
90
+ stats["bytes_freed"] += size
91
+ logger.debug(f"Deleted temp directory: {item}")
92
+ except Exception as e:
93
+ stats["errors"].append(f"Failed to delete {item}: {e}")
94
+ logger.debug(f"Failed to delete {item}: {e}")
95
+
96
+ # Clean up files
97
+ for pattern in FILE_PATTERNS:
98
+ for item in temp_dir.glob(pattern):
99
+ if not item.is_file():
100
+ continue
101
+ try:
102
+ mtime = item.stat().st_mtime
103
+ if mtime < cutoff_time:
104
+ size = item.stat().st_size
105
+ item.unlink(missing_ok=True)
106
+ if not item.exists():
107
+ stats["files_deleted"] += 1
108
+ stats["bytes_freed"] += size
109
+ logger.debug(f"Deleted temp file: {item}")
110
+ except Exception as e:
111
+ stats["errors"].append(f"Failed to delete {item}: {e}")
112
+ logger.debug(f"Failed to delete {item}: {e}")
113
+
114
+ # Log summary if anything was cleaned
115
+ if stats["dirs_deleted"] > 0 or stats["files_deleted"] > 0:
116
+ mb_freed = stats["bytes_freed"] / (1024 * 1024)
117
+ logger.info(
118
+ f"Temp cleanup: {stats['dirs_deleted']} dirs, "
119
+ f"{stats['files_deleted']} files, {mb_freed:.1f} MB freed"
120
+ )
121
+
122
+ return stats
123
+
124
+
125
+ def _get_dir_size(path: Path) -> int:
126
+ """Get total size of a directory in bytes."""
127
+ total = 0
128
+ try:
129
+ for item in path.rglob("*"):
130
+ if item.is_file():
131
+ try:
132
+ total += item.stat().st_size
133
+ except (OSError, PermissionError):
134
+ pass
135
+ except (OSError, PermissionError):
136
+ pass
137
+ return total
138
+
139
+
140
+ if __name__ == "__main__":
141
+ # Allow running directly for testing/manual cleanup
142
+ logging.basicConfig(level=logging.DEBUG)
143
+ print("Running temp cleanup...")
144
+ stats = cleanup_stale_temp()
145
+ mb_freed = stats["bytes_freed"] / (1024 * 1024)
146
+ print(f"Cleanup complete: {stats['dirs_deleted']} dirs, {stats['files_deleted']} files, {mb_freed:.1f} MB freed")
147
+ if stats["errors"]:
148
+ print(f"Errors (non-fatal): {len(stats['errors'])}")