Write

"""
Agents Router
=============

API endpoints for agent management and control.
"""

from datetime import datetime
from typing import Optional, Dict, Any, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, Feature
from .websocket import broadcast_agent_log

router = APIRouter(prefix="/api/projects", tags=["agents"])

# ============================================================================
# Agent State Storage (in-memory for now)
# ============================================================================

# Store agent states in memory (will be replaced with persistent storage later)
_agent_states: Dict[str, Dict[str, Dict[str, Any]]] = {}

def get_agent_state(project_name: str, agent_id: str) -> Dict[str, Any]:
    """Get agent state for a project."""
    if project_name not in _agent_states:
        _agent_states[project_name] = {
            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
        }
    return _agent_states[project_name].get(agent_id, {"status": "stopped"})

def set_agent_state(project_name: str, agent_id: str, state: Dict[str, Any]):
    """Set agent state for a project."""
    if project_name not in _agent_states:
        _agent_states[project_name] = {
            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
        }
    _agent_states[project_name][agent_id] = state

# ============================================================================
# Pydantic Schemas
# ============================================================================

class AgentStatus(BaseModel):
    """Schema for agent status."""
    status: str  # "stopped", "running", "paused", "stalled", "error"
    started_at: Optional[datetime] = None
    paused_at: Optional[datetime] = None
    error: Optional[str] = None
    crashed_at: Optional[datetime] = None

class AgentStatusResponse(BaseModel):
    """Schema for agent status response (both agents)."""
    agent_a: AgentStatus
    agent_b: AgentStatus

class AgentActionResponse(BaseModel):
    """Schema for agent action response."""
    agent_id: str
    status: str
    message: str

class TestLogRequest(BaseModel):
    """Schema for test log request."""
    log_type: str = "info"  # "info", "error", "tool", "permission"
    content: str

class ReportCrashRequest(BaseModel):
    """Schema for reporting agent crash."""
    error_message: str = Field(..., description="Error message describing the crash")
    cleanup_features: bool = Field(default=True, description="Whether to return in-progress features to pending")

