Skip to main content

a3s_code_core/
subagent_task_tracker.rs

1//! In-memory tracker for delegated subagent tasks.
2//!
3//! Materializes a queryable view of subagent task lifecycle from the
4//! `AgentEvent` stream. The event stream remains the authoritative record;
5//! this module exists so callers can ask "what is task X doing right now?"
6//! without scanning `run_events()`.
7
8use 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    /// FIFO queue of task_ids that have transitioned to a terminal
55    /// state (Completed / Failed / Cancelled). Used to evict the
56    /// oldest terminal entry when `max_terminal_tasks` is configured.
57    /// Running tasks are never in this queue.
58    terminal_order: RwLock<VecDeque<String>>,
59    /// FIFO cap on terminal-state snapshots. `None` keeps the
60    /// unbounded default.
61    max_terminal_tasks: Option<usize>,
62}
63
64impl InMemorySubagentTaskTracker {
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Construct a tracker with an optional FIFO cap on terminal-state
70    /// snapshots. Running tasks are never dropped.
71    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    /// Internal helper: mark a task_id as terminal in the FIFO queue
81    /// and evict oldest entries past the cap. Idempotent for tasks
82    /// that are already in the terminal queue (a SubagentEnd arriving
83    /// after a cancel won't double-push).
84    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        // Hold all three structures together for the push + eviction so a
90        // concurrent `record_event` (which takes only `tasks`) cannot
91        // re-insert a victim into `tasks` in the window between its removal
92        // from `tasks` and `cancellers`. Canonical order:
93        // terminal_order -> tasks -> cancellers. Callers (`cancel`,
94        // `record_event`) always drop their `tasks`/`cancellers` guards
95        // before invoking this, so holding all three here cannot deadlock.
96        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    /// Register a `CancellationToken` for a running task so callers can
111    /// trigger cancellation through `cancel(task_id)`. The task executor
112    /// is expected to remove the entry on exit via `clear_canceller`.
113    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    /// Fire the registered token and mark the snapshot as `Cancelled`.
125    /// Returns `true` if a token was found (caller can interpret as
126    /// "cancellation initiated"), `false` if the task id was unknown or
127    /// the task already finished. The eventual `SubagentEnd` event won't
128    /// overwrite the Cancelled status — see `record_event`.
129    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    /// Apply a single agent event to the tracker. Non-subagent events are ignored.
159    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                        // Late start (e.g. background path) — keep the first-seen
176                        // started_ms but refresh fields we now know.
177                        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                    // Preserve a pre-set Cancelled status (set by `cancel()`)
260                    // — a late SubagentEnd from the cancelled child loop is
261                    // expected and must not downgrade the terminal state.
262                    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    /// Replace the tracker's task snapshots with the given set. Cancellers
316    /// are **not** restored (they are runtime-only channels tied to live
317    /// child loops). After `replace_snapshots`, any task whose status was
318    /// `Running` at checkpoint time will appear `Running` in the tracker
319    /// but `cancel(task_id)` will return `false` because no canceller is
320    /// registered — callers should normally checkpoint at a quiescent
321    /// point so no tasks are `Running`.
322    ///
323    /// Used by [`SessionStore`](crate::store::SessionStore) rehydration to
324    /// restore the materialized subagent view of a previously-saved
325    /// session.
326    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        // Cancellers reference live tokens — invalidate the lot.
333        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        // The cancelled child loop will still emit a (likely failed)
552        // SubagentEnd. The terminal status should remain Cancelled.
553        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        // Guards the canonical lock-ordering change in mark_terminal_and_evict
579        // (terminal_order -> tasks -> cancellers held together). A bad ordering
580        // would ABBA-deadlock against concurrent cancel()/record_event and hang.
581        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        // Terminal cap honored; tracker still usable.
603        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        // Three fully terminal tasks; oldest must be evicted.
620        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        // Only the two most-recent terminal tasks survive.
631        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        // One running, two terminal — the cap applies only to terminal
647        // entries, so the running task survives even if it would be
648        // the "oldest".
649        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        // Only the most recent terminal task survives.
669        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        // Two cancellations — second one should evict the first.
680        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}