feat(timezone): add timezone-aware session context (#671)

* feat(timezone): add timezone-aware session context (#661)

All timestamps were UTC-only, causing daily logs to split at UTC midnight,
cron schedules to fire in UTC, and no quiet hours for heartbeat. This adds
timezone as a per-session property flowing from the client.

Key changes:
- New `src/timezone.rs` module with resolution chain, parsing, and detection
- `IncomingMessage` carries optional timezone from client
- `JobContext.user_timezone` flows timezone to tools
- `next_cron_fire()` accepts timezone for schedule evaluation
- `Trigger::Cron` stores optional timezone (backward-compatible)
- Workspace gains `_tz` variants for daily logs and system prompt
- Heartbeat supports quiet hours (`HEARTBEAT_QUIET_START/END`)
- Web frontend sends `Intl.DateTimeFormat().resolvedOptions().timeZone`
- REPL auto-detects system timezone
- `DEFAULT_TIMEZONE` env var / settings for server-wide default

Storage stays UTC. Conversion happens at display boundaries.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): address review feedback on timezone-aware sessions

- Validate quiet hours values (0-23) in HeartbeatConfig::resolve()
- Fall back to settings values when env vars are unset for quiet hours
- Validate IANA timezone strings in routine_create/update with parse_timezone
- Add timezone field to routine_create tool schema
- Allow standalone timezone update on cron routines without changing schedule
- Return path from append_daily_log_tz to avoid TOCTOU race at midnight
- Delegate append_daily_log to append_daily_log_tz(entry, UTC) to avoid drift
- Preserve timezone through approval flow via PendingApproval.user_timezone
- Improve test_today_in_tz to not depend on hardcoded year
- Add 3 regression tests for quiet hours config validation

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix formatting in routine.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): address second round of review feedback

- Remove .claude/scheduled_tasks.lock from repo and add to .gitignore
- Store resolved timezone (not raw message.timezone) in PendingApproval
- Carry forward user_timezone through chained approvals in thread_ops
- Wire quiet_hours_start/end from config to HeartbeatRunner
- Support X-Timezone header as fallback in chat_send_handler

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): include user's local time in time tool response

The time tool's "now" operation now returns local_iso and timezone
fields based on ctx.user_timezone, so the LLM can report time in
the user's timezone instead of always UTC.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix formatting in time.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): address Copilot review round 3 — validation, deterministic tests, schema fixes

