Add in-progress status tracking for features

Implements feature locking to prevent multiple agent sessions from working
on the same feature simultaneously. This is essential for parallel agent
execution.

Database changes:
- Add `in_progress` boolean column to Feature model
- Add migration function to handle existing databases

MCP Server tools:
- Add `feature_mark_in_progress` - lock feature when starting work
- Add `feature_clear_in_progress` - unlock feature when abandoning
- Update `feature_get_next` to skip in-progress features
- Update `feature_get_stats` to include in_progress count
- Update `feature_mark_passing` and `feature_skip` to clear in_progress

Backend updates:
- Update progress.py to track and display in_progress count
- Update features router to properly categorize in-progress features
- Update WebSocket to broadcast in_progress in progress updates
- Add in_progress to FeatureResponse schema

Frontend updates:
- Add in_progress to TypeScript types (Feature, ProjectStats, WSProgressMessage)
- Update useWebSocket hook to track in_progress state

Prompt template:
- Add instructions for agents to mark features in-progress immediately
- Document new MCP tools in allowed tools section

Also fixes spec_chat_session.py to use absolute project path instead of
relative path for consistency with CLI behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2025-12-30 19:00:49 +02:00
parent d6c736261b
commit f180e1933d
12 changed files with 179 additions and 30 deletions

View File

@@ -88,6 +88,13 @@ Get the next feature to implement:
Use the feature_get_next tool Use the feature_get_next tool
``` ```
Once you've retrieved the feature, **immediately mark it as in-progress**:
```
# Mark feature as in-progress to prevent other sessions from working on it
Use the feature_mark_in_progress tool with feature_id=42
```
Focus on completing one feature perfectly and completing its testing steps in this session before moving on to other features. Focus on completing one feature perfectly and completing its testing steps in this session before moving on to other features.
It's ok if you only complete one feature in this session, as there will be more sessions later that continue to make progress. It's ok if you only complete one feature in this session, as there will be more sessions later that continue to make progress.
@@ -335,20 +342,26 @@ The feature tools exist to reduce token usage. **DO NOT make exploratory queries
### ALLOWED Feature Tools (ONLY these): ### ALLOWED Feature Tools (ONLY these):
``` ```
# 1. Get progress stats (passing/total counts) # 1. Get progress stats (passing/in_progress/total counts)
feature_get_stats feature_get_stats
# 2. Get the NEXT feature to work on (one feature only) # 2. Get the NEXT feature to work on (one feature only)
feature_get_next feature_get_next
# 3. Get up to 3 random passing features for regression testing # 3. Mark a feature as in-progress (call immediately after feature_get_next)
feature_mark_in_progress with feature_id={id}
# 4. Get up to 3 random passing features for regression testing
feature_get_for_regression feature_get_for_regression
# 4. Mark a feature as passing (after verification) # 5. Mark a feature as passing (after verification)
feature_mark_passing with feature_id={id} feature_mark_passing with feature_id={id}
# 5. Skip a feature (moves to end of queue) - ONLY when blocked by dependency # 6. Skip a feature (moves to end of queue) - ONLY when blocked by dependency
feature_skip with feature_id={id} feature_skip with feature_id={id}
# 7. Clear in-progress status (when abandoning a feature)
feature_clear_in_progress with feature_id={id}
``` ```
### RULES: ### RULES:

View File

