car-server-core 0.47.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! One absolute wall-clock deadline for a whole coder session.
//!
//! ## Why the session, not the loop
//!
//! Every other coder bound counts a *unit of work*: `max_iterations` and
//! `repair_invokes` count rounds, `timeout_secs` bounds one CLI invocation,
//! `max_turns_per_iteration` bounds one round's model turns. None bounds
//! elapsed time, so a session ran as long as its turns happened to take.
//!
//! The first attempt at fixing that put the ceiling on each loop's *config*, so
//! every rung of the fallback ladder built its own and restarted the clock —
//! `foreman -> native` and `external -> native` each got a fresh hour, and
//! `foreman_loop` had no ceiling at all. That is not a session budget; it is a
//! per-loop one wearing the word "session".
//!
//! So the deadline is created **once**, above the ladder, and shared by
//! reference (`Arc`) rather than cloned into each config. Sharing a handle
//! instead of a value is what makes "one clock" structural: a rung cannot
//! restart something it does not own.
//!
//! The evidence this was worth doing is a workaround already in the tree.
//! `car-cli`'s coder A/B enforces a per-task wall bound from *outside* the
//! daemon — it times out its `car code` client and then makes a second, explicit
//! `coder.cancel`, with a comment noting that killing the client does not stop
//! the session, which would "orphan there, burning the backbone (and throttling
//! the live run via rate limits) for its full budget."
//!
//! ## Admission, not interruption — plus a clamp
//!
//! The deadline is checked **between** iterations, never mid-flight. A round
//! already running finishes; the next is not admitted. Interrupting mid-round
//! would abandon edits with no contract evaluation over them, which is the
//! defect [`super::external_loop`] exists to avoid: the worktree is the state,
//! and something that stops the process does not get to pronounce the verdict.
//! Because the previous round already evaluated the contract, a denied
//! admission cannot be hiding a green result.
//!
//! Admission alone would still let a round overrun the ceiling by its own full
//! length — an external invocation admitted just under the line could run its
//! entire 1800s past it. So callers additionally **clamp** a round's own timeout
//! to [`SessionDeadline::remaining_secs`]. That is not interruption either: it
//! is a round that starts with a shorter clock, and a CLI hitting its own
//! timeout already flows through `Infrastructure` -> `evaluate_contract`, so
//! nothing goes unjudged.

use std::sync::Arc;
use std::time::Instant;

/// One hour. Chosen to sit above every bound a caller already imposes — the
/// coder A/B cuts its arms at 900s (native) and 300s (external), and an external
/// invocation self-limits at 1800s — so it truncates no already-bounded run and
/// catches the one that isn't.
pub const DEFAULT_SESSION_WALL_SECS: u64 = 3600;

/// A session's absolute deadline. Immutable after construction, so it is shared
/// as `Arc<SessionDeadline>` with no lock.
#[derive(Debug)]
pub struct SessionDeadline {
    started: Instant,
    max_wall_secs: Option<u64>,
}

impl SessionDeadline {
    /// Start the clock. `None` disables the ceiling entirely.
    pub fn new(max_wall_secs: Option<u64>) -> Self {
        Self {
            started: Instant::now(),
            max_wall_secs,
        }
    }

    /// The default ceiling, as a shared handle ready to thread down the ladder.
    pub fn shared_default() -> Arc<Self> {
        Arc::new(Self::new(Some(DEFAULT_SESSION_WALL_SECS)))
    }

    /// No ceiling. For callers that impose their own bound.
    pub fn unlimited() -> Arc<Self> {
        Arc::new(Self::new(None))
    }

    /// Whether another iteration may begin. `None` admits; `Some(reason)`
    /// denies, with text meant for a human and for `LoopOutcome.error`.
    pub fn admit(&self) -> Option<String> {
        let max = self.max_wall_secs?;
        let elapsed = self.elapsed_secs();
        if elapsed < max {
            return None;
        }
        Some(format!(
            "session budget exhausted: {elapsed}s elapsed of a {max}s ceiling"
        ))
    }

    /// Seconds left before the deadline, or `None` when unbounded.
    ///
    /// Callers clamp a round's own timeout to this so admission cannot be
    /// followed by a full-length overrun. Saturates at 0 rather than wrapping.
    pub fn remaining_secs(&self) -> Option<u64> {
        self.max_wall_secs
            .map(|max| max.saturating_sub(self.elapsed_secs()))
    }

    pub fn elapsed_secs(&self) -> u64 {
        self.started.elapsed().as_secs()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_fresh_deadline_admits() {
        assert!(SessionDeadline::new(Some(DEFAULT_SESSION_WALL_SECS))
            .admit()
            .is_none());
    }

    /// A zero ceiling is already spent, so the very first admission is denied.
    /// The reason names both numbers — "budget exhausted" alone leaves a human
    /// unable to tell a misconfiguration from a genuinely long session.
    #[test]
    fn an_exhausted_deadline_denies_with_both_numbers() {
        let reason = SessionDeadline::new(Some(0))
            .admit()
            .expect("a 0s ceiling must deny");
        assert!(reason.contains("session budget exhausted"), "{reason}");
        assert!(reason.contains("0s ceiling"), "{reason}");
    }

    #[test]
    fn no_ceiling_never_denies_and_has_no_remainder() {
        let d = SessionDeadline::new(None);
        assert!(d.admit().is_none());
        assert_eq!(d.remaining_secs(), None);
    }

    /// The clamp input. A round's own timeout is reduced to this so admission
    /// cannot be followed by a full-length overrun past the ceiling.
    #[test]
    fn remaining_saturates_at_zero_rather_than_wrapping() {
        assert_eq!(SessionDeadline::new(Some(0)).remaining_secs(), Some(0));
        let plenty = SessionDeadline::new(Some(3600))
            .remaining_secs()
            .expect("bounded");
        assert!(
            plenty > 3500,
            "a fresh hour should have nearly all of it left"
        );
    }

    /// The default must clear every bound a caller already imposes, or it would
    /// silently truncate runs that are already correctly bounded — the coder
    /// A/B's 900s native arm being the one that matters.
    #[test]
    fn the_default_ceiling_clears_existing_caller_bounds() {
        assert!(
            DEFAULT_SESSION_WALL_SECS > 900,
            "must not truncate the A/B's native arm"
        );
        assert!(
            DEFAULT_SESSION_WALL_SECS >= 1800,
            "must not truncate one external invocation"
        );
    }

    /// The point of the rewrite: one handle, shared, so a fallback-ladder rung
    /// cannot restart a clock it does not own.
    #[test]
    fn a_shared_handle_reports_one_clock() {
        let a = SessionDeadline::shared_default();
        let b = Arc::clone(&a);
        assert!(
            Arc::ptr_eq(&a, &b),
            "clones must share, not copy, the clock"
        );
        assert_eq!(a.elapsed_secs(), b.elapsed_secs());
    }
}