Skip to main content

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}