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
```
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.
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):
```
# 1. Get progress stats (passing/total counts)
# 1. Get progress stats (passing/in_progress/total counts)
feature_get_stats
# 2. Get the NEXT feature to work on (one feature only)
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
# 4. Mark a feature as passing (after verification)
# 5. Mark a feature as passing (after verification)
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}
# 7. Clear in-progress status (when abandoning a feature)
feature_clear_in_progress with feature_id={id}
```
### RULES:

View File

@@ -28,6 +28,7 @@ class Feature(Base):
description = Column(Text, nullable=False)
steps = Column(JSON, nullable=False) # Stored as JSON array
passes = Column(Boolean, default=False, index=True)
in_progress = Column(Boolean, default=False, index=True)
def to_dict(self) -> dict:
"""Convert feature to dictionary for JSON serialization."""
@@ -39,6 +40,7 @@ class Feature(Base):
"description": self.description,
"steps": self.steps,
"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()}"
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:
"""
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)
engine = create_engine(db_url, connect_args={"check_same_thread": False})
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)
return engine, SessionLocal

View File

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

View File

@@ -12,6 +12,8 @@ Tools:
- feature_get_for_regression: Get random passing features for testing
- feature_mark_passing: Mark a feature as passing
- 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
"""
@@ -47,6 +49,16 @@ class SkipFeatureInput(BaseModel):
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")
@@ -106,20 +118,22 @@ def get_session():
def feature_get_stats() -> str:
"""Get statistics about feature completion progress.
Returns the number of passing features, total features, and completion percentage.
Use this to track overall progress of the implementation.
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), total (int), percentage (float)
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)
@@ -131,24 +145,26 @@ def feature_get_stats() -> str:
def feature_get_next() -> str:
"""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.
Returns:
JSON with feature details (id, priority, category, name, description, steps, passes)
or error message if all features are passing.
JSON with feature details (id, priority, category, name, description, steps, passes, in_progress)
or error message if all features are passing or in-progress.
"""
session = get_session()
try:
feature = (
session.query(Feature)
.filter(Feature.passes == False)
.filter(Feature.in_progress == False)
.order_by(Feature.priority.asc(), Feature.id.asc())
.first()
)
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)
finally:
@@ -195,8 +211,8 @@ def feature_mark_passing(
) -> str:
"""Mark a feature as passing after successful implementation.
Updates the feature's passes field to true. Use this after you have
implemented the feature and verified it works correctly.
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
@@ -212,6 +228,7 @@ def feature_mark_passing(
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
feature.passes = True
feature.in_progress = False
session.commit()
session.refresh(feature)
@@ -232,7 +249,8 @@ def feature_skip(
- 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.
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
@@ -257,6 +275,7 @@ def feature_skip(
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)
@@ -271,6 +290,74 @@ def feature_skip(
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()
def feature_create_bulk(
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
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:
project_dir: Directory containing the project
Returns:
(passing_count, total_count)
(passing_count, in_progress_count, total_count)
"""
db_file = project_dir / "features.db"
if not db_file.exists():
return 0, 0
return 0, 0, 0
try:
conn = sqlite3.connect(db_file)
@@ -77,11 +77,17 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int]:
total = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1")
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()
return passing, total
return passing, in_progress, total
except Exception as 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]:
@@ -205,11 +211,14 @@ def print_session_header(session_num: int, is_initializer: bool) -> None:
def print_progress_summary(project_dir: Path) -> None:
"""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:
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)
else:
print("\nProgress: No features in database yet")

View File

@@ -93,6 +93,7 @@ def feature_to_response(f) -> FeatureResponse:
description=f.description,
steps=f.steps if isinstance(f.steps, list) else [],
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()
pending = []
in_progress = []
done = []
for f in all_features:
feature_response = feature_to_response(f)
if f.passes:
done.append(feature_response)
elif f.in_progress:
in_progress.append(feature_response)
else:
pending.append(feature_response)
return FeatureListResponse(
pending=pending,
in_progress=[],
in_progress=in_progress,
done=done,
)
except HTTPException:

View File

@@ -73,7 +73,7 @@ def validate_project_name(name: str) -> str:
def get_project_stats(project_dir: Path) -> ProjectStats:
"""Get statistics for a project."""
_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
return ProjectStats(passing=passing, total=total, percentage=round(percentage, 1))

View File

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

View File

@@ -83,8 +83,12 @@ class SpecChatSession:
except UnicodeDecodeError:
skill_content = skill_path.read_text(encoding="utf-8", errors="replace")
# Replace $ARGUMENTS with the project path (use forward slashes for consistency)
project_path = f"generations/{self.project_name}"
# Ensure project directory exists (like CLI does in start.py)
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)
# 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
count_passing_tests = _get_count_passing_tests()
last_passing = -1
last_in_progress = -1
last_total = -1
while True:
try:
passing, total = count_passing_tests(project_dir)
passing, in_progress, total = count_passing_tests(project_dir)
# 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_in_progress = in_progress
last_total = total
percentage = (passing / total * 100) if total > 0 else 0
await websocket.send_json({
"type": "progress",
"passing": passing,
"in_progress": in_progress,
"total": total,
"percentage": round(percentage, 1),
})
@@ -204,11 +207,12 @@ async def project_websocket(websocket: WebSocket, project_name: str):
# Send initial progress
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
await websocket.send_json({
"type": "progress",
"passing": passing,
"in_progress": in_progress,
"total": total,
"percentage": round(percentage, 1),
})

View File

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

View File

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