Skip to content

Commit 167f6d4

Browse files
dcramerclaude
andcommitted
fix(schedule): Default timezone to system time, store per-entry
The schedule system was defaulting to UTC even when users specified times in their local timezone (e.g., "7:45am PST"). This caused tasks to trigger at the wrong time or appear to never run. 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 so entries remember what timezone they were created in - Move schedule.jsonl to ~/.ash/schedule.jsonl (root of ash home) - 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 167f6d4

12 files changed

Lines changed: 266 additions & 57 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: 19 additions & 5 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
@@ -160,6 +169,9 @@ def to_json_line(self) -> str:
160169
if self.last_run:
161170
data["last_run"] = self.last_run.isoformat()
162171

172+
if self.timezone:
173+
data["timezone"] = self.timezone
174+
163175
# Context fields
164176
if self.chat_id:
165177
data["chat_id"] = self.chat_id
@@ -208,6 +220,7 @@ def parse_datetime(key: str) -> datetime | None:
208220
"trigger_at",
209221
"cron",
210222
"last_run",
223+
"timezone",
211224
"chat_id",
212225
"chat_title",
213226
"user_id",
@@ -223,6 +236,7 @@ def parse_datetime(key: str) -> datetime | None:
223236
trigger_at=trigger_at,
224237
cron=cron,
225238
last_run=last_run,
239+
timezone=data.get("timezone"),
226240
chat_id=data.get("chat_id"),
227241
chat_title=data.get("chat_title"),
228242
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,

tests/test_cli.py

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -254,49 +254,27 @@ def test_schedule_list_empty(self, cli_runner, monkeypatch, tmp_path):
254254
assert "No scheduled tasks" in result.stdout
255255

256256
def test_schedule_list_with_entries(self, cli_runner, monkeypatch, tmp_path):
257-
from ash.config import models
258-
259-
workspace = tmp_path / "workspace"
260-
workspace.mkdir()
261-
schedule_file = workspace / "schedule.jsonl"
257+
schedule_file = tmp_path / "schedule.jsonl"
262258
schedule_file.write_text(
263259
'{"trigger_at": "2026-01-12T09:00:00+00:00", "message": "Test task"}\n'
264260
)
265261
monkeypatch.setattr(
266-
"ash.config.load_config",
267-
lambda: models.AshConfig(
268-
models={
269-
"default": models.ModelConfig(
270-
provider="anthropic", model="claude-3-sonnet"
271-
)
272-
},
273-
workspace=workspace,
274-
),
262+
"ash.config.paths.get_schedule_file",
263+
lambda: schedule_file,
275264
)
276265

277266
result = cli_runner.invoke(app, ["schedule", "list"])
278267
assert result.exit_code == 0
279268
assert "Test task" in result.stdout
280269

281270
def test_schedule_cancel_success(self, cli_runner, monkeypatch, tmp_path):
282-
from ash.config import models
283-
284-
workspace = tmp_path / "workspace"
285-
workspace.mkdir()
286-
schedule_file = workspace / "schedule.jsonl"
271+
schedule_file = tmp_path / "schedule.jsonl"
287272
schedule_file.write_text(
288273
'{"id": "abc12345", "trigger_at": "2026-01-12T09:00:00+00:00", "message": "Task to cancel"}\n'
289274
)
290275
monkeypatch.setattr(
291-
"ash.config.load_config",
292-
lambda: models.AshConfig(
293-
models={
294-
"default": models.ModelConfig(
295-
provider="anthropic", model="claude-3-sonnet"
296-
)
297-
},
298-
workspace=workspace,
299-
),
276+
"ash.config.paths.get_schedule_file",
277+
lambda: schedule_file,
300278
)
301279

302280
result = cli_runner.invoke(app, ["schedule", "cancel", "--id", "abc12345"])
@@ -307,25 +285,14 @@ def test_schedule_cancel_success(self, cli_runner, monkeypatch, tmp_path):
307285
assert schedule_file.read_text().strip() == ""
308286

309287
def test_schedule_clear_with_force(self, cli_runner, monkeypatch, tmp_path):
310-
from ash.config import models
311-
312-
workspace = tmp_path / "workspace"
313-
workspace.mkdir()
314-
schedule_file = workspace / "schedule.jsonl"
288+
schedule_file = tmp_path / "schedule.jsonl"
315289
schedule_file.write_text(
316290
'{"trigger_at": "2026-01-12T09:00:00+00:00", "message": "Task 1"}\n'
317291
'{"trigger_at": "2026-01-13T09:00:00+00:00", "message": "Task 2"}\n'
318292
)
319293
monkeypatch.setattr(
320-
"ash.config.load_config",
321-
lambda: models.AshConfig(
322-
models={
323-
"default": models.ModelConfig(
324-
provider="anthropic", model="claude-3-sonnet"
325-
)
326-
},
327-
workspace=workspace,
328-
),
294+
"ash.config.paths.get_schedule_file",
295+
lambda: schedule_file,
329296
)
330297

331298
result = cli_runner.invoke(app, ["schedule", "clear", "--force"])

0 commit comments

Comments
 (0)