From 3f0ea6e118ece9cc9519f7a38b706ea49bc9ec3e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 18 May 2026 17:02:44 +0200 Subject: [PATCH] feat(cmmd): growth control + daily-tick + fix-count budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three classes of unbounded resource use the daemon had no guard against: - `history.jsonl` grew forever (one row per tick per memory root). - `/tmp/cmmd-tick-.log` agent transcripts accumulated indefinitely. - The memory dir's `.git/objects` grew with every pre-tick snapshot. Plus two budget caps that lived only in agent prose, not in code: - "≤3 fixes per tick" was advisory — agent could quietly apply more. - No daily ceiling on agent spawns — a stuck-finding-issues loop could burn tokens forever. What this commit adds: History rotation, per-tick-log TTL sweeper, and per-MEMORY_ROOT `git gc` all run once per main-loop iteration (history.rs). New env knobs: HISTORY_MAX_LINES=10000, TICK_LOG_TTL_DAYS=7, GIT_GC_INTERVAL_DAYS=7. Daily-tick counter is UTC-day rolled and persisted in state.json so the ceiling survives restarts; MAX_TICKS_PER_DAY=100 default. The agent is not spawned for the rest of the day once the cap is hit, and the tick is recorded as `reason_skipped=MAX_TICKS_PER_DAY reached`. Fix-count cap is enforced post-spawn by counting `git status --porcelain` entries vs the pre-tick SHA; if exceeded, `history::restore(pre_sha)` rolls the whole tick back. MAX_FIXES_PER_TICK=3 default. Six new Prometheus counters expose all of the above (cmmd_history_rotations_total, cmmd_tick_logs_swept_total, cmmd_git_gc_runs_total, cmmd_ticks_blocked_daily_cap_total, cmmd_ticks_reverted_fix_cap_total, cmmd_day_ticks_ran). Zero-dep UTC-date helper added to state.rs (Hinnant's civil-from-days) to avoid a chrono dependency for one date string. 44 tests pass (8 new), clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.rs | 20 ++++++ src/history.rs | 182 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 135 +++++++++++++++++++++++++++++++++--- src/metrics.rs | 73 ++++++++++++++++++++ src/state.rs | 82 ++++++++++++++++++++++ 5 files changed, 482 insertions(+), 10 deletions(-) diff --git a/src/config.rs b/src/config.rs index bca3e0c..21cb95f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,21 @@ pub struct Config { pub webhook_url: String, pub model: String, pub max_turns: u32, + /// Hard cap on number of file modifications the agent is allowed to apply + /// per tick. Enforced post-spawn by counting `git status --porcelain` + /// entries against the pre-tick snapshot. 0 = unlimited. + pub max_fixes_per_tick: u64, + /// Daily ceiling on the number of *ran* ticks (skipped ticks don't count). + /// The expensive path won't spawn `claude -p` once this is hit. Resets + /// at UTC midnight. 0 = unlimited. + pub max_ticks_per_day: u64, + /// Rotate $HISTORY_FILE when it exceeds this many lines. 0 = disabled. + pub history_max_lines: u64, + /// TTL in days for `/tmp/cmmd-tick-.log` files. 0 = disabled. + pub tick_log_ttl_days: u64, + /// Cadence for `git gc --prune=now --aggressive` over each MEMORY_ROOT. + /// 0 = disabled. + pub git_gc_interval_days: u64, pub claude_bin: String, pub authmux_bin: String, pub claude_accounts_dir: PathBuf, @@ -76,6 +91,11 @@ impl Config { webhook_url: env_str("WEBHOOK_URL", ""), model: env_str("MODEL", "claude-haiku-4-5-20251001"), max_turns: env_u64("MAX_TURNS", 12) as u32, + max_fixes_per_tick: env_u64("MAX_FIXES_PER_TICK", 3), + max_ticks_per_day: env_u64("MAX_TICKS_PER_DAY", 100), + history_max_lines: env_u64("HISTORY_MAX_LINES", 10_000), + tick_log_ttl_days: env_u64("TICK_LOG_TTL_DAYS", 7), + git_gc_interval_days: env_u64("GIT_GC_INTERVAL_DAYS", 7), claude_bin: env_str("CLAUDE_BIN", "claude"), authmux_bin: env_str("AUTHMUX_BIN", "authmux"), claude_accounts_dir: env_path( diff --git a/src/history.rs b/src/history.rs index 59ca1d9..d89b971 100644 --- a/src/history.rs +++ b/src/history.rs @@ -45,6 +45,132 @@ pub fn append(path: &Path, rec: &TickRecord) -> Result<()> { Ok(()) } +/// Rotate `path` if it has more than `max_lines` lines: keeps the newest +/// `max_lines` in place and moves the previous file to `path.1` (overwriting +/// any older rotation). Returns true if rotation happened. 0 = disabled. +/// +/// This is what keeps `history.jsonl` from growing unbounded: at one tick per +/// minute × N memory roots, the file otherwise accumulates forever. +pub fn rotate_if_oversize(path: &Path, max_lines: u64) -> Result { + if max_lines == 0 { + return Ok(false); + } + let Ok(body) = std::fs::read_to_string(path) else { + return Ok(false); + }; + let lines: Vec<&str> = body.lines().collect(); + let n = lines.len() as u64; + if n <= max_lines { + return Ok(false); + } + // Keep newest `max_lines` in place. + let keep_from = lines.len().saturating_sub(max_lines as usize); + let mut kept = lines[keep_from..].join("\n"); + if !kept.is_empty() { + kept.push('\n'); + } + // Move pre-rotation copy to `.1` (a single rotation slot keeps disk + // bounded; older history is intentionally discarded). + let backup = path.with_extension( + path.extension() + .map(|e| format!("{}.1", e.to_string_lossy())) + .unwrap_or_else(|| "1".to_string()), + ); + let tmp = path.with_extension("rot.tmp"); + std::fs::write(&tmp, kept).with_context(|| format!("write {}", tmp.display()))?; + let _ = std::fs::rename(path, &backup); + std::fs::rename(&tmp, path) + .with_context(|| format!("rename to {}", path.display()))?; + Ok(true) +} + +/// Delete `/tmp/cmmd-tick-*.log` files older than `ttl_days`. Returns the +/// number of files removed. 0 = disabled. This is the *biggest* disk eater +/// of the three growth sources because per-tick agent transcripts are large. +pub fn sweep_tick_logs(ttl_days: u64) -> usize { + if ttl_days == 0 { + return 0; + } + let dir = std::path::Path::new("/tmp"); + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + let cutoff = SystemTime::now() + .checked_sub(std::time::Duration::from_secs(ttl_days * 86_400)) + .unwrap_or(UNIX_EPOCH); + let mut removed = 0usize; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_s = name.to_string_lossy(); + if !name_s.starts_with("cmmd-tick-") || !name_s.ends_with(".log") { + continue; + } + let mtime = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(UNIX_EPOCH); + if mtime < cutoff && std::fs::remove_file(entry.path()).is_ok() { + removed += 1; + } + } + removed +} + +/// Run `git gc --prune=now --aggressive` over `memory_root` if it's a git +/// repo and the marker file says we haven't gc'd in `interval_days`. Best +/// effort — failures are logged by the caller, never fatal. The marker lives +/// inside `.git/cmmd-last-gc` so it travels with the repo. +pub fn git_gc_if_due(memory_root: &Path, interval_days: u64) -> Result { + if interval_days == 0 { + return Ok(false); + } + let git_dir = memory_root.join(".git"); + if !git_dir.exists() { + return Ok(false); + } + let marker = git_dir.join("cmmd-last-gc"); + let now = now_unix(); + let interval = interval_days.saturating_mul(86_400); + if let Ok(prev) = std::fs::read_to_string(&marker) { + if let Ok(prev_unix) = prev.trim().parse::() { + if now.saturating_sub(prev_unix) < interval { + return Ok(false); + } + } + } + let status = Command::new("git") + .arg("-C") + .arg(memory_root) + .arg("gc") + .arg("--prune=now") + .arg("--quiet") + .status() + .context("git gc")?; + if !status.success() { + return Err(anyhow::anyhow!("git gc failed")); + } + let _ = std::fs::write(&marker, now.to_string()); + Ok(true) +} + +/// Count files in `memory_root`'s working tree that differ from HEAD. Used +/// to enforce MAX_FIXES_PER_TICK: if the agent edited more files than the +/// allowed cap, the caller can `restore(pre_sha)` to roll the lot back. +pub fn count_dirty_files(memory_root: &Path) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(memory_root) + .arg("status") + .arg("--porcelain") + .output() + .context("git status --porcelain")?; + if !out.status.success() { + return Err(anyhow::anyhow!("git status failed")); + } + let body = String::from_utf8_lossy(&out.stdout); + Ok(body.lines().filter(|l| !l.trim().is_empty()).count()) +} + /// Read the last `n` records (newest first). Cheap for typical n; we read the /// whole file and slice — tick-report.jsonl is rarely huge. pub fn tail(path: &Path, n: usize) -> Vec { @@ -278,4 +404,60 @@ mod tests { let b = new_tick_id(); assert_ne!(a, b); } + + #[test] + fn rotate_keeps_newest_lines_and_writes_backup() { + let dir = unique_tmp("rotate"); + let log = dir.join("ticks.jsonl"); + for i in 0..10 { + append(&log, &sample_rec(&format!("t{i}"))).unwrap(); + } + let rotated = rotate_if_oversize(&log, 4).unwrap(); + assert!(rotated, "should have rotated"); + // The newest 4 must remain. + let after = std::fs::read_to_string(&log).unwrap(); + let kept_lines: Vec<&str> = after.lines().collect(); + assert_eq!(kept_lines.len(), 4); + assert!(kept_lines[3].contains("\"t9\""), "newest line preserved"); + // Backup exists. + let backup = dir.join("ticks.jsonl.1"); + assert!(backup.exists(), "backup file written"); + } + + #[test] + fn rotate_below_threshold_is_a_noop() { + let dir = unique_tmp("rotate-below"); + let log = dir.join("ticks.jsonl"); + append(&log, &sample_rec("only")).unwrap(); + let rotated = rotate_if_oversize(&log, 10).unwrap(); + assert!(!rotated); + } + + #[test] + fn rotate_disabled_with_zero_max() { + let dir = unique_tmp("rotate-zero"); + let log = dir.join("ticks.jsonl"); + for i in 0..5 { + append(&log, &sample_rec(&format!("z{i}"))).unwrap(); + } + assert!(!rotate_if_oversize(&log, 0).unwrap()); + } + + #[test] + fn sweep_tick_logs_ignores_recent_files() { + // Create a fresh file in /tmp matching the pattern; sweeper must + // not touch it because mtime is "now". + let p = std::env::temp_dir() + .join(format!("cmmd-tick-roundtrip-{}.log", std::process::id())); + std::fs::write(&p, "x").unwrap(); + let removed = sweep_tick_logs(7); + assert!(p.exists(), "fresh transcript must survive sweep"); + let _ = std::fs::remove_file(&p); + let _ = removed; + } + + #[test] + fn sweep_tick_logs_disabled_with_zero_ttl() { + assert_eq!(sweep_tick_logs(0), 0); + } } diff --git a/src/main.rs b/src/main.rs index 08cca1f..491e07d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -734,6 +734,48 @@ async fn main_loop( loop { let tick_started = now_unix(); + // --- Growth control --- + // 1. Rotate history.jsonl if oversize. The file accumulates one row + // per tick per root forever otherwise. + // 2. Sweep per-tick agent transcripts (/tmp/cmmd-tick-*.log) older + // than the configured TTL — these are the real disk eater. + // Both run once per iteration before any per-root work so a slow + // tick can't postpone cleanup. + match history::rotate_if_oversize(&cfg.history_file, cfg.history_max_lines) { + Ok(true) => { + info!(file = %cfg.history_file.display(), "history rotated"); + metrics_handle.record_history_rotation(); + } + Ok(false) => {} + Err(e) => warn!("history rotate failed: {e}"), + } + let swept = history::sweep_tick_logs(cfg.tick_log_ttl_days); + if swept > 0 { + info!(count = swept, "swept stale tick transcripts"); + metrics_handle.record_tick_logs_swept(swept as u64); + } + + // --- Daily-tick budget --- + // Roll the per-day counter if UTC date has changed; persist so the + // ceiling survives a restart within the same day. + let today = state::utc_date_string(tick_started); + let mut persisted = state::load(&cfg.state_file); + state::bump_day_if_needed(&mut persisted, &today); + metrics_handle.set_day_ticks_ran(persisted.day_ticks_ran); + let daily_cap_reached = cfg.max_ticks_per_day > 0 + && persisted.day_ticks_ran >= cfg.max_ticks_per_day; + if daily_cap_reached { + warn!( + ran = persisted.day_ticks_ran, + cap = cfg.max_ticks_per_day, + date = %today, + "MAX_TICKS_PER_DAY reached — agent will not be spawned this iteration" + ); + } + if let Err(e) = state::save(&cfg.state_file, &persisted) { + warn!("persist state failed: {e}"); + } + // Always refresh authmux + account dirs first (memory stat updates // per-root below). This is what makes "who is logged in" visible at // any time via `mmctl status`. @@ -783,20 +825,93 @@ async fn main_loop( info!(root = %root.display(), "tending memory root"); let tick_id = history::new_tick_id(); - let outcome = match tick::run(&cfg, root, dry_run_now, &mem, &tick_id).await { - Ok(o) => o, - Err(e) => { - error!(root = %root.display(), "tick error: {e:#}"); - tick::TickOutcome { - ran: false, - reason_skipped: Some(format!("{e}")), - exit_code: None, - audit_total_issues: 0, - pre_tick_sha: None, + let outcome = if daily_cap_reached { + // Cheap path: refuse to spawn, but still record the tick so + // history/metrics reflect that we hit the cap. + metrics_handle.record_daily_cap_block(); + tick::TickOutcome { + ran: false, + reason_skipped: Some(format!( + "MAX_TICKS_PER_DAY={} reached for {}", + cfg.max_ticks_per_day, today + )), + exit_code: None, + audit_total_issues: 0, + pre_tick_sha: None, + } + } else { + match tick::run(&cfg, root, dry_run_now, &mem, &tick_id).await { + Ok(o) => o, + Err(e) => { + error!(root = %root.display(), "tick error: {e:#}"); + tick::TickOutcome { + ran: false, + reason_skipped: Some(format!("{e}")), + exit_code: None, + audit_total_issues: 0, + pre_tick_sha: None, + } } } }; + // --- Enforce MAX_FIXES_PER_TICK --- + // The "≤3 fixes per tick" rule used to live only in the agent + // prompt. If the agent ignored it (or hallucinated more edits), + // there was no daemon-side guard. Now: after a non-dry-run tick + // that produced changes, count dirty files vs the pre-tick SHA + // and roll the whole tick back if it exceeded the cap. + if outcome.ran + && !dry_run_now + && cfg.max_fixes_per_tick > 0 + && cfg.git_track + { + if let Some(pre_sha) = outcome.pre_tick_sha.as_ref() { + match history::count_dirty_files(root) { + Ok(n) if (n as u64) > cfg.max_fixes_per_tick => { + warn!( + changed = n, + cap = cfg.max_fixes_per_tick, + "agent exceeded MAX_FIXES_PER_TICK — reverting to pre-tick snapshot" + ); + if let Err(e) = history::restore(root, pre_sha) { + error!("revert to pre-tick SHA failed: {e}"); + } else { + metrics_handle.record_fix_cap_revert(); + } + } + Ok(_) => {} + Err(e) => warn!("could not count dirty files: {e}"), + } + } + } + + // --- Persisted daily-tick counter --- + // Only count *ran* ticks against the daily cap — skipped ticks + // (lsof guard, audit-clean) cost nothing. Re-read state to avoid + // clobbering any concurrent mmctl mutation. + if outcome.ran { + let mut p = state::load(&cfg.state_file); + state::bump_day_if_needed(&mut p, &today); + p.day_ticks_ran = p.day_ticks_ran.saturating_add(1); + metrics_handle.set_day_ticks_ran(p.day_ticks_ran); + if let Err(e) = state::save(&cfg.state_file, &p) { + warn!("persist day_ticks_ran failed: {e}"); + } + } + + // --- Periodic git gc on this root --- + // Without this the memory dir's .git/objects grows forever. + // Best-effort — failures are logged, never fatal. + match history::git_gc_if_due(root, cfg.git_gc_interval_days) { + Ok(true) => { + info!(root = %root.display(), "git gc complete"); + metrics_handle.record_git_gc(); + } + Ok(false) => {} + Err(e) => warn!(root = %root.display(), "git gc failed: {e}"), + } + let now = now_unix(); if let Some(r) = &outcome.reason_skipped { info!(root = %root.display(), reason = %r, "tick skipped"); diff --git a/src/metrics.rs b/src/metrics.rs index dea8ac9..ce52a5d 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -20,6 +20,17 @@ pub struct Metrics { pub audit_issues_last: AtomicU64, pub history_appends_total: AtomicU64, pub last_tick_unix: AtomicU64, + // Growth-control counters. These exist so a runaway daemon is visible in + // Prometheus before it eats the disk: if `history_rotations_total` is + // climbing fast, ticks are running far too often. + pub history_rotations_total: AtomicU64, + pub tick_logs_swept_total: AtomicU64, + pub git_gc_runs_total: AtomicU64, + // Budget-cap counters. Each increment means the daemon refused to spend + // tokens it would otherwise have spent — i.e. saved you from a bill. + pub ticks_blocked_daily_cap_total: AtomicU64, + pub ticks_reverted_fix_cap_total: AtomicU64, + pub day_ticks_ran: AtomicU64, } impl Metrics { @@ -53,6 +64,32 @@ impl Metrics { self.history_appends_total.fetch_add(1, Ordering::Relaxed); } + pub fn record_history_rotation(&self) { + self.history_rotations_total.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_tick_logs_swept(&self, n: u64) { + self.tick_logs_swept_total.fetch_add(n, Ordering::Relaxed); + } + + pub fn record_git_gc(&self) { + self.git_gc_runs_total.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_daily_cap_block(&self) { + self.ticks_blocked_daily_cap_total + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_fix_cap_revert(&self) { + self.ticks_reverted_fix_cap_total + .fetch_add(1, Ordering::Relaxed); + } + + pub fn set_day_ticks_ran(&self, n: u64) { + self.day_ticks_ran.store(n, Ordering::Relaxed); + } + /// `tick_interval_sec` lets the exporter publish a derived gauge for /// "is the daemon stuck?" — staleness > 2× interval is unhealthy. pub fn render_prometheus(&self, tick_interval_sec: u64) -> String { @@ -124,6 +161,42 @@ impl Metrics { out.push_str("# HELP cmmd_tick_staleness_seconds Seconds since the last tick (helps spot a stuck daemon).\n"); out.push_str("# TYPE cmmd_tick_staleness_seconds gauge\n"); out.push_str(&format!("cmmd_tick_staleness_seconds {}\n", staleness)); + out.push_str("# HELP cmmd_history_rotations_total history.jsonl rotations performed.\n"); + out.push_str("# TYPE cmmd_history_rotations_total counter\n"); + out.push_str(&format!( + "cmmd_history_rotations_total {}\n", + self.history_rotations_total.load(Ordering::Relaxed) + )); + out.push_str("# HELP cmmd_tick_logs_swept_total Per-tick agent transcript files deleted by TTL sweeper.\n"); + out.push_str("# TYPE cmmd_tick_logs_swept_total counter\n"); + out.push_str(&format!( + "cmmd_tick_logs_swept_total {}\n", + self.tick_logs_swept_total.load(Ordering::Relaxed) + )); + out.push_str("# HELP cmmd_git_gc_runs_total git gc invocations across MEMORY_ROOTS.\n"); + out.push_str("# TYPE cmmd_git_gc_runs_total counter\n"); + out.push_str(&format!( + "cmmd_git_gc_runs_total {}\n", + self.git_gc_runs_total.load(Ordering::Relaxed) + )); + out.push_str("# HELP cmmd_ticks_blocked_daily_cap_total Ticks aborted because MAX_TICKS_PER_DAY was reached.\n"); + out.push_str("# TYPE cmmd_ticks_blocked_daily_cap_total counter\n"); + out.push_str(&format!( + "cmmd_ticks_blocked_daily_cap_total {}\n", + self.ticks_blocked_daily_cap_total.load(Ordering::Relaxed) + )); + out.push_str("# HELP cmmd_ticks_reverted_fix_cap_total Ticks rolled back because the agent exceeded MAX_FIXES_PER_TICK.\n"); + out.push_str("# TYPE cmmd_ticks_reverted_fix_cap_total counter\n"); + out.push_str(&format!( + "cmmd_ticks_reverted_fix_cap_total {}\n", + self.ticks_reverted_fix_cap_total.load(Ordering::Relaxed) + )); + out.push_str("# HELP cmmd_day_ticks_ran Ticks ran today (UTC). Resets at midnight.\n"); + out.push_str("# TYPE cmmd_day_ticks_ran gauge\n"); + out.push_str(&format!( + "cmmd_day_ticks_ran {}\n", + self.day_ticks_ran.load(Ordering::Relaxed) + )); out } } diff --git a/src/state.rs b/src/state.rs index 1fda5c3..c318279 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,6 +10,46 @@ pub struct PersistedState { /// Runtime override of the configured dry_run. None = no override /// (use the configured value). pub dry_run_override: Option, + + /// Date (UTC, "YYYY-MM-DD") that `day_ticks_ran` is counting against. + /// When the daemon notices the date has rolled over, it resets the + /// counter. Persisted so the cap survives restarts within the same day. + #[serde(default)] + pub day_ticks_ran_date: String, + /// Count of *ran* ticks (not skipped) recorded against + /// `day_ticks_ran_date`. The MAX_TICKS_PER_DAY ceiling is checked + /// against this before each agent spawn. + #[serde(default)] + pub day_ticks_ran: u64, +} + +/// Reset the daily counter if the UTC date has rolled. Returns the current +/// counter value (post-reset). +pub fn bump_day_if_needed(state: &mut PersistedState, today_utc: &str) -> u64 { + if state.day_ticks_ran_date != today_utc { + state.day_ticks_ran_date = today_utc.to_string(); + state.day_ticks_ran = 0; + } + state.day_ticks_ran +} + +/// UTC-date string ("YYYY-MM-DD") for an absolute unix timestamp. Avoids a +/// chrono dependency — the daemon doesn't need any other date math. +pub fn utc_date_string(unix: u64) -> String { + // Days since 1970-01-01 (Thursday). + let days = (unix / 86_400) as i64; + // Civil-from-days algorithm (Howard Hinnant's date.h, public domain). + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let y = if m <= 2 { y + 1 } else { y }; + format!("{:04}-{:02}-{:02}", y, m, d) } pub fn load(path: &Path) -> PersistedState { @@ -29,3 +69,45 @@ pub fn save(path: &Path, state: &PersistedState) -> Result<()> { std::fs::rename(&tmp, path).with_context(|| format!("rename to {}", path.display()))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn utc_date_known_anchors() { + // 1970-01-01 00:00:00 UTC = unix 0 + assert_eq!(utc_date_string(0), "1970-01-01"); + // 2026-05-18 00:00:00 UTC = 1_779_062_400 (20591 days × 86400) + assert_eq!(utc_date_string(1_779_062_400), "2026-05-18"); + // 2026-05-18 23:59:59 UTC stays on the same day + assert_eq!(utc_date_string(1_779_148_799), "2026-05-18"); + // 2026-05-19 00:00:00 UTC rolls over + assert_eq!(utc_date_string(1_779_148_800), "2026-05-19"); + // Leap-year edge: 2024-02-29 + assert_eq!(utc_date_string(1_709_164_800), "2024-02-29"); + } + + #[test] + fn bump_day_resets_counter_on_date_change() { + let mut s = PersistedState { + day_ticks_ran_date: "2026-05-17".to_string(), + day_ticks_ran: 42, + ..Default::default() + }; + bump_day_if_needed(&mut s, "2026-05-18"); + assert_eq!(s.day_ticks_ran, 0); + assert_eq!(s.day_ticks_ran_date, "2026-05-18"); + } + + #[test] + fn bump_day_preserves_counter_within_same_day() { + let mut s = PersistedState { + day_ticks_ran_date: "2026-05-18".to_string(), + day_ticks_ran: 7, + ..Default::default() + }; + bump_day_if_needed(&mut s, "2026-05-18"); + assert_eq!(s.day_ticks_ran, 7); + } +}