diff --git a/api/database.py b/api/database.py index 90dc49af..096a0694 100644 --- a/api/database.py +++ b/api/database.py @@ -58,6 +58,16 @@ class Feature(Base): # NULL/empty = no dependencies (backwards compatible) dependencies = Column(JSON, nullable=True, default=None) + # DEPRECATED: Kept for backward compatibility with existing databases. + # These columns are no longer used but prevent NOT NULL violations on insert. + # Removed in commit 486979c but old databases still have them. + testing_in_progress = Column(Boolean, nullable=False, default=False) + last_tested_at = Column(DateTime, nullable=True, default=None) + + # Track how many times this feature has been regression tested. + # Used for least-tested-first selection to ensure even test distribution. + regression_count = Column(Integer, nullable=False, default=0, index=True) + def to_dict(self) -> dict: """Convert feature to dictionary for JSON serialization.""" return { @@ -72,6 +82,8 @@ def to_dict(self) -> dict: "in_progress": self.in_progress if self.in_progress is not None else False, # Dependencies: NULL/empty treated as empty list for backwards compat "dependencies": self.dependencies if self.dependencies else [], + # Regression test tracking + "regression_count": self.regression_count or 0, } def get_dependencies_safe(self) -> list[int]: @@ -247,6 +259,22 @@ def _migrate_add_testing_columns(engine) -> None: pass +def _migrate_add_regression_count_column(engine) -> None: + """Add regression_count column to existing databases that don't have it. + + This column tracks how many times each feature has been regression tested, + enabling least-tested-first selection for even test distribution. + """ + with engine.connect() as conn: + # Check if column exists + result = conn.execute(text("PRAGMA table_info(features)")) + columns = [row[1] for row in result.fetchall()] + + if "regression_count" not in columns: + conn.execute(text("ALTER TABLE features ADD COLUMN regression_count INTEGER DEFAULT 0")) + conn.commit() + + def _is_network_path(path: Path) -> bool: """Detect if path is on a network filesystem. @@ -372,6 +400,7 @@ def create_database(project_dir: Path) -> tuple: _migrate_fix_null_boolean_fields(engine) _migrate_add_dependencies_column(engine) _migrate_add_testing_columns(engine) + _migrate_add_regression_count_column(engine) # Migrate to add schedules tables _migrate_add_schedules_tables(engine) diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index a394f1e9..0e1a1cbe 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -11,6 +11,7 @@ - feature_get_summary: Get minimal feature info (id, name, status, deps) - feature_mark_passing: Mark a feature as passing - feature_mark_failing: Mark a feature as failing (regression detected) +- feature_get_for_regression: Get passing features for regression testing (least-tested-first) - feature_skip: Skip a feature (move to end of queue) - feature_mark_in_progress: Mark a feature as in-progress - feature_claim_and_get: Atomically claim and get feature details @@ -74,11 +75,6 @@ class ClearInProgressInput(BaseModel): 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") @@ -305,6 +301,61 @@ def feature_mark_failing( session.close() +@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 passing features for regression testing, prioritizing least-tested features. + + Returns features that are currently passing, ordered by regression_count (ascending) + so that features tested fewer times are prioritized. This ensures even distribution + of regression testing across all features, avoiding duplicate testing of the same + features while others are never tested. + + Each returned feature has its regression_count incremented to track testing frequency. + + Args: + limit: Maximum number of features to return (1-10, default 3) + + Returns: + JSON with list of features for regression testing. + """ + session = get_session() + try: + # Use with_for_update() to acquire row-level locks before reading. + # This prevents race conditions where concurrent requests both select + # the same features (with lowest regression_count) before either commits. + # The lock ensures requests are serialized: the second request will block + # until the first commits, then see the updated regression_count values. + features = ( + session.query(Feature) + .filter(Feature.passes == True) + .order_by(Feature.regression_count.asc(), Feature.id.asc()) + .limit(limit) + .with_for_update() + .all() + ) + + # Increment regression_count for selected features (now safe under lock) + for feature in features: + feature.regression_count = (feature.regression_count or 0) + 1 + session.commit() + + # Refresh to get updated counts after commit releases the lock + for feature in features: + session.refresh(feature) + + return json.dumps({ + "features": [f.to_dict() for f in features], + "count": len(features) + }) + except Exception as e: + session.rollback() + return json.dumps({"error": f"Failed to get regression features: {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)] diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 58dd50d5..20d64c50 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -10,6 +10,7 @@ import json import logging import os +import re import shutil import sys import threading @@ -68,8 +69,9 @@ class ExpandChatSession: Unlike SpecChatSession which writes spec files, this session: 1. Reads existing app_spec.txt for context - 2. Chats with the user to define new features - 3. Claude creates features via the feature_create_bulk MCP tool + 2. Parses feature definitions from Claude's output + 3. Creates features via REST API + 4. Tracks which features were created during the session """ def __init__(self, project_name: str, project_dir: Path): @@ -291,8 +293,7 @@ async def _query_claude( """ Internal method to query Claude and stream responses. - Feature creation is handled by Claude calling the feature_create_bulk - MCP tool directly -- no text parsing needed. + Handles text responses and detects feature creation blocks. """ if not self.client: return @@ -316,6 +317,9 @@ async def _query_claude( else: await self.client.query(message) + # Accumulate full response to detect feature blocks + full_response = "" + # Stream the response async for msg in self.client.receive_response(): msg_type = type(msg).__name__ @@ -327,6 +331,7 @@ async def _query_claude( if block_type == "TextBlock" and hasattr(block, "text"): text = block.text if text: + full_response += text yield {"type": "text", "content": text} self.messages.append({ @@ -335,6 +340,123 @@ async def _query_claude( "timestamp": datetime.now().isoformat() }) + # Check for feature creation blocks in full response (handle multiple blocks) + features_matches = re.findall( + r'\s*(\[[\s\S]*?\])\s*', + full_response + ) + + if features_matches: + # Collect all features from all blocks, deduplicating by name + all_features: list[dict] = [] + seen_names: set[str] = set() + + for features_json in features_matches: + try: + features_data = json.loads(features_json) + + if features_data and isinstance(features_data, list): + for feature in features_data: + name = feature.get("name", "") + if name and name not in seen_names: + seen_names.add(name) + all_features.append(feature) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse features JSON block: {e}") + # Continue processing other blocks + + if all_features: + try: + # Create all deduplicated features + created = await self._create_features_bulk(all_features) + + if created: + self.features_created += len(created) + self.created_feature_ids.extend([f["id"] for f in created]) + + yield { + "type": "features_created", + "count": len(created), + "features": created + } + + logger.info(f"Created {len(created)} features for {self.project_name}") + except Exception: + logger.exception("Failed to create features") + yield { + "type": "error", + "content": "Failed to create features" + } + + async def _create_features_bulk(self, features: list[dict]) -> list[dict]: + """ + Create features directly in the database. + + Args: + features: List of feature dictionaries with category, name, description, steps + + Returns: + List of created feature dictionaries with IDs + + Note: + Uses flush() to get IDs immediately without re-querying by priority range, + which could pick up rows from concurrent writers. + """ + # Import database classes + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from api.database import Feature, create_database + + # Get database session + _, SessionLocal = create_database(self.project_dir) + session = SessionLocal() + + try: + # Determine starting priority + max_priority_feature = session.query(Feature).order_by(Feature.priority.desc()).first() + current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1 + + created_rows: list = [] + + for f in features: + db_feature = Feature( + priority=current_priority, + category=f.get("category", "functional"), + name=f.get("name", "Unnamed feature"), + description=f.get("description", ""), + steps=f.get("steps", []), + passes=False, + in_progress=False, + ) + session.add(db_feature) + created_rows.append(db_feature) + current_priority += 1 + + # Flush to get IDs without relying on priority range query + session.flush() + + # Build result from the flushed objects (IDs are now populated) + created_features = [ + { + "id": db_feature.id, + "name": db_feature.name, + "category": db_feature.category, + } + for db_feature in created_rows + ] + + session.commit() + return created_features + + except Exception: + session.rollback() + raise + finally: + session.close() + def get_features_created(self) -> int: """Get the total number of features created in this session.""" return self.features_created