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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(db
1070 .resolve_subagent_run_id(99, "competitor research")
1071 .unwrap()
1072 .is_none());
1073
1074 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 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 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}