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