Skip to main content

microclaw_engine/internal/storage/db/
turns.rs

1use super::*;
2
3/// An interactive turn that was in flight when the previous process died.
4#[derive(Debug, Clone)]
5pub struct InterruptedTurn {
6    pub chat_id: i64,
7    pub channel: String,
8    pub chat_type: String,
9    pub started_at: String,
10    /// Rolling "step N: tool, tool" snapshot from the agent loop, if the run
11    /// got far enough to execute tools.
12    pub progress_text: Option<String>,
13    /// Durable agent-loop phase at the last checkpoint.
14    pub phase: String,
15    /// Zero-based loop iteration captured with `session_json`.
16    pub iteration: i64,
17    /// Last session state known to be safe for another LLM call.
18    pub session_json: Option<String>,
19    /// Whether startup recovery may continue automatically from this row.
20    pub resumable: bool,
21    /// Pending or executing tool summary when the process stopped.
22    pub tool_summary: Option<String>,
23    pub last_checkpoint_at: Option<String>,
24    pub run_id: Option<String>,
25}
26
27pub(crate) fn map_interrupted_turn(row: &rusqlite::Row<'_>) -> rusqlite::Result<InterruptedTurn> {
28    Ok(InterruptedTurn {
29        chat_id: row.get(0)?,
30        channel: row.get(1)?,
31        chat_type: row.get(2)?,
32        started_at: row.get(3)?,
33        progress_text: row.get(4)?,
34        phase: row.get(5)?,
35        iteration: row.get(6)?,
36        session_json: row.get(7)?,
37        resumable: row.get::<_, i64>(8)? != 0,
38        tool_summary: row.get(9)?,
39        last_checkpoint_at: row.get(10)?,
40        run_id: row.get(11)?,
41    })
42}
43
44impl Database {
45    /// Record that an interactive turn is in flight for `chat_id`. One row per
46    /// chat: a re-entrant start (queued follow-up in the same chat) just
47    /// refreshes the timestamp.
48    pub fn mark_turn_active(&self, chat_id: i64, channel: &str) -> Result<(), MicroClawError> {
49        self.mark_turn_active_with_context(chat_id, channel, "private", None)
50    }
51
52    /// Record an interactive run together with the routing context needed to
53    /// resume it after process restart.
54    pub fn mark_turn_active_with_context(
55        &self,
56        chat_id: i64,
57        channel: &str,
58        chat_type: &str,
59        run_id: Option<&str>,
60    ) -> Result<(), MicroClawError> {
61        let conn = self.lock_conn();
62        let now = chrono::Utc::now().to_rfc3339();
63        conn.execute(
64            "INSERT INTO active_turns (
65                chat_id, channel, chat_type, started_at, progress_text,
66                phase, iteration, session_json, resumable, tool_summary,
67                last_checkpoint_at, run_id
68             )
69             VALUES (?1, ?2, ?3, ?4, NULL, 'starting', 0, NULL, 0, NULL, ?4, ?5)
70             ON CONFLICT(chat_id) DO UPDATE SET
71                channel = ?2,
72                chat_type = ?3,
73                started_at = ?4,
74                progress_text = NULL,
75                phase = 'starting',
76                iteration = 0,
77                session_json = NULL,
78                resumable = 0,
79                tool_summary = NULL,
80                last_checkpoint_at = ?4,
81                run_id = ?5",
82            params![chat_id, channel, chat_type, now, run_id],
83        )?;
84        Ok(())
85    }
86
87    /// Attach a new process-local run id to an interrupted turn without
88    /// overwriting its last safe checkpoint. If recovery crashes before the
89    /// next checkpoint, the following startup can resume from the same state.
90    pub fn mark_turn_recovery_started(
91        &self,
92        chat_id: i64,
93        run_id: &str,
94    ) -> Result<(), MicroClawError> {
95        let conn = self.lock_conn();
96        conn.execute(
97            "UPDATE active_turns
98             SET phase = 'resuming', run_id = ?2
99             WHERE chat_id = ?1",
100            params![chat_id, run_id],
101        )?;
102        Ok(())
103    }
104
105    /// Update the rolling progress snapshot for an in-flight interactive turn.
106    /// No-op for chats without an active turn (e.g. scheduler-driven runs).
107    pub fn update_turn_progress(&self, chat_id: i64, progress: &str) -> Result<(), MicroClawError> {
108        let conn = self.lock_conn();
109        conn.execute(
110            "UPDATE active_turns SET progress_text = ?2 WHERE chat_id = ?1",
111            params![chat_id, progress],
112        )?;
113        Ok(())
114    }
115
116    /// Persist a durable agent-loop boundary.
117    ///
118    /// `session_json` must end at a provider-neutral message boundary. Callers
119    /// set `resumable=false` while a side-effecting tool may be in flight, so
120    /// startup recovery can never blindly replay an operation with uncertain
121    /// outcome.
122    #[allow(clippy::too_many_arguments)]
123    pub fn checkpoint_active_turn(
124        &self,
125        chat_id: i64,
126        phase: &str,
127        iteration: i64,
128        session_json: Option<&str>,
129        resumable: bool,
130        progress_text: Option<&str>,
131        tool_summary: Option<&str>,
132    ) -> Result<(), MicroClawError> {
133        let conn = self.lock_conn();
134        let now = chrono::Utc::now().to_rfc3339();
135        conn.execute(
136            "UPDATE active_turns
137             SET phase = ?2,
138                 iteration = ?3,
139                 session_json = COALESCE(?4, session_json),
140                 resumable = ?5,
141                 progress_text = COALESCE(?6, progress_text),
142                 tool_summary = ?7,
143                 last_checkpoint_at = ?8
144             WHERE chat_id = ?1",
145            params![
146                chat_id,
147                phase,
148                iteration,
149                session_json,
150                i64::from(resumable),
151                progress_text,
152                tool_summary,
153                now
154            ],
155        )?;
156        Ok(())
157    }
158
159    /// Inspect in-flight interactive runs without mutating recovery state.
160    pub fn list_active_turns(&self) -> Result<Vec<InterruptedTurn>, MicroClawError> {
161        let conn = self.lock_conn();
162        let mut stmt = conn.prepare(
163            "SELECT chat_id, channel, chat_type, started_at, progress_text,
164                    phase, iteration, session_json, resumable, tool_summary,
165                    last_checkpoint_at, run_id
166             FROM active_turns ORDER BY started_at",
167        )?;
168        let rows = stmt
169            .query_map([], map_interrupted_turn)?
170            .collect::<Result<Vec<_>, _>>()?;
171        Ok(rows)
172    }
173
174    /// The interactive turn for `chat_id` finished (any outcome).
175    pub fn clear_turn_active(&self, chat_id: i64) -> Result<(), MicroClawError> {
176        let conn = self.lock_conn();
177        conn.execute(
178            "DELETE FROM active_turns WHERE chat_id = ?1",
179            params![chat_id],
180        )?;
181        Ok(())
182    }
183
184    /// Startup crash recovery: return-and-clear every turn that was in flight
185    /// when the previous process died. The table is emptied in the same
186    /// transaction so a second caller can never double-notify.
187    pub fn take_interrupted_turns(&self) -> Result<Vec<InterruptedTurn>, MicroClawError> {
188        let mut conn = self.lock_conn();
189        let tx = conn.transaction()?;
190        let rows: Vec<InterruptedTurn> = {
191            let mut stmt = tx.prepare(
192                "SELECT chat_id, channel, chat_type, started_at, progress_text,
193                        phase, iteration, session_json, resumable, tool_summary,
194                        last_checkpoint_at, run_id
195                 FROM active_turns ORDER BY started_at",
196            )?;
197            let collected = stmt
198                .query_map([], map_interrupted_turn)?
199                .collect::<Result<Vec<InterruptedTurn>, _>>()?;
200            collected
201        };
202        tx.execute("DELETE FROM active_turns", [])?;
203        tx.commit()?;
204        Ok(rows)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use crate::internal::storage::db::test_support::*;
211
212    #[test]
213    fn test_active_turns_mark_clear_and_take() {
214        let (db, dir) = test_db();
215
216        // Nothing tracked → nothing to recover.
217        assert!(db.take_interrupted_turns().unwrap().is_empty());
218
219        db.mark_turn_active(10, "telegram").unwrap();
220        db.mark_turn_active(20, "discord").unwrap();
221        // Re-entrant mark for the same chat refreshes rather than duplicates.
222        db.mark_turn_active(10, "telegram").unwrap();
223        // Progress snapshots attach to the in-flight turn; unknown chat = no-op.
224        db.update_turn_progress(10, "step 3: web_search, read_file")
225            .unwrap();
226        db.update_turn_progress(999, "ignored").unwrap();
227        // A turn that finished cleanly leaves no residue.
228        db.clear_turn_active(20).unwrap();
229
230        let orphans = db.take_interrupted_turns().unwrap();
231        assert_eq!(orphans.len(), 1);
232        assert_eq!(orphans[0].chat_id, 10);
233        assert_eq!(orphans[0].channel, "telegram");
234        assert!(!orphans[0].started_at.is_empty());
235        assert_eq!(
236            orphans[0].progress_text.as_deref(),
237            Some("step 3: web_search, read_file")
238        );
239
240        // take is destructive: a second sweep can never double-notify.
241        assert!(db.take_interrupted_turns().unwrap().is_empty());
242        // Clearing an unknown chat is a no-op, not an error.
243        db.clear_turn_active(999).unwrap();
244        cleanup(&dir);
245    }
246
247    #[test]
248    fn test_active_turn_checkpoint_survives_recovery_restart_boundary() {
249        let (db, dir) = test_db();
250        let session = r#"[{"role":"user","content":"finish the task"}]"#;
251
252        db.mark_turn_active_with_context(42, "slack", "group", Some("run-original"))
253            .unwrap();
254        db.checkpoint_active_turn(
255            42,
256            "ready_for_llm",
257            3,
258            Some(session),
259            true,
260            Some("step 3 complete"),
261            None,
262        )
263        .unwrap();
264        let checkpoint_at = db.list_active_turns().unwrap()[0]
265            .last_checkpoint_at
266            .clone();
267
268        // A recovery claim updates only process-local metadata. Simulating a
269        // second crash here must leave the safe session checkpoint available.
270        db.mark_turn_recovery_started(42, "run-recovery").unwrap();
271        let turns = db.list_active_turns().unwrap();
272        assert_eq!(turns.len(), 1);
273        assert_eq!(turns[0].chat_type, "group");
274        assert_eq!(turns[0].phase, "resuming");
275        assert_eq!(turns[0].iteration, 3);
276        assert_eq!(turns[0].session_json.as_deref(), Some(session));
277        assert!(turns[0].resumable);
278        assert_eq!(turns[0].run_id.as_deref(), Some("run-recovery"));
279        assert_eq!(turns[0].last_checkpoint_at, checkpoint_at);
280
281        // Once a tool starts, retain the prior evidence but mark the boundary
282        // non-resumable so startup cannot replay an uncertain side effect.
283        db.checkpoint_active_turn(
284            42,
285            "executing_tools",
286            4,
287            None,
288            false,
289            None,
290            Some("send_message(medium)"),
291        )
292        .unwrap();
293        let turn = db.list_active_turns().unwrap().pop().unwrap();
294        assert!(!turn.resumable);
295        assert_eq!(turn.session_json.as_deref(), Some(session));
296        assert_eq!(turn.tool_summary.as_deref(), Some("send_message(medium)"));
297
298        db.clear_turn_active(42).unwrap();
299        cleanup(&dir);
300    }
301}