From 486b51eb51364979e192030f7443fa331e2ec783 Mon Sep 17 00:00:00 2001 From: Baldwinzc <56501736+Baldwinzc@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:27:51 +0800 Subject: [PATCH] fix(scheduler): normalize once-schedule next_run_at to UTC (#4607) The once branch of next_run_at returned the run time with the task's local timezone offset attached, while the cron branch normalizes to UTC. The value is persisted into scheduled_tasks.next_run_at, and SQLAlchemy's SQLite dialect discards tzinfo on bind, so a once task declared in a non-UTC timezone fires shifted by the whole offset (e.g. 8 hours late for Asia/Shanghai, hours early for negative offsets - also bypassing the min_once_delay_seconds guard). Postgres timestamptz normalizes on write, which is why only SQLite deployments are affected. Align the once branch with the cron branch by converting to UTC before returning. --- .../harness/deerflow/scheduler/schedules.py | 4 +++ .../tests/test_scheduled_task_schedules.py | 26 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/scheduler/schedules.py b/backend/packages/harness/deerflow/scheduler/schedules.py index 8509c3523..a7783e1a3 100644 --- a/backend/packages/harness/deerflow/scheduler/schedules.py +++ b/backend/packages/harness/deerflow/scheduler/schedules.py @@ -41,6 +41,10 @@ def next_run_at( # A naive run_at means "wall-clock time in the task's declared # timezone", matching how cron schedules interpret it. run_at = run_at.replace(tzinfo=ZoneInfo(timezone_name)) + # Normalize to UTC like the cron branch: next_run_at is persisted to + # timezone-discarding columns (SQLite), where a non-UTC offset shifts + # the effective fire time by the whole offset. + run_at = run_at.astimezone(UTC) return run_at if run_at > now else None if schedule_type == "cron": diff --git a/backend/tests/test_scheduled_task_schedules.py b/backend/tests/test_scheduled_task_schedules.py index fcaac8676..48644a9df 100644 --- a/backend/tests/test_scheduled_task_schedules.py +++ b/backend/tests/test_scheduled_task_schedules.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import pytest @@ -38,6 +38,30 @@ def test_next_run_at_for_once_returns_none_after_fire_time(): assert result is None +def test_next_run_at_for_once_normalizes_naive_run_at_to_utc(): + now = datetime(2026, 7, 31, 0, 0, tzinfo=UTC) + result = next_run_at( + "once", + {"run_at": "2026-08-01T09:00:00"}, + "Asia/Shanghai", + now=now, + ) + assert result == datetime(2026, 8, 1, 1, 0, tzinfo=UTC) + assert result.utcoffset() == timedelta(0) + + +def test_next_run_at_for_once_normalizes_aware_run_at_to_utc(): + now = datetime(2026, 7, 31, 0, 0, tzinfo=UTC) + result = next_run_at( + "once", + {"run_at": "2026-08-01T09:00:00+08:00"}, + "UTC", + now=now, + ) + assert result == datetime(2026, 8, 1, 1, 0, tzinfo=UTC) + assert result.utcoffset() == timedelta(0) + + def test_next_run_at_for_cron_uses_timezone(): now = datetime(2026, 7, 1, 0, 30, tzinfo=UTC) result = next_run_at(