Files
autocoder/server/routers/agent.py
Marian Paul 0bab585630 feat: add time-based agent scheduling with APScheduler
Add comprehensive scheduling system that allows agents to automatically
start and stop during configured time windows, helping users manage
Claude API token limits by running agents during off-hours.

Backend Changes:
- Add Schedule and ScheduleOverride database models for persistent storage
- Implement APScheduler-based SchedulerService with UTC timezone support
- Add schedule CRUD API endpoints (/api/projects/{name}/schedules)
- Add manual override tracking to prevent unwanted auto-start/stop
- Integrate scheduler lifecycle with FastAPI startup/shutdown
- Fix timezone bug: explicitly set timezone=timezone.utc on CronTrigger
  to ensure correct UTC scheduling (critical fix)

Frontend Changes:
- Add ScheduleModal component for creating and managing schedules
- Add clock button and schedule status display to AgentControl
- Add timezone utilities for converting between UTC and local time
- Add React Query hooks for schedule data fetching
- Fix 204 No Content handling in fetchJSON for delete operations
- Invalidate nextRun cache when manually stopping agent during window
- Add TypeScript type annotations to Terminal component callbacks

Features:
- Multiple overlapping schedules per project supported
- Auto-start at scheduled time via APScheduler cron jobs
- Auto-stop after configured duration
- Manual start/stop creates persistent overrides in database
- Crash recovery with exponential backoff (max 3 retries)
- Server restart preserves schedules and active overrides
- Times displayed in user's local timezone, stored as UTC
- Immediate start if schedule created during active window

Dependencies:
- Add APScheduler for reliable cron-like scheduling

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 10:31:23 +01:00

194 lines
6.1 KiB
Python

"""
Agent Router
============
API endpoints for agent control (start/stop/pause/resume).
Uses project registry for path lookups.
"""
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import AgentActionResponse, AgentStartRequest, AgentStatus
from ..services.process_manager import get_manager
def _get_project_path(project_name: str) -> Path:
"""Get project path from registry."""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from registry import get_project_path
return get_project_path(project_name)
def _get_settings_defaults() -> tuple[bool, str, int, bool]:
"""Get defaults from global settings.
Returns:
Tuple of (yolo_mode, model, testing_agent_ratio, count_testing_in_concurrency)
"""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from registry import DEFAULT_MODEL, get_all_settings
settings = get_all_settings()
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
model = settings.get("model", DEFAULT_MODEL)
# Parse testing agent settings with defaults
try:
testing_agent_ratio = int(settings.get("testing_agent_ratio", "1"))
except (ValueError, TypeError):
testing_agent_ratio = 1
count_testing = (settings.get("count_testing_in_concurrency") or "false").lower() == "true"
return yolo_mode, model, testing_agent_ratio, count_testing
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
# Root directory for process manager
ROOT_DIR = Path(__file__).parent.parent.parent
def validate_project_name(name: str) -> str:
"""Validate and sanitize project name to prevent path traversal."""
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
raise HTTPException(
status_code=400,
detail="Invalid project name"
)
return name
def get_project_manager(project_name: str):
"""Get the process manager for a project."""
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=f"Project directory not found: {project_dir}")
return get_manager(project_name, project_dir, ROOT_DIR)
@router.get("/status", response_model=AgentStatus)
async def get_agent_status(project_name: str):
"""Get the current status of the agent for a project."""
manager = get_project_manager(project_name)
# Run healthcheck to detect crashed processes
await manager.healthcheck()
return AgentStatus(
status=manager.status,
pid=manager.pid,
started_at=manager.started_at,
yolo_mode=manager.yolo_mode,
model=manager.model,
parallel_mode=manager.parallel_mode,
max_concurrency=manager.max_concurrency,
testing_agent_ratio=manager.testing_agent_ratio,
count_testing_in_concurrency=manager.count_testing_in_concurrency,
)
@router.post("/start", response_model=AgentActionResponse)
async def start_agent(
project_name: str,
request: AgentStartRequest = AgentStartRequest(),
):
"""Start the agent for a project."""
manager = get_project_manager(project_name)
# Get defaults from global settings if not provided in request
default_yolo, default_model, default_testing_ratio, default_count_testing = _get_settings_defaults()
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
model = request.model if request.model else default_model
max_concurrency = request.max_concurrency or 1
testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio
count_testing = request.count_testing_in_concurrency if request.count_testing_in_concurrency is not None else default_count_testing
success, message = await manager.start(
yolo_mode=yolo_mode,
model=model,
max_concurrency=max_concurrency,
testing_agent_ratio=testing_agent_ratio,
count_testing_in_concurrency=count_testing,
)
# Notify scheduler of manual start (to prevent auto-stop during scheduled window)
if success:
from ..services.scheduler_service import get_scheduler
project_dir = _get_project_path(project_name)
if project_dir:
get_scheduler().notify_manual_start(project_name, project_dir)
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/stop", response_model=AgentActionResponse)
async def stop_agent(project_name: str):
"""Stop the agent for a project."""
manager = get_project_manager(project_name)
success, message = await manager.stop()
# Notify scheduler of manual stop (to prevent auto-start during scheduled window)
if success:
from ..services.scheduler_service import get_scheduler
project_dir = _get_project_path(project_name)
if project_dir:
get_scheduler().notify_manual_stop(project_name, project_dir)
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/pause", response_model=AgentActionResponse)
async def pause_agent(project_name: str):
"""Pause the agent for a project."""
manager = get_project_manager(project_name)
success, message = await manager.pause()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/resume", response_model=AgentActionResponse)
async def resume_agent(project_name: str):
"""Resume a paused agent."""
manager = get_project_manager(project_name)
success, message = await manager.resume()
return AgentActionResponse(
success=success,
status=manager.status,
message=message,
)