agentd/runtime/timers.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **durable timer wheel**: absolute
3//! deadlines owned by a step, a tool request, a start node or the lifecycle;
4//! armed through the store, fired by the loop's tick (`fire`), re-armed from
5//! the restored records at startup (past deadlines fire immediately).
6//!
7//! The durable row outlives the firing: `fire` hands the record to the loop and
8//! only `settle` — at the head of the next tick's `fire`, i.e. after the tick
9//! that ran the effect has checkpointed — deletes it. See [`Timers::fire`] for
10//! why that ordering is the only crash-safe one.
11
12use super::reactor::Runtime;
13use crate::engine::run::StepStatus;
14use crate::state::{Durable, TimerRecord, now_ms, ulid};
15use crate::store::StoreError;
16use serde_json::{Value, json};
17use std::collections::BTreeMap;
18
19pub struct Timers {
20 /// id → record (sorted by id; scanned by deadline on fire — the count is small).
21 map: BTreeMap<String, TimerRecord>,
22 /// Fired, effect in flight, row still in the store — drained by `settle`.
23 settling: Vec<TimerRecord>,
24}
25
26impl Timers {
27 pub fn new() -> Timers {
28 Timers {
29 map: BTreeMap::new(),
30 settling: Vec::new(),
31 }
32 }
33
34 /// Adopt restored records.
35 pub fn restore(&mut self, records: Vec<TimerRecord>) {
36 for r in records {
37 self.map.insert(r.id.clone(), r);
38 }
39 }
40
41 /// Arm a durable timer. `owner` names who to notify (`{"kind": "tool",
42 /// "node": n, "req": id}` / `{"kind": "step", "run": r, "step": s}` / …).
43 pub fn arm(
44 &mut self,
45 d: &Durable,
46 deadline_ms: u64,
47 owner: Value,
48 payload: Value,
49 ) -> Result<String, StoreError> {
50 let id = ulid::new();
51 let rec = TimerRecord {
52 id: id.clone(),
53 deadline_ms,
54 owner,
55 payload,
56 };
57 d.timer_arm(&rec)?;
58 crate::state::kill_point("wait.armed");
59 self.map.insert(id.clone(), rec);
60 Ok(id)
61 }
62
63 /// Disarm (delete) a timer — armed or still settling (a cancelled run's
64 /// timers arrive here through `owned_by`, which reports both, so a row that
65 /// fired moments ago is deleted rather than left to re-fire after a restart).
66 pub fn disarm(&mut self, d: &Durable, id: &str) -> Result<(), StoreError> {
67 let armed = self.map.remove(id).is_some();
68 let settling = self.settling.iter().any(|r| r.id == id);
69 self.settling.retain(|r| r.id != id);
70 if armed || settling {
71 d.timer_disarm(id)?;
72 }
73 Ok(())
74 }
75
76 /// Fire every due timer: returns them, removed from the wheel but NOT yet
77 /// from the store.
78 ///
79 /// The caller runs the effect (`on_timer`) after this returns, and that
80 /// effect is only durable once the same tick reaches its checkpoint.
81 /// Deleting the row first opens a window in which a crash loses BOTH the
82 /// timer and its consequence: the suspended step the timer owned would have
83 /// nothing left to wake it — `poll_waits` does not look at the timer-backed
84 /// wait kinds — so the run wedges forever while the reactor keeps spinning
85 /// at its 5 ms floor around a step that can never advance. Effects are
86 /// at-least-once by design — every effect carries an idempotency key and a
87 /// replay is expected — so the survivable direction
88 /// is the other one — keep the row until the consequence is durable and let
89 /// a crash inside the window re-fire the timer on restore.
90 pub fn fire(&mut self, d: &Durable, now: u64) -> Vec<TimerRecord> {
91 // The previous tick's effects are checkpointed by now (step 10 of the
92 // loop runs between two `fire`s), so their rows can go.
93 self.settle(d);
94 let due: Vec<String> = self
95 .map
96 .iter()
97 .filter(|(_, r)| r.deadline_ms <= now)
98 .map(|(id, _)| id.clone())
99 .collect();
100 let mut out = Vec::new();
101 for id in due {
102 if let Some(r) = self.map.remove(&id) {
103 self.settling.push(r.clone());
104 out.push(r);
105 }
106 }
107 out
108 }
109
110 /// Delete the rows of timers whose effect has been checkpointed. Idempotent
111 /// (a delete that is lost re-fires the timer once more, which is safe).
112 pub fn settle(&mut self, d: &Durable) {
113 for r in std::mem::take(&mut self.settling) {
114 let _ = d.timer_disarm(&r.id);
115 }
116 }
117
118 /// Whether `id` is still armed (a settling timer has already fired).
119 pub fn contains(&self, id: &str) -> bool {
120 self.map.contains_key(id)
121 }
122
123 /// The earliest deadline (for idle decisions).
124 pub fn next_deadline(&self) -> Option<u64> {
125 self.map.values().map(|r| r.deadline_ms).min()
126 }
127 pub fn len(&self) -> usize {
128 self.map.len()
129 }
130 pub fn is_empty(&self) -> bool {
131 self.map.is_empty()
132 }
133 /// Timers owned by something matching `pred` — armed *and* settling, so a
134 /// cancelled run takes its just-fired rows with it.
135 pub fn owned_by(&self, pred: impl Fn(&Value) -> bool) -> Vec<String> {
136 self.map
137 .values()
138 .chain(self.settling.iter())
139 .filter(|r| pred(&r.owner))
140 .map(|r| r.id.clone())
141 .collect()
142 }
143 pub fn status(&self) -> Value {
144 let now = now_ms();
145 json!(self.map.values().map(|r| json!({"id": r.id, "in_ms": r.deadline_ms.saturating_sub(now), "owner": r.owner})).collect::<Vec<_>>())
146 }
147}
148
149impl Default for Timers {
150 fn default() -> Self {
151 Self::new()
152 }
153}
154
155impl Runtime {
156 /// **Restore-time repair** — the startup pass that re-arms timers: a
157 /// `Suspended` step whose durable timer did not come back is unreachable.
158 /// `poll_waits` only resolves the wait kinds it can evaluate itself
159 /// (`condition`, `run`, `join`, deadlines…); the timer-backed ones —
160 /// `sleep`, `waiting_budget`, `retry_backoff` — are woken by `on_timer` and
161 /// by nothing else, so a missing row means that run never moves again while
162 /// the reactor keeps ticking around it.
163 ///
164 /// A timer can go missing legitimately: the store lost it, or the process
165 /// died in the window between a firing and its checkpoint (which `fire`
166 /// narrows but cannot close). Either way the repair is the same — re-arm at
167 /// the recorded deadline, which fires immediately when that instant has
168 /// passed. Re-running an idempotency-keyed effect is safe; leaving the step
169 /// wedged is not. If the store refuses the re-arm, the step is failed
170 /// explicitly so the operator sees a failure instead of a hang.
171 pub(crate) fn repair_orphaned_timer_waits(&mut self) {
172 let now = now_ms();
173 // (run, step, wait kind, deadline) — collected first: arming mutates.
174 let orphans: Vec<(String, String, String, u64)> = self
175 .runs
176 .values()
177 .filter(|r| !r.status.is_terminal())
178 .flat_map(|r| {
179 r.steps
180 .iter()
181 .filter_map(|(sid, st)| {
182 if st.status != StepStatus::Suspended {
183 return None;
184 }
185 let w = st.wait.as_ref()?;
186 // Only a wait that NAMES a timer depends on one.
187 let id = w["timer"].as_str()?;
188 if self.timers.contains(id) {
189 return None;
190 }
191 Some((
192 r.id.clone(),
193 sid.clone(),
194 w["kind"].as_str().unwrap_or("").to_string(),
195 w["deadline_ms"]
196 .as_u64()
197 .or_else(|| w["until_ms"].as_u64())
198 .unwrap_or(now),
199 ))
200 })
201 .collect::<Vec<_>>()
202 })
203 .collect();
204 for (run, step, kind, deadline) in orphans {
205 // The owner kind is what `on_timer` switches on: `step` finishes a
206 // `sleep` done, `step_budget` returns the step to pending. The
207 // original `sleep` payload (`slept_ms`) died with the row, so the
208 // repaired firing reports the repair as the step's output instead.
209 let (owner_kind, payload) = match kind.as_str() {
210 "sleep" => ("step", json!({"repaired": true})),
211 _ => ("step_budget", Value::Null),
212 };
213 match self.timers.arm(
214 &self.durable,
215 deadline,
216 json!({"kind": owner_kind, "run": run, "step": step}),
217 payload,
218 ) {
219 Ok(id) => {
220 if let Some(st) = self.runs.get_mut(&run).and_then(|r| r.steps.get_mut(&step))
221 && let Some(w) = st.wait.as_mut()
222 {
223 w["timer"] = json!(id);
224 w["repaired"] = json!(true);
225 }
226 if let Some(r) = self.runs.get_mut(&run) {
227 r.touch();
228 }
229 self.log.warn(
230 "restore.timer.repaired",
231 json!({"run": run, "step": step, "wait": kind, "deadline_ms": deadline, "timer": id}),
232 );
233 }
234 Err(e) => {
235 self.log.error(
236 "restore.timer.lost",
237 json!({"run": run, "step": step, "wait": kind, "err": e.to_string()}),
238 );
239 self.finish_step_pub(
240 &run,
241 &step,
242 StepStatus::Failed,
243 None,
244 Some(format!(
245 "suspended on a timer that is gone, and re-arming it failed: {e}"
246 )),
247 0,
248 );
249 }
250 }
251 }
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::state::Policy;
259 use crate::store::memory::MemoryStore;
260 use std::sync::Arc;
261
262 #[test]
263 fn timers_arm_fire_disarm_and_restore() {
264 let d = Durable::new(
265 Arc::new(MemoryStore::new()),
266 "agentd",
267 "i",
268 Policy::default(),
269 None,
270 );
271 let mut t = Timers::new();
272 let now = now_ms();
273 let a = t
274 .arm(
275 &d,
276 now + 10_000,
277 json!({"kind": "step", "run": "r"}),
278 json!({}),
279 )
280 .unwrap();
281 let b = t
282 .arm(
283 &d,
284 now.saturating_sub(1),
285 json!({"kind": "tool", "node": 1, "req": 2}),
286 json!({"slept": 1}),
287 )
288 .unwrap();
289 assert_eq!(t.len(), 2);
290 assert_eq!(t.next_deadline(), Some(now.saturating_sub(1)));
291 let fired = t.fire(&d, now);
292 assert_eq!(fired.len(), 1);
293 assert_eq!(fired[0].id, b);
294 assert_eq!(t.owned_by(|o| o["kind"] == json!("step")), vec![a.clone()]);
295 // `b`'s effect has not been checkpointed yet, so its row is still in the
296 // store: a crash here re-fires it rather than losing it.
297 assert_eq!(d.restore().unwrap().timers().len(), 2);
298 // The next tick settles it — one firing, one deletion.
299 assert!(t.fire(&d, now).is_empty());
300 // Restore from the store: only `a` survives.
301 let restored = d.restore().unwrap();
302 let mut t2 = Timers::new();
303 t2.restore(restored.timers());
304 assert_eq!(t2.len(), 1);
305 t2.disarm(&d, &a).unwrap();
306 assert!(t2.is_empty());
307 assert!(d.restore().unwrap().timers().is_empty());
308 }
309}