use std::collections::BTreeMap;
use std::time::Duration;
use serde::Serialize;
use crate::harness::Usage;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResourceBudget {
pub max_total_tokens: Option<u64>,
pub max_cost_usd: Option<f64>,
pub max_wall_time: Option<Duration>,
pub max_processes: Option<u32>,
pub max_storage_bytes: Option<u64>,
pub max_identical_failures: Option<u32>,
}
impl ResourceBudget {
#[must_use]
pub fn identical_failure_breach(&self, count: u32) -> Option<String> {
self.max_identical_failures.and_then(|cap| {
(cap > 0 && count >= cap).then(|| {
format!(
"repeated-identical-failure breaker: the same failure was observed {count} \
time(s) (ceiling {cap})"
)
})
})
}
pub const UNLIMITED: ResourceBudget = ResourceBudget {
max_total_tokens: None,
max_cost_usd: None,
max_wall_time: None,
max_processes: None,
max_storage_bytes: None,
max_identical_failures: None,
};
#[must_use]
pub const fn live_default() -> Self {
Self {
max_total_tokens: Some(2_000_000),
max_cost_usd: Some(10.0),
max_wall_time: Some(Duration::from_secs(3600)),
max_processes: Some(50),
max_storage_bytes: Some(2 * 1024 * 1024 * 1024),
max_identical_failures: Some(3),
}
}
}
#[must_use]
pub fn failure_fingerprint(
chunk_id: &str,
tier: &str,
status: &str,
findings: &[String],
) -> String {
let mut parts: Vec<String> = findings.iter().map(|f| normalize_volatile(f)).collect();
parts.sort_unstable();
format!(
"{chunk_id}\u{1}{tier}\u{1}{status}\u{1}{}",
parts.join("\u{1}")
)
}
#[must_use]
fn normalize_volatile(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut run = String::new();
let flush = |run: &mut String, out: &mut String| {
if run.len() >= 7 && run.chars().all(|c| c.is_ascii_hexdigit()) {
out.push_str("<hex>");
} else {
out.push_str(run);
}
run.clear();
};
for ch in s.chars() {
if ch.is_ascii_hexdigit() {
run.push(ch);
} else {
flush(&mut run, &mut out);
out.push(ch);
}
}
flush(&mut run, &mut out);
out
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct ResourceMeter {
pub total_tokens: u64,
pub cost_usd: f64,
pub processes: u32,
pub storage_bytes: u64,
#[serde(skip)]
failure_counts: BTreeMap<String, u32>,
}
impl ResourceMeter {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record_agent_run(&mut self, usage: Option<&Usage>) {
self.processes = self.processes.saturating_add(1);
if let Some(u) = usage {
let components = u
.input_tokens
.unwrap_or(0)
.saturating_add(u.output_tokens.unwrap_or(0));
let tokens = u.total_tokens.map_or(components, |t| t.max(components));
self.total_tokens = self.total_tokens.saturating_add(tokens);
if let Some(c) = u.cost_usd {
if c.is_finite() && c > 0.0 {
let next = self.cost_usd + c;
if next.is_finite() {
self.cost_usd = next;
}
}
}
}
}
pub fn observe_storage_bytes(&mut self, bytes: u64) {
self.storage_bytes = self.storage_bytes.max(bytes);
}
pub fn record_failure(&mut self, fingerprint: &str) -> u32 {
let c = self
.failure_counts
.entry(fingerprint.to_string())
.or_insert(0);
*c = c.saturating_add(1);
*c
}
#[must_use]
pub fn breach(&self, budget: &ResourceBudget, elapsed: Duration) -> Option<String> {
if let Some(cap) = budget.max_cost_usd {
if cap > 0.0 && self.cost_usd > cap {
return Some(format!(
"cost ceiling exceeded: ${:.4} spent > ${cap:.4} ceiling",
self.cost_usd
));
}
}
if let Some(cap) = budget.max_total_tokens {
if cap > 0 && self.total_tokens > cap {
return Some(format!(
"token ceiling exceeded: {} tokens > {cap} ceiling",
self.total_tokens
));
}
}
if let Some(cap) = budget.max_wall_time {
if !cap.is_zero() && elapsed > cap {
return Some(format!(
"wall-time ceiling exceeded: {:.1}s elapsed > {}s ceiling",
elapsed.as_secs_f64(),
cap.as_secs()
));
}
}
if let Some(cap) = budget.max_processes {
if cap > 0 && self.processes > cap {
return Some(format!(
"process-count ceiling exceeded: {} agent invocation(s) > {cap} ceiling",
self.processes
));
}
}
if let Some(cap) = budget.max_storage_bytes {
if cap > 0 && self.storage_bytes > cap {
return Some(format!(
"storage ceiling exceeded: {} bytes > {cap} bytes ceiling",
self.storage_bytes
));
}
}
None
}
}
#[must_use]
pub fn dir_size_bytes(root: &std::path::Path) -> u64 {
let mut acc: u64 = 0;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else {
continue;
};
if meta.is_dir() {
stack.push(entry.path());
} else {
acc = acc.saturating_add(meta.len());
}
}
}
acc
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(tokens: u64, cost: f64) -> Usage {
Usage {
input_tokens: None,
output_tokens: None,
total_tokens: Some(tokens),
cost_usd: Some(cost),
}
}
#[test]
fn unlimited_never_breaches() {
let mut m = ResourceMeter::new();
m.record_agent_run(Some(&usage(1_000_000, 999.0)));
m.observe_storage_bytes(u64::MAX);
assert_eq!(
m.breach(&ResourceBudget::UNLIMITED, Duration::from_secs(1)),
None
);
}
#[test]
fn cost_ceiling_trips_with_kill_switch_ordering() {
let mut m = ResourceMeter::new();
m.record_agent_run(Some(&usage(10_000_000, 25.0)));
let budget = ResourceBudget {
max_cost_usd: Some(10.0),
max_total_tokens: Some(1_000),
..ResourceBudget::UNLIMITED
};
let msg = m.breach(&budget, Duration::ZERO).expect("cost breach");
assert!(msg.contains("cost ceiling exceeded"), "{msg}");
}
#[test]
fn token_ceiling_trips() {
let mut m = ResourceMeter::new();
m.record_agent_run(Some(&usage(2_500, 0.0)));
let budget = ResourceBudget {
max_total_tokens: Some(2_000),
..ResourceBudget::UNLIMITED
};
assert!(m
.breach(&budget, Duration::ZERO)
.unwrap()
.contains("token ceiling"));
}
#[test]
fn wall_time_ceiling_trips_only_past_the_cap() {
let m = ResourceMeter::new();
let budget = ResourceBudget {
max_wall_time: Some(Duration::from_secs(60)),
..ResourceBudget::UNLIMITED
};
assert_eq!(
m.breach(&budget, Duration::from_secs(60)),
None,
"at cap is fine"
);
assert!(m
.breach(&budget, Duration::from_secs(61))
.unwrap()
.contains("wall-time"));
}
#[test]
fn process_ceiling_counts_every_agent_run_including_no_usage() {
let mut m = ResourceMeter::new();
for _ in 0..3 {
m.record_agent_run(None); }
let budget = ResourceBudget {
max_processes: Some(2),
..ResourceBudget::UNLIMITED
};
assert!(m
.breach(&budget, Duration::ZERO)
.unwrap()
.contains("process-count"));
}
#[test]
fn storage_keeps_high_water_mark() {
let mut m = ResourceMeter::new();
m.observe_storage_bytes(5_000);
m.observe_storage_bytes(1_000); let budget = ResourceBudget {
max_storage_bytes: Some(4_000),
..ResourceBudget::UNLIMITED
};
assert!(m
.breach(&budget, Duration::ZERO)
.unwrap()
.contains("storage ceiling"));
}
#[test]
fn zero_ceiling_disables_the_breaker() {
let mut m = ResourceMeter::new();
m.record_agent_run(Some(&usage(9_999, 9_999.0)));
m.observe_storage_bytes(u64::MAX);
let budget = ResourceBudget {
max_total_tokens: Some(0),
max_cost_usd: Some(0.0),
max_processes: Some(0),
max_storage_bytes: Some(0),
max_wall_time: Some(Duration::ZERO),
max_identical_failures: Some(0),
};
assert_eq!(m.breach(&budget, Duration::from_secs(10_000)), None);
assert_eq!(budget.identical_failure_breach(100), None);
}
#[test]
fn negative_or_nonfinite_cost_is_ignored() {
let mut m = ResourceMeter::new();
m.record_agent_run(Some(&Usage {
input_tokens: None,
output_tokens: None,
total_tokens: None,
cost_usd: Some(-5.0),
}));
m.record_agent_run(Some(&Usage {
input_tokens: None,
output_tokens: None,
total_tokens: None,
cost_usd: Some(f64::NAN),
}));
assert!(
m.cost_usd.abs() < f64::EPSILON,
"cost stayed zero: {}",
m.cost_usd
);
}
#[test]
fn tokens_fall_back_to_input_plus_output() {
let mut m = ResourceMeter::new();
m.record_agent_run(Some(&Usage {
input_tokens: Some(700),
output_tokens: Some(300),
total_tokens: None,
cost_usd: None,
}));
assert_eq!(m.total_tokens, 1_000);
}
#[test]
fn identical_failure_breaker_trips_on_the_nth_recurrence() {
let mut m = ResourceMeter::new();
let budget = ResourceBudget {
max_identical_failures: Some(3),
..ResourceBudget::UNLIMITED
};
let fp = failure_fingerprint(
"c1",
"code",
"chunk_floor_blocked",
&["test regressed: t".into()],
);
assert_eq!(m.record_failure(&fp), 1);
assert_eq!(budget.identical_failure_breach(1), None);
assert_eq!(m.record_failure(&fp), 2);
assert_eq!(budget.identical_failure_breach(2), None);
assert_eq!(m.record_failure(&fp), 3);
assert!(budget
.identical_failure_breach(3)
.unwrap()
.contains("repeated-identical-failure"));
}
#[test]
fn distinct_failures_do_not_aggregate() {
let mut m = ResourceMeter::new();
let a = failure_fingerprint(
"c1",
"code",
"chunk_floor_blocked",
&["test regressed: a".into()],
);
let b = failure_fingerprint(
"c1",
"code",
"chunk_floor_blocked",
&["test regressed: b".into()],
);
assert_eq!(m.record_failure(&a), 1);
assert_eq!(
m.record_failure(&b),
1,
"a different finding is a different key"
);
}
#[test]
fn fingerprint_is_order_insensitive_over_findings() {
let a = failure_fingerprint("c1", "code", "s", &["x".into(), "y".into()]);
let b = failure_fingerprint("c1", "code", "s", &["y".into(), "x".into()]);
assert_eq!(a, b, "reordered findings must fingerprint identically");
}
#[test]
fn fingerprint_is_tier_scoped_so_promotion_resets_the_count() {
let f = &["test regressed: t".into()];
let code = failure_fingerprint("c1", "code", "chunk_floor_blocked", f);
let mid = failure_fingerprint("c1", "mid", "chunk_floor_blocked", f);
assert_ne!(code, mid, "tier is part of the key");
}
#[test]
fn fingerprint_normalizes_volatile_commit_oids() {
let a = failure_fingerprint(
"c1",
"code",
"chunk_failed",
&[
"harness moved HEAD to a1b2c3d4e5f6a7b8, not a descendant of deadbeefcafe1234"
.into(),
],
);
let b = failure_fingerprint(
"c1",
"code",
"chunk_failed",
&[
"harness moved HEAD to 99887766554433aa, not a descendant of 0011223344556677"
.into(),
],
);
assert_eq!(a, b, "oid-only differences must normalize to the same key");
}
#[test]
fn normalize_volatile_leaves_short_numbers_alone() {
assert_eq!(normalize_volatile("count 8 to 5"), "count 8 to 5");
assert_eq!(
normalize_volatile("commit abcdef01 landed"),
"commit <hex> landed"
);
}
}