mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
Major refactoring of the parallel orchestrator to run regression testing agents independently from coding agents. This improves system reliability and provides better control over testing behavior. Key changes: Database & MCP Layer: - Add testing_in_progress and last_tested_at columns to Feature model - Add feature_claim_for_testing() for atomic test claim with retry - Add feature_release_testing() to release claims after testing - Refactor claim functions to iterative loops (no recursion) - Add OperationalError retry handling for transient DB errors - Reduce MAX_CLAIM_RETRIES from 10 to 5 Orchestrator: - Decouple testing agent lifecycle from coding agents - Add _maintain_testing_agents() for continuous testing maintenance - Fix TOCTOU race in _spawn_testing_agent() - hold lock during spawn - Add _cleanup_stale_testing_locks() with 30-min timeout - Fix log ordering - start_session() before stale flag cleanup - Add stale testing_in_progress cleanup on startup Dead Code Removal: - Remove count_testing_in_concurrency from entire stack (12+ files) - Remove ineffective with_for_update() from features router API & UI: - Pass testing_agent_ratio via CLI to orchestrator - Update testing prompt template to use new claim/release tools - Rename UI label to "Regression Agents" with clearer description - Add process_utils.py for cross-platform process tree management Testing agents now: - Run continuously as long as passing features exist - Can re-test features multiple times to catch regressions - Are controlled by fixed count (0-3) via testing_agent_ratio setting - Have atomic claiming to prevent concurrent testing of same feature Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1233 lines
46 KiB
Python
Executable File
1233 lines
46 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
MCP Server for Feature Management
|
|
==================================
|
|
|
|
Provides tools to manage features in the autonomous coding system,
|
|
replacing the previous FastAPI-based REST API.
|
|
|
|
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_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_create_bulk: Create multiple features at once
|
|
- feature_create: Create a single feature
|
|
"""
|
|
|
|
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
|
|
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))
|
|
|
|
from api.database import Feature, create_database
|
|
from api.dependency_resolver import (
|
|
MAX_DEPENDENCIES_PER_FEATURE,
|
|
compute_scheduling_scores,
|
|
would_create_circular_dependency,
|
|
)
|
|
from api.migration import migrate_json_to_sqlite
|
|
|
|
# Configuration from environment
|
|
PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve()
|
|
|
|
|
|
# Pydantic models for input validation
|
|
class MarkPassingInput(BaseModel):
|
|
"""Input for marking a feature as passing."""
|
|
feature_id: int = Field(..., description="The ID of the feature to mark as passing", ge=1)
|
|
|
|
|
|
class SkipFeatureInput(BaseModel):
|
|
"""Input for skipping a feature."""
|
|
feature_id: int = Field(..., description="The ID of the feature to skip", ge=1)
|
|
|
|
|
|
class MarkInProgressInput(BaseModel):
|
|
"""Input for marking a feature as in-progress."""
|
|
feature_id: int = Field(..., description="The ID of the feature to mark as in-progress", ge=1)
|
|
|
|
|
|
class ClearInProgressInput(BaseModel):
|
|
"""Input for clearing in-progress status."""
|
|
feature_id: int = Field(..., description="The ID of the feature to clear in-progress status", ge=1)
|
|
|
|
|
|
class RegressionInput(BaseModel):
|
|
"""Input for getting regression features."""
|
|
limit: int = Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")
|
|
|
|
|
|
class FeatureCreateItem(BaseModel):
|
|
"""Schema for creating a single feature."""
|
|
category: str = Field(..., min_length=1, max_length=100, description="Feature category")
|
|
name: str = Field(..., min_length=1, max_length=255, description="Feature name")
|
|
description: str = Field(..., min_length=1, description="Detailed description")
|
|
steps: list[str] = Field(..., min_length=1, description="Implementation/test steps")
|
|
|
|
|
|
class BulkCreateInput(BaseModel):
|
|
"""Input for bulk creating features."""
|
|
features: list[FeatureCreateItem] = Field(..., min_length=1, description="List of features to create")
|
|
|
|
|
|
# Global database session maker (initialized on startup)
|
|
_session_maker = None
|
|
_engine = None
|
|
|
|
# Lock for priority assignment to prevent race conditions
|
|
_priority_lock = threading.Lock()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def server_lifespan(server: FastMCP):
|
|
"""Initialize database on startup, cleanup on shutdown."""
|
|
global _session_maker, _engine
|
|
|
|
# Create project directory if it doesn't exist
|
|
PROJECT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Initialize database
|
|
_engine, _session_maker = create_database(PROJECT_DIR)
|
|
|
|
# Run migration if needed (converts legacy JSON to SQLite)
|
|
migrate_json_to_sqlite(PROJECT_DIR, _session_maker)
|
|
|
|
yield
|
|
|
|
# Cleanup
|
|
if _engine:
|
|
_engine.dispose()
|
|
|
|
|
|
# Initialize the MCP server
|
|
mcp = FastMCP("features", lifespan=server_lifespan)
|
|
|
|
|
|
def get_session():
|
|
"""Get a new database session."""
|
|
if _session_maker is None:
|
|
raise RuntimeError("Database not initialized")
|
|
return _session_maker()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_get_stats() -> str:
|
|
"""Get statistics about feature completion progress.
|
|
|
|
Returns the number of passing features, in-progress features, total features,
|
|
and completion percentage. Use this to track overall progress of the implementation.
|
|
|
|
Returns:
|
|
JSON with: passing (int), in_progress (int), total (int), percentage (float)
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
total = session.query(Feature).count()
|
|
passing = session.query(Feature).filter(Feature.passes == True).count()
|
|
in_progress = session.query(Feature).filter(Feature.in_progress == True).count()
|
|
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
|
|
|
|
return json.dumps({
|
|
"passing": passing,
|
|
"in_progress": in_progress,
|
|
"total": total,
|
|
"percentage": percentage
|
|
}, indent=2)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@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
|
|
) -> str:
|
|
"""Get random passing features for regression testing.
|
|
|
|
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.
|
|
|
|
Args:
|
|
limit: Maximum number of features to return (1-10, default 3)
|
|
|
|
Returns:
|
|
JSON with: features (list of feature objects), count (int)
|
|
"""
|
|
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()
|
|
)
|
|
|
|
return json.dumps({
|
|
"features": [f.to_dict() for f in features],
|
|
"count": len(features)
|
|
}, 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)],
|
|
tested_ok: Annotated[bool, Field(description="True if the feature passed testing, False if regression found")] = True
|
|
) -> str:
|
|
"""Release a feature after regression testing completes.
|
|
|
|
Clears the testing_in_progress flag and updates last_tested_at timestamp.
|
|
|
|
This should be called after testing is complete, whether the feature
|
|
passed or failed. If tested_ok=False, the feature was marked as failing
|
|
by a previous call to feature_mark_failing.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature that was being tested
|
|
tested_ok: True if testing passed, False if a regression was found
|
|
|
|
Returns:
|
|
JSON with release confirmation or error message.
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
|
|
if feature is None:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
|
|
|
if not feature.testing_in_progress:
|
|
return json.dumps({
|
|
"warning": f"Feature {feature_id} was not being tested",
|
|
"feature": feature.to_dict()
|
|
})
|
|
|
|
# Clear testing flag and update timestamp
|
|
feature.testing_in_progress = False
|
|
feature.last_tested_at = datetime.now(timezone.utc)
|
|
session.commit()
|
|
session.refresh(feature)
|
|
|
|
status = "passed" if tested_ok else "failed (regression detected)"
|
|
return json.dumps({
|
|
"message": f"Feature #{feature_id} testing {status}",
|
|
"feature": feature.to_dict()
|
|
}, indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to release testing claim: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_mark_passing(
|
|
feature_id: Annotated[int, Field(description="The ID of the feature to mark as passing", ge=1)]
|
|
) -> str:
|
|
"""Mark a feature as passing after successful implementation.
|
|
|
|
Updates the feature's passes field to true and clears the in_progress flag.
|
|
Use this after you have implemented the feature and verified it works correctly.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature to mark as passing
|
|
|
|
Returns:
|
|
JSON with the updated feature details, or error if not found.
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
|
|
if feature is None:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
|
|
|
feature.passes = True
|
|
feature.in_progress = False
|
|
session.commit()
|
|
session.refresh(feature)
|
|
|
|
return json.dumps(feature.to_dict(), indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to mark feature passing: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_mark_failing(
|
|
feature_id: Annotated[int, Field(description="The ID of the feature to mark as failing", ge=1)]
|
|
) -> str:
|
|
"""Mark a feature as failing after finding a regression.
|
|
|
|
Updates the feature's passes field to false and clears the in_progress flag.
|
|
Use this when a testing agent discovers that a previously-passing feature
|
|
no longer works correctly (regression detected).
|
|
|
|
After marking as failing, you should:
|
|
1. Investigate the root cause
|
|
2. Fix the regression
|
|
3. Verify the fix
|
|
4. Call feature_mark_passing once fixed
|
|
|
|
Args:
|
|
feature_id: The ID of the feature to mark as failing
|
|
|
|
Returns:
|
|
JSON with the updated feature details, or error if not found.
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
|
|
if feature is None:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
|
|
|
feature.passes = False
|
|
feature.in_progress = False
|
|
session.commit()
|
|
session.refresh(feature)
|
|
|
|
return json.dumps({
|
|
"message": f"Feature #{feature_id} marked as failing - regression detected",
|
|
"feature": feature.to_dict()
|
|
}, indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to mark feature failing: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_skip(
|
|
feature_id: Annotated[int, Field(description="The ID of the feature to skip", ge=1)]
|
|
) -> str:
|
|
"""Skip a feature by moving it to the end of the priority queue.
|
|
|
|
Use this when a feature cannot be implemented yet due to:
|
|
- Dependencies on other features that aren't implemented yet
|
|
- External blockers (missing assets, unclear requirements)
|
|
- Technical prerequisites that need to be addressed first
|
|
|
|
The feature's priority is set to max_priority + 1, so it will be
|
|
worked on after all other pending features. Also clears the in_progress
|
|
flag so the feature returns to "pending" status.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature to skip
|
|
|
|
Returns:
|
|
JSON with skip details: id, name, old_priority, new_priority, message
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
|
|
if feature is None:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
|
|
|
if feature.passes:
|
|
return json.dumps({"error": "Cannot skip a feature that is already passing"})
|
|
|
|
old_priority = feature.priority
|
|
|
|
# Use lock to prevent race condition in priority assignment
|
|
with _priority_lock:
|
|
# Get max priority and set this feature to max + 1
|
|
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
|
|
new_priority = (max_priority_result[0] + 1) if max_priority_result else 1
|
|
|
|
feature.priority = new_priority
|
|
feature.in_progress = False
|
|
session.commit()
|
|
|
|
session.refresh(feature)
|
|
|
|
return json.dumps({
|
|
"id": feature.id,
|
|
"name": feature.name,
|
|
"old_priority": old_priority,
|
|
"new_priority": new_priority,
|
|
"message": f"Feature '{feature.name}' moved to end of queue"
|
|
}, indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to skip feature: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
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().
|
|
|
|
This prevents other agent sessions from working on the same feature.
|
|
Use this as soon as you retrieve a feature to work on.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature to mark as in-progress
|
|
|
|
Returns:
|
|
JSON with the updated feature details, or error if not found or already in-progress.
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
|
|
if feature is None:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
|
|
|
if feature.passes:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
|
|
|
if feature.in_progress:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
|
|
|
|
feature.in_progress = True
|
|
session.commit()
|
|
session.refresh(feature)
|
|
|
|
return json.dumps(feature.to_dict(), indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to mark feature in-progress: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_clear_in_progress(
|
|
feature_id: Annotated[int, Field(description="The ID of the feature to clear in-progress status", ge=1)]
|
|
) -> str:
|
|
"""Clear in-progress status from a feature.
|
|
|
|
Use this when abandoning a feature or manually unsticking a stuck feature.
|
|
The feature will return to the pending queue.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature to clear in-progress status
|
|
|
|
Returns:
|
|
JSON with the updated feature details, or error if not found.
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
|
|
if feature is None:
|
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
|
|
|
feature.in_progress = False
|
|
session.commit()
|
|
session.refresh(feature)
|
|
|
|
return json.dumps(feature.to_dict(), indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to clear in-progress status: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_create_bulk(
|
|
features: Annotated[list[dict], Field(description="List of features to create, each with category, name, description, and steps")]
|
|
) -> str:
|
|
"""Create multiple features in a single operation.
|
|
|
|
Features are assigned sequential priorities based on their order.
|
|
All features start with passes=false.
|
|
|
|
This is typically used by the initializer agent to set up the initial
|
|
feature list from the app specification.
|
|
|
|
Args:
|
|
features: List of features to create, each with:
|
|
- category (str): Feature category
|
|
- name (str): Feature name
|
|
- description (str): Detailed description
|
|
- steps (list[str]): Implementation/test steps
|
|
- depends_on_indices (list[int], optional): Array indices (0-based) of
|
|
features in THIS batch that this feature depends on. Use this instead
|
|
of 'dependencies' since IDs aren't known until after creation.
|
|
Example: [0, 2] means this feature depends on features at index 0 and 2.
|
|
|
|
Returns:
|
|
JSON with: created (int) - number of features created, with_dependencies (int)
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
# Use lock to prevent race condition in priority assignment
|
|
with _priority_lock:
|
|
# Get the starting priority
|
|
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
|
|
start_priority = (max_priority_result[0] + 1) if max_priority_result else 1
|
|
|
|
# First pass: validate all features and their index-based dependencies
|
|
for i, feature_data in enumerate(features):
|
|
# Validate required fields
|
|
if not all(key in feature_data for key in ["category", "name", "description", "steps"]):
|
|
return json.dumps({
|
|
"error": f"Feature at index {i} missing required fields (category, name, description, steps)"
|
|
})
|
|
|
|
# Validate depends_on_indices
|
|
indices = feature_data.get("depends_on_indices", [])
|
|
if indices:
|
|
# Check max dependencies
|
|
if len(indices) > MAX_DEPENDENCIES_PER_FEATURE:
|
|
return json.dumps({
|
|
"error": f"Feature at index {i} has {len(indices)} dependencies, max is {MAX_DEPENDENCIES_PER_FEATURE}"
|
|
})
|
|
# Check for duplicates
|
|
if len(indices) != len(set(indices)):
|
|
return json.dumps({
|
|
"error": f"Feature at index {i} has duplicate dependencies"
|
|
})
|
|
# Check for forward references (can only depend on earlier features)
|
|
for idx in indices:
|
|
if not isinstance(idx, int) or idx < 0:
|
|
return json.dumps({
|
|
"error": f"Feature at index {i} has invalid dependency index: {idx}"
|
|
})
|
|
if idx >= i:
|
|
return json.dumps({
|
|
"error": f"Feature at index {i} cannot depend on feature at index {idx} (forward reference not allowed)"
|
|
})
|
|
|
|
# Second pass: create all features
|
|
created_features: list[Feature] = []
|
|
for i, feature_data in enumerate(features):
|
|
db_feature = Feature(
|
|
priority=start_priority + i,
|
|
category=feature_data["category"],
|
|
name=feature_data["name"],
|
|
description=feature_data["description"],
|
|
steps=feature_data["steps"],
|
|
passes=False,
|
|
in_progress=False,
|
|
)
|
|
session.add(db_feature)
|
|
created_features.append(db_feature)
|
|
|
|
# Flush to get IDs assigned
|
|
session.flush()
|
|
|
|
# Third pass: resolve index-based dependencies to actual IDs
|
|
deps_count = 0
|
|
for i, feature_data in enumerate(features):
|
|
indices = feature_data.get("depends_on_indices", [])
|
|
if indices:
|
|
# Convert indices to actual feature IDs
|
|
dep_ids = [created_features[idx].id for idx in indices]
|
|
created_features[i].dependencies = sorted(dep_ids)
|
|
deps_count += 1
|
|
|
|
session.commit()
|
|
|
|
return json.dumps({
|
|
"created": len(created_features),
|
|
"with_dependencies": deps_count
|
|
}, indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": str(e)})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_create(
|
|
category: Annotated[str, Field(min_length=1, max_length=100, description="Feature category (e.g., 'Authentication', 'API', 'UI')")],
|
|
name: Annotated[str, Field(min_length=1, max_length=255, description="Feature name")],
|
|
description: Annotated[str, Field(min_length=1, description="Detailed description of the feature")],
|
|
steps: Annotated[list[str], Field(min_length=1, description="List of implementation/verification steps")]
|
|
) -> str:
|
|
"""Create a single feature in the project backlog.
|
|
|
|
Use this when the user asks to add a new feature, capability, or test case.
|
|
The feature will be added with the next available priority number.
|
|
|
|
Args:
|
|
category: Feature category for grouping (e.g., 'Authentication', 'API', 'UI')
|
|
name: Descriptive name for the feature
|
|
description: Detailed description of what this feature should do
|
|
steps: List of steps to implement or verify the feature
|
|
|
|
Returns:
|
|
JSON with the created feature details including its ID
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
# Use lock to prevent race condition in priority assignment
|
|
with _priority_lock:
|
|
# Get the next priority
|
|
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
|
|
next_priority = (max_priority_result[0] + 1) if max_priority_result else 1
|
|
|
|
db_feature = Feature(
|
|
priority=next_priority,
|
|
category=category,
|
|
name=name,
|
|
description=description,
|
|
steps=steps,
|
|
passes=False,
|
|
in_progress=False,
|
|
)
|
|
session.add(db_feature)
|
|
session.commit()
|
|
|
|
session.refresh(db_feature)
|
|
|
|
return json.dumps({
|
|
"success": True,
|
|
"message": f"Created feature: {name}",
|
|
"feature": db_feature.to_dict()
|
|
}, indent=2)
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": str(e)})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_add_dependency(
|
|
feature_id: Annotated[int, Field(ge=1, description="Feature to add dependency to")],
|
|
dependency_id: Annotated[int, Field(ge=1, description="ID of the dependency feature")]
|
|
) -> str:
|
|
"""Add a dependency relationship between features.
|
|
|
|
The dependency_id feature must be completed before feature_id can be started.
|
|
Validates: self-reference, existence, circular dependencies, max limit.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature that will depend on another feature
|
|
dependency_id: The ID of the feature that must be completed first
|
|
|
|
Returns:
|
|
JSON with success status and updated dependencies list, or error message
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
# Security: Self-reference check
|
|
if feature_id == dependency_id:
|
|
return json.dumps({"error": "A feature cannot depend on itself"})
|
|
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
dependency = session.query(Feature).filter(Feature.id == dependency_id).first()
|
|
|
|
if not feature:
|
|
return json.dumps({"error": f"Feature {feature_id} not found"})
|
|
if not dependency:
|
|
return json.dumps({"error": f"Dependency feature {dependency_id} not found"})
|
|
|
|
current_deps = feature.dependencies or []
|
|
|
|
# Security: Max dependencies limit
|
|
if len(current_deps) >= MAX_DEPENDENCIES_PER_FEATURE:
|
|
return json.dumps({"error": f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed per feature"})
|
|
|
|
# Check if already exists
|
|
if dependency_id in current_deps:
|
|
return json.dumps({"error": "Dependency already exists"})
|
|
|
|
# Security: Circular dependency check
|
|
# would_create_circular_dependency(features, source_id, target_id)
|
|
# source_id = feature gaining the dependency, target_id = feature being depended upon
|
|
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
|
if would_create_circular_dependency(all_features, feature_id, dependency_id):
|
|
return json.dumps({"error": "Cannot add: would create circular dependency"})
|
|
|
|
# Add dependency
|
|
current_deps.append(dependency_id)
|
|
feature.dependencies = sorted(current_deps)
|
|
session.commit()
|
|
|
|
return json.dumps({
|
|
"success": True,
|
|
"feature_id": feature_id,
|
|
"dependencies": feature.dependencies
|
|
})
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to add dependency: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_remove_dependency(
|
|
feature_id: Annotated[int, Field(ge=1, description="Feature to remove dependency from")],
|
|
dependency_id: Annotated[int, Field(ge=1, description="ID of dependency to remove")]
|
|
) -> str:
|
|
"""Remove a dependency from a feature.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature to remove a dependency from
|
|
dependency_id: The ID of the dependency to remove
|
|
|
|
Returns:
|
|
JSON with success status and updated dependencies list, or error message
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
if not feature:
|
|
return json.dumps({"error": f"Feature {feature_id} not found"})
|
|
|
|
current_deps = feature.dependencies or []
|
|
if dependency_id not in current_deps:
|
|
return json.dumps({"error": "Dependency does not exist"})
|
|
|
|
current_deps.remove(dependency_id)
|
|
feature.dependencies = current_deps if current_deps else None
|
|
session.commit()
|
|
|
|
return json.dumps({
|
|
"success": True,
|
|
"feature_id": feature_id,
|
|
"dependencies": feature.dependencies or []
|
|
})
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to remove dependency: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_get_ready(
|
|
limit: Annotated[int, Field(default=10, ge=1, le=50, description="Max features to return")] = 10
|
|
) -> str:
|
|
"""Get all features ready to start (dependencies satisfied, not in progress).
|
|
|
|
Useful for parallel execution - returns multiple features that can run simultaneously.
|
|
A feature is ready if it is not passing, not in progress, and all dependencies are passing.
|
|
|
|
Args:
|
|
limit: Maximum number of features to return (1-50, default 10)
|
|
|
|
Returns:
|
|
JSON with: features (list), count (int), total_ready (int)
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
all_features = session.query(Feature).all()
|
|
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
|
|
deps = f.dependencies or []
|
|
if all(dep_id in passing_ids for dep_id in deps):
|
|
ready.append(f.to_dict())
|
|
|
|
# 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],
|
|
"count": len(ready[:limit]),
|
|
"total_ready": len(ready)
|
|
}, indent=2)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_get_blocked() -> str:
|
|
"""Get all features that are blocked by unmet dependencies.
|
|
|
|
Returns features that have dependencies which are not yet passing.
|
|
Each feature includes a 'blocked_by' field listing the blocking feature IDs.
|
|
|
|
Returns:
|
|
JSON with: features (list with blocked_by field), count (int)
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
all_features = session.query(Feature).all()
|
|
passing_ids = {f.id for f in all_features if f.passes}
|
|
|
|
blocked = []
|
|
for f in all_features:
|
|
if f.passes:
|
|
continue
|
|
deps = f.dependencies or []
|
|
blocking = [d for d in deps if d not in passing_ids]
|
|
if blocking:
|
|
blocked.append({
|
|
**f.to_dict(),
|
|
"blocked_by": blocking
|
|
})
|
|
|
|
return json.dumps({
|
|
"features": blocked,
|
|
"count": len(blocked)
|
|
}, indent=2)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_get_graph() -> str:
|
|
"""Get dependency graph data for visualization.
|
|
|
|
Returns nodes (features) and edges (dependencies) for rendering a graph.
|
|
Each node includes status: 'pending', 'in_progress', 'done', or 'blocked'.
|
|
|
|
Returns:
|
|
JSON with: nodes (list), edges (list of {source, target})
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
all_features = session.query(Feature).all()
|
|
passing_ids = {f.id for f in all_features if f.passes}
|
|
|
|
nodes = []
|
|
edges = []
|
|
|
|
for f in all_features:
|
|
deps = f.dependencies or []
|
|
blocking = [d for d in deps if d not in passing_ids]
|
|
|
|
if f.passes:
|
|
status = "done"
|
|
elif blocking:
|
|
status = "blocked"
|
|
elif f.in_progress:
|
|
status = "in_progress"
|
|
else:
|
|
status = "pending"
|
|
|
|
nodes.append({
|
|
"id": f.id,
|
|
"name": f.name,
|
|
"category": f.category,
|
|
"status": status,
|
|
"priority": f.priority,
|
|
"dependencies": deps
|
|
})
|
|
|
|
for dep_id in deps:
|
|
edges.append({"source": dep_id, "target": f.id})
|
|
|
|
return json.dumps({
|
|
"nodes": nodes,
|
|
"edges": edges
|
|
}, indent=2)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@mcp.tool()
|
|
def feature_set_dependencies(
|
|
feature_id: Annotated[int, Field(ge=1, description="Feature to set dependencies for")],
|
|
dependency_ids: Annotated[list[int], Field(description="List of dependency feature IDs")]
|
|
) -> str:
|
|
"""Set all dependencies for a feature at once, replacing any existing dependencies.
|
|
|
|
Validates: self-reference, existence of all dependencies, circular dependencies, max limit.
|
|
|
|
Args:
|
|
feature_id: The ID of the feature to set dependencies for
|
|
dependency_ids: List of feature IDs that must be completed first
|
|
|
|
Returns:
|
|
JSON with success status and updated dependencies list, or error message
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
# Security: Self-reference check
|
|
if feature_id in dependency_ids:
|
|
return json.dumps({"error": "A feature cannot depend on itself"})
|
|
|
|
# Security: Max dependencies limit
|
|
if len(dependency_ids) > MAX_DEPENDENCIES_PER_FEATURE:
|
|
return json.dumps({"error": f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed"})
|
|
|
|
# Check for duplicates
|
|
if len(dependency_ids) != len(set(dependency_ids)):
|
|
return json.dumps({"error": "Duplicate dependencies not allowed"})
|
|
|
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
if not feature:
|
|
return json.dumps({"error": f"Feature {feature_id} not found"})
|
|
|
|
# Validate all dependencies exist
|
|
all_feature_ids = {f.id for f in session.query(Feature).all()}
|
|
missing = [d for d in dependency_ids if d not in all_feature_ids]
|
|
if missing:
|
|
return json.dumps({"error": f"Dependencies not found: {missing}"})
|
|
|
|
# Check for circular dependencies
|
|
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
|
# Temporarily update the feature's dependencies for cycle check
|
|
test_features = []
|
|
for f in all_features:
|
|
if f["id"] == feature_id:
|
|
test_features.append({**f, "dependencies": dependency_ids})
|
|
else:
|
|
test_features.append(f)
|
|
|
|
for dep_id in dependency_ids:
|
|
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
|
|
if would_create_circular_dependency(test_features, feature_id, dep_id):
|
|
return json.dumps({"error": f"Cannot add dependency {dep_id}: would create circular dependency"})
|
|
|
|
# Set dependencies
|
|
feature.dependencies = sorted(dependency_ids) if dependency_ids else None
|
|
session.commit()
|
|
|
|
return json.dumps({
|
|
"success": True,
|
|
"feature_id": feature_id,
|
|
"dependencies": feature.dependencies or []
|
|
})
|
|
except Exception as e:
|
|
session.rollback()
|
|
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run()
|