mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-01 23:13:36 +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:
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Play, Square, Loader2, GitBranch } from 'lucide-react'
|
||||
import { Play, Square, Loader2, GitBranch, Clock } from 'lucide-react'
|
||||
import {
|
||||
useStartAgent,
|
||||
useStopAgent,
|
||||
useSettings,
|
||||
} from '../hooks/useProjects'
|
||||
import { useNextScheduledRun } from '../hooks/useSchedules'
|
||||
import { formatNextRun, formatEndTime } from '../lib/timeUtils'
|
||||
import { ScheduleModal } from './ScheduleModal'
|
||||
import type { AgentStatus } from '../lib/types'
|
||||
|
||||
interface AgentControlProps {
|
||||
@@ -21,6 +24,9 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
|
||||
|
||||
const startAgent = useStartAgent(projectName)
|
||||
const stopAgent = useStopAgent(projectName)
|
||||
const { data: nextRun } = useNextScheduledRun(projectName)
|
||||
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||||
|
||||
const isLoading = startAgent.isPending || stopAgent.isPending
|
||||
const isRunning = status === 'running' || status === 'paused'
|
||||
@@ -40,78 +46,113 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
|
||||
const isStopped = status === 'stopped' || status === 'crashed'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Concurrency slider - visible when stopped (not during loading or running) */}
|
||||
{isStopped && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value))}
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Concurrency slider - visible when stopped */}
|
||||
{isStopped && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
|
||||
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
|
||||
aria-label="Set number of concurrent agents"
|
||||
/>
|
||||
<span className="text-xs font-bold min-w-[1.5rem] text-center">
|
||||
{concurrency}x
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show concurrency indicator when running with multiple agents */}
|
||||
{isRunning && isParallel && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold">
|
||||
<GitBranch size={14} />
|
||||
<span>{concurrency}x</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule status display */}
|
||||
{nextRun?.is_currently_running && nextRun.next_end && (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-neo-done)] font-bold">
|
||||
<Clock size={16} className="flex-shrink-0" />
|
||||
<span>Running until {formatEndTime(nextRun.next_end)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!nextRun?.is_currently_running && nextRun?.next_start && (
|
||||
<div className="flex items-center gap-2 text-sm text-white font-bold">
|
||||
<Clock size={16} className="flex-shrink-0" />
|
||||
<span>Next: {formatNextRun(nextRun.next_start)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start/Stop button */}
|
||||
{isLoadingStatus ? (
|
||||
<button
|
||||
disabled
|
||||
className="neo-btn text-sm py-2 px-3 opacity-50 cursor-not-allowed"
|
||||
title="Loading agent status..."
|
||||
aria-label="Loading agent status"
|
||||
>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</button>
|
||||
) : isStopped ? (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
|
||||
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
|
||||
aria-label="Set number of concurrent agents"
|
||||
/>
|
||||
<span className="text-xs font-bold min-w-[1.5rem] text-center">
|
||||
{concurrency}x
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
className={`neo-btn text-sm py-2 px-3 ${
|
||||
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success'
|
||||
}`}
|
||||
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
|
||||
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className={`neo-btn text-sm py-2 px-3 ${
|
||||
yoloMode ? 'neo-btn-yolo' : 'neo-btn-danger'
|
||||
}`}
|
||||
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
|
||||
aria-label={yoloMode ? 'Stop Agent in YOLO Mode' : 'Stop Agent'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Square size={18} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show concurrency indicator when running with multiple agents */}
|
||||
{isRunning && isParallel && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold">
|
||||
<GitBranch size={14} />
|
||||
<span>{concurrency}x</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Clock button to open schedule modal */}
|
||||
<button
|
||||
onClick={() => setShowScheduleModal(true)}
|
||||
className="neo-btn text-sm py-2 px-3"
|
||||
title="Manage schedules"
|
||||
aria-label="Manage agent schedules"
|
||||
>
|
||||
<Clock size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingStatus ? (
|
||||
<button
|
||||
disabled
|
||||
className="neo-btn text-sm py-2 px-3 opacity-50 cursor-not-allowed"
|
||||
title="Loading agent status..."
|
||||
aria-label="Loading agent status"
|
||||
>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</button>
|
||||
) : isStopped ? (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className={`neo-btn text-sm py-2 px-3 ${
|
||||
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success'
|
||||
}`}
|
||||
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
|
||||
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
className={`neo-btn text-sm py-2 px-3 ${
|
||||
yoloMode ? 'neo-btn-yolo' : 'neo-btn-danger'
|
||||
}`}
|
||||
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
|
||||
aria-label={yoloMode ? 'Stop Agent in YOLO Mode' : 'Stop Agent'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Square size={18} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Schedule Modal */}
|
||||
<ScheduleModal
|
||||
projectName={projectName}
|
||||
isOpen={showScheduleModal}
|
||||
onClose={() => setShowScheduleModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user