mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
a27bca5648
commit
a8d3d2d5ec
8 changed files with 256 additions and 11 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
200
runtime/scheduler/system_timezone.py
Normal file
200
runtime/scheduler/system_timezone.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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': ...
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
39
tests/test_scheduler_system_timezone.py
Normal file
39
tests/test_scheduler_system_timezone.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue