1use crate::agent::AgentEvent;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, VecDeque};
11use tokio::sync::RwLock;
12use tokio_util::sync::CancellationToken;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum SubagentStatus {
18 Running,
19 Completed,
20 Failed,
21 Cancelled,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct SubagentProgressEntry {
26 pub timestamp_ms: u64,
27 pub status: String,
28 pub metadata: serde_json::Value,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SubagentTaskSnapshot {
33 pub task_id: String,
34 pub parent_session_id: String,
35 pub child_session_id: String,
36 pub agent: String,
37 pub description: String,
38 pub status: SubagentStatus,
39 pub started_ms: u64,
40 pub updated_ms: u64,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub finished_ms: Option<u64>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub output: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub success: Option<bool>,
47 pub progress: Vec<SubagentProgressEntry>,
48}
49
50#[derive(Debug, Default)]
51pub struct InMemorySubagentTaskTracker {
52 tasks: RwLock<HashMap<String, SubagentTaskSnapshot>>,
53 cancellers: RwLock<HashMap<String, CancellationToken>>,
54 terminal_order: RwLock<VecDeque<String>>,
59 max_terminal_tasks: Option<usize>,
62}
63
64impl InMemorySubagentTaskTracker {
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn with_max_terminal_tasks(max: usize) -> Self {
72 Self {
73 tasks: RwLock::new(HashMap::new()),
74 cancellers: RwLock::new(HashMap::new()),
75 terminal_order: RwLock::new(VecDeque::new()),
76 max_terminal_tasks: Some(max),
77 }
78 }
79
80 async fn mark_terminal_and_evict(&self, task_id: &str) {
85 let cap = match self.max_terminal_tasks {
86 Some(n) => n,
87 None => return,
88 };
89 let mut order = self.terminal_order.write().await;
97 let mut tasks = self.tasks.write().await;
98 let mut cancellers = self.cancellers.write().await;
99 if !order.iter().any(|id| id == task_id) {
100 order.push_back(task_id.to_string());
101 }
102 while order.len() > cap {
103 if let Some(victim) = order.pop_front() {
104 tasks.remove(&victim);
105 cancellers.remove(&victim);
106 }
107 }
108 }
109
110 pub async fn register_canceller(&self, task_id: &str, token: CancellationToken) {
114 self.cancellers
115 .write()
116 .await
117 .insert(task_id.to_string(), token);
118 }
119
120 pub async fn clear_canceller(&self, task_id: &str) {
121 self.cancellers.write().await.remove(task_id);
122 }
123
124 pub async fn cancel(&self, task_id: &str) -> bool {
130 let token = self.cancellers.write().await.remove(task_id);
131 match token {
132 Some(token) => {
133 token.cancel();
134 let now = now_ms();
135 let transitioned = {
136 let mut tasks = self.tasks.write().await;
137 if let Some(entry) = tasks.get_mut(task_id) {
138 if entry.status == SubagentStatus::Running {
139 entry.status = SubagentStatus::Cancelled;
140 entry.updated_ms = now;
141 true
142 } else {
143 false
144 }
145 } else {
146 false
147 }
148 };
149 if transitioned {
150 self.mark_terminal_and_evict(task_id).await;
151 }
152 true
153 }
154 None => false,
155 }
156 }
157
158 pub async fn record_event(&self, event: &AgentEvent) {
160 match event {
161 AgentEvent::SubagentStart {
162 task_id,
163 session_id,
164 parent_session_id,
165 agent,
166 description,
167 started_ms,
168 } => {
169 let now = now_ms();
170 let started = normalize_event_ms(*started_ms, now);
171 let mut tasks = self.tasks.write().await;
172 tasks
173 .entry(task_id.clone())
174 .and_modify(|task| {
175 task.parent_session_id = parent_session_id.clone();
178 task.child_session_id = session_id.clone();
179 task.agent = agent.clone();
180 task.description = description.clone();
181 task.updated_ms = now.max(started);
182 })
183 .or_insert_with(|| SubagentTaskSnapshot {
184 task_id: task_id.clone(),
185 parent_session_id: parent_session_id.clone(),
186 child_session_id: session_id.clone(),
187 agent: agent.clone(),
188 description: description.clone(),
189 status: SubagentStatus::Running,
190 started_ms: started,
191 updated_ms: now.max(started),
192 finished_ms: None,
193 output: None,
194 success: None,
195 progress: Vec::new(),
196 });
197 }
198 AgentEvent::SubagentProgress {
199 task_id,
200 session_id,
201 status,
202 metadata,
203 } => {
204 let now = now_ms();
205 let mut tasks = self.tasks.write().await;
206 let entry = tasks
207 .entry(task_id.clone())
208 .or_insert_with(|| SubagentTaskSnapshot {
209 task_id: task_id.clone(),
210 parent_session_id: String::new(),
211 child_session_id: session_id.clone(),
212 agent: String::new(),
213 description: String::new(),
214 status: SubagentStatus::Running,
215 started_ms: now,
216 updated_ms: now,
217 finished_ms: None,
218 output: None,
219 success: None,
220 progress: Vec::new(),
221 });
222 entry.updated_ms = now;
223 entry.progress.push(SubagentProgressEntry {
224 timestamp_ms: now,
225 status: status.clone(),
226 metadata: metadata.clone(),
227 });
228 }
229 AgentEvent::SubagentEnd {
230 task_id,
231 session_id,
232 agent,
233 output,
234 success,
235 finished_ms,
236 } => {
237 let now = now_ms();
238 let finished = normalize_event_ms(*finished_ms, now);
239 let was_running = {
240 let mut tasks = self.tasks.write().await;
241 let entry =
242 tasks
243 .entry(task_id.clone())
244 .or_insert_with(|| SubagentTaskSnapshot {
245 task_id: task_id.clone(),
246 parent_session_id: String::new(),
247 child_session_id: session_id.clone(),
248 agent: agent.clone(),
249 description: String::new(),
250 status: SubagentStatus::Running,
251 started_ms: now,
252 updated_ms: now,
253 finished_ms: None,
254 output: None,
255 success: None,
256 progress: Vec::new(),
257 });
258 let was_running = entry.status == SubagentStatus::Running;
259 if entry.status != SubagentStatus::Cancelled {
263 entry.status = if *success {
264 SubagentStatus::Completed
265 } else {
266 SubagentStatus::Failed
267 };
268 }
269 entry.updated_ms = finished;
270 entry.finished_ms = Some(finished);
271 entry.output = Some(output.clone());
272 entry.success = Some(*success);
273 was_running
274 };
275 if was_running {
276 self.mark_terminal_and_evict(task_id).await;
277 }
278 }
279 _ => {}
280 }
281 }
282
283 pub async fn get(&self, task_id: &str) -> Option<SubagentTaskSnapshot> {
284 self.tasks.read().await.get(task_id).cloned()
285 }
286
287 pub async fn list(&self) -> Vec<SubagentTaskSnapshot> {
288 let mut tasks = self
289 .tasks
290 .read()
291 .await
292 .values()
293 .cloned()
294 .collect::<Vec<_>>();
295 tasks.sort_by_key(|task| task.started_ms);
296 tasks
297 }
298
299 pub async fn list_pending(&self) -> Vec<SubagentTaskSnapshot> {
300 self.list()
301 .await
302 .into_iter()
303 .filter(|task| task.status == SubagentStatus::Running)
304 .collect()
305 }
306
307 pub async fn list_for_parent(&self, parent_session_id: &str) -> Vec<SubagentTaskSnapshot> {
308 self.list()
309 .await
310 .into_iter()
311 .filter(|task| task.parent_session_id == parent_session_id)
312 .collect()
313 }
314
315 pub async fn replace_snapshots(&self, snapshots: Vec<SubagentTaskSnapshot>) {
327 let mut map = HashMap::with_capacity(snapshots.len());
328 for snap in snapshots {
329 map.insert(snap.task_id.clone(), snap);
330 }
331 *self.tasks.write().await = map;
332 self.cancellers.write().await.clear();
334 }
335}
336
337fn now_ms() -> u64 {
338 use std::time::{SystemTime, UNIX_EPOCH};
339 SystemTime::now()
340 .duration_since(UNIX_EPOCH)
341 .map(|d| d.as_millis() as u64)
342 .unwrap_or(0)
343}
344
345fn normalize_event_ms(event_ms: u64, fallback_ms: u64) -> u64 {
346 if event_ms == 0 {
347 fallback_ms
348 } else {
349 event_ms
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 fn start_event(task_id: &str, parent: &str, child: &str) -> AgentEvent {
358 AgentEvent::SubagentStart {
359 task_id: task_id.to_string(),
360 session_id: child.to_string(),
361 parent_session_id: parent.to_string(),
362 agent: "explore".to_string(),
363 description: "find things".to_string(),
364 started_ms: 0,
365 }
366 }
367
368 fn progress_event(task_id: &str, child: &str, status: &str) -> AgentEvent {
369 AgentEvent::SubagentProgress {
370 task_id: task_id.to_string(),
371 session_id: child.to_string(),
372 status: status.to_string(),
373 metadata: serde_json::json!({}),
374 }
375 }
376
377 fn end_event(task_id: &str, child: &str, success: bool) -> AgentEvent {
378 AgentEvent::SubagentEnd {
379 task_id: task_id.to_string(),
380 session_id: child.to_string(),
381 agent: "explore".to_string(),
382 output: "done".to_string(),
383 success,
384 finished_ms: 0,
385 }
386 }
387
388 #[tokio::test]
389 async fn lifecycle_start_progress_end_transitions_status() {
390 let tracker = InMemorySubagentTaskTracker::new();
391
392 tracker
393 .record_event(&start_event("task-1", "parent", "child"))
394 .await;
395 let snap = tracker.get("task-1").await.unwrap();
396 assert_eq!(snap.status, SubagentStatus::Running);
397 assert_eq!(snap.parent_session_id, "parent");
398 assert_eq!(snap.child_session_id, "child");
399 assert!(snap.finished_ms.is_none());
400
401 tracker
402 .record_event(&progress_event("task-1", "child", "tool_completed: bash"))
403 .await;
404 let snap = tracker.get("task-1").await.unwrap();
405 assert_eq!(snap.status, SubagentStatus::Running);
406 assert_eq!(snap.progress.len(), 1);
407
408 tracker
409 .record_event(&end_event("task-1", "child", true))
410 .await;
411 let snap = tracker.get("task-1").await.unwrap();
412 assert_eq!(snap.status, SubagentStatus::Completed);
413 assert_eq!(snap.success, Some(true));
414 assert_eq!(snap.output.as_deref(), Some("done"));
415 assert!(snap.finished_ms.is_some());
416 }
417
418 #[tokio::test]
419 async fn lifecycle_uses_event_timestamps_for_duration() {
420 let tracker = InMemorySubagentTaskTracker::new();
421
422 tracker
423 .record_event(&AgentEvent::SubagentStart {
424 task_id: "task-timed".to_string(),
425 session_id: "child-timed".to_string(),
426 parent_session_id: "parent".to_string(),
427 agent: "general".to_string(),
428 description: "timed".to_string(),
429 started_ms: 1_000,
430 })
431 .await;
432 tracker
433 .record_event(&AgentEvent::SubagentEnd {
434 task_id: "task-timed".to_string(),
435 session_id: "child-timed".to_string(),
436 agent: "general".to_string(),
437 output: "done".to_string(),
438 success: true,
439 finished_ms: 2_750,
440 })
441 .await;
442
443 let snap = tracker.get("task-timed").await.unwrap();
444 assert_eq!(snap.started_ms, 1_000);
445 assert_eq!(snap.finished_ms, Some(2_750));
446 }
447
448 #[tokio::test]
449 async fn failed_end_event_marks_status_failed() {
450 let tracker = InMemorySubagentTaskTracker::new();
451 tracker
452 .record_event(&start_event("task-2", "parent", "child"))
453 .await;
454 tracker
455 .record_event(&end_event("task-2", "child", false))
456 .await;
457 let snap = tracker.get("task-2").await.unwrap();
458 assert_eq!(snap.status, SubagentStatus::Failed);
459 assert_eq!(snap.success, Some(false));
460 }
461
462 #[tokio::test]
463 async fn pending_list_excludes_completed_tasks() {
464 let tracker = InMemorySubagentTaskTracker::new();
465 tracker
466 .record_event(&start_event("task-a", "parent", "child-a"))
467 .await;
468 tracker
469 .record_event(&start_event("task-b", "parent", "child-b"))
470 .await;
471 tracker
472 .record_event(&end_event("task-a", "child-a", true))
473 .await;
474
475 let pending = tracker.list_pending().await;
476 assert_eq!(pending.len(), 1);
477 assert_eq!(pending[0].task_id, "task-b");
478 }
479
480 #[tokio::test]
481 async fn list_for_parent_filters_by_session() {
482 let tracker = InMemorySubagentTaskTracker::new();
483 tracker
484 .record_event(&start_event("task-a", "session-1", "child-a"))
485 .await;
486 tracker
487 .record_event(&start_event("task-b", "session-2", "child-b"))
488 .await;
489
490 let mine = tracker.list_for_parent("session-1").await;
491 assert_eq!(mine.len(), 1);
492 assert_eq!(mine[0].task_id, "task-a");
493 }
494
495 #[tokio::test]
496 async fn end_before_start_still_records_terminal_state() {
497 let tracker = InMemorySubagentTaskTracker::new();
498 tracker
499 .record_event(&end_event("task-late", "child", true))
500 .await;
501 let snap = tracker.get("task-late").await.unwrap();
502 assert_eq!(snap.status, SubagentStatus::Completed);
503 }
504
505 #[tokio::test]
506 async fn non_subagent_events_are_ignored() {
507 let tracker = InMemorySubagentTaskTracker::new();
508 tracker
509 .record_event(&AgentEvent::TextDelta {
510 text: "ignore me".to_string(),
511 })
512 .await;
513 assert!(tracker.list().await.is_empty());
514 }
515
516 #[tokio::test]
517 async fn cancel_fires_token_and_marks_snapshot_cancelled() {
518 let tracker = InMemorySubagentTaskTracker::new();
519 tracker
520 .record_event(&start_event("task-c", "parent", "child"))
521 .await;
522
523 let token = CancellationToken::new();
524 tracker.register_canceller("task-c", token.clone()).await;
525 assert!(!token.is_cancelled());
526
527 let fired = tracker.cancel("task-c").await;
528 assert!(fired, "cancel should report success");
529 assert!(token.is_cancelled(), "registered token should be triggered");
530
531 let snap = tracker.get("task-c").await.unwrap();
532 assert_eq!(snap.status, SubagentStatus::Cancelled);
533 }
534
535 #[tokio::test]
536 async fn cancel_returns_false_for_unknown_task() {
537 let tracker = InMemorySubagentTaskTracker::new();
538 assert!(!tracker.cancel("task-does-not-exist").await);
539 }
540
541 #[tokio::test]
542 async fn late_subagent_end_does_not_downgrade_cancelled_status() {
543 let tracker = InMemorySubagentTaskTracker::new();
544 tracker
545 .record_event(&start_event("task-d", "parent", "child"))
546 .await;
547 let token = CancellationToken::new();
548 tracker.register_canceller("task-d", token).await;
549 assert!(tracker.cancel("task-d").await);
550
551 tracker
554 .record_event(&end_event("task-d", "child", false))
555 .await;
556 let snap = tracker.get("task-d").await.unwrap();
557 assert_eq!(snap.status, SubagentStatus::Cancelled);
558 assert!(snap.finished_ms.is_some());
559 assert_eq!(snap.success, Some(false));
560 }
561
562 #[tokio::test]
563 async fn clear_canceller_disarms_future_cancel_calls() {
564 let tracker = InMemorySubagentTaskTracker::new();
565 tracker
566 .record_event(&start_event("task-e", "parent", "child"))
567 .await;
568 let token = CancellationToken::new();
569 tracker.register_canceller("task-e", token.clone()).await;
570 tracker.clear_canceller("task-e").await;
571
572 assert!(!tracker.cancel("task-e").await);
573 assert!(!token.is_cancelled());
574 }
575
576 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
577 async fn concurrent_record_and_cancel_under_terminal_cap_does_not_deadlock() {
578 let tracker = std::sync::Arc::new(InMemorySubagentTaskTracker::with_max_terminal_tasks(8));
582 let mut handles = Vec::new();
583 for i in 0..60 {
584 let t = std::sync::Arc::clone(&tracker);
585 handles.push(tokio::spawn(async move {
586 let task_id = format!("t-{i}");
587 let child = format!("c-{i}");
588 t.record_event(&start_event(&task_id, "parent", &child))
589 .await;
590 if i % 2 == 0 {
591 t.register_canceller(&task_id, CancellationToken::new())
592 .await;
593 let _ = t.cancel(&task_id).await;
594 } else {
595 t.record_event(&end_event(&task_id, &child, true)).await;
596 }
597 }));
598 }
599 for h in handles {
600 h.await.unwrap();
601 }
602 let terminal = tracker
604 .list()
605 .await
606 .into_iter()
607 .filter(|t| t.status != SubagentStatus::Running)
608 .count();
609 assert!(
610 terminal <= 8,
611 "terminal cap must hold under load, got {terminal}"
612 );
613 }
614
615 #[tokio::test]
616 async fn max_terminal_tasks_evicts_oldest_completed_only() {
617 let tracker = InMemorySubagentTaskTracker::with_max_terminal_tasks(2);
618
619 for i in 0..3 {
621 let task_id = format!("done-{i}");
622 tracker
623 .record_event(&start_event(&task_id, "parent", "child"))
624 .await;
625 tracker
626 .record_event(&end_event(&task_id, "child", true))
627 .await;
628 }
629
630 let list = tracker.list().await;
632 let ids: Vec<&str> = list.iter().map(|t| t.task_id.as_str()).collect();
633 assert_eq!(ids.len(), 2);
634 assert!(ids.contains(&"done-1"));
635 assert!(ids.contains(&"done-2"));
636 assert!(
637 !ids.contains(&"done-0"),
638 "oldest terminal entry must be evicted"
639 );
640 }
641
642 #[tokio::test]
643 async fn max_terminal_tasks_never_evicts_running_tasks() {
644 let tracker = InMemorySubagentTaskTracker::with_max_terminal_tasks(1);
645
646 tracker
650 .record_event(&start_event("running", "parent", "child"))
651 .await;
652 for i in 0..3 {
653 let task_id = format!("done-{i}");
654 tracker
655 .record_event(&start_event(&task_id, "parent", "child"))
656 .await;
657 tracker
658 .record_event(&end_event(&task_id, "child", true))
659 .await;
660 }
661
662 let list = tracker.list().await;
663 let ids: Vec<&str> = list.iter().map(|t| t.task_id.as_str()).collect();
664 assert!(
665 ids.contains(&"running"),
666 "running task must never be evicted"
667 );
668 assert!(ids.contains(&"done-2"));
670 assert!(!ids.contains(&"done-0"));
671 assert!(!ids.contains(&"done-1"));
672 assert_eq!(list.len(), 2);
673 }
674
675 #[tokio::test]
676 async fn cancel_path_also_participates_in_terminal_cap() {
677 let tracker = InMemorySubagentTaskTracker::with_max_terminal_tasks(1);
678
679 for i in 0..2 {
681 let task_id = format!("c-{i}");
682 tracker
683 .record_event(&start_event(&task_id, "parent", "child"))
684 .await;
685 tracker
686 .register_canceller(&task_id, CancellationToken::new())
687 .await;
688 assert!(tracker.cancel(&task_id).await);
689 }
690
691 let list = tracker.list().await;
692 assert_eq!(list.len(), 1);
693 assert_eq!(list[0].task_id, "c-1");
694 }
695}