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
+44 -4
View File
@@ -565,11 +565,26 @@ impl Workspace {
///
/// Daily logs are raw, append-only notes for the current day.
pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> {
let today = Utc::now().date_naive();
self.append_daily_log_tz(entry, chrono_tz::Tz::UTC)
.await
.map(|_| ())
}
/// Append an entry to today's daily log using the given timezone.
///
/// Returns the path that was written to (e.g. `daily/2024-01-15.md`).
pub async fn append_daily_log_tz(
&self,
entry: &str,
tz: chrono_tz::Tz,
) -> Result<String, WorkspaceError> {
let now = crate::timezone::now_in_tz(tz);
let today = now.date_naive();
let path = format!("daily/{}.md", today.format("%Y-%m-%d"));
let timestamp = Utc::now().format("%H:%M:%S");
let timestamp = now.format("%H:%M:%S");
let timestamped_entry = format!("[{}] {}", timestamp, entry);
self.append(&path, &timestamped_entry).await
self.append(&path, &timestamped_entry).await?;
Ok(path)
}
// ==================== System Prompt ====================
@@ -584,6 +599,18 @@ impl Workspace {
self.system_prompt_for_context(false).await
}
/// Build the system prompt with timezone-aware daily log dates.
///
/// Uses the given timezone to determine "today" and "yesterday" for daily log injection.
pub async fn system_prompt_for_context_tz(
&self,
is_group_chat: bool,
tz: chrono_tz::Tz,
) -> Result<String, WorkspaceError> {
self.system_prompt_for_context_inner(is_group_chat, Some(tz))
.await
}
/// Build the system prompt, optionally excluding personal memory.
///
/// When `is_group_chat` is true, MEMORY.md is excluded to prevent
@@ -591,6 +618,16 @@ impl Workspace {
pub async fn system_prompt_for_context(
&self,
is_group_chat: bool,
) -> Result<String, WorkspaceError> {
self.system_prompt_for_context_inner(is_group_chat, None)
.await
}
/// Inner implementation for system prompt building.
async fn system_prompt_for_context_inner(
&self,
is_group_chat: bool,
tz: Option<chrono_tz::Tz>,
) -> Result<String, WorkspaceError> {
let mut parts = Vec::new();
@@ -645,7 +682,10 @@ impl Workspace {
}
// Add today's memory context (last 2 days of daily logs)
let today = Utc::now().date_naive();
let today = match tz {
Some(t) => crate::timezone::today_in_tz(t),
None => Utc::now().date_naive(),
};
let yesterday = today.pred_opt().unwrap_or(today);
for date in [today, yesterday] {