@@ -28,6 +28,7 @@ class Feature(Base):
description = Column(Text, nullable=False) description = Column(Text, nullable=False)
steps = Column(JSON, nullable=False) # Stored as JSON array steps = Column(JSON, nullable=False) # Stored as JSON array
passes = Column(Boolean, default=False, index=True) passes = Column(Boolean, default=False, index=True)
in_progress = Column(Boolean, default=False, index=True)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert feature to dictionary for JSON serialization.""" """Convert feature to dictionary for JSON serialization."""
@@ -39,6 +40,7 @@ class Feature(Base):
"description": self.description, "description": self.description,
"steps": self.steps, "steps": self.steps,
"passes": self.passes, "passes": self.passes,
"in_progress": self.in_progress,
} }
@@ -56,6 +58,21 @@ def get_database_url(project_dir: Path) -> str:
return f"sqlite:///{db_path.as_posix()}" return f"sqlite:///{db_path.as_posix()}"
def _migrate_add_in_progress_column(engine) -> None:
"""Add in_progress column to existing databases that don't have it."""
from sqlalchemy import text
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 "in_progress" not in columns:
# Add the column with default value
conn.execute(text("ALTER TABLE features ADD COLUMN in_progress BOOLEAN DEFAULT 0"))
conn.commit()
def create_database(project_dir: Path) -> tuple: def create_database(project_dir: Path) -> tuple:
""" """
Create database and return engine + session maker. Create database and return engine + session maker.
@@ -69,6 +86,10 @@ def create_database(project_dir: Path) -> tuple:
db_url = get_database_url(project_dir) db_url = get_database_url(project_dir)
engine = create_engine(db_url, connect_args={"check_same_thread": False}) engine = create_engine(db_url, connect_args={"check_same_thread": False})
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Migrate existing databases to add in_progress column
_migrate_add_in_progress_column(engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
return engine, SessionLocal return engine, SessionLocal

View File

@@ -22,6 +22,7 @@ FEATURE_MCP_TOOLS = [
"mcp__features__feature_get_stats", "mcp__features__feature_get_stats",
"mcp__features__feature_get_next", "mcp__features__feature_get_next",
"mcp__features__feature_get_for_regression", "mcp__features__feature_get_for_regression",
"mcp__features__feature_mark_in_progress",
"mcp__features__feature_mark_passing", "mcp__features__feature_mark_passing",
"mcp__features__feature_skip", "mcp__features__feature_skip",
"mcp__features__feature_create_bulk", "mcp__features__feature_create_bulk",

View File

@@ -12,6 +12,8 @@ Tools:
- feature_get_for_regression: Get random passing features for testing - feature_get_for_regression: Get random passing features for testing
- feature_mark_passing: Mark a feature as passing - feature_mark_passing: Mark a feature as passing
- feature_skip: Skip a feature (move to end of queue) - 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_bulk: Create multiple features at once
""" """
@@ -47,6 +49,16 @@ class SkipFeatureInput(BaseModel):
feature_id: int = Field(..., description="The ID of the feature to skip", ge=1) 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): class RegressionInput(BaseModel):
"""Input for getting regression features.""" """Input for getting regression features."""
limit: int = Field(default=3, ge=1, le=10, description="Maximum number of passing features to return") limit: int = Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")
@@ -106,20 +118,22 @@ def get_session():
def feature_get_stats() -> str: def feature_get_stats() -> str:
"""Get statistics about feature completion progress. """Get statistics about feature completion progress.
Returns the number of passing features, total features, and completion percentage. Returns the number of passing features, in-progress features, total features,
Use this to track overall progress of the implementation. and completion percentage. Use this to track overall progress of the implementation.
Returns: Returns:
JSON with: passing (int), total (int), percentage (float) JSON with: passing (int), in_progress (int), total (int), percentage (float)
""" """
session = get_session() session = get_session()
try: try:
total = session.query(Feature).count() total = session.query(Feature).count()
passing = session.query(Feature).filter(Feature.passes == True).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 percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
return json.dumps({ return json.dumps({
"passing": passing, "passing": passing,
"in_progress": in_progress,
"total": total, "total": total,
"percentage": percentage "percentage": percentage
}, indent=2) }, indent=2)
@@ -131,24 +145,26 @@ def feature_get_stats() -> str:
def feature_get_next() -> str: def feature_get_next() -> str:
"""Get the highest-priority pending feature to work on. """Get the highest-priority pending feature to work on.
Returns the feature with the lowest priority number that has passes=false. Returns the feature with the lowest priority number that has passes=false
and is not currently in-progress by another session.
Use this at the start of each coding session to determine what to implement next. Use this at the start of each coding session to determine what to implement next.
Returns: Returns:
JSON with feature details (id, priority, category, name, description, steps, passes) JSON with feature details (id, priority, category, name, description, steps, passes, in_progress)
or error message if all features are passing. or error message if all features are passing or in-progress.
""" """
session = get_session() session = get_session()
try: try:
feature = ( feature = (
session.query(Feature) session.query(Feature)
.filter(Feature.passes == False) .filter(Feature.passes == False)
.filter(Feature.in_progress == False)
.order_by(Feature.priority.asc(), Feature.id.asc()) .order_by(Feature.priority.asc(), Feature.id.asc())
.first() .first()
) )
if feature is None: if feature is None:
return json.dumps({"error": "All features are passing! No more work to do."}) return json.dumps({"error": "All features are passing or in-progress! No more work to do."})
return json.dumps(feature.to_dict(), indent=2) return json.dumps(feature.to_dict(), indent=2)
finally: finally:
@@ -195,8 +211,8 @@ def feature_mark_passing(
) -> str: ) -> str:
"""Mark a feature as passing after successful implementation. """Mark a feature as passing after successful implementation.
Updates the feature's passes field to true. Use this after you have Updates the feature's passes field to true and clears the in_progress flag.
implemented the feature and verified it works correctly. Use this after you have implemented the feature and verified it works correctly.
Args: Args:
feature_id: The ID of the feature to mark as passing feature_id: The ID of the feature to mark as passing
@@ -212,6 +228,7 @@ def feature_mark_passing(
return json.dumps({"error": f"Feature with ID {feature_id} not found"}) return json.dumps({"error": f"Feature with ID {feature_id} not found"})
feature.passes = True feature.passes = True
feature.in_progress = False
session.commit() session.commit()
session.refresh(feature) session.refresh(feature)
@@ -232,7 +249,8 @@ def feature_skip(
- Technical prerequisites that need to be addressed first - Technical prerequisites that need to be addressed first
The feature's priority is set to max_priority + 1, so it will be The feature's priority is set to max_priority + 1, so it will be
worked on after all other pending features. worked on after all other pending features. Also clears the in_progress
flag so the feature returns to "pending" status.
Args: Args:
feature_id: The ID of the feature to skip feature_id: The ID of the feature to skip
@@ -257,6 +275,7 @@ def feature_skip(
new_priority = (max_priority_result[0] + 1) if max_priority_result else 1 new_priority = (max_priority_result[0] + 1) if max_priority_result else 1
feature.priority = new_priority feature.priority = new_priority
feature.in_progress = False
session.commit() session.commit()
session.refresh(feature) session.refresh(feature)
@@ -271,6 +290,74 @@ def feature_skip(
session.close() 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)
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)
finally:
session.close()
@mcp.tool() @mcp.tool()
def feature_create_bulk( def feature_create_bulk(
features: Annotated[list[dict], Field(description="List of features to create, each with category, name, description, and steps")] features: Annotated[list[dict], Field(description="List of features to create, each with category, name, description, and steps")]

View File

@@ -56,19 +56,19 @@ def has_features(project_dir: Path) -> bool:
return False return False
def count_passing_tests(project_dir: Path) -> tuple[int, int]: def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
""" """
Count passing and total tests via direct database access. Count passing, in_progress, and total tests via direct database access.
Args: Args:
project_dir: Directory containing the project project_dir: Directory containing the project
Returns: Returns:
(passing_count, total_count) (passing_count, in_progress_count, total_count)
""" """
db_file = project_dir / "features.db" db_file = project_dir / "features.db"
if not db_file.exists(): if not db_file.exists():
return 0, 0 return 0, 0, 0
try: try:
conn = sqlite3.connect(db_file) conn = sqlite3.connect(db_file)
@@ -77,11 +77,17 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int]:
total = cursor.fetchone()[0] total = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1") cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1")
passing = cursor.fetchone()[0] passing = cursor.fetchone()[0]
# Handle case where in_progress column doesn't exist yet
try:
cursor.execute("SELECT COUNT(*) FROM features WHERE in_progress = 1")
in_progress = cursor.fetchone()[0]
except sqlite3.OperationalError:
in_progress = 0
conn.close() conn.close()
return passing, total return passing, in_progress, total
except Exception as e: except Exception as e:
print(f"[Database error in count_passing_tests: {e}]") print(f"[Database error in count_passing_tests: {e}]")
return 0, 0 return 0, 0, 0
def get_all_passing_features(project_dir: Path) -> list[dict]: def get_all_passing_features(project_dir: Path) -> list[dict]:
@@ -205,11 +211,14 @@ def print_session_header(session_num: int, is_initializer: bool) -> None:
def print_progress_summary(project_dir: Path) -> None: def print_progress_summary(project_dir: Path) -> None:
"""Print a summary of current progress.""" """Print a summary of current progress."""
passing, total = count_passing_tests(project_dir) passing, in_progress, total = count_passing_tests(project_dir)
if total > 0: if total > 0:
percentage = (passing / total) * 100 percentage = (passing / total) * 100
print(f"\nProgress: {passing}/{total} tests passing ({percentage:.1f}%)") status_parts = [f"{passing}/{total} tests passing ({percentage:.1f}%)"]
if in_progress > 0:
status_parts.append(f"{in_progress} in progress")
print(f"\nProgress: {', '.join(status_parts)}")
send_progress_webhook(passing, total, project_dir) send_progress_webhook(passing, total, project_dir)
else: else:
print("\nProgress: No features in database yet") print("\nProgress: No features in database yet")

View File

@@ -93,6 +93,7 @@ def feature_to_response(f) -> FeatureResponse:
description=f.description, description=f.description,
steps=f.steps if isinstance(f.steps, list) else [], steps=f.steps if isinstance(f.steps, list) else [],
passes=f.passes, passes=f.passes,
in_progress=f.in_progress,
) )
@@ -123,18 +124,21 @@ async def list_features(project_name: str):
all_features = session.query(Feature).order_by(Feature.priority).all() all_features = session.query(Feature).order_by(Feature.priority).all()
pending = [] pending = []
in_progress = []
done = [] done = []
for f in all_features: for f in all_features:
feature_response = feature_to_response(f) feature_response = feature_to_response(f)
if f.passes: if f.passes:
done.append(feature_response) done.append(feature_response)
elif f.in_progress:
in_progress.append(feature_response)
else: else:
pending.append(feature_response) pending.append(feature_response)
return FeatureListResponse( return FeatureListResponse(
pending=pending, pending=pending,
in_progress=[], in_progress=in_progress,
done=done, done=done,
) )
except HTTPException: except HTTPException:

