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