Skip to main content

car_server_core/coder/
budget.rs

1//! One absolute wall-clock deadline for a whole coder session.
2//!
3//! ## Why the session, not the loop
4//!
5//! Every other coder bound counts a *unit of work*: `max_iterations` and
6//! `repair_invokes` count rounds, `timeout_secs` bounds one CLI invocation,
7//! `max_turns_per_iteration` bounds one round's model turns. None bounds
8//! elapsed time, so a session ran as long as its turns happened to take.
9//!
10//! The first attempt at fixing that put the ceiling on each loop's *config*, so
11//! every rung of the fallback ladder built its own and restarted the clock —
12//! `foreman -> native` and `external -> native` each got a fresh hour, and
13//! `foreman_loop` had no ceiling at all. That is not a session budget; it is a
14//! per-loop one wearing the word "session".
15//!
16//! So the deadline is created **once**, above the ladder, and shared by
17//! reference (`Arc`) rather than cloned into each config. Sharing a handle
18//! instead of a value is what makes "one clock" structural: a rung cannot
19//! restart something it does not own.
20//!
21//! The evidence this was worth doing is a workaround already in the tree.
22//! `car-cli`'s coder A/B enforces a per-task wall bound from *outside* the
23//! daemon — it times out its `car code` client and then makes a second, explicit
24//! `coder.cancel`, with a comment noting that killing the client does not stop
25//! the session, which would "orphan there, burning the backbone (and throttling
26//! the live run via rate limits) for its full budget."
27//!
28//! ## Admission plus in-flight clamps
29//!
30//! Coding loops still check admission between iterations, but both external
31//! rounds and native model turns also clamp their in-flight timeout to
32//! [`SessionDeadline::remaining_secs`]. A timeout never pronounces the work
33//! failed by itself: it flows to contract evaluation first, because the
34//! worktree is the state and nothing goes unjudged.
35//!
36//! Dropping a timed-out native generation future stops worker-isolated model
37//! work: `inference_worker.rs` owns the worker child with `kill_on_drop`, so the
38//! drop kills and reaps it. This is not a universal interruption guarantee.
39//! In-process fallbacks run on blocking threads that cannot be cancelled and
40//! may keep running after the session has ended. The session still reaches a
41//! typed timeout promptly; the residual work is the same limitation documented
42//! on the agent-build deadline path.
43//!
44//! Agent-project builds are bounded one level higher. Their unit of work is an
45//! in-memory generated spec plus scenario evaluation, and nothing is written to
46//! the worktree until all scenarios pass. Dropping that future at the deadline
47//! therefore cancels the session-facing build without abandoning edits or
48//! inventing a verdict, with the same worker-vs-in-process residual above.
49
50use std::sync::Arc;
51use std::time::{Duration, Instant};
52
53/// One hour. Chosen to sit above every bound a caller already imposes — the
54/// coder A/B cuts its arms at 900s (native) and 300s (external), and an external
55/// invocation self-limits at 1800s — so it truncates no already-bounded run and
56/// catches the one that isn't.
57pub const DEFAULT_SESSION_WALL_SECS: u64 = 3600;
58
59/// Resolve the confirmed contract's timeout into the agent-build wall deadline.
60///
61/// A zero operator knob explicitly keeps today's behavior: a positive contract
62/// timeout is honored and a missing or zero timeout is unlimited. With a
63/// positive knob, the confirmed card may lower the deadline, but may never
64/// remove it or raise it above the operator's ceiling.
65pub(super) fn agent_build_deadline_secs(
66    contract_timeout_secs: Option<u64>,
67    max_agent_build_wall_secs: u64,
68) -> Option<u64> {
69    if max_agent_build_wall_secs == 0 {
70        return contract_timeout_secs.and_then(|secs| (secs > 0).then_some(secs));
71    }
72
73    match contract_timeout_secs {
74        Some(secs) if secs > 0 => Some(secs.min(max_agent_build_wall_secs)),
75        Some(_) | None => Some(max_agent_build_wall_secs),
76    }
77}
78
79/// A session's absolute deadline. Immutable after construction, so it is shared
80/// as `Arc<SessionDeadline>` with no lock.
81#[derive(Debug)]
82pub struct SessionDeadline {
83    started: Instant,
84    max_wall: Option<Duration>,
85}
86
87impl SessionDeadline {
88    /// Start the clock. `None` disables the ceiling entirely.
89    pub fn new(max_wall_secs: Option<u64>) -> Self {
90        Self::from_duration(max_wall_secs.map(Duration::from_secs))
91    }
92
93    /// Duration-based constructor used by short, deterministic deadline tests.
94    /// Production configuration remains whole seconds on the wire and on disk.
95    pub(crate) fn from_duration(max_wall: Option<Duration>) -> Self {
96        Self {
97            started: Instant::now(),
98            max_wall,
99        }
100    }
101
102    /// The default ceiling, as a shared handle ready to thread down the ladder.
103    pub fn shared_default() -> Arc<Self> {
104        Arc::new(Self::new(Some(DEFAULT_SESSION_WALL_SECS)))
105    }
106
107    /// No ceiling. For callers that impose their own bound.
108    pub fn unlimited() -> Arc<Self> {
109        Arc::new(Self::new(None))
110    }
111
112    /// Whether another iteration may begin. `None` admits; `Some(reason)`
113    /// denies, with text meant for a human and for `LoopOutcome.error`.
114    pub fn admit(&self) -> Option<String> {
115        let max = self.max_wall?;
116        let elapsed = self.started.elapsed();
117        if elapsed < max {
118            return None;
119        }
120        Some(format!(
121            "session budget exhausted: {}s elapsed of a {}s ceiling",
122            elapsed.as_secs(),
123            max.as_secs()
124        ))
125    }
126
127    /// Seconds left before the deadline, or `None` when unbounded.
128    ///
129    /// Callers clamp a round's own timeout to this so admission cannot be
130    /// followed by a full-length overrun. Saturates at 0 rather than wrapping.
131    pub fn remaining_secs(&self) -> Option<u64> {
132        self.max_wall
133            .map(|max| max.as_secs().saturating_sub(self.elapsed_secs()))
134    }
135
136    /// Exact remaining duration. Agent builds use this to cancel an in-flight
137    /// model/scenario future at the deadline instead of waiting for the next
138    /// iteration-admission boundary.
139    pub fn remaining_duration(&self) -> Option<Duration> {
140        self.max_wall
141            .map(|max| max.saturating_sub(self.started.elapsed()))
142    }
143
144    pub fn elapsed_secs(&self) -> u64 {
145        self.started.elapsed().as_secs()
146    }
147
148    /// Elapsed wall time in whole milliseconds, for check-result durations.
149    pub fn elapsed_millis(&self) -> u64 {
150        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
151    }
152
153    /// Configured whole-second ceiling, or `None` when unlimited.
154    pub fn max_wall_secs(&self) -> Option<u64> {
155        self.max_wall.map(|max| max.as_secs())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn agent_build_deadline_clamps_the_contract_to_the_operator_ceiling() {
165        let cases = [
166            (0, None, None),
167            (0, Some(0), None),
168            (0, Some(300), Some(300)),
169            (600, None, Some(600)),
170            (600, Some(0), Some(600)),
171            (600, Some(300), Some(300)),
172            (600, Some(600), Some(600)),
173            (600, Some(900), Some(600)),
174        ];
175
176        for (knob, contract, expected) in cases {
177            assert_eq!(
178                agent_build_deadline_secs(contract, knob),
179                expected,
180                "contract {contract:?}, knob {knob}"
181            );
182        }
183    }
184
185    #[test]
186    fn a_fresh_deadline_admits() {
187        assert!(SessionDeadline::new(Some(DEFAULT_SESSION_WALL_SECS))
188            .admit()
189            .is_none());
190    }
191
192    /// A zero ceiling is already spent, so the very first admission is denied.
193    /// The reason names both numbers — "budget exhausted" alone leaves a human
194    /// unable to tell a misconfiguration from a genuinely long session.
195    #[test]
196    fn an_exhausted_deadline_denies_with_both_numbers() {
197        let reason = SessionDeadline::new(Some(0))
198            .admit()
199            .expect("a 0s ceiling must deny");
200        assert!(reason.contains("session budget exhausted"), "{reason}");
201        assert!(reason.contains("0s ceiling"), "{reason}");
202    }
203
204    #[test]
205    fn no_ceiling_never_denies_and_has_no_remainder() {
206        let d = SessionDeadline::new(None);
207        assert!(d.admit().is_none());
208        assert_eq!(d.remaining_secs(), None);
209    }
210
211    /// The clamp input. A round's own timeout is reduced to this so admission
212    /// cannot be followed by a full-length overrun past the ceiling.
213    #[test]
214    fn remaining_saturates_at_zero_rather_than_wrapping() {
215        assert_eq!(SessionDeadline::new(Some(0)).remaining_secs(), Some(0));
216        let plenty = SessionDeadline::new(Some(3600))
217            .remaining_secs()
218            .expect("bounded");
219        assert!(
220            plenty > 3500,
221            "a fresh hour should have nearly all of it left"
222        );
223    }
224
225    /// The default must clear every bound a caller already imposes, or it would
226    /// silently truncate runs that are already correctly bounded — the coder
227    /// A/B's 900s native arm being the one that matters.
228    #[test]
229    fn the_default_ceiling_clears_existing_caller_bounds() {
230        assert!(
231            DEFAULT_SESSION_WALL_SECS > 900,
232            "must not truncate the A/B's native arm"
233        );
234        assert!(
235            DEFAULT_SESSION_WALL_SECS >= 1800,
236            "must not truncate one external invocation"
237        );
238    }
239
240    /// The point of the rewrite: one handle, shared, so a fallback-ladder rung
241    /// cannot restart a clock it does not own.
242    #[test]
243    fn a_shared_handle_reports_one_clock() {
244        let a = SessionDeadline::shared_default();
245        let b = Arc::clone(&a);
246        assert!(
247            Arc::ptr_eq(&a, &b),
248            "clones must share, not copy, the clock"
249        );
250        assert_eq!(a.elapsed_secs(), b.elapsed_secs());
251    }
252}