fix(scheduler): reject past one-time 'at' cron schedules - #1526
Conversation
A one-time 'at' schedule with a timestamp already in the past is accepted, fires on the next scheduler tick, and the one-shot job is then deleted — so the payload runs immediately with no future occurrence and disappears. That is almost never what a caller scheduling a one-time reminder intends. Validate the 'at' timestamp against the current time in SchedulerOps.add and SchedulerOps.update (the two structured-schedule entry points) and raise a field-named ValueError before persisting, matching the existing timezone and interval validation style. Recurring cron/every schedules are unaffected. Fixes TokenRhythm#1516
|
Refreshed this PR onto the latest Verification on the refreshed head:
GitHub reports the branch as mergeable; the newly triggered CI jobs are still running. |
|
Task A refresh (2026-09-05): fetched latest Verification on the refreshed head:
No new comments/reviews or unresolved review threads. |
|
Refreshed this branch onto latest upstream/main (1663b1a); new head is 37231ee. The one-commit base update merged without conflicts, and the strict one-time schedule diff remains limited to scheduler ops plus its regression tests. Verification: 17 strict-schedule tests passed, focused Ruff passed, and git diff --check passed. |
|
Task A refresh: merged latest |
|
Task A refresh: merged latest |
|
Refreshed onto current upstream/main (cfa8e4b); merge completed without conflicts and preserved the scheduler change. New head: 172d5c4. Verification: .venv/bin/pytest -q tests/test_scheduler/test_ops_strict_schedule.py (17 passed); git diff --check refs/task-a/upstream/main...HEAD (clean). Fresh CI is running. |
|
Task A refresh: merged latest |
|
Task A refresh: merged latest Verification: |
Summary
Fixes #1516.
A one-time
atcron schedule whose timestamp is already in the past is currently accepted. On the next scheduler tick the job fires, and becauseatjobs aredelete_after_run, the one-shot job is immediately removed. The net effect is that the payload runs right away with no future occurrence and then disappears — exactly the surprising behavior reported in the issue (CLI exits 0, Web UI shows "Schedule created" then immediately runs the reminder, and the job vanishes from the list).This change validates the
attimestamp against the current time and rejects a past value before persisting, so the caller gets a clear error instead of a silent immediate run.What changed
_reject_past_at()inscheduler/ops.py, which parses the ISO-8601 value (reusingparse_iso_at) and raises a field-namedValueError(schedule.at is in the past: ...) when it is earlier thannow.atjob:SchedulerOps.addandSchedulerOps.update. The check runs beforenext_run_atis set, so nothing is stored on rejection.cronandeveryschedules are untouched; only one-timeatis affected.The error is raised as
ValueError, which the RPC layer and the admin cron tool already translate into their respective field-named /ToolErrormessages, matching how the existing timezone and interval validations surface.I deliberately kept this to a hard rejection rather than an opt-in catch-up flag, since the issue notes catch-up is not an existing intentional feature here. If maintainers would prefer an explicit opt-in (e.g.
allowPast) instead, I'm happy to adjust.Tests
Added regression tests in
tests/test_scheduler/test_ops_strict_schedule.py:test_ops_add_at_rejects_past_timestamp— a pastatonaddraises and persists nothing.test_ops_update_at_rejects_past_timestamp— repointing an existing job to a pastatis rejected.Verified locally:
.venv/bin/python -m pytest tests/test_scheduler tests/test_gateway/test_rpc_cron_strict_schedule.py tests/test_tools/test_admin_cron_strict.py→ 221 passed.ruff check src tests→ all checks passed.Third-party origins
none.
I use a coding assistant to help implement, and I review and take responsibility for the final change.