mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat/schedule: pin monthly last-day-of-month
This commit is contained in:
@@ -29,6 +29,9 @@ class ScheduleConfig(BaseModel):
|
||||
# Monthly schedules can pin a day-of-month explicitly. None preserves the
|
||||
# legacy "same day as the current reference" behavior for older records.
|
||||
day_of_month: Optional[int] = Field(default=None, ge=1, le=31)
|
||||
# When true, monthly schedules fire on the calendar's last day (28-31)
|
||||
# regardless of day_of_month, so "end of month" survives short months.
|
||||
last_day_of_month: bool = False
|
||||
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy
|
||||
# records that predate explicit tz. storage._load_all_from_disk coerces
|
||||
# "local" to the host zone in memory; we leave it on disk until the
|
||||
|
||||
@@ -145,13 +145,17 @@ def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datet
|
||||
if sched.repeat_unit == "month":
|
||||
target_day = sched.day_of_month or ref_local.day
|
||||
step = max(1, sched.repeat_every)
|
||||
c = candidate.replace(day=min(target_day, calendar.monthrange(candidate.year, candidate.month)[1]))
|
||||
|
||||
def month_day(year: int, month: int) -> int:
|
||||
last = calendar.monthrange(year, month)[1]
|
||||
return last if sched.last_day_of_month else min(target_day, last)
|
||||
|
||||
c = candidate.replace(day=month_day(candidate.year, candidate.month))
|
||||
while c <= ref_local:
|
||||
total = c.month - 1 + step
|
||||
year = c.year + total // 12
|
||||
month = total % 12 + 1
|
||||
day = min(target_day, calendar.monthrange(year, month)[1])
|
||||
c = c.replace(year=year, month=month, day=day)
|
||||
c = c.replace(year=year, month=month, day=month_day(year, month))
|
||||
return c.astimezone(timezone.utc)
|
||||
|
||||
if candidate <= ref_local:
|
||||
|
||||
@@ -345,3 +345,27 @@ async def test_kick_wakes_loop_before_timeout(monkeypatch):
|
||||
await asyncio.wait_for(fired.wait(), timeout=3.0)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
|
||||
|
||||
async def test_last_day_of_month_fires_on_month_end():
|
||||
"""last_day_of_month ignores day_of_month and lands on the calendar's
|
||||
final day, so it survives short months (Feb) instead of clamping."""
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
from backend.apps.workflows import scheduler
|
||||
sched = ScheduleConfig(
|
||||
enabled=True, repeat_unit="month", repeat_every=1,
|
||||
day_of_month=15, last_day_of_month=True, hour=9, minute=0,
|
||||
timezone="America/Los_Angeles",
|
||||
)
|
||||
wf = _make_wf(schedule=sched)
|
||||
tz = ZoneInfo("America/Los_Angeles")
|
||||
# From mid-February, the next fire is Feb 28 (or 29 on a leap year), NOT the 15th.
|
||||
ref = datetime(2026, 2, 10, 12, 0, tzinfo=tz).astimezone(timezone.utc)
|
||||
nxt = scheduler.compute_next_fire(wf, ref=ref)
|
||||
local = nxt.astimezone(tz)
|
||||
assert local.month == 2 and local.day == 28
|
||||
assert local.hour == 9
|
||||
# From end of Feb, the following fire rolls to Mar 31 (last day again).
|
||||
nxt2 = scheduler.compute_next_fire(wf, ref=nxt)
|
||||
local2 = nxt2.astimezone(tz)
|
||||
assert local2.month == 3 and local2.day == 31
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface ScheduleConfig {
|
||||
hour: number;
|
||||
minute: number;
|
||||
day_of_month?: number | null;
|
||||
/** Monthly schedules fire on the calendar's last day (28-31) when true. */
|
||||
last_day_of_month?: boolean;
|
||||
timezone: string;
|
||||
/** End conditions; null on both = forever. Scheduler auto-disables on threshold. */
|
||||
ends_at: string | null;
|
||||
|
||||
Reference in New Issue
Block a user