diff --git a/backend/packages/harness/deerflow/domain/schedule/model/spec.py b/backend/packages/harness/deerflow/domain/schedule/model/spec.py index 560ba36be..787dace43 100644 --- a/backend/packages/harness/deerflow/domain/schedule/model/spec.py +++ b/backend/packages/harness/deerflow/domain/schedule/model/spec.py @@ -161,12 +161,21 @@ class ScheduleSpec: single occurrence is in the past). CRON is evaluated in this schedule's timezone and returned as UTC. A naive `now` is read as UTC (schedules.py:32-33). + + **Both branches return UTC** (#4607). `run_at` keeps the zone it was + declared in, so returning it unconverted handed the caller a non-UTC + offset while CRON handed back 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 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 saw it. """ if now.tzinfo is None: now = now.replace(tzinfo=UTC) if self.schedule_type is ScheduleType.ONCE: - return self.run_at if self.run_at > now else None + return self.run_at.astimezone(UTC) if self.run_at > now else None zone = ZoneInfo(self.timezone) next_local = croniter(self.cron, now.astimezone(zone)).get_next(datetime) diff --git a/backend/tests/test_schedule_domain.py b/backend/tests/test_schedule_domain.py index fa2661020..f448c0f0a 100644 --- a/backend/tests/test_schedule_domain.py +++ b/backend/tests/test_schedule_domain.py @@ -231,6 +231,35 @@ class TestNextAfter: spec = cron_spec("0 9 * * *", "UTC") assert spec.next_after(NOW.replace(tzinfo=None)) == spec.next_after(NOW) + def test_once_in_a_non_utc_zone_is_returned_as_utc(self): + """#4607: both branches must return UTC. + + `run_at` keeps the zone it was declared in. Returning it unconverted + made ONCE disagree with CRON, and the value is persisted into a column + whose SQLite dialect discards tzinfo -- so the task fired a whole + offset late (+08:00 here). Comparing `utcoffset()` rather than the + instant is the point: the two are equal as instants either way, and it + is the *label* that gets thrown away on write. + """ + spec = ScheduleSpec.once_at(datetime(2026, 8, 2, 9, 0), "Asia/Shanghai") # noqa: DTZ001 -- naive local wall time is the subject + next_at = spec.next_after(NOW) + assert next_at.utcoffset() == timedelta(0) + assert next_at == datetime(2026, 8, 2, 1, 0, tzinfo=UTC) + + def test_a_negative_offset_once_is_also_returned_as_utc(self): + """The negative-offset half: this one fired *early*, which additionally + slipped past the `min_once_delay_seconds` floor.""" + spec = ScheduleSpec.once_at(datetime(2026, 8, 2, 9, 0), "America/New_York") # noqa: DTZ001 -- naive local wall time is the subject + next_at = spec.next_after(NOW) + assert next_at.utcoffset() == timedelta(0) + assert next_at == datetime(2026, 8, 2, 13, 0, tzinfo=UTC) # EDT, UTC-4 + + def test_both_branches_agree_on_the_returned_offset(self): + """The invariant behind the two cases above, stated once.""" + once = ScheduleSpec.once_at(datetime(2026, 8, 2, 9, 0), "Asia/Shanghai") # noqa: DTZ001 -- naive local wall time is the subject + cron = cron_spec("0 9 * * *", "Asia/Shanghai") + assert once.next_after(NOW).utcoffset() == cron.next_after(NOW).utcoffset() + # ---------------------------------------------------------------- C. ensure_launchable