Skip to content

Commit 92945c5

Browse files
dcramerclaude
andcommitted
fix(schedule): Default timezone to system time, evaluate cron in UTC
The schedule system was defaulting to UTC even when users specified times in their local timezone. Also, cron expressions were being evaluated in local time which could cause issues if timezone changes. Changes: - Add get_system_timezone() to detect system timezone from TZ env, /etc/timezone, or /etc/localtime symlink - Change AshConfig.timezone default from "UTC" to system timezone - Add timezone field to ScheduleEntry for display purposes - Always evaluate cron expressions in UTC for consistency - Move schedule.jsonl to ~/.ash/schedule.jsonl - Mount schedule file directly in sandbox at /schedule.jsonl - Improve error message for past times to show parsed value Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e5f6712 commit 92945c5

12 files changed

Lines changed: 281 additions & 74 deletions

File tree

packages/ash-sandbox-cli/src/ash_sandbox_cli/commands/schedule.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
no_args_is_help=True,
1616
)
1717

18-
SCHEDULE_FILE = Path("/workspace/schedule.jsonl")
18+
SCHEDULE_FILE = Path("/schedule.jsonl")
1919

2020

2121
def _get_context() -> dict[str, str]:
@@ -173,7 +173,14 @@ def create(
173173
typer.echo(f"Error: Could not parse time: {at}", err=True)
174174
raise typer.Exit(1)
175175
if trigger_time <= datetime.now(UTC):
176-
typer.echo(f"Error: --at must be in the future. Got: {at}", err=True)
176+
from zoneinfo import ZoneInfo
177+
178+
tz = ZoneInfo(ctx["timezone"])
179+
local_str = trigger_time.astimezone(tz).strftime("%Y-%m-%d %H:%M %Z")
180+
typer.echo(
181+
f"Error: Time '{at}' parsed as {local_str} which is in the past",
182+
err=True,
183+
)
177184
raise typer.Exit(1)
178185

179186
# Validate cron format
@@ -210,6 +217,8 @@ def create(
210217
entry["user_id"] = ctx["user_id"]
211218
if ctx["username"]:
212219
entry["username"] = ctx["username"]
220+
# Store timezone so cron expressions are evaluated in the correct local time
221+
entry["timezone"] = ctx["timezone"]
213222

214223
entry["created_at"] = datetime.now(UTC).isoformat()
215224

src/ash/cli/commands/schedule.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def schedule(
7070
) -> None:
7171
"""Manage scheduled tasks.
7272
73-
Scheduled tasks are stored in workspace/schedule.jsonl.
73+
Scheduled tasks are stored in ~/.ash/schedule.jsonl.
7474
7575
Examples:
7676
ash schedule list # List all scheduled tasks
@@ -82,10 +82,9 @@ def schedule(
8282
click.echo(ctx.get_help())
8383
raise typer.Exit(0)
8484

85-
from ash.config import load_config
85+
from ash.config.paths import get_schedule_file
8686

87-
config = load_config()
88-
schedule_file = config.workspace / "schedule.jsonl"
87+
schedule_file = get_schedule_file()
8988

9089
if action == "list":
9190
_schedule_list(schedule_file)

src/ash/cli/commands/serve.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,10 @@ async def _run_server(
163163
)
164164

165165
# Set up schedule watcher
166+
from ash.config.paths import get_schedule_file
166167
from ash.events import ScheduledTaskHandler, ScheduleWatcher
167168

168-
schedule_file = ash_config.workspace / "schedule.jsonl"
169+
schedule_file = get_schedule_file()
169170
schedule_watcher = ScheduleWatcher(schedule_file, timezone=ash_config.timezone)
170171

171172
# Build sender map from available providers

src/ash/config/models.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
model_validator,
1515
)
1616

17-
from ash.config.paths import get_database_path, get_workspace_path
17+
from ash.config.paths import (
18+
get_database_path,
19+
get_system_timezone,
20+
get_workspace_path,
21+
)
1822

1923
logger = logging.getLogger(__name__)
2024

@@ -264,7 +268,8 @@ class AshConfig(BaseModel):
264268
workspace: Path = Field(default_factory=get_workspace_path)
265269
# User's timezone (IANA timezone name, e.g., "America/New_York")
266270
# Used for displaying times and evaluating cron schedules
267-
timezone: str = "UTC"
271+
# Default: detect from system (TZ env, /etc/timezone, /etc/localtime)
272+
timezone: str = Field(default_factory=get_system_timezone)
268273
# Named model configurations (new style)
269274
models: dict[str, ModelConfig] = Field(default_factory=dict)
270275

src/ash/config/paths.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,43 @@
1515
ENV_VAR = "ASH_HOME"
1616

1717

18+
def get_system_timezone() -> str:
19+
"""Detect system timezone, falling back to UTC.
20+
21+
Resolution order:
22+
1. TZ environment variable (if set)
23+
2. /etc/timezone file (Debian/Ubuntu)
24+
3. /etc/localtime symlink target (most Linux distros)
25+
4. Fallback to UTC
26+
27+
Returns:
28+
IANA timezone name (e.g., "America/Los_Angeles", "Europe/London", "UTC").
29+
"""
30+
# Check TZ environment variable first
31+
if tz := os.environ.get("TZ"):
32+
return tz
33+
34+
# Linux: read /etc/timezone (Debian/Ubuntu)
35+
try:
36+
tz = Path("/etc/timezone").read_text().strip()
37+
if tz:
38+
return tz
39+
except (FileNotFoundError, PermissionError):
40+
pass
41+
42+
# Linux: follow /etc/localtime symlink (most distros)
43+
try:
44+
link = Path("/etc/localtime").resolve()
45+
parts = str(link).split("zoneinfo/")
46+
if len(parts) > 1:
47+
return parts[1]
48+
except (FileNotFoundError, PermissionError):
49+
pass
50+
51+
# Fallback to UTC
52+
return "UTC"
53+
54+
1855
@lru_cache(maxsize=1)
1956
def get_ash_home() -> Path:
2057
"""Get the base directory for all Ash data.
@@ -116,6 +153,11 @@ def get_installed_skills_path() -> Path:
116153
return get_ash_home() / "skills.installed"
117154

118155

156+
def get_schedule_file() -> Path:
157+
"""Get the schedule file path."""
158+
return get_ash_home() / "schedule.jsonl"
159+
160+
119161
def get_uv_cache_path() -> Path:
120162
"""Get the uv package cache directory path for sandbox."""
121163
return get_ash_home() / "cache" / "uv"
@@ -158,6 +200,7 @@ def get_all_paths() -> dict[str, Path]:
158200
"config": get_config_path(),
159201
"database": get_database_path(),
160202
"workspace": get_workspace_path(),
203+
"schedule": get_schedule_file(),
161204
"logs": get_logs_path(),
162205
"run": get_run_path(),
163206
"chats": get_chats_path(),

src/ash/events/schedule.py

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ class ScheduleEntry:
4242
trigger_at: datetime | None = None # One-shot
4343
cron: str | None = None # Periodic
4444
last_run: datetime | None = None # For periodic
45+
# Timezone the entry was created in (IANA name)
46+
# Used for evaluating cron expressions in the correct local time
47+
timezone: str | None = None
4548
# Context for routing response back
4649
chat_id: str | None = None
4750
chat_title: str | None = None # Friendly name for the chat
@@ -61,7 +64,8 @@ def next_fire_time(self, timezone: str = "UTC") -> datetime | None:
6164
"""Get the next fire time for this entry.
6265
6366
Args:
64-
timezone: IANA timezone name for evaluating cron expressions.
67+
timezone: Fallback IANA timezone name for evaluating cron expressions.
68+
If the entry has a stored timezone, that takes precedence.
6569
6670
Returns:
6771
The next fire time in UTC, or None if not schedulable.
@@ -70,18 +74,23 @@ def next_fire_time(self, timezone: str = "UTC") -> datetime | None:
7074
return self.trigger_at
7175

7276
if self.cron:
73-
return self._next_run_time(timezone)
77+
# Use stored timezone if available, otherwise fall back to parameter
78+
tz = self.timezone or timezone
79+
return self._next_run_time(tz)
7480

7581
return None
7682

7783
def is_due(self, timezone: str = "UTC") -> bool:
7884
"""Check if this entry is due for execution.
7985
8086
Args:
81-
timezone: IANA timezone name for evaluating cron expressions.
87+
timezone: Fallback IANA timezone name for evaluating cron expressions.
88+
If the entry has a stored timezone, that takes precedence.
8289
"""
8390
now = datetime.now(UTC)
8491
entry_id = self.id or "?"
92+
# Use stored timezone if available, otherwise fall back to parameter
93+
tz = self.timezone or timezone
8594

8695
if self.trigger_at:
8796
is_due = now >= self.trigger_at
@@ -92,15 +101,15 @@ def is_due(self, timezone: str = "UTC") -> bool:
92101
return is_due
93102

94103
if self.cron:
95-
next_run = self._next_run_time(timezone)
104+
next_run = self._next_run_time(tz)
96105
if next_run is None:
97106
logger.debug(
98107
f"Entry {entry_id}: cron={self.cron}, next_run=None, due=False"
99108
)
100109
return False
101110
is_due = now >= next_run
102111
logger.debug(
103-
f"Entry {entry_id}: cron='{self.cron}' (tz={timezone}), "
112+
f"Entry {entry_id}: cron='{self.cron}' (tz={tz}), "
104113
f"next_run={next_run.isoformat()}, now={now.isoformat()}, due={is_due}"
105114
)
106115
return is_due
@@ -110,33 +119,29 @@ def is_due(self, timezone: str = "UTC") -> bool:
110119
def _next_run_time(self, timezone: str = "UTC") -> datetime | None:
111120
"""Calculate next run time from cron and last_run.
112121
113-
Cron expressions are evaluated in the user's timezone so that
114-
"0 8 * * *" means 8am local time, not 8am UTC.
122+
Cron expressions are always evaluated in UTC for consistency.
123+
This ensures scheduled times don't shift if system timezone changes.
115124
116125
Args:
117-
timezone: IANA timezone name for evaluating cron expressions.
126+
timezone: Unused, kept for API compatibility. Cron always uses UTC.
118127
"""
119128
if not self.cron:
120129
return None
121130
try:
122-
from zoneinfo import ZoneInfo
123-
124131
from croniter import croniter
125132

126-
tz = ZoneInfo(timezone)
127-
133+
# Always use UTC for cron evaluation
128134
if self.last_run:
129-
# Normal case: get next run after last_run
130-
# Convert last_run to user timezone for cron evaluation
131-
base_time = self.last_run.astimezone(tz)
135+
base_time = self.last_run.astimezone(UTC)
132136
else:
133-
# New task: wait for the next scheduled occurrence
134-
base_time = datetime.now(UTC).astimezone(tz)
135-
136-
# croniter evaluates in the timezone of base_time
137-
next_local = croniter(self.cron, base_time).get_next(datetime)
138-
# Convert back to UTC for comparison
139-
return next_local.astimezone(UTC)
137+
base_time = datetime.now(UTC)
138+
139+
# croniter evaluates in UTC
140+
next_utc = croniter(self.cron, base_time).get_next(datetime)
141+
# Ensure it's UTC-aware
142+
if next_utc.tzinfo is None:
143+
next_utc = next_utc.replace(tzinfo=UTC)
144+
return next_utc
140145
except Exception as e:
141146
logger.warning(
142147
f"Failed to parse cron expression '{self.cron}' for entry {self.id}: {e}"
@@ -160,6 +165,9 @@ def to_json_line(self) -> str:
160165
if self.last_run:
161166
data["last_run"] = self.last_run.isoformat()
162167

168+
if self.timezone:
169+
data["timezone"] = self.timezone
170+
163171
# Context fields
164172
if self.chat_id:
165173
data["chat_id"] = self.chat_id
@@ -208,6 +216,7 @@ def parse_datetime(key: str) -> datetime | None:
208216
"trigger_at",
209217
"cron",
210218
"last_run",
219+
"timezone",
211220
"chat_id",
212221
"chat_title",
213222
"user_id",
@@ -223,6 +232,7 @@ def parse_datetime(key: str) -> datetime | None:
223232
trigger_at=trigger_at,
224233
cron=cron,
225234
last_run=last_run,
235+
timezone=data.get("timezone"),
226236
chat_id=data.get("chat_id"),
227237
chat_title=data.get("chat_title"),
228238
user_id=data.get("user_id"),

src/ash/sandbox/manager.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ class SandboxConfig:
8181
# Chats mounting (for agent to read chat state/participants)
8282
chats_path: Path | None = None # Host path to chats directory
8383

84+
# Schedule file mounting (for schedule.jsonl)
85+
schedule_file: Path | None = None # Host path to schedule.jsonl
86+
8487
# Logs mounting (for agent to inspect server logs)
8588
logs_path: Path | None = None # Host path to logs directory
8689

@@ -188,6 +191,15 @@ async def create_container(
188191
"mode": "ro",
189192
}
190193

194+
if self._config.schedule_file:
195+
# Create schedule file if it doesn't exist
196+
self._config.schedule_file.parent.mkdir(parents=True, exist_ok=True)
197+
self._config.schedule_file.touch(exist_ok=True)
198+
volumes[str(self._config.schedule_file)] = {
199+
"bind": "/schedule.jsonl",
200+
"mode": "rw",
201+
}
202+
191203
if self._config.logs_path and self._config.logs_path.exists():
192204
volumes[str(self._config.logs_path)] = {"bind": "/logs", "mode": "ro"}
193205

src/ash/tools/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,15 @@ def build_sandbox_manager_config(
127127
get_chats_path,
128128
get_logs_path,
129129
get_rpc_socket_path,
130+
get_schedule_file,
130131
get_uv_cache_path,
131132
)
132133
from ash.sandbox.manager import SandboxConfig as SandboxManagerConfig
133134
from ash.sessions.manager import get_sessions_path
134135

135136
sessions_path = get_sessions_path()
136137
chats_path = get_chats_path()
138+
schedule_file = get_schedule_file()
137139
logs_path = get_logs_path()
138140
rpc_socket_path = get_rpc_socket_path()
139141
uv_cache_path = get_uv_cache_path()
@@ -144,6 +146,7 @@ def build_sandbox_manager_config(
144146
network_mode=default_network_mode,
145147
sessions_path=sessions_path,
146148
chats_path=chats_path,
149+
schedule_file=schedule_file,
147150
logs_path=logs_path,
148151
rpc_socket_path=rpc_socket_path,
149152
uv_cache_path=uv_cache_path,
@@ -163,6 +166,7 @@ def build_sandbox_manager_config(
163166
sessions_path=sessions_path,
164167
sessions_access=config.sessions_access,
165168
chats_path=chats_path,
169+
schedule_file=schedule_file,
166170
logs_path=logs_path,
167171
rpc_socket_path=rpc_socket_path,
168172
uv_cache_path=uv_cache_path,

0 commit comments

Comments
 (0)