class CrashResponse(BaseModel):
    """Schema for crash response."""
    agent_id: str
    status: str
    error: str
    features_cleaned: int
    message: str

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("/{name}/agents/status", response_model=AgentStatusResponse)
async def get_agents_status(name: str, db: AsyncSession = Depends(get_db)):
    """Get status of both agents for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    agent_a = get_agent_state(name, "A")
    agent_b = get_agent_state(name, "B")

    return AgentStatusResponse(
        agent_a=AgentStatus(**agent_a),
        agent_b=AgentStatus(**agent_b),
    )

@router.post("/{name}/agents/{agent_id}/start", response_model=AgentActionResponse)
async def start_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Start an agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] == "running":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is already running"
        )

    # Update state to running (clear any previous error state)
    set_agent_state(name, agent_id, {
        "status": "running",
        "started_at": datetime.utcnow(),
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="running",
        message=f"Agent {agent_id} started successfully"
    )

@router.post("/{name}/agents/{agent_id}/stop", response_model=AgentActionResponse)
async def stop_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Stop an agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Update state to stopped
    set_agent_state(name, agent_id, {
        "status": "stopped",
        "started_at": None,
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="stopped",
        message=f"Agent {agent_id} stopped successfully"
    )

@router.post("/{name}/agents/{agent_id}/pause", response_model=AgentActionResponse)
async def pause_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Pause an agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] != "running":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is not running (current status: {current_state['status']})"
        )

    # Update state to paused
    set_agent_state(name, agent_id, {
        "status": "paused",
        "started_at": current_state.get("started_at"),
        "paused_at": datetime.utcnow(),
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="paused",
        message=f"Agent {agent_id} paused successfully"
    )

@router.post("/{name}/agents/{agent_id}/resume", response_model=AgentActionResponse)
async def resume_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Resume a paused agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] != "paused":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is not paused (current status: {current_state['status']})"
        )

    # Update state to running
    set_agent_state(name, agent_id, {
        "status": "running",
        "started_at": current_state.get("started_at"),
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="running",
        message=f"Agent {agent_id} resumed successfully"
    )

@router.post("/{name}/agents/{agent_id}/report-crash", response_model=CrashResponse)
async def report_agent_crash(
    name: str,
    agent_id: str,
    request: ReportCrashRequest,
    db: AsyncSession = Depends(get_db)
):
    """Report an agent crash and optionally clean up its features.

    This endpoint is called when an agent crashes or encounters an unrecoverable error.
    It updates the agent status to 'error' and optionally returns any in-progress
    features assigned to this agent back to 'pending' status.

    The other agent is completely unaffected by this crash.
    """
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Update agent state to error
    set_agent_state(name, agent_id, {
        "status": "error",
        "started_at": None,
        "paused_at": None,
        "error": request.error_message,
        "crashed_at": datetime.utcnow(),
    })

    # Clean up features if requested
    features_cleaned = 0
    if request.cleanup_features:
        # Find all in-progress features assigned to this agent
        result = await db.execute(
            select(Feature).where(and_(
                Feature.project_id == project.id,
                Feature.assigned_agent == agent_id,
                Feature.in_progress == True
            ))
        )
        features = result.scalars().all()

        for feature in features:
            feature.in_progress = False
            feature.updated_at = datetime.utcnow()
            features_cleaned += 1

        await db.commit()

    # Broadcast crash notification
    await broadcast_agent_log(
        name,
        agent_id,
        "error",
        f"Agent {agent_id} crashed: {request.error_message}"
    )

    return CrashResponse(
        agent_id=agent_id,
        status="error",
        error=request.error_message,
        features_cleaned=features_cleaned,
        message=f"Agent {agent_id} crash reported. {features_cleaned} features returned to pending."
    )

@router.post("/{name}/agents/{agent_id}/clear-error", response_model=AgentActionResponse)
async def clear_agent_error(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Clear error state from an agent (reset to stopped).

    Use this after investigating and addressing the cause of the crash.
    """
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] != "error":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is not in error state (current status: {current_state['status']})"
        )

    # Clear error and reset to stopped
    set_agent_state(name, agent_id, {
        "status": "stopped",
        "started_at": None,
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="stopped",
        message=f"Agent {agent_id} error cleared, ready to start"
    )

@router.post("/{name}/agents/{agent_id}/test-log")
async def send_test_log(
    name: str,
    agent_id: str,
    request: TestLogRequest,
    db: AsyncSession = Depends(get_db)
):
    """Send a test log message to the terminal (for testing purposes)."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Broadcast the log message
    await broadcast_agent_log(name, agent_id, request.log_type, request.content)

    return {"status": "ok", "message": "Log sent successfully"}


File created successfully at: C:/Users/MikeSwanson/claude-projects/Autocode-remix/Autocode-fork/backend/app/routers/agents.py

"""
Agents Router
=============

API endpoints for agent management and control.
"""

from datetime import datetime
from typing import Optional, Dict, Any, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, Feature
from .websocket import broadcast_agent_log

router = APIRouter(prefix="/api/projects", tags=["agents"])

# ============================================================================
# Agent State Storage (in-memory for now)
# ============================================================================

# Store agent states in memory (will be replaced with persistent storage later)
_agent_states: Dict[str, Dict[str, Dict[str, Any]]] = {}

def get_agent_state(project_name: str, agent_id: str) -> Dict[str, Any]:
    """Get agent state for a project."""
    if project_name not in _agent_states:
        _agent_states[project_name] = {
            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
        }
    return _agent_states[project_name].get(agent_id, {"status": "stopped"})

def set_agent_state(project_name: str, agent_id: str, state: Dict[str, Any]):
    """Set agent state for a project."""
    if project_name not in _agent_states:
        _agent_states[project_name] = {
            "A": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
            "B": {"status": "stopped", "started_at": None, "paused_at": None, "error": None, "crashed_at": None},
        }
    _agent_states[project_name][agent_id] = state

# ============================================================================
# Pydantic Schemas
# ============================================================================

class AgentStatus(BaseModel):
    """Schema for agent status."""
    status: str  # "stopped", "running", "paused", "stalled", "error"
    started_at: Optional[datetime] = None
    paused_at: Optional[datetime] = None
    error: Optional[str] = None
    crashed_at: Optional[datetime] = None

class AgentStatusResponse(BaseModel):
    """Schema for agent status response (both agents)."""
    agent_a: AgentStatus
    agent_b: AgentStatus

class AgentActionResponse(BaseModel):
    """Schema for agent action response."""
    agent_id: str
    status: str
    message: str

class TestLogRequest(BaseModel):
    """Schema for test log request."""
    log_type: str = "info"  # "info", "error", "tool", "permission"
    content: str

class ReportCrashRequest(BaseModel):
    """Schema for reporting agent crash."""
    error_message: str = Field(..., description="Error message describing the crash")
    cleanup_features: bool = Field(default=True, description="Whether to return in-progress features to pending")

class CrashResponse(BaseModel):
    """Schema for crash response."""
    agent_id: str
    status: str
    error: str
    features_cleaned: int
    message: str

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("/{name}/agents/status", response_model=AgentStatusResponse)
async def get_agents_status(name: str, db: AsyncSession = Depends(get_db)):
    """Get status of both agents for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    agent_a = get_agent_state(name, "A")
    agent_b = get_agent_state(name, "B")

    return AgentStatusResponse(
        agent_a=AgentStatus(**agent_a),
        agent_b=AgentStatus(**agent_b),
    )

@router.post("/{name}/agents/{agent_id}/start", response_model=AgentActionResponse)
async def start_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Start an agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] == "running":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is already running"
        )

    # Update state to running (clear any previous error state)
    set_agent_state(name, agent_id, {
        "status": "running",
        "started_at": datetime.utcnow(),
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="running",
        message=f"Agent {agent_id} started successfully"
    )

@router.post("/{name}/agents/{agent_id}/stop", response_model=AgentActionResponse)
async def stop_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Stop an agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Update state to stopped
    set_agent_state(name, agent_id, {
        "status": "stopped",
        "started_at": None,
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="stopped",
        message=f"Agent {agent_id} stopped successfully"
    )

@router.post("/{name}/agents/{agent_id}/pause", response_model=AgentActionResponse)
async def pause_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Pause an agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] != "running":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is not running (current status: {current_state['status']})"
        )

    # Update state to paused
    set_agent_state(name, agent_id, {
        "status": "paused",
        "started_at": current_state.get("started_at"),
        "paused_at": datetime.utcnow(),
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="paused",
        message=f"Agent {agent_id} paused successfully"
    )

@router.post("/{name}/agents/{agent_id}/resume", response_model=AgentActionResponse)
async def resume_agent(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Resume a paused agent for a project."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] != "paused":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is not paused (current status: {current_state['status']})"
        )

    # Update state to running
    set_agent_state(name, agent_id, {
        "status": "running",
        "started_at": current_state.get("started_at"),
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="running",
        message=f"Agent {agent_id} resumed successfully"
    )

@router.post("/{name}/agents/{agent_id}/report-crash", response_model=CrashResponse)
async def report_agent_crash(
    name: str,
    agent_id: str,
    request: ReportCrashRequest,
    db: AsyncSession = Depends(get_db)
):
    """Report an agent crash and optionally clean up its features.

    This endpoint is called when an agent crashes or encounters an unrecoverable error.
    It updates the agent status to 'error' and optionally returns any in-progress
    features assigned to this agent back to 'pending' status.

    The other agent is completely unaffected by this crash.
    """
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Update agent state to error
    set_agent_state(name, agent_id, {
        "status": "error",
        "started_at": None,
        "paused_at": None,
        "error": request.error_message,
        "crashed_at": datetime.utcnow(),
    })

    # Clean up features if requested
    features_cleaned = 0
    if request.cleanup_features:
        # Find all in-progress features assigned to this agent
        result = await db.execute(
            select(Feature).where(and_(
                Feature.project_id == project.id,
                Feature.assigned_agent == agent_id,
                Feature.in_progress == True
            ))
        )
        features = result.scalars().all()

        for feature in features:
            feature.in_progress = False
            feature.updated_at = datetime.utcnow()
            features_cleaned += 1

        await db.commit()

    # Broadcast crash notification
    await broadcast_agent_log(
        name,
        agent_id,
        "error",
        f"Agent {agent_id} crashed: {request.error_message}"
    )

    return CrashResponse(
        agent_id=agent_id,
        status="error",
        error=request.error_message,
        features_cleaned=features_cleaned,
        message=f"Agent {agent_id} crash reported. {features_cleaned} features returned to pending."
    )

@router.post("/{name}/agents/{agent_id}/clear-error", response_model=AgentActionResponse)
async def clear_agent_error(name: str, agent_id: str, db: AsyncSession = Depends(get_db)):
    """Clear error state from an agent (reset to stopped).

    Use this after investigating and addressing the cause of the crash.
    """
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    current_state = get_agent_state(name, agent_id)

    if current_state["status"] != "error":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Agent {agent_id} is not in error state (current status: {current_state['status']})"
        )

    # Clear error and reset to stopped
    set_agent_state(name, agent_id, {
        "status": "stopped",
        "started_at": None,
        "paused_at": None,
        "error": None,
        "crashed_at": None,
    })

    return AgentActionResponse(
        agent_id=agent_id,
        status="stopped",
        message=f"Agent {agent_id} error cleared, ready to start"
    )

@router.post("/{name}/agents/{agent_id}/test-log")
async def send_test_log(
    name: str,
    agent_id: str,
    request: TestLogRequest,
    db: AsyncSession = Depends(get_db)
):
    """Send a test log message to the terminal (for testing purposes)."""
    # Validate agent_id
    if agent_id not in ("A", "B"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Agent ID must be 'A' or 'B'"
        )

    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Broadcast the log message
    await broadcast_agent_log(name, agent_id, request.log_type, request.content)

    return {"status": "ok", "message": "Log sent successfully"}


TodoWrite

Implement agent crash handling gracefully

Add crash detection and status update in backend

Add feature cleanup on agent crash

Test agent isolation on crash

Mark feature #81 as passing


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Implement agent crash handling gracefully

Add crash detection and status update in backend

Add feature cleanup on agent crash

Test agent isolation on crash

Mark feature #81 as passing

Add feature cleanup on agent crash

Test agent isolation on crash

Mark feature #81 as passing


Now let me test the crash handling on port 8106:


Bash

Verify server is running


Good, server is running. Now let me restart the server with the updated agents.py since main_v2.py should auto-reload:


<task-notification>
<task-id>bf0a4da</task-id>
<output-file>C:\Users\MIKESW~1\AppData\Local\Temp\claude\C--Users-MikeSwanson-claude-projects-Autocode-remix-Autocode-fork\tasks\bf0a4da.output</output-file>
<status>killed</status>
<summary>Background command "Start server on port 8106" was killed.</summary>
</task-notification>
Read the output file to retrieve the result: C:\Users\MIKESW~1\AppData\Local\Temp\claude\C--Users-MikeSwanson-claude-projects-Autocode-remix-Autocode-fork\tasks\bf0a4da.output


Successfully killed shell: bf0a4da (bash "C:/Users/MikeSwanson/claude-projects/Autocode-remix/Autocode-fork/start_server_8106.sh")


Start server on port 8106


Command running in background with ID: befdf03. Output is being written to: C:\Users\MIKESW~1\AppData\Local\Temp\claude\C--Users-MikeSwanson-claude-projects-Autocode-remix-Autocode-fork\tasks\befdf03.output


sleep 4 && curl -s http://127.0.0.1:8106/api/projects/file-conflict-test/agents/status

Check initial agent status


I notice the response doesn't have the new error fields. Let me check if the server reloaded properly:


     1→[stderr] C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\backend\app\routers\features.py:706: FastAPIDeprecationWarning: `regex` has been deprecated, please use `pattern` instead
     2→  agent_id: str = Query(..., regex="^[AB]$", description="Agent ID (A or B)"),
     3→[stderr] INFO:     Started server process [29328]
     4→INFO:     Waiting for application startup.
     5→[stderr] INFO:     Application startup complete.
     6→[stderr] ERROR:    [Errno 10048] error while attempting to bind on address ('127.0.0.1', 8106): [winerror 10048] only one usage of each socket address (protocol/network address/port) is normally permitted
     7→INFO:     Waiting for application shutdown.
     8→INFO:     Application shutdown complete.
     9→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

[stderr] C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\backend\app\routers\features.py:706: FastAPIDeprecationWarning: `regex` has been deprecated, please use `pattern` instead
  agent_id: str = Query(..., regex="^[AB]$", description="Agent ID (A or B)"),
[stderr] INFO:     Started server process [29328]
INFO:     Waiting for application startup.
[stderr] INFO:     Application startup complete.
[stderr] ERROR:    [Errno 10048] error while attempting to bind on address ('127.0.0.1', 8106): [winerror 10048] only one usage of each socket address (protocol/network address/port) is normally permitted
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.


The port is still in use. The previous server session is still alive on port 8106. Let me use a different port:


bash -c 'SCRIPT_DIR="$(cd "$(dirname "C:/Users/MikeSwanson/claude-projects/Autocode-remix/Autocode-fork")" && pwd)"; cd "C:/Users/MikeSwanson/claude-projects/Autocode-remix/Autocode-fork/backend"; "C:/Users/MikeSwanson/claude-projects/Autocode-remix/Autocode-fork/backend/venv/Scripts/python.exe" -m uvicorn app.main_v2:app --host 127.0.0.1 --port 8107'

Start server on port 8107


Command running in background with ID: b26b0c4. Output is being written to: C:\Users\MIKESW~1\AppData\Local\Temp\claude\C--Users-MikeSwanson-claude-projects-Autocode-remix-Autocode-fork\tasks\b26b0c4.output


Check health on port 8107


Now let's test the crash handling:


Check agent status with new fields


Now the status includes the new fields. Let me test the full crash scenario:


curl -s -X POST http://127.0.0.1:8107/api/projects/file-conflict-test/agents/A/start && echo "" && curl -s -X POST http://127.0.0.1:8107/api/projects/file-conflict-test/agents/B/start

Start both agents


{"agent_id":"A","status":"running","message":"Agent A started successfully"}
{"agent_id":"B","status":"running","message":"Agent B started successfully"}


Both agents are running. Now let's simulate Agent A crashing:


curl -s -X POST http://127.0.0.1:8107/api/projects/file-conflict-test/agents/A/report-crash -H "Content-Type: application/json" -d '{"error_message": "Simulated crash: memory corruption detected", "cleanup_features": true}'

Simulate Agent A crash


Simulated crash: memory corruption detected

Agent A crash reported. 0 features returned to pending.


Agent A is now in error state. Let's verify Agent B is unaffected:


Check both agent statuses


Excellent! Agent A shows:
- status: "error"
- error: "Simulated crash: memory corruption detected"
- crashed_at: timestamp

Agent B is completely unaffected - still "running"!

Now let's test clearing the error:


curl -s -X POST http://127.0.0.1:8107/api/projects/file-conflict-test/agents/A/clear-error

Clear Agent A error


Agent A error cleared, ready to start

Agent A error cleared, ready to start


Now let's test that feature cleanup works. First, let me create a test feature and mark it as in-progress for Agent A:


curl -s -X POST http://127.0.0.1:8107/api/projects/file-conflict-test/features -H "Content-Type: application/json" -d '{"name": "Crash test feature", "description": "Test feature for crash cleanup", "category": "test"}'

Create test feature


Crash test feature

Test feature for crash cleanup

Test feature for crash cleanup


Bash

curl -s -X PUT http://127.0.0.1:8107/api/projects/file-conflict-test/features/40 -H "Content-Type: application/json" -d '{"assigned_agent": "A", "in_progress": true}'

Assign feature to Agent A and mark in_progress


Crash test feature

Test feature for crash cleanup

Test feature for crash cleanup


Now start Agent A, then simulate a crash and verify the feature is returned to pending:


Bash

curl -s -X POST http://127.0.0.1:8107/api/projects/file-conflict-test/agents/A/start

Start Agent A


Agent A started successfully

Agent A started successfully


curl -s -X POST http://127.0.0.1:8107/api/projects/file-conflict-test/agents/A/report-crash -H "Content-Type: application/json" -d '{"error_message": "Fatal error: agent process terminated unexpectedly", "cleanup_features": true}'

Report Agent A crash with feature cleanup


Fatal error: agent process terminated unexpectedly

Agent A crash reported. 1 features returned to pending.


The crash report shows 1 feature was cleaned (returned to pending). Let's verify:


Check feature status after crash


Crash test feature

Test feature for crash cleanup

Test feature for crash cleanup


The feature now has `in_progress: false` - it was successfully returned to pending state! The agent assignment is preserved (still "A") so the same agent can pick it up again when restarted.

All tests pass. Let me mark the feature as passing:


TodoWrite

Implement agent crash handling gracefully

Add crash detection and status update in backend

Add feature cleanup on agent crash

Test agent isolation on crash

Mark feature #81 as passing


Add feature cleanup on agent crash

Test agent isolation on crash

Mark feature #81 as passing

Add feature cleanup on agent crash

Test agent isolation on crash

Mark feature #81 as passing


Agent crash handled gracefully

Verify one agent crashing doesn't affect the other

Step 3: Verify Agent A status shows error

Step 4: Verify Agent B continues working

Step 5: Verify Agent A's features returned to pending


**100 features passing - 94.3%!** Let me commit the changes:


Bash

git -C "C:/Users/MikeSwanson/claude-projects/Autocode-remix/Autocode-fork" add backend/app/routers/agents.py && git -C "C:/Users/MikeSwanson/claude-projects/Autocode-remix/Autocode-fork" commit -m "$(cat <<'EOF'
feat: Add graceful agent crash handling

Implements crash handling for dual-agent execution:
- Added 'error' status with error message and crash timestamp
- Added report-crash endpoint to mark agent as crashed
- Feature cleanup: returns in-progress features to pending on crash
- Clear-error endpoint to reset agent after crash investigation
- Agent isolation: crash of one agent doesn't affect the other

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"

Commit crash handling changes


[master 1231d85] feat: Add graceful agent crash handling
 1 file changed, 160 insertions(+), 10 deletions(-)
warning: in the working copy of 'backend/app/routers/agents.py', LF will be replaced by CRLF the next time Git touches it


Let me get the next feature:


Agent permissions enforce file access limits

Verify agents cannot access files outside allowed folders

Step 1: Configure permissions to allow only /project folder

Step 3: Agent attempts to read /etc/passwd

Step 5: Verify feature stalls with permission reason


This is a security feature requiring file permission enforcement. Let me explore the permissions system first:


     1→"""
     2→Permissions Router
     3→==================
     4→
     5→API endpoints for permission management within projects.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import Optional, List
    10→
    11→from fastapi import APIRouter, Depends, HTTPException, status
    12→from pydantic import BaseModel, Field
    13→from sqlalchemy import select, and_
    14→from sqlalchemy.ext.asyncio import AsyncSession
    15→
    16→from ..database import get_db
    17→from ..models import Project, Permission, PermissionRequest
    18→from .websocket import broadcast_permission_request
    19→
    20→router = APIRouter(prefix="/api/projects", tags=["permissions"])
    21→
    22→
    23→# ============================================================================
    24→# Pydantic Schemas
    25→# ============================================================================
    26→
    27→class PermissionResponse(BaseModel):
    28→    """Schema for permission response."""
    29→    id: int
    30→    project_id: int
    31→    permission_type: str
    32→    permission_value: str
    33→    granted: bool
    34→    created_at: datetime
    35→
    36→    class Config:
    37→        from_attributes = True
    38→
    39→
    40→class PermissionListResponse(BaseModel):
    41→    """Schema for permission list response."""
    42→    permissions: List[PermissionResponse]
    43→    count: int
    44→
    45→
    46→class PermissionCreate(BaseModel):
    47→    """Schema for creating a permission."""
    48→    permission_type: str = Field(..., min_length=1, max_length=50)
    49→    permission_value: str = Field(..., min_length=1, max_length=500)
    50→    granted: bool = True
    51→
    52→
    53→class PermissionUpdate(BaseModel):
    54→    """Schema for updating a permission."""
    55→    granted: Optional[bool] = None
    56→
    57→
    58→class PermissionRequestResponse(BaseModel):
    59→    """Schema for permission request response."""
    60→    id: int
    61→    project_id: int
    62→    feature_id: Optional[int]
    63→    agent_id: str
    64→    permission_type: str
    65→    permission_value: str
    66→    status: str
    67→    requested_at: datetime
    68→    resolved_at: Optional[datetime]
    69→
    70→    class Config:
    71→        from_attributes = True
    72→
    73→
    74→class PermissionRequestListResponse(BaseModel):
    75→    """Schema for permission request list response."""
    76→    requests: List[PermissionRequestResponse]
    77→    count: int
    78→
    79→
    80→class PermissionRequestCreate(BaseModel):
    81→    """Schema for creating a permission request."""
    82→    agent_id: str = Field(..., min_length=1, max_length=1)  # "A" or "B"
    83→    permission_type: str = Field(..., min_length=1, max_length=50)
    84→    permission_value: str = Field(..., min_length=1, max_length=500)
    85→    feature_id: Optional[int] = None
    86→
    87→
    88→class PermissionImportItem(BaseModel):
    89→    """Schema for a single permission in import."""
    90→    permission_type: str = Field(..., min_length=1, max_length=50)
    91→    permission_value: str = Field(..., min_length=1, max_length=500)
    92→    granted: bool = True
    93→
    94→
    95→class PermissionImportRequest(BaseModel):
    96→    """Schema for importing permissions from configuration."""
    97→    permissions: List[PermissionImportItem]
    98→    replace_existing: bool = False  # If true, delete all existing permissions first
    99→
   100→
   101→class PermissionImportResponse(BaseModel):
   102→    """Schema for import response."""
   103→    imported: int
   104→    skipped: int
   105→    message: str
   106→
   107→
   108→class PermissionExportItem(BaseModel):
   109→    """Schema for a single permission in export."""
   110→    permission_type: str
   111→    permission_value: str
   112→    granted: bool
   113→
   114→
   115→class PermissionExportResponse(BaseModel):
   116→    """Schema for export response."""
   117→    project_name: str
   118→    permissions: List[PermissionExportItem]
   119→    count: int
   120→
   121→
   122→# ============================================================================
   123→# Permission Endpoints
   124→# ============================================================================
   125→
   126→@router.get("/{name}/permissions", response_model=PermissionListResponse)
   127→async def list_permissions(name: str, db: AsyncSession = Depends(get_db)):
   128→    """List all permissions for a project."""
   129→    # Verify project exists
   130→    result = await db.execute(select(Project).where(Project.name == name))
   131→    project = result.scalar_one_or_none()
   132→
   133→    if not project:
   134→        raise HTTPException(
   135→            status_code=status.HTTP_404_NOT_FOUND,
   136→            detail=f"Project '{name}' not found"
   137→        )
   138→
   139→    # Get permissions
   140→    result = await db.execute(
   141→        select(Permission)
   142→        .where(Permission.project_id == project.id)
   143→        .order_by(Permission.created_at.desc())
   144→    )
   145→    permissions = result.scalars().all()
   146→
   147→    return PermissionListResponse(
   148→        permissions=[PermissionResponse.model_validate(p) for p in permissions],
   149→        count=len(permissions)
   150→    )
   151→
   152→
   153→@router.post("/{name}/permissions", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED)
   154→async def create_permission(name: str, permission: PermissionCreate, db: AsyncSession = Depends(get_db)):
   155→    """Create a new permission for a project."""
   156→    # Verify project exists
   157→    result = await db.execute(select(Project).where(Project.name == name))
   158→    project = result.scalar_one_or_none()
   159→
   160→    if not project:
   161→        raise HTTPException(
   162→            status_code=status.HTTP_404_NOT_FOUND,
   163→            detail=f"Project '{name}' not found"
   164→        )
   165→
   166→    # Check if permission already exists
   167→    result = await db.execute(
   168→        select(Permission).where(and_(
   169→            Permission.project_id == project.id,
   170→            Permission.permission_type == permission.permission_type,
   171→            Permission.permission_value == permission.permission_value
   172→        ))
   173→    )
   174→    existing = result.scalar_one_or_none()
   175→    if existing:
   176→        raise HTTPException(
   177→            status_code=status.HTTP_409_CONFLICT,
   178→            detail=f"Permission already exists"
   179→        )
   180→
   181→    # Create permission
   182→    db_permission = Permission(
   183→        project_id=project.id,
   184→        permission_type=permission.permission_type,
   185→        permission_value=permission.permission_value,
   186→        granted=permission.granted,
   187→    )
   188→    db.add(db_permission)
   189→    await db.commit()
   190→    await db.refresh(db_permission)
   191→
   192→    return PermissionResponse.model_validate(db_permission)
   193→
   194→
   195→# ============================================================================
   196→# Permission Import/Export Endpoints (must be before /{permission_id} routes)
   197→# ============================================================================
   198→
   199→@router.get("/{name}/permissions/export", response_model=PermissionExportResponse)
   200→async def export_permissions(name: str, db: AsyncSession = Depends(get_db)):
   201→    """Export permissions to a configuration format."""
   202→    # Verify project exists
   203→    result = await db.execute(select(Project).where(Project.name == name))
   204→    project = result.scalar_one_or_none()
   205→
   206→    if not project:
   207→        raise HTTPException(
   208→            status_code=status.HTTP_404_NOT_FOUND,
   209→            detail=f"Project '{name}' not found"
   210→        )
   211→
   212→    # Get all permissions
   213→    result = await db.execute(
   214→        select(Permission)
   215→        .where(Permission.project_id == project.id)
   216→        .order_by(Permission.permission_type, Permission.permission_value)
   217→    )
   218→    permissions = result.scalars().all()
   219→
   220→    return PermissionExportResponse(
   221→        project_name=name,
   222→        permissions=[
   223→            PermissionExportItem(
   224→                permission_type=p.permission_type,
   225→                permission_value=p.permission_value,
   226→                granted=p.granted
   227→            )
   228→            for p in permissions
   229→        ],
   230→        count=len(permissions)
   231→    )
   232→
   233→
   234→@router.post("/{name}/permissions/import", response_model=PermissionImportResponse)
   235→async def import_permissions(name: str, import_data: PermissionImportRequest, db: AsyncSession = Depends(get_db)):
   236→    """Import permissions from a configuration file."""
   237→    # Verify project exists
   238→    result = await db.execute(select(Project).where(Project.name == name))
   239→    project = result.scalar_one_or_none()
   240→
   241→    if not project:
   242→        raise HTTPException(
   243→            status_code=status.HTTP_404_NOT_FOUND,
   244→            detail=f"Project '{name}' not found"
   245→        )
   246→
   247→    imported = 0
   248→    skipped = 0
   249→
   250→    # If replace_existing is true, delete all existing permissions first
   251→    if import_data.replace_existing:
   252→        existing_result = await db.execute(
   253→            select(Permission).where(Permission.project_id == project.id)
   254→        )
   255→        existing_permissions = existing_result.scalars().all()
   256→        for perm in existing_permissions:
   257→            await db.delete(perm)
   258→
   259→    # Import each permission
   260→    for perm in import_data.permissions:
   261→        # Check if permission already exists (skip duplicates)
   262→        result = await db.execute(
   263→            select(Permission).where(and_(
   264→                Permission.project_id == project.id,
   265→                Permission.permission_type == perm.permission_type,
   266→                Permission.permission_value == perm.permission_value
   267→            ))
   268→        )
   269→        existing = result.scalar_one_or_none()
   270→
   271→        if existing:
   272→            skipped += 1
   273→            continue
   274→
   275→        # Create new permission
   276→        db_permission = Permission(
   277→            project_id=project.id,
   278→            permission_type=perm.permission_type,
   279→            permission_value=perm.permission_value,
   280→            granted=perm.granted,
   281→        )
   282→        db.add(db_permission)
   283→        imported += 1
   284→
   285→    await db.commit()
   286→
   287→    return PermissionImportResponse(
   288→        imported=imported,
   289→        skipped=skipped,
   290→        message=f"Successfully imported {imported} permissions ({skipped} skipped as duplicates)"
   291→    )
   292→
   293→
   294→# ============================================================================
   295→# Permission ID Endpoints
   296→# ============================================================================
   297→
   298→@router.get("/{name}/permissions/{permission_id}", response_model=PermissionResponse)
   299→async def get_permission(name: str, permission_id: int, db: AsyncSession = Depends(get_db)):
   300→    """Get a specific permission by ID."""
   301→    # Verify project exists
   302→    result = await db.execute(select(Project).where(Project.name == name))
   303→    project = result.scalar_one_or_none()
   304→
   305→    if not project:
   306→        raise HTTPException(
   307→            status_code=status.HTTP_404_NOT_FOUND,
   308→            detail=f"Project '{name}' not found"
   309→        )
   310→
   311→    # Get permission
   312→    result = await db.execute(
   313→        select(Permission).where(and_(
   314→            Permission.id == permission_id,
   315→            Permission.project_id == project.id
   316→        ))
   317→    )
   318→    permission = result.scalar_one_or_none()
   319→
   320→    if not permission:
   321→        raise HTTPException(
   322→            status_code=status.HTTP_404_NOT_FOUND,
   323→            detail=f"Permission {permission_id} not found in project '{name}'"
   324→        )
   325→
   326→    return PermissionResponse.model_validate(permission)
   327→
   328→
   329→@router.put("/{name}/permissions/{permission_id}", response_model=PermissionResponse)
   330→async def update_permission(name: str, permission_id: int, permission_update: PermissionUpdate, db: AsyncSession = Depends(get_db)):
   331→    """Update a permission."""
   332→    # Verify project exists
   333→    result = await db.execute(select(Project).where(Project.name == name))
   334→    project = result.scalar_one_or_none()
   335→
   336→    if not project:
   337→        raise HTTPException(
   338→            status_code=status.HTTP_404_NOT_FOUND,
   339→            detail=f"Project '{name}' not found"
   340→        )
   341→
   342→    # Get permission
   343→    result = await db.execute(
   344→        select(Permission).where(and_(
   345→            Permission.id == permission_id,
   346→            Permission.project_id == project.id
   347→        ))
   348→    )
   349→    permission = result.scalar_one_or_none()
   350→
   351→    if not permission:
   352→        raise HTTPException(
   353→            status_code=status.HTTP_404_NOT_FOUND,
   354→            detail=f"Permission {permission_id} not found in project '{name}'"
   355→        )
   356→
   357→    # Update granted status
   358→    if permission_update.granted is not None:
   359→        permission.granted = permission_update.granted
   360→
   361→    await db.commit()
   362→    await db.refresh(permission)
   363→
   364→    return PermissionResponse.model_validate(permission)
   365→
   366→
   367→@router.delete("/{name}/permissions/{permission_id}", status_code=status.HTTP_204_NO_CONTENT)
   368→async def delete_permission(name: str, permission_id: int, db: AsyncSession = Depends(get_db)):
   369→    """Delete a permission."""
   370→    # Verify project exists
   371→    result = await db.execute(select(Project).where(Project.name == name))
   372→    project = result.scalar_one_or_none()
   373→
   374→    if not project:
   375→        raise HTTPException(
   376→            status_code=status.HTTP_404_NOT_FOUND,
   377→            detail=f"Project '{name}' not found"
   378→        )
   379→
   380→    # Get permission
   381→    result = await db.execute(
   382→        select(Permission).where(and_(
   383→            Permission.id == permission_id,
   384→            Permission.project_id == project.id
   385→        ))
   386→    )
   387→    permission = result.scalar_one_or_none()
   388→
   389→    if not permission:
   390→        raise HTTPException(
   391→            status_code=status.HTTP_404_NOT_FOUND,
   392→            detail=f"Permission {permission_id} not found in project '{name}'"
   393→        )
   394→
   395→    await db.delete(permission)
   396→    await db.commit()
   397→
   398→
   399→# ============================================================================
   400→# Permission Request Endpoints
   401→# ============================================================================
   402→
   403→@router.get("/{name}/permission-requests", response_model=PermissionRequestListResponse)
   404→async def list_permission_requests(
   405→    name: str,
   406→    status_filter: Optional[str] = None,
   407→    db: AsyncSession = Depends(get_db)
   408→):
   409→    """List permission requests for a project."""
   410→    # Verify project exists
   411→    result = await db.execute(select(Project).where(Project.name == name))
   412→    project = result.scalar_one_or_none()
   413→
   414→    if not project:
   415→        raise HTTPException(
   416→            status_code=status.HTTP_404_NOT_FOUND,
   417→            detail=f"Project '{name}' not found"
   418→        )
   419→
   420→    # Build query
   421→    query = select(PermissionRequest).where(PermissionRequest.project_id == project.id)
   422→
   423→    if status_filter:
   424→        query = query.where(PermissionRequest.status == status_filter)
   425→
   426→    query = query.order_by(PermissionRequest.requested_at.desc())
   427→
   428→    result = await db.execute(query)
   429→    requests = result.scalars().all()
   430→
   431→    return PermissionRequestListResponse(
   432→        requests=[PermissionRequestResponse.model_validate(r) for r in requests],
   433→        count=len(requests)
   434→    )
   435→
   436→
   437→@router.post("/{name}/permission-requests", response_model=PermissionRequestResponse, status_code=status.HTTP_201_CREATED)
   438→async def create_permission_request(name: str, request: PermissionRequestCreate, db: AsyncSession = Depends(get_db)):
   439→    """Create a new permission request (typically called by agents)."""
   440→    # Verify project exists
   441→    result = await db.execute(select(Project).where(Project.name == name))
   442→    project = result.scalar_one_or_none()
   443→
   444→    if not project:
   445→        raise HTTPException(
   446→            status_code=status.HTTP_404_NOT_FOUND,
   447→            detail=f"Project '{name}' not found"
   448→        )
   449→
   450→    # Create permission request
   451→    db_request = PermissionRequest(
   452→        project_id=project.id,
   453→        feature_id=request.feature_id,
   454→        agent_id=request.agent_id,
   455→        permission_type=request.permission_type,
   456→        permission_value=request.permission_value,
   457→        status="pending",
   458→    )
   459→    db.add(db_request)
   460→    await db.commit()
   461→    await db.refresh(db_request)
   462→
   463→    # Broadcast permission request to connected WebSocket clients
   464→    await broadcast_permission_request(
   465→        project_name=name,
   466→        request_id=db_request.id,
   467→        permission_type=db_request.permission_type,
   468→        permission_value=db_request.permission_value,
   469→        agent_id=db_request.agent_id
   470→    )
   471→
   472→    return PermissionRequestResponse.model_validate(db_request)
   473→
   474→
   475→@router.post("/{name}/permission-requests/{request_id}/grant", response_model=PermissionRequestResponse)
   476→async def grant_permission_request(name: str, request_id: int, db: AsyncSession = Depends(get_db)):
   477→    """Grant a pending permission request."""
   478→    # Verify project exists
   479→    result = await db.execute(select(Project).where(Project.name == name))
   480→    project = result.scalar_one_or_none()
   481→
   482→    if not project:
   483→        raise HTTPException(
   484→            status_code=status.HTTP_404_NOT_FOUND,
   485→            detail=f"Project '{name}' not found"
   486→        )
   487→
   488→    # Get permission request
   489→    result = await db.execute(
   490→        select(PermissionRequest).where(and_(
   491→            PermissionRequest.id == request_id,
   492→            PermissionRequest.project_id == project.id
   493→        ))
   494→    )
   495→    request = result.scalar_one_or_none()
   496→
   497→    if not request:
   498→        raise HTTPException(
   499→            status_code=status.HTTP_404_NOT_FOUND,
   500→            detail=f"Permission request {request_id} not found"
   501→        )
   502→
   503→    if request.status != "pending":
   504→        raise HTTPException(
   505→            status_code=status.HTTP_400_BAD_REQUEST,
   506→            detail=f"Permission request is already {request.status}"
   507→        )
   508→
   509→    # Update request status
   510→    request.status = "granted"
   511→    request.resolved_at = datetime.utcnow()
   512→
   513→    # Create the actual permission
   514→    db_permission = Permission(
   515→        project_id=project.id,
   516→        permission_type=request.permission_type,
   517→        permission_value=request.permission_value,
   518→        granted=True,
   519→    )
   520→    db.add(db_permission)
   521→
   522→    await db.commit()
   523→    await db.refresh(request)
   524→
   525→    return PermissionRequestResponse.model_validate(request)
   526→
   527→
   528→@router.post("/{name}/permission-requests/{request_id}/deny", response_model=PermissionRequestResponse)
   529→async def deny_permission_request(name: str, request_id: int, db: AsyncSession = Depends(get_db)):
   530→    """Deny a pending permission request."""
   531→    # Verify project exists
   532→    result = await db.execute(select(Project).where(Project.name == name))
   533→    project = result.scalar_one_or_none()
   534→
   535→    if not project:
   536→        raise HTTPException(
   537→            status_code=status.HTTP_404_NOT_FOUND,
   538→            detail=f"Project '{name}' not found"
   539→        )
   540→
   541→    # Get permission request
   542→    result = await db.execute(
   543→        select(PermissionRequest).where(and_(
   544→            PermissionRequest.id == request_id,
   545→            PermissionRequest.project_id == project.id
   546→        ))
   547→    )
   548→    request = result.scalar_one_or_none()
   549→
   550→    if not request:
   551→        raise HTTPException(
   552→            status_code=status.HTTP_404_NOT_FOUND,
   553→            detail=f"Permission request {request_id} not found"
   554→        )
   555→
   556→    if request.status != "pending":
   557→        raise HTTPException(
   558→            status_code=status.HTTP_400_BAD_REQUEST,
   559→            detail=f"Permission request is already {request.status}"
   560→        )
   561→
   562→    # Update request status
   563→    request.status = "denied"
   564→    request.resolved_at = datetime.utcnow()
   565→
   566→    await db.commit()
   567→    await db.refresh(request)
   568→
   569→    return PermissionRequestResponse.model_validate(request)
   570→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Permissions Router
==================

API endpoints for permission management within projects.
"""

from datetime import datetime
from typing import Optional, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, Permission, PermissionRequest
from .websocket import broadcast_permission_request

router = APIRouter(prefix="/api/projects", tags=["permissions"])

# ============================================================================
# Pydantic Schemas
# ============================================================================

class PermissionResponse(BaseModel):
    """Schema for permission response."""
    id: int
    project_id: int
    permission_type: str
    permission_value: str
    granted: bool
    created_at: datetime

    class Config:
        from_attributes = True

class PermissionListResponse(BaseModel):
    """Schema for permission list response."""
    permissions: List[PermissionResponse]
    count: int

class PermissionCreate(BaseModel):
    """Schema for creating a permission."""
    permission_type: str = Field(..., min_length=1, max_length=50)
    permission_value: str = Field(..., min_length=1, max_length=500)
    granted: bool = True

class PermissionUpdate(BaseModel):
    """Schema for updating a permission."""
    granted: Optional[bool] = None

class PermissionRequestResponse(BaseModel):
    """Schema for permission request response."""
    id: int
    project_id: int
    feature_id: Optional[int]
    agent_id: str
    permission_type: str
    permission_value: str
    status: str
    requested_at: datetime
    resolved_at: Optional[datetime]

    class Config:
        from_attributes = True

class PermissionRequestListResponse(BaseModel):
    """Schema for permission request list response."""
    requests: List[PermissionRequestResponse]
    count: int

class PermissionRequestCreate(BaseModel):
    """Schema for creating a permission request."""
    agent_id: str = Field(..., min_length=1, max_length=1)  # "A" or "B"
    permission_type: str = Field(..., min_length=1, max_length=50)
    permission_value: str = Field(..., min_length=1, max_length=500)
    feature_id: Optional[int] = None

class PermissionImportItem(BaseModel):
    """Schema for a single permission in import."""
    permission_type: str = Field(..., min_length=1, max_length=50)
    permission_value: str = Field(..., min_length=1, max_length=500)
    granted: bool = True

class PermissionImportRequest(BaseModel):
    """Schema for importing permissions from configuration."""
    permissions: List[PermissionImportItem]
    replace_existing: bool = False  # If true, delete all existing permissions first

class PermissionImportResponse(BaseModel):
    """Schema for import response."""
    imported: int
    skipped: int
    message: str

class PermissionExportItem(BaseModel):
    """Schema for a single permission in export."""
    permission_type: str
    permission_value: str
    granted: bool

class PermissionExportResponse(BaseModel):
    """Schema for export response."""
    project_name: str
    permissions: List[PermissionExportItem]
    count: int

# ============================================================================
# Permission Endpoints
# ============================================================================

@router.get("/{name}/permissions", response_model=PermissionListResponse)
async def list_permissions(name: str, db: AsyncSession = Depends(get_db)):
    """List all permissions for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get permissions
    result = await db.execute(
        select(Permission)
        .where(Permission.project_id == project.id)
        .order_by(Permission.created_at.desc())
    )
    permissions = result.scalars().all()

    return PermissionListResponse(
        permissions=[PermissionResponse.model_validate(p) for p in permissions],
        count=len(permissions)
    )

@router.post("/{name}/permissions", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED)
async def create_permission(name: str, permission: PermissionCreate, db: AsyncSession = Depends(get_db)):
    """Create a new permission for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Check if permission already exists
    result = await db.execute(
        select(Permission).where(and_(
            Permission.project_id == project.id,
            Permission.permission_type == permission.permission_type,
            Permission.permission_value == permission.permission_value
        ))
    )
    existing = result.scalar_one_or_none()
    if existing:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Permission already exists"
        )

    # Create permission
    db_permission = Permission(
        project_id=project.id,
        permission_type=permission.permission_type,
        permission_value=permission.permission_value,
        granted=permission.granted,
    )
    db.add(db_permission)
    await db.commit()
    await db.refresh(db_permission)

    return PermissionResponse.model_validate(db_permission)

