Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>.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,
Expand Down Expand Up @@ -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(
Expand Down
182 changes: 182 additions & 0 deletions src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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<bool> {
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::<u64>() {
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<usize> {
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<TickRecord> {
Expand Down Expand Up @@ -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);
}
}
135 changes: 125 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading