mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
|
||||
25
progress.py
25
progress.py
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ class FeatureResponse(FeatureBase):
|
||||
id: int
|
||||
priority: int
|
||||
passes: bool
|
||||
in_progress: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user