Skip to content

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

Open
ilblackdragon wants to merge 9 commits intomainfrom
fix/661-timezone-aware-sessions
Open

feat(timezone): add timezone-aware session context#671
ilblackdragon wants to merge 9 commits intomainfrom
fix/661-timezone-aware-sessions

Conversation

@ilblackdragon
Copy link
Member

Summary

Closes #661

  • Adds timezone as a per-session property flowing from client → agent → tools
  • src/timezone.rs: resolution chain (client → user setting → config default → UTC), parsing, system detection
  • IncomingMessage.timezone carries IANA timezone from channel clients
  • JobContext.user_timezone flows timezone to tools (daily log, memory)
  • next_cron_fire() evaluates cron schedules in the specified timezone
  • Trigger::Cron stores optional timezone (backward-compatible via #[serde(default)])
  • Workspace gains _tz variants for daily logs and system prompt date selection
  • Heartbeat supports quiet hours (HEARTBEAT_QUIET_START/HEARTBEAT_QUIET_END env vars)
  • Web frontend sends Intl.DateTimeFormat().resolvedOptions().timeZone in chat requests
  • REPL auto-detects system timezone via iana-time-zone
  • DEFAULT_TIMEZONE env var and settings field for server-wide default

Key principle: Storage stays UTC. Conversion happens at display boundaries and when interpreting user-provided times.

Test plan

  • cargo fmt — clean
  • cargo clippy --all --all-features — zero warnings
  • cargo test — all pass (pre-existing wit_compat failure unrelated)
  • cargo check --no-default-features --features libsql — compiles
  • 20 new tests: timezone resolution (9), cron with timezone (3), heartbeat quiet hours (3), channel builder (1), routine roundtrip (3), backward compat (1)
  • Manual: Set DEFAULT_TIMEZONE=America/New_York, send message, verify daily log uses ET date
  • Manual: Create cron routine with timezone, verify next_fire_at is correct UTC
  • Manual: Web frontend sends timezone in chat/send POST body

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 7, 2026 18:06
@github-actions github-actions bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: channel/web Web gateway channel scope: tool/builtin Built-in tools scope: workspace Persistent memory / workspace scope: config Configuration scope: dependencies Dependency updates size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Mar 7, 2026
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the system's ability to handle timezones, moving from a UTC-centric approach to one that respects user and system-defined timezones across various functionalities. The core principle is to maintain UTC for storage while performing conversions at display boundaries and when interpreting user-provided times. This change improves the accuracy and relevance of time-sensitive operations, such as cron job scheduling, daily log entries, and system prompts, by aligning them with the local context of the user or system.

Highlights

  • Timezone-aware Session Context: Introduced timezone as a per-session property, flowing from client through the agent to various tools, ensuring operations respect the user's local time.
  • Centralized Timezone Management: A new src/timezone.rs module provides utilities for timezone resolution (client -> user setting -> config default -> UTC), parsing IANA timezone strings, and detecting the system's local timezone.
  • Cron Schedule Enhancements: The Trigger::Cron now supports an optional timezone, allowing cron schedules to be evaluated in a specific timezone, with backward compatibility maintained. The next_cron_fire() function was updated to utilize this.
  • Workspace and Daily Log Updates: The workspace now includes timezone-aware methods for generating system prompts (system_prompt_for_context_tz) and appending to daily logs (append_daily_log_tz), ensuring dates are correctly localized.
  • Heartbeat Quiet Hours: Added support for 'quiet hours' in the heartbeat mechanism, configurable via environment variables (HEARTBEAT_QUIET_START/HEARTBEAT_QUIET_END) and evaluated in a specified timezone, allowing the heartbeat to be skipped during certain periods.
  • Client-side Timezone Detection: The web frontend now automatically sends the client's IANA timezone in chat requests, and the REPL auto-detects the system timezone, providing a more personalized experience.
  • Configuration and Data Flow: A DEFAULT_TIMEZONE environment variable and settings field were added for server-wide default timezone configuration. The IncomingMessage and JobContext structures were updated to carry timezone information throughout the system.
Changelog
  • Cargo.lock
    • Added chrono-tz dependency for timezone handling.
    • Added iana-time-zone dependency for system timezone detection.
    • Added phf (0.12.1) and phf_shared (0.12.1) dependencies, likely transitive for timezone crates.
  • Cargo.toml
    • Added chrono-tz crate for timezone data and operations.
    • Added iana-time-zone crate for detecting the system's timezone.
  • src/agent/dispatcher.rs
    • Resolved user timezone from incoming message, user settings, or default configuration.
    • Updated system_prompt_for_context calls to use the new timezone-aware variant.
    • Assigned the resolved user timezone to JobContext.
    • Initialized default_timezone in agent test configurations.
  • src/agent/heartbeat.rs
    • Added quiet_hours_start, quiet_hours_end, and timezone fields to HeartbeatConfig.
    • Implemented is_quiet_hours method to check if the current time falls within configured quiet hours.
    • Integrated is_quiet_hours check into the HeartbeatRunner loop to skip during quiet periods.
    • Added unit tests for quiet hours functionality.
  • src/agent/routine.rs
    • Modified Trigger::Cron enum to include an optional timezone field, defaulting to None for backward compatibility.
    • Updated Trigger::from_db to parse the new timezone field for cron triggers.
    • Modified Trigger::to_config_json to serialize the timezone field for cron triggers.
    • Updated next_cron_fire function to accept an optional timezone for cron schedule evaluation.
    • Added new tests for cron trigger timezone roundtrip, backward compatibility, and timezone-aware firing.
  • src/agent/routine_engine.rs
    • Updated execute_routine to pass the timezone from Trigger::Cron to next_cron_fire when computing the next fire time.
  • src/agent/thread_ops.rs
    • Set the user_timezone in JobContext from the IncomingMessage if available.
  • src/channels/channel.rs
    • Added an optional timezone field to IncomingMessage to carry IANA timezone strings from clients.
    • Added a with_timezone builder method to IncomingMessage.
    • Added a test case for setting timezone on an incoming message.
  • src/channels/repl.rs
    • Detected the system's timezone using iana-time-zone at REPL startup.
    • Included the detected system timezone in all IncomingMessage instances sent from the REPL.
  • src/channels/web/handlers/routines.rs
    • Updated routine_to_info to destructure Trigger::Cron with the new timezone field.
  • src/channels/web/server.rs
    • Updated chat_send_handler to set the timezone on the IncomingMessage if provided in the request body.
    • Updated routine_to_info to destructure Trigger::Cron with the new timezone field.
  • src/channels/web/static/app.js
    • Modified sendMessage and confirmRestart functions to include the client's Intl.DateTimeFormat().resolvedOptions().timeZone in API requests.
  • src/channels/web/types.rs
    • Added an optional timezone field to SendMessageRequest.
    • Added an optional timezone field to WsClientMessage::Message.
    • Updated test cases for WsClientMessage deserialization to account for the new timezone field.
  • src/channels/web/ws.rs
    • Updated handle_client_message to extract the timezone from WsClientMessage::Message and apply it to the IncomingMessage.
    • Updated test cases to include the timezone field in WsClientMessage.
  • src/config/agent.rs
    • Added default_timezone field to AgentConfig.
    • Configured default_timezone to be loaded from environment variables or settings, defaulting to 'UTC'.
  • src/config/heartbeat.rs
    • Imported parse_option_env helper function.
    • Added quiet_hours_start and quiet_hours_end fields to HeartbeatConfig.
    • Configured quiet_hours_start and quiet_hours_end to be loaded from environment variables.
  • src/context/state.rs
    • Added user_timezone field to JobContext with a default value of 'UTC'.
    • Added a with_timezone builder method to JobContext.
  • src/db/libsql/jobs.rs
    • Initialized the user_timezone field in JobContext when loading job contexts from the database.
  • src/history/store.rs
    • Initialized the user_timezone field in JobContext when loading job contexts from history.
  • src/lib.rs
    • Declared timezone as a public module.
  • src/settings.rs
    • Added quiet_hours_start and quiet_hours_end fields to HeartbeatSettings.
    • Added default_timezone field to AgentSettings with a default value of 'UTC'.
  • src/testing.rs
    • Updated test routine creation to include timezone: None for cron triggers.
  • src/timezone.rs
    • Added a new module for timezone resolution and utility functions.
    • Implemented resolve_timezone to determine the effective timezone based on a priority chain.
    • Provided parse_timezone for converting IANA strings to chrono_tz::Tz.
    • Added today_in_tz and now_in_tz for getting current date/time in a specific timezone.
    • Implemented detect_system_timezone to get the local system timezone.
    • Included comprehensive unit tests for timezone resolution and parsing.
  • src/tools/builtin/memory.rs
    • Modified MemoryWriteTool::execute to use JobContext.user_timezone for daily log operations.
    • Updated daily log appending to use append_daily_log_tz and format the date based on the user's timezone.
  • src/tools/builtin/routine.rs
    • Modified RoutineCreateTool to extract an optional timezone parameter for cron triggers and validate it.
    • Updated RoutineCreateTool and RoutineUpdateTool to pass the timezone to next_cron_fire when creating or updating cron routines.
    • Ensured RoutineUpdateTool preserves existing timezone if only the schedule is changed.
  • src/workspace/mod.rs
    • Added append_daily_log_tz method to append entries to daily logs using a specified timezone.
    • Introduced system_prompt_for_context_tz to build system prompts with timezone-aware daily log dates.
    • Refactored system prompt generation into system_prompt_for_context_inner to handle optional timezones for 'today' and 'yesterday' calculations.
  • tests/e2e_routine_heartbeat.rs
    • Updated test routine creation to include timezone: None for cron triggers.
    • Initialized timezone: None for IncomingMessage in event trigger tests.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces comprehensive timezone support across the application, integrating chrono-tz and iana-time-zone dependencies. Key changes include a new timezone module for resolving and parsing timezones, and propagating timezone information through IncomingMessages, JobContexts, and web/REPL channels. This enables timezone-aware daily logs in system prompts and memory, allows cron triggers to specify a timezone for schedule evaluation, and implements heartbeat quiet hours configurable with a specific timezone. The review comments point out that the RoutineUpdateTool's logic for cron triggers needs refinement to allow timezone-only updates and prevent unintended type changes, and that the user_timezone in JobContext is hardcoded to "UTC" when loaded from the database, suggesting it should be persisted for full timezone support in jobs.

Comment on lines 465 to 487
if let Some(schedule) = params.get("schedule").and_then(|v| v.as_str()) {
let timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
// Preserve existing timezone if only schedule is changing
if let Trigger::Cron { ref timezone, .. } = routine.trigger {
timezone.clone()
} else {
None
}
});
// Validate
next_cron_fire(schedule)
next_cron_fire(schedule, timezone.as_deref())
.map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?;

routine.trigger = Trigger::Cron {
schedule: schedule.to_string(),
timezone: timezone.clone(),
};
routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None);
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This logic for updating a cron routine is only triggered if the schedule parameter is provided. This prevents updating only the timezone. This block should be triggered if either schedule or timezone is provided.

Additionally, this logic implicitly changes the trigger type to Cron if it wasn't already, which might be unintended for an update tool.

Consider refactoring to handle updates to existing cron triggers more robustly and explicitly erroring if schedule or timezone is provided for a non-cron routine.

        let new_schedule_opt = params.get("schedule").and_then(|v| v.as_str());
        let new_timezone_opt = params.get("timezone").and_then(|v| v.as_str());

        if new_schedule_opt.is_some() || new_timezone_opt.is_some() {
            if let Trigger::Cron {
                schedule: old_schedule,
                timezone: old_timezone,
            } = &routine.trigger
            {
                let schedule = new_schedule_opt.unwrap_or(old_schedule);
                let timezone = new_timezone_opt.map(String::from).or_else(|| old_timezone.clone());

                // Validate
                next_cron_fire(schedule, timezone.as_deref())
                    .map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?;

                routine.trigger = Trigger::Cron {
                    schedule: schedule.to_string(),
                    timezone: timezone.clone(),
                };
                routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None);
            } else {
                return Err(ToolError::InvalidParameters(
                    "Cannot update schedule or timezone on a non-cron routine.".to_string(),
                ));
            }
        }

tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(),
)),
user_timezone: "UTC".to_string(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When loading a job from the database, user_timezone is hardcoded to "UTC". This means that for persisted jobs, like those created by routines, the user's timezone context is lost. Tools that rely on this context (e.g., for daily logs) will incorrectly use UTC. To fully support timezones in jobs, the user_timezone should be persisted with the job data in the agent_jobs table and loaded here.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds end-to-end timezone awareness as a per-session context (client → channel → agent → tools), while keeping stored timestamps in UTC and applying timezone conversions at interpretation/display boundaries.

Changes:

  • Introduces src/timezone.rs for timezone resolution/parsing, “now/today in TZ”, and system timezone detection.
  • Plumbs client-provided timezone through web + REPL channels into IncomingMessage, resolves it in the agent, and carries it via JobContext.user_timezone for tools (daily log/system prompt).
  • Adds timezone support to cron routines (Trigger::Cron.timezone, next_cron_fire(..., timezone)) and introduces heartbeat “quiet hours” config fields.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
Cargo.toml Adds chrono-tz + iana-time-zone dependencies.
Cargo.lock Locks new transitive deps for timezone support.
src/lib.rs Exposes the new timezone module.
src/timezone.rs Implements timezone utilities + unit tests.
src/settings.rs Adds default_timezone and heartbeat quiet-hours fields to settings.
src/config/agent.rs Adds DEFAULT_TIMEZONEAgentConfig.default_timezone.
src/config/heartbeat.rs Adds env parsing for heartbeat quiet-hours fields.
src/channels/channel.rs Adds IncomingMessage.timezone and a setter.
src/channels/repl.rs Detects system TZ and attaches it to REPL messages.
src/channels/web/types.rs Extends HTTP + WS request types to carry timezone in chat messages.
src/channels/web/ws.rs Threads WS message timezone into IncomingMessage.
src/channels/web/server.rs Threads HTTP chat-send timezone into IncomingMessage; updates cron trigger matching.
src/channels/web/handlers/routines.rs Updates cron trigger match to ignore new timezone field.
src/channels/web/static/app.js Sends browser-resolved IANA timezone in chat requests.
src/context/state.rs Adds JobContext.user_timezone and a builder method.
src/agent/dispatcher.rs Resolves per-message timezone and uses it for system prompt + job context.
src/agent/thread_ops.rs Attempts to apply message timezone during tool-approval tool execution.
src/workspace/mod.rs Adds _tz variants for daily log append + system prompt daily-log date selection.
src/tools/builtin/memory.rs Uses JobContext.user_timezone to write daily logs with local date/time.
src/agent/routine.rs Adds optional timezone to cron triggers; evaluates cron schedules in that TZ.
src/tools/builtin/routine.rs Accepts optional timezone for cron routines; uses timezone-aware next_cron_fire.
src/agent/routine_engine.rs Updates cron trigger match; computes next_fire_at with timezone.
src/agent/heartbeat.rs Adds quiet-hours evaluation and skips heartbeat during quiet hours.
src/history/store.rs Sets default user_timezone when hydrating JobContext from DB row.
src/db/libsql/jobs.rs Sets default user_timezone when hydrating JobContext from DB row.
src/testing.rs Updates test fixtures for new cron trigger field.
tests/e2e_routine_heartbeat.rs Updates tests to populate new timezone/cron fields.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +48
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
.or_else(|| settings.heartbeat.notify_user.clone()),
quiet_hours_start: parse_option_env("HEARTBEAT_QUIET_START")?,
quiet_hours_end: parse_option_env("HEARTBEAT_QUIET_END")?,
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quiet_hours_start/quiet_hours_end are parsed only from env vars here; values set in settings.heartbeat.quiet_hours_* are ignored when the env vars are unset. This makes the new settings fields ineffective unless users configure env vars. Consider resolving these fields with an env override that falls back to the corresponding settings values, consistent with the other heartbeat fields.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +48
quiet_hours_start: parse_option_env("HEARTBEAT_QUIET_START")?,
quiet_hours_end: parse_option_env("HEARTBEAT_QUIET_END")?,
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HEARTBEAT_QUIET_START/HEARTBEAT_QUIET_END are documented as hours in the 0–23 range, but the current parsing accepts any u32 (e.g., 24) with no validation. This can lead to surprising quiet-hour behavior at runtime. Consider validating the parsed values are <= 23 and returning a ConfigError::InvalidValue otherwise.

Copilot uses AI. Check for mistakes.
Comment on lines +146 to 157
let timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(String::from);
// 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,
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool accepts a timezone parameter but does not validate it; invalid IANA names will be stored on the routine while next_cron_fire() silently falls back to UTC. This can create routines that appear timezone-aware but actually run in UTC. Consider validating timezone with crate::timezone::parse_timezone (and rejecting invalid values) and updating the tool schema to advertise the timezone field.

Copilot uses AI. Check for mistakes.
routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None);
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None);
}

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

routine_update only reads and applies the timezone parameter inside the if schedule ... branch. As a result, callers cannot update a cron routine's timezone without also changing the schedule. Consider handling a standalone timezone update (and recomputing next_fire_at) when the trigger is cron.

Suggested change
// Handle standalone timezone updates for existing cron triggers
if params.get("schedule").is_none() {
if let Some(tz) = params.get("timezone").and_then(|v| v.as_str()) {
if let Trigger::Cron { schedule, timezone } = &mut routine.trigger {
let tz_string = tz.to_string();
let next = next_cron_fire(schedule, Some(&tz_string))
.map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?;
*timezone = Some(tz_string);
routine.next_fire_at = next;
}
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +242 to 250
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"))
let today = crate::timezone::today_in_tz(tz);
format!("daily/{}.md", today.format("%Y-%m-%d"))
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This computes the daily-log path using a second Utc::now() call via today_in_tz(tz) after appending. If the date rolls over between the append and this calculation (especially around midnight in the target TZ), the tool could write to one file but report a different path. Consider deriving the returned path from the same now/today value used for the append (e.g., return the used date/path from append_daily_log_tz, or compute now once in the tool and reuse it).

Copilot uses AI. Check for mistakes.
Comment on lines 737 to +742
let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
if let Some(ref tz) = message.timezone {
job_ctx.user_timezone = tz.clone();
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the tool-approval flow, the incoming approval message typically won’t carry a timezone (e.g., WebSocket approval messages have no timezone field). This means approved tools will run with the default "UTC" timezone even if the thread/session was established with a different timezone. Consider storing the resolved timezone in the thread/pending-approval state (or reusing the thread’s prior context) and using that when building job_ctx, rather than relying on message.timezone here.

Suggested change
let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
if let Some(ref tz) = message.timezone {
job_ctx.user_timezone = tz.clone();
}
// Reuse the original job context from the pending approval so that
// the previously resolved timezone and other context are preserved.
let mut job_ctx = pending.job_ctx.clone();
job_ctx.http_interceptor = self.deps.http_interceptor.clone();

Copilot uses AI. Check for mistakes.
Comment on lines 564 to +587
/// Append an entry to today's daily log.
///
/// 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();
let path = format!("daily/{}.md", today.format("%Y-%m-%d"));
let timestamp = Utc::now().format("%H:%M:%S");
let timestamped_entry = format!("[{}] {}", timestamp, entry);
self.append(&path, &timestamped_entry).await
}

/// Append an entry to today's daily log using the given timezone.
pub async fn append_daily_log_tz(
&self,
entry: &str,
tz: chrono_tz::Tz,
) -> Result<(), 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 = now.format("%H:%M:%S");
let timestamped_entry = format!("[{}] {}", timestamp, entry);
self.append(&path, &timestamped_entry).await
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

append_daily_log_tz duplicates the logic from append_daily_log. To keep behavior consistent (and avoid subtle drift like using multiple Utc::now() calls), consider implementing append_daily_log in terms of append_daily_log_tz(entry, Tz::UTC) instead of maintaining two near-identical implementations.

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +106
fn test_today_in_tz_returns_valid_date() {
let date = today_in_tz(Tz::UTC);
assert!(date.year() >= 2024);
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts the current year is >= 2024, which makes it time-dependent and potentially flaky on systems with incorrect clocks (or when run in earlier years). Consider asserting a property that’s independent of the wall clock (e.g., that today_in_tz(Tz::UTC) returns a valid date and now_in_tz doesn’t panic), or use a fixed timestamp in the test instead of Utc::now().

Copilot uses AI. Check for mistakes.
ilblackdragon and others added 2 commits March 7, 2026 10:13
Resolve conflicts in src/channels/channel.rs and tests/e2e_routine_heartbeat.rs
by keeping both timezone and attachments fields from the WASM channel attachments PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 7, 2026 18:42
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 29 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -0,0 +1 @@
{"sessionId":"da5a1fe1-7b4f-4b22-84f7-a905b589d0af","pid":72935,"acquiredAt":1772906795706} No newline at end of file
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like an editor/automation lock artifact (contains a sessionId/pid/timestamp) and doesn’t appear to be part of the product. It should be removed from the repo and added to .gitignore to avoid committing per-developer state (and potential metadata leakage).

Suggested change
{"sessionId":"da5a1fe1-7b4f-4b22-84f7-a905b589d0af","pid":72935,"acquiredAt":1772906795706}

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +58
let user_tz = crate::timezone::resolve_timezone(
message.timezone.as_deref(),
None, // user setting lookup can be added later
&self.config.default_timezone,
);
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timezone resolution currently skips the “user setting” step: resolve_timezone(..., None, ...). This contradicts the stated resolution chain (client → user setting → config default → UTC) and means a persisted user timezone will never be applied. Consider plumbing the user setting lookup into this call (or update the PR description/scope if intentionally deferred).

Copilot uses AI. Check for mistakes.
Comment on lines 796 to 800
tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: message.timezone.clone(),
};
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PendingApproval.user_timezone is populated from message.timezone, which is the raw client-provided value and may be None even when a fallback timezone (e.g., DEFAULT_TIMEZONE) was used for this session. This can cause approvals to execute tools under UTC unexpectedly if the approval message doesn’t include a timezone. Store the resolved effective timezone string here instead (the same one written to job_ctx.user_timezone).

Copilot uses AI. Check for mistakes.
tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(),
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
user_timezone: message.timezone.clone(),
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like in dispatcher.rs, PendingApproval.user_timezone is being set from the raw message.timezone. If the effective timezone came from a fallback (config default / future user setting), it will be lost across the approval flow and tool execution will fall back to UTC. Persist the resolved timezone used for the session/job context instead.

Suggested change
user_timezone: message.timezone.clone(),
user_timezone: resolved_timezone.clone(),

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +104
/// Check whether the current time falls within configured quiet hours.
pub fn is_quiet_hours(&self) -> bool {
use chrono::Timelike;
let (Some(start), Some(end)) = (self.quiet_hours_start, self.quiet_hours_end) else {
return false;
};
let tz = self
.timezone
.as_deref()
.and_then(crate::timezone::parse_timezone)
.unwrap_or(chrono_tz::UTC);
let now_hour = crate::timezone::now_in_tz(tz).hour();
if start <= end {
now_hour >= start && now_hour < end
} else {
// Wraps midnight, e.g. 22..06
now_hour >= start || now_hour < end
}
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quiet-hours fields were added to the agent-side HeartbeatConfig, but the runner appears to be constructed from HeartbeatConfig::default().with_interval(...) in the main loop (i.e., quiet hours/timezone are never populated from crate::config::HeartbeatConfig). As a result, is_quiet_hours() will always behave as if quiet hours are disabled in production unless something else sets these fields. Wire the resolved config values into the agent heartbeat config (and decide which timezone to use) or remove these fields to avoid a false sense of support.

Copilot uses AI. Check for mistakes.
Comment on lines 622 to +625
let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
if let Some(ref tz) = req.timezone {
msg = msg.with_timezone(tz);
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions accepting timezone via X-Timezone header, but this handler only reads timezone from the JSON body. If header support is intended for backward compatibility / non-browser clients, consider reading it here as an additional input (and define precedence vs body).

Copilot uses AI. Check for mistakes.
Comment on lines +159 to +167
WsClientMessage::Message {
content,
thread_id,
timezone,
} => {
let mut incoming = IncomingMessage::new("gateway", user_id, &content);
if let Some(ref tz) = timezone {
incoming = incoming.with_timezone(tz);
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions accepting timezone via WebSocket query params/handshake, but timezone is only accepted inside WsClientMessage::Message. If handshake/query support is required, it should be parsed during connection setup and applied as a default when individual messages omit timezone.

Copilot uses AI. Check for mistakes.
ilblackdragon and others added 2 commits March 7, 2026 11:07
- 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 <noreply@anthropic.com>
Keep both quiet hours wiring and notify config in agent_loop.
Keep both quiet hours tests and store param test in heartbeat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 7, 2026 19:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 30 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

let mut config = AgentHeartbeatConfig::default()
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
config.quiet_hours_start = hb_config.quiet_hours_start;
config.quiet_hours_end = hb_config.quiet_hours_end;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quiet hours are evaluated in HeartbeatConfig::is_quiet_hours() using self.config.timezone (falls back to UTC when unset). Here the agent loop only wires quiet_hours_start/end, so quiet hours will always be interpreted in UTC rather than the intended default/user timezone. Consider setting config.timezone (e.g., to self.config.default_timezone, or introducing a dedicated heartbeat timezone setting/env var) when spawning the heartbeat runner.

Suggested change
config.quiet_hours_end = hb_config.quiet_hours_end;
config.quiet_hours_end = hb_config.quiet_hours_end;
// Ensure quiet hours are evaluated in the intended timezone
// rather than always defaulting to UTC. Prefer the explicit
// agent timezone, falling back to the agent's default timezone.
if let Some(tz) = self
.config
.timezone
.clone()
.or_else(|| self.config.default_timezone.clone())
{
config.timezone = Some(tz);
}

Copilot uses AI. Check for mistakes.
Comment on lines +478 to +482
// Validate timezone param if provided
let new_timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(|tz| {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

routine_update now reads/validates a timezone parameter, but the tool's parameters_schema() does not advertise a timezone field. This makes it unlikely that LLM/tool callers will ever send the field and can also break structured validation on the client side. Add timezone to the update tool schema properties (matching the create tool) and document the expected IANA format.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +98
default_timezone: parse_optional_env(
"DEFAULT_TIMEZONE",
settings.agent.default_timezone.clone(),
)?,
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_TIMEZONE is accepted as an arbitrary string here and only later gets validated implicitly (invalid values silently fall back to UTC in resolve_timezone). To avoid surprising behavior, consider validating the configured timezone at config load time (e.g., parse via crate::timezone::parse_timezone and return ConfigError::InvalidValue on failure).

Copilot uses AI. Check for mistakes.
Comment on lines +572 to +576
#[test]
fn test_quiet_hours_inside() {
let config = HeartbeatConfig {
quiet_hours_start: Some(22),
quiet_hours_end: Some(6),
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't assert any expected behavior (it only calls is_quiet_hours() to ensure it doesn't panic), so it won't catch regressions in the quiet-hours logic. Consider refactoring is_quiet_hours() to accept an injectable "current hour" (or time source) so tests can assert true/false for specific times and wrap-around cases.

Copilot uses AI. Check for mistakes.
ilblackdragon and others added 2 commits March 7, 2026 13:27
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 7, 2026 21:32
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 31 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +65 to +70
// Include local time in the user's timezone if set
if let Some(tz) = crate::timezone::parse_timezone(&ctx.user_timezone) {
let local = now.with_timezone(&tz);
result["local_iso"] = serde_json::Value::String(local.to_rfc3339());
result["timezone"] = serde_json::Value::String(tz.name().to_string());
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The time now output only includes timezone/local_iso when ctx.user_timezone parses successfully. If ctx.user_timezone is ever invalid (e.g., from unvalidated channel input), consumers will get an inconsistent schema with the timezone fields missing. Consider always including a timezone field and falling back to UTC for local_iso when parsing fails, so callers can rely on stable output structure.

Copilot uses AI. Check for mistakes.
Comment on lines +574 to +584
let config = HeartbeatConfig {
quiet_hours_start: Some(22),
quiet_hours_end: Some(6),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// We can't control the clock, so just verify the method doesn't panic
let _ = config.is_quiet_hours();
}

#[test]
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new quiet-hours tests don't verify the actual quiet-hours logic: test_quiet_hours_inside only checks for non-panicking behavior, so regressions in the hour-window calculation (including wrap-midnight cases) could slip through. Consider refactoring is_quiet_hours() to accept an injected DateTime/clock (or splitting out a pure function that takes now_hour) so unit tests can deterministically assert inside/outside behavior.

Suggested change
let config = HeartbeatConfig {
quiet_hours_start: Some(22),
quiet_hours_end: Some(6),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// We can't control the clock, so just verify the method doesn't panic
let _ = config.is_quiet_hours();
}
#[test]
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour() as u8;
let start = hour;
let end = (hour + 1) % 24;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// The current UTC hour should be inside [start, end) by construction.
assert!(config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_outside_simple() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour() as u8;
let start = (hour + 1) % 24;
let end = (hour + 2) % 24;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// The current UTC hour should be outside [start, end) by construction.
assert!(!config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_wrap_midnight_excludes_now() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour() as u8;
let start = (hour + 1) % 24;
let end = hour;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// This wrap-midnight window [start, end) covers all hours except `hour`,
// so the current UTC hour should be outside quiet hours.
assert!(!config.is_quiet_hours());
}
#[test]

Copilot uses AI. Check for mistakes.
Comment on lines +749 to +753
// Prefer timezone from the approval message, fall back to the
// timezone stored when the approval was originally requested.
if let Some(ref tz) = message.timezone.as_ref().or(pending.user_timezone.as_ref()) {
job_ctx.user_timezone = tz.to_string();
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approval path copies message.timezone directly into job_ctx.user_timezone, and it takes precedence over the previously-resolved pending.user_timezone. Because IncomingMessage.timezone is unvalidated, an invalid timezone in the approval message can overwrite a valid stored timezone and cause downstream tools to silently fall back to UTC (or lose timezone info). Consider validating/normalizing the candidate timezone (e.g., via resolve_timezone(...) or parse_timezone with fallback to pending.user_timezone) before setting job_ctx.user_timezone.

Copilot uses AI. Check for mistakes.
Comment on lines +478 to +489
// 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()?;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

routine_update now accepts a timezone parameter (it is read and validated here), but the tool's parameters_schema() does not document/allow a timezone property. This mismatch can prevent callers that rely on the schema from ever sending timezone, and makes the tool contract inaccurate. Add the timezone field to the schema (similar to routine_create).

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +110
let timezone = config
.get("timezone")
.and_then(|v| v.as_str())
.map(String::from);
Ok(Trigger::Cron { schedule, timezone })
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trigger::from_db() stores the timezone string without validating it as an IANA timezone. If the DB contains an invalid value (e.g., from older data, manual edits, or future migrations), the system will silently treat the cron as UTC later (because next_cron_fire ignores invalid timezones), which can be hard to debug. Consider validating here (and returning a RoutineError or coercing invalid values to None) so invalid stored timezones are surfaced early.

Copilot uses AI. Check for mistakes.
…stic 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: config Configuration scope: dependencies Dependency updates scope: tool/builtin Built-in tools scope: workspace Persistent memory / workspace size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add timezone-aware session context with client detection

2 participants