Merge pull request #184 from CaitlynByrne/feature/blocked-for-human-input

feat: add blocked for human input feature
This commit is contained in:
Auto
2026-02-12 07:37:11 +02:00
21 changed files with 591 additions and 56 deletions

View File

@@ -222,7 +222,7 @@ async def run_autonomous_agent(
# Check if all features are already complete (before starting a new session)
# Skip this check if running as initializer (needs to create features first)
if not is_initializer and iteration == 1:
passing, in_progress, total = count_passing_tests(project_dir)
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
if total > 0 and passing == total:
print("\n" + "=" * 70)
print(" ALL FEATURES ALREADY COMPLETE!")
@@ -348,7 +348,7 @@ async def run_autonomous_agent(
print_progress_summary(project_dir)
# Check if all features are complete - exit gracefully if done
passing, in_progress, total = count_passing_tests(project_dir)
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
if total > 0 and passing == total:
print("\n" + "=" * 70)
print(" ALL FEATURES COMPLETE!")

View File

@@ -43,10 +43,10 @@ class Feature(Base):
__tablename__ = "features"
# Composite index for common status query pattern (passes, in_progress)
# Composite index for common status query pattern (passes, in_progress, needs_human_input)
# Used by feature_get_stats, get_ready_features, and other status queries
__table_args__ = (
Index('ix_feature_status', 'passes', 'in_progress'),
Index('ix_feature_status', 'passes', 'in_progress', 'needs_human_input'),
)
id = Column(Integer, primary_key=True, index=True)
@@ -61,6 +61,11 @@ class Feature(Base):
# NULL/empty = no dependencies (backwards compatible)
dependencies = Column(JSON, nullable=True, default=None)
# Human input: agent can request structured input from a human
needs_human_input = Column(Boolean, nullable=False, default=False, index=True)
human_input_request = Column(JSON, nullable=True, default=None) # Agent's structured request
human_input_response = Column(JSON, nullable=True, default=None) # Human's response
def to_dict(self) -> dict:
"""Convert feature to dictionary for JSON serialization."""
return {
@@ -75,6 +80,10 @@ class Feature(Base):
"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 [],
# Human input fields
"needs_human_input": self.needs_human_input if self.needs_human_input is not None else False,
"human_input_request": self.human_input_request,
"human_input_response": self.human_input_response,
}
def get_dependencies_safe(self) -> list[int]:
@@ -302,6 +311,21 @@ def _is_network_path(path: Path) -> bool:
return False
def _migrate_add_human_input_columns(engine) -> None:
"""Add human input columns to existing databases that don't have them."""
with engine.connect() as conn:
result = conn.execute(text("PRAGMA table_info(features)"))
columns = [row[1] for row in result.fetchall()]
if "needs_human_input" not in columns:
conn.execute(text("ALTER TABLE features ADD COLUMN needs_human_input BOOLEAN DEFAULT 0"))
if "human_input_request" not in columns:
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_request TEXT DEFAULT NULL"))
if "human_input_response" not in columns:
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_response TEXT DEFAULT NULL"))
conn.commit()
def _migrate_add_schedules_tables(engine) -> None:
"""Create schedules and schedule_overrides tables if they don't exist."""
from sqlalchemy import inspect
@@ -425,6 +449,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_human_input_columns(engine)
# Migrate to add schedules tables
_migrate_add_schedules_tables(engine)

View File

@@ -151,17 +151,20 @@ def feature_get_stats() -> str:
result = session.query(
func.count(Feature.id).label('total'),
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress')
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress'),
func.sum(case((Feature.needs_human_input == True, 1), else_=0)).label('needs_human_input')
).first()
total = result.total or 0
passing = int(result.passing or 0)
in_progress = int(result.in_progress or 0)
needs_human_input = int(result.needs_human_input or 0)
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
return json.dumps({
"passing": passing,
"in_progress": in_progress,
"needs_human_input": needs_human_input,
"total": total,
"percentage": percentage
})
@@ -221,6 +224,7 @@ def feature_get_summary(
"name": feature.name,
"passes": feature.passes,
"in_progress": feature.in_progress,
"needs_human_input": feature.needs_human_input if feature.needs_human_input is not None else False,
"dependencies": feature.dependencies or []
})
finally:
@@ -401,11 +405,11 @@ def feature_mark_in_progress(
"""
session = get_session()
try:
# Atomic claim: only succeeds if feature is not already claimed or passing
# Atomic claim: only succeeds if feature is not already claimed, passing, or blocked for human input
result = session.execute(text("""
UPDATE features
SET in_progress = 1
WHERE id = :id AND passes = 0 AND in_progress = 0
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
"""), {"id": feature_id})
session.commit()
@@ -418,6 +422,8 @@ def feature_mark_in_progress(
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"})
if getattr(feature, 'needs_human_input', False):
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
# Fetch the claimed feature
@@ -455,11 +461,14 @@ def feature_claim_and_get(
if feature.passes:
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
# Try atomic claim: only succeeds if not already claimed
if getattr(feature, 'needs_human_input', False):
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
# Try atomic claim: only succeeds if not already claimed and not blocked for human input
result = session.execute(text("""
UPDATE features
SET in_progress = 1
WHERE id = :id AND passes = 0 AND in_progress = 0
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
"""), {"id": feature_id})
session.commit()
@@ -806,6 +815,8 @@ def feature_get_ready(
for f in all_features:
if f.passes or f.in_progress:
continue
if getattr(f, 'needs_human_input', False):
continue
deps = f.dependencies or []
if all(dep_id in passing_ids for dep_id in deps):
ready.append(f.to_dict())
@@ -888,6 +899,8 @@ def feature_get_graph() -> str:
if f.passes:
status = "done"
elif getattr(f, 'needs_human_input', False):
status = "needs_human_input"
elif blocking:
status = "blocked"
elif f.in_progress:
@@ -984,6 +997,103 @@ def feature_set_dependencies(
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
@mcp.tool()
def feature_request_human_input(
feature_id: Annotated[int, Field(description="The ID of the feature that needs human input", ge=1)],
prompt: Annotated[str, Field(min_length=1, description="Explain what you need from the human and why")],
fields: Annotated[list[dict], Field(min_length=1, description="List of input fields to collect")]
) -> str:
"""Request structured input from a human for a feature that is blocked.
Use this ONLY when the feature genuinely cannot proceed without human intervention:
- Creating API keys or external accounts
- Choosing between design approaches that require human preference
- Configuring external services the agent cannot access
- Providing credentials or secrets
Do NOT use this for issues you can solve yourself (debugging, reading docs, etc.).
The feature will be moved out of in_progress and into a "needs human input" state.
Once the human provides their response, the feature returns to the pending queue
and will include the human's response when you pick it up again.
Args:
feature_id: The ID of the feature that needs human input
prompt: A clear explanation of what you need and why
fields: List of input fields, each with:
- id (str): Unique field identifier
- label (str): Human-readable label
- type (str): "text", "textarea", "select", or "boolean" (default: "text")
- required (bool): Whether the field is required (default: true)
- placeholder (str, optional): Placeholder text
- options (list, optional): For select type: [{value, label}]
Returns:
JSON with success confirmation or error message
"""
# Validate fields
VALID_FIELD_TYPES = {"text", "textarea", "select", "boolean"}
seen_ids: set[str] = set()
for i, field in enumerate(fields):
if "id" not in field or "label" not in field:
return json.dumps({"error": f"Field at index {i} missing required 'id' or 'label'"})
fid = field["id"]
flabel = field["label"]
if not isinstance(fid, str) or not fid.strip():
return json.dumps({"error": f"Field at index {i} has empty or invalid 'id'"})
if not isinstance(flabel, str) or not flabel.strip():
return json.dumps({"error": f"Field at index {i} has empty or invalid 'label'"})
if fid in seen_ids:
return json.dumps({"error": f"Duplicate field id '{fid}' at index {i}"})
seen_ids.add(fid)
ftype = field.get("type", "text")
if ftype not in VALID_FIELD_TYPES:
return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
if ftype == "select" and not field.get("options"):
return json.dumps({"error": f"Field at index {i} is type 'select' but missing 'options' array"})
request_data = {
"prompt": prompt,
"fields": fields,
}
session = get_session()
try:
# Atomically set needs_human_input, clear in_progress, store request, clear previous response
result = session.execute(text("""
UPDATE features
SET needs_human_input = 1,
in_progress = 0,
human_input_request = :request,
human_input_response = NULL
WHERE id = :id AND passes = 0 AND in_progress = 1
"""), {"id": feature_id, "request": json.dumps(request_data)})
session.commit()
if result.rowcount == 0:
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 not feature.in_progress:
return json.dumps({"error": f"Feature with ID {feature_id} is not in progress"})
return json.dumps({"error": "Failed to request human input for unknown reason"})
feature = session.query(Feature).filter(Feature.id == feature_id).first()
return json.dumps({
"success": True,
"feature_id": feature_id,
"name": feature.name,
"message": f"Feature '{feature.name}' is now blocked waiting for human input"
})
except Exception as e:
session.rollback()
return json.dumps({"error": f"Failed to request human input: {str(e)}"})
finally:
session.close()
@mcp.tool()
def ask_user(
questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")]

View File

@@ -496,6 +496,9 @@ class ParallelOrchestrator:
for fd in feature_dicts:
if not fd.get("in_progress") or fd.get("passes"):
continue
# Skip if blocked for human input
if fd.get("needs_human_input"):
continue
# Skip if already running in this orchestrator instance
if fd["id"] in running_ids:
continue
@@ -540,11 +543,14 @@ class ParallelOrchestrator:
running_ids.update(batch_ids)
ready = []
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0}
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, "needs_human_input": 0}
for fd in feature_dicts:
if fd.get("passes"):
skipped_reasons["passes"] += 1
continue
if fd.get("needs_human_input"):
skipped_reasons["needs_human_input"] += 1
continue
if fd.get("in_progress"):
skipped_reasons["in_progress"] += 1
continue

View File

@@ -62,54 +62,71 @@ def has_features(project_dir: Path) -> bool:
return False
def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
def count_passing_tests(project_dir: Path) -> tuple[int, int, int, int]:
"""
Count passing, in_progress, and total tests via direct database access.
Count passing, in_progress, total, and needs_human_input tests via direct database access.
Args:
project_dir: Directory containing the project
Returns:
(passing_count, in_progress_count, total_count)
(passing_count, in_progress_count, total_count, needs_human_input_count)
"""
from autoforge_paths import get_features_db_path
db_file = get_features_db_path(project_dir)
if not db_file.exists():
return 0, 0, 0
return 0, 0, 0, 0
try:
with closing(_get_connection(db_file)) as conn:
cursor = conn.cursor()
# Single aggregate query instead of 3 separate COUNT queries
# Handle case where in_progress column doesn't exist yet (legacy DBs)
# Single aggregate query instead of separate COUNT queries
# Handle case where columns don't exist yet (legacy DBs)
try:
cursor.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN needs_human_input = 1 THEN 1 ELSE 0 END) as needs_human_input
FROM features
""")
row = cursor.fetchone()
total = row[0] or 0
passing = row[1] or 0
in_progress = row[2] or 0
needs_human_input = row[3] or 0
except sqlite3.OperationalError:
# Fallback for databases without in_progress column
cursor.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
FROM features
""")
row = cursor.fetchone()
total = row[0] or 0
passing = row[1] or 0
in_progress = 0
return passing, in_progress, total
# Fallback for databases without newer columns
try:
cursor.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress
FROM features
""")
row = cursor.fetchone()
total = row[0] or 0
passing = row[1] or 0
in_progress = row[2] or 0
needs_human_input = 0
except sqlite3.OperationalError:
cursor.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
FROM features
""")
row = cursor.fetchone()
total = row[0] or 0
passing = row[1] or 0
in_progress = 0
needs_human_input = 0
return passing, in_progress, total, needs_human_input
except Exception as e:
print(f"[Database error in count_passing_tests: {e}]")
return 0, 0, 0
return 0, 0, 0, 0
def get_all_passing_features(project_dir: Path) -> list[dict]:
@@ -234,7 +251,7 @@ 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, in_progress, total = count_passing_tests(project_dir)
passing, in_progress, total, _needs_human_input = count_passing_tests(project_dir)
if total > 0:
percentage = (passing / total) * 100

View File

@@ -23,6 +23,7 @@ from ..schemas import (
FeatureListResponse,
FeatureResponse,
FeatureUpdate,
HumanInputResponse,
)
from ..utils.project_helpers import get_project_path as _get_project_path
from ..utils.validation import validate_project_name
@@ -104,6 +105,9 @@ def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureRespon
in_progress=f.in_progress if f.in_progress is not None else False,
blocked=blocked,
blocking_dependencies=blocking,
needs_human_input=getattr(f, 'needs_human_input', False) or False,
human_input_request=getattr(f, 'human_input_request', None),
human_input_response=getattr(f, 'human_input_response', None),
)
@@ -143,11 +147,14 @@ async def list_features(project_name: str):
pending = []
in_progress = []
done = []
needs_human_input_list = []
for f in all_features:
feature_response = feature_to_response(f, passing_ids)
if f.passes:
done.append(feature_response)
elif getattr(f, 'needs_human_input', False):
needs_human_input_list.append(feature_response)
elif f.in_progress:
in_progress.append(feature_response)
else:
@@ -157,6 +164,7 @@ async def list_features(project_name: str):
pending=pending,
in_progress=in_progress,
done=done,
needs_human_input=needs_human_input_list,
)
except HTTPException:
raise
@@ -341,9 +349,11 @@ async def get_dependency_graph(project_name: str):
deps = f.dependencies or []
blocking = [d for d in deps if d not in passing_ids]
status: Literal["pending", "in_progress", "done", "blocked"]
status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
if f.passes:
status = "done"
elif getattr(f, 'needs_human_input', False):
status = "needs_human_input"
elif blocking:
status = "blocked"
elif f.in_progress:
@@ -564,6 +574,71 @@ async def skip_feature(project_name: str, feature_id: int):
raise HTTPException(status_code=500, detail="Failed to skip feature")
@router.post("/{feature_id}/resolve-human-input", response_model=FeatureResponse)
async def resolve_human_input(project_name: str, feature_id: int, response: HumanInputResponse):
"""Resolve a human input request for a feature.
Validates all required fields have values, stores the response,
and returns the feature to the pending queue for agents to pick up.
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
if not getattr(feature, 'needs_human_input', False):
raise HTTPException(status_code=400, detail="Feature is not waiting for human input")
# Validate required fields
request_data = feature.human_input_request
if request_data and isinstance(request_data, dict):
for field_def in request_data.get("fields", []):
if field_def.get("required", True):
field_id = field_def.get("id")
if field_id not in response.fields or response.fields[field_id] in (None, ""):
raise HTTPException(
status_code=400,
detail=f"Required field '{field_def.get('label', field_id)}' is missing"
)
# Store response and return to pending queue
from datetime import datetime, timezone
response_data = {
"fields": {k: v for k, v in response.fields.items()},
"responded_at": datetime.now(timezone.utc).isoformat(),
}
feature.human_input_response = response_data
feature.needs_human_input = False
# Keep in_progress=False, passes=False so it returns to pending
session.commit()
session.refresh(feature)
# Compute passing IDs for response
all_features = session.query(Feature).all()
passing_ids = {f.id for f in all_features if f.passes}
return feature_to_response(feature, passing_ids)
except HTTPException:
raise
except Exception:
logger.exception("Failed to resolve human input")
raise HTTPException(status_code=500, detail="Failed to resolve human input")
# ============================================================================
# Dependency Management Endpoints
# ============================================================================