# ============================================================================
# Permission Import/Export Endpoints (must be before /{permission_id} routes)
# ============================================================================

@router.get("/{name}/permissions/export", response_model=PermissionExportResponse)
async def export_permissions(name: str, db: AsyncSession = Depends(get_db)):
    """Export permissions to a configuration format."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get all permissions
    result = await db.execute(
        select(Permission)
        .where(Permission.project_id == project.id)
        .order_by(Permission.permission_type, Permission.permission_value)
    )
    permissions = result.scalars().all()

    return PermissionExportResponse(
        project_name=name,
        permissions=[
            PermissionExportItem(
                permission_type=p.permission_type,
                permission_value=p.permission_value,
                granted=p.granted
            )
            for p in permissions
        ],
        count=len(permissions)
    )

@router.post("/{name}/permissions/import", response_model=PermissionImportResponse)
async def import_permissions(name: str, import_data: PermissionImportRequest, db: AsyncSession = Depends(get_db)):
    """Import permissions from a configuration file."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    imported = 0
    skipped = 0

    # If replace_existing is true, delete all existing permissions first
    if import_data.replace_existing:
        existing_result = await db.execute(
            select(Permission).where(Permission.project_id == project.id)
        )
        existing_permissions = existing_result.scalars().all()
        for perm in existing_permissions:
            await db.delete(perm)

    # Import each permission
    for perm in import_data.permissions:
        # Check if permission already exists (skip duplicates)
        result = await db.execute(
            select(Permission).where(and_(
                Permission.project_id == project.id,
                Permission.permission_type == perm.permission_type,
                Permission.permission_value == perm.permission_value
            ))
        )
        existing = result.scalar_one_or_none()

        if existing:
            skipped += 1
            continue

        # Create new permission
        db_permission = Permission(
            project_id=project.id,
            permission_type=perm.permission_type,
            permission_value=perm.permission_value,
            granted=perm.granted,
        )
        db.add(db_permission)
        imported += 1

    await db.commit()

    return PermissionImportResponse(
        imported=imported,
        skipped=skipped,
        message=f"Successfully imported {imported} permissions ({skipped} skipped as duplicates)"
    )

