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 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    /// FIFO queue of task_ids that have transitioned to a terminal
58    /// state (Completed / Failed / Cancelled). Used to evict the
59    /// oldest terminal entry when `max_terminal_tasks` is configured.
60    /// Running tasks are never in this queue.
61    terminal_order: RwLock<VecDeque<String>>,
62    /// FIFO cap on terminal-state snapshots. `None` keeps the
63    /// unbounded default.
64    max_terminal_tasks: Option<usize>,
65}
66
67impl InMemorySubagentTaskTracker {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Construct a tracker with an optional FIFO cap on terminal-state
73    /// snapshots. Running tasks are never dropped.
74    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    /// Internal helper: mark a task_id as terminal in the FIFO queue
84    /// and evict oldest entries past the cap. Idempotent for tasks
85    /// that are already in the terminal queue (a SubagentEnd arriving
86    /// after a cancel won't double-push).
87    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        // Hold all three structures together for the push + eviction so a
93        // concurrent `record_event` (which takes only `tasks`) cannot
94        // re-insert a victim into `tasks` in the window between its removal
95        // from `tasks` and `cancellers`. Canonical order:
96        // terminal_order -> tasks -> cancellers. Callers (`cancel`,
97        // `record_event`) always drop their `tasks`/`cancellers` guards
98        // before invoking this, so holding all three here cannot deadlock.
99        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    /// Register a `CancellationToken` for a running task so callers can
114    /// trigger cancellation through `cancel(task_id)`. The task executor
115    /// is expected to remove the entry on exit via `clear_canceller`.
116    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    /// Fire the registered token and mark the snapshot as `Cancelled`.
128    /// Returns `true` if a token was found (caller can interpret as
129    /// "cancellation initiated"), `false` if the task id was unknown or
130    /// the task already finished. The eventual `SubagentEnd` event won't
131    /// overwrite the Cancelled status — see `record_event`.
132    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    /// Apply a single agent event to the tracker. Non-subagent events are ignored.
162    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                        // Late start (e.g. background path) — keep the first-seen
179                        // started_ms but refresh fields we now know.
180                        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                    // Preserve a pre-set Cancelled status (set by `cancel()`)
266                    // — a late SubagentEnd from the cancelled child loop is
267                    // expected and must not downgrade the terminal state.
268                    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    /// Replace the tracker's task snapshots with the given set. Cancellers
332    /// are **not** restored (they are runtime-only channels tied to live
333    /// child loops). After `replace_snapshots`, any task whose status was
334    /// `Running` at checkpoint time will appear `Running` in the tracker
335    /// but `cancel(task_id)` will return `false` because no canceller is
336    /// registered — callers should normally checkpoint at a quiescent
337    /// point so no tasks are `Running`.
338    ///
339    /// Used by [`SessionStore`](crate::store::SessionStore) rehydration to
340    /// restore the materialized subagent view of a previously-saved
341    /// session.
342    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        // Cancellers reference live tokens — invalidate the lot.
349        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        // The cancelled child loop will still emit a (likely failed)
600        // SubagentEnd. The terminal status should remain Cancelled.
601        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        // Guards the canonical lock-ordering change in mark_terminal_and_evict
627        // (terminal_order -> tasks -> cancellers held together). A bad ordering
628        // would ABBA-deadlock against concurrent cancel()/record_event and hang.
629        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        // Terminal cap honored; tracker still usable.
651        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        // Three fully terminal tasks; oldest must be evicted.
668        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        // Only the two most-recent terminal tasks survive.
679        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        // One running, two terminal — the cap applies only to terminal
695        // entries, so the running task survives even if it would be
696        // the "oldest".
697        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        // Only the most recent terminal task survives.
717        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        // Two cancellations — second one should evict the first.
728        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}