View File

@@ -102,7 +102,7 @@ def get_project_stats(project_dir: Path) -> ProjectStats:
"""Get statistics for a project."""
_init_imports()
assert _count_passing_tests is not None # guaranteed by _init_imports()
passing, in_progress, total = _count_passing_tests(project_dir)
passing, in_progress, total, _needs_human_input = _count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0.0
return ProjectStats(
passing=passing,

View File

@@ -120,16 +120,41 @@ class FeatureResponse(FeatureBase):
in_progress: bool
blocked: bool = False # Computed: has unmet dependencies
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
needs_human_input: bool = False
human_input_request: dict | None = None
human_input_response: dict | None = None
class Config:
from_attributes = True
class HumanInputField(BaseModel):
"""Schema for a single human input field."""
id: str
label: str
type: Literal["text", "textarea", "select", "boolean"] = "text"
required: bool = True
placeholder: str | None = None
options: list[dict] | None = None # For select: [{value, label}]
class HumanInputRequest(BaseModel):
"""Schema for an agent's human input request."""
prompt: str
fields: list[HumanInputField]
class HumanInputResponse(BaseModel):
"""Schema for a human's response to an input request."""
fields: dict[str, str | bool | list[str]]
class FeatureListResponse(BaseModel):
"""Response containing list of features organized by status."""
pending: list[FeatureResponse]
in_progress: list[FeatureResponse]
done: list[FeatureResponse]
needs_human_input: list[FeatureResponse] = Field(default_factory=list)
class FeatureBulkCreate(BaseModel):
@@ -153,7 +178,7 @@ class DependencyGraphNode(BaseModel):
id: int
name: str
category: str
status: Literal["pending", "in_progress", "done", "blocked"]
status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
priority: int
dependencies: list[int]
@@ -257,6 +282,7 @@ class WSProgressMessage(BaseModel):
in_progress: int
total: int
percentage: float
needs_human_input: int = 0
class WSFeatureUpdateMessage(BaseModel):

View File

@@ -716,15 +716,19 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
last_in_progress = -1
last_total = -1
last_needs_human_input = -1
while True:
try:
passing, in_progress, total = count_passing_tests(project_dir)
passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
# Only send if changed
if passing != last_passing or in_progress != last_in_progress or total != last_total:
if (passing != last_passing or in_progress != last_in_progress
or total != last_total or needs_human_input != last_needs_human_input):
last_passing = passing
last_in_progress = in_progress
last_total = total
last_needs_human_input = needs_human_input
percentage = (passing / total * 100) if total > 0 else 0
await websocket.send_json({
@@ -733,6 +737,7 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
"in_progress": in_progress,
"total": total,
"percentage": round(percentage, 1),
"needs_human_input": needs_human_input,
})
await asyncio.sleep(2) # Poll every 2 seconds
@@ -885,7 +890,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
# Send initial progress
count_passing_tests = _get_count_passing_tests()
passing, in_progress, total = count_passing_tests(project_dir)
passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0
await websocket.send_json({
"type": "progress",
@@ -893,6 +898,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
"in_progress": in_progress,
"total": total,
"percentage": round(percentage, 1),
"needs_human_input": needs_human_input,
})
# Keep connection alive and handle incoming messages

View File

@@ -130,7 +130,8 @@ function App() {
const allFeatures = [
...(features?.pending ?? []),
...(features?.in_progress ?? []),
...(features?.done ?? [])
...(features?.done ?? []),
...(features?.needs_human_input ?? [])
]
const feature = allFeatures.find(f => f.id === nodeId)
if (feature) setSelectedFeature(feature)
@@ -181,7 +182,7 @@ function App() {
// E : Expand project with AI (when project selected, has spec and has features)
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
(features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0) {
e.preventDefault()
setShowExpandProject(true)
}
@@ -245,7 +246,7 @@ function App() {
// Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : {
passing: features?.done.length ?? 0,
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0) + (features?.needs_human_input?.length ?? 0),
percentage: 0,
}
@@ -443,6 +444,7 @@ function App() {
features.pending.length === 0 &&
features.in_progress.length === 0 &&
features.done.length === 0 &&
(features.needs_human_input?.length || 0) === 0 &&
wsState.agentStatus === 'running' && (
<Card className="p-8 text-center">
<CardContent className="p-0">
@@ -458,7 +460,7 @@ function App() {
)}
{/* View Toggle - only show when there are features */}
{features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && (
{features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0 && (
<div className="flex justify-center">
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
</div>

View File

@@ -15,7 +15,7 @@ import {
Handle,
} from '@xyflow/react'
import dagre from 'dagre'
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw, UserCircle } from 'lucide-react'
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
import { AgentAvatar } from './AgentAvatar'
import { Button } from '@/components/ui/button'
@@ -93,18 +93,20 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
// Custom node component
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
const statusColors = {
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
needs_human_input: 'bg-amber-100 border-amber-300 dark:bg-amber-900/30 dark:border-amber-700',
}
const textColors = {
const textColors: Record<string, string> = {
pending: 'text-yellow-900 dark:text-yellow-100',
in_progress: 'text-cyan-900 dark:text-cyan-100',
done: 'text-green-900 dark:text-green-100',
blocked: 'text-red-900 dark:text-red-100',
needs_human_input: 'text-amber-900 dark:text-amber-100',
}
const StatusIcon = () => {
@@ -115,6 +117,8 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
case 'blocked':
return <AlertTriangle size={16} className="text-destructive" />
case 'needs_human_input':
return <UserCircle size={16} className={textColors[data.status]} />
default:
return <Circle size={16} className={textColors[data.status]} />
}
@@ -323,6 +327,8 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
return '#06b6d4' // cyan-500
case 'blocked':
return '#ef4444' // red-500
case 'needs_human_input':
return '#f59e0b' // amber-500
default:
return '#eab308' // yellow-500
}

View File

@@ -1,4 +1,4 @@
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
import { CheckCircle2, Circle, Loader2, MessageCircle, UserCircle } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types'
import { DependencyBadge } from './DependencyBadge'
import { AgentAvatar } from './AgentAvatar'
@@ -45,7 +45,8 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
cursor-pointer transition-all hover:border-primary py-3
${isInProgress ? 'animate-pulse' : ''}
${feature.passes ? 'border-primary/50' : ''}
${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''}
${feature.needs_human_input ? 'border-amber-500/50' : ''}
${isBlocked && !feature.passes && !feature.needs_human_input ? 'border-destructive/50 opacity-80' : ''}
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
`}
>
@@ -105,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
<CheckCircle2 size={16} className="text-primary" />
<span className="text-primary font-medium">Complete</span>
</>
) : feature.needs_human_input ? (
<>
<UserCircle size={16} className="text-amber-500" />
<span className="text-amber-500 font-medium">Needs Your Input</span>
</>
) : isBlocked ? (
<>
<Circle size={16} className="text-destructive" />

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle, UserCircle } from 'lucide-react'
import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects'
import { EditFeatureForm } from './EditFeatureForm'
import { HumanInputForm } from './HumanInputForm'
import type { Feature } from '../lib/types'
import {
Dialog,
@@ -50,10 +51,12 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
const deleteFeature = useDeleteFeature(projectName)
const { data: allFeatures } = useFeatures(projectName)
const resolveHumanInput = useResolveHumanInput(projectName)
// Build a map of feature ID to feature for looking up dependency names
const featureMap = new Map<number, Feature>()
if (allFeatures) {
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => {
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done, ...(allFeatures.needs_human_input || [])].forEach(f => {
featureMap.set(f.id, f)
})
}
@@ -141,6 +144,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
<CheckCircle2 size={24} className="text-primary" />
<span className="font-semibold text-primary">COMPLETE</span>
</>
) : feature.needs_human_input ? (
<>
<UserCircle size={24} className="text-amber-500" />
<span className="font-semibold text-amber-500">NEEDS YOUR INPUT</span>
</>
) : (
<>
<Circle size={24} className="text-muted-foreground" />
@@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
</span>
</div>
{/* Human Input Request */}
{feature.needs_human_input && feature.human_input_request && (
<HumanInputForm
request={feature.human_input_request}
onSubmit={async (fields) => {
setError(null)
try {
await resolveHumanInput.mutateAsync({ featureId: feature.id, fields })
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit response')
}
}}
isLoading={resolveHumanInput.isPending}
/>
)}
{/* Previous Human Input Response */}
{feature.human_input_response && !feature.needs_human_input && (
<Alert className="border-green-500 bg-green-50 dark:bg-green-950/20">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<AlertDescription>
<h4 className="font-semibold mb-1 text-green-700 dark:text-green-400">Human Input Provided</h4>
<p className="text-sm text-green-600 dark:text-green-300">
Response submitted{feature.human_input_response.responded_at
? ` at ${new Date(feature.human_input_response.responded_at).toLocaleString()}`
: ''}.
</p>
</AlertDescription>
</Alert>
)}
{/* Description */}
<div>
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">

View File

@@ -0,0 +1,150 @@
import { useState } from 'react'
import { Loader2, UserCircle, Send } from 'lucide-react'
import type { HumanInputRequest } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Switch } from '@/components/ui/switch'
interface HumanInputFormProps {
request: HumanInputRequest
onSubmit: (fields: Record<string, string | boolean | string[]>) => Promise<void>
isLoading: boolean
}
export function HumanInputForm({ request, onSubmit, isLoading }: HumanInputFormProps) {
const [values, setValues] = useState<Record<string, string | boolean | string[]>>(() => {
const initial: Record<string, string | boolean | string[]> = {}
for (const field of request.fields) {
if (field.type === 'boolean') {
initial[field.id] = false
} else {
initial[field.id] = ''
}
}
return initial
})
const [validationError, setValidationError] = useState<string | null>(null)
const handleSubmit = async () => {
// Validate required fields
for (const field of request.fields) {
if (field.required) {
const val = values[field.id]
if (val === undefined || val === null || val === '') {
setValidationError(`"${field.label}" is required`)
return
}
}
}
setValidationError(null)
await onSubmit(values)
}
return (
<Alert className="border-amber-500 bg-amber-50 dark:bg-amber-950/20">
<UserCircle className="h-5 w-5 text-amber-600" />
<AlertDescription className="space-y-4">
<div>
<h4 className="font-semibold text-amber-700 dark:text-amber-400">Agent needs your help</h4>
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">
{request.prompt}
</p>
</div>
<div className="space-y-3">
{request.fields.map((field) => (
<div key={field.id} className="space-y-1.5">
<Label htmlFor={`human-input-${field.id}`} className="text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === 'text' && (
<Input
id={`human-input-${field.id}`}
value={values[field.id] as string}
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
placeholder={field.placeholder || ''}
disabled={isLoading}
/>
)}
{field.type === 'textarea' && (
<Textarea
id={`human-input-${field.id}`}
value={values[field.id] as string}
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
placeholder={field.placeholder || ''}
disabled={isLoading}
rows={3}
/>
)}
{field.type === 'select' && field.options && (
<div className="space-y-1.5">
{field.options.map((option) => (
<label
key={option.value}
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors
${values[field.id] === option.value
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name={`human-input-${field.id}`}
value={option.value}
checked={values[field.id] === option.value}
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
disabled={isLoading}
className="accent-primary"
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
)}
{field.type === 'boolean' && (
<div className="flex items-center gap-2">
<Switch
id={`human-input-${field.id}`}
checked={values[field.id] as boolean}
onCheckedChange={(checked) => setValues(prev => ({ ...prev, [field.id]: checked }))}
disabled={isLoading}
/>
<Label htmlFor={`human-input-${field.id}`} className="text-sm">
{values[field.id] ? 'Yes' : 'No'}
</Label>
</div>
)}
</div>
))}
</div>
{validationError && (
<p className="text-sm text-destructive">{validationError}</p>
)}
<Button
onClick={handleSubmit}
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Send size={16} />
Submit Response
</>
)}
</Button>
</AlertDescription>
</Alert>
)
}

View File

@@ -13,13 +13,16 @@ interface KanbanBoardProps {
}
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0
// Combine all features for dependency status calculation
const allFeatures = features
? [...features.pending, ...features.in_progress, ...features.done]
? [...features.pending, ...features.in_progress, ...features.done, ...(features.needs_human_input || [])]
: []
const needsInputCount = features?.needs_human_input?.length || 0
const showNeedsInput = needsInputCount > 0
if (!features) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -40,7 +43,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={`grid grid-cols-1 ${showNeedsInput ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6`}>
<KanbanColumn
title="Pending"
count={features.pending.length}
@@ -64,6 +67,17 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
color="progress"
onFeatureClick={onFeatureClick}
/>
{showNeedsInput && (
<KanbanColumn
title="Needs Input"
count={needsInputCount}
features={features.needs_human_input}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="human_input"
onFeatureClick={onFeatureClick}
/>
)}
<KanbanColumn
title="Done"
count={features.done.length}

View File

@@ -11,7 +11,7 @@ interface KanbanColumnProps {
features: Feature[]
allFeatures?: Feature[]
activeAgents?: ActiveAgent[]
color: 'pending' | 'progress' | 'done'
color: 'pending' | 'progress' | 'done' | 'human_input'
onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void
onExpandProject?: () => void
@@ -24,6 +24,7 @@ const colorMap = {
pending: 'border-t-4 border-t-muted',
progress: 'border-t-4 border-t-primary',
done: 'border-t-4 border-t-primary',
human_input: 'border-t-4 border-t-amber-500',
}
export function KanbanColumn({

View File

@@ -137,6 +137,7 @@ function isAllComplete(features: FeatureListResponse | undefined): boolean {
return (
features.pending.length === 0 &&
features.in_progress.length === 0 &&
(features.needs_human_input?.length || 0) === 0 &&
features.done.length > 0
)
}

View File

@@ -133,6 +133,18 @@ export function useUpdateFeature(projectName: string) {
})
}
export function useResolveHumanInput(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ featureId, fields }: { featureId: number; fields: Record<string, string | boolean | string[]> }) =>
api.resolveHumanInput(projectName, featureId, { fields }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
},
})
}
// ============================================================================
// Agent
// ============================================================================

View File

@@ -33,6 +33,7 @@ interface WebSocketState {
progress: {
passing: number
in_progress: number
needs_human_input: number
total: number
percentage: number
}
@@ -60,7 +61,7 @@ const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent
export function useProjectWebSocket(projectName: string | null) {
const [state, setState] = useState<WebSocketState>({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
agentStatus: 'loading',
logs: [],
isConnected: false,
@@ -107,6 +108,7 @@ export function useProjectWebSocket(projectName: string | null) {
progress: {
passing: message.passing,
in_progress: message.in_progress,
needs_human_input: message.needs_human_input ?? 0,
total: message.total,
percentage: message.percentage,
},
@@ -385,7 +387,7 @@ export function useProjectWebSocket(projectName: string | null) {
// Reset state when project changes to clear stale data
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
setState({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
agentStatus: 'loading',
logs: [],
isConnected: false,

View File

@@ -181,6 +181,17 @@ export async function createFeaturesBulk(
})
}
export async function resolveHumanInput(
projectName: string,
featureId: number,
response: { fields: Record<string, string | boolean | string[]> }
): Promise<Feature> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/resolve-human-input`, {
method: 'POST',
body: JSON.stringify(response),
})
}
// ============================================================================
// Dependency Graph API
// ============================================================================

View File

@@ -57,6 +57,26 @@ export interface ProjectPrompts {
coding_prompt: string
}
// Human input types
export interface HumanInputField {
id: string
label: string
type: 'text' | 'textarea' | 'select' | 'boolean'
required: boolean
placeholder?: string
options?: { value: string; label: string }[]
}
export interface HumanInputRequest {
prompt: string
fields: HumanInputField[]
}
export interface HumanInputResponseData {
fields: Record<string, string | boolean | string[]>
responded_at?: string
}
// Feature types
export interface Feature {
id: number
@@ -70,10 +90,13 @@ export interface Feature {
dependencies?: number[] // Optional for backwards compat
blocked?: boolean // Computed by API
blocking_dependencies?: number[] // Computed by API
needs_human_input?: boolean
human_input_request?: HumanInputRequest | null
human_input_response?: HumanInputResponseData | null
}
// Status type for graph nodes
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked' | 'needs_human_input'
// Graph visualization types
export interface GraphNode {
@@ -99,6 +122,7 @@ export interface FeatureListResponse {
pending: Feature[]
in_progress: Feature[]
done: Feature[]
needs_human_input: Feature[]
}
export interface FeatureCreate {
@@ -250,6 +274,7 @@ export interface WSProgressMessage {
in_progress: number
total: number
percentage: number
needs_human_input?: number
}
export interface WSFeatureUpdateMessage {