use std::sync::Arc;
use std::time::{Duration, Instant};
pub const DEFAULT_SESSION_WALL_SECS: u64 = 3600;
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),
}
}
#[derive(Debug)]
pub struct SessionDeadline {
started: Instant,
max_wall: Option<Duration>,
}
impl SessionDeadline {
pub fn new(max_wall_secs: Option<u64>) -> Self {
Self::from_duration(max_wall_secs.map(Duration::from_secs))
}
pub(crate) fn from_duration(max_wall: Option<Duration>) -> Self {
Self {
started: Instant::now(),
max_wall,
}
}
pub fn shared_default() -> Arc<Self> {
Arc::new(Self::new(Some(DEFAULT_SESSION_WALL_SECS)))
}
pub fn unlimited() -> Arc<Self> {
Arc::new(Self::new(None))
}
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()
))
}
pub fn remaining_secs(&self) -> Option<u64> {
self.max_wall
.map(|max| max.as_secs().saturating_sub(self.elapsed_secs()))
}
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()
}
pub fn elapsed_millis(&self) -> u64 {
u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
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());
}
#[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);
}
#[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"
);
}
#[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"
);
}
#[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());
}
}