rayhpeng ee33b88893 fix(schedule): normalize a once schedule's next fire time to UTC
Ports #4607 onto the hexagonal path, where the same bug was reproduced:
`next_after` returned ONCE's `run_at` with the task's declared offset
still attached while the CRON branch returned UTC.

That value is persisted into `scheduled_tasks.next_run_at`, and
SQLAlchemy's SQLite dialect discards tzinfo on bind, so the stored
instant was wrong by the whole offset -- a task declared in
Asia/Shanghai fired eight hours late, and a negative offset fired early,
slipping past the `min_once_delay_seconds` floor on the way. Postgres
timestamptz normalizes on write, which is why only SQLite deployments
were affected.

`ensure_launchable` delegates to `next_after`, so both entry points are
covered by the one conversion. The regression cases assert on
`utcoffset()` rather than the instant, because the two are equal as
instants either way -- it is the label that gets discarded on write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:03:22 +08:00
..