View File

@@ -73,7 +73,7 @@ def validate_project_name(name: str) -> str:
def get_project_stats(project_dir: Path) -> ProjectStats: def get_project_stats(project_dir: Path) -> ProjectStats:
"""Get statistics for a project.""" """Get statistics for a project."""
_init_imports() _init_imports()
passing, total = _count_passing_tests(project_dir) passing, _, total = _count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0.0 percentage = (passing / total * 100) if total > 0 else 0.0
return ProjectStats(passing=passing, total=total, percentage=round(percentage, 1)) return ProjectStats(passing=passing, total=total, percentage=round(percentage, 1))

View File

@@ -78,6 +78,7 @@ class FeatureResponse(FeatureBase):
id: int id: int
priority: int priority: int
passes: bool passes: bool
in_progress: bool
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -83,8 +83,12 @@ class SpecChatSession:
except UnicodeDecodeError: except UnicodeDecodeError:
skill_content = skill_path.read_text(encoding="utf-8", errors="replace") skill_content = skill_path.read_text(encoding="utf-8", errors="replace")
# Replace $ARGUMENTS with the project path (use forward slashes for consistency) # Ensure project directory exists (like CLI does in start.py)
project_path = f"generations/{self.project_name}" self.project_dir.mkdir(parents=True, exist_ok=True)
# Replace $ARGUMENTS with absolute project path (like CLI does in start.py:184)
# Using absolute path avoids confusion when project folder name differs from app name
project_path = str(self.project_dir.resolve())
system_prompt = skill_content.replace("$ARGUMENTS", project_path) system_prompt = skill_content.replace("$ARGUMENTS", project_path)
# Create Claude SDK client with limited tools for spec creation # Create Claude SDK client with limited tools for spec creation

