diff --git a/CLAUDE.md b/CLAUDE.md
index 51c0949..a6857db 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,6 +45,9 @@ python autonomous_agent_demo.py --project-dir my-app # if registered
# YOLO mode: rapid prototyping without browser testing
python autonomous_agent_demo.py --project-dir my-app --yolo
+
+# Parallel mode: run multiple agents concurrently (1-5 agents)
+python autonomous_agent_demo.py --project-dir my-app --parallel --max-concurrency 3
```
### YOLO Mode (Rapid Prototyping)
@@ -95,6 +98,8 @@ npm run lint # Run ESLint
- `prompts.py` - Prompt template loading with project-specific fallback
- `progress.py` - Progress tracking, database queries, webhook notifications
- `registry.py` - Project registry for mapping names to paths (cross-platform)
+- `parallel_orchestrator.py` - Concurrent agent execution with dependency-aware scheduling
+- `api/dependency_resolver.py` - Cycle detection (Kahn's algorithm + DFS) and dependency validation
### Project Registry
@@ -121,26 +126,40 @@ The FastAPI server provides REST endpoints for the UI:
Features are stored in SQLite (`features.db`) via SQLAlchemy. The agent interacts with features through an MCP server:
- `mcp_server/feature_mcp.py` - MCP server exposing feature management tools
-- `api/database.py` - SQLAlchemy models (Feature table with priority, category, name, description, steps, passes)
+- `api/database.py` - SQLAlchemy models (Feature table with priority, category, name, description, steps, passes, dependencies)
MCP tools available to the agent:
- `feature_get_stats` - Progress statistics
-- `feature_get_next` - Get highest-priority pending feature
+- `feature_get_next` - Get highest-priority pending feature (respects dependencies)
+- `feature_claim_next` - Atomically claim next available feature (for parallel mode)
- `feature_get_for_regression` - Random passing features for regression testing
- `feature_mark_passing` - Mark feature complete
- `feature_skip` - Move feature to end of queue
- `feature_create_bulk` - Initialize all features (used by initializer)
+- `feature_add_dependency` - Add dependency between features (with cycle detection)
+- `feature_remove_dependency` - Remove a dependency
### React UI (ui/)
-- Tech stack: React 18, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI
+- Tech stack: React 18, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI, dagre (graph layout)
- `src/App.tsx` - Main app with project selection, kanban board, agent controls
-- `src/hooks/useWebSocket.ts` - Real-time updates via WebSocket
+- `src/hooks/useWebSocket.ts` - Real-time updates via WebSocket (progress, agent status, logs, agent updates)
- `src/hooks/useProjects.ts` - React Query hooks for API calls
- `src/lib/api.ts` - REST API client
- `src/lib/types.ts` - TypeScript type definitions
-- `src/components/FolderBrowser.tsx` - Server-side filesystem browser for project folder selection
-- `src/components/NewProjectModal.tsx` - Multi-step project creation wizard
+
+Key components:
+- `AgentMissionControl.tsx` - Dashboard showing active agents with mascots (Spark, Fizz, Octo, Hoot, Buzz)
+- `DependencyGraph.tsx` - Interactive node graph visualization with dagre layout
+- `CelebrationOverlay.tsx` - Confetti animation on feature completion
+- `FolderBrowser.tsx` - Server-side filesystem browser for project folder selection
+
+Keyboard shortcuts (press `?` for help):
+- `D` - Toggle debug panel
+- `G` - Toggle Kanban/Graph view
+- `N` - Add new feature
+- `A` - Toggle AI assistant
+- `,` - Open settings
### Project Structure for Generated Apps
@@ -181,10 +200,20 @@ Defense-in-depth approach configured in `client.py`:
### Real-time UI Updates
The UI receives updates via WebSocket (`/ws/projects/{project_name}`):
-- `progress` - Test pass counts
+- `progress` - Test pass counts (passing, in_progress, total)
- `agent_status` - Running/paused/stopped/crashed
-- `log` - Agent output lines (streamed from subprocess stdout)
+- `log` - Agent output lines with optional featureId/agentIndex for attribution
- `feature_update` - Feature status changes
+- `agent_update` - Multi-agent state updates (thinking/working/testing/success/error) with mascot names
+
+### Parallel Mode
+
+When running with `--parallel`, the orchestrator:
+1. Spawns multiple Claude agents as subprocesses (up to `--max-concurrency`)
+2. Each agent claims features atomically via `feature_claim_next`
+3. Features blocked by unmet dependencies are skipped
+4. Browser contexts are isolated per agent using `--isolated` flag
+5. AgentTracker parses output and emits `agent_update` messages for UI
### Design System
diff --git a/agent.py b/agent.py
index c6199b4..79d585c 100644
--- a/agent.py
+++ b/agent.py
@@ -203,8 +203,14 @@ async def run_autonomous_agent(
prompt = get_coding_prompt(project_dir)
# Run session with async context manager
- async with client:
- status, response = await run_agent_session(client, prompt, project_dir)
+ # Wrap in try/except to handle MCP server startup failures gracefully
+ try:
+ async with client:
+ status, response = await run_agent_session(client, prompt, project_dir)
+ except Exception as e:
+ print(f"Client/MCP server error: {e}")
+ # Don't crash - return error status so the loop can retry
+ status, response = "error", str(e)
# Handle status
if status == "continue":
diff --git a/api/dependency_resolver.py b/api/dependency_resolver.py
index daaad17..3e1980b 100644
--- a/api/dependency_resolver.py
+++ b/api/dependency_resolver.py
@@ -245,6 +245,81 @@ def _detect_cycles(features: list[dict], feature_map: dict) -> list[list[int]]:
return cycles
+def compute_scheduling_scores(features: list[dict]) -> dict[int, float]:
+ """Compute scheduling scores for all features.
+
+ Higher scores mean higher priority for scheduling. The algorithm considers:
+ 1. Unblocking potential - Features that unblock more downstream work score higher
+ 2. Depth in graph - Features with no dependencies (roots) are "shovel-ready"
+ 3. User priority - Existing priority field as tiebreaker
+
+ Score formula: (1000 * unblock) + (100 * depth_score) + (10 * priority_factor)
+
+ Args:
+ features: List of feature dicts with id, priority, dependencies fields
+
+ Returns:
+ Dict mapping feature_id -> score (higher = schedule first)
+ """
+ if not features:
+ return {}
+
+ # Build adjacency lists
+ children: dict[int, list[int]] = {f["id"]: [] for f in features} # who depends on me
+ parents: dict[int, list[int]] = {f["id"]: [] for f in features} # who I depend on
+
+ for f in features:
+ for dep_id in (f.get("dependencies") or []):
+ if dep_id in children: # Only valid deps
+ children[dep_id].append(f["id"])
+ parents[f["id"]].append(dep_id)
+
+ # Calculate depths via BFS from roots
+ depths: dict[int, int] = {}
+ roots = [f["id"] for f in features if not parents[f["id"]]]
+ queue = [(root, 0) for root in roots]
+ while queue:
+ node_id, depth = queue.pop(0)
+ if node_id not in depths or depth > depths[node_id]:
+ depths[node_id] = depth
+ for child_id in children[node_id]:
+ queue.append((child_id, depth + 1))
+
+ # Handle orphaned nodes (shouldn't happen but be safe)
+ for f in features:
+ if f["id"] not in depths:
+ depths[f["id"]] = 0
+
+ # Calculate transitive downstream counts (reverse topo order)
+ downstream: dict[int, int] = {f["id"]: 0 for f in features}
+ # Process in reverse depth order (leaves first)
+ for fid in sorted(depths.keys(), key=lambda x: -depths[x]):
+ for parent_id in parents[fid]:
+ downstream[parent_id] += 1 + downstream[fid]
+
+ # Normalize and compute scores
+ max_depth = max(depths.values()) if depths else 0
+ max_downstream = max(downstream.values()) if downstream else 0
+
+ scores: dict[int, float] = {}
+ for f in features:
+ fid = f["id"]
+
+ # Unblocking score: 0-1, higher = unblocks more
+ unblock = downstream[fid] / max_downstream if max_downstream > 0 else 0
+
+ # Depth score: 0-1, higher = closer to root (no deps)
+ depth_score = 1 - (depths[fid] / max_depth) if max_depth > 0 else 1
+
+ # Priority factor: 0-1, lower priority number = higher factor
+ priority = f.get("priority", 999)
+ priority_factor = (10 - min(priority, 10)) / 10
+
+ scores[fid] = (1000 * unblock) + (100 * depth_score) + (10 * priority_factor)
+
+ return scores
+
+
def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]:
"""Get features that are ready to be worked on.
@@ -270,8 +345,9 @@ def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]:
if all(dep_id in passing_ids for dep_id in deps):
ready.append(f)
- # Sort by priority
- ready.sort(key=lambda f: (f.get("priority", 999), f["id"]))
+ # Sort by scheduling score (higher = first), then priority, then id
+ scores = compute_scheduling_scores(features)
+ ready.sort(key=lambda f: (-scores.get(f["id"], 0), f.get("priority", 999), f["id"]))
return ready[:limit]
diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py
index f640fc5..f3f7c8d 100755
--- a/mcp_server/feature_mcp.py
+++ b/mcp_server/feature_mcp.py
@@ -41,6 +41,7 @@ from api.dependency_resolver import (
would_create_circular_dependency,
are_dependencies_satisfied,
get_blocking_dependencies,
+ compute_scheduling_scores,
MAX_DEPENDENCIES_PER_FEATURE,
)
@@ -178,7 +179,11 @@ def feature_get_next() -> str:
# Get pending, non-in-progress features
pending = [f for f in all_features if not f.passes and not f.in_progress]
- pending.sort(key=lambda f: (f.priority, f.id))
+
+ # Sort by scheduling score (higher = first), then priority, then id
+ all_dicts = [f.to_dict() for f in all_features]
+ scores = compute_scheduling_scores(all_dicts)
+ pending.sort(key=lambda f: (-scores.get(f.id, 0), f.priority, f.id))
if not pending:
if any(f.in_progress for f in all_features if not f.passes):
@@ -247,7 +252,11 @@ def _feature_claim_next_internal(attempt: int = 0) -> str:
# Get pending, non-in-progress features
pending = [f for f in all_features if not f.passes and not f.in_progress]
- pending.sort(key=lambda f: (f.priority, f.id))
+
+ # Sort by scheduling score (higher = first), then priority, then id
+ all_dicts = [f.to_dict() for f in all_features]
+ scores = compute_scheduling_scores(all_dicts)
+ pending.sort(key=lambda f: (-scores.get(f.id, 0), f.priority, f.id))
if not pending:
if any(f.in_progress for f in all_features if not f.passes):
@@ -814,6 +823,7 @@ def feature_get_ready(
passing_ids = {f.id for f in all_features if f.passes}
ready = []
+ all_dicts = [f.to_dict() for f in all_features]
for f in all_features:
if f.passes or f.in_progress:
continue
@@ -821,8 +831,9 @@ def feature_get_ready(
if all(dep_id in passing_ids for dep_id in deps):
ready.append(f.to_dict())
- # Sort by priority
- ready.sort(key=lambda f: (f["priority"], f["id"]))
+ # Sort by scheduling score (higher = first), then priority, then id
+ scores = compute_scheduling_scores(all_dicts)
+ ready.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
return json.dumps({
"features": ready[:limit],
diff --git a/parallel_orchestrator.py b/parallel_orchestrator.py
index 35d03c4..8b634f6 100644
--- a/parallel_orchestrator.py
+++ b/parallel_orchestrator.py
@@ -20,7 +20,7 @@ from pathlib import Path
from typing import Callable, Awaitable
from api.database import Feature, create_database
-from api.dependency_resolver import are_dependencies_satisfied
+from api.dependency_resolver import are_dependencies_satisfied, compute_scheduling_scores
# Root directory of autocoder (where this script and autonomous_agent_demo.py live)
AUTOCODER_ROOT = Path(__file__).parent.resolve()
@@ -103,8 +103,10 @@ class ParallelOrchestrator:
continue
resumable.append(f.to_dict())
- # Sort by priority (highest priority first)
- resumable.sort(key=lambda f: (f["priority"], f["id"]))
+ # Sort by scheduling score (higher = first), then priority, then id
+ all_dicts = [f.to_dict() for f in session.query(Feature).all()]
+ scores = compute_scheduling_scores(all_dicts)
+ resumable.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
return resumable
finally:
session.close()
@@ -131,18 +133,25 @@ class ParallelOrchestrator:
if are_dependencies_satisfied(f.to_dict(), all_dicts):
ready.append(f.to_dict())
- # Sort by priority
- ready.sort(key=lambda f: (f["priority"], f["id"]))
+ # Sort by scheduling score (higher = first), then priority, then id
+ scores = compute_scheduling_scores(all_dicts)
+ ready.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
return ready
finally:
session.close()
def get_all_complete(self) -> bool:
- """Check if all features are complete."""
+ """Check if all features are complete or permanently failed."""
session = self.get_session()
try:
- pending = session.query(Feature).filter(Feature.passes == False).count()
- return pending == 0
+ all_features = session.query(Feature).all()
+ for f in all_features:
+ if f.passes:
+ continue # Completed successfully
+ if self._failure_counts.get(f.id, 0) >= MAX_FEATURE_RETRIES:
+ continue # Permanently failed, count as "done"
+ return False # Still workable
+ return True
finally:
session.close()
@@ -289,6 +298,7 @@ class ParallelOrchestrator:
status = "completed" if return_code == 0 else "failed"
if self.on_status:
self.on_status(feature_id, status)
+ # CRITICAL: This print triggers the WebSocket to emit agent_update with state='error' or 'success'
print(f"Feature #{feature_id} {status}", flush=True)
def stop_feature(self, feature_id: int) -> tuple[bool, str]:
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index fbaff40..148dc66 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -336,6 +336,7 @@ function App() {
+ Feature #{agent.featureId}: {agent.featureName} +
+No logs available
+ ) : ( +