mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
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>
This commit is contained in:
119
api/database.py
119
api/database.py
@@ -6,12 +6,13 @@ SQLite database schema for feature storage using SQLAlchemy.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine, text
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, create_engine, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import Session, relationship, sessionmaker
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -59,6 +60,91 @@ class Feature(Base):
|
||||
return []
|
||||
|
||||
|
||||
class Schedule(Base):
|
||||
"""Time-based schedule for automated agent start/stop."""
|
||||
|
||||
__tablename__ = "schedules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_name = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Timing (stored in UTC)
|
||||
start_time = Column(String(5), nullable=False) # "HH:MM" format
|
||||
duration_minutes = Column(Integer, nullable=False) # 1-1440
|
||||
|
||||
# Day filtering (bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64)
|
||||
days_of_week = Column(Integer, nullable=False, default=127) # 127 = all days
|
||||
|
||||
# State
|
||||
enabled = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
# Agent configuration for scheduled runs
|
||||
yolo_mode = Column(Boolean, nullable=False, default=False)
|
||||
model = Column(String(50), nullable=True) # None = use global default
|
||||
|
||||
# Crash recovery tracking
|
||||
crash_count = Column(Integer, nullable=False, default=0) # Resets at window start
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
overrides = relationship(
|
||||
"ScheduleOverride", back_populates="schedule", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert schedule to dictionary for JSON serialization."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"project_name": self.project_name,
|
||||
"start_time": self.start_time,
|
||||
"duration_minutes": self.duration_minutes,
|
||||
"days_of_week": self.days_of_week,
|
||||
"enabled": self.enabled,
|
||||
"yolo_mode": self.yolo_mode,
|
||||
"model": self.model,
|
||||
"crash_count": self.crash_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
def is_active_on_day(self, weekday: int) -> bool:
|
||||
"""Check if schedule is active on given weekday (0=Monday, 6=Sunday)."""
|
||||
day_bit = 1 << weekday
|
||||
return bool(self.days_of_week & day_bit)
|
||||
|
||||
|
||||
class ScheduleOverride(Base):
|
||||
"""Persisted manual override for a schedule window."""
|
||||
|
||||
__tablename__ = "schedule_overrides"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
schedule_id = Column(
|
||||
Integer, ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Override details
|
||||
override_type = Column(String(10), nullable=False) # "start" or "stop"
|
||||
expires_at = Column(DateTime, nullable=False) # When this window ends (UTC)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
schedule = relationship("Schedule", back_populates="overrides")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert override to dictionary for JSON serialization."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"schedule_id": self.schedule_id,
|
||||
"override_type": self.override_type,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def get_database_path(project_dir: Path) -> Path:
|
||||
"""Return the path to the SQLite database for a project."""
|
||||
return project_dir / "features.db"
|
||||
@@ -164,6 +250,32 @@ def _is_network_path(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _migrate_add_schedules_tables(engine) -> None:
|
||||
"""Create schedules and schedule_overrides tables if they don't exist."""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(engine)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# Create schedules table if missing
|
||||
if "schedules" not in existing_tables:
|
||||
Schedule.__table__.create(bind=engine)
|
||||
|
||||
# Create schedule_overrides table if missing
|
||||
if "schedule_overrides" not in existing_tables:
|
||||
ScheduleOverride.__table__.create(bind=engine)
|
||||
|
||||
# Add crash_count column if missing (for upgrades)
|
||||
if "schedules" in existing_tables:
|
||||
columns = [c["name"] for c in inspector.get_columns("schedules")]
|
||||
if "crash_count" not in columns:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text("ALTER TABLE schedules ADD COLUMN crash_count INTEGER DEFAULT 0")
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def create_database(project_dir: Path) -> tuple:
|
||||
"""
|
||||
Create database and return engine + session maker.
|
||||
@@ -196,6 +308,9 @@ def create_database(project_dir: Path) -> tuple:
|
||||
_migrate_fix_null_boolean_fields(engine)
|
||||
_migrate_add_dependencies_column(engine)
|
||||
|
||||
# Migrate to add schedules tables
|
||||
_migrate_add_schedules_tables(engine)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
return engine, SessionLocal
|
||||
|
||||
|
||||
Reference in New Issue
Block a user