Skip to content

Commit 4b0367e

Browse files
authored
Merge pull request #149 from spacedriveapp/feat/cron-timezone-reliability
Fix cron timezone resolution and delete drift
2 parents fa635b3 + 63aa38e commit 4b0367e

9 files changed

Lines changed: 281 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ uuid = { version = "1.15", features = ["v4", "serde"] }
6565

6666
# Time handling
6767
chrono = { version = "0.4", features = ["serde"] }
68+
chrono-tz = "0.10"
6869

6970
# Regular expressions (for leak detection)
7071
regex = "1.11"

docs/content/docs/(configuration)/config.mdx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ max_turns = 5 # max LLM turns per channel message
5959
context_window = 128000 # context window size in tokens
6060
history_backfill_count = 50 # messages to fetch from platform on new channel
6161
worker_log_mode = "errors_only" # "errors_only", "all_separate", or "all_combined"
62+
cron_timezone = "UTC" # optional default timezone for cron active hours
6263

6364
# Model routing per process type.
6465
[defaults.routing]
@@ -105,6 +106,7 @@ screenshot_dir = "/path/to/screenshots" # optional, defaults to data_dir/screens
105106
id = "main"
106107
default = true
107108
workspace = "/custom/workspace/path" # optional, defaults to ~/.spacebot/agents/{id}/workspace
109+
cron_timezone = "America/Los_Angeles" # optional per-agent cron timezone override
108110

109111
# Per-agent routing overrides (merges with defaults).
110112
[agents.routing]
@@ -180,7 +182,7 @@ This creates a single "main" agent with default settings.
180182
For any setting, the resolution chain is:
181183

182184
```
183-
agent-level override > [defaults] section > hardcoded default
185+
agent-level override > [defaults] section > env var fallback (if supported) > hardcoded default
184186
```
185187

186188
An agent with no overrides inherits everything from `[defaults]`. An agent with partial overrides gets those values from its own config and everything else from defaults. See [Agents](/docs/agents) for how agent config merging works.
@@ -394,6 +396,7 @@ At least one provider (legacy key or custom provider) must be configured.
394396
| `context_window` | integer | 128000 | Context window size in tokens |
395397
| `history_backfill_count` | integer | 50 | Messages to fetch from platform on new channel |
396398
| `worker_log_mode` | string | `"errors_only"` | Worker log persistence: `"errors_only"`, `"all_separate"`, or `"all_combined"` |
399+
| `cron_timezone` | string | None | Default timezone for cron active-hours evaluation (IANA name like `UTC` or `America/New_York`) |
397400

398401
### `[defaults.routing]`
399402

@@ -481,6 +484,7 @@ Thresholds are fractions of `context_window`.
481484
| `id` | string | **required** | Agent identifier |
482485
| `default` | bool | false | Whether this is the default agent |
483486
| `workspace` | string | `~/.spacebot/agents/{id}/workspace` | Custom workspace path |
487+
| `cron_timezone` | string | inherits | Per-agent timezone override for cron active-hours evaluation |
484488
| `max_concurrent_branches` | integer | inherits | Override instance default |
485489
| `max_turns` | integer | inherits | Override instance default |
486490
| `context_window` | integer | inherits | Override instance default |
@@ -499,6 +503,15 @@ Agent-specific routing is set via `[agents.routing]` with the same keys as `[def
499503
| `active_end_hour` | integer | None | End of active hours window |
500504
| `enabled` | bool | true | Whether this cron job is active |
501505

506+
Cron timezone precedence is:
507+
508+
1. `agents.cron_timezone`
509+
2. `defaults.cron_timezone`
510+
3. `SPACEBOT_CRON_TIMEZONE`
511+
4. server local timezone
512+
513+
If a configured timezone is invalid, Spacebot logs a warning and falls back to server local time.
514+
502515
### `[messaging.discord]`
503516

504517
| Key | Type | Default | Description |

docs/content/docs/(features)/cron.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,21 @@ Any code with access to `CronStore` and `Scheduler` can create cron jobs. The co
149149

150150
## Active Hours
151151

152-
The active window uses 24-hour local time. If `active_start_hour` and `active_end_hour` are both set, the cron job only fires within that window.
152+
The active window uses a resolved timezone for each agent:
153+
154+
1. `agents.cron_timezone`
155+
2. `defaults.cron_timezone`
156+
3. `SPACEBOT_CRON_TIMEZONE`
157+
4. server local timezone
158+
159+
If `active_start_hour` and `active_end_hour` are both set, the cron job only fires within that window.
153160

154161
Midnight wrapping is handled: a window of `22:00-06:00` means "10pm to 6am" — the cron job fires if the current hour is >= 22 or < 6.
155162

156163
If active hours are not set, the cron job runs at all hours.
157164

165+
If a configured timezone is invalid, Spacebot logs a warning and falls back to server local timezone.
166+
158167
Active hours don't affect the timer interval — the timer still ticks at `interval_secs`. When a tick lands outside the active window, it's skipped. The next tick happens at the normal interval, not "as soon as the window opens."
159168

160169
## Circuit Breaker

src/api/agents.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ pub(super) async fn create_agent(
296296
browser: None,
297297
mcp: None,
298298
brave_search_key: None,
299+
cron_timezone: None,
299300
cron: Vec::new(),
300301
};
301302
let agent_config = raw_config.resolve(&instance_dir, defaults);

src/api/cron.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ struct CronJobWithStats {
8989
#[derive(Serialize)]
9090
pub(super) struct CronListResponse {
9191
jobs: Vec<CronJobWithStats>,
92+
timezone: String,
9293
}
9394

9495
#[derive(Serialize)]
@@ -108,7 +109,11 @@ pub(super) async fn list_cron_jobs(
108109
Query(query): Query<CronQuery>,
109110
) -> Result<Json<CronListResponse>, StatusCode> {
110111
let stores = state.cron_stores.load();
112+
let schedulers = state.cron_schedulers.load();
111113
let store = stores.get(&query.agent_id).ok_or(StatusCode::NOT_FOUND)?;
114+
let scheduler = schedulers
115+
.get(&query.agent_id)
116+
.ok_or(StatusCode::NOT_FOUND)?;
112117

113118
let configs = store.load_all_unfiltered().await.map_err(|error| {
114119
tracing::warn!(%error, agent_id = %query.agent_id, "failed to load cron jobs");
@@ -137,7 +142,10 @@ pub(super) async fn list_cron_jobs(
137142
});
138143
}
139144

140-
Ok(Json(CronListResponse { jobs }))
145+
Ok(Json(CronListResponse {
146+
jobs,
147+
timezone: scheduler.cron_timezone_label(),
148+
}))
141149
}
142150

143151
/// Get execution history for cron jobs.

0 commit comments

Comments
 (0)