feat: add time-based agent scheduling with APScheduler#75
feat: add time-based agent scheduling with APScheduler#75leonvanzyl merged 6 commits intoAutoForgeAI:masterfrom
Conversation
📝 WalkthroughWalkthroughAdds persistent schedule models and migrations, an APScheduler-based SchedulerService with overrides and crash recovery, a new per-project schedules REST API, and frontend UI, hooks, types, and utilities for schedule management and display. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant API
participant DB
participant Scheduler
participant PM as ProcessManager
Client->>API: POST /api/projects/{p}/schedules
API->>DB: INSERT schedule
DB-->>API: schedule created
API->>Scheduler: register start/stop jobs
alt schedule enabled & within active window
API->>Scheduler: request immediate start
Scheduler->>DB: check manual stop overrides
DB-->>Scheduler: no override
Scheduler->>PM: start agent
PM-->>Scheduler: started
end
Scheduler->>API: scheduled_start trigger
API->>DB: check overrides and schedule state
DB-->>API: allowed/not allowed
alt allowed
API->>PM: start agent
PM-->>API: started
end
Scheduler->>API: scheduled_stop trigger
API->>DB: verify other schedules / compute end
API->>PM: stop agent
PM-->>API: stopped
Client->>API: GET /api/projects/{p}/schedules/next
API->>DB: query schedules + overrides
DB-->>API: next-run data
API-->>Client: NextRunResponse
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Very nice!
…On Sat, Jan 17, 2026, 2:27 PM Marian Paul ***@***.***> wrote:
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
------------------------------
You can view, comment on, or merge this pull request online at:
#75
Commit Summary
- 7e2d755
<7e2d755>
feat: add time-based agent scheduling with APScheduler
File Changes
(16 files <https://github.com/leonvanzyl/autocoder/pull/75/files>)
- *M* api/database.py
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-6a0d8c37c66441e7248ec676d7eb0ee364b9f747350c1d71e3f4fdcf448c029e>
(119)
- *M* requirements.txt
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-4d7c51b1efe9043e44439a949dfd92e5827321b34082903477fd04876edb7552>
(1)
- *M* server/main.py
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-c16fbf0c6f7b90a46b94b36f88893c2d174476088608841f7254afba0e81373d>
(13)
- *M* server/routers/__init__.py
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-687dc60add3c0d51a191614034ad2ac98790118b7ab0f2069bd03a215fc605a6>
(2)
- *M* server/routers/agent.py
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-54cd102d14f3e7f433a173b35b66f32254683e003694cf1ff7e65af19b3d990e>
(14)
- *A* server/routers/schedules.py
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-da554be45994e37d244b2687e71ea8bb1375aa36ee5456322bd33ab33c0e1152>
(417)
- *M* server/schemas.py
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-7f9d89aac6e5eee2f4784d298ca7a1e1f530715f5aeb07830a54389a467163de>
(89)
- *A* server/services/scheduler_service.py
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-aa00cc75780ce532b8a8f45878f3437ab6b3994acf3a374cf5ac2d977cc5236d>
(602)
- *M* ui/src/components/AgentControl.tsx
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-ce847a7e1fdcc2d07c799fcf01c3dc53e61ee5d5e332fab5ca6e13cc3b3e5ab8>
(161)
- *A* ui/src/components/ScheduleModal.tsx
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-e5f9704dba10e9ce0e7be741830134d7c4e372f2a4e9a2011df14e21bce89ecb>
(397)
- *M* ui/src/components/Terminal.tsx
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-9d7f605a96841a1306f6254d045eec8a7bc9522a8bbde1ab9219a7b614a44c7b>
(4)
- *M* ui/src/hooks/useProjects.ts
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-339f561b606e73ec2a4e1dcc1d88e7ca2727c0e1a1039a267a720ac8067aee65>
(2)
- *A* ui/src/hooks/useSchedules.ts
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-39e3b25321c6a5a6716258acfae1b13eca980810f28b0b5997f54ee369eba8f7>
(112)
- *M* ui/src/lib/api.ts
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-1561b0cda1b63b7aed26fcf49ef212928426b92776a8765c16d53f550fe07436>
(59)
- *A* ui/src/lib/timeUtils.ts
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-9c80623f4b320403d09573aa841a9f17b90fc5bbb1135b4febe6a833b7b8e3bc>
(155)
- *M* ui/src/lib/types.ts
<https://github.com/leonvanzyl/autocoder/pull/75/files#diff-0f454e5510fb70d1c10bb1ed02a810106178c425136693d0d636efcf2c520041>
(47)
Patch Links:
- https://github.com/leonvanzyl/autocoder/pull/75.patch
- https://github.com/leonvanzyl/autocoder/pull/75.diff
—
Reply to this email directly, view it on GitHub
<#75>, or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AX42UPFIIQH5QMZBIWT5ENL4HKLKDAVCNFSM6AAAAACSA46FJWVHI2DSMVQWIX3LMV43ASLTON2WKOZTHAZDKNZXGIYTGNQ>
.
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
|
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@server/routers/schedules.py`:
- Around line 320-321: The current check "if data.model is not None" on
ScheduleUpdate prevents explicitly clearing model to null; instead read only
provided fields from the incoming Pydantic model and apply them to the schedule
(e.g., call data.model_dump(exclude_unset=True) or otherwise detect unset vs
provided), then iterate the returned dict and setattr(schedule, field, value) so
that sending {"model": null} will set schedule.model = None while omitting the
field leaves it unchanged; update the endpoint handling code that references
data.model and schedule.model accordingly.
In `@server/services/scheduler_service.py`:
- Around line 130-160: The stop job is scheduled with the same `days` cron
expression as the start job, causing incorrect firing when the schedule crosses
midnight; detect midnight-crossing schedules by comparing `start_dt` and
`end_dt` (or if `end_dt.date() != start_dt.date()` / `end_dt < start_dt`) and,
when that occurs, compute a shifted day bitfield for the stop job (e.g., add a
helper method `_shift_days_forward(bitfield: int) -> int` that rotates the 7-bit
day mask forward by one day) and use that shifted value in the `CronTrigger` for
the stop job instead of the original `days`; update where `stop_trigger =
CronTrigger(..., day_of_week=days, ...)` to pass the shifted days so stop jobs
occur on the next day.
- Around line 305-327: _in _is_within_window_: the midnight-wrap logic and
tzinfo stripping are incorrect; instead of removing tzinfo and using
end_time.replace(day=...), keep timezone-awareness (use now.tzinfo when building
start_time/end_time) and detect crossing-midnight by comparing start_time and
end_time and/or evaluating both today's and yesterday's window: compute
start_time from schedule.start_time and schedule.duration_minutes for the
current day (using now.tzinfo), compute end_time = start_time +
timedelta(minutes=duration), then return True if start_time <= now < end_time OR
(if start_time > end_time, which means it crosses midnight) also check the
equivalent window that started yesterday (start_time - 1 day <= now < end_time -
1 day); remove any end_time.replace(day=...) and do not strip tzinfo.
In `@ui/src/components/ScheduleModal.tsx`:
- Around line 307-323: The duration input currently falls back to 0 via
parseInt(e.target.value) || 0 which allows invalid zero values; update the
onChange in the input (where setNewSchedule is used) to parse the value with
parseInt(..., 10), treat NaN as 1, and clamp the result to the 1–1440 range
before calling setNewSchedule (so newSchedule.duration_minutes always stays
within bounds), and additionally add a range check in handleCreateSchedule that
rejects submissions where newSchedule.duration_minutes < 1 or > 1440 by calling
setError and returning early; reference newSchedule, setNewSchedule,
formatDuration, and handleCreateSchedule when making the changes.
In `@ui/src/lib/timeUtils.ts`:
- Around line 61-80: The "day + time" branch in formatNextRun uses
date.toLocaleDateString with hour/minute options which per Intl are ignored;
change that call to date.toLocaleString so the time component is included, keep
the same options ({ weekday: 'short', hour: 'numeric', minute: '2-digit' }), and
ensure formatNextRun returns the correct localized day+time string for future
runs.
🧹 Nitpick comments (8)
requirements.txt (1)
10-10: Consider adding an upper bound for APScheduler major versions.
APScheduler 4.0 introduces extensive breaking changes including scheduler API redesign, removal of Worker/AsyncWorker classes, data store API changes, schema field renames, and job immutability. A pinned upper bound prevents unexpected upgrades to incompatible versions.🔧 Suggested pinning
-apscheduler>=3.10.0 +apscheduler>=3.10.0,<4.0.0server/routers/agent.py (1)
115-121: Redundant_get_project_pathcall.
project_diris already resolved inget_project_manager()at line 63 but isn't returned. Consider refactoring to avoid the duplicate lookup, or extract the project directory from the manager.♻️ Suggested approach
Option 1: Modify
get_project_managerto return both manager and path:-def get_project_manager(project_name: str): +def get_project_manager(project_name: str) -> tuple: """Get the process manager for a project.""" project_name = validate_project_name(project_name) project_dir = _get_project_path(project_name) ... - return get_manager(project_name, project_dir, ROOT_DIR) + return get_manager(project_name, project_dir, ROOT_DIR), project_dirOption 2: Access project_dir from the manager if it exposes it.
ui/src/components/ScheduleModal.tsx (1)
136-145: Consider replacingconfirm()with an accessible modal.The native
confirm()dialog is blocking and may not be accessible with screen readers. For consistency with the neobrutalism design system and better accessibility, consider using a confirmation modal component.server/services/scheduler_service.py (2)
44-48: Minor: Redundant timezone import.The
timezoneis already imported at the module level (line 12). The local import asdt_timezoneis unnecessary.♻️ Suggested simplification
def __init__(self): - from datetime import timezone as dt_timezone - # CRITICAL: Use UTC timezone since all schedule times are stored in UTC - self.scheduler = AsyncIOScheduler(timezone=dt_timezone.utc) + self.scheduler = AsyncIOScheduler(timezone=timezone.utc) self._started = False
19-20: This pattern ofsys.pathmanipulation is consistent throughout the codebase, making it an optional refactoring consideration at the project architecture level rather than file-specific.The
sys.path.insert()approach appears in 16+ files across the codebase includingserver/schemas.py,server/websocket.py,server/routers/*,mcp_server/feature_mcp.py, and other service modules. This is a systematic architectural pattern, not an isolated code smell. If the project structure requires this workaround, it may be worth addressing holistically (e.g., via proper package setup or a centralized import utility module) rather than in individual files.server/routers/schedules.py (3)
25-48: Consider extracting shared utilities.
_get_project_pathandvalidate_project_nameare duplicated fromserver/routers/agent.py(per relevant code snippets). Consider extracting these to a shared module to maintain DRY principles.
140-166: Consider exposing public methods for router usage.The endpoint directly calls private methods
scheduler._is_within_windowandscheduler._start_agent. While functional, this breaks encapsulation. Consider adding public methods likeis_schedule_active()andstart_schedule_immediately()to theSchedulerServiceclass.
382-395: Duplicated logic withscheduler_service._calculate_window_end.This helper duplicates the window end calculation from
server/services/scheduler_service.py(lines 480-494). Consider reusing the scheduler service method or extracting to a shared utility to avoid divergence.
|
The solution change quite a bit. Please could you rebase and adjust this solution? |
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>
0c3a06c to
a6fe2ef
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@requirements.txt`:
- Line 10: Update the APScheduler requirement to pin it to the 3.x series so
imports like from apscheduler.schedulers.asyncio import AsyncIOScheduler and
from apscheduler.triggers.cron import CronTrigger keep working; change the
requirement line in requirements.txt from the loose constraint
(apscheduler>=3.10.0) to a pinned 3.x range (e.g., apscheduler>=3.10.0,<4.0.0)
to prevent pip from installing APScheduler 4.x.
In `@server/services/scheduler_service.py`:
- Around line 593-602: The bitfield shift code uses multiple statements on
single lines causing ruff E701; refactor each conditional so the if and its body
are on separate lines (e.g., if bitfield & 1:\n shifted |= 2) for every case
that currently reads like "if bitfield & X: shifted |= Y", keeping the same
logic that maps bitfield -> shifted (use the existing variables bitfield and
shifted and preserve the final "return shifted").
🧹 Nitpick comments (12)
server/routers/agent.py (1)
133-138: Consider reusing the project_dir from get_project_manager.The
_get_project_path(project_name)is called again here, butget_project_manageralready retrieves and validates the project directory at line 76. Consider extracting the project_dir earlier to avoid redundant lookups.Suggested optimization
`@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) + 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}") + manager = get_manager(project_name, project_dir, ROOT_DIR) ... # 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) + get_scheduler().notify_manual_start(project_name, project_dir)ui/src/hooks/useSchedules.ts (1)
53-64: Consider invalidating the specific schedule query on update.When a schedule is updated, the individual schedule query (
['schedule', projectName, scheduleId]) should also be invalidated to ensure any component usinguseSchedulegets fresh data.Suggested improvement
export function useUpdateSchedule(projectName: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ scheduleId, update }: { scheduleId: number; update: ScheduleUpdate }) => api.updateSchedule(projectName, scheduleId, update), - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['schedules', projectName] }) + queryClient.invalidateQueries({ queryKey: ['schedule', projectName, variables.scheduleId] }) queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] }) }, }) }api/database.py (1)
89-89:datetime.utcnowis deprecated in Python 3.12+.
datetime.utcnow()returns a naive datetime and is deprecated. Consider usingdatetime.now(timezone.utc)for timezone-aware UTC timestamps.♻️ Suggested fix
from datetime import datetime +from datetime import timezone ... class Schedule(Base): ... - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) class ScheduleOverride(Base): ... - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))Also applies to: 132-132
server/services/scheduler_service.py (1)
497-511: Consider simplifying the timezone comparison.The
tzinfostripping on line 507 is unnecessary since bothnowandwindow_startderive from the same timezone-aware datetime. A direct comparison would work:♻️ Simplified comparison
def _calculate_window_end(self, schedule, now: datetime) -> datetime: """Calculate when the current window ends.""" start_hour, start_minute = map(int, schedule.start_time.split(":")) # Create start time for today window_start = now.replace( hour=start_hour, minute=start_minute, second=0, microsecond=0 ) # If current time is before start time, the window started yesterday - if now.replace(tzinfo=None) < window_start.replace(tzinfo=None): + if now < window_start: window_start = window_start - timedelta(days=1) window_end = window_start + timedelta(minutes=schedule.duration_minutes) return window_endserver/routers/schedules.py (3)
25-48: Code duplication:_get_project_pathandvalidate_project_nameare duplicated from agent.py.Both functions are copied from
server/routers/agent.py(lines 17-25 and 62-69). Consider extracting these to a shared utility module to avoid maintenance burden.# server/routers/utils.py (new file) import re from pathlib import Path from fastapi import HTTPException 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 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
143-163: Calling private methods (_is_within_window,_start_agent) on scheduler service.Accessing underscored methods from outside the class breaks encapsulation. Consider exposing these as public methods on
SchedulerServiceif they're needed externally.♻️ Suggested approach
Add public wrapper methods in
SchedulerService:# In scheduler_service.py def is_within_window(self, schedule, now: datetime) -> bool: """Public: Check if current time is within schedule window.""" return self._is_within_window(schedule, now) async def start_agent_for_schedule(self, project_name: str, project_dir: Path, schedule): """Public: Start agent for a specific schedule.""" await self._start_agent(project_name, project_dir, schedule)Then update the router to use the public methods.
374-387: Code duplication:_calculate_window_endduplicated from scheduler_service.py.This function exists in both
server/routers/schedules.py(lines 374-387) andserver/services/scheduler_service.py(lines 497-511). Consider consolidating to a shared location.ui/src/components/ScheduleModal.tsx (5)
54-89: Consider using Radix UI Dialog for built-in accessibility features.The manual focus trap implementation is correct, but Radix UI (mentioned in coding guidelines) provides a
Dialogprimitive with built-in focus management, scroll locking, and aria attributes. This would reduce custom accessibility code.If you decide to keep the manual implementation, consider moving the initial focus to a more intuitive element (like the first form input) rather than the close button.
142-151: Nativeconfirm()dialog is inconsistent with neobrutalism design.The component docstring mentions following "neobrutalism design patterns from SettingsModal," but
window.confirm()uses the browser's native dialog. Consider a custom confirmation modal for visual consistency.
186-191: Addaria-livefor screen reader announcement of errors.Error messages should be announced to screen reader users when they appear.
♿ Suggested accessibility improvement
{error && ( - <div className="mb-4 p-3 border-2 border-red-500 bg-red-50 text-red-800 rounded"> + <div className="mb-4 p-3 border-2 border-red-500 bg-red-50 text-red-800 rounded" role="alert" aria-live="polite"> {error} </div> )}
338-354: Addaria-pressedfor toggle button accessibility.Day selection buttons act as toggles but don't communicate their pressed state to assistive technology.
♿ Suggested accessibility improvement
<button key={day.label} onClick={() => handleToggleDay(day.bit)} + aria-pressed={isActive} className={`neo-btn px-3 py-2 text-sm ${ isActive ? 'bg-[var(--color-neo-progress)] text-white border-[var(--color-neo-progress)]' : 'neo-btn-ghost' }`} > {day.label} </button>
372-386: Use model dropdown instead of text input to enforce valid models at entry.SettingsModal already implements a radio group pattern for model selection using the
useAvailableModels()hook, which fetches from/api/settings/models. Adopt the same pattern in ScheduleModal to prevent invalid models from being submitted. While server-side validation (ScheduleUpdate schema) will catch these errors, providing a constrained UI improves user experience and consistency across the app.The two available models are:
- claude-opus-4-5-20251101 (Claude Opus 4.5)
- claude-sonnet-4-5-20250929 (Claude Sonnet 4.5)
|
@leonvanzyl done! |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@server/services/scheduler_service.py`:
- Around line 253-289: The scheduled stop currently ignores manual-start
overrides created by notify_manual_start; update _handle_scheduled_stop to,
after loading the schedule and before performing the stop, query
ScheduleOverride for any non-expired override that prevents auto-stop (e.g.
where ScheduleOverride.schedule_id == schedule_id or matches the
schedule/project and ScheduleOverride.override_type or action == "start" and
ScheduleOverride.expires_at > now), and if any such active "start" override
exists, log and return without stopping; keep the existing expired-overrides
delete/commit logic and continue to use the same db session and timezone-aware
now variable when checking overrides before calling _stop_agent.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@server/services/scheduler_service.py`:
- Around line 253-304: The stop handler can race with a start that occurs at the
exact same minute for the same schedule; modify async method
_handle_scheduled_stop to skip stopping when the schedule being processed has a
new start window that begins at the same timestamp as the current stop (i.e.,
same-minute start/stop for the same Schedule). After loading Schedule (and
before clearing overrides and calling _stop_agent), compute "now" (already
defined) and determine if the schedule's next start time equals now (or if
schedule.duration_minutes >= 1440 and the schedule is active across days) — if
so, log and return without calling self._stop_agent. Use the loaded Schedule
fields (e.g., schedule.duration_minutes or schedule.next_start / start_time
logic) and keep the existing _other_schedules_still_active and ScheduleOverride
checks intact. Ensure database session (db) is used for any queries needed to
detect the same-minute start.
🧹 Nitpick comments (1)
server/services/scheduler_service.py (1)
511-525: Prefer timezone-aware comparison (drop tzinfo stripping).The comparison can stay timezone-aware since everything is already UTC. This avoids subtle inconsistencies if timezone handling changes later.
♻️ Suggested cleanup
- if now.replace(tzinfo=None) < window_start.replace(tzinfo=None): + if now < window_start: window_start = window_start - timedelta(days=1)
| async def _handle_scheduled_stop( | ||
| self, project_name: str, schedule_id: int, project_dir_str: str | ||
| ): | ||
| """Handle scheduled agent stop.""" | ||
| logger.info(f"Scheduled stop triggered for {project_name} (schedule {schedule_id})") | ||
| project_dir = Path(project_dir_str) | ||
|
|
||
| try: | ||
| from api.database import Schedule, ScheduleOverride, create_database | ||
|
|
||
| _, SessionLocal = create_database(project_dir) | ||
| db = SessionLocal() | ||
|
|
||
| try: | ||
| schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first() | ||
| if not schedule: | ||
| logger.warning(f"Schedule {schedule_id} not found in database") | ||
| return | ||
|
|
||
| # Check if other schedules are still active (latest stop wins) | ||
| if self._other_schedules_still_active(db, project_name, schedule_id): | ||
| logger.info( | ||
| f"Skipping scheduled stop for {project_name}: " | ||
| f"other schedules still active (latest stop wins)" | ||
| ) | ||
| return | ||
|
|
||
| # Clear expired overrides for this schedule | ||
| now = datetime.now(timezone.utc) | ||
| db.query(ScheduleOverride).filter( | ||
| ScheduleOverride.schedule_id == schedule_id, | ||
| ScheduleOverride.expires_at <= now, | ||
| ).delete() | ||
| db.commit() | ||
|
|
||
| # Check for active manual-start overrides that prevent auto-stop | ||
| active_start_override = db.query(ScheduleOverride).filter( | ||
| ScheduleOverride.schedule_id == schedule_id, | ||
| ScheduleOverride.override_type == "start", | ||
| ScheduleOverride.expires_at > now, | ||
| ).first() | ||
|
|
||
| if active_start_override: | ||
| logger.info( | ||
| f"Skipping scheduled stop for {project_name}: " | ||
| f"active manual-start override (expires {active_start_override.expires_at})" | ||
| ) | ||
| return | ||
|
|
||
| # Stop agent | ||
| await self._stop_agent(project_name, project_dir) | ||
|
|
There was a problem hiding this comment.
Prevent stop/start collisions when a window ends exactly at the next start.
If duration_minutes == 1440 (or any future full-day config) and the schedule is active on consecutive days, the stop job fires at the same minute as the next start. Job ordering is nondeterministic, so the stop can immediately cancel the just-started agent and leave it off for a full day.
Consider skipping the stop when a new window for the same schedule starts at the same minute.
🛠️ Suggested guard to avoid same-minute stop/start
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
if not schedule:
logger.warning(f"Schedule {schedule_id} not found in database")
return
+
+ now = datetime.now(timezone.utc)
+ current_hhmm = now.strftime("%H:%M")
+ if (
+ schedule.duration_minutes >= 1440
+ and schedule.is_active_on_day(now.weekday())
+ and schedule.start_time == current_hhmm
+ ):
+ logger.info(
+ f"Skipping scheduled stop for {project_name}: "
+ "new window starts now for the same schedule"
+ )
+ return
# Check if other schedules are still active (latest stop wins)
if self._other_schedules_still_active(db, project_name, schedule_id):
logger.info(
f"Skipping scheduled stop for {project_name}: "
f"other schedules still active (latest stop wins)"
)
return
# Clear expired overrides for this schedule
- now = datetime.now(timezone.utc)
db.query(ScheduleOverride).filter(
ScheduleOverride.schedule_id == schedule_id,
ScheduleOverride.expires_at <= now,
).delete()🤖 Prompt for AI Agents
In `@server/services/scheduler_service.py` around lines 253 - 304, The stop
handler can race with a start that occurs at the exact same minute for the same
schedule; modify async method _handle_scheduled_stop to skip stopping when the
schedule being processed has a new start window that begins at the same
timestamp as the current stop (i.e., same-minute start/stop for the same
Schedule). After loading Schedule (and before clearing overrides and calling
_stop_agent), compute "now" (already defined) and determine if the schedule's
next start time equals now (or if schedule.duration_minutes >= 1440 and the
schedule is active across days) — if so, log and return without calling
self._stop_agent. Use the loaded Schedule fields (e.g.,
schedule.duration_minutes or schedule.next_start / start_time logic) and keep
the existing _other_schedules_still_active and ScheduleOverride checks intact.
Ensure database session (db) is used for any queries needed to detect the
same-minute start.
…+ features - Kanban board renders 149 features correctly with all data - Scroll performance: 0.10ms down, 0.00ms up (well under 100ms threshold) - Smooth scrolling through 18,292px of content with no lag - All feature cards display category badges, names, IDs, priorities, timestamps - Screenshots captured at top, bottom, and after verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@server/schemas.py`:
- Around line 482-518: The ScheduleCreate.start_time description is ambiguous
about timezone (says "local" but payload has no tz), so update the schema to be
explicit: either change the start_time Field description to state it must be UTC
(e.g., "Start time in HH:MM format (UTC)") or add a new timezone/offset field
(e.g., start_time_tz or start_time_offset) and validate it; ensure any
validation/usage in code that consumes ScheduleCreate (references to
ScheduleCreate, start_time, and validate_model) is updated to convert/interpret
times consistently and that the Field description reflects the chosen approach.
In `@ui/src/components/ScheduleModal.tsx`:
- Around line 96-110: The submit handler handleCreateSchedule lacks submit-time
validation for newSchedule.max_concurrency; add a guard in handleCreateSchedule
that checks newSchedule.max_concurrency is between 1 and 5 (inclusive), and if
out of range call setError('Max concurrency must be between 1 and 5') and return
before proceeding with the request; reference newSchedule.max_concurrency and
handleCreateSchedule when adding this validation so client-side constraints
match server constraints.
♻️ Duplicate comments (2)
server/services/scheduler_service.py (2)
266-304: Guard against same-minute stop/start for full-day windows.
Ifduration_minutes == 1440and the schedule is active on consecutive days, the stop and next start can fire in the same minute. Job ordering is nondeterministic, so the stop can immediately cancel a fresh start.🐛 Proposed guard
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first() if not schedule: logger.warning(f"Schedule {schedule_id} not found in database") return + now = datetime.now(timezone.utc) + current_hhmm = now.strftime("%H:%M") + if ( + schedule.duration_minutes >= 1440 + and schedule.is_active_on_day(now.weekday()) + and schedule.start_time == current_hhmm + ): + logger.info( + f"Skipping scheduled stop for {project_name}: " + "new window starts now for the same schedule" + ) + return + # Check if other schedules are still active (latest stop wins) if self._other_schedules_still_active(db, project_name, schedule_id): logger.info( f"Skipping scheduled stop for {project_name}: " f"other schedules still active (latest stop wins)" ) return # Clear expired overrides for this schedule - now = datetime.now(timezone.utc) db.query(ScheduleOverride).filter(
405-441: Re-check the window after crash backoff.
After waiting, the schedule window may have ended; restarting then would run agents outside the intended window.🐛 Suggested re-check before restart
logger.info( f"Restarting agent for {project_name} in {delay}s " f"(attempt {schedule.crash_count})" ) await asyncio.sleep(delay) + now = datetime.now(timezone.utc) + if not schedule.enabled or not self._is_within_window(schedule, now): + logger.info( + f"Skipping restart for {project_name}: window ended or schedule disabled" + ) + return await self._start_agent(project_name, project_dir, schedule) return # Only restart once
🧹 Nitpick comments (3)
ui/src/lib/types.ts (1)
521-529: ClarifyScheduleUpdate.start_timetimezone semantics.
ScheduleCreate.start_timeis documented as local-time input, butScheduleUpdate.start_timeis undocumented, which can lead to UTC/local mismatches and shifted schedules. Consider aligning comments for consistency.♻️ Suggested clarification
export interface ScheduleUpdate { - start_time?: string + start_time?: string // "HH:MM" format (local time, will be stored as UTC) duration_minutes?: number days_of_week?: number enabled?: boolean yolo_mode?: boolean model?: string | null - max_concurrency?: number + max_concurrency?: number // 1-5 concurrent agents }server/schemas.py (1)
520-540: Consider disallowingdays_of_week=0to avoid “enabled but never runs”.If
enabled=Trueanddays_of_week=0, the schedule can’t trigger. Tightening the lower bound helps prevent accidental no-op schedules; disabling should useenabled=Falseinstead.♻️ Proposed adjustment
- days_of_week: int | None = Field(None, ge=0, le=127) + days_of_week: int | None = Field(None, ge=1, le=127)api/database.py (1)
124-131: Constrainoverride_typeto valid values.
Right now any string can be persisted, which silently bypasses override logic. Consider enforcing"start"/"stop"at the DB level.♻️ Suggested constraint
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, create_engine, text +from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Text, create_engine, text ... class ScheduleOverride(Base): """Persisted manual override for a schedule window.""" __tablename__ = "schedule_overrides" + __table_args__ = ( + CheckConstraint( + "override_type IN ('start','stop')", + name="ck_schedule_overrides_type", + ), + )
| class ScheduleCreate(BaseModel): | ||
| """Request schema for creating a schedule.""" | ||
| start_time: str = Field( | ||
| ..., | ||
| pattern=r'^([0-1][0-9]|2[0-3]):[0-5][0-9]$', | ||
| description="Start time in HH:MM format (local time, will be stored as UTC)" | ||
| ) | ||
| duration_minutes: int = Field( | ||
| ..., | ||
| ge=1, | ||
| le=1440, | ||
| description="Duration in minutes (1-1440)" | ||
| ) | ||
| days_of_week: int = Field( | ||
| default=127, | ||
| ge=0, | ||
| le=127, | ||
| description="Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64" | ||
| ) | ||
| enabled: bool = True | ||
| yolo_mode: bool = False | ||
| model: str | None = None | ||
| max_concurrency: int = Field( | ||
| default=3, | ||
| ge=1, | ||
| le=5, | ||
| description="Max concurrent agents (1-5)" | ||
| ) | ||
|
|
||
| @field_validator('model') | ||
| @classmethod | ||
| def validate_model(cls, v: str | None) -> str | None: | ||
| """Validate model is in the allowed list.""" | ||
| if v is not None and v not in VALID_MODELS: | ||
| raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}") | ||
| return v | ||
|
|
There was a problem hiding this comment.
Clarify whether start_time is UTC or local.
The field description says “local time,” but the payload has no timezone, so non-UI clients will likely schedule at the wrong time. Consider documenting it as UTC (or add a timezone/offset field if the server should do conversion).
✏️ Suggested doc fix
- description="Start time in HH:MM format (local time, will be stored as UTC)"
+ description="Start time in HH:MM format (UTC). Clients must convert local time to UTC before sending."📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| class ScheduleCreate(BaseModel): | |
| """Request schema for creating a schedule.""" | |
| start_time: str = Field( | |
| ..., | |
| pattern=r'^([0-1][0-9]|2[0-3]):[0-5][0-9]$', | |
| description="Start time in HH:MM format (local time, will be stored as UTC)" | |
| ) | |
| duration_minutes: int = Field( | |
| ..., | |
| ge=1, | |
| le=1440, | |
| description="Duration in minutes (1-1440)" | |
| ) | |
| days_of_week: int = Field( | |
| default=127, | |
| ge=0, | |
| le=127, | |
| description="Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64" | |
| ) | |
| enabled: bool = True | |
| yolo_mode: bool = False | |
| model: str | None = None | |
| max_concurrency: int = Field( | |
| default=3, | |
| ge=1, | |
| le=5, | |
| description="Max concurrent agents (1-5)" | |
| ) | |
| @field_validator('model') | |
| @classmethod | |
| def validate_model(cls, v: str | None) -> str | None: | |
| """Validate model is in the allowed list.""" | |
| if v is not None and v not in VALID_MODELS: | |
| raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}") | |
| return v | |
| class ScheduleCreate(BaseModel): | |
| """Request schema for creating a schedule.""" | |
| start_time: str = Field( | |
| ..., | |
| pattern=r'^([0-1][0-9]|2[0-3]):[0-5][0-9]$', | |
| description="Start time in HH:MM format (UTC). Clients must convert local time to UTC before sending." | |
| ) | |
| duration_minutes: int = Field( | |
| ..., | |
| ge=1, | |
| le=1440, | |
| description="Duration in minutes (1-1440)" | |
| ) | |
| days_of_week: int = Field( | |
| default=127, | |
| ge=0, | |
| le=127, | |
| description="Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64" | |
| ) | |
| enabled: bool = True | |
| yolo_mode: bool = False | |
| model: str | None = None | |
| max_concurrency: int = Field( | |
| default=3, | |
| ge=1, | |
| le=5, | |
| description="Max concurrent agents (1-5)" | |
| ) | |
| `@field_validator`('model') | |
| `@classmethod` | |
| def validate_model(cls, v: str | None) -> str | None: | |
| """Validate model is in the allowed list.""" | |
| if v is not None and v not in VALID_MODELS: | |
| raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}") | |
| return v |
🤖 Prompt for AI Agents
In `@server/schemas.py` around lines 482 - 518, The ScheduleCreate.start_time
description is ambiguous about timezone (says "local" but payload has no tz), so
update the schema to be explicit: either change the start_time Field description
to state it must be UTC (e.g., "Start time in HH:MM format (UTC)") or add a new
timezone/offset field (e.g., start_time_tz or start_time_offset) and validate
it; ensure any validation/usage in code that consumes ScheduleCreate (references
to ScheduleCreate, start_time, and validate_model) is updated to
convert/interpret times consistently and that the Field description reflects the
chosen approach.
| const handleCreateSchedule = async () => { | ||
| try { | ||
| setError(null) | ||
|
|
||
| // Validate | ||
| if (newSchedule.days_of_week === 0) { | ||
| setError('Please select at least one day') | ||
| return | ||
| } | ||
|
|
||
| // Validate duration | ||
| if (newSchedule.duration_minutes < 1 || newSchedule.duration_minutes > 1440) { | ||
| setError('Duration must be between 1 and 1440 minutes') | ||
| return | ||
| } |
There was a problem hiding this comment.
Add client-side validation for max_concurrency.
max_concurrency is constrained to 1–5 in the UI, but there’s no submit-time guard. A quick validation keeps parity with server constraints and avoids accidental out-of-range submissions.
🔧 Suggested fix
if (newSchedule.duration_minutes < 1 || newSchedule.duration_minutes > 1440) {
setError('Duration must be between 1 and 1440 minutes')
return
}
+
+ if (newSchedule.max_concurrency < 1 || newSchedule.max_concurrency > 5) {
+ setError('Concurrent agents must be between 1 and 5')
+ return
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleCreateSchedule = async () => { | |
| try { | |
| setError(null) | |
| // Validate | |
| if (newSchedule.days_of_week === 0) { | |
| setError('Please select at least one day') | |
| return | |
| } | |
| // Validate duration | |
| if (newSchedule.duration_minutes < 1 || newSchedule.duration_minutes > 1440) { | |
| setError('Duration must be between 1 and 1440 minutes') | |
| return | |
| } | |
| const handleCreateSchedule = async () => { | |
| try { | |
| setError(null) | |
| // Validate | |
| if (newSchedule.days_of_week === 0) { | |
| setError('Please select at least one day') | |
| return | |
| } | |
| // Validate duration | |
| if (newSchedule.duration_minutes < 1 || newSchedule.duration_minutes > 1440) { | |
| setError('Duration must be between 1 and 1440 minutes') | |
| return | |
| } | |
| if (newSchedule.max_concurrency < 1 || newSchedule.max_concurrency > 5) { | |
| setError('Concurrent agents must be between 1 and 5') | |
| return | |
| } |
🤖 Prompt for AI Agents
In `@ui/src/components/ScheduleModal.tsx` around lines 96 - 110, The submit
handler handleCreateSchedule lacks submit-time validation for
newSchedule.max_concurrency; add a guard in handleCreateSchedule that checks
newSchedule.max_concurrency is between 1 and 5 (inclusive), and if out of range
call setError('Max concurrency must be between 1 and 5') and return before
proceeding with the request; reference newSchedule.max_concurrency and
handleCreateSchedule when adding this validation so client-side constraints
match server constraints.
|
Thank you! |
This commit fixes several issues identified in the agent scheduling feature from PR #75: Frontend Fixes: - Add day boundary handling in timeUtils.ts for timezone conversions - Add utcToLocalWithDayShift/localToUTCWithDayShift functions - Add shiftDaysForward/shiftDaysBackward helpers for bitfield adjustment - Update ScheduleModal to correctly adjust days_of_week when crossing day boundaries during UTC conversion (fixes schedules running on wrong days for users in extreme timezones like UTC+9) Backend Fixes: - Add MAX_SCHEDULES_PER_PROJECT (50) limit to prevent resource exhaustion - Wire up crash recovery callback in scheduler_service._start_agent() - Convert schedules.py endpoints to use context manager for DB sessions - Fix race condition in override creation with atomic delete-then-create - Replace deprecated datetime.utcnow with datetime.now(timezone.utc) - Add DB-level CHECK constraints for Schedule model fields Files Modified: - api/database.py: Add _utc_now helper, CheckConstraint imports, constraints - progress.py: Replace deprecated datetime.utcnow - server/routers/schedules.py: Add context manager, schedule limits - server/services/assistant_database.py: Replace deprecated datetime.utcnow - server/services/scheduler_service.py: Wire crash recovery, fix race condition - ui/src/components/ScheduleModal.tsx: Use day shift functions - ui/src/lib/timeUtils.ts: Add day boundary handling functions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
) - Added ErrorResponse Pydantic model with error_code, message, details fields - Created custom exception classes: NotFoundError (404), ConflictError (409), ValidationError (422), DatabaseError (500), BadRequestError (400), UnauthorizedError (401), ForbiddenError (403) - Implemented exception handlers for all error types including SQLAlchemy - Registered handlers globally in FastAPI app via register_exception_handlers() - All API errors now return consistent JSON format: {"error_code": "...", "message": "...", "details": {...}} Tests: - 52 unit tests in test_feature_75_error_responses.py - 9 E2E tests in test_feature_75_e2e.py - Feature verification script: verify_feature_75.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…error response format The test assertions were using data["detail"] which was the old FastAPI HTTPException format. Feature AutoForgeAI#75 standardized all error responses to use data["message"] instead. - test_404_includes_run_id_in_detail: Changed to check data["message"] - test_409_includes_current_status: Changed to check data["message"] All 29 tests now pass. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…uling feat: add time-based agent scheduling with APScheduler
…ature This commit fixes several issues identified in the agent scheduling feature from PR AutoForgeAI#75: Frontend Fixes: - Add day boundary handling in timeUtils.ts for timezone conversions - Add utcToLocalWithDayShift/localToUTCWithDayShift functions - Add shiftDaysForward/shiftDaysBackward helpers for bitfield adjustment - Update ScheduleModal to correctly adjust days_of_week when crossing day boundaries during UTC conversion (fixes schedules running on wrong days for users in extreme timezones like UTC+9) Backend Fixes: - Add MAX_SCHEDULES_PER_PROJECT (50) limit to prevent resource exhaustion - Wire up crash recovery callback in scheduler_service._start_agent() - Convert schedules.py endpoints to use context manager for DB sessions - Fix race condition in override creation with atomic delete-then-create - Replace deprecated datetime.utcnow with datetime.now(timezone.utc) - Add DB-level CHECK constraints for Schedule model fields Files Modified: - api/database.py: Add _utc_now helper, CheckConstraint imports, constraints - progress.py: Replace deprecated datetime.utcnow - server/routers/schedules.py: Add context manager, schedule limits - server/services/assistant_database.py: Replace deprecated datetime.utcnow - server/services/scheduler_service.py: Wire crash recovery, fix race condition - ui/src/components/ScheduleModal.tsx: Use day shift functions - ui/src/lib/timeUtils.ts: Add day boundary handling functions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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:
Frontend Changes:
Features:
Dependencies:
Summary by CodeRabbit
New Features
UI
API
Chores
✏️ Tip: You can customize this high-level summary in your review settings.