mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-02 23:33:35 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user