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
+19 -2
View File
@@ -7,17 +7,19 @@ use crate::settings::Settings;
pub struct HeartbeatConfig {
/// Whether heartbeat is enabled.
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,
/// Channel to notify on heartbeat findings.
pub notify_channel: Option<String>,
/// User ID to notify on heartbeat findings.
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.
pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end.
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>,
}
@@ -28,6 +30,7 @@ impl Default for HeartbeatConfig {
interval_secs: 1800, // 30 minutes
notify_channel: None,
notify_user: None,
fire_at: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
@@ -37,6 +40,19 @@ impl Default for HeartbeatConfig {
impl HeartbeatConfig {
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 {
enabled: parse_bool_env("HEARTBEAT_ENABLED", settings.heartbeat.enabled)?,
interval_secs: parse_optional_env(
@@ -47,6 +63,7 @@ impl HeartbeatConfig {
.or_else(|| settings.heartbeat.notify_channel.clone()),
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
.or_else(|| settings.heartbeat.notify_user.clone()),
fire_at,
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
.or(settings.heartbeat.quiet_hours_start)
.map(|h| {