View File

@@ -117,21 +117,24 @@ async def poll_progress(websocket: WebSocket, project_name: str):
project_dir = _get_generations_dir() / project_name project_dir = _get_generations_dir() / project_name
count_passing_tests = _get_count_passing_tests() count_passing_tests = _get_count_passing_tests()
last_passing = -1 last_passing = -1
last_in_progress = -1
last_total = -1 last_total = -1
while True: while True:
try: try:
passing, total = count_passing_tests(project_dir) passing, in_progress, total = count_passing_tests(project_dir)
# Only send if changed # Only send if changed
if passing != last_passing or total != last_total: if passing != last_passing or in_progress != last_in_progress or total != last_total:
last_passing = passing last_passing = passing
last_in_progress = in_progress
last_total = total last_total = total
percentage = (passing / total * 100) if total > 0 else 0 percentage = (passing / total * 100) if total > 0 else 0
await websocket.send_json({ await websocket.send_json({
"type": "progress", "type": "progress",
"passing": passing, "passing": passing,
"in_progress": in_progress,
"total": total, "total": total,
"percentage": round(percentage, 1), "percentage": round(percentage, 1),
}) })
@@ -204,11 +207,12 @@ async def project_websocket(websocket: WebSocket, project_name: str):
# Send initial progress # Send initial progress
count_passing_tests = _get_count_passing_tests() count_passing_tests = _get_count_passing_tests()
passing, total = count_passing_tests(project_dir) passing, in_progress, total = count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0 percentage = (passing / total * 100) if total > 0 else 0
await websocket.send_json({ await websocket.send_json({
"type": "progress", "type": "progress",
"passing": passing, "passing": passing,
"in_progress": in_progress,
"total": total, "total": total,
"percentage": round(percentage, 1), "percentage": round(percentage, 1),
}) })