# ============================================================================
# Permission ID Endpoints
# ============================================================================

@router.get("/{name}/permissions/{permission_id}", response_model=PermissionResponse)
async def get_permission(name: str, permission_id: int, db: AsyncSession = Depends(get_db)):
    """Get a specific permission by ID."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get permission
    result = await db.execute(
        select(Permission).where(and_(
            Permission.id == permission_id,
            Permission.project_id == project.id
        ))
    )
    permission = result.scalar_one_or_none()

    if not permission:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Permission {permission_id} not found in project '{name}'"
        )

    return PermissionResponse.model_validate(permission)

@router.put("/{name}/permissions/{permission_id}", response_model=PermissionResponse)
async def update_permission(name: str, permission_id: int, permission_update: PermissionUpdate, db: AsyncSession = Depends(get_db)):
    """Update a permission."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get permission
    result = await db.execute(
        select(Permission).where(and_(
            Permission.id == permission_id,
            Permission.project_id == project.id
        ))
    )
    permission = result.scalar_one_or_none()

    if not permission:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Permission {permission_id} not found in project '{name}'"
        )

    # Update granted status
    if permission_update.granted is not None:
        permission.granted = permission_update.granted

    await db.commit()
    await db.refresh(permission)

    return PermissionResponse.model_validate(permission)

