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, not interruption — plus a clamp
29//!
30//! Ordinary coding loops check the deadline **between** iterations, never
31//! mid-flight. A round already running finishes; the next is not admitted.
32//! Interrupting one would abandon edits with no contract evaluation over them,
33//! which is the defect [`super::external_loop`] exists to avoid: the worktree is
34//! the state, and something that stops the process does not get to pronounce the
35//! verdict. Because the previous round already evaluated the contract, a denied
36//! admission cannot be hiding a green result.
37//!
38//! Agent-project builds are the deliberate exception. Their unit of work is an
39//! in-memory generated spec plus scenario evaluation, and nothing is written to
40//! the worktree until all scenarios pass. Dropping that future at the deadline
41//! therefore cancels a stalled generation/scenario without abandoning edits or
42//! inventing a verdict.
43//!
44//! Admission alone would still let a round overrun the ceiling by its own full
45//! length — an external invocation admitted just under the line could run its
46//! entire 1800s past it. So callers additionally **clamp** a round's own timeout
47//! to [`SessionDeadline::remaining_secs`]. That is not interruption either: it
48//! is a round that starts with a shorter clock, and a CLI hitting its own
49//! timeout already flows through `Infrastructure` -> `evaluate_contract`, so
50//! nothing goes unjudged.
51
52use std::sync::Arc;
53use std::time::{Duration, Instant};
54
55/// One hour. Chosen to sit above every bound a caller already imposes — the
56/// coder A/B cuts its arms at 900s (native) and 300s (external), and an external
57/// invocation self-limits at 1800s — so it truncates no already-bounded run and
58/// catches the one that isn't.
59pub const DEFAULT_SESSION_WALL_SECS: u64 = 3600;
60
61/// A session's absolute deadline. Immutable after construction, so it is shared
62/// as `Arc<SessionDeadline>` with no lock.
63#[derive(Debug)]
64pub struct SessionDeadline {
65    started: Instant,
66    max_wall: Option<Duration>,
67}
68
69impl SessionDeadline {
70    /// Start the clock. `None` disables the ceiling entirely.
71    pub fn new(max_wall_secs: Option<u64>) -> Self {
72        Self::from_duration(max_wall_secs.map(Duration::from_secs))
73    }
74
75    /// Duration-based constructor used by short, deterministic deadline tests.
76    /// Production configuration remains whole seconds on the wire and on disk.
77    pub(crate) fn from_duration(max_wall: Option<Duration>) -> Self {
78        Self {
79            started: Instant::now(),
80            max_wall,
81        }
82    }
83
84    /// The default ceiling, as a shared handle ready to thread down the ladder.
85    pub fn shared_default() -> Arc<Self> {
86        Arc::new(Self::new(Some(DEFAULT_SESSION_WALL_SECS)))
87    }
88
89    /// No ceiling. For callers that impose their own bound.
90    pub fn unlimited() -> Arc<Self> {
91        Arc::new(Self::new(None))
92    }
93
94    /// Whether another iteration may begin. `None` admits; `Some(reason)`
95    /// denies, with text meant for a human and for `LoopOutcome.error`.
96    pub fn admit(&self) -> Option<String> {
97        let max = self.max_wall?;
98        let elapsed = self.started.elapsed();
99        if elapsed < max {
100            return None;
101        }
102        Some(format!(
103            "session budget exhausted: {}s elapsed of a {}s ceiling",
104            elapsed.as_secs(),
105            max.as_secs()
106        ))
107    }
108
109    /// Seconds left before the deadline, or `None` when unbounded.
110    ///
111    /// Callers clamp a round's own timeout to this so admission cannot be
112    /// followed by a full-length overrun. Saturates at 0 rather than wrapping.
113    pub fn remaining_secs(&self) -> Option<u64> {
114        self.max_wall
115            .map(|max| max.as_secs().saturating_sub(self.elapsed_secs()))
116    }
117
118    /// Exact remaining duration. Agent builds use this to cancel an in-flight
119    /// model/scenario future at the deadline instead of waiting for the next
120    /// iteration-admission boundary.
121    pub fn remaining_duration(&self) -> Option<Duration> {
122        self.max_wall
123            .map(|max| max.saturating_sub(self.started.elapsed()))
124    }
125
126    pub fn elapsed_secs(&self) -> u64 {
127        self.started.elapsed().as_secs()
128    }
129
130    /// Elapsed wall time in whole milliseconds, for check-result durations.
131    pub fn elapsed_millis(&self) -> u64 {
132        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
133    }
134
135    /// Configured whole-second ceiling, or `None` when unlimited.
136    pub fn max_wall_secs(&self) -> Option<u64> {
137        self.max_wall.map(|max| max.as_secs())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn a_fresh_deadline_admits() {
147        assert!(SessionDeadline::new(Some(DEFAULT_SESSION_WALL_SECS))
148            .admit()
149            .is_none());
150    }
151
152    /// A zero ceiling is already spent, so the very first admission is denied.
153    /// The reason names both numbers — "budget exhausted" alone leaves a human
154    /// unable to tell a misconfiguration from a genuinely long session.
155    #[test]
156    fn an_exhausted_deadline_denies_with_both_numbers() {
157        let reason = SessionDeadline::new(Some(0))
158            .admit()
159            .expect("a 0s ceiling must deny");
160        assert!(reason.contains("session budget exhausted"), "{reason}");
161        assert!(reason.contains("0s ceiling"), "{reason}");
162    }
163
164    #[test]
165    fn no_ceiling_never_denies_and_has_no_remainder() {
166        let d = SessionDeadline::new(None);
167        assert!(d.admit().is_none());
168        assert_eq!(d.remaining_secs(), None);
169    }
170
171    /// The clamp input. A round's own timeout is reduced to this so admission
172    /// cannot be followed by a full-length overrun past the ceiling.
173    #[test]
174    fn remaining_saturates_at_zero_rather_than_wrapping() {
175        assert_eq!(SessionDeadline::new(Some(0)).remaining_secs(), Some(0));
176        let plenty = SessionDeadline::new(Some(3600))
177            .remaining_secs()
178            .expect("bounded");
179        assert!(
180            plenty > 3500,
181            "a fresh hour should have nearly all of it left"
182        );
183    }
184
185    /// The default must clear every bound a caller already imposes, or it would
186    /// silently truncate runs that are already correctly bounded — the coder
187    /// A/B's 900s native arm being the one that matters.
188    #[test]
189    fn the_default_ceiling_clears_existing_caller_bounds() {
190        assert!(
191            DEFAULT_SESSION_WALL_SECS > 900,
192            "must not truncate the A/B's native arm"
193        );
194        assert!(
195            DEFAULT_SESSION_WALL_SECS >= 1800,
196            "must not truncate one external invocation"
197        );
198    }
199
200    /// The point of the rewrite: one handle, shared, so a fallback-ladder rung
201    /// cannot restart a clock it does not own.
202    #[test]
203    fn a_shared_handle_reports_one_clock() {
204        let a = SessionDeadline::shared_default();
205        let b = Arc::clone(&a);
206        assert!(
207            Arc::ptr_eq(&a, &b),
208            "clones must share, not copy, the clock"
209        );
210        assert_eq!(a.elapsed_secs(), b.elapsed_secs());
211    }
212}