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}