@router.delete("/{name}/permissions/{permission_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_permission(name: str, permission_id: int, db: AsyncSession = Depends(get_db)):
    """Delete a permission."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get permission
    result = await db.execute(
        select(Permission).where(and_(
            Permission.id == permission_id,
            Permission.project_id == project.id
        ))
    )
    permission = result.scalar_one_or_none()

    if not permission:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Permission {permission_id} not found in project '{name}'"
        )

    await db.delete(permission)
    await db.commit()

# ============================================================================
# Permission Request Endpoints
# ============================================================================

@router.get("/{name}/permission-requests", response_model=PermissionRequestListResponse)
async def list_permission_requests(
    name: str,
    status_filter: Optional[str] = None,
    db: AsyncSession = Depends(get_db)
):
    """List permission requests for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Build query
    query = select(PermissionRequest).where(PermissionRequest.project_id == project.id)

    if status_filter:
        query = query.where(PermissionRequest.status == status_filter)

    query = query.order_by(PermissionRequest.requested_at.desc())

    result = await db.execute(query)
    requests = result.scalars().all()

    return PermissionRequestListResponse(
        requests=[PermissionRequestResponse.model_validate(r) for r in requests],
        count=len(requests)
    )

@router.post("/{name}/permission-requests", response_model=PermissionRequestResponse, status_code=status.HTTP_201_CREATED)
async def create_permission_request(name: str, request: PermissionRequestCreate, db: AsyncSession = Depends(get_db)):
    """Create a new permission request (typically called by agents)."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Create permission request
    db_request = PermissionRequest(
        project_id=project.id,
        feature_id=request.feature_id,
        agent_id=request.agent_id,
        permission_type=request.permission_type,
        permission_value=request.permission_value,
        status="pending",
    )
    db.add(db_request)
    await db.commit()
    await db.refresh(db_request)

    # Broadcast permission request to connected WebSocket clients
    await broadcast_permission_request(
        project_name=name,
        request_id=db_request.id,
        permission_type=db_request.permission_type,
        permission_value=db_request.permission_value,
        agent_id=db_request.agent_id
    )

    return PermissionRequestResponse.model_validate(db_request)

@router.post("/{name}/permission-requests/{request_id}/grant", response_model=PermissionRequestResponse)
async def grant_permission_request(name: str, request_id: int, db: AsyncSession = Depends(get_db)):
    """Grant a pending permission request."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get permission request
    result = await db.execute(
        select(PermissionRequest).where(and_(
            PermissionRequest.id == request_id,
            PermissionRequest.project_id == project.id
        ))
    )
    request = result.scalar_one_or_none()

    if not request:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Permission request {request_id} not found"
        )

    if request.status != "pending":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Permission request is already {request.status}"
        )

    # Update request status
    request.status = "granted"
    request.resolved_at = datetime.utcnow()

    # Create the actual permission
    db_permission = Permission(
        project_id=project.id,
        permission_type=request.permission_type,
        permission_value=request.permission_value,
        granted=True,
    )
    db.add(db_permission)

    await db.commit()
    await db.refresh(request)

    return PermissionRequestResponse.model_validate(request)