- Validate DEFAULT_TIMEZONE and HEARTBEAT_TIMEZONE at config load time
- Add timezone field to HeartbeatSettings and config::HeartbeatConfig
- Wire heartbeat timezone from config through agent_loop to HeartbeatRunner
- Add timezone to routine_update tool schema (was accepted but not advertised)
- Error on schedule/timezone update for non-cron routines
- Validate timezone in Trigger::from_db (coerce invalid to None with warning)
- Validate timezone in approval path (thread_ops.rs) before overwriting
- Time tool always includes timezone/local_iso fields (fallback to UTC)
- Make quiet hours tests deterministic using current UTC hour
- Add regression tests for config validation

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-08 08:01:56 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a20e19ab16
commit df3635d6be
31 changed files with 797 additions and 47 deletions
+5 -4
View File
@@ -172,7 +172,7 @@ impl Tool for MemoryWriteTool {
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
@@ -239,11 +239,12 @@ impl Tool for MemoryWriteTool {
paths::MEMORY.to_string()
}
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
self.workspace
.append_daily_log(content)
.append_daily_log_tz(content, tz)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d"))
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?
}
"heartbeat" => {
if append {
+68 -10
View File
@@ -107,6 +107,10 @@ impl Tool for RoutineCreateTool {
"notify_user": {
"type": "string",
"description": "User/target to notify (e.g. username, chat ID). Defaults to 'default'."
},
"timezone": {
"type": "string",
"description": "IANA timezone for cron schedule evaluation (e.g. 'America/New_York'). Defaults to UTC."
}
},
"required": ["name", "trigger_type", "prompt"]
@@ -143,12 +147,26 @@ impl Tool for RoutineCreateTool {
"cron trigger requires 'schedule'".to_string(),
)
})?;
let timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(|tz| {
crate::timezone::parse_timezone(tz)
.map(|_| tz.to_string())
.ok_or_else(|| {
ToolError::InvalidParameters(format!(
"invalid IANA timezone: '{tz}'"
))
})
})
.transpose()?;
// Validate cron expression
next_cron_fire(schedule).map_err(|e| {
next_cron_fire(schedule, timezone.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
Trigger::Cron {
schedule: schedule.to_string(),
timezone,
}
}
"event" => {
@@ -228,8 +246,12 @@ impl Tool for RoutineCreateTool {
.unwrap_or(300);
// Compute next fire time for cron
let next_fire = if let Trigger::Cron { ref schedule } = trigger {
next_cron_fire(schedule).unwrap_or(None)
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else {
None
};
@@ -412,6 +434,10 @@ impl Tool for RoutineUpdateTool {
"type": "string",
"description": "New cron schedule (for cron triggers)"
},
"timezone": {
"type": "string",
"description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers."
},
"description": {
"type": "string",
"description": "New description"
@@ -453,15 +479,47 @@ impl Tool for RoutineUpdateTool {
}
}
if let Some(schedule) = params.get("schedule").and_then(|v| v.as_str()) {
// Validate
next_cron_fire(schedule)
.map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?;
// Validate timezone param if provided
let new_timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(|tz| {
crate::timezone::parse_timezone(tz)
.map(|_| tz.to_string())
.ok_or_else(|| {
ToolError::InvalidParameters(format!("invalid IANA timezone: '{tz}'"))
})
})
.transpose()?;
routine.trigger = Trigger::Cron {
schedule: schedule.to_string(),
let new_schedule = params.get("schedule").and_then(|v| v.as_str());
if new_schedule.is_some() || new_timezone.is_some() {
// Extract existing cron fields (cloned to avoid borrow conflict)
let existing_cron = match &routine.trigger {
Trigger::Cron { schedule, timezone } => Some((schedule.clone(), timezone.clone())),
_ => None,
};
routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None);
if let Some((old_schedule, old_tz)) = existing_cron {
let effective_schedule = new_schedule.unwrap_or(&old_schedule);
let effective_tz = new_timezone.or(old_tz);
// Validate
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
routine.trigger = Trigger::Cron {
schedule: effective_schedule.to_string(),
timezone: effective_tz.clone(),
};
routine.next_fire_at =
next_cron_fire(effective_schedule, effective_tz.as_deref()).unwrap_or(None);
} else {
return Err(ToolError::InvalidParameters(
"Cannot update schedule or timezone on a non-cron routine.".to_string(),
));
}
}
self.store
+46 -2
View File
@@ -48,7 +48,7 @@ impl Tool for TimeTool {
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
@@ -57,10 +57,15 @@ impl Tool for TimeTool {
let result = match operation {
"now" => {
let now = Utc::now();
let tz =
crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::UTC);
let local = now.with_timezone(&tz);
serde_json::json!({
"iso": now.to_rfc3339(),
"unix": now.timestamp(),
"unix_millis": now.timestamp_millis()
"unix_millis": now.timestamp_millis(),
"local_iso": local.to_rfc3339(),
"timezone": tz.name()
})
}
"parse" => {
@@ -112,3 +117,42 @@ impl Tool for TimeTool {
false // Internal tool, no external data
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_now_includes_local_time_when_timezone_set() {
let tool = TimeTool;
let mut ctx = JobContext::with_user("test", "chat", "test");
ctx.user_timezone = "America/New_York".to_string();
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert!(
output.result.get("local_iso").is_some(),
"should have local_iso"
);
assert_eq!(
output.result["timezone"].as_str(),
Some("America/New_York"),
"should report timezone"
);
}
#[tokio::test]
async fn test_now_includes_utc_timezone_by_default() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
// Default user_timezone is "UTC" which is a valid IANA timezone
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert!(output.result.get("iso").is_some(), "should have iso");
assert_eq!(output.result["timezone"].as_str(), Some("UTC"));
}
}