feat: add multi-feature batching for coding agents

Enable the orchestrator to assign 1-3 features per coding agent subprocess,
selected via dependency chain extension + same-category fill. This reduces
cold-start overhead and leverages shared context across related features.

Orchestrator (parallel_orchestrator.py):
- Add batch tracking: _batch_features and _feature_to_primary data structures
- Add build_feature_batches() with dependency chain + category fill algorithm
- Add start_feature_batch() and _spawn_coding_agent_batch() methods
- Update _on_agent_complete() for batch cleanup across all features
- Update stop_feature() with _feature_to_primary lookup
- Update get_ready_features() to exclude all batch feature IDs
- Update main loop to build batches then spawn per available slot

CLI and agent layer:
- Add --feature-ids (comma-separated) and --batch-size CLI args
- Add feature_ids parameter to run_autonomous_agent() with batch prompt selection
- Add get_batch_feature_prompt() with sequential workflow instructions

WebSocket layer (server/websocket.py):
- Add BATCH_CODING_AGENT_START_PATTERN and BATCH_FEATURES_COMPLETE_PATTERN
- Add _handle_batch_agent_start() and _handle_batch_agent_complete() methods
- Add featureIds field to all agent_update messages
- Track current_feature_id updates as agent moves through batch

Frontend (React UI):
- Add featureIds to ActiveAgent and WSAgentUpdateMessage types
- Update KanbanColumn and DependencyGraph agent-feature maps for batch
- Update AgentCard to show "Batch: #X, #Y, #Z" with active feature highlight
- Add "Features per Agent" segmented control (1-3) in SettingsModal

Settings integration (full stack):
- Add batch_size to schemas, settings router, agent router, process manager
- Default batch_size=3, user-configurable 1-3 via settings UI
- batch_size=1 is functionally identical to pre-batching behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-02-01 16:35:07 +02:00
parent e1e5209866
commit 1607fc8175
16 changed files with 654 additions and 82 deletions

View File

@@ -17,11 +17,11 @@ from ..utils.project_helpers import get_project_path as _get_project_path
from ..utils.validation import validate_project_name
def _get_settings_defaults() -> tuple[bool, str, int, bool]:
def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
"""Get defaults from global settings.
Returns:
Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless)
Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size)
"""
import sys
root = Path(__file__).parent.parent.parent
@@ -42,7 +42,12 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool]:
playwright_headless = (settings.get("playwright_headless") or "true").lower() == "true"
return yolo_mode, model, testing_agent_ratio, playwright_headless
try:
batch_size = int(settings.get("batch_size", "3"))
except (ValueError, TypeError):
batch_size = 3
return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
@@ -91,19 +96,22 @@ async def start_agent(
manager = get_project_manager(project_name)
# Get defaults from global settings if not provided in request
default_yolo, default_model, default_testing_ratio, playwright_headless = _get_settings_defaults()
default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size = _get_settings_defaults()
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
model = request.model if request.model else default_model
max_concurrency = request.max_concurrency or 1
testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio
batch_size = default_batch_size
success, message = await manager.start(
yolo_mode=yolo_mode,
model=model,
max_concurrency=max_concurrency,
testing_agent_ratio=testing_agent_ratio,
playwright_headless=playwright_headless,
batch_size=batch_size,
)
# Notify scheduler of manual start (to prevent auto-stop during scheduled window)

View File

@@ -92,6 +92,7 @@ async def get_settings():
ollama_mode=_is_ollama_mode(),
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
batch_size=_parse_int(all_settings.get("batch_size"), 3),
)
@@ -110,6 +111,9 @@ async def update_settings(update: SettingsUpdate):
if update.playwright_headless is not None:
set_setting("playwright_headless", "true" if update.playwright_headless else "false")
if update.batch_size is not None:
set_setting("batch_size", str(update.batch_size))
# Return updated settings
all_settings = get_all_settings()
return SettingsResponse(
@@ -119,4 +123,5 @@ async def update_settings(update: SettingsUpdate):
ollama_mode=_is_ollama_mode(),
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
batch_size=_parse_int(all_settings.get("batch_size"), 3),
)

View File

@@ -399,6 +399,7 @@ class SettingsResponse(BaseModel):
ollama_mode: bool = False # True if Ollama API is configured via .env
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
playwright_headless: bool = True
batch_size: int = 3 # Features per coding agent batch (1-3)
class ModelsResponse(BaseModel):
@@ -413,6 +414,7 @@ class SettingsUpdate(BaseModel):
model: str | None = None
testing_agent_ratio: int | None = None # 0-3
playwright_headless: bool | None = None
batch_size: int | None = None # Features per agent batch (1-3)
@field_validator('model')
@classmethod
@@ -428,6 +430,13 @@ class SettingsUpdate(BaseModel):
raise ValueError("testing_agent_ratio must be between 0 and 3")
return v
@field_validator('batch_size')
@classmethod
def validate_batch_size(cls, v: int | None) -> int | None:
if v is not None and (v < 1 or v > 3):
raise ValueError("batch_size must be between 1 and 3")
return v
# ============================================================================
# Dev Server Schemas

