Skip to content

feat: add time-based agent scheduling with APScheduler#75

Merged
leonvanzyl merged 6 commits intoAutoForgeAI:masterfrom
ipodishima:feature/agent-scheduling
Jan 22, 2026
Merged

feat: add time-based agent scheduling with APScheduler#75
leonvanzyl merged 6 commits intoAutoForgeAI:masterfrom
ipodishima:feature/agent-scheduling

Conversation

@ipodishima
Copy link
Contributor

@ipodishima ipodishima commented Jan 17, 2026

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.

Screenshot 2026-01-17 at 21 34 45 Screenshot 2026-01-17 at 21 33 24 Screenshot 2026-01-17 at 21 33 35

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

Summary by CodeRabbit

  • New Features

    • Project-scoped scheduling backend with recurring windows, automatic start/stop, crash-retry, manual start/stop overrides, and startup recovery.
  • UI

    • Next-run indicator, running-until display, persistent schedule button, and Schedule modal for creating, editing, enabling/disabling, and deleting schedules.
  • API

    • New REST endpoints and client hooks to list/create/update/delete schedules and fetch next scheduled run.
  • Chores

    • Database migration to add schedule tables and added scheduling dependency.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 17, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Database Layer
api/database.py, requirements.txt
New Schedule and ScheduleOverride ORM models, to_dict and is_active_on_day, one-to-many relationship with cascade delete, migration helper _migrate_add_schedules_tables, and added apscheduler>=3.10.0 requirement.
Scheduler Service
server/services/scheduler_service.py
New SchedulerService singleton using APScheduler: loads per-project schedules, registers start/stop cron jobs (handles cross-midnight), enforces manual start/stop overrides, crash-restart with exponential backoff, startup recovery, and exposes get_scheduler() / cleanup_scheduler().
API Routes & Integration
server/routers/schedules.py, server/routers/agent.py, server/routers/__init__.py, server/main.py
New schedules router (list/create/get/update/delete/next) with per-project DB sessions; agent router notifies scheduler on manual start/stop; app starts scheduler on startup and cleans it on shutdown.
API Schemas
server/schemas.py
Added ScheduleCreate, ScheduleUpdate, ScheduleResponse, ScheduleListResponse, and NextRunResponse schemas (validation included).
Scheduler API Clients / Hooks
ui/src/lib/api.ts, ui/src/hooks/useSchedules.ts, ui/src/hooks/useProjects.ts
Added schedule API functions and React Query hooks (list/create/update/delete/toggle/next-run); next-run query invalidated on relevant mutations and after manual stop.
Frontend UI & Utilities
ui/src/components/ScheduleModal.tsx, ui/src/components/AgentControl.tsx, ui/src/lib/timeUtils.ts, ui/src/lib/types.ts, ui/src/components/Terminal.tsx
New ScheduleModal UI, AgentControl shows next-run/current running status and opens modal; time utilities for UTC↔local conversion, formatting, day-bit helpers; TS types for schedules; minor typing fixes in Terminal.

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
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰
I tuned the clocks and planted rows of time,
I hop, I nudge, I restart at the chime.
When humans press pause, I mark their day,
When windows open, I hop out to play.
Schedules set — carrots and uptime rhyme.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add time-based agent scheduling with APScheduler' directly and accurately summarizes the main change—introducing a scheduling system using APScheduler for automated agent start/stop control.
Docstring Coverage ✅ Passed Docstring coverage is 84.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Bortus-AI
Copy link

Bortus-AI commented Jan 17, 2026 via email

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.0
server/routers/agent.py (1)

115-121: Redundant _get_project_path call.

project_dir is already resolved in get_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_manager to 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_dir

Option 2: Access project_dir from the manager if it exposes it.

ui/src/components/ScheduleModal.tsx (1)

136-145: Consider replacing confirm() 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 timezone is already imported at the module level (line 12). The local import as dt_timezone is 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 of sys.path manipulation 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 including server/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_path and validate_project_name are duplicated from server/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_window and scheduler._start_agent. While functional, this breaks encapsulation. Consider adding public methods like is_schedule_active() and start_schedule_immediately() to the SchedulerService class.


382-395: Duplicated logic with scheduler_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.

@leonvanzyl
Copy link
Collaborator

The solution change quite a bit. Please could you rebase and adjust this solution?

ipodishima and others added 2 commits January 19, 2026 10:31
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>
@ipodishima ipodishima force-pushed the feature/agent-scheduling branch from 0c3a06c to a6fe2ef Compare January 19, 2026 09:35
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, but get_project_manager already 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 using useSchedule gets 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.utcnow is deprecated in Python 3.12+.

datetime.utcnow() returns a naive datetime and is deprecated. Consider using datetime.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 tzinfo stripping on line 507 is unnecessary since both now and window_start derive 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_end
server/routers/schedules.py (3)

25-48: Code duplication: _get_project_path and validate_project_name are 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 SchedulerService if 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_end duplicated from scheduler_service.py.

This function exists in both server/routers/schedules.py (lines 374-387) and server/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 Dialog primitive 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: Native confirm() 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: Add aria-live for 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: Add aria-pressed for 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)

@ipodishima
Copy link
Contributor Author

@leonvanzyl done!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +253 to +304
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)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

pRizz added a commit to pRizz/autocoder that referenced this pull request Jan 21, 2026
…+ 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>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
If duration_minutes == 1440 and 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: Clarify ScheduleUpdate.start_time timezone semantics.

ScheduleCreate.start_time is documented as local-time input, but ScheduleUpdate.start_time is 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 disallowing days_of_week=0 to avoid “enabled but never runs”.

If enabled=True and days_of_week=0, the schedule can’t trigger. Tightening the lower bound helps prevent accidental no-op schedules; disabling should use enabled=False instead.

♻️ 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: Constrain override_type to 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",
+        ),
+    )

Comment on lines +482 to +518
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

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +96 to +110
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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

@leonvanzyl leonvanzyl merged commit 44e333d into AutoForgeAI:master Jan 22, 2026
3 checks passed
@leonvanzyl
Copy link
Collaborator

Thank you!

leonvanzyl added a commit that referenced this pull request Jan 22, 2026
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>
rudiheydra added a commit to rudiheydra/AutoBuildr that referenced this pull request Jan 27, 2026
)

- 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>
rudiheydra added a commit to rudiheydra/AutoBuildr that referenced this pull request Feb 3, 2026
…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>
CoreAspectStu pushed a commit to CoreAspectStu/autocoder-custom that referenced this pull request Feb 9, 2026
…uling

feat: add time-based agent scheduling with APScheduler
CoreAspectStu pushed a commit to CoreAspectStu/autocoder-custom that referenced this pull request Feb 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants