mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 10:04:53 +02:00
[aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93)
This commit is contained in:
@@ -57,12 +57,12 @@ TOOLS = [
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"enum": ["daily_morning", "weekdays_morning", "weekly_monday", "weekly_friday", "monthly_first", "custom"],
|
||||
"description": "Cadence preset. Use 'custom' to specify your own hour/minute/days.",
|
||||
"description": "Cadence preset. Use 'custom' for anything else, including sub-day cadences like 'every 20 minutes' (repeat_unit='minute') or 'every 3 hours' (repeat_unit='hour').",
|
||||
},
|
||||
"hour": {"type": "integer", "description": "Hour 0-23 in the user's local time. Required when preset='custom'."},
|
||||
"minute": {"type": "integer", "description": "Minute 0/15/30/45. Required when preset='custom'."},
|
||||
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"], "description": "Required when preset='custom'."},
|
||||
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit when preset='custom' (e.g. repeat_unit='week' + repeat_every=2 means every other week). Defaults to 1."},
|
||||
"minute": {"type": "integer", "description": "Minute 0/15/30/45. For repeat_unit='hour' this is the minute past the hour; ignored for repeat_unit='minute'. Required when preset='custom'."},
|
||||
"repeat_unit": {"type": "string", "enum": ["minute", "hour", "day", "week", "month"], "description": "Required when preset='custom'. 'minute' fires every repeat_every minutes (min 15); 'hour' fires every repeat_every hours."},
|
||||
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit when preset='custom' (e.g. repeat_unit='week' + repeat_every=2 means every other week; repeat_unit='minute' + repeat_every=15 means every 15 minutes). Defaults to 1; minimum 15 when repeat_unit='minute'."},
|
||||
"on_days": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
@@ -91,8 +91,8 @@ TOOLS = [
|
||||
"schedule_enabled": {"type": "boolean", "description": "Quick on/off without changing other schedule fields."},
|
||||
"hour": {"type": "integer", "description": "Hour 0-23 in the schedule's timezone."},
|
||||
"minute": {"type": "integer", "description": "Minute 0-59."},
|
||||
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"]},
|
||||
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit (e.g. 2 with repeat_unit='week' means every other week)."},
|
||||
"repeat_unit": {"type": "string", "enum": ["minute", "hour", "day", "week", "month"]},
|
||||
"repeat_every": {"type": "integer", "description": "Interval count for repeat_unit (e.g. 2 with repeat_unit='week' means every other week; 15 with repeat_unit='minute' means every 15 minutes, the minimum)."},
|
||||
"on_days": {"type": "array", "items": {"type": "integer"}, "description": "Weekdays (Sun=0..Sat=6) when repeat_unit='week'."},
|
||||
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles')."},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from typing import Optional, Literal, Any
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
@@ -18,9 +18,11 @@ class ScheduleConfig(BaseModel):
|
||||
# Bounds keep the scheduler from blowing up on malformed input. The
|
||||
# FE clamps these too, but defense-in-depth: a misbehaving agent
|
||||
# tool, an old JSON file, or a curl-wielding power user shouldn't
|
||||
# be able to crash _next_fire_after by passing hour=99.
|
||||
repeat_every: int = Field(default=1, ge=1, le=365)
|
||||
repeat_unit: Literal["day", "week", "month"] = "week"
|
||||
# be able to crash _next_fire_after by passing hour=99. The per-unit
|
||||
# upper bound on repeat_every is clamped (not rejected) in
|
||||
# _enforce_interval_bounds below, so only the floor lives on the Field.
|
||||
repeat_every: int = Field(default=1, ge=1)
|
||||
repeat_unit: Literal["minute", "hour", "day", "week", "month"] = "week"
|
||||
on_days: list[int] = Field(default_factory=list)
|
||||
hour: int = Field(default=9, ge=0, le=23)
|
||||
minute: int = Field(default=0, ge=0, le=59)
|
||||
@@ -51,6 +53,18 @@ class ScheduleConfig(BaseModel):
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_interval_bounds(self) -> "ScheduleConfig":
|
||||
# Per-unit bounds, clamped rather than rejected so a stray value from
|
||||
# an agent tool or old record can't crash the scheduler. The minute
|
||||
# unit floors at 15 (no once-a-minute token-burning loop) and ceilings
|
||||
# at 1440 (24h); every other unit keeps the original 365 ceiling.
|
||||
if self.repeat_unit == "minute":
|
||||
self.repeat_every = max(15, min(self.repeat_every, 1440))
|
||||
else:
|
||||
self.repeat_every = min(self.repeat_every, 365)
|
||||
return self
|
||||
|
||||
|
||||
class ActionsConfig(BaseModel):
|
||||
prevent_unused: bool = False
|
||||
|
||||
@@ -7,9 +7,11 @@ startup we walk persisted workflows once, decide what to do about missed
|
||||
fires via on_missed, and queue each.
|
||||
|
||||
Schedule semantics:
|
||||
unit=day: fires every repeat_every days at hour:minute
|
||||
unit=week: fires on the listed weekday(s) every repeat_every weeks
|
||||
unit=month: fires on the original day-of-month every repeat_every months
|
||||
unit=minute: fires every repeat_every minutes (15 is the enforced floor)
|
||||
unit=hour: fires every repeat_every hours at :minute past the hour
|
||||
unit=day: fires every repeat_every days at hour:minute
|
||||
unit=week: fires on the listed weekday(s) every repeat_every weeks
|
||||
unit=month: fires on the original day-of-month every repeat_every months
|
||||
|
||||
Wall-clock math runs in the workflow's IANA timezone, then we convert to
|
||||
UTC at the boundary. This is the only safe way to honor DST (a "9am
|
||||
@@ -105,6 +107,21 @@ def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datet
|
||||
tz = _resolve_tz(sched.timezone)
|
||||
ref_local = ref_utc.astimezone(tz)
|
||||
base = ref_local.replace(second=0, microsecond=0)
|
||||
|
||||
if sched.repeat_unit == "minute":
|
||||
step = max(15, sched.repeat_every)
|
||||
c = base
|
||||
while c <= ref_local:
|
||||
c = c + timedelta(minutes=step)
|
||||
return c.astimezone(timezone.utc)
|
||||
|
||||
if sched.repeat_unit == "hour":
|
||||
step = max(1, sched.repeat_every)
|
||||
c = base.replace(minute=sched.minute)
|
||||
while c <= ref_local:
|
||||
c = c + timedelta(hours=step)
|
||||
return c.astimezone(timezone.utc)
|
||||
|
||||
candidate = base.replace(hour=sched.hour, minute=sched.minute)
|
||||
if candidate <= ref_local:
|
||||
candidate = candidate + timedelta(days=1)
|
||||
@@ -144,8 +161,9 @@ def compute_next_fire(wf: Workflow, ref: Optional[datetime] = None) -> Optional[
|
||||
def fires_in_window(wf: Workflow, days: int = 30) -> int:
|
||||
"""Count fires from now through `days` days from now. Used by the
|
||||
cost-estimate response. Honors end conditions so the projection doesn't
|
||||
over-count after ends_at or max_runs. Caps the walk at 1000 fires to
|
||||
guard pathological sub-day schedules (none today, but cheap insurance).
|
||||
over-count after ends_at or max_runs. Caps the walk at 5000 fires: a
|
||||
15-minute schedule fires ~2880x in 30 days, so the cap has to clear that
|
||||
to keep the estimate honest while still bounding the loop.
|
||||
"""
|
||||
sched = wf.schedule
|
||||
if not sched.enabled:
|
||||
@@ -158,10 +176,10 @@ def fires_in_window(wf: Workflow, days: int = 30) -> int:
|
||||
if ends_at_utc is not None and ends_at_utc < end_utc:
|
||||
end_utc = ends_at_utc
|
||||
remaining_budget = (
|
||||
sched.max_runs - sched.runs_count if sched.max_runs is not None else 1000
|
||||
sched.max_runs - sched.runs_count if sched.max_runs is not None else 5000
|
||||
)
|
||||
count = 0
|
||||
while count < min(1000, remaining_budget):
|
||||
while count < min(5000, remaining_budget):
|
||||
nxt = _next_fire_after(sched, cursor_utc)
|
||||
if nxt is None or nxt > end_utc:
|
||||
break
|
||||
|
||||
@@ -734,8 +734,9 @@ async def schedule_agent_session(workflow_id: str):
|
||||
f" - workflow_id: \"{wf.id}\"\n"
|
||||
" - schedule_enabled: true\n"
|
||||
" - hour (0-23) and minute (0-59) in the user's local time\n"
|
||||
" - repeat_unit: \"day\" | \"week\" | \"month\"\n"
|
||||
" - repeat_every: the interval count (1 unless they say e.g. \"every other\")\n"
|
||||
" - repeat_unit: \"minute\" | \"hour\" | \"day\" | \"week\" | \"month\"\n"
|
||||
" - repeat_every: the interval count (1 unless they say e.g. \"every other\"; "
|
||||
"for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n"
|
||||
" - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n"
|
||||
" - timezone: an IANA name only if the user names a specific zone\n\n"
|
||||
"If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence "
|
||||
|
||||
Reference in New Issue
Block a user