From 529b855d608a277abab3a0b51f3f2a9474f953e0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 10 Apr 2026 19:11:16 -0700 Subject: [PATCH] codex: fix overdue one-shot timer claim on restart (#17380) --- codex-rs/core/src/codex/timer_runtime.rs | 6 +++- codex-rs/state/src/runtime/timers.rs | 42 ++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex/timer_runtime.rs b/codex-rs/core/src/codex/timer_runtime.rs index d0e3d7cc69..181d520566 100644 --- a/codex-rs/core/src/codex/timer_runtime.rs +++ b/codex-rs/core/src/codex/timer_runtime.rs @@ -572,7 +572,11 @@ impl Session { let thread_id = self.thread_id_string(); let result = if claimed.deleted_one_shot_timer { state_db - .claim_one_shot_thread_timer(&thread_id, &claimed.timer.id) + .claim_one_shot_thread_timer( + &thread_id, + &claimed.timer.id, + claimed.context.queued_at, + ) .await } else { let persisted_timer = PersistedTimer { diff --git a/codex-rs/state/src/runtime/timers.rs b/codex-rs/state/src/runtime/timers.rs index 994bbc7178..2e00ec6e99 100644 --- a/codex-rs/state/src/runtime/timers.rs +++ b/codex-rs/state/src/runtime/timers.rs @@ -138,17 +138,26 @@ WHERE thread_id = ? &self, thread_id: &str, id: &str, + due_at: i64, ) -> anyhow::Result { let result = sqlx::query( r#" DELETE FROM thread_timers WHERE thread_id = ? AND id = ? - AND pending_run = 1 + AND ( + pending_run = 1 + OR ( + pending_run = 0 + AND next_run_at IS NOT NULL + AND next_run_at <= ? + ) + ) "#, ) .bind(thread_id) .bind(id) + .bind(due_at) .execute(self.pool.as_ref()) .await?; Ok(result.rows_affected() > 0) @@ -340,13 +349,13 @@ ORDER BY name assert!( runtime - .claim_one_shot_thread_timer("thread-1", "timer-1") + .claim_one_shot_thread_timer("thread-1", "timer-1", 110) .await .expect("claim timer") ); assert!( !runtime - .claim_one_shot_thread_timer("thread-1", "timer-1") + .claim_one_shot_thread_timer("thread-1", "timer-1", 110) .await .expect("claim timer again") ); @@ -409,6 +418,33 @@ ORDER BY name assert!(!timers[0].pending_run); } + #[tokio::test] + async fn one_shot_claim_consumes_overdue_timer_after_restart() { + let runtime = test_runtime().await; + let mut params = timer_params("timer-1", "thread-1"); + params.trigger_json = r#"{"kind":"delay","seconds":10,"repeat":false}"#.to_string(); + params.next_run_at = Some(110); + params.pending_run = false; + runtime + .create_thread_timer(¶ms) + .await + .expect("create overdue one-shot timer"); + + assert!( + runtime + .claim_one_shot_thread_timer("thread-1", "timer-1", 110) + .await + .expect("claim overdue one-shot timer") + ); + assert!( + runtime + .list_thread_timers("thread-1") + .await + .expect("list timers") + .is_empty() + ); + } + #[tokio::test] async fn recurring_idle_claim_rejects_stale_last_run_at_even_when_pending_stays_true() { let runtime = test_runtime().await;