diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db5e3b71e..bace2d9577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -327,6 +327,13 @@ This release closes that milestone with **181 merged pull requests**. ### Fixed +- **scheduler:** Fixed-hour cron tasks no longer fire twice on the daylight-saving + fall-back day. `croniter` returns both occurrences of an ambiguous wall-clock + hour (the first with `fold=0`, the second with `fold=1`). For tasks where + neither minute nor hour contains a wildcard, the second occurrence is skipped + to preserve once-per-day semantics (Vixie cron contract), while wildcard + schedules (such as `0 * * * *`) still run in both occurrences of the repeated + hour. (issue #6052, [#6066]) - **persistence:** A SQLite `checkpointer.connection_string` written as a `file:` URI now fails at startup instead of silently writing somewhere else. LangGraph's SQLite checkpointer and Store open connection strings without @@ -6259,5 +6266,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5987]: https://github.com/bytedance/deer-flow/pull/5987 [#5991]: https://github.com/bytedance/deer-flow/pull/5991 [#6015]: https://github.com/bytedance/deer-flow/pull/6015 +[#6066]: https://github.com/bytedance/deer-flow/pull/6066 [#6069]: https://github.com/bytedance/deer-flow/pull/6069 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 5f711bfde8..b9046e9b9a 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -332,6 +332,10 @@ ### 修复 +- **调度器:** 固定小时的 cron 任务在夏令时回退(DST fall-back)当天不再重复运行两次。 + `croniter` 会返回模糊本地时间的两个实例(首个为 `fold=0`,第二个为 `fold=1`)。 + 对于分和时字段不包含通配符的固定任务,现在会跳过第二个重复实例(`fold=1`),保持每天只运行一次 + 的契约(Vixie cron 规范),同时通配符计划(如 `0 * * * *`)仍会在重复的小时内每小时正常触发。(Issue #6052, [#6066]) - **持久化:** SQLite `checkpointer.connection_string` 若写成 `file:` URI,现在会在 启动时报错,而不是悄悄写到别处。LangGraph 的 SQLite checkpointer 与 Store 打开 连接串时未传 `uri=True`,SQLite 会把 URI 当作字面文件名: @@ -5310,4 +5314,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5987]: https://github.com/bytedance/deer-flow/pull/5987 [#5991]: https://github.com/bytedance/deer-flow/pull/5991 [#6015]: https://github.com/bytedance/deer-flow/pull/6015 +[#6066]: https://github.com/bytedance/deer-flow/pull/6066 [#6069]: https://github.com/bytedance/deer-flow/pull/6069 diff --git a/backend/packages/harness/deerflow/scheduler/schedules.py b/backend/packages/harness/deerflow/scheduler/schedules.py index 50092c60c7..c483409f9e 100644 --- a/backend/packages/harness/deerflow/scheduler/schedules.py +++ b/backend/packages/harness/deerflow/scheduler/schedules.py @@ -30,6 +30,26 @@ def parse_interval_seconds(schedule_spec: dict[str, object]) -> int: return raw +def _is_fixed_time_cron(cron_expr: str) -> bool: + """Return whether a cron expression specifies a single fixed minute and hour. + + Expressions with wildcards ('*'), steps ('/'), ranges ('-'), or lists (',') + in either the minute or hour field (e.g. '0 0-23 * * *' or '*/15 2 * * *') + fire multiple times and should preserve all occurrences across repeated hours. + """ + parts = cron_expr.strip().split() + if len(parts) >= 2: + minute, hour = parts[0], parts[1] + special = ("*", "/", "-", ",") + return not any(ch in minute or ch in hour for ch in special) + return False + + +def _is_ambiguous_datetime(dt: datetime) -> bool: + """Return whether a local datetime is ambiguous (e.g. during a DST fall-back).""" + return dt.replace(fold=0).utcoffset() != dt.replace(fold=1).utcoffset() + + def next_run_at( schedule_type: str, schedule_spec: dict[str, object], @@ -60,11 +80,24 @@ def next_run_at( cron_expr = normalize_cron_expression(str(schedule_spec.get("cron", ""))) zone = ZoneInfo(timezone_name) local_now = now.astimezone(zone) - next_local = croniter(cron_expr, local_now).get_next(datetime) + it = croniter(cron_expr, local_now) + next_local = it.get_next(datetime) if next_local.tzinfo is None: next_local = next_local.replace(tzinfo=zone) - return next_local.astimezone(UTC) + # During a daylight-saving fall-back (e.g. 03:00 -> 02:00), an ambiguous + # wall-clock hour repeats twice. croniter returns both occurrences (the + # first with fold=0, the second with fold=1). For fixed-time tasks + # (where neither minute nor hour contains '*'), running twice on the + # same calendar day violates the cron contract (Vixie cron / POSIX + # behavior). Skip the second occurrence (fold=1) to preserve once-per-day + # semantics while letting wildcard schedules (e.g. '0 * * * *') fire each hour. + if next_local.fold == 1 and _is_fixed_time_cron(cron_expr) and _is_ambiguous_datetime(next_local): + next_local = it.get_next(datetime) + if next_local.tzinfo is None: + next_local = next_local.replace(tzinfo=zone) + + return next_local.astimezone(UTC) if schedule_type == "interval": every_seconds = parse_interval_seconds(schedule_spec) return now.astimezone(UTC) + timedelta(seconds=every_seconds) diff --git a/backend/tests/test_scheduled_task_schedules.py b/backend/tests/test_scheduled_task_schedules.py index b02da11fa4..7c607f5ee4 100644 --- a/backend/tests/test_scheduled_task_schedules.py +++ b/backend/tests/test_scheduled_task_schedules.py @@ -74,6 +74,85 @@ def test_next_run_at_for_cron_uses_timezone(): assert result == datetime(2026, 7, 1, 1, 0, tzinfo=UTC) +def test_next_run_at_for_cron_skips_duplicate_fixed_hour_run_on_dst_fallback(): + """A fixed-hour task runs once per day on DST fall-back (Vixie cron contract).""" + # Europe/Berlin fall-back: 2026-10-25 03:00 CEST turns back to 02:00 CET. + # 02:30 is ambiguous and occurs twice. + now = datetime(2026, 10, 24, 12, 0, tzinfo=UTC) + first = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=now) + assert first == datetime(2026, 10, 25, 0, 30, tzinfo=UTC) # 02:30 CEST (+02:00) + + second = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=first) + assert second == datetime(2026, 10, 26, 1, 30, tzinfo=UTC) # 02:30 CET (+01:00) next day + + # America/New_York fall-back: 2026-11-01 02:00 EDT turns back to 01:00 EST. + # 01:30 is ambiguous and occurs twice. + ny_now = datetime(2026, 10, 31, 12, 0, tzinfo=UTC) + ny_first = next_run_at("cron", {"cron": "30 1 * * *"}, "America/New_York", now=ny_now) + assert ny_first == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # 01:30 EDT (-04:00) + + ny_second = next_run_at("cron", {"cron": "30 1 * * *"}, "America/New_York", now=ny_first) + assert ny_second == datetime(2026, 11, 2, 6, 30, tzinfo=UTC) # 01:30 EST (-05:00) next day + + # Australia/Sydney fall-back (Southern Hemisphere): 2027-04-04 03:00 AEDT (+11:00) turns back to 02:00 AEST (+10:00). + # 02:30 is ambiguous and occurs twice. + syd_now = datetime(2027, 4, 3, 12, 0, tzinfo=UTC) + syd_first = next_run_at("cron", {"cron": "30 2 * * *"}, "Australia/Sydney", now=syd_now) + assert syd_first == datetime(2027, 4, 3, 15, 30, tzinfo=UTC) # 02:30 AEDT (+11:00) + + syd_second = next_run_at("cron", {"cron": "30 2 * * *"}, "Australia/Sydney", now=syd_first) + assert syd_second == datetime(2027, 4, 4, 16, 30, tzinfo=UTC) # 02:30 AEST (+10:00) next day + + +def test_next_run_at_for_cron_spring_forward_does_not_skip_day(): + """Europe/Berlin spring-forward: 2027-03-28 02:00 CET jumps to 03:00 CEST. + 02:30 does not exist and resolves to 03:00 with fold=0; must not be skipped.""" + b_now = datetime(2027, 3, 27, 13, 0, tzinfo=UTC) + first = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=b_now) + assert first == datetime(2027, 3, 28, 1, 0, tzinfo=UTC) # 03:00 CEST (+02:00) + + second = next_run_at("cron", {"cron": "30 2 * * *"}, "Europe/Berlin", now=first) + assert second == datetime(2027, 3, 29, 0, 30, tzinfo=UTC) # 02:30 CEST next day + + +def test_next_run_at_for_cron_wildcards_fire_in_repeated_dst_hour(): + """Wildcard schedules (e.g. hourly) fire in both occurrences of the repeated hour.""" + now = datetime(2026, 10, 24, 23, 0, tzinfo=UTC) + # 02:00 CEST (+02:00) -> 00:00 UTC + dt1 = next_run_at("cron", {"cron": "0 * * * *"}, "Europe/Berlin", now=now) + assert dt1 == datetime(2026, 10, 25, 0, 0, tzinfo=UTC) + + # 02:00 CET (+01:00) -> 01:00 UTC + dt2 = next_run_at("cron", {"cron": "0 * * * *"}, "Europe/Berlin", now=dt1) + assert dt2 == datetime(2026, 10, 25, 1, 0, tzinfo=UTC) + + # 03:00 CET (+01:00) -> 02:00 UTC + dt3 = next_run_at("cron", {"cron": "0 * * * *"}, "Europe/Berlin", now=dt2) + assert dt3 == datetime(2026, 10, 25, 2, 0, tzinfo=UTC) + + # Step-minute schedule (*/15 2 * * *) fires in both occurrences of the repeated hour + now_step = datetime(2026, 10, 24, 23, 45, tzinfo=UTC) + step_first = next_run_at("cron", {"cron": "*/15 2 * * *"}, "Europe/Berlin", now=now_step) + assert step_first == datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST + + # Hour range schedule (0 0-23 * * *) is semantically hourly and fires in both occurrences + range_dt1 = next_run_at("cron", {"cron": "0 0-23 * * *"}, "Europe/Berlin", now=now) + assert range_dt1 == datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST (+02:00) + + range_dt2 = next_run_at("cron", {"cron": "0 0-23 * * *"}, "Europe/Berlin", now=range_dt1) + assert range_dt2 == datetime(2026, 10, 25, 1, 0, tzinfo=UTC) # 02:00 CET (+01:00) + + range_dt3 = next_run_at("cron", {"cron": "0 0-23 * * *"}, "Europe/Berlin", now=range_dt2) + assert range_dt3 == datetime(2026, 10, 25, 2, 0, tzinfo=UTC) # 03:00 CET (+01:00) + + # Hour list schedule (0 1,2,3 * * *) also fires in both occurrences of hour 2 + list_dt1 = next_run_at("cron", {"cron": "0 1,2,3 * * *"}, "Europe/Berlin", now=now) + assert list_dt1 == datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST + + list_dt2 = next_run_at("cron", {"cron": "0 1,2,3 * * *"}, "Europe/Berlin", now=list_dt1) + assert list_dt2 == datetime(2026, 10, 25, 1, 0, tzinfo=UTC) # 02:00 CET + + def test_next_run_at_for_interval_adds_seconds_in_utc(): now = datetime(2026, 7, 1, 0, 0, tzinfo=UTC) result = next_run_at(