Skip to main content

mur_common/
limits.rs

1//! Execution limits: the one `limits:` block every scope carries (spec
2//! 2026-09-12 execution-limits §3.1), its duration grammar, the built-in
3//! defaults (§3.3) and the resolver that says what is in force and where it
4//! came from (§3.9).
5//!
6//! Three knobs, no more: `deadline` (wall clock for the unit of work),
7//! `stuck` (minutes of no progress before a stop, or `off`) and `cost_usd`
8//! (only meaningful on a metered model — applicability is the caller's call,
9//! this module resolves the number). Inner scopes REPLACE a key; nothing here
10//! adds two caps together, because the product of caps is the problem the
11//! spec exists to remove.
12
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17/// The block as written in YAML. Every key optional; an absent key means
18/// "inherit", never "unlimited" — `stuck: off` is the explicit opt-out.
19#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct Limits {
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub deadline: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub stuck: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub cost_usd: Option<f64>,
28}
29
30impl Limits {
31    pub fn is_empty(&self) -> bool {
32        self.deadline.is_none() && self.stuck.is_none() && self.cost_usd.is_none()
33    }
34}
35
36/// The stuck detector's setting once resolved.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Stuck {
39    Off,
40    After(Duration),
41}
42
43/// Where a resolved value came from — the half of `mur limits` that makes it
44/// worth running.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Source {
47    BuiltIn,
48    Global,
49    Fleet,
50    Agent,
51    Flag,
52}
53
54impl Source {
55    pub fn label(self) -> &'static str {
56        match self {
57            Source::BuiltIn => "built-in default",
58            Source::Global => "~/.mur/config.yaml",
59            Source::Fleet => "fleet.yaml",
60            Source::Agent => "profile.yaml",
61            Source::Flag => "command-line flag",
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct Resolved<T> {
68    pub value: T,
69    pub source: Source,
70}
71
72#[derive(Debug, Clone, PartialEq)]
73pub struct ResolvedLimits {
74    pub deadline: Resolved<Option<Duration>>,
75    pub stuck: Resolved<Stuck>,
76    pub cost_usd: Resolved<Option<f64>>,
77}
78
79/// Which built-in deadline applies when no scope sets one.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Scope {
82    FleetRun,
83    SingleTask,
84}
85
86/// §3.3 — the constants `mur limits` prints as `(built-in default)`.
87pub const DEFAULT_STUCK: Duration = Duration::from_secs(10 * 60);
88pub const DEFAULT_DEADLINE_FLEET: Duration = Duration::from_secs(60 * 60);
89pub const DEFAULT_DEADLINE_TASK: Duration = Duration::from_secs(30 * 60);
90
91/// `30s`, `5m`, `2h`, `1d`, `1h30m`, or a bare integer (seconds). `None` on
92/// anything else — the caller turns that into a load error naming the key.
93pub fn parse_duration(s: &str) -> Option<Duration> {
94    let s = s.trim();
95    if s.is_empty() {
96        return None;
97    }
98    if let Ok(n) = s.parse::<u64>() {
99        return Some(Duration::from_secs(n));
100    }
101    let mut total: u64 = 0;
102    let mut num = String::new();
103    let mut saw_unit = false;
104    for c in s.chars() {
105        if c.is_ascii_digit() {
106            num.push(c);
107            continue;
108        }
109        let n: u64 = num.parse().ok()?;
110        num.clear();
111        let mult = match c {
112            's' => 1,
113            'm' => 60,
114            'h' => 3600,
115            'd' => 86_400,
116            _ => return None,
117        };
118        total = total.checked_add(n.checked_mul(mult)?)?;
119        saw_unit = true;
120    }
121    if !num.is_empty() || !saw_unit {
122        return None;
123    }
124    Some(Duration::from_secs(total))
125}
126
127fn parse_stuck(s: &str) -> Option<Stuck> {
128    if s.trim().eq_ignore_ascii_case("off") {
129        return Some(Stuck::Off);
130    }
131    parse_duration(s).map(Stuck::After)
132}
133
134/// §4: an unparsable value is an error naming the key, never a silent default.
135pub fn validate(l: &Limits) -> Result<(), String> {
136    if let Some(d) = &l.deadline
137        && parse_duration(d).is_none()
138    {
139        return Err(format!(
140            "limits.deadline: `{d}` is not a duration (30s, 5m, 2h, 1h30m, 1d)"
141        ));
142    }
143    if let Some(s) = &l.stuck
144        && parse_stuck(s).is_none()
145    {
146        return Err(format!("limits.stuck: `{s}` is not a duration or `off`"));
147    }
148    if let Some(c) = l.cost_usd
149        && !(c.is_finite() && c >= 0.0)
150    {
151        return Err(format!(
152            "limits.cost_usd: `{c}` must be a non-negative number"
153        ));
154    }
155    Ok(())
156}
157
158/// The resolver. Precedence, narrowest wins: flag > agent > fleet > global >
159/// built-in. Each key is resolved on its own; a scope that sets only `stuck`
160/// leaves `deadline` to the next scope out.
161pub fn resolve(
162    scope: Scope,
163    global: &Limits,
164    fleet: Option<&Limits>,
165    agent: Option<&Limits>,
166    flags: &Limits,
167) -> Result<ResolvedLimits, String> {
168    for (l, who) in [
169        (Some(flags), "flag"),
170        (agent, "profile.yaml"),
171        (fleet, "fleet.yaml"),
172        (Some(global), "config.yaml"),
173    ] {
174        if let Some(l) = l {
175            validate(l).map_err(|e| format!("{who}: {e}"))?;
176        }
177    }
178    // Narrowest first; the first scope that carries the key wins.
179    let layers: [(Option<&Limits>, Source); 4] = [
180        (Some(flags), Source::Flag),
181        (agent, Source::Agent),
182        (fleet, Source::Fleet),
183        (Some(global), Source::Global),
184    ];
185    let pick = |get: &dyn Fn(&Limits) -> bool| -> Option<(&Limits, Source)> {
186        layers
187            .iter()
188            .find_map(|(l, src)| l.filter(|l| get(l)).map(|l| (l, *src)))
189    };
190
191    let deadline = match pick(&|l| l.deadline.is_some()) {
192        Some((l, src)) => Resolved {
193            value: l.deadline.as_deref().and_then(parse_duration),
194            source: src,
195        },
196        None => Resolved {
197            value: Some(match scope {
198                Scope::FleetRun => DEFAULT_DEADLINE_FLEET,
199                Scope::SingleTask => DEFAULT_DEADLINE_TASK,
200            }),
201            source: Source::BuiltIn,
202        },
203    };
204    let stuck = match pick(&|l| l.stuck.is_some()) {
205        Some((l, src)) => Resolved {
206            value: l
207                .stuck
208                .as_deref()
209                .and_then(parse_stuck)
210                .unwrap_or(Stuck::After(DEFAULT_STUCK)),
211            source: src,
212        },
213        None => Resolved {
214            value: Stuck::After(DEFAULT_STUCK),
215            source: Source::BuiltIn,
216        },
217    };
218    let cost_usd = match pick(&|l| l.cost_usd.is_some()) {
219        Some((l, src)) => Resolved {
220            value: l.cost_usd,
221            source: src,
222        },
223        None => Resolved {
224            value: None,
225            source: Source::BuiltIn,
226        },
227    };
228    Ok(ResolvedLimits {
229        deadline,
230        stuck,
231        cost_usd,
232    })
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn l(deadline: Option<&str>, stuck: Option<&str>, cost: Option<f64>) -> Limits {
240        Limits {
241            deadline: deadline.map(str::to_string),
242            stuck: stuck.map(str::to_string),
243            cost_usd: cost,
244        }
245    }
246
247    #[test]
248    fn durations_parse_the_spec_grammar_and_nothing_else() {
249        assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
250        assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
251        assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
252        assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86_400)));
253        assert_eq!(parse_duration("1h30m"), Some(Duration::from_secs(5400)));
254        assert_eq!(parse_duration(" 90 "), Some(Duration::from_secs(90)));
255        for bad in ["", "off", "2 hours", "1h3", "h", "-5m", "1.5h"] {
256            assert_eq!(parse_duration(bad), None, "{bad:?} must not parse");
257        }
258    }
259
260    #[test]
261    fn narrowest_scope_wins_per_key_and_never_combines() {
262        let global = l(Some("4h"), Some("20m"), Some(50.0));
263        let fleet = l(Some("2h"), None, None);
264        let agent = l(None, Some("off"), None);
265        let r = resolve(
266            Scope::FleetRun,
267            &global,
268            Some(&fleet),
269            Some(&agent),
270            &Limits::default(),
271        )
272        .unwrap();
273        assert_eq!(r.deadline.value, Some(Duration::from_secs(7200)));
274        assert_eq!(
275            r.deadline.source,
276            Source::Fleet,
277            "fleet set it, agent did not"
278        );
279        assert_eq!(r.stuck.value, Stuck::Off);
280        assert_eq!(r.stuck.source, Source::Agent);
281        assert_eq!(r.cost_usd.value, Some(50.0));
282        assert_eq!(r.cost_usd.source, Source::Global, "nobody narrower set it");
283
284        // A flag beats everyone, for its key only.
285        let flags = l(Some("10m"), None, None);
286        let r = resolve(Scope::FleetRun, &global, Some(&fleet), Some(&agent), &flags).unwrap();
287        assert_eq!(r.deadline.source, Source::Flag);
288        assert_eq!(r.stuck.source, Source::Agent);
289    }
290
291    #[test]
292    fn built_in_defaults_fill_what_no_scope_set_and_say_so() {
293        let none = Limits::default();
294        let r = resolve(Scope::FleetRun, &none, None, None, &none).unwrap();
295        assert_eq!(r.deadline.value, Some(DEFAULT_DEADLINE_FLEET));
296        assert_eq!(r.deadline.source, Source::BuiltIn);
297        assert_eq!(r.stuck.value, Stuck::After(DEFAULT_STUCK));
298        assert_eq!(r.cost_usd.value, None);
299        let r = resolve(Scope::SingleTask, &none, None, None, &none).unwrap();
300        assert_eq!(r.deadline.value, Some(DEFAULT_DEADLINE_TASK));
301    }
302
303    #[test]
304    fn a_bad_value_is_an_error_that_names_the_key_and_the_scope() {
305        let bad = l(Some("soon"), None, None);
306        let e = resolve(
307            Scope::FleetRun,
308            &Limits::default(),
309            Some(&bad),
310            None,
311            &Limits::default(),
312        )
313        .unwrap_err();
314        assert!(
315            e.contains("fleet.yaml") && e.contains("limits.deadline") && e.contains("soon"),
316            "{e}"
317        );
318        let e = validate(&l(None, Some("sometimes"), None)).unwrap_err();
319        assert!(e.contains("limits.stuck"), "{e}");
320        let e = validate(&l(None, None, Some(-1.0))).unwrap_err();
321        assert!(e.contains("limits.cost_usd"), "{e}");
322    }
323
324    #[test]
325    fn unknown_keys_are_rejected_at_load() {
326        let e = serde_yaml_ng::from_str::<Limits>("deadline: 1h\nmax_iterations: 5\n").unwrap_err();
327        assert!(e.to_string().contains("max_iterations"), "{e}");
328        let ok: Limits = serde_yaml_ng::from_str("stuck: off\n").unwrap();
329        assert_eq!(ok.stuck.as_deref(), Some("off"));
330        assert!(serde_yaml_ng::to_string(&Limits::default()).unwrap().trim() == "{}");
331    }
332}