fix(cron): improve timezone scheduling and tz propagation
This commit is contained in:
parent
a219a91bc5
commit
ae903e983c
@ -50,6 +50,10 @@ class CronTool(Tool):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
|
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
|
||||||
},
|
},
|
||||||
|
"tz": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
|
||||||
|
},
|
||||||
"at": {
|
"at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
|
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
|
||||||
@ -68,30 +72,40 @@ class CronTool(Tool):
|
|||||||
message: str = "",
|
message: str = "",
|
||||||
every_seconds: int | None = None,
|
every_seconds: int | None = None,
|
||||||
cron_expr: str | None = None,
|
cron_expr: str | None = None,
|
||||||
|
tz: str | None = None,
|
||||||
at: str | None = None,
|
at: str | None = None,
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> str:
|
) -> str:
|
||||||
if action == "add":
|
if action == "add":
|
||||||
return self._add_job(message, every_seconds, cron_expr, at)
|
return self._add_job(message, every_seconds, cron_expr, tz, at)
|
||||||
elif action == "list":
|
elif action == "list":
|
||||||
return self._list_jobs()
|
return self._list_jobs()
|
||||||
elif action == "remove":
|
elif action == "remove":
|
||||||
return self._remove_job(job_id)
|
return self._remove_job(job_id)
|
||||||
return f"Unknown action: {action}"
|
return f"Unknown action: {action}"
|
||||||
|
|
||||||
def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
|
def _add_job(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
every_seconds: int | None,
|
||||||
|
cron_expr: str | None,
|
||||||
|
tz: str | None,
|
||||||
|
at: str | None,
|
||||||
|
) -> str:
|
||||||
if not message:
|
if not message:
|
||||||
return "Error: message is required for add"
|
return "Error: message is required for add"
|
||||||
if not self._channel or not self._chat_id:
|
if not self._channel or not self._chat_id:
|
||||||
return "Error: no session context (channel/chat_id)"
|
return "Error: no session context (channel/chat_id)"
|
||||||
|
if tz and not cron_expr:
|
||||||
|
return "Error: tz can only be used with cron_expr"
|
||||||
|
|
||||||
# Build schedule
|
# Build schedule
|
||||||
delete_after = False
|
delete_after = False
|
||||||
if every_seconds:
|
if every_seconds:
|
||||||
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
|
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
|
||||||
elif cron_expr:
|
elif cron_expr:
|
||||||
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
|
||||||
elif at:
|
elif at:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
dt = datetime.fromisoformat(at)
|
dt = datetime.fromisoformat(at)
|
||||||
|
|||||||
@ -719,21 +719,32 @@ def cron_list(
|
|||||||
table.add_column("Status")
|
table.add_column("Status")
|
||||||
table.add_column("Next Run")
|
table.add_column("Next Run")
|
||||||
|
|
||||||
|
import datetime
|
||||||
import time
|
import time
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
# Format schedule
|
# Format schedule
|
||||||
if job.schedule.kind == "every":
|
if job.schedule.kind == "every":
|
||||||
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
|
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
|
||||||
elif job.schedule.kind == "cron":
|
elif job.schedule.kind == "cron":
|
||||||
sched = job.schedule.expr or ""
|
sched = job.schedule.expr or ""
|
||||||
|
if job.schedule.tz:
|
||||||
|
sched = f"{sched} ({job.schedule.tz})"
|
||||||
else:
|
else:
|
||||||
sched = "one-time"
|
sched = "one-time"
|
||||||
|
|
||||||
# Format next run
|
# Format next run
|
||||||
next_run = ""
|
next_run = ""
|
||||||
if job.state.next_run_at_ms:
|
if job.state.next_run_at_ms:
|
||||||
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
|
ts = job.state.next_run_at_ms / 1000
|
||||||
next_run = next_time
|
if job.schedule.kind == "cron" and job.schedule.tz:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(ts, ZoneInfo(job.schedule.tz))
|
||||||
|
next_run = dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
except Exception:
|
||||||
|
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
||||||
|
else:
|
||||||
|
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
||||||
|
|
||||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||||
|
|
||||||
@ -748,6 +759,7 @@ def cron_add(
|
|||||||
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
|
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
|
||||||
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
|
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
|
||||||
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
|
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
|
||||||
|
tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"),
|
||||||
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
|
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
|
||||||
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
|
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
|
||||||
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
|
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
|
||||||
@ -758,11 +770,15 @@ def cron_add(
|
|||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
|
|
||||||
|
if tz and not cron_expr:
|
||||||
|
console.print("[red]Error: --tz can only be used with --cron[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Determine schedule type
|
# Determine schedule type
|
||||||
if every:
|
if every:
|
||||||
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
||||||
elif cron_expr:
|
elif cron_expr:
|
||||||
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
|
||||||
elif at:
|
elif at:
|
||||||
import datetime
|
import datetime
|
||||||
dt = datetime.datetime.fromisoformat(at)
|
dt = datetime.datetime.fromisoformat(at)
|
||||||
|
|||||||
@ -32,7 +32,8 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
|
|||||||
try:
|
try:
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
base_time = time.time()
|
# Use the caller-provided reference time for deterministic scheduling.
|
||||||
|
base_time = now_ms / 1000
|
||||||
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
|
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
|
||||||
base_dt = datetime.fromtimestamp(base_time, tz=tz)
|
base_dt = datetime.fromtimestamp(base_time, tz=tz)
|
||||||
cron = croniter(schedule.expr, base_dt)
|
cron = croniter(schedule.expr, base_dt)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user