Skip to main content

artifactize_core/
limits.rs

1//! Machine-wide limits in `$STATE/limits.json`: `backends` caps how many Agent reviews of a
2//! backend (a built-in or custom provider name) are in flight at once across every `verify`
3//! that shares the state directory, `agentSessions` sets whether Agent conversations are saved
4//! and how large their store grows, and `signoffIdleGraceSeconds` sets how long a Run with only
5//! unclaimed sign-offs left stays open.
6//!
7//! ```json
8//! {"backends": {"codex": 4, "openai": 16},
9//!  "agentSessions": {"enabled": true, "maxBytes": 1073741824, "targetBytes": 805306368},
10//!  "signoffIdleGraceSeconds": 120}
11//! ```
12
13use std::{collections::BTreeMap, io::Write, path::Path, time::Duration};
14
15use serde::{Deserialize, Serialize};
16
17use crate::config::Backend;
18#[cfg(test)]
19mod backend_tests;
20
21pub const FILE: &str = "limits.json";
22/// Machine-wide limits are a small declaration; reject oversized files before JSON allocation.
23const MAX_BYTES: u64 = 64 * 1024;
24/// Reject implausible capacity declarations while allowing large hosts; absent backends
25/// remain unlimited rather than being silently clamped to this declared-slot bound.
26const MAX_SLOTS: u32 = 100_000;
27/// The session store's default size that starts a collection: 1 GiB.
28pub const SESSIONS_MAX_BYTES: u64 = 1024 * 1024 * 1024;
29/// The size a collection brings the session store down to by default: 768 MiB.
30pub const SESSIONS_TARGET_BYTES: u64 = 768 * 1024 * 1024;
31/// Collect to three quarters of a small declared maximum to leave growth headroom.
32/// Divide first, preserving quarter-block rounding and avoiding multiplication overflow.
33const SESSION_TARGET_NUMERATOR: u64 = 3;
34const SESSION_TARGET_DENOMINATOR: u64 = 4;
35/// How long a Run with only unclaimed sign-offs left stays open by default: time for a person
36/// who just asked for a sign-off to claim it before the Run ends.
37pub const SIGNOFF_IDLE_GRACE: Duration = Duration::from_secs(120);
38/// The longest idle grace accepted, a day: longer would keep a forgotten Run open for good.
39pub const MAX_SIGNOFF_IDLE_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
40/// The field `signoffIdleGraceSeconds` is saved under.
41const GRACE_FIELD: &str = "signoffIdleGraceSeconds";
42
43/// Backend names to their machine-wide slot counts; a backend without an entry is unlimited.
44/// It serializes as the limits in force, in the file's own field names.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Limits {
48    backends: BTreeMap<Backend, u32>,
49    agent_sessions: AgentSessions,
50    #[serde(rename = "signoffIdleGraceSeconds", serialize_with = "seconds")]
51    signoff_idle_grace: Duration,
52}
53
54fn seconds<S: serde::Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
55    serializer.serialize_u64(duration.as_secs())
56}
57
58impl Default for Limits {
59    fn default() -> Self {
60        Self {
61            backends: BTreeMap::new(),
62            agent_sessions: AgentSessions::default(),
63            signoff_idle_grace: SIGNOFF_IDLE_GRACE,
64        }
65    }
66}
67
68/// Saving Agent conversations and the size bounds of their store. When the store grows past
69/// `max_bytes`, the oldest sessions are deleted until it is at or below `target_bytes`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct AgentSessions {
73    pub enabled: bool,
74    pub max_bytes: u64,
75    pub target_bytes: u64,
76}
77
78impl Default for AgentSessions {
79    fn default() -> Self {
80        Self {
81            enabled: true,
82            max_bytes: SESSIONS_MAX_BYTES,
83            target_bytes: SESSIONS_TARGET_BYTES,
84        }
85    }
86}
87
88#[derive(Deserialize)]
89#[serde(deny_unknown_fields)]
90struct Declared {
91    #[serde(default, deserialize_with = "backend_limits")]
92    backends: BTreeMap<Backend, u32>,
93    #[serde(default, rename = "agentSessions")]
94    agent_sessions: Option<DeclaredSessions>,
95    #[serde(default, rename = "signoffIdleGraceSeconds")]
96    signoff_idle_grace_seconds: Option<u64>,
97}
98
99/// A declared idle grace, in whole seconds, within its bound.
100fn grace(seconds: u64) -> Result<Duration, String> {
101    let grace = Duration::from_secs(seconds);
102    if grace > MAX_SIGNOFF_IDLE_GRACE {
103        return Err(format!(
104            "{GRACE_FIELD} must be at most {}.",
105            MAX_SIGNOFF_IDLE_GRACE.as_secs()
106        ));
107    }
108    Ok(grace)
109}
110
111fn backend_limits<'de, D: serde::Deserializer<'de>>(
112    deserializer: D,
113) -> Result<BTreeMap<Backend, u32>, D::Error> {
114    // Parse each external name once, retain its Backend, and preserve existing diagnostics
115    // and lexicographic validation order at this declaration boundary.
116    let names = BTreeMap::<String, u32>::deserialize(deserializer)?;
117    names
118        .into_iter()
119        .map(|(name, limit)| {
120            let backend =
121                serde_json::from_value::<Backend>(serde_json::Value::String(name.clone()))
122                    .map_err(|error| serde::de::Error::custom(format!("backends: {error}")))?;
123            if !(1..=MAX_SLOTS).contains(&limit) {
124                return Err(serde::de::Error::custom(format!(
125                    "backends.{name} must be between 1 and {MAX_SLOTS}."
126                )));
127            }
128            Ok((backend, limit))
129        })
130        .collect()
131}
132
133#[derive(Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135struct DeclaredSessions {
136    enabled: Option<bool>,
137    max_bytes: Option<u64>,
138    target_bytes: Option<u64>,
139}
140
141impl DeclaredSessions {
142    /// The declared bounds over the defaults. Without `targetBytes`, a `maxBytes` at or below
143    /// the 768 MiB default collects down to three quarters of it.
144    fn resolve(self) -> Result<AgentSessions, String> {
145        let max_bytes = self.max_bytes.unwrap_or(SESSIONS_MAX_BYTES);
146        if max_bytes == 0 {
147            return Err("agentSessions.maxBytes must be at least 1.".into());
148        }
149        let target_bytes = match self.target_bytes {
150            Some(target) if target >= max_bytes => {
151                return Err(format!(
152                    "agentSessions.targetBytes ({target}) must be lower than maxBytes ({max_bytes})."
153                ));
154            }
155            Some(target) => target,
156            None if SESSIONS_TARGET_BYTES < max_bytes => SESSIONS_TARGET_BYTES,
157            None => max_bytes / SESSION_TARGET_DENOMINATOR * SESSION_TARGET_NUMERATOR,
158        };
159        Ok(AgentSessions {
160            enabled: self.enabled.unwrap_or(true),
161            max_bytes,
162            target_bytes,
163        })
164    }
165}
166
167impl Limits {
168    /// Read `$STATE/limits.json`. No file means no limits; a file that is not a regular file,
169    /// is not valid, names an unknown backend or a limit outside 1–100000, or declares
170    /// `agentSessions` bounds out of order is an error.
171    pub fn read(state: &Path) -> Result<Self, String> {
172        let path = state.join(FILE);
173        let invalid = |message: String| format!("{}: {message}", crate::platform::path_text(&path));
174        let too_large =
175            || invalid("limits.json must be a regular file no larger than 64 KiB.".into());
176        let file = match crate::platform::open_regular(&path) {
177            Ok(file) => file,
178            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
179                return Ok(Self::default());
180            }
181            Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {
182                return Err(too_large());
183            }
184            Err(error) => return Err(invalid(error.to_string())),
185        };
186        let mut text = String::new();
187        std::io::Read::read_to_string(&mut std::io::Read::take(&file, MAX_BYTES + 1), &mut text)
188            .map_err(|e| invalid(e.to_string()))?;
189        if text.len() as u64 > MAX_BYTES {
190            return Err(too_large());
191        }
192        let declared: Declared = serde_json::from_str(&text).map_err(|error| {
193            let message = error.to_string();
194            // Backend validation previously followed decoding, so its diagnostics had no
195            // JSON location suffix. Preserve that contract when parsing names at the edge.
196            let message = if message.starts_with("backends:") || message.starts_with("backends.") {
197                message
198                    .strip_suffix(&format!(
199                        " at line {} column {}",
200                        error.line(),
201                        error.column()
202                    ))
203                    .unwrap_or(&message)
204            } else {
205                &message
206            };
207            invalid(message.to_owned())
208        })?;
209        let agent_sessions = declared
210            .agent_sessions
211            .map(DeclaredSessions::resolve)
212            .transpose()
213            .map_err(invalid)?
214            .unwrap_or_default();
215        let signoff_idle_grace = declared
216            .signoff_idle_grace_seconds
217            .map(grace)
218            .transpose()
219            .map_err(invalid)?
220            .unwrap_or(SIGNOFF_IDLE_GRACE);
221        Ok(Self {
222            backends: declared.backends,
223            agent_sessions,
224            signoff_idle_grace,
225        })
226    }
227
228    /// Save `grace` as the idle grace of the Runs started from now on, keeping every other
229    /// declared limit as written, and return the limits that are now in force. A file that does
230    /// not read is not overwritten.
231    pub fn set_signoff_idle_grace(state: &Path, grace: Duration) -> Result<Self, String> {
232        if grace > MAX_SIGNOFF_IDLE_GRACE || grace.subsec_nanos() != 0 {
233            return Err(format!(
234                "The sign-off idle grace must be whole seconds, at most {}.",
235                MAX_SIGNOFF_IDLE_GRACE.as_secs()
236            ));
237        }
238        Self::read(state)?;
239        let path = state.join(FILE);
240        let invalid = |message: String| format!("{}: {message}", crate::platform::path_text(&path));
241        let mut declared = match std::fs::read_to_string(&path) {
242            Ok(text) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&text)
243                .map_err(|e| invalid(e.to_string()))?,
244            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Default::default(),
245            Err(error) => return Err(invalid(error.to_string())),
246        };
247        declared.insert(GRACE_FIELD.into(), grace.as_secs().into());
248        crate::platform::create_private_dir_all(state).map_err(|e| invalid(e.to_string()))?;
249        let mut file =
250            tempfile::NamedTempFile::new_in(state).map_err(|e| invalid(e.to_string()))?;
251        serde_json::to_writer_pretty(&mut file, &declared).map_err(|e| invalid(e.to_string()))?;
252        file.write_all(b"\n").map_err(|e| invalid(e.to_string()))?;
253        file.persist(&path)
254            .map_err(|e| invalid(e.error.to_string()))?;
255        Self::read(state)
256    }
257
258    /// The slot count of a parsed backend; `None` when it is unlimited.
259    pub fn limit(&self, backend: &Backend) -> Option<u32> {
260        self.backends.get(backend).copied()
261    }
262
263    pub fn backends(&self) -> &BTreeMap<Backend, u32> {
264        &self.backends
265    }
266
267    pub fn agent_sessions(&self) -> AgentSessions {
268        self.agent_sessions
269    }
270
271    /// How long a Run with nothing but unclaimed sign-offs left stays open before it ends.
272    pub fn signoff_idle_grace(&self) -> Duration {
273        self.signoff_idle_grace
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use std::fs;
281
282    fn read(text: &str) -> Result<Limits, String> {
283        let directory = crate::test_os::tempdir();
284        fs::write(directory.path().join(FILE), text).unwrap();
285        Limits::read(directory.path())
286    }
287
288    #[test]
289    fn agent_sessions_default_and_validate() {
290        assert_eq!(
291            read("{}").unwrap().agent_sessions(),
292            AgentSessions {
293                enabled: true,
294                max_bytes: 1_073_741_824,
295                target_bytes: 805_306_368,
296            }
297        );
298        assert_eq!(
299            read(r#"{"agentSessions":{"enabled":false}}"#)
300                .unwrap()
301                .agent_sessions(),
302            AgentSessions {
303                enabled: false,
304                max_bytes: 1_073_741_824,
305                target_bytes: 805_306_368,
306            }
307        );
308        assert_eq!(
309            read(r#"{"agentSessions":{"maxBytes":4000,"targetBytes":1000}}"#)
310                .unwrap()
311                .agent_sessions(),
312            AgentSessions {
313                enabled: true,
314                max_bytes: 4000,
315                target_bytes: 1000
316            }
317        );
318        // A small maxBytes alone collects down to three quarters of it.
319        assert_eq!(
320            read(r#"{"agentSessions":{"maxBytes":4000}}"#)
321                .unwrap()
322                .agent_sessions()
323                .target_bytes,
324            3000
325        );
326        // Quarter-block rounding stays divide-then-multiply, not floor(3*n/4).
327        for (max_bytes, target_bytes) in [(1, 0), (3, 0), (5, 3), (7, 3), (8, 6), (11, 6)] {
328            assert_eq!(
329                read(&format!(
330                    r#"{{"agentSessions":{{"maxBytes":{max_bytes}}}}}"#
331                ))
332                .unwrap()
333                .agent_sessions()
334                .target_bytes,
335                target_bytes
336            );
337        }
338        assert_eq!(
339            read(r#"{"agentSessions":{"maxBytes":18446744073709551615}}"#)
340                .unwrap()
341                .agent_sessions()
342                .target_bytes,
343            805306368
344        );
345        for (text, error) in [
346            (
347                r#"{"agentSessions":{"maxBytes":100,"targetBytes":100}}"#,
348                "agentSessions.targetBytes (100) must be lower than maxBytes (100).",
349            ),
350            (
351                r#"{"agentSessions":{"targetBytes":2147483648}}"#,
352                "must be lower than maxBytes (1073741824)",
353            ),
354            (
355                r#"{"agentSessions":{"maxBytes":0}}"#,
356                "agentSessions.maxBytes must be at least 1.",
357            ),
358            (r#"{"agentSessions":{"maxbytes":10}}"#, "unknown field"),
359            (r#"{"agentSessions":{"maxBytes":-1}}"#, "invalid value"),
360            (r#"{"agentSessions":{"enabled":"no"}}"#, "invalid type"),
361            (r#"{"agentSessions":true}"#, "invalid type"),
362        ] {
363            let message = read(text).unwrap_err();
364            assert!(message.contains(error), "{text}: {message}");
365            assert!(message.contains(FILE), "{message}");
366        }
367    }
368
369    #[test]
370    fn signoff_idle_grace_defaults_validates_and_saves_beside_other_limits() {
371        assert_eq!(
372            read("{}").unwrap().signoff_idle_grace(),
373            Duration::from_secs(120)
374        );
375        assert_eq!(
376            read(r#"{"signoffIdleGraceSeconds":0}"#)
377                .unwrap()
378                .signoff_idle_grace(),
379            Duration::ZERO
380        );
381        for (text, error) in [
382            (
383                r#"{"signoffIdleGraceSeconds":86401}"#,
384                "must be at most 86400",
385            ),
386            (r#"{"signoffIdleGraceSeconds":-1}"#, "invalid value"),
387            (r#"{"signoffIdleGraceSeconds":"2m"}"#, "invalid type"),
388        ] {
389            let message = read(text).unwrap_err();
390            assert!(message.contains(error), "{text}: {message}");
391        }
392
393        let directory = crate::test_os::tempdir();
394        let state = directory.path().join("state");
395        let saved = Limits::set_signoff_idle_grace(&state, Duration::from_secs(300)).unwrap();
396        assert_eq!(saved.signoff_idle_grace(), Duration::from_secs(300));
397        fs::write(
398            state.join(FILE),
399            r#"{"backends":{"codex":4},"agentSessions":{"maxBytes":4000}}"#,
400        )
401        .unwrap();
402        let saved = Limits::set_signoff_idle_grace(&state, Duration::ZERO).unwrap();
403        assert_eq!(saved.signoff_idle_grace(), Duration::ZERO);
404        assert_eq!(saved.limit(&crate::config::Provider::Codex.into()), Some(4));
405        assert_eq!(saved.agent_sessions().max_bytes, 4000);
406        assert_eq!(saved.agent_sessions().target_bytes, 3000);
407        assert!(
408            Limits::set_signoff_idle_grace(&state, Duration::from_secs(86401))
409                .unwrap_err()
410                .contains("at most 86400")
411        );
412        fs::write(state.join(FILE), r#"{"unknown":1}"#).unwrap();
413        assert!(Limits::set_signoff_idle_grace(&state, Duration::ZERO).is_err());
414        assert_eq!(
415            fs::read_to_string(state.join(FILE)).unwrap(),
416            r#"{"unknown":1}"#
417        );
418    }
419}