diff --git a/codex-rs/core/src/codex/timer_runtime.rs b/codex-rs/core/src/codex/timer_runtime.rs index 954be52c8d..5306670f75 100644 --- a/codex-rs/core/src/codex/timer_runtime.rs +++ b/codex-rs/core/src/codex/timer_runtime.rs @@ -20,6 +20,7 @@ use crate::pending_input::GeneratedMessageInput; use crate::pending_input::PendingInputItem; use crate::timers::ClaimedTimer; use crate::timers::CreateTimer; +use crate::timers::MAX_ACTIVE_TIMERS_PER_THREAD; use crate::timers::PersistedTimer; use crate::timers::RecurringTimerPolicy; use crate::timers::RestoredTimerTask; @@ -157,11 +158,25 @@ impl Session { let params = self .thread_timer_create_params(&persisted_timer, TIMER_SOURCE_AGENT) .await?; - if let Err(err) = state_db.create_thread_timer(¶ms).await { - if let Some(runtime) = self.timers.lock().await.remove_timer(&id) { - TimersState::cancel_runtime(&runtime); + match state_db + .create_thread_timer_if_below_limit(¶ms, MAX_ACTIVE_TIMERS_PER_THREAD) + .await + { + Ok(true) => {} + Ok(false) => { + if let Some(runtime) = self.timers.lock().await.remove_timer(&id) { + TimersState::cancel_runtime(&runtime); + } + return Err(format!( + "too many active timers; each thread supports at most {MAX_ACTIVE_TIMERS_PER_THREAD} timers" + )); + } + Err(err) => { + if let Some(runtime) = self.timers.lock().await.remove_timer(&id) { + TimersState::cancel_runtime(&runtime); + } + return Err(format!("failed to persist timer to sqlite: {err}")); } - return Err(format!("failed to persist timer to sqlite: {err}")); } if let Some(timer_spec) = timer_spec { diff --git a/codex-rs/core/src/codex/timer_runtime_tests.rs b/codex-rs/core/src/codex/timer_runtime_tests.rs index eff2eaa8cb..94ace5d507 100644 --- a/codex-rs/core/src/codex/timer_runtime_tests.rs +++ b/codex-rs/core/src/codex/timer_runtime_tests.rs @@ -1,5 +1,6 @@ use super::make_session_and_context_with_rx; use crate::messages::MessagePayload; +use crate::timers::MAX_ACTIVE_TIMERS_PER_THREAD; use crate::timers::ThreadTimerTrigger; use crate::timers::TimerDelivery; use codex_features::Feature; @@ -108,3 +109,82 @@ async fn maybe_start_pending_timer_claims_only_one_timer_while_start_is_in_progr 1 ); } + +#[tokio::test] +async fn create_timer_rejects_when_sqlite_thread_timer_limit_is_reached() { + let (mut session, _, _) = make_session_and_context_with_rx().await; + Arc::get_mut(&mut session) + .expect("session should have no other references") + .features + .enable(Feature::Timers) + .expect("test config should allow feature update"); + let config = { + let state = session.state.lock().await; + state + .session_configuration + .original_config_do_not_use + .clone() + }; + let state_db = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should open"); + let thread_id = session.conversation_id.to_string(); + for index in 0..MAX_ACTIVE_TIMERS_PER_THREAD { + state_db + .create_thread_timer(&test_timer_params(&thread_id, &format!("timer-{index}"))) + .await + .expect("seed timer"); + } + + let err = session + .create_timer( + ThreadTimerTrigger::Delay { + seconds: 10, + repeat: None, + }, + MessagePayload { + content: "overflow".to_string(), + instructions: None, + meta: Default::default(), + }, + TimerDelivery::AfterTurn, + ) + .await + .expect_err("timer creation should reject full sqlite timer set"); + + assert_eq!( + err, + format!( + "too many active timers; each thread supports at most {MAX_ACTIVE_TIMERS_PER_THREAD} timers" + ) + ); + assert_eq!( + state_db + .list_thread_timers(&thread_id) + .await + .expect("list timers") + .len(), + MAX_ACTIVE_TIMERS_PER_THREAD + ); +} + +fn test_timer_params(thread_id: &str, id: &str) -> codex_state::ThreadTimerCreateParams { + codex_state::ThreadTimerCreateParams { + id: id.to_string(), + thread_id: thread_id.to_string(), + source: "agent".to_string(), + client_id: "codex-cli".to_string(), + trigger_json: r#"{"kind":"delay","seconds":10}"#.to_string(), + content: "existing timer".to_string(), + instructions: None, + meta_json: "{}".to_string(), + delivery: TimerDelivery::AfterTurn.as_str().to_string(), + created_at: 100, + next_run_at: Some(200), + last_run_at: None, + pending_run: false, + } +}