View File

@@ -8,6 +8,7 @@ import type { WSMessage, AgentStatus } from '../lib/types'
interface WebSocketState { interface WebSocketState {
progress: { progress: {
passing: number passing: number
in_progress: number
total: number total: number
percentage: number percentage: number
} }
@@ -20,7 +21,7 @@ const MAX_LOGS = 100 // Keep last 100 log lines
export function useProjectWebSocket(projectName: string | null) { export function useProjectWebSocket(projectName: string | null) {
const [state, setState] = useState<WebSocketState>({ const [state, setState] = useState<WebSocketState>({
progress: { passing: 0, total: 0, percentage: 0 }, progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
agentStatus: 'stopped', agentStatus: 'stopped',
logs: [], logs: [],
isConnected: false, isConnected: false,
@@ -57,6 +58,7 @@ export function useProjectWebSocket(projectName: string | null) {
...prev, ...prev,
progress: { progress: {
passing: message.passing, passing: message.passing,
in_progress: message.in_progress,
total: message.total, total: message.total,
percentage: message.percentage, percentage: message.percentage,
}, },

View File

@@ -5,6 +5,7 @@
// Project types // Project types
export interface ProjectStats { export interface ProjectStats {
passing: number passing: number
in_progress: number
total: number total: number
percentage: number percentage: number
} }
@@ -34,6 +35,7 @@ export interface Feature {
description: string description: string
steps: string[] steps: string[]
passes: boolean passes: boolean
in_progress: boolean
} }
export interface FeatureListResponse { export interface FeatureListResponse {
@@ -79,6 +81,7 @@ export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_statu
export interface WSProgressMessage { export interface WSProgressMessage {
type: 'progress' type: 'progress'
passing: number passing: number
in_progress: number
total: number total: number
percentage: number percentage: number
} }