View File

@@ -298,6 +298,7 @@ class AgentProcessManager:
max_concurrency: int | None = None,
testing_agent_ratio: int = 1,
playwright_headless: bool = True,
batch_size: int = 3,
) -> tuple[bool, str]:
"""
Start the agent as a subprocess.
@@ -349,6 +350,9 @@ class AgentProcessManager:
# Add testing agent configuration
cmd.extend(["--testing-ratio", str(testing_agent_ratio)])
# Add --batch-size flag for multi-feature batching
cmd.extend(["--batch-size", str(batch_size)])
try:
# Start subprocess with piped stdout/stderr
# Use project_dir as cwd so Claude SDK sandbox allows access to project files

View File

@@ -39,6 +39,14 @@ TESTING_AGENT_START_PATTERN = re.compile(r'Started testing agent for feature #(\
# Matches: "Feature #123 testing completed" or "Feature #123 testing failed"
TESTING_AGENT_COMPLETE_PATTERN = re.compile(r'Feature #(\d+) testing (completed|failed)')
# Pattern to detect batch coding agent start message
# Matches: "Started coding agent for features #5, #8, #12"
BATCH_CODING_AGENT_START_PATTERN = re.compile(r'Started coding agent for features (#\d+(?:,\s*#\d+)*)')
# Pattern to detect batch completion
# Matches: "Features #5, #8, #12 completed" or "Features #5, #8, #12 failed"
BATCH_FEATURES_COMPLETE_PATTERN = re.compile(r'Features (#\d+(?:,\s*#\d+)*)\s+(completed|failed)')
# Patterns for detecting agent activity and thoughts
THOUGHT_PATTERNS = [
# Claude's tool usage patterns (actual format: [Tool: name])
@@ -64,9 +72,9 @@ ORCHESTRATOR_PATTERNS = {
'capacity_check': re.compile(r'\[DEBUG\] Spawning loop: (\d+) ready, (\d+) slots'),
'at_capacity': re.compile(r'At max capacity|at max testing agents|At max total agents'),
'feature_start': re.compile(r'Starting feature \d+/\d+: #(\d+) - (.+)'),
'coding_spawn': re.compile(r'Started coding agent for feature #(\d+)'),
'coding_spawn': re.compile(r'Started coding agent for features? #(\d+)'),
'testing_spawn': re.compile(r'Started testing agent for feature #(\d+)'),
'coding_complete': re.compile(r'Feature #(\d+) (completed|failed)'),
'coding_complete': re.compile(r'Features? #(\d+)(?:,\s*#\d+)* (completed|failed)'),
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
'all_complete': re.compile(r'All features complete'),
'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
@@ -96,7 +104,17 @@ class AgentTracker:
# Check for orchestrator status messages first
# These don't have [Feature #X] prefix
# Coding agent start: "Started coding agent for feature #X"
# Batch coding agent start: "Started coding agent for features #5, #8, #12"
batch_start_match = BATCH_CODING_AGENT_START_PATTERN.match(line)
if batch_start_match:
try:
feature_ids = [int(x.strip().lstrip('#')) for x in batch_start_match.group(1).split(',')]
if feature_ids:
return await self._handle_batch_agent_start(feature_ids, "coding")
except ValueError:
pass
# Single coding agent start: "Started coding agent for feature #X"
if line.startswith("Started coding agent for feature #"):
m = re.search(r'#(\d+)', line)
if m:
@@ -119,6 +137,17 @@ class AgentTracker:
is_success = testing_complete_match.group(2) == "completed"
return await self._handle_agent_complete(feature_id, is_success, agent_type="testing")
# Batch features complete: "Features #5, #8, #12 completed/failed"
batch_complete_match = BATCH_FEATURES_COMPLETE_PATTERN.match(line)
if batch_complete_match:
try:
feature_ids = [int(x.strip().lstrip('#')) for x in batch_complete_match.group(1).split(',')]
is_success = batch_complete_match.group(2) == "completed"
if feature_ids:
return await self._handle_batch_agent_complete(feature_ids, is_success, "coding")
except ValueError:
pass
# Coding agent complete: "Feature #X completed/failed" (without "testing" keyword)
if line.startswith("Feature #") and ("completed" in line or "failed" in line) and "testing" not in line:
m = re.search(r'#(\d+)', line)
@@ -158,6 +187,7 @@ class AgentTracker:
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'agent_index': agent_index,
'agent_type': 'coding',
'feature_ids': [feature_id],
'state': 'thinking',
'feature_name': f'Feature #{feature_id}',
'last_thought': None,
@@ -165,6 +195,10 @@ class AgentTracker:
agent = self.active_agents[key]
# Update current_feature_id for batch agents when output comes from a different feature
if 'current_feature_id' in agent and feature_id in agent.get('feature_ids', []):
agent['current_feature_id'] = feature_id
# Detect state and thought from content
state = 'working'
thought = None
@@ -188,6 +222,7 @@ class AgentTracker:
'agentName': agent['name'],
'agentType': agent['agent_type'],
'featureId': feature_id,
'featureIds': agent.get('feature_ids', [feature_id]),
'featureName': agent['feature_name'],
'state': state,
'thought': thought,
@@ -244,6 +279,7 @@ class AgentTracker:
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'agent_index': agent_index,
'agent_type': agent_type,
'feature_ids': [feature_id],
'state': 'thinking',
'feature_name': feature_name,
'last_thought': 'Starting work...',
@@ -255,12 +291,55 @@ class AgentTracker:
'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'agentType': agent_type,
'featureId': feature_id,
'featureIds': [feature_id],
'featureName': feature_name,
'state': 'thinking',
'thought': 'Starting work...',
'timestamp': datetime.now().isoformat(),
}
async def _handle_batch_agent_start(self, feature_ids: list[int], agent_type: str = "coding") -> dict | None:
"""Handle batch agent start message from orchestrator."""
if not feature_ids:
return None
primary_id = feature_ids[0]
async with self._lock:
key = (primary_id, agent_type)
agent_index = self._next_agent_index
self._next_agent_index += 1
feature_name = f'Features {", ".join(f"#{fid}" for fid in feature_ids)}'
self.active_agents[key] = {
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'agent_index': agent_index,
'agent_type': agent_type,
'feature_ids': list(feature_ids),
'current_feature_id': primary_id,
'state': 'thinking',
'feature_name': feature_name,
'last_thought': 'Starting batch work...',
}
# Register all feature IDs so output lines can find this agent
for fid in feature_ids:
secondary_key = (fid, agent_type)
if secondary_key != key:
self.active_agents[secondary_key] = self.active_agents[key]
return {
'type': 'agent_update',
'agentIndex': agent_index,
'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'agentType': agent_type,
'featureId': primary_id,
'featureIds': list(feature_ids),
'featureName': feature_name,
'state': 'thinking',
'thought': 'Starting batch work...',
'timestamp': datetime.now().isoformat(),
}
async def _handle_agent_complete(self, feature_id: int, is_success: bool, agent_type: str = "coding") -> dict | None:
"""Handle agent completion - ALWAYS emits a message, even if agent wasn't tracked.
@@ -282,6 +361,7 @@ class AgentTracker:
'agentName': agent['name'],
'agentType': agent.get('agent_type', agent_type),
'featureId': feature_id,
'featureIds': agent.get('feature_ids', [feature_id]),
'featureName': agent['feature_name'],
'state': state,
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
@@ -298,6 +378,7 @@ class AgentTracker:
'agentName': 'Unknown',
'agentType': agent_type,
'featureId': feature_id,
'featureIds': [feature_id],
'featureName': f'Feature #{feature_id}',
'state': state,
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
@@ -305,6 +386,49 @@ class AgentTracker:
'synthetic': True,
}
async def _handle_batch_agent_complete(self, feature_ids: list[int], is_success: bool, agent_type: str = "coding") -> dict | None:
"""Handle batch agent completion."""
if not feature_ids:
return None
primary_id = feature_ids[0]
async with self._lock:
state = 'success' if is_success else 'error'
key = (primary_id, agent_type)
if key in self.active_agents:
agent = self.active_agents[key]
result = {
'type': 'agent_update',
'agentIndex': agent['agent_index'],
'agentName': agent['name'],
'agentType': agent.get('agent_type', agent_type),
'featureId': primary_id,
'featureIds': agent.get('feature_ids', list(feature_ids)),
'featureName': agent['feature_name'],
'state': state,
'thought': 'Batch completed successfully!' if is_success else 'Batch failed to complete',
'timestamp': datetime.now().isoformat(),
}
# Clean up all keys for this batch
for fid in feature_ids:
self.active_agents.pop((fid, agent_type), None)
return result
else:
# Synthetic completion
return {
'type': 'agent_update',
'agentIndex': -1,
'agentName': 'Unknown',
'agentType': agent_type,
'featureId': primary_id,
'featureIds': list(feature_ids),
'featureName': f'Features {", ".join(f"#{fid}" for fid in feature_ids)}',
'state': state,
'thought': 'Batch completed successfully!' if is_success else 'Batch failed to complete',
'timestamp': datetime.now().isoformat(),
'synthetic': True,
}
class OrchestratorTracker:
"""Tracks orchestrator state for Mission Control observability.