Skip to main content

microclaw_engine/internal/storage/db/
subagents.rs

1use super::*;
2
3#[derive(Debug, Clone)]
4pub struct SubagentRunRecord {
5    pub run_id: String,
6    pub parent_run_id: Option<String>,
7    pub depth: i64,
8    pub chat_id: i64,
9    pub caller_channel: String,
10    pub task: String,
11    pub context: String,
12    pub status: String,
13    pub created_at: String,
14    pub started_at: Option<String>,
15    pub finished_at: Option<String>,
16    pub cancel_requested: bool,
17    pub error_text: Option<String>,
18    pub result_text: Option<String>,
19    pub input_tokens: i64,
20    pub output_tokens: i64,
21    pub total_tokens: i64,
22    pub provider: String,
23    pub model: String,
24    pub token_budget: i64,
25    pub artifact_json: Option<String>,
26    pub label: Option<String>,
27    pub progress_text: Option<String>,
28    pub last_progress_at: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32pub struct SubagentAnnounceRecord {
33    pub id: i64,
34    pub run_id: String,
35    pub chat_id: i64,
36    pub caller_channel: String,
37    pub payload_text: String,
38    pub status: String,
39    pub attempts: i64,
40    pub next_attempt_at: Option<String>,
41    pub last_error: Option<String>,
42}
43
44pub struct CreateSubagentRunParams<'a> {
45    pub run_id: &'a str,
46    pub parent_run_id: Option<&'a str>,
47    pub depth: i64,
48    pub token_budget: i64,
49    pub chat_id: i64,
50    pub caller_channel: &'a str,
51    pub task: &'a str,
52    pub context: &'a str,
53    pub provider: &'a str,
54    pub model: &'a str,
55    pub label: Option<&'a str>,
56}
57
58pub struct FinishSubagentRunParams<'a> {
59    pub run_id: &'a str,
60    pub status: &'a str,
61    pub error_text: Option<&'a str>,
62    pub result_text: Option<&'a str>,
63    pub artifact_json: Option<&'a str>,
64    pub input_tokens: i64,
65    pub output_tokens: i64,
66}
67
68#[derive(Debug, Clone)]
69pub struct SubagentObservabilitySnapshot {
70    pub active_runs: i64,
71    pub queued_runs: i64,
72    pub running_runs: i64,
73    pub pending_announces: i64,
74    pub retry_announces: i64,
75    pub failed_announces: i64,
76    pub completed_24h: i64,
77    pub failed_24h: i64,
78    pub budget_exceeded_24h: i64,
79    pub avg_duration_ms_24h: i64,
80    pub recent_runs: Vec<SubagentRunRecord>,
81}
82
83#[derive(Debug, Clone)]
84pub struct SubagentEventRecord {
85    pub id: i64,
86    pub run_id: String,
87    pub event_type: String,
88    pub detail: Option<String>,
89    pub created_at: String,
90}
91
92impl Database {
93    pub fn create_subagent_run(
94        &self,
95        params: CreateSubagentRunParams<'_>,
96    ) -> Result<(), MicroClawError> {
97        let conn = self.lock_conn();
98        let now = chrono::Utc::now().to_rfc3339();
99        conn.execute(
100            "INSERT INTO subagent_runs(
101                run_id, parent_run_id, depth, token_budget, chat_id, caller_channel, task, context, status, created_at, provider, model, label
102            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'accepted', ?9, ?10, ?11, ?12)",
103            params![
104                params.run_id,
105                params.parent_run_id,
106                params.depth,
107                params.token_budget,
108                params.chat_id,
109                params.caller_channel,
110                params.task,
111                params.context,
112                now,
113                params.provider,
114                params.model,
115                params.label
116            ],
117        )?;
118        Ok(())
119    }
120
121    /// Record a progress snapshot for a running sub-agent: update the latest
122    /// progress text/time on the run and append a `progress` event to its
123    /// timeline. Returns the previous `last_progress_at` (for throttling).
124    pub fn record_subagent_progress(
125        &self,
126        run_id: &str,
127        progress_text: &str,
128    ) -> Result<Option<String>, MicroClawError> {
129        let conn = self.lock_conn();
130        let now = chrono::Utc::now().to_rfc3339();
131        let prev: Option<String> = conn
132            .query_row(
133                "SELECT last_progress_at FROM subagent_runs WHERE run_id = ?1",
134                params![run_id],
135                |row| row.get(0),
136            )
137            .optional()?
138            .flatten();
139        conn.execute(
140            "UPDATE subagent_runs SET progress_text = ?2, last_progress_at = ?3 WHERE run_id = ?1",
141            params![run_id, progress_text, now],
142        )?;
143        conn.execute(
144            "INSERT INTO subagent_events(run_id, event_type, detail, created_at)
145             VALUES (?1, 'progress', ?2, ?3)",
146            params![run_id, progress_text, now],
147        )?;
148        Ok(prev)
149    }
150
151    pub fn mark_subagent_queued(&self, run_id: &str) -> Result<(), MicroClawError> {
152        let conn = self.lock_conn();
153        conn.execute(
154            "UPDATE subagent_runs
155             SET status = 'queued'
156             WHERE run_id = ?1 AND status = 'accepted'",
157            params![run_id],
158        )?;
159        Ok(())
160    }
161
162    pub fn mark_subagent_running(&self, run_id: &str) -> Result<(), MicroClawError> {
163        let conn = self.lock_conn();
164        let now = chrono::Utc::now().to_rfc3339();
165        conn.execute(
166            "UPDATE subagent_runs
167             SET status = 'running', started_at = COALESCE(started_at, ?2)
168             WHERE run_id = ?1",
169            params![run_id, now],
170        )?;
171        Ok(())
172    }
173
174    pub fn mark_subagent_finished(
175        &self,
176        params: FinishSubagentRunParams<'_>,
177    ) -> Result<(), MicroClawError> {
178        let conn = self.lock_conn();
179        let now = chrono::Utc::now().to_rfc3339();
180        conn.execute(
181            "UPDATE subagent_runs
182             SET status = ?2,
183                 finished_at = ?3,
184                 error_text = ?4,
185                 result_text = ?5,
186                 artifact_json = ?6,
187                 input_tokens = ?7,
188                 output_tokens = ?8,
189                 total_tokens = (?7 + ?8)
190             WHERE run_id = ?1",
191            params![
192                params.run_id,
193                params.status,
194                now,
195                params.error_text,
196                params.result_text,
197                params.artifact_json,
198                params.input_tokens,
199                params.output_tokens
200            ],
201        )?;
202        Ok(())
203    }
204
205    /// Startup crash recovery: sub-agent runs execute in-process, so any run
206    /// still marked in-flight when a new process boots was killed mid-run.
207    /// Retire them as `interrupted` so lists and gates stop counting them,
208    /// and return how many rows were fixed.
209    pub fn recover_orphaned_subagent_runs(&self) -> Result<usize, MicroClawError> {
210        let conn = self.lock_conn();
211        let now = chrono::Utc::now().to_rfc3339();
212        let affected = conn.execute(
213            "UPDATE subagent_runs
214             SET status = 'interrupted',
215                 finished_at = ?1,
216                 error_text = COALESCE(error_text, 'process restarted while this run was in flight')
217             WHERE status IN ('accepted', 'queued', 'running')",
218            params![now],
219        )?;
220        Ok(affected)
221    }
222
223    pub fn request_subagent_cancel(
224        &self,
225        run_id: &str,
226        chat_id: i64,
227    ) -> Result<bool, MicroClawError> {
228        let conn = self.lock_conn();
229        let affected = conn.execute(
230            "UPDATE subagent_runs
231             SET cancel_requested = 1
232             WHERE run_id = ?1 AND chat_id = ?2
233               AND status IN ('accepted', 'queued', 'running')",
234            params![run_id, chat_id],
235        )?;
236        Ok(affected > 0)
237    }
238
239    pub fn list_subagent_runs(
240        &self,
241        chat_id: i64,
242        limit: usize,
243    ) -> Result<Vec<SubagentRunRecord>, MicroClawError> {
244        let conn = self.lock_conn();
245        let mut stmt = conn.prepare(
246            "SELECT run_id, parent_run_id, depth, token_budget, chat_id, caller_channel, task, context, status, created_at,
247                    started_at, finished_at, cancel_requested, error_text, result_text,
248                    input_tokens, output_tokens, total_tokens, provider, model, artifact_json,
249                    label, progress_text, last_progress_at
250             FROM subagent_runs
251             WHERE chat_id = ?1
252             ORDER BY created_at DESC
253             LIMIT ?2",
254        )?;
255        let rows = stmt.query_map(params![chat_id, limit.max(1) as i64], |row| {
256            Ok(SubagentRunRecord {
257                run_id: row.get(0)?,
258                parent_run_id: row.get(1)?,
259                depth: row.get(2)?,
260                token_budget: row.get(3)?,
261                chat_id: row.get(4)?,
262                caller_channel: row.get(5)?,
263                task: row.get(6)?,
264                context: row.get(7)?,
265                status: row.get(8)?,
266                created_at: row.get(9)?,
267                started_at: row.get(10)?,
268                finished_at: row.get(11)?,
269                cancel_requested: row.get::<_, i64>(12)? != 0,
270                error_text: row.get(13)?,
271                result_text: row.get(14)?,
272                input_tokens: row.get(15)?,
273                output_tokens: row.get(16)?,
274                total_tokens: row.get(17)?,
275                provider: row.get(18)?,
276                model: row.get(19)?,
277                artifact_json: row.get(20)?,
278                label: row.get(21)?,
279                progress_text: row.get(22)?,
280                last_progress_at: row.get(23)?,
281            })
282        })?;
283        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
284    }
285
286    /// Average wall-clock duration (seconds) of recently-completed sub-agent runs
287    /// in a chat, for rough ETA hints. `None` when there's no completed history.
288    pub fn avg_completed_subagent_duration_secs(
289        &self,
290        chat_id: i64,
291    ) -> Result<Option<i64>, MicroClawError> {
292        let conn = self.lock_conn();
293        let avg: Option<f64> = conn.query_row(
294            "SELECT AVG((julianday(finished_at) - julianday(started_at)) * 86400.0)
295             FROM subagent_runs
296             WHERE chat_id = ?1 AND status = 'completed'
297               AND started_at IS NOT NULL AND finished_at IS NOT NULL",
298            params![chat_id],
299            |row| row.get(0),
300        )?;
301        Ok(avg.map(|v| v.round() as i64).filter(|v| *v > 0))
302    }
303
304    /// All currently-active sub-agent runs across chats (accepted/queued/running),
305    /// oldest first. Used by the proactive task-standup loop.
306    pub fn list_active_subagent_runs(&self) -> Result<Vec<SubagentRunRecord>, MicroClawError> {
307        let conn = self.lock_conn();
308        let mut stmt = conn.prepare(
309            "SELECT run_id, parent_run_id, depth, token_budget, chat_id, caller_channel, task, context, status, created_at,
310                    started_at, finished_at, cancel_requested, error_text, result_text,
311                    input_tokens, output_tokens, total_tokens, provider, model, artifact_json,
312                    label, progress_text, last_progress_at
313             FROM subagent_runs
314             WHERE status IN ('accepted', 'queued', 'running')
315             ORDER BY chat_id ASC, created_at ASC",
316        )?;
317        let rows = stmt.query_map([], |row| {
318            Ok(SubagentRunRecord {
319                run_id: row.get(0)?,
320                parent_run_id: row.get(1)?,
321                depth: row.get(2)?,
322                token_budget: row.get(3)?,
323                chat_id: row.get(4)?,
324                caller_channel: row.get(5)?,
325                task: row.get(6)?,
326                context: row.get(7)?,
327                status: row.get(8)?,
328                created_at: row.get(9)?,
329                started_at: row.get(10)?,
330                finished_at: row.get(11)?,
331                cancel_requested: row.get::<_, i64>(12)? != 0,
332                error_text: row.get(13)?,
333                result_text: row.get(14)?,
334                input_tokens: row.get(15)?,
335                output_tokens: row.get(16)?,
336                total_tokens: row.get(17)?,
337                provider: row.get(18)?,
338                model: row.get(19)?,
339                artifact_json: row.get(20)?,
340                label: row.get(21)?,
341                progress_text: row.get(22)?,
342                last_progress_at: row.get(23)?,
343            })
344        })?;
345        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
346    }
347
348    pub fn get_subagent_run(
349        &self,
350        run_id: &str,
351        chat_id: i64,
352    ) -> Result<Option<SubagentRunRecord>, MicroClawError> {
353        let conn = self.lock_conn();
354        conn.query_row(
355            "SELECT run_id, parent_run_id, depth, token_budget, chat_id, caller_channel, task, context, status, created_at,
356                    started_at, finished_at, cancel_requested, error_text, result_text,
357                    input_tokens, output_tokens, total_tokens, provider, model, artifact_json,
358                    label, progress_text, last_progress_at
359             FROM subagent_runs
360             WHERE run_id = ?1 AND chat_id = ?2",
361            params![run_id, chat_id],
362            |row| {
363                Ok(SubagentRunRecord {
364                    run_id: row.get(0)?,
365                    parent_run_id: row.get(1)?,
366                    depth: row.get(2)?,
367                    token_budget: row.get(3)?,
368                    chat_id: row.get(4)?,
369                    caller_channel: row.get(5)?,
370                    task: row.get(6)?,
371                    context: row.get(7)?,
372                    status: row.get(8)?,
373                    created_at: row.get(9)?,
374                    started_at: row.get(10)?,
375                    finished_at: row.get(11)?,
376                    cancel_requested: row.get::<_, i64>(12)? != 0,
377                    error_text: row.get(13)?,
378                    result_text: row.get(14)?,
379                    input_tokens: row.get(15)?,
380                    output_tokens: row.get(16)?,
381                    total_tokens: row.get(17)?,
382                    provider: row.get(18)?,
383                    model: row.get(19)?,
384                    artifact_json: row.get(20)?,
385                label: row.get(21)?,
386                progress_text: row.get(22)?,
387                last_progress_at: row.get(23)?,
388                })
389            },
390        )
391        .optional()
392        .map_err(Into::into)
393    }
394
395    /// All child runs of a parent run, oldest first.
396    pub fn list_subagent_children(
397        &self,
398        parent_run_id: &str,
399    ) -> Result<Vec<SubagentRunRecord>, MicroClawError> {
400        let conn = self.lock_conn();
401        let mut stmt = conn.prepare(
402            "SELECT run_id, parent_run_id, depth, token_budget, chat_id, caller_channel, task, context, status, created_at,
403                    started_at, finished_at, cancel_requested, error_text, result_text,
404                    input_tokens, output_tokens, total_tokens, provider, model, artifact_json,
405                    label, progress_text, last_progress_at
406             FROM subagent_runs
407             WHERE parent_run_id = ?1
408             ORDER BY created_at ASC",
409        )?;
410        let rows = stmt.query_map(params![parent_run_id], |row| {
411            Ok(SubagentRunRecord {
412                run_id: row.get(0)?,
413                parent_run_id: row.get(1)?,
414                depth: row.get(2)?,
415                token_budget: row.get(3)?,
416                chat_id: row.get(4)?,
417                caller_channel: row.get(5)?,
418                task: row.get(6)?,
419                context: row.get(7)?,
420                status: row.get(8)?,
421                created_at: row.get(9)?,
422                started_at: row.get(10)?,
423                finished_at: row.get(11)?,
424                cancel_requested: row.get::<_, i64>(12)? != 0,
425                error_text: row.get(13)?,
426                result_text: row.get(14)?,
427                input_tokens: row.get(15)?,
428                output_tokens: row.get(16)?,
429                total_tokens: row.get(17)?,
430                provider: row.get(18)?,
431                model: row.get(19)?,
432                artifact_json: row.get(20)?,
433                label: row.get(21)?,
434                progress_text: row.get(22)?,
435                last_progress_at: row.get(23)?,
436            })
437        })?;
438        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
439    }
440
441    /// Resolve a sub-agent reference that is either an exact run_id or a
442    /// human-friendly label, scoped to a chat. Exact run_id wins; otherwise the
443    /// most recent run with that label is returned, preferring active ones.
444    pub fn resolve_subagent_run_id(
445        &self,
446        chat_id: i64,
447        run_id_or_label: &str,
448    ) -> Result<Option<String>, MicroClawError> {
449        let conn = self.lock_conn();
450        let exact: Option<String> = conn
451            .query_row(
452                "SELECT run_id FROM subagent_runs WHERE run_id = ?1 AND chat_id = ?2",
453                params![run_id_or_label, chat_id],
454                |row| row.get(0),
455            )
456            .optional()?;
457        if exact.is_some() {
458            return Ok(exact);
459        }
460        let by_label: Option<String> = conn
461            .query_row(
462                "SELECT run_id FROM subagent_runs
463                 WHERE chat_id = ?1 AND label = ?2
464                 ORDER BY (status IN ('accepted','queued','running')) DESC, created_at DESC
465                 LIMIT 1",
466                params![chat_id, run_id_or_label],
467                |row| row.get(0),
468            )
469            .optional()?;
470        Ok(by_label)
471    }
472
473    pub fn is_subagent_cancel_requested(&self, run_id: &str) -> Result<bool, MicroClawError> {
474        let conn = self.lock_conn();
475        let requested = conn
476            .query_row(
477                "SELECT cancel_requested FROM subagent_runs WHERE run_id = ?1",
478                params![run_id],
479                |row| row.get::<_, i64>(0),
480            )
481            .optional()?
482            .unwrap_or(0);
483        Ok(requested != 0)
484    }
485
486    pub fn count_active_subagent_runs_for_chat(&self, chat_id: i64) -> Result<i64, MicroClawError> {
487        let conn = self.lock_conn();
488        conn.query_row(
489            "SELECT COUNT(*)
490             FROM subagent_runs
491             WHERE chat_id = ?1
492               AND status IN ('accepted', 'queued', 'running')",
493            params![chat_id],
494            |row| row.get(0),
495        )
496        .map_err(Into::into)
497    }
498
499    pub fn count_active_subagent_children(
500        &self,
501        parent_run_id: &str,
502    ) -> Result<i64, MicroClawError> {
503        let conn = self.lock_conn();
504        conn.query_row(
505            "SELECT COUNT(*)
506             FROM subagent_runs
507             WHERE parent_run_id = ?1
508               AND status IN ('accepted', 'queued', 'running')",
509            params![parent_run_id],
510            |row| row.get(0),
511        )
512        .map_err(Into::into)
513    }
514
515    pub fn enqueue_subagent_announce(
516        &self,
517        run_id: &str,
518        chat_id: i64,
519        caller_channel: &str,
520        payload_text: &str,
521    ) -> Result<(), MicroClawError> {
522        let conn = self.lock_conn();
523        let now = chrono::Utc::now().to_rfc3339();
524        conn.execute(
525            "INSERT INTO subagent_announces(
526                run_id, chat_id, caller_channel, payload_text, status, attempts, next_attempt_at, created_at, updated_at
527            ) VALUES(?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?6)
528            ON CONFLICT(run_id) DO NOTHING",
529            params![run_id, chat_id, caller_channel, payload_text, now, now],
530        )?;
531        Ok(())
532    }
533
534    pub fn list_due_subagent_announces(
535        &self,
536        now_iso: &str,
537        limit: usize,
538    ) -> Result<Vec<SubagentAnnounceRecord>, MicroClawError> {
539        let conn = self.lock_conn();
540        let mut stmt = conn.prepare(
541            "SELECT id, run_id, chat_id, caller_channel, payload_text, status, attempts, next_attempt_at, last_error
542             FROM subagent_announces
543             WHERE status IN ('pending', 'retry')
544               AND (next_attempt_at IS NULL OR unixepoch(next_attempt_at) <= unixepoch(?1))
545             ORDER BY id ASC
546             LIMIT ?2",
547        )?;
548        let rows = stmt.query_map(params![now_iso, limit.max(1) as i64], |row| {
549            Ok(SubagentAnnounceRecord {
550                id: row.get(0)?,
551                run_id: row.get(1)?,
552                chat_id: row.get(2)?,
553                caller_channel: row.get(3)?,
554                payload_text: row.get(4)?,
555                status: row.get(5)?,
556                attempts: row.get(6)?,
557                next_attempt_at: row.get(7)?,
558                last_error: row.get(8)?,
559            })
560        })?;
561        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
562    }
563
564    pub fn mark_subagent_announce_sent(&self, id: i64) -> Result<(), MicroClawError> {
565        let conn = self.lock_conn();
566        let now = chrono::Utc::now().to_rfc3339();
567        conn.execute(
568            "UPDATE subagent_announces
569             SET status='sent', updated_at=?2
570             WHERE id=?1",
571            params![id, now],
572        )?;
573        Ok(())
574    }
575
576    pub fn mark_subagent_announce_retry(
577        &self,
578        id: i64,
579        attempts: i64,
580        next_attempt_at: Option<&str>,
581        last_error: &str,
582        terminal_fail: bool,
583    ) -> Result<(), MicroClawError> {
584        let conn = self.lock_conn();
585        let now = chrono::Utc::now().to_rfc3339();
586        let status = if terminal_fail { "failed" } else { "retry" };
587        conn.execute(
588            "UPDATE subagent_announces
589             SET status=?2, attempts=?3, next_attempt_at=?4, last_error=?5, updated_at=?6
590             WHERE id=?1",
591            params![id, status, attempts, next_attempt_at, last_error, now],
592        )?;
593        Ok(())
594    }
595
596    pub fn append_subagent_event(
597        &self,
598        run_id: &str,
599        event_type: &str,
600        detail: Option<&str>,
601    ) -> Result<(), MicroClawError> {
602        let conn = self.lock_conn();
603        let now = chrono::Utc::now().to_rfc3339();
604        conn.execute(
605            "INSERT INTO subagent_events(run_id, event_type, detail, created_at)
606             VALUES(?1, ?2, ?3, ?4)",
607            params![run_id, event_type, detail, now],
608        )?;
609        Ok(())
610    }
611
612    pub fn list_subagent_events(
613        &self,
614        run_id: &str,
615        limit: usize,
616    ) -> Result<Vec<SubagentEventRecord>, MicroClawError> {
617        let conn = self.lock_conn();
618        let mut stmt = conn.prepare(
619            "SELECT id, run_id, event_type, detail, created_at
620             FROM subagent_events
621             WHERE run_id = ?1
622             ORDER BY created_at DESC
623             LIMIT ?2",
624        )?;
625        let rows = stmt.query_map(params![run_id, limit.max(1) as i64], |row| {
626            Ok(SubagentEventRecord {
627                id: row.get(0)?,
628                run_id: row.get(1)?,
629                event_type: row.get(2)?,
630                detail: row.get(3)?,
631                created_at: row.get(4)?,
632            })
633        })?;
634        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
635    }
636
637    pub fn set_subagent_focus(&self, chat_id: i64, run_id: &str) -> Result<(), MicroClawError> {
638        let conn = self.lock_conn();
639        let now = chrono::Utc::now().to_rfc3339();
640        conn.execute(
641            "INSERT INTO subagent_focus_bindings(chat_id, run_id, updated_at)
642             VALUES(?1, ?2, ?3)
643             ON CONFLICT(chat_id) DO UPDATE SET
644                run_id = excluded.run_id,
645                updated_at = excluded.updated_at",
646            params![chat_id, run_id, now],
647        )?;
648        Ok(())
649    }
650
651    pub fn clear_subagent_focus(&self, chat_id: i64) -> Result<(), MicroClawError> {
652        let conn = self.lock_conn();
653        conn.execute(
654            "DELETE FROM subagent_focus_bindings WHERE chat_id = ?1",
655            params![chat_id],
656        )?;
657        Ok(())
658    }
659
660    pub fn get_subagent_focus(&self, chat_id: i64) -> Result<Option<String>, MicroClawError> {
661        let conn = self.lock_conn();
662        conn.query_row(
663            "SELECT run_id FROM subagent_focus_bindings WHERE chat_id = ?1",
664            params![chat_id],
665            |row| row.get(0),
666        )
667        .optional()
668        .map_err(Into::into)
669    }
670
671    pub fn get_subagent_observability_snapshot(
672        &self,
673        chat_id: Option<i64>,
674        recent_limit: usize,
675    ) -> Result<SubagentObservabilitySnapshot, MicroClawError> {
676        let conn = self.lock_conn();
677        let since = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339();
678        let active_filter = if chat_id.is_some() {
679            " AND chat_id = ?1"
680        } else {
681            ""
682        };
683
684        let active_runs: i64 = if let Some(cid) = chat_id {
685            conn.query_row(
686                "SELECT COUNT(*) FROM subagent_runs
687                 WHERE status IN ('accepted','queued','running') AND chat_id = ?1",
688                params![cid],
689                |row| row.get(0),
690            )?
691        } else {
692            conn.query_row(
693                "SELECT COUNT(*) FROM subagent_runs
694                 WHERE status IN ('accepted','queued','running')",
695                [],
696                |row| row.get(0),
697            )?
698        };
699        let queued_runs: i64 = if let Some(cid) = chat_id {
700            conn.query_row(
701                "SELECT COUNT(*) FROM subagent_runs WHERE status = 'queued' AND chat_id = ?1",
702                params![cid],
703                |row| row.get(0),
704            )?
705        } else {
706            conn.query_row(
707                "SELECT COUNT(*) FROM subagent_runs WHERE status = 'queued'",
708                [],
709                |row| row.get(0),
710            )?
711        };
712        let running_runs: i64 = if let Some(cid) = chat_id {
713            conn.query_row(
714                "SELECT COUNT(*) FROM subagent_runs WHERE status = 'running' AND chat_id = ?1",
715                params![cid],
716                |row| row.get(0),
717            )?
718        } else {
719            conn.query_row(
720                "SELECT COUNT(*) FROM subagent_runs WHERE status = 'running'",
721                [],
722                |row| row.get(0),
723            )?
724        };
725
726        let pending_announces: i64 = if let Some(cid) = chat_id {
727            conn.query_row(
728                "SELECT COUNT(*) FROM subagent_announces WHERE status = 'pending' AND chat_id = ?1",
729                params![cid],
730                |row| row.get(0),
731            )?
732        } else {
733            conn.query_row(
734                "SELECT COUNT(*) FROM subagent_announces WHERE status = 'pending'",
735                [],
736                |row| row.get(0),
737            )?
738        };
739        let retry_announces: i64 = if let Some(cid) = chat_id {
740            conn.query_row(
741                "SELECT COUNT(*) FROM subagent_announces WHERE status = 'retry' AND chat_id = ?1",
742                params![cid],
743                |row| row.get(0),
744            )?
745        } else {
746            conn.query_row(
747                "SELECT COUNT(*) FROM subagent_announces WHERE status = 'retry'",
748                [],
749                |row| row.get(0),
750            )?
751        };
752        let failed_announces: i64 = if let Some(cid) = chat_id {
753            conn.query_row(
754                "SELECT COUNT(*) FROM subagent_announces WHERE status = 'failed' AND chat_id = ?1",
755                params![cid],
756                |row| row.get(0),
757            )?
758        } else {
759            conn.query_row(
760                "SELECT COUNT(*) FROM subagent_announces WHERE status = 'failed'",
761                [],
762                |row| row.get(0),
763            )?
764        };
765
766        let completed_24h: i64 = if let Some(cid) = chat_id {
767            conn.query_row(
768                "SELECT COUNT(*) FROM subagent_runs
769                 WHERE status = 'completed' AND finished_at IS NOT NULL
770                   AND unixepoch(finished_at) >= unixepoch(?1)
771                   AND chat_id = ?2",
772                params![since, cid],
773                |row| row.get(0),
774            )?
775        } else {
776            conn.query_row(
777                "SELECT COUNT(*) FROM subagent_runs
778                 WHERE status = 'completed' AND finished_at IS NOT NULL
779                   AND unixepoch(finished_at) >= unixepoch(?1)",
780                params![since],
781                |row| row.get(0),
782            )?
783        };
784        let failed_24h: i64 = if let Some(cid) = chat_id {
785            conn.query_row(
786                "SELECT COUNT(*) FROM subagent_runs
787                 WHERE status IN ('failed','timed_out','cancelled') AND finished_at IS NOT NULL
788                   AND unixepoch(finished_at) >= unixepoch(?1)
789                   AND chat_id = ?2",
790                params![since, cid],
791                |row| row.get(0),
792            )?
793        } else {
794            conn.query_row(
795                "SELECT COUNT(*) FROM subagent_runs
796                 WHERE status IN ('failed','timed_out','cancelled') AND finished_at IS NOT NULL
797                   AND unixepoch(finished_at) >= unixepoch(?1)",
798                params![since],
799                |row| row.get(0),
800            )?
801        };
802        let budget_exceeded_24h: i64 = if let Some(cid) = chat_id {
803            conn.query_row(
804                "SELECT COUNT(*) FROM subagent_runs
805                 WHERE status = 'budget_exceeded' AND finished_at IS NOT NULL
806                   AND unixepoch(finished_at) >= unixepoch(?1)
807                   AND chat_id = ?2",
808                params![since, cid],
809                |row| row.get(0),
810            )?
811        } else {
812            conn.query_row(
813                "SELECT COUNT(*) FROM subagent_runs
814                 WHERE status = 'budget_exceeded' AND finished_at IS NOT NULL
815                   AND unixepoch(finished_at) >= unixepoch(?1)",
816                params![since],
817                |row| row.get(0),
818            )?
819        };
820
821        let avg_duration_ms_24h: i64 = if let Some(cid) = chat_id {
822            conn.query_row(
823                "SELECT COALESCE(AVG((julianday(finished_at) - julianday(started_at)) * 86400000.0), 0)
824                 FROM subagent_runs
825                 WHERE started_at IS NOT NULL AND finished_at IS NOT NULL
826                   AND unixepoch(finished_at) >= unixepoch(?1)
827                   AND chat_id = ?2",
828                params![since, cid],
829                |row| row.get::<_, f64>(0).map(|v| v as i64),
830            )?
831        } else {
832            conn.query_row(
833                "SELECT COALESCE(AVG((julianday(finished_at) - julianday(started_at)) * 86400000.0), 0)
834                 FROM subagent_runs
835                 WHERE started_at IS NOT NULL AND finished_at IS NOT NULL
836                   AND unixepoch(finished_at) >= unixepoch(?1)",
837                params![since],
838                |row| row.get::<_, f64>(0).map(|v| v as i64),
839            )?
840        };
841
842        let mut stmt = conn.prepare(&format!(
843            "SELECT run_id, parent_run_id, depth, token_budget, chat_id, caller_channel, task, context, status, created_at,
844                    started_at, finished_at, cancel_requested, error_text, result_text,
845                    input_tokens, output_tokens, total_tokens, provider, model, artifact_json,
846                    label, progress_text, last_progress_at
847             FROM subagent_runs
848             WHERE 1=1 {active_filter}
849             ORDER BY created_at DESC
850             LIMIT ?{}",
851            if chat_id.is_some() { 2 } else { 1 }
852        ))?;
853        let recent_runs = if let Some(cid) = chat_id {
854            let rows = stmt.query_map(params![cid, recent_limit.max(1) as i64], |row| {
855                Ok(SubagentRunRecord {
856                    run_id: row.get(0)?,
857                    parent_run_id: row.get(1)?,
858                    depth: row.get(2)?,
859                    token_budget: row.get(3)?,
860                    chat_id: row.get(4)?,
861                    caller_channel: row.get(5)?,
862                    task: row.get(6)?,
863                    context: row.get(7)?,
864                    status: row.get(8)?,
865                    created_at: row.get(9)?,
866                    started_at: row.get(10)?,
867                    finished_at: row.get(11)?,
868                    cancel_requested: row.get::<_, i64>(12)? != 0,
869                    error_text: row.get(13)?,
870                    result_text: row.get(14)?,
871                    input_tokens: row.get(15)?,
872                    output_tokens: row.get(16)?,
873                    total_tokens: row.get(17)?,
874                    provider: row.get(18)?,
875                    model: row.get(19)?,
876                    artifact_json: row.get(20)?,
877                    label: row.get(21)?,
878                    progress_text: row.get(22)?,
879                    last_progress_at: row.get(23)?,
880                })
881            })?;
882            rows.collect::<Result<Vec<_>, _>>()?
883        } else {
884            let rows = stmt.query_map(params![recent_limit.max(1) as i64], |row| {
885                Ok(SubagentRunRecord {
886                    run_id: row.get(0)?,
887                    parent_run_id: row.get(1)?,
888                    depth: row.get(2)?,
889                    token_budget: row.get(3)?,
890                    chat_id: row.get(4)?,
891                    caller_channel: row.get(5)?,
892                    task: row.get(6)?,
893                    context: row.get(7)?,
894                    status: row.get(8)?,
895                    created_at: row.get(9)?,
896                    started_at: row.get(10)?,
897                    finished_at: row.get(11)?,
898                    cancel_requested: row.get::<_, i64>(12)? != 0,
899                    error_text: row.get(13)?,
900                    result_text: row.get(14)?,
901                    input_tokens: row.get(15)?,
902                    output_tokens: row.get(16)?,
903                    total_tokens: row.get(17)?,
904                    provider: row.get(18)?,
905                    model: row.get(19)?,
906                    artifact_json: row.get(20)?,
907                    label: row.get(21)?,
908                    progress_text: row.get(22)?,
909                    last_progress_at: row.get(23)?,
910                })
911            })?;
912            rows.collect::<Result<Vec<_>, _>>()?
913        };
914
915        Ok(SubagentObservabilitySnapshot {
916            active_runs,
917            queued_runs,
918            running_runs,
919            pending_announces,
920            retry_announces,
921            failed_announces,
922            completed_24h,
923            failed_24h,
924            budget_exceeded_24h,
925            avg_duration_ms_24h,
926            recent_runs,
927        })
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::internal::storage::db::test_support::*;
935
936    #[test]
937    fn test_contract_verdict_counts_since_aggregates_verdicts() {
938        let (db, dir) = test_db();
939        db.append_subagent_event("run-1", "contract", Some("verified 2/2"))
940            .unwrap();
941        db.append_subagent_event("run-2", "contract", Some("failed 1/3"))
942            .unwrap();
943        db.append_subagent_event("run-2", "contract", Some("verified 3/3"))
944            .unwrap();
945        // Non-contract events must not count.
946        db.append_subagent_event("run-3", "submit_result", None)
947            .unwrap();
948
949        let (verified, failed) = db
950            .contract_verdict_counts_since("2000-01-01T00:00:00Z")
951            .unwrap();
952        assert_eq!(verified, 2);
953        assert_eq!(failed, 1);
954
955        let (verified, failed) = db
956            .contract_verdict_counts_since("2999-01-01T00:00:00Z")
957            .unwrap();
958        assert_eq!(verified, 0);
959        assert_eq!(failed, 0);
960        cleanup(&dir);
961    }
962
963    #[test]
964    fn test_recover_orphaned_subagent_runs() {
965        let (db, dir) = test_db();
966        for (run_id, status) in [
967            ("orph-accepted", None),
968            ("orph-running", Some("running")),
969            ("done-ok", Some("done")),
970        ] {
971            db.create_subagent_run(CreateSubagentRunParams {
972                run_id,
973                parent_run_id: None,
974                depth: 1,
975                token_budget: 0,
976                chat_id: 7,
977                caller_channel: "telegram",
978                task: "t",
979                context: "",
980                provider: "anthropic",
981                model: "claude-test",
982                label: None,
983            })
984            .unwrap();
985            match status {
986                Some("running") => db.mark_subagent_running(run_id).unwrap(),
987                Some(s) => db
988                    .mark_subagent_finished(FinishSubagentRunParams {
989                        run_id,
990                        status: s,
991                        error_text: None,
992                        result_text: Some("ok"),
993                        artifact_json: None,
994                        input_tokens: 0,
995                        output_tokens: 0,
996                    })
997                    .unwrap(),
998                None => {}
999            }
1000        }
1001
1002        let fixed = db.recover_orphaned_subagent_runs().unwrap();
1003        assert_eq!(fixed, 2, "accepted + running rows must be retired");
1004
1005        for run_id in ["orph-accepted", "orph-running"] {
1006            let run = db.get_subagent_run(run_id, 7).unwrap().unwrap();
1007            assert_eq!(run.status, "interrupted");
1008            assert!(run.finished_at.is_some());
1009            assert!(run.error_text.as_deref().unwrap().contains("restarted"));
1010        }
1011        // Finished runs are untouched, and nothing counts as active anymore.
1012        let done = db.get_subagent_run("done-ok", 7).unwrap().unwrap();
1013        assert_eq!(done.status, "done");
1014        assert!(done.error_text.is_none());
1015        assert!(db.list_active_subagent_runs().unwrap().is_empty());
1016        // Idempotent: a second recovery pass finds nothing.
1017        assert_eq!(db.recover_orphaned_subagent_runs().unwrap(), 0);
1018        cleanup(&dir);
1019    }
1020
1021    #[test]
1022    fn test_subagent_run_label_and_progress() {
1023        let (db, dir) = test_db();
1024        db.create_subagent_run(CreateSubagentRunParams {
1025            run_id: "subrun-1",
1026            parent_run_id: None,
1027            depth: 1,
1028            token_budget: 0,
1029            chat_id: 42,
1030            caller_channel: "telegram",
1031            task: "research competitor pricing",
1032            context: "",
1033            provider: "anthropic",
1034            model: "claude-test",
1035            label: Some("competitor research"),
1036        })
1037        .unwrap();
1038
1039        // Label round-trips through both get and list.
1040        let run = db.get_subagent_run("subrun-1", 42).unwrap().unwrap();
1041        assert_eq!(run.label.as_deref(), Some("competitor research"));
1042        assert!(run.progress_text.is_none());
1043        assert!(run.last_progress_at.is_none());
1044        let listed = db.list_subagent_runs(42, 10).unwrap();
1045        assert_eq!(listed.len(), 1);
1046        assert_eq!(listed[0].label.as_deref(), Some("competitor research"));
1047
1048        // Active-runs query (used by the standup loop) sees the fresh run.
1049        let active = db.list_active_subagent_runs().unwrap();
1050        assert_eq!(active.len(), 1);
1051        assert_eq!(active[0].run_id, "subrun-1");
1052        assert_eq!(active[0].chat_id, 42);
1053
1054        // Resolve by exact run_id, by label, and a miss.
1055        assert_eq!(
1056            db.resolve_subagent_run_id(42, "subrun-1")
1057                .unwrap()
1058                .as_deref(),
1059            Some("subrun-1")
1060        );
1061        assert_eq!(
1062            db.resolve_subagent_run_id(42, "competitor research")
1063                .unwrap()
1064                .as_deref(),
1065            Some("subrun-1")
1066        );
1067        assert!(db.resolve_subagent_run_id(42, "nope").unwrap().is_none());
1068        // Wrong chat → no match.
1069        assert!(db
1070            .resolve_subagent_run_id(99, "competitor research")
1071            .unwrap()
1072            .is_none());
1073
1074        // Children listing for fan-in: a child of subrun-1.
1075        db.create_subagent_run(CreateSubagentRunParams {
1076            run_id: "subrun-1a",
1077            parent_run_id: Some("subrun-1"),
1078            depth: 2,
1079            token_budget: 0,
1080            chat_id: 42,
1081            caller_channel: "telegram",
1082            task: "child task",
1083            context: "",
1084            provider: "anthropic",
1085            model: "claude-test",
1086            label: Some("child"),
1087        })
1088        .unwrap();
1089        let kids = db.list_subagent_children("subrun-1").unwrap();
1090        assert_eq!(kids.len(), 1);
1091        assert_eq!(kids[0].run_id, "subrun-1a");
1092        assert!(db.list_subagent_children("subrun-1a").unwrap().is_empty());
1093
1094        // First progress: no previous timestamp.
1095        let prev = db
1096            .record_subagent_progress("subrun-1", "checked 3/5 sources")
1097            .unwrap();
1098        assert!(prev.is_none());
1099        let run = db.get_subagent_run("subrun-1", 42).unwrap().unwrap();
1100        assert_eq!(run.progress_text.as_deref(), Some("checked 3/5 sources"));
1101        assert!(run.last_progress_at.is_some());
1102
1103        // Second progress: returns the prior timestamp (used for throttling) and
1104        // appends a second event to the run timeline.
1105        let prev2 = db
1106            .record_subagent_progress("subrun-1", "checked 5/5 sources")
1107            .unwrap();
1108        assert!(prev2.is_some());
1109        let events = db.list_subagent_events("subrun-1", 50).unwrap();
1110        let progress_events = events.iter().filter(|e| e.event_type == "progress").count();
1111        assert_eq!(progress_events, 2);
1112
1113        cleanup(&dir);
1114    }
1115}