From a8d3d2d5ec139900cdb2e2d97a1594d7756131af Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 2 Jul 2026 17:31:21 +0800 Subject: [PATCH] fix(scheduler): default scheduled job timezone to system timezone Replace hardcoded Asia/Shanghai with host IANA timezone detection for new scheduled jobs across tools, admin API, gateway cron, and store layer. Co-authored-by: Cursor --- interfaces/admin/routes.py | 3 +- runtime/scheduler/cron_service.py | 3 +- runtime/scheduler/expressions.py | 8 +- runtime/scheduler/system_timezone.py | 200 ++++++++++++++++++ .../experts/productivity/schedule_tools.py | 5 +- svc/persistence/assistant_store_protocol.py | 2 +- svc/persistence/scheduled_job_store.py | 7 +- tests/test_scheduler_system_timezone.py | 39 ++++ 8 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 runtime/scheduler/system_timezone.py create mode 100644 tests/test_scheduler_system_timezone.py diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 1515f443..00d3644b 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -2360,6 +2360,7 @@ def build_admin_router() -> APIRouter: ) -> dict[str, Any]: from runtime.scheduler.cron_service import build_default_delivery from runtime.scheduler.expressions import normalize_schedule_kind + from runtime.scheduler.system_timezone import default_system_timezone store = get_assistant_store() ctx = _resolve_auth(store, authorization) @@ -2381,7 +2382,7 @@ def build_admin_router() -> APIRouter: prompt_text=prompt_text, schedule_kind=schedule_kind, schedule_expr=schedule_expr, - timezone_name=str(payload.get("timezone") or "Asia/Shanghai"), + timezone_name=str(payload.get("timezone") or default_system_timezone()), description=str(payload.get("description") or ""), interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"), specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"), diff --git a/runtime/scheduler/cron_service.py b/runtime/scheduler/cron_service.py index b7083501..ddf27246 100644 --- a/runtime/scheduler/cron_service.py +++ b/runtime/scheduler/cron_service.py @@ -4,6 +4,7 @@ import json from typing import Any from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind +from runtime.scheduler.system_timezone import default_system_timezone from runtime.scheduler.service import run_scheduled_job_now from runtime.scheduler.session_resolver import parse_delivery_json, resolve_weixin_binding @@ -49,7 +50,7 @@ class CronService: prompt_text=prompt, schedule_kind=schedule_kind, schedule_expr=schedule, - timezone_name=str(p.get("timezone") or "Asia/Shanghai"), + timezone_name=str(p.get("timezone") or default_system_timezone()), description=str(p.get("description") or ""), interaction_mode=str(p.get("interaction_mode") or "expert"), specialist=str(p.get("specialist") or "generalist"), diff --git a/runtime/scheduler/expressions.py b/runtime/scheduler/expressions.py index 5bd81e82..b6bb216e 100644 --- a/runtime/scheduler/expressions.py +++ b/runtime/scheduler/expressions.py @@ -4,6 +4,8 @@ from datetime import datetime, timedelta, timezone from typing import Any from zoneinfo import ZoneInfo +from runtime.scheduler.system_timezone import default_system_timezone + try: from croniter import croniter except ImportError: # pragma: no cover - guarded in requirements @@ -33,7 +35,7 @@ def compute_next_run_at( *, schedule_kind: str, schedule_expr: str, - timezone_name: str = "Asia/Shanghai", + timezone_name: str | None = None, from_dt: datetime | None = None, ) -> str | None: kind = normalize_schedule_kind(schedule_kind) @@ -63,9 +65,9 @@ def compute_next_run_at( if croniter is None: raise RuntimeError("croniter is required for cron schedules") try: - tz = ZoneInfo(str(timezone_name or "Asia/Shanghai")) + tz = ZoneInfo(str(timezone_name or default_system_timezone())) except Exception: - tz = ZoneInfo("Asia/Shanghai") + tz = ZoneInfo(default_system_timezone()) local_base = base.astimezone(tz) itr = croniter(expr, local_base) nxt_local = itr.get_next(datetime) diff --git a/runtime/scheduler/system_timezone.py b/runtime/scheduler/system_timezone.py new file mode 100644 index 00000000..f98045e2 --- /dev/null +++ b/runtime/scheduler/system_timezone.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone +from functools import lru_cache +from zoneinfo import ZoneInfo, available_timezones + +# Windows registry TimeZoneKeyName -> IANA (common subset; unmapped names fall back to offset). +_WINDOWS_IANA_TZ: dict[str, str] = { + "Afghanistan Standard Time": "Asia/Kabul", + "Alaskan Standard Time": "America/Anchorage", + "Arab Standard Time": "Asia/Riyadh", + "Arabian Standard Time": "Asia/Dubai", + "Arabic Standard Time": "Asia/Baghdad", + "Argentina Standard Time": "America/Buenos_Aires", + "AUS Central Standard Time": "Australia/Darwin", + "AUS Eastern Standard Time": "Australia/Sydney", + "Azores Standard Time": "Atlantic/Azores", + "Bangladesh Standard Time": "Asia/Dhaka", + "Canada Central Standard Time": "America/Regina", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "Caucasus Standard Time": "Asia/Yerevan", + "Cen. Australia Standard Time": "Australia/Adelaide", + "Central America Standard Time": "America/Guatemala", + "Central Asia Standard Time": "Asia/Almaty", + "Central Brazilian Standard Time": "America/Cuiaba", + "Central Europe Standard Time": "Europe/Budapest", + "Central European Standard Time": "Europe/Warsaw", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Central Standard Time": "America/Chicago", + "Central Standard Time (Mexico)": "America/Mexico_City", + "China Standard Time": "Asia/Shanghai", + "Dateline Standard Time": "Etc/GMT+12", + "E. Africa Standard Time": "Africa/Nairobi", + "E. Australia Standard Time": "Australia/Brisbane", + "E. Europe Standard Time": "Europe/Chisinau", + "E. South America Standard Time": "America/Sao_Paulo", + "Eastern Standard Time": "America/New_York", + "Egypt Standard Time": "Africa/Cairo", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "Fiji Standard Time": "Pacific/Fiji", + "FLE Standard Time": "Europe/Kiev", + "Georgian Standard Time": "Asia/Tbilisi", + "GMT Standard Time": "Europe/London", + "Greenland Standard Time": "America/Godthab", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "GTB Standard Time": "Europe/Bucharest", + "Hawaiian Standard Time": "Pacific/Honolulu", + "India Standard Time": "Asia/Kolkata", + "Iran Standard Time": "Asia/Tehran", + "Israel Standard Time": "Asia/Jerusalem", + "Jordan Standard Time": "Asia/Amman", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Korea Standard Time": "Asia/Seoul", + "Libya Standard Time": "Africa/Tripoli", + "Line Islands Standard Time": "Pacific/Kiritimati", + "Magadan Standard Time": "Asia/Magadan", + "Mauritius Standard Time": "Indian/Mauritius", + "Mid-Atlantic Standard Time": "Etc/GMT+2", + "Middle East Standard Time": "Asia/Beirut", + "Montevideo Standard Time": "America/Montevideo", + "Morocco Standard Time": "Africa/Casablanca", + "Mountain Standard Time": "America/Denver", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Myanmar Standard Time": "Asia/Yangon", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Namibia Standard Time": "Africa/Windhoek", + "Nepal Standard Time": "Asia/Kathmandu", + "New Zealand Standard Time": "Pacific/Auckland", + "Newfoundland Standard Time": "America/St_Johns", + "North Asia East Standard Time": "Asia/Ulaanbaatar", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "Pacific SA Standard Time": "America/Santiago", + "Pacific Standard Time": "America/Los_Angeles", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "Pakistan Standard Time": "Asia/Karachi", + "Paraguay Standard Time": "America/Asuncion", + "Romance Standard Time": "Europe/Paris", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Russia Time Zone 11": "Asia/Kamchatka", + "Russia Time Zone 3": "Europe/Samara", + "Russian Standard Time": "Europe/Moscow", + "SA Eastern Standard Time": "America/Cayenne", + "SA Pacific Standard Time": "America/Bogota", + "SA Western Standard Time": "America/La_Paz", + "SE Asia Standard Time": "Asia/Bangkok", + "Singapore Standard Time": "Asia/Singapore", + "South Africa Standard Time": "Africa/Johannesburg", + "Sri Lanka Standard Time": "Asia/Colombo", + "Syria Standard Time": "Asia/Damascus", + "Taipei Standard Time": "Asia/Taipei", + "Tasmania Standard Time": "Australia/Hobart", + "Tokyo Standard Time": "Asia/Tokyo", + "Tonga Standard Time": "Pacific/Tongatapu", + "Turkey Standard Time": "Europe/Istanbul", + "US Eastern Standard Time": "America/Indianapolis", + "US Mountain Standard Time": "America/Phoenix", + "UTC": "UTC", + "UTC+12": "Etc/GMT-12", + "UTC-02": "Etc/GMT+2", + "UTC-11": "Etc/GMT+11", + "Venezuela Standard Time": "America/Caracas", + "Vladivostok Standard Time": "Asia/Vladivostok", + "W. Australia Standard Time": "Australia/Perth", + "W. Central Africa Standard Time": "Africa/Lagos", + "W. Europe Standard Time": "Europe/Berlin", + "West Asia Standard Time": "Asia/Tashkent", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Yakutsk Standard Time": "Asia/Yakutsk", +} + + +def _validate_iana(name: str) -> str | None: + tz = str(name or "").strip() + if not tz: + return None + try: + ZoneInfo(tz) + except Exception: + return None + if tz not in available_timezones() and tz != "UTC": + # Allow Etc/* aliases even if not enumerated on some platforms. + if not tz.startswith("Etc/"): + return None + return tz + + +def _offset_fallback_timezone() -> str: + local = datetime.now().astimezone() + offset = local.utcoffset() + if offset is None: + return "UTC" + hours = int(offset.total_seconds() // 3600) + if hours == 0: + return "UTC" + # Etc/GMT sign is inverted: Etc/GMT-8 == UTC+8. + sign = "-" if hours > 0 else "+" + return f"Etc/GMT{sign}{abs(hours)}" + + +def _windows_system_timezone() -> str | None: + try: + import winreg + except ImportError: + return None + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation", + ) as key: + win_name = str(winreg.QueryValueEx(key, "TimeZoneKeyName")[0] or "").strip() + except Exception: + return None + mapped = _WINDOWS_IANA_TZ.get(win_name) + if mapped: + return _validate_iana(mapped) + return None + + +def _unix_system_timezone() -> str | None: + try: + local_tz = datetime.now().astimezone().tzinfo + key = getattr(local_tz, "key", None) + if isinstance(key, str): + return _validate_iana(key) + except Exception: + return None + return None + + +@lru_cache(maxsize=1) +def default_system_timezone() -> str: + """Return the host IANA timezone used as the scheduler default.""" + for candidate in ( + os.environ.get("AIA_SCHEDULER_DEFAULT_TIMEZONE"), + os.environ.get("TZ"), + ): + validated = _validate_iana(str(candidate or "")) + if validated: + return validated + + if sys.platform == "win32": + win_tz = _windows_system_timezone() + if win_tz: + return win_tz + else: + unix_tz = _unix_system_timezone() + if unix_tz: + return unix_tz + + offset_tz = _validate_iana(_offset_fallback_timezone()) + return offset_tz or "UTC" + + +def reset_default_system_timezone_cache() -> None: + default_system_timezone.cache_clear() + + +__all__ = ["default_system_timezone", "reset_default_system_timezone_cache"] diff --git a/runtime/tools/experts/productivity/schedule_tools.py b/runtime/tools/experts/productivity/schedule_tools.py index b13f420a..4d66e7c7 100644 --- a/runtime/tools/experts/productivity/schedule_tools.py +++ b/runtime/tools/experts/productivity/schedule_tools.py @@ -5,6 +5,7 @@ from typing import Any from runtime.scheduler.cron_service import build_delivery_for_session from runtime.scheduler.expressions import normalize_schedule_kind +from runtime.scheduler.system_timezone import default_system_timezone from runtime.scheduler.service import run_scheduled_job_now from runtime.types import normalize_interaction_mode, normalize_requested_specialist from svc.persistence.assistant_store import get_assistant_store @@ -72,7 +73,7 @@ def schedule_create_tool() -> ToolSpec: prompt_text=prompt_text, schedule_kind=schedule_kind, schedule_expr=schedule_expr, - timezone_name=str(args.get("timezone") or "Asia/Shanghai"), + timezone_name=str(args.get("timezone") or default_system_timezone()), description=str(args.get("description") or ""), interaction_mode=interaction_mode, specialist=specialist, @@ -99,7 +100,7 @@ def schedule_create_tool() -> ToolSpec: "prompt_text": {"type": "string"}, "schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]}, "schedule_expr": {"type": "string"}, - "timezone": {"type": "string", "default": "Asia/Shanghai"}, + "timezone": {"type": "string", "description": "IANA timezone; defaults to the host system timezone."}, "interaction_mode": {"type": "string"}, "specialist": {"type": "string"}, "selected_specialist": {"type": "string"}, diff --git a/svc/persistence/assistant_store_protocol.py b/svc/persistence/assistant_store_protocol.py index 1dbadc03..51190201 100644 --- a/svc/persistence/assistant_store_protocol.py +++ b/svc/persistence/assistant_store_protocol.py @@ -170,7 +170,7 @@ class AssistantStoreProtocol(Protocol): def todo_create(self, *, tenant_id: 'str', owner_user_id: 'str', title: 'str', due_at: 'str | None' = None, assignee_user_id: 'str | None' = None) -> 'dict[str, Any]': ... def todo_list(self, *, tenant_id: 'str', assignee_user_id: 'str | None' = None, status: 'str | None' = 'open', limit: 'int' = 50) -> 'list[dict[str, Any]]': ... def todo_set_status(self, *, tenant_id: 'str', todo_id: 'str', status: 'str') -> 'bool': ... - def scheduled_job_create(self, *, tenant_id: 'str', name: 'str', prompt_text: 'str', schedule_kind: 'str', schedule_expr: 'str', timezone_name: 'str' = 'Asia/Shanghai', description: 'str' = '', interaction_mode: 'str' = 'expert', specialist: 'str' = 'generalist', lang: 'str' = 'zh', delivery: 'dict[str, Any] | None' = None, source_session_id: 'str | None' = None, created_by_user_id: 'str' = '', source: 'str' = 'admin', status: 'str' = 'active') -> 'Any': ... + def scheduled_job_create(self, *, tenant_id: 'str', name: 'str', prompt_text: 'str', schedule_kind: 'str', schedule_expr: 'str', timezone_name: 'str | None' = None, description: 'str' = '', interaction_mode: 'str' = 'expert', specialist: 'str' = 'generalist', lang: 'str' = 'zh', delivery: 'dict[str, Any] | None' = None, source_session_id: 'str | None' = None, created_by_user_id: 'str' = '', source: 'str' = 'admin', status: 'str' = 'active') -> 'Any': ... def scheduled_job_get(self, *, job_id: 'str', tenant_id: 'str | None' = None) -> 'Any | None': ... def scheduled_job_list(self, *, tenant_id: 'str', status: 'str | None' = None, limit: 'int' = 100, offset: 'int' = 0) -> 'list[Any]': ... def scheduled_job_update(self, *, tenant_id: 'str', job_id: 'str', patch: 'dict[str, Any]') -> 'Any | None': ... diff --git a/svc/persistence/scheduled_job_store.py b/svc/persistence/scheduled_job_store.py index 3c36a2be..52d93775 100644 --- a/svc/persistence/scheduled_job_store.py +++ b/svc/persistence/scheduled_job_store.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone from typing import Any from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind +from runtime.scheduler.system_timezone import default_system_timezone SCHEDULED_JOB_DDL = """ CREATE TABLE IF NOT EXISTS scheduled_job ( @@ -121,7 +122,7 @@ def _row_to_job(row: Any) -> ScheduledJob: status=str(row["status"] or ""), schedule_kind=str(row["schedule_kind"] or ""), schedule_expr=str(row["schedule_expr"] or ""), - timezone=str(row["timezone"] or "Asia/Shanghai"), + timezone=str(row["timezone"] or default_system_timezone()), prompt_text=str(row["prompt_text"] or ""), interaction_mode=str(row["interaction_mode"] or "expert"), specialist=str(row["specialist"] or "generalist"), @@ -180,7 +181,7 @@ class ScheduledJobStoreMixin: prompt_text: str, schedule_kind: str, schedule_expr: str, - timezone_name: str = "Asia/Shanghai", + timezone_name: str | None = None, description: str = "", interaction_mode: str = "expert", specialist: str = "generalist", @@ -194,7 +195,7 @@ class ScheduledJobStoreMixin: jid = str(uuid.uuid4()) ts = utc_now_iso() kind = normalize_schedule_kind(schedule_kind) - tz = str(timezone_name or "Asia/Shanghai").strip() or "Asia/Shanghai" + tz = str(timezone_name or default_system_timezone()).strip() or default_system_timezone() next_run = compute_next_run_at( schedule_kind=kind, schedule_expr=str(schedule_expr or "").strip(), diff --git a/tests/test_scheduler_system_timezone.py b/tests/test_scheduler_system_timezone.py new file mode 100644 index 00000000..2747dbb3 --- /dev/null +++ b/tests/test_scheduler_system_timezone.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from runtime.scheduler.system_timezone import ( + default_system_timezone, + reset_default_system_timezone_cache, +) +from zoneinfo import ZoneInfo + + +class SchedulerSystemTimezoneTests(unittest.TestCase): + def tearDown(self) -> None: + reset_default_system_timezone_cache() + os.environ.pop("AIA_SCHEDULER_DEFAULT_TIMEZONE", None) + os.environ.pop("TZ", None) + + def test_env_override_wins(self) -> None: + os.environ["AIA_SCHEDULER_DEFAULT_TIMEZONE"] = "Europe/Berlin" + reset_default_system_timezone_cache() + self.assertEqual(default_system_timezone(), "Europe/Berlin") + + def test_windows_registry_maps_to_iana(self) -> None: + with patch("runtime.scheduler.system_timezone.sys.platform", "win32"), patch( + "runtime.scheduler.system_timezone._windows_system_timezone", + return_value="Asia/Jakarta", + ): + reset_default_system_timezone_cache() + self.assertEqual(default_system_timezone(), "Asia/Jakarta") + + def test_default_is_valid_iana(self) -> None: + tz = default_system_timezone() + ZoneInfo(tz) + + +if __name__ == "__main__": + unittest.main()