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}