car-server-core 0.55.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
Documentation
//! 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 plus in-flight clamps
//!
//! Coding loops still check admission between iterations, but both external
//! rounds and native model turns also clamp their in-flight timeout to
//! [`SessionDeadline::remaining_secs`]. A timeout never pronounces the work
//! failed by itself: it flows to contract evaluation first, because the
//! worktree is the state and nothing goes unjudged.
//!
//! Dropping a timed-out native generation future stops worker-isolated model
//! work: `inference_worker.rs` owns the worker child with `kill_on_drop`, so the
//! drop kills and reaps it. This is not a universal interruption guarantee.
//! In-process fallbacks run on blocking threads that cannot be cancelled and
//! may keep running after the session has ended. The session still reaches a
//! typed timeout promptly; the residual work is the same limitation documented
//! on the agent-build deadline path.
//!
//! Agent-project builds are bounded one level higher. Their unit of work is an
//! in-memory generated spec plus scenario evaluation, and nothing is written to
//! the worktree until all scenarios pass. Dropping that future at the deadline
//! therefore cancels the session-facing build without abandoning edits or
//! inventing a verdict, with the same worker-vs-in-process residual above.

use std::sync::Arc;
use std::time::{Duration, 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;

/// Resolve the confirmed contract's timeout into the agent-build wall deadline.
///
/// A zero operator knob explicitly keeps today's behavior: a positive contract
/// timeout is honored and a missing or zero timeout is unlimited. With a
/// positive knob, the confirmed card may lower the deadline, but may never
/// remove it or raise it above the operator's ceiling.
pub(super) fn agent_build_deadline_secs(
    contract_timeout_secs: Option<u64>,
    max_agent_build_wall_secs: u64,
) -> Option<u64> {
    if max_agent_build_wall_secs == 0 {
        return contract_timeout_secs.and_then(|secs| (secs > 0).then_some(secs));
    }

    match contract_timeout_secs {
        Some(secs) if secs > 0 => Some(secs.min(max_agent_build_wall_secs)),
        Some(_) | None => Some(max_agent_build_wall_secs),
    }
}

/// 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: Option<Duration>,
}

impl SessionDeadline {
    /// Start the clock. `None` disables the ceiling entirely.
    pub fn new(max_wall_secs: Option<u64>) -> Self {
        Self::from_duration(max_wall_secs.map(Duration::from_secs))
    }

    /// Duration-based constructor used by short, deterministic deadline tests.
    /// Production configuration remains whole seconds on the wire and on disk.
    pub(crate) fn from_duration(max_wall: Option<Duration>) -> Self {
        Self {
            started: Instant::now(),
            max_wall,
        }
    }

    /// 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?;
        let elapsed = self.started.elapsed();
        if elapsed < max {
            return None;
        }
        Some(format!(
            "session budget exhausted: {}s elapsed of a {}s ceiling",
            elapsed.as_secs(),
            max.as_secs()
        ))
    }

    /// 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
            .map(|max| max.as_secs().saturating_sub(self.elapsed_secs()))
    }

    /// Exact remaining duration. Agent builds use this to cancel an in-flight
    /// model/scenario future at the deadline instead of waiting for the next
    /// iteration-admission boundary.
    pub fn remaining_duration(&self) -> Option<Duration> {
        self.max_wall
            .map(|max| max.saturating_sub(self.started.elapsed()))
    }

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

    /// Elapsed wall time in whole milliseconds, for check-result durations.
    pub fn elapsed_millis(&self) -> u64 {
        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
    }

    /// Configured whole-second ceiling, or `None` when unlimited.
    pub fn max_wall_secs(&self) -> Option<u64> {
        self.max_wall.map(|max| max.as_secs())
    }
}

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

    #[test]
    fn agent_build_deadline_clamps_the_contract_to_the_operator_ceiling() {
        let cases = [
            (0, None, None),
            (0, Some(0), None),
            (0, Some(300), Some(300)),
            (600, None, Some(600)),
            (600, Some(0), Some(600)),
            (600, Some(300), Some(300)),
            (600, Some(600), Some(600)),
            (600, Some(900), Some(600)),
        ];

        for (knob, contract, expected) in cases {
            assert_eq!(
                agent_build_deadline_secs(contract, knob),
                expected,
                "contract {contract:?}, knob {knob}"
            );
        }
    }

    #[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());
    }
}