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(