This commit is contained in:
Marian Paul
2026-01-17 21:59:57 +01:00
parent 0bab585630
commit a6fe2ef633
4 changed files with 69 additions and 31 deletions

View File

@@ -306,19 +306,11 @@ async def update_schedule(
was_enabled = schedule.enabled
# Update fields
if data.start_time is not None:
schedule.start_time = data.start_time
if data.duration_minutes is not None:
schedule.duration_minutes = data.duration_minutes
if data.days_of_week is not None:
schedule.days_of_week = data.days_of_week
if data.enabled is not None:
schedule.enabled = data.enabled
if data.yolo_mode is not None:
schedule.yolo_mode = data.yolo_mode
if data.model is not None:
schedule.model = data.model
# Update only fields that were explicitly provided
# This allows sending {"model": null} to clear it vs omitting the field entirely
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(schedule, field, value)
db.commit()
db.refresh(schedule)

View File

@@ -131,6 +131,9 @@ class SchedulerService:
start_dt = datetime.strptime(schedule.start_time, "%H:%M")
end_dt = start_dt + timedelta(minutes=schedule.duration_minutes)
# Detect midnight crossing
crosses_midnight = end_dt.date() != start_dt.date()
# Handle midnight wraparound for end time
end_hour = end_dt.hour
end_minute = end_dt.minute
@@ -148,8 +151,15 @@ class SchedulerService:
)
# Stop job - CRITICAL: timezone=timezone.utc is required for correct UTC scheduling
# If schedule crosses midnight, shift days forward so stop occurs on next day
stop_job_id = f"schedule_{schedule.id}_stop"
stop_trigger = CronTrigger(hour=end_hour, minute=end_minute, day_of_week=days, timezone=timezone.utc)
if crosses_midnight:
shifted_bitfield = self._shift_days_forward(schedule.days_of_week)
stop_days = self._bitfield_to_cron_days(shifted_bitfield)
else:
stop_days = days
stop_trigger = CronTrigger(hour=end_hour, minute=end_minute, day_of_week=stop_days, timezone=timezone.utc)
self.scheduler.add_job(
self._handle_scheduled_stop,
stop_trigger,
@@ -304,27 +314,34 @@ class SchedulerService:
def _is_within_window(self, schedule, now: datetime) -> bool:
"""Check if current time is within schedule window."""
# Check if active on this day
if not schedule.is_active_on_day(now.weekday()):
return False
# Parse schedule times
# Parse schedule times (keep timezone awareness from now)
start_hour, start_minute = map(int, schedule.start_time.split(":"))
start_time = now.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
# Calculate end time
end_time = start_time + timedelta(minutes=schedule.duration_minutes)
current_time = now.replace(tzinfo=None) if now.tzinfo else now
start_time = start_time.replace(tzinfo=None)
end_time = end_time.replace(tzinfo=None)
# Detect midnight crossing
crosses_midnight = end_time < start_time or end_time.date() != start_time.date()
# Handle midnight wraparound
if end_time.day > start_time.day:
# Schedule crosses midnight
return current_time >= start_time or current_time < end_time.replace(day=start_time.day)
if crosses_midnight:
# Check today's window (start_time to midnight) OR yesterday's window (midnight to end_time)
# Today: if we're after start_time on the current day
if schedule.is_active_on_day(now.weekday()) and now >= start_time:
return True
# Yesterday: check if we're before end_time and yesterday was active
yesterday = (now.weekday() - 1) % 7
if schedule.is_active_on_day(yesterday):
yesterday_start = start_time - timedelta(days=1)
yesterday_end = end_time - timedelta(days=1)
if yesterday_start <= now < yesterday_end:
return True
return False
else:
return start_time <= current_time < end_time
# Normal case: doesn't cross midnight
return schedule.is_active_on_day(now.weekday()) and start_time <= now < end_time
async def _start_agent(self, project_name: str, project_dir: Path, schedule):
"""Start the agent for a project."""
@@ -563,6 +580,27 @@ class SchedulerService:
except Exception as e:
logger.error(f"Error checking startup for {project_name}: {e}")
@staticmethod
def _shift_days_forward(bitfield: int) -> int:
"""
Shift the 7-bit day mask forward by one day for midnight-crossing schedules.
Examples:
Monday (1) -> Tuesday (2)
Sunday (64) -> Monday (1)
Mon+Tue (3) -> Tue+Wed (6)
"""
shifted = 0
# Shift each day forward, wrapping Sunday to Monday
if bitfield & 1: shifted |= 2 # Mon -> Tue
if bitfield & 2: shifted |= 4 # Tue -> Wed
if bitfield & 4: shifted |= 8 # Wed -> Thu
if bitfield & 8: shifted |= 16 # Thu -> Fri
if bitfield & 16: shifted |= 32 # Fri -> Sat
if bitfield & 32: shifted |= 64 # Sat -> Sun
if bitfield & 64: shifted |= 1 # Sun -> Mon
return shifted
@staticmethod
def _bitfield_to_cron_days(bitfield: int) -> str:
"""Convert days bitfield to APScheduler cron format."""

View File

@@ -102,6 +102,12 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
return
}
// Validate duration
if (newSchedule.duration_minutes < 1 || newSchedule.duration_minutes > 1440) {
setError('Duration must be between 1 and 1440 minutes')
return
}
// Convert local time to UTC
const scheduleToCreate = {
...newSchedule,
@@ -309,12 +315,14 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
min="1"
max="1440"
value={newSchedule.duration_minutes}
onChange={(e) =>
onChange={(e) => {
const parsed = parseInt(e.target.value, 10)
const value = isNaN(parsed) ? 1 : Math.max(1, Math.min(1440, parsed))
setNewSchedule((prev) => ({
...prev,
duration_minutes: parseInt(e.target.value) || 0,
duration_minutes: value,
}))
}
}}
className="neo-input w-full"
/>
<div className="text-xs text-gray-600 mt-1">

View File

@@ -73,7 +73,7 @@ export function formatNextRun(isoString: string): string {
}
// Further out - show day and time
return date.toLocaleDateString([], {
return date.toLocaleString([], {
weekday: 'short',
hour: 'numeric',
minute: '2-digit'