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,903 @@
1
+ """
2
+ WebSocket Handlers
3
+ ==================
4
+
5
+ Real-time updates for project progress, agent output, and dev server output.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import re
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Set
15
+
16
+ from fastapi import WebSocket, WebSocketDisconnect
17
+
18
+ from .schemas import AGENT_MASCOTS
19
+ from .services.chat_constants import ROOT_DIR
20
+ from .services.dev_server_manager import get_devserver_manager
21
+ from .services.process_manager import get_manager
22
+ from .utils.project_helpers import get_project_path as _get_project_path
23
+ from .utils.validation import is_valid_project_name as validate_project_name
24
+
25
+ # Lazy imports
26
+ _count_passing_tests = None
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Pattern to extract feature ID from parallel orchestrator output
31
+ # Both coding and testing agents now use the same [Feature #X] format
32
+ FEATURE_ID_PATTERN = re.compile(r'\[Feature #(\d+)\]\s*(.*)')
33
+
34
+ # Pattern to detect testing agent start message (includes feature ID)
35
+ # Matches: "Started testing agent for feature #123 (PID xxx)"
36
+ TESTING_AGENT_START_PATTERN = re.compile(r'Started testing agent for feature #(\d+)')
37
+
38
+ # Pattern to detect testing agent completion
39
+ # Matches: "Feature #123 testing completed" or "Feature #123 testing failed"
40
+ TESTING_AGENT_COMPLETE_PATTERN = re.compile(r'Feature #(\d+) testing (completed|failed)')
41
+
42
+ # Pattern to detect batch coding agent start message
43
+ # Matches: "Started coding agent for features #5, #8, #12"
44
+ BATCH_CODING_AGENT_START_PATTERN = re.compile(r'Started coding agent for features (#\d+(?:,\s*#\d+)*)')
45
+
46
+ # Pattern to detect batch completion
47
+ # Matches: "Features #5, #8, #12 completed" or "Features #5, #8, #12 failed"
48
+ BATCH_FEATURES_COMPLETE_PATTERN = re.compile(r'Features (#\d+(?:,\s*#\d+)*)\s+(completed|failed)')
49
+
50
+ # Patterns for detecting agent activity and thoughts
51
+ THOUGHT_PATTERNS = [
52
+ # Claude's tool usage patterns (actual format: [Tool: name])
53
+ (re.compile(r'\[Tool:\s*Read\]', re.I), 'thinking'),
54
+ (re.compile(r'\[Tool:\s*(?:Write|Edit|NotebookEdit)\]', re.I), 'working'),
55
+ (re.compile(r'\[Tool:\s*Bash\]', re.I), 'testing'),
56
+ (re.compile(r'\[Tool:\s*(?:Glob|Grep)\]', re.I), 'thinking'),
57
+ (re.compile(r'\[Tool:\s*(\w+)\]', re.I), 'working'), # Fallback for other tools
58
+ # Claude's internal thoughts
59
+ (re.compile(r'(?:Reading|Analyzing|Checking|Looking at|Examining)\s+(.+)', re.I), 'thinking'),
60
+ (re.compile(r'(?:Creating|Writing|Adding|Implementing|Building)\s+(.+)', re.I), 'working'),
61
+ (re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
62
+ (re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
63
+ # Test results
64
+ (re.compile(r'(?:PASS|passed|success)', re.I), 'success'),
65
+ (re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
66
+ ]
67
+
68
+ # Orchestrator event patterns for Mission Control observability
69
+ ORCHESTRATOR_PATTERNS = {
70
+ 'init_start': re.compile(r'Running initializer agent'),
71
+ 'init_complete': re.compile(r'INITIALIZATION COMPLETE'),
72
+ 'capacity_check': re.compile(r'\[DEBUG\] Spawning loop: (\d+) ready, (\d+) slots'),
73
+ 'at_capacity': re.compile(r'At max capacity|at max testing agents|At max total agents'),
74
+ 'feature_start': re.compile(r'Starting feature \d+/\d+: #(\d+) - (.+)'),
75
+ 'coding_spawn': re.compile(r'Started coding agent for features? #(\d+)'),
76
+ 'testing_spawn': re.compile(r'Started testing agent for feature #(\d+)'),
77
+ 'coding_complete': re.compile(r'Features? #(\d+)(?:,\s*#\d+)* (completed|failed)'),
78
+ 'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
79
+ 'all_complete': re.compile(r'All features complete'),
80
+ 'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
81
+ }
82
+
83
+
84
+ class AgentTracker:
85
+ """Tracks active agents and their states for multi-agent mode.
86
+
87
+ Both coding and testing agents are tracked using a composite key of
88
+ (feature_id, agent_type) to allow simultaneous tracking of both agent
89
+ types for the same feature.
90
+ """
91
+
92
+ def __init__(self):
93
+ # (feature_id, agent_type) -> {name, state, last_thought, agent_index, agent_type}
94
+ self.active_agents: dict[tuple[int, str], dict] = {}
95
+ self._next_agent_index = 0
96
+ self._lock = asyncio.Lock()
97
+
98
+ async def process_line(self, line: str) -> dict | None:
99
+ """
100
+ Process an output line and return an agent_update message if relevant.
101
+
102
+ Returns None if no update should be emitted.
103
+ """
104
+ # Check for orchestrator status messages first
105
+ # These don't have [Feature #X] prefix
106
+
107
+ # Batch coding agent start: "Started coding agent for features #5, #8, #12"
108
+ batch_start_match = BATCH_CODING_AGENT_START_PATTERN.match(line)
109
+ if batch_start_match:
110
+ try:
111
+ feature_ids = [int(x.strip().lstrip('#')) for x in batch_start_match.group(1).split(',')]
112
+ if feature_ids:
113
+ return await self._handle_batch_agent_start(feature_ids, "coding")
114
+ except ValueError:
115
+ pass
116
+
117
+ # Single coding agent start: "Started coding agent for feature #X"
118
+ if line.startswith("Started coding agent for feature #"):
119
+ m = re.search(r'#(\d+)', line)
120
+ if m:
121
+ try:
122
+ feature_id = int(m.group(1))
123
+ return await self._handle_agent_start(feature_id, line, agent_type="coding")
124
+ except ValueError:
125
+ pass
126
+
127
+ # Testing agent start: "Started testing agent for feature #X (PID xxx)"
128
+ testing_start_match = TESTING_AGENT_START_PATTERN.match(line)
129
+ if testing_start_match:
130
+ feature_id = int(testing_start_match.group(1))
131
+ return await self._handle_agent_start(feature_id, line, agent_type="testing")
132
+
133
+ # Testing agent complete: "Feature #X testing completed/failed"
134
+ testing_complete_match = TESTING_AGENT_COMPLETE_PATTERN.match(line)
135
+ if testing_complete_match:
136
+ feature_id = int(testing_complete_match.group(1))
137
+ is_success = testing_complete_match.group(2) == "completed"
138
+ return await self._handle_agent_complete(feature_id, is_success, agent_type="testing")
139
+
140
+ # Batch features complete: "Features #5, #8, #12 completed/failed"
141
+ batch_complete_match = BATCH_FEATURES_COMPLETE_PATTERN.match(line)
142
+ if batch_complete_match:
143
+ try:
144
+ feature_ids = [int(x.strip().lstrip('#')) for x in batch_complete_match.group(1).split(',')]
145
+ is_success = batch_complete_match.group(2) == "completed"
146
+ if feature_ids:
147
+ return await self._handle_batch_agent_complete(feature_ids, is_success, "coding")
148
+ except ValueError:
149
+ pass
150
+
151
+ # Coding agent complete: "Feature #X completed/failed" (without "testing" keyword)
152
+ if line.startswith("Feature #") and ("completed" in line or "failed" in line) and "testing" not in line:
153
+ m = re.search(r'#(\d+)', line)
154
+ if m:
155
+ try:
156
+ feature_id = int(m.group(1))
157
+ is_success = "completed" in line
158
+ return await self._handle_agent_complete(feature_id, is_success, agent_type="coding")
159
+ except ValueError:
160
+ pass
161
+
162
+ # Check for feature-specific output lines: [Feature #X] content
163
+ # Both coding and testing agents use this format now
164
+ match = FEATURE_ID_PATTERN.match(line)
165
+ if not match:
166
+ return None
167
+
168
+ feature_id = int(match.group(1))
169
+ content = match.group(2)
170
+
171
+ async with self._lock:
172
+ # Check if either coding or testing agent exists for this feature
173
+ # This prevents creating ghost agents when a testing agent outputs [Feature #X] lines
174
+ coding_key = (feature_id, 'coding')
175
+ testing_key = (feature_id, 'testing')
176
+
177
+ if coding_key in self.active_agents:
178
+ key = coding_key
179
+ elif testing_key in self.active_agents:
180
+ key = testing_key
181
+ else:
182
+ # Neither exists, create a new coding agent entry (implicit tracking)
183
+ key = coding_key
184
+ agent_index = self._next_agent_index
185
+ self._next_agent_index += 1
186
+ self.active_agents[key] = {
187
+ 'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
188
+ 'agent_index': agent_index,
189
+ 'agent_type': 'coding',
190
+ 'feature_ids': [feature_id],
191
+ 'state': 'thinking',
192
+ 'feature_name': f'Feature #{feature_id}',
193
+ 'last_thought': None,
194
+ }
195
+
196
+ agent = self.active_agents[key]
197
+
198
+ # Update current_feature_id for batch agents when output comes from a different feature
199
+ if 'current_feature_id' in agent and feature_id in agent.get('feature_ids', []):
200
+ agent['current_feature_id'] = feature_id
201
+
202
+ # Detect state and thought from content
203
+ state = 'working'
204
+ thought = None
205
+
206
+ for pattern, detected_state in THOUGHT_PATTERNS:
207
+ m = pattern.search(content)
208
+ if m:
209
+ state = detected_state
210
+ thought = m.group(1) if m.lastindex else content[:100]
211
+ break
212
+
213
+ # Only emit update if state changed or we have a new thought
214
+ if state != agent['state'] or thought != agent['last_thought']:
215
+ agent['state'] = state
216
+ if thought:
217
+ agent['last_thought'] = thought
218
+
219
+ return {
220
+ 'type': 'agent_update',
221
+ 'agentIndex': agent['agent_index'],
222
+ 'agentName': agent['name'],
223
+ 'agentType': agent['agent_type'],
224
+ 'featureId': feature_id,
225
+ 'featureIds': agent.get('feature_ids', [feature_id]),
226
+ 'featureName': agent['feature_name'],
227
+ 'state': state,
228
+ 'thought': thought,
229
+ 'timestamp': datetime.now().isoformat(),
230
+ }
231
+
232
+ return None
233
+
234
+ async def get_agent_info(self, feature_id: int, agent_type: str = "coding") -> tuple[int | None, str | None]:
235
+ """Get agent index and name for a feature ID and agent type.
236
+
237
+ Thread-safe method that acquires the lock before reading state.
238
+
239
+ Args:
240
+ feature_id: The feature ID to look up.
241
+ agent_type: The agent type ("coding" or "testing"). Defaults to "coding".
242
+
243
+ Returns:
244
+ Tuple of (agentIndex, agentName) or (None, None) if not tracked.
245
+ """
246
+ async with self._lock:
247
+ key = (feature_id, agent_type)
248
+ agent = self.active_agents.get(key)
249
+ if agent:
250
+ return agent['agent_index'], agent['name']
251
+ return None, None
252
+
253
+ async def reset(self):
254
+ """Reset tracker state when orchestrator stops or crashes.
255
+
256
+ Clears all active agents and resets the index counter to prevent
257
+ ghost agents accumulating across start/stop cycles.
258
+
259
+ Must be called with await since it acquires the async lock.
260
+ """
261
+ async with self._lock:
262
+ self.active_agents.clear()
263
+ self._next_agent_index = 0
264
+
265
+ async def _handle_agent_start(self, feature_id: int, line: str, agent_type: str = "coding") -> dict | None:
266
+ """Handle agent start message from orchestrator."""
267
+ async with self._lock:
268
+ key = (feature_id, agent_type) # Composite key for separate tracking
269
+ agent_index = self._next_agent_index
270
+ self._next_agent_index += 1
271
+
272
+ # Try to extract feature name from line
273
+ feature_name = f'Feature #{feature_id}'
274
+ name_match = re.search(r'#\d+:\s*(.+)$', line)
275
+ if name_match:
276
+ feature_name = name_match.group(1)
277
+
278
+ self.active_agents[key] = {
279
+ 'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
280
+ 'agent_index': agent_index,
281
+ 'agent_type': agent_type,
282
+ 'feature_ids': [feature_id],
283
+ 'state': 'thinking',
284
+ 'feature_name': feature_name,
285
+ 'last_thought': 'Starting work...',
286
+ }
287
+
288
+ return {
289
+ 'type': 'agent_update',
290
+ 'agentIndex': agent_index,
291
+ 'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
292
+ 'agentType': agent_type,
293
+ 'featureId': feature_id,
294
+ 'featureIds': [feature_id],
295
+ 'featureName': feature_name,
296
+ 'state': 'thinking',
297
+ 'thought': 'Starting work...',
298
+ 'timestamp': datetime.now().isoformat(),
299
+ }
300
+
301
+ async def _handle_batch_agent_start(self, feature_ids: list[int], agent_type: str = "coding") -> dict | None:
302
+ """Handle batch agent start message from orchestrator."""
303
+ if not feature_ids:
304
+ return None
305
+ primary_id = feature_ids[0]
306
+ async with self._lock:
307
+ key = (primary_id, agent_type)
308
+ agent_index = self._next_agent_index
309
+ self._next_agent_index += 1
310
+
311
+ feature_name = f'Features {", ".join(f"#{fid}" for fid in feature_ids)}'
312
+
313
+ self.active_agents[key] = {
314
+ 'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
315
+ 'agent_index': agent_index,
316
+ 'agent_type': agent_type,
317
+ 'feature_ids': list(feature_ids),
318
+ 'current_feature_id': primary_id,
319
+ 'state': 'thinking',
320
+ 'feature_name': feature_name,
321
+ 'last_thought': 'Starting batch work...',
322
+ }
323
+
324
+ # Register all feature IDs so output lines can find this agent
325
+ for fid in feature_ids:
326
+ secondary_key = (fid, agent_type)
327
+ if secondary_key != key:
328
+ self.active_agents[secondary_key] = self.active_agents[key]
329
+
330
+ return {
331
+ 'type': 'agent_update',
332
+ 'agentIndex': agent_index,
333
+ 'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
334
+ 'agentType': agent_type,
335
+ 'featureId': primary_id,
336
+ 'featureIds': list(feature_ids),
337
+ 'featureName': feature_name,
338
+ 'state': 'thinking',
339
+ 'thought': 'Starting batch work...',
340
+ 'timestamp': datetime.now().isoformat(),
341
+ }
342
+
343
+ async def _handle_agent_complete(self, feature_id: int, is_success: bool, agent_type: str = "coding") -> dict | None:
344
+ """Handle agent completion - ALWAYS emits a message, even if agent wasn't tracked.
345
+
346
+ Args:
347
+ feature_id: The feature ID.
348
+ is_success: Whether the agent completed successfully.
349
+ agent_type: The agent type ("coding" or "testing"). Defaults to "coding".
350
+ """
351
+ async with self._lock:
352
+ key = (feature_id, agent_type) # Composite key for correct agent lookup
353
+ state = 'success' if is_success else 'error'
354
+
355
+ if key in self.active_agents:
356
+ # Normal case: agent was tracked
357
+ agent = self.active_agents[key]
358
+ result = {
359
+ 'type': 'agent_update',
360
+ 'agentIndex': agent['agent_index'],
361
+ 'agentName': agent['name'],
362
+ 'agentType': agent.get('agent_type', agent_type),
363
+ 'featureId': feature_id,
364
+ 'featureIds': agent.get('feature_ids', [feature_id]),
365
+ 'featureName': agent['feature_name'],
366
+ 'state': state,
367
+ 'thought': 'Completed successfully!' if is_success else 'Failed to complete',
368
+ 'timestamp': datetime.now().isoformat(),
369
+ }
370
+ del self.active_agents[key]
371
+ return result
372
+ else:
373
+ # Synthetic completion for untracked agent
374
+ # This ensures UI always receives completion messages
375
+ return {
376
+ 'type': 'agent_update',
377
+ 'agentIndex': -1, # Sentinel for untracked
378
+ 'agentName': 'Unknown',
379
+ 'agentType': agent_type,
380
+ 'featureId': feature_id,
381
+ 'featureIds': [feature_id],
382
+ 'featureName': f'Feature #{feature_id}',
383
+ 'state': state,
384
+ 'thought': 'Completed successfully!' if is_success else 'Failed to complete',
385
+ 'timestamp': datetime.now().isoformat(),
386
+ 'synthetic': True,
387
+ }
388
+
389
+ async def _handle_batch_agent_complete(self, feature_ids: list[int], is_success: bool, agent_type: str = "coding") -> dict | None:
390
+ """Handle batch agent completion."""
391
+ if not feature_ids:
392
+ return None
393
+ primary_id = feature_ids[0]
394
+ async with self._lock:
395
+ state = 'success' if is_success else 'error'
396
+ key = (primary_id, agent_type)
397
+
398
+ if key in self.active_agents:
399
+ agent = self.active_agents[key]
400
+ result = {
401
+ 'type': 'agent_update',
402
+ 'agentIndex': agent['agent_index'],
403
+ 'agentName': agent['name'],
404
+ 'agentType': agent.get('agent_type', agent_type),
405
+ 'featureId': primary_id,
406
+ 'featureIds': agent.get('feature_ids', list(feature_ids)),
407
+ 'featureName': agent['feature_name'],
408
+ 'state': state,
409
+ 'thought': 'Batch completed successfully!' if is_success else 'Batch failed to complete',
410
+ 'timestamp': datetime.now().isoformat(),
411
+ }
412
+ # Clean up all keys for this batch
413
+ for fid in feature_ids:
414
+ self.active_agents.pop((fid, agent_type), None)
415
+ return result
416
+ else:
417
+ # Synthetic completion
418
+ return {
419
+ 'type': 'agent_update',
420
+ 'agentIndex': -1,
421
+ 'agentName': 'Unknown',
422
+ 'agentType': agent_type,
423
+ 'featureId': primary_id,
424
+ 'featureIds': list(feature_ids),
425
+ 'featureName': f'Features {", ".join(f"#{fid}" for fid in feature_ids)}',
426
+ 'state': state,
427
+ 'thought': 'Batch completed successfully!' if is_success else 'Batch failed to complete',
428
+ 'timestamp': datetime.now().isoformat(),
429
+ 'synthetic': True,
430
+ }
431
+
432
+
433
+ class OrchestratorTracker:
434
+ """Tracks orchestrator state for Mission Control observability.
435
+
436
+ Parses orchestrator stdout for key events and emits orchestrator_update
437
+ WebSocket messages showing what decisions the orchestrator is making.
438
+ """
439
+
440
+ def __init__(self):
441
+ self.state = 'idle'
442
+ self.coding_agents = 0
443
+ self.testing_agents = 0
444
+ self.max_concurrency = 3 # Default, will be updated from output
445
+ self.ready_count = 0
446
+ self.blocked_count = 0
447
+ self.recent_events: list[dict] = []
448
+ self._lock = asyncio.Lock()
449
+
450
+ async def process_line(self, line: str) -> dict | None:
451
+ """
452
+ Process an output line and return an orchestrator_update message if relevant.
453
+
454
+ Returns None if no update should be emitted.
455
+ """
456
+ async with self._lock:
457
+ update = None
458
+
459
+ # Check for initializer start
460
+ if ORCHESTRATOR_PATTERNS['init_start'].search(line):
461
+ self.state = 'initializing'
462
+ update = self._create_update(
463
+ 'init_start',
464
+ 'Initializing project features...'
465
+ )
466
+
467
+ # Check for initializer complete
468
+ elif ORCHESTRATOR_PATTERNS['init_complete'].search(line):
469
+ self.state = 'scheduling'
470
+ update = self._create_update(
471
+ 'init_complete',
472
+ 'Initialization complete, preparing to schedule features'
473
+ )
474
+
475
+ # Check for capacity status
476
+ elif match := ORCHESTRATOR_PATTERNS['capacity_check'].search(line):
477
+ self.ready_count = int(match.group(1))
478
+ slots = int(match.group(2))
479
+ self.state = 'scheduling' if self.ready_count > 0 else 'monitoring'
480
+ update = self._create_update(
481
+ 'capacity_check',
482
+ f'{self.ready_count} features ready, {slots} slots available'
483
+ )
484
+
485
+ # Check for at capacity
486
+ elif ORCHESTRATOR_PATTERNS['at_capacity'].search(line):
487
+ self.state = 'monitoring'
488
+ update = self._create_update(
489
+ 'at_capacity',
490
+ 'At maximum capacity, monitoring active agents'
491
+ )
492
+
493
+ # Check for feature start
494
+ elif match := ORCHESTRATOR_PATTERNS['feature_start'].search(line):
495
+ feature_id = int(match.group(1))
496
+ feature_name = match.group(2).strip()
497
+ self.state = 'spawning'
498
+ update = self._create_update(
499
+ 'feature_start',
500
+ f'Preparing Feature #{feature_id}: {feature_name}',
501
+ feature_id=feature_id,
502
+ feature_name=feature_name
503
+ )
504
+
505
+ # Check for coding agent spawn
506
+ elif match := ORCHESTRATOR_PATTERNS['coding_spawn'].search(line):
507
+ feature_id = int(match.group(1))
508
+ self.coding_agents += 1
509
+ self.state = 'spawning'
510
+ update = self._create_update(
511
+ 'coding_spawn',
512
+ f'Spawned coding agent for Feature #{feature_id}',
513
+ feature_id=feature_id
514
+ )
515
+
516
+ # Check for testing agent spawn
517
+ elif match := ORCHESTRATOR_PATTERNS['testing_spawn'].search(line):
518
+ feature_id = int(match.group(1))
519
+ self.testing_agents += 1
520
+ self.state = 'spawning'
521
+ update = self._create_update(
522
+ 'testing_spawn',
523
+ f'Spawned testing agent for Feature #{feature_id}',
524
+ feature_id=feature_id
525
+ )
526
+
527
+ # Check for coding agent complete
528
+ elif match := ORCHESTRATOR_PATTERNS['coding_complete'].search(line):
529
+ # Only match if "testing" is not in the line
530
+ if 'testing' not in line.lower():
531
+ feature_id = int(match.group(1))
532
+ self.coding_agents = max(0, self.coding_agents - 1)
533
+ self.state = 'monitoring'
534
+ update = self._create_update(
535
+ 'coding_complete',
536
+ f'Coding agent finished Feature #{feature_id}',
537
+ feature_id=feature_id
538
+ )
539
+
540
+ # Check for testing agent complete
541
+ elif match := ORCHESTRATOR_PATTERNS['testing_complete'].search(line):
542
+ feature_id = int(match.group(1))
543
+ self.testing_agents = max(0, self.testing_agents - 1)
544
+ self.state = 'monitoring'
545
+ update = self._create_update(
546
+ 'testing_complete',
547
+ f'Testing agent finished Feature #{feature_id}',
548
+ feature_id=feature_id
549
+ )
550
+
551
+ # Check for blocked features count
552
+ elif match := ORCHESTRATOR_PATTERNS['blocked_features'].search(line):
553
+ self.blocked_count = int(match.group(1))
554
+
555
+ # Check for all complete
556
+ elif ORCHESTRATOR_PATTERNS['all_complete'].search(line):
557
+ self.state = 'complete'
558
+ self.coding_agents = 0
559
+ self.testing_agents = 0
560
+ update = self._create_update(
561
+ 'all_complete',
562
+ 'All features complete!'
563
+ )
564
+
565
+ return update
566
+
567
+ def _create_update(
568
+ self,
569
+ event_type: str,
570
+ message: str,
571
+ feature_id: int | None = None,
572
+ feature_name: str | None = None
573
+ ) -> dict:
574
+ """Create an orchestrator_update WebSocket message."""
575
+ timestamp = datetime.now().isoformat()
576
+
577
+ # Add to recent events (keep last 5)
578
+ event: dict[str, str | int] = {
579
+ 'eventType': event_type,
580
+ 'message': message,
581
+ 'timestamp': timestamp,
582
+ }
583
+ if feature_id is not None:
584
+ event['featureId'] = feature_id
585
+ if feature_name is not None:
586
+ event['featureName'] = feature_name
587
+
588
+ self.recent_events = [event] + self.recent_events[:4]
589
+
590
+ update = {
591
+ 'type': 'orchestrator_update',
592
+ 'eventType': event_type,
593
+ 'state': self.state,
594
+ 'message': message,
595
+ 'timestamp': timestamp,
596
+ 'codingAgents': self.coding_agents,
597
+ 'testingAgents': self.testing_agents,
598
+ 'maxConcurrency': self.max_concurrency,
599
+ 'readyCount': self.ready_count,
600
+ 'blockedCount': self.blocked_count,
601
+ }
602
+
603
+ if feature_id is not None:
604
+ update['featureId'] = feature_id
605
+ if feature_name is not None:
606
+ update['featureName'] = feature_name
607
+
608
+ return update
609
+
610
+ async def reset(self):
611
+ """Reset tracker state when orchestrator stops or crashes."""
612
+ async with self._lock:
613
+ self.state = 'idle'
614
+ self.coding_agents = 0
615
+ self.testing_agents = 0
616
+ self.ready_count = 0
617
+ self.blocked_count = 0
618
+ self.recent_events.clear()
619
+
620
+
621
+ def _get_count_passing_tests():
622
+ """Lazy import of count_passing_tests."""
623
+ global _count_passing_tests
624
+ if _count_passing_tests is None:
625
+ import sys
626
+ root = Path(__file__).parent.parent
627
+ if str(root) not in sys.path:
628
+ sys.path.insert(0, str(root))
629
+ from progress import count_passing_tests
630
+ _count_passing_tests = count_passing_tests
631
+ return _count_passing_tests
632
+
633
+
634
+ class ConnectionManager:
635
+ """Manages WebSocket connections per project."""
636
+
637
+ def __init__(self):
638
+ # project_name -> set of WebSocket connections
639
+ self.active_connections: dict[str, Set[WebSocket]] = {}
640
+ self._lock = asyncio.Lock()
641
+
642
+ async def connect(self, websocket: WebSocket, project_name: str):
643
+ """Accept a WebSocket connection for a project."""
644
+ await websocket.accept()
645
+
646
+ async with self._lock:
647
+ if project_name not in self.active_connections:
648
+ self.active_connections[project_name] = set()
649
+ self.active_connections[project_name].add(websocket)
650
+
651
+ async def disconnect(self, websocket: WebSocket, project_name: str):
652
+ """Remove a WebSocket connection."""
653
+ async with self._lock:
654
+ if project_name in self.active_connections:
655
+ self.active_connections[project_name].discard(websocket)
656
+ if not self.active_connections[project_name]:
657
+ del self.active_connections[project_name]
658
+
659
+ async def broadcast_to_project(self, project_name: str, message: dict):
660
+ """Broadcast a message to all connections for a project."""
661
+ async with self._lock:
662
+ connections = list(self.active_connections.get(project_name, set()))
663
+
664
+ dead_connections = []
665
+
666
+ for connection in connections:
667
+ try:
668
+ await connection.send_json(message)
669
+ except Exception:
670
+ dead_connections.append(connection)
671
+
672
+ # Clean up dead connections
673
+ if dead_connections:
674
+ async with self._lock:
675
+ for connection in dead_connections:
676
+ if project_name in self.active_connections:
677
+ self.active_connections[project_name].discard(connection)
678
+
679
+ def get_connection_count(self, project_name: str) -> int:
680
+ """Get number of active connections for a project."""
681
+ return len(self.active_connections.get(project_name, set()))
682
+
683
+
684
+ # Global connection manager
685
+ manager = ConnectionManager()
686
+
687
+ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Path):
688
+ """Poll database for progress changes and send updates."""
689
+ count_passing_tests = _get_count_passing_tests()
690
+ last_passing = -1
691
+ last_in_progress = -1
692
+ last_total = -1
693
+
694
+ while True:
695
+ try:
696
+ passing, in_progress, total = count_passing_tests(project_dir)
697
+
698
+ # Only send if changed
699
+ if passing != last_passing or in_progress != last_in_progress or total != last_total:
700
+ last_passing = passing
701
+ last_in_progress = in_progress
702
+ last_total = total
703
+ percentage = (passing / total * 100) if total > 0 else 0
704
+
705
+ await websocket.send_json({
706
+ "type": "progress",
707
+ "passing": passing,
708
+ "in_progress": in_progress,
709
+ "total": total,
710
+ "percentage": round(percentage, 1),
711
+ })
712
+
713
+ await asyncio.sleep(2) # Poll every 2 seconds
714
+ except asyncio.CancelledError:
715
+ raise
716
+ except Exception as e:
717
+ logger.warning(f"Progress polling error: {e}")
718
+ break
719
+
720
+
721
+ async def project_websocket(websocket: WebSocket, project_name: str):
722
+ """
723
+ WebSocket endpoint for project updates.
724
+
725
+ Streams:
726
+ - Progress updates (passing/total counts)
727
+ - Agent status changes
728
+ - Agent stdout/stderr lines
729
+ """
730
+ if not validate_project_name(project_name):
731
+ await websocket.close(code=4000, reason="Invalid project name")
732
+ return
733
+
734
+ project_dir = _get_project_path(project_name)
735
+ if not project_dir:
736
+ await websocket.close(code=4004, reason="Project not found in registry")
737
+ return
738
+
739
+ if not project_dir.exists():
740
+ await websocket.close(code=4004, reason="Project directory not found")
741
+ return
742
+
743
+ await manager.connect(websocket, project_name)
744
+
745
+ # Get agent manager and register callbacks
746
+ agent_manager = get_manager(project_name, project_dir, ROOT_DIR)
747
+
748
+ # Create agent tracker for multi-agent mode
749
+ agent_tracker = AgentTracker()
750
+
751
+ # Create orchestrator tracker for observability
752
+ orchestrator_tracker = OrchestratorTracker()
753
+
754
+ async def on_output(line: str):
755
+ """Handle agent output - broadcast to this WebSocket."""
756
+ try:
757
+ # Extract feature ID from line if present
758
+ feature_id = None
759
+ agent_index = None
760
+ match = FEATURE_ID_PATTERN.match(line)
761
+ if match:
762
+ feature_id = int(match.group(1))
763
+ agent_index, _ = await agent_tracker.get_agent_info(feature_id)
764
+
765
+ # Send the raw log line with optional feature/agent attribution
766
+ log_msg: dict[str, str | int] = {
767
+ "type": "log",
768
+ "line": line,
769
+ "timestamp": datetime.now().isoformat(),
770
+ }
771
+ if feature_id is not None:
772
+ log_msg["featureId"] = feature_id
773
+ if agent_index is not None:
774
+ log_msg["agentIndex"] = agent_index
775
+
776
+ await websocket.send_json(log_msg)
777
+
778
+ # Check if this line indicates agent activity (parallel mode)
779
+ # and emit agent_update messages if so
780
+ agent_update = await agent_tracker.process_line(line)
781
+ if agent_update:
782
+ await websocket.send_json(agent_update)
783
+
784
+ # Also check for orchestrator events and emit orchestrator_update messages
785
+ orch_update = await orchestrator_tracker.process_line(line)
786
+ if orch_update:
787
+ await websocket.send_json(orch_update)
788
+ except Exception:
789
+ pass # Connection may be closed
790
+
791
+ async def on_status_change(status: str):
792
+ """Handle status change - broadcast to this WebSocket."""
793
+ try:
794
+ await websocket.send_json({
795
+ "type": "agent_status",
796
+ "status": status,
797
+ })
798
+ # Reset trackers when agent stops OR crashes to prevent ghost agents on restart
799
+ if status in ("stopped", "crashed"):
800
+ await agent_tracker.reset()
801
+ await orchestrator_tracker.reset()
802
+ except Exception:
803
+ pass # Connection may be closed
804
+
805
+ # Register callbacks
806
+ agent_manager.add_output_callback(on_output)
807
+ agent_manager.add_status_callback(on_status_change)
808
+
809
+ # Get dev server manager and register callbacks
810
+ devserver_manager = get_devserver_manager(project_name, project_dir)
811
+
812
+ async def on_dev_output(line: str):
813
+ """Handle dev server output - broadcast to this WebSocket."""
814
+ try:
815
+ await websocket.send_json({
816
+ "type": "dev_log",
817
+ "line": line,
818
+ "timestamp": datetime.now().isoformat(),
819
+ })
820
+ except Exception:
821
+ pass # Connection may be closed
822
+
823
+ async def on_dev_status_change(status: str):
824
+ """Handle dev server status change - broadcast to this WebSocket."""
825
+ try:
826
+ await websocket.send_json({
827
+ "type": "dev_server_status",
828
+ "status": status,
829
+ "url": devserver_manager.detected_url,
830
+ })
831
+ except Exception:
832
+ pass # Connection may be closed
833
+
834
+ # Register dev server callbacks
835
+ devserver_manager.add_output_callback(on_dev_output)
836
+ devserver_manager.add_status_callback(on_dev_status_change)
837
+
838
+ # Start progress polling task
839
+ poll_task = asyncio.create_task(poll_progress(websocket, project_name, project_dir))
840
+
841
+ try:
842
+ # Send initial agent status
843
+ await websocket.send_json({
844
+ "type": "agent_status",
845
+ "status": agent_manager.status,
846
+ })
847
+
848
+ # Send initial dev server status
849
+ await websocket.send_json({
850
+ "type": "dev_server_status",
851
+ "status": devserver_manager.status,
852
+ "url": devserver_manager.detected_url,
853
+ })
854
+
855
+ # Send initial progress
856
+ count_passing_tests = _get_count_passing_tests()
857
+ passing, in_progress, total = count_passing_tests(project_dir)
858
+ percentage = (passing / total * 100) if total > 0 else 0
859
+ await websocket.send_json({
860
+ "type": "progress",
861
+ "passing": passing,
862
+ "in_progress": in_progress,
863
+ "total": total,
864
+ "percentage": round(percentage, 1),
865
+ })
866
+
867
+ # Keep connection alive and handle incoming messages
868
+ while True:
869
+ try:
870
+ # Wait for any incoming messages (ping/pong, commands, etc.)
871
+ data = await websocket.receive_text()
872
+ message = json.loads(data)
873
+
874
+ # Handle ping
875
+ if message.get("type") == "ping":
876
+ await websocket.send_json({"type": "pong"})
877
+
878
+ except WebSocketDisconnect:
879
+ break
880
+ except json.JSONDecodeError:
881
+ 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}")
884
+ break
885
+
886
+ finally:
887
+ # Clean up
888
+ poll_task.cancel()
889
+ try:
890
+ await poll_task
891
+ except asyncio.CancelledError:
892
+ pass
893
+
894
+ # Unregister agent callbacks
895
+ agent_manager.remove_output_callback(on_output)
896
+ agent_manager.remove_status_callback(on_status_change)
897
+
898
+ # Unregister dev server callbacks
899
+ devserver_manager.remove_output_callback(on_dev_output)
900
+ devserver_manager.remove_status_callback(on_dev_status_change)
901
+
902
+ # Disconnect from manager
903
+ await manager.disconnect(websocket, project_name)