@router.post("/{name}/permission-requests/{request_id}/deny", response_model=PermissionRequestResponse)
async def deny_permission_request(name: str, request_id: int, db: AsyncSession = Depends(get_db)):
    """Deny a pending permission request."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get permission request
    result = await db.execute(
        select(PermissionRequest).where(and_(
            PermissionRequest.id == request_id,
            PermissionRequest.project_id == project.id
        ))
    )
    request = result.scalar_one_or_none()

    if not request:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Permission request {request_id} not found"
        )

    if request.status != "pending":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Permission request is already {request.status}"
        )

    # Update request status
    request.status = "denied"
    request.resolved_at = datetime.utcnow()

    await db.commit()
    await db.refresh(request)

    return PermissionRequestResponse.model_validate(request)


The permission system allows creating permissions, permission requests, and grant/deny operations. However, there's no endpoint for checking if a specific action is allowed. I need to add:

1. A permission check endpoint that agents call before accessing files
2. Logic to enforce file access limits based on allowed paths

This feature is more complex and requires implementing path-based permission checking. Let me add the necessary endpoints:


TodoWrite

Implement file access permission checking

Implementing file access permission checking

Add check-access endpoint for agents

Add path-based permission logic

Test file access enforcement

Mark feature #97 as passing


Add feature cleanup on agent crash

Test agent isolation on crash

Mark feature #81 as passing

Implement file access permission checking

Implementing file access permission checking

Add check-access endpoint for agents

Add path-based permission logic

Test file access enforcement

Mark feature #97 as passing


Let me add the file access permission checking endpoint to the permissions router. I need to read the file first, then append the new endpoint:


Bash

Delete permissions.py for rewrite