feat(heartbeat): fire_at time-of-day scheduling with IANA timezone (#1029)

* feat(heartbeat): fire_at time-of-day scheduling with IANA timezone support

- HEARTBEAT_FIRE_AT=HH:MM — fire heartbeat at a specific time of day instead
  of on a rolling interval; format is 24h HH:MM (e.g. "14:00")
- HEARTBEAT_TIMEZONE=Region/City — IANA timezone name for fire_at (e.g.
  "Pacific/Auckland", "America/New_York"). Defaults to UTC.
- When fire_at is set, interval_secs is ignored
- Config also readable from settings.toml [heartbeat] section

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

* feat(heartbeat): wire fire_at + timezone into HeartbeatConfig runner

Missed file from heartbeat scheduling commit. HeartbeatConfig struct in
agent/heartbeat.rs now carries fire_at: Option<NaiveTime> and timezone: Tz
so the runner can schedule against a fixed time of day.

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

* fix: add chrono-tz dependency for heartbeat fire_at timezone support

The chrono-tz crate was used in the heartbeat fire_at commits but
its Cargo.toml entry was lost during rebase conflict resolution.

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

* style: rustfmt fix for chained method call

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

* test(heartbeat): add fire_at scheduling and DST safety tests

- test_default_config_has_no_fire_at: interval-based default unchanged
- test_with_fire_at_builder: builder sets time and timezone
- test_duration_until_next_fire_is_bounded: result always 1s–24h
- test_duration_until_next_fire_dst_timezone_no_panic: US Eastern DST
- test_resolved_tz_defaults_to_utc: missing timezone falls back to UTC
- test_resolved_tz_parses_iana: IANA string resolves correctly

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

* fix(heartbeat): restore drift-free interval, add settings.json fallback for fire_at

- Interval path: restore tokio::time::interval (drift-free) instead of
  tokio::time::sleep which drifts by loop body execution time
- fire_at config: fall back to settings.heartbeat.fire_at when
  HEARTBEAT_FIRE_AT env var is not set, consistent with other settings

Addresses Gemini Code Assist review feedback.

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

---------

Co-authored-by: IronClaw <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Nick Stebbings
2026-03-16 07:46:59 +00:00
committed by GitHub
co-authored by IronClaw Claude Sonnet 4.6
parent 3e0e35d1bc
commit f618166ad8
4 changed files with 167 additions and 17 deletions
Generated
+4 -4
View File
@@ -4365,9 +4365,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.75" version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"cfg-if", "cfg-if",
@@ -4403,9 +4403,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.111" version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
+138 -10
View File
@@ -26,6 +26,8 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use chrono::TimeZone as _;
use chrono_tz::Tz;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::channels::OutgoingResponse; use crate::channels::OutgoingResponse;
@@ -37,7 +39,7 @@ use crate::workspace::hygiene::HygieneConfig;
/// Configuration for the heartbeat runner. /// Configuration for the heartbeat runner.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HeartbeatConfig { pub struct HeartbeatConfig {
/// Interval between heartbeat checks. /// Interval between heartbeat checks (used when fire_at is not set).
pub interval: Duration, pub interval: Duration,
/// Whether heartbeat is enabled. /// Whether heartbeat is enabled.
pub enabled: bool, pub enabled: bool,
@@ -47,11 +49,13 @@ pub struct HeartbeatConfig {
pub notify_user_id: Option<String>, pub notify_user_id: Option<String>,
/// Channel to notify on heartbeat findings. /// Channel to notify on heartbeat findings.
pub notify_channel: Option<String>, pub notify_channel: Option<String>,
/// Fixed time-of-day to fire (24h). When set, interval is ignored.
pub fire_at: Option<chrono::NaiveTime>,
/// Hour (0-23) when quiet hours start. /// Hour (0-23) when quiet hours start.
pub quiet_hours_start: Option<u32>, pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end. /// Hour (0-23) when quiet hours end.
pub quiet_hours_end: Option<u32>, pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name). /// Timezone for fire_at and quiet hours evaluation (IANA name).
pub timezone: Option<String>, pub timezone: Option<String>,
} }
@@ -63,6 +67,7 @@ impl Default for HeartbeatConfig {
max_failures: 3, max_failures: 3,
notify_user_id: None, notify_user_id: None,
notify_channel: None, notify_channel: None,
fire_at: None,
quiet_hours_start: None, quiet_hours_start: None,
quiet_hours_end: None, quiet_hours_end: None,
timezone: None, timezone: None,
@@ -109,6 +114,21 @@ impl HeartbeatConfig {
self.notify_channel = Some(channel.into()); self.notify_channel = Some(channel.into());
self self
} }
/// Set a fixed time-of-day to fire (overrides interval).
pub fn with_fire_at(mut self, time: chrono::NaiveTime, tz: Option<String>) -> Self {
self.fire_at = Some(time);
self.timezone = tz;
self
}
/// Resolve timezone string to chrono_tz::Tz (defaults to UTC).
fn resolved_tz(&self) -> Tz {
self.timezone
.as_deref()
.and_then(crate::timezone::parse_timezone)
.unwrap_or(chrono_tz::UTC)
}
} }
/// Result of a heartbeat check. /// Result of a heartbeat check.
@@ -124,6 +144,33 @@ pub enum HeartbeatResult {
Failed(String), Failed(String),
} }
/// Compute how long to sleep until the next occurrence of `fire_at` in `tz`.
///
/// If the target time today is still in the future, sleep until then.
/// Otherwise sleep until the same time tomorrow.
fn duration_until_next_fire(fire_at: chrono::NaiveTime, tz: Tz) -> Duration {
let now = chrono::Utc::now().with_timezone(&tz);
let today = now.date_naive();
// Try to build today's target datetime in the given timezone.
// `.earliest()` picks the first occurrence if DST creates ambiguity.
let candidate = tz.from_local_datetime(&today.and_time(fire_at)).earliest();
let target = match candidate {
Some(t) if t > now => t,
_ => {
// Already past (or ambiguous) — schedule for tomorrow
let tomorrow = today + chrono::Duration::days(1);
tz.from_local_datetime(&tomorrow.and_time(fire_at))
.earliest()
.unwrap_or_else(|| now + chrono::Duration::days(1))
}
};
let secs = (target - now).num_seconds().max(1) as u64;
Duration::from_secs(secs)
}
/// Heartbeat runner for proactive periodic execution. /// Heartbeat runner for proactive periodic execution.
pub struct HeartbeatRunner { pub struct HeartbeatRunner {
config: HeartbeatConfig, config: HeartbeatConfig,
@@ -175,17 +222,39 @@ impl HeartbeatRunner {
return; return;
} }
tracing::info!( // Two scheduling modes:
"Starting heartbeat loop with interval {:?}", // fire_at → sleep until the next occurrence (recalculated each iteration)
self.config.interval // interval → tokio::time::interval (drift-free, accounts for loop body time)
); let mut tick_interval = if self.config.fire_at.is_none() {
let mut iv = tokio::time::interval(self.config.interval);
// Don't fire immediately on startup.
iv.tick().await;
Some(iv)
} else {
None
};
let mut interval = tokio::time::interval(self.config.interval); if let Some(fire_at) = self.config.fire_at {
// Don't run immediately on startup tracing::info!(
interval.tick().await; "Starting heartbeat loop: fire daily at {:?} {:?}",
fire_at,
self.config.timezone
);
} else {
tracing::info!(
"Starting heartbeat loop with interval {:?}",
self.config.interval
);
}
loop { loop {
interval.tick().await; if let Some(fire_at) = self.config.fire_at {
let sleep_dur = duration_until_next_fire(fire_at, self.config.resolved_tz());
tracing::info!("Next heartbeat in {:.1}h", sleep_dur.as_secs_f64() / 3600.0);
tokio::time::sleep(sleep_dur).await;
} else if let Some(ref mut iv) = tick_interval {
iv.tick().await;
}
// Skip during quiet hours // Skip during quiet hours
if self.config.is_quiet_hours() { if self.config.is_quiet_hours() {
@@ -656,4 +725,63 @@ mod tests {
) -> tokio::task::JoinHandle<()> = spawn_heartbeat; ) -> tokio::task::JoinHandle<()> = spawn_heartbeat;
let _ = _fn_ptr; let _ = _fn_ptr;
} }
// ==================== fire_at scheduling ====================
#[test]
fn test_default_config_has_no_fire_at() {
let config = HeartbeatConfig::default();
assert!(config.fire_at.is_none());
// Interval-based scheduling should be the default
assert_eq!(config.interval, Duration::from_secs(30 * 60));
}
#[test]
fn test_with_fire_at_builder() {
let time = chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap();
let config =
HeartbeatConfig::default().with_fire_at(time, Some("Pacific/Auckland".to_string()));
assert_eq!(config.fire_at, Some(time));
assert_eq!(config.timezone, Some("Pacific/Auckland".to_string()));
}
#[test]
fn test_duration_until_next_fire_is_bounded() {
// Result must always be between 1 second and ~24 hours
let time = chrono::NaiveTime::from_hms_opt(14, 0, 0).unwrap();
let dur = duration_until_next_fire(time, chrono_tz::UTC);
assert!(dur.as_secs() >= 1, "duration must be at least 1 second");
assert!(
dur.as_secs() <= 86_401,
"duration must be at most ~24 hours, got {}s",
dur.as_secs()
);
}
#[test]
fn test_duration_until_next_fire_dst_timezone_no_panic() {
// Use a timezone with DST (US Eastern) — should never panic
let tz: Tz = "America/New_York".parse().unwrap();
// Test a range of times including midnight boundaries
for hour in [0, 2, 3, 12, 23] {
let time = chrono::NaiveTime::from_hms_opt(hour, 30, 0).unwrap();
let dur = duration_until_next_fire(time, tz);
assert!(dur.as_secs() >= 1);
assert!(dur.as_secs() <= 86_401);
}
}
#[test]
fn test_resolved_tz_defaults_to_utc() {
let config = HeartbeatConfig::default();
assert_eq!(config.resolved_tz(), chrono_tz::UTC);
}
#[test]
fn test_resolved_tz_parses_iana() {
let time = chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap();
let config =
HeartbeatConfig::default().with_fire_at(time, Some("Europe/London".to_string()));
assert_eq!(config.resolved_tz(), chrono_tz::Europe::London);
}
} }
+19 -2
View File
@@ -7,17 +7,19 @@ use crate::settings::Settings;
pub struct HeartbeatConfig { pub struct HeartbeatConfig {
/// Whether heartbeat is enabled. /// Whether heartbeat is enabled.
pub enabled: bool, pub enabled: bool,
/// Interval between heartbeat checks in seconds. /// Interval between heartbeat checks in seconds (used when fire_at is not set).
pub interval_secs: u64, pub interval_secs: u64,
/// Channel to notify on heartbeat findings. /// Channel to notify on heartbeat findings.
pub notify_channel: Option<String>, pub notify_channel: Option<String>,
/// User ID to notify on heartbeat findings. /// User ID to notify on heartbeat findings.
pub notify_user: Option<String>, pub notify_user: Option<String>,
/// Fixed time-of-day to fire (HH:MM, 24h). When set, interval_secs is ignored.
pub fire_at: Option<chrono::NaiveTime>,
/// Hour (0-23) when quiet hours start. /// Hour (0-23) when quiet hours start.
pub quiet_hours_start: Option<u32>, pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end. /// Hour (0-23) when quiet hours end.
pub quiet_hours_end: Option<u32>, pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name). /// Timezone for fire_at and quiet hours evaluation (IANA name).
pub timezone: Option<String>, pub timezone: Option<String>,
} }
@@ -28,6 +30,7 @@ impl Default for HeartbeatConfig {
interval_secs: 1800, // 30 minutes interval_secs: 1800, // 30 minutes
notify_channel: None, notify_channel: None,
notify_user: None, notify_user: None,
fire_at: None,
quiet_hours_start: None, quiet_hours_start: None,
quiet_hours_end: None, quiet_hours_end: None,
timezone: None, timezone: None,
@@ -37,6 +40,19 @@ impl Default for HeartbeatConfig {
impl HeartbeatConfig { impl HeartbeatConfig {
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> { pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
let fire_at_str =
optional_env("HEARTBEAT_FIRE_AT")?.or_else(|| settings.heartbeat.fire_at.clone());
let fire_at = fire_at_str
.map(|s| {
chrono::NaiveTime::parse_from_str(&s, "%H:%M").map_err(|e| {
ConfigError::InvalidValue {
key: "HEARTBEAT_FIRE_AT".to_string(),
message: format!("must be HH:MM (24h), e.g. '14:00': {e}"),
}
})
})
.transpose()?;
Ok(Self { Ok(Self {
enabled: parse_bool_env("HEARTBEAT_ENABLED", settings.heartbeat.enabled)?, enabled: parse_bool_env("HEARTBEAT_ENABLED", settings.heartbeat.enabled)?,
interval_secs: parse_optional_env( interval_secs: parse_optional_env(
@@ -47,6 +63,7 @@ impl HeartbeatConfig {
.or_else(|| settings.heartbeat.notify_channel.clone()), .or_else(|| settings.heartbeat.notify_channel.clone()),
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")? notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
.or_else(|| settings.heartbeat.notify_user.clone()), .or_else(|| settings.heartbeat.notify_user.clone()),
fire_at,
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")? quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
.or(settings.heartbeat.quiet_hours_start) .or(settings.heartbeat.quiet_hours_start)
.map(|h| { .map(|h| {
+6 -1
View File
@@ -360,6 +360,10 @@ pub struct HeartbeatSettings {
#[serde(default)] #[serde(default)]
pub notify_user: Option<String>, pub notify_user: Option<String>,
/// Fixed time-of-day to fire (HH:MM, 24h). When set, interval_secs is ignored.
#[serde(default)]
pub fire_at: Option<String>,
/// Hour (0-23) when quiet hours start (heartbeat skipped). /// Hour (0-23) when quiet hours start (heartbeat skipped).
#[serde(default)] #[serde(default)]
pub quiet_hours_start: Option<u32>, pub quiet_hours_start: Option<u32>,
@@ -368,7 +372,7 @@ pub struct HeartbeatSettings {
#[serde(default)] #[serde(default)]
pub quiet_hours_end: Option<u32>, pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name, e.g. "America/New_York"). /// Timezone for fire_at and quiet hours (IANA name, e.g. "Pacific/Auckland").
#[serde(default)] #[serde(default)]
pub timezone: Option<String>, pub timezone: Option<String>,
} }
@@ -384,6 +388,7 @@ impl Default for HeartbeatSettings {
interval_secs: default_heartbeat_interval(), interval_secs: default_heartbeat_interval(),
notify_channel: None, notify_channel: None,
notify_user: None, notify_user: None,
fire_at: None,
quiet_hours_start: None, quiet_hours_start: None,
quiet_hours_end: None, quiet_hours_end: None,
timezone: None, timezone: None,