mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
refactor: orchestrator pre-selects features for all agents
Replace agent-initiated feature selection with orchestrator pre-selection for both coding and testing agents. This ensures Mission Control displays correct feature numbers for testing agents (previously showed "Feature #0"). Key changes: MCP Server (mcp_server/feature_mcp.py): - Add feature_get_by_id tool for agents to fetch assigned feature details - Remove obsolete tools: feature_get_next, feature_claim_next, feature_claim_for_testing, feature_get_for_regression - Remove helper functions and unused imports (text, OperationalError, func) Orchestrator (parallel_orchestrator.py): - Change running_testing_agents from list to dict[int, Popen] - Add claim_feature_for_testing() with random selection - Add release_testing_claim() method - Pass --testing-feature-id to spawned testing agents - Use unified [Feature #X] output format for both agent types Agent Entry Points: - autonomous_agent_demo.py: Add --testing-feature-id CLI argument - agent.py: Pass testing_feature_id to get_testing_prompt() Prompt Templates: - coding_prompt.template.md: Update to use feature_get_by_id - testing_prompt.template.md: Update workflow for pre-assigned features - prompts.py: Update pre-claimed headers for both agent types WebSocket (server/websocket.py): - Simplify tracking with unified [Feature #X] pattern - Remove testing-specific parsing code Assistant (server/services/assistant_chat_session.py): - Update help text with current available tools Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,28 +3,33 @@
|
||||
MCP Server for Feature Management
|
||||
==================================
|
||||
|
||||
Provides tools to manage features in the autonomous coding system,
|
||||
replacing the previous FastAPI-based REST API.
|
||||
Provides tools to manage features in the autonomous coding system.
|
||||
|
||||
Tools:
|
||||
- feature_get_stats: Get progress statistics
|
||||
- feature_get_next: Get next feature to implement
|
||||
- feature_get_for_regression: Get random passing features for testing
|
||||
- feature_get_by_id: Get a specific feature by ID
|
||||
- feature_mark_passing: Mark a feature as passing
|
||||
- feature_mark_failing: Mark a feature as failing (regression detected)
|
||||
- feature_skip: Skip a feature (move to end of queue)
|
||||
- feature_mark_in_progress: Mark a feature as in-progress
|
||||
- feature_clear_in_progress: Clear in-progress status
|
||||
- feature_release_testing: Release testing lock on a feature
|
||||
- feature_create_bulk: Create multiple features at once
|
||||
- feature_create: Create a single feature
|
||||
- feature_add_dependency: Add a dependency between features
|
||||
- feature_remove_dependency: Remove a dependency
|
||||
- feature_get_ready: Get features ready to implement
|
||||
- feature_get_blocked: Get features blocked by dependencies
|
||||
- feature_get_graph: Get the dependency graph
|
||||
|
||||
Note: Feature selection (which feature to work on) is handled by the
|
||||
orchestrator, not by agents. Agents receive pre-assigned feature IDs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time as _time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -32,9 +37,6 @@ from typing import Annotated
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
# Add parent directory to path so we can import from api module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
@@ -158,346 +160,32 @@ def feature_get_stats() -> str:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def feature_get_next() -> str:
|
||||
"""Get the highest-priority pending feature that has all dependencies satisfied.
|
||||
|
||||
Returns the feature with the lowest priority number that:
|
||||
1. Has passes=false and in_progress=false
|
||||
2. Has all dependency features already passing (or no dependencies)
|
||||
3. All dependency IDs actually exist (orphaned dependencies are ignored)
|
||||
|
||||
For backwards compatibility: if all pending features are blocked by dependencies,
|
||||
falls back to returning the first pending feature (same as before dependencies).
|
||||
|
||||
Returns:
|
||||
JSON with feature details (id, priority, category, name, description, steps, passes,
|
||||
in_progress, dependencies) or error message if all features are passing.
|
||||
"""
|
||||
session = get_session()
|
||||
try:
|
||||
all_features = session.query(Feature).all()
|
||||
all_feature_ids = {f.id for f in all_features}
|
||||
passing_ids = {f.id for f in all_features if f.passes}
|
||||
|
||||
# Get pending, non-in-progress features
|
||||
pending = [f for f in all_features if not f.passes and not f.in_progress]
|
||||
|
||||
# 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):
|
||||
return json.dumps({"error": "All pending features are in progress by other agents"})
|
||||
return json.dumps({"error": "All features are passing! No more work to do."})
|
||||
|
||||
# Find first feature with satisfied dependencies
|
||||
for feature in pending:
|
||||
deps = feature.dependencies or []
|
||||
# Filter out orphaned dependencies (IDs that no longer exist)
|
||||
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||
if all(dep_id in passing_ids for dep_id in valid_deps):
|
||||
return json.dumps(feature.to_dict(), indent=2)
|
||||
|
||||
# All pending features are blocked by unmet dependencies
|
||||
# Return error with details about what's blocking progress
|
||||
blocking_info = []
|
||||
for feature in pending[:3]: # Show first 3 blocked features
|
||||
deps = feature.dependencies or []
|
||||
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||
orphaned = [d for d in deps if d not in all_feature_ids]
|
||||
unmet = [d for d in valid_deps if d not in passing_ids]
|
||||
info = f"#{feature.id} '{feature.name}'"
|
||||
if unmet:
|
||||
info += f" blocked by: {unmet}"
|
||||
if orphaned:
|
||||
info += f" (orphaned deps ignored: {orphaned})"
|
||||
blocking_info.append(info)
|
||||
|
||||
return json.dumps({
|
||||
"error": "All pending features are blocked by unmet dependencies",
|
||||
"blocked_features": len(pending),
|
||||
"examples": blocking_info,
|
||||
"hint": "Complete the blocking dependencies first, or remove invalid dependencies"
|
||||
}, indent=2)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
# Maximum retry attempts for feature claiming under contention
|
||||
MAX_CLAIM_RETRIES = 5
|
||||
|
||||
|
||||
def _feature_claim_next_internal() -> str:
|
||||
"""Internal implementation of feature claiming with iterative retry.
|
||||
|
||||
Uses an iterative loop instead of recursion to avoid double session.close()
|
||||
issues and deep call stacks under contention.
|
||||
|
||||
Returns:
|
||||
JSON with claimed feature details, or error message if no feature available.
|
||||
"""
|
||||
for attempt in range(MAX_CLAIM_RETRIES):
|
||||
session = get_session()
|
||||
try:
|
||||
# Use a lock to prevent concurrent claims within this process
|
||||
with _priority_lock:
|
||||
all_features = session.query(Feature).all()
|
||||
all_feature_ids = {f.id for f in all_features}
|
||||
passing_ids = {f.id for f in all_features if f.passes}
|
||||
|
||||
# Get pending, non-in-progress features
|
||||
pending = [f for f in all_features if not f.passes and not f.in_progress]
|
||||
|
||||
# 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):
|
||||
return json.dumps({"error": "All pending features are in progress by other agents"})
|
||||
return json.dumps({"error": "All features are passing! No more work to do."})
|
||||
|
||||
# Find first feature with satisfied dependencies
|
||||
candidate_id = None
|
||||
for feature in pending:
|
||||
deps = feature.dependencies or []
|
||||
# Filter out orphaned dependencies (IDs that no longer exist)
|
||||
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||
if all(dep_id in passing_ids for dep_id in valid_deps):
|
||||
candidate_id = feature.id
|
||||
break
|
||||
|
||||
if candidate_id is None:
|
||||
# All pending features are blocked by unmet dependencies
|
||||
blocking_info = []
|
||||
for feature in pending[:3]:
|
||||
deps = feature.dependencies or []
|
||||
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||
orphaned = [d for d in deps if d not in all_feature_ids]
|
||||
unmet = [d for d in valid_deps if d not in passing_ids]
|
||||
info = f"#{feature.id} '{feature.name}'"
|
||||
if unmet:
|
||||
info += f" blocked by: {unmet}"
|
||||
if orphaned:
|
||||
info += f" (orphaned deps ignored: {orphaned})"
|
||||
blocking_info.append(info)
|
||||
|
||||
return json.dumps({
|
||||
"error": "All pending features are blocked by unmet dependencies",
|
||||
"blocked_features": len(pending),
|
||||
"examples": blocking_info,
|
||||
"hint": "Complete the blocking dependencies first, or remove invalid dependencies"
|
||||
}, indent=2)
|
||||
|
||||
# Atomic claim: UPDATE only if still claimable
|
||||
# This prevents race conditions even across processes
|
||||
result = session.execute(
|
||||
text("""
|
||||
UPDATE features
|
||||
SET in_progress = 1
|
||||
WHERE id = :feature_id
|
||||
AND in_progress = 0
|
||||
AND passes = 0
|
||||
"""),
|
||||
{"feature_id": candidate_id}
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Check if we actually claimed it
|
||||
if result.rowcount == 0:
|
||||
# Another process claimed it first - will retry after backoff
|
||||
pass # Fall through to finally block, then retry loop
|
||||
else:
|
||||
# Successfully claimed - fetch and return
|
||||
session.expire_all() # Clear cache to get fresh data
|
||||
claimed_feature = session.query(Feature).filter(Feature.id == candidate_id).first()
|
||||
return json.dumps(claimed_feature.to_dict(), indent=2)
|
||||
|
||||
except OperationalError:
|
||||
# Transient database error (e.g., SQLITE_BUSY) - rollback and retry
|
||||
session.rollback()
|
||||
# Fall through to backoff and retry
|
||||
except Exception as e:
|
||||
# Non-transient error - fail immediately
|
||||
session.rollback()
|
||||
return json.dumps({"error": f"Failed to claim feature: {str(e)}"})
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Exponential backoff with jitter before next attempt
|
||||
# Base 0.1s, 0.2s, 0.4s, 0.8s, 1.0s (capped)
|
||||
# Jitter of up to 30% prevents synchronized retries under high contention
|
||||
backoff = min(0.1 * (2 ** attempt), 1.0)
|
||||
jitter = random.uniform(0, backoff * 0.3)
|
||||
_time.sleep(backoff + jitter)
|
||||
|
||||
# Exhausted all retries
|
||||
return json.dumps({
|
||||
"error": "Failed to claim feature after maximum retries",
|
||||
"hint": "High contention detected - try again or reduce parallel agents"
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def feature_claim_next() -> str:
|
||||
"""Atomically get and claim the next available feature.
|
||||
|
||||
This combines feature_get_next() and feature_mark_in_progress() in a single
|
||||
atomic operation, preventing race conditions where two agents could claim
|
||||
the same feature.
|
||||
|
||||
Returns the feature with the lowest priority number that:
|
||||
1. Has passes=false and in_progress=false
|
||||
2. Has all dependency features already passing (or no dependencies)
|
||||
3. All dependency IDs actually exist (orphaned dependencies are ignored)
|
||||
|
||||
On success, the feature's in_progress flag is set to True.
|
||||
Uses exponential backoff retry (up to 5 attempts) under contention.
|
||||
|
||||
Returns:
|
||||
JSON with claimed feature details, or error message if no feature available.
|
||||
"""
|
||||
return _feature_claim_next_internal()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def feature_get_for_regression(
|
||||
limit: Annotated[int, Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")] = 3
|
||||
def feature_get_by_id(
|
||||
feature_id: Annotated[int, Field(description="The ID of the feature to retrieve", ge=1)]
|
||||
) -> str:
|
||||
"""Get random passing features for regression testing.
|
||||
"""Get a specific feature by its ID.
|
||||
|
||||
Returns a random selection of features that are currently passing
|
||||
and NOT currently in progress (to avoid conflicts with coding agents).
|
||||
Use this to verify that previously implemented features still work
|
||||
after making changes.
|
||||
Returns the full details of a feature including its name, description,
|
||||
verification steps, and current status.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of features to return (1-10, default 3)
|
||||
feature_id: The ID of the feature to retrieve
|
||||
|
||||
Returns:
|
||||
JSON with: features (list of feature objects), count (int)
|
||||
JSON with feature details, or error if not found.
|
||||
"""
|
||||
session = get_session()
|
||||
try:
|
||||
features = (
|
||||
session.query(Feature)
|
||||
.filter(Feature.passes == True)
|
||||
.filter(Feature.in_progress == False) # Avoid conflicts with coding agents
|
||||
.order_by(func.random())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||
|
||||
return json.dumps({
|
||||
"features": [f.to_dict() for f in features],
|
||||
"count": len(features)
|
||||
}, indent=2)
|
||||
if feature is None:
|
||||
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
||||
|
||||
return json.dumps(feature.to_dict(), indent=2)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _feature_claim_for_testing_internal() -> str:
|
||||
"""Internal implementation of testing feature claim with iterative retry.
|
||||
|
||||
Uses an iterative loop instead of recursion to avoid double session.close()
|
||||
issues and deep call stacks under contention.
|
||||
|
||||
Returns:
|
||||
JSON with claimed feature details, or message if no features available.
|
||||
"""
|
||||
for attempt in range(MAX_CLAIM_RETRIES):
|
||||
session = get_session()
|
||||
try:
|
||||
# Use lock to prevent concurrent claims within this process
|
||||
with _priority_lock:
|
||||
# Find a candidate feature
|
||||
candidate = (
|
||||
session.query(Feature)
|
||||
.filter(Feature.passes == True)
|
||||
.filter(Feature.in_progress == False)
|
||||
.filter(Feature.testing_in_progress == False)
|
||||
.order_by(func.random())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not candidate:
|
||||
return json.dumps({
|
||||
"message": "No features available for testing",
|
||||
"hint": "All passing features are either being coded or tested"
|
||||
})
|
||||
|
||||
# Atomic claim using UPDATE with WHERE clause
|
||||
# This prevents race conditions even across processes
|
||||
result = session.execute(
|
||||
text("""
|
||||
UPDATE features
|
||||
SET testing_in_progress = 1
|
||||
WHERE id = :feature_id
|
||||
AND passes = 1
|
||||
AND in_progress = 0
|
||||
AND testing_in_progress = 0
|
||||
"""),
|
||||
{"feature_id": candidate.id}
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Check if we actually claimed it
|
||||
if result.rowcount == 0:
|
||||
# Another process claimed it first - will retry after backoff
|
||||
pass # Fall through to finally block, then retry loop
|
||||
else:
|
||||
# Successfully claimed - fetch and return
|
||||
session.expire_all()
|
||||
claimed = session.query(Feature).filter(Feature.id == candidate.id).first()
|
||||
return json.dumps(claimed.to_dict(), indent=2)
|
||||
|
||||
except OperationalError:
|
||||
# Transient database error (e.g., SQLITE_BUSY) - rollback and retry
|
||||
session.rollback()
|
||||
# Fall through to backoff and retry
|
||||
except Exception as e:
|
||||
# Non-transient error - fail immediately
|
||||
session.rollback()
|
||||
return json.dumps({"error": f"Failed to claim feature: {str(e)}"})
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Exponential backoff with jitter before next attempt
|
||||
backoff = min(0.1 * (2 ** attempt), 1.0)
|
||||
jitter = random.uniform(0, backoff * 0.3)
|
||||
_time.sleep(backoff + jitter)
|
||||
|
||||
# Exhausted all retries
|
||||
return json.dumps({
|
||||
"error": "Failed to claim feature after maximum retries",
|
||||
"hint": "High contention detected - try again or reduce testing agents"
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def feature_claim_for_testing() -> str:
|
||||
"""Atomically claim a passing feature for regression testing.
|
||||
|
||||
Returns a random passing feature that is:
|
||||
- Currently passing (passes=True)
|
||||
- Not being worked on by coding agents (in_progress=False)
|
||||
- Not already being tested (testing_in_progress=False)
|
||||
|
||||
The feature's testing_in_progress flag is set to True atomically to prevent
|
||||
other testing agents from claiming the same feature. Uses exponential backoff
|
||||
retry (up to 5 attempts) under contention.
|
||||
|
||||
After testing, you MUST call feature_release_testing() to release the claim.
|
||||
|
||||
Returns:
|
||||
JSON with feature details if available, or message if no features available.
|
||||
"""
|
||||
return _feature_claim_for_testing_internal()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def feature_release_testing(
|
||||
feature_id: Annotated[int, Field(description="The ID of the feature to release", ge=1)],
|
||||
@@ -692,10 +380,10 @@ def feature_skip(
|
||||
def feature_mark_in_progress(
|
||||
feature_id: Annotated[int, Field(description="The ID of the feature to mark as in-progress", ge=1)]
|
||||
) -> str:
|
||||
"""Mark a feature as in-progress. Call immediately after feature_get_next().
|
||||
"""Mark a feature as in-progress.
|
||||
|
||||
This prevents other agent sessions from working on the same feature.
|
||||
Use this as soon as you retrieve a feature to work on.
|
||||
Call this after getting your assigned feature details with feature_get_by_id.
|
||||
|
||||
Args:
|
||||
feature_id: The ID of the feature to mark as in-progress
|
||||
|
||||
Reference in New Issue
Block a user