Skip to main content

a3s_code_core/tools/task/
parallel_execution.rs

1use super::*;
2use std::collections::HashMap;
3
4pub(super) struct ParallelToolOptions<'a> {
5    pub(super) parent_session_id: Option<&'a str>,
6    pub(super) timeout_ms: Option<u64>,
7    pub(super) min_success_count: Option<usize>,
8    pub(super) allow_partial_failure: bool,
9    pub(super) parent_cancellation: Option<&'a CancellationToken>,
10}
11
12impl TaskExecutor {
13    /// Execute multiple tasks in parallel.
14    ///
15    /// Spawns all tasks concurrently and waits for all to complete.
16    /// Returns results in the same order as the input tasks. Routed through
17    /// the [`AgentExecutor`] seam so the
18    /// same fan-out works whether steps run locally (default) or are placed
19    /// on remote nodes by a host.
20    pub async fn execute_parallel(
21        self: &Arc<Self>,
22        tasks: Vec<TaskParams>,
23        event_tx: Option<broadcast::Sender<AgentEvent>>,
24        parent_session_id: Option<&str>,
25    ) -> Vec<TaskResult> {
26        self.execute_parallel_with_parent_cancellation(
27            tasks,
28            event_tx,
29            parent_session_id,
30            self.parent_cancellation.as_ref(),
31        )
32        .await
33    }
34
35    async fn execute_parallel_with_parent_cancellation(
36        self: &Arc<Self>,
37        tasks: Vec<TaskParams>,
38        event_tx: Option<broadcast::Sender<AgentEvent>>,
39        parent_session_id: Option<&str>,
40        parent_cancellation: Option<&CancellationToken>,
41    ) -> Vec<TaskResult> {
42        let parent = parent_session_id.map(|s| s.to_string());
43        let specs = tasks
44            .into_iter()
45            .map(|params| AgentStepSpec {
46                task_id: format!("task-{}", uuid::Uuid::new_v4()),
47                agent: params.agent,
48                description: params.description,
49                prompt: params.prompt,
50                max_steps: params.max_steps,
51                parent_session_id: parent.clone(),
52                output_schema: params.output_schema,
53            })
54            .collect();
55
56        let executor: Arc<dyn AgentExecutor> = match parent_cancellation {
57            Some(cancellation) => Arc::new(ScopedTaskExecutor {
58                executor: Arc::clone(self),
59                parent_cancellation: cancellation.clone(),
60                parallel_lifecycle: None,
61            }),
62            None => Arc::<Self>::clone(self),
63        };
64        crate::orchestration::execute_steps_parallel(executor, specs, event_tx)
65            .await
66            .into_iter()
67            .map(TaskResult::from)
68            .collect()
69    }
70
71    pub(super) async fn execute_parallel_for_tool(
72        self: &Arc<Self>,
73        tasks: Vec<TaskParams>,
74        event_tx: Option<broadcast::Sender<AgentEvent>>,
75        options: ParallelToolOptions<'_>,
76    ) -> ParallelTaskRun {
77        let ParallelToolOptions {
78            parent_session_id,
79            timeout_ms,
80            min_success_count,
81            allow_partial_failure,
82            parent_cancellation,
83        } = options;
84        let parallel_cancellation = parent_cancellation
85            .map(CancellationToken::child_token)
86            .unwrap_or_default();
87        let should_return_early = allow_partial_failure && min_success_count.is_some();
88        if timeout_ms.is_none() && !should_return_early {
89            return ParallelTaskRun {
90                results: self
91                    .execute_parallel_with_parent_cancellation(
92                        tasks,
93                        event_tx,
94                        parent_session_id,
95                        Some(&parallel_cancellation),
96                    )
97                    .await,
98                timed_out: false,
99                returned_early: false,
100                timeout_ms: None,
101                min_success_count: None,
102            };
103        }
104
105        let task_count = tasks.len();
106        let parent = parent_session_id.map(ToString::to_string);
107        let specs = tasks
108            .into_iter()
109            .map(|params| AgentStepSpec {
110                task_id: format!("task-{}", uuid::Uuid::new_v4()),
111                agent: params.agent,
112                description: params.description,
113                prompt: params.prompt,
114                max_steps: params.max_steps,
115                parent_session_id: parent.clone(),
116                output_schema: params.output_schema,
117            })
118            .collect::<Vec<_>>();
119        let labels = specs
120            .iter()
121            .map(|spec| (spec.task_id.clone(), spec.agent.clone()))
122            .collect::<Vec<_>>();
123        let target_successes = min_success_count
124            .unwrap_or(task_count)
125            .clamp(1, task_count.max(1));
126
127        let max_concurrency = self.max_parallel_tasks.max(1);
128        let parallel_lifecycle = Arc::new(ParallelTaskLifecycle::default());
129        let scoped_executor: Arc<dyn AgentExecutor> = Arc::new(ScopedTaskExecutor {
130            executor: Arc::clone(self),
131            parent_cancellation: parallel_cancellation.clone(),
132            parallel_lifecycle: Some(Arc::clone(&parallel_lifecycle)),
133        });
134        let mut pending = specs.into_iter().enumerate();
135        let mut join_set = JoinSet::new();
136        let mut active_indexes = HashMap::new();
137        let mut active_count = 0usize;
138        while active_count < max_concurrency {
139            let Some((index, spec)) = pending.next() else {
140                break;
141            };
142            let task_id = spawn_parallel_task_step(
143                &mut join_set,
144                Arc::clone(&scoped_executor),
145                event_tx.clone(),
146                index,
147                spec,
148            );
149            active_indexes.insert(task_id, index);
150            active_count += 1;
151        }
152
153        let mut results: Vec<Option<TaskResult>> = vec![None; task_count];
154        let mut completed_count = 0usize;
155        let mut success_count = 0usize;
156        let mut timed_out = false;
157        let mut returned_early = false;
158        let deadline = timeout_ms.map(|timeout| {
159            tokio::time::Instant::now() + std::time::Duration::from_millis(timeout.max(1))
160        });
161
162        while completed_count < task_count {
163            if should_return_early && success_count >= target_successes {
164                returned_early = true;
165                break;
166            }
167
168            let next = match deadline {
169                Some(deadline) => {
170                    tokio::select! {
171                        result = join_set.join_next_with_id() => result,
172                        _ = tokio::time::sleep_until(deadline) => {
173                            timed_out = true;
174                            break;
175                        }
176                    }
177                }
178                None => join_set.join_next_with_id().await,
179            };
180
181            let Some(joined) = next else {
182                break;
183            };
184            active_count = active_count.saturating_sub(1);
185            let (index, outcome) = match joined {
186                Ok((task_id, (reported_index, Ok(outcome)))) => {
187                    let index = take_parallel_task_index(&mut active_indexes, task_id)
188                        .unwrap_or(reported_index);
189                    if index != reported_index {
190                        tracing::error!(
191                            tracked_index = index,
192                            reported_index,
193                            "parallel branch returned a mismatched task index"
194                        );
195                    }
196                    (index, outcome)
197                }
198                Ok((task_id, (reported_index, Err(error)))) => {
199                    let index = take_parallel_task_index(&mut active_indexes, task_id)
200                        .unwrap_or(reported_index);
201                    let (task_id, agent) = labels
202                        .get(index)
203                        .cloned()
204                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
205                    (index, StepOutcome::failed(task_id, agent, error))
206                }
207                Err(error) => {
208                    let index = take_parallel_task_index(&mut active_indexes, error.id())
209                        .unwrap_or_else(|| {
210                        tracing::error!(%error, "parallel branch join failed without a tracked index");
211                        usize::MAX
212                    });
213                    let (task_id, agent) = labels
214                        .get(index)
215                        .cloned()
216                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
217                    (
218                        index,
219                        StepOutcome::failed(task_id, agent, error.to_string()),
220                    )
221                }
222            };
223            let accepted = index < task_count && results[index].is_none();
224            if accepted {
225                if outcome.success {
226                    success_count += 1;
227                }
228                results[index] = Some(TaskResult::from(outcome));
229                completed_count += 1;
230            }
231
232            if accepted && should_return_early && success_count >= target_successes {
233                returned_early = true;
234                break;
235            }
236
237            while active_count < max_concurrency {
238                let Some((index, spec)) = pending.next() else {
239                    break;
240                };
241                let task_id = spawn_parallel_task_step(
242                    &mut join_set,
243                    Arc::clone(&scoped_executor),
244                    event_tx.clone(),
245                    index,
246                    spec,
247                );
248                active_indexes.insert(task_id, index);
249                active_count += 1;
250            }
251        }
252
253        let unfinished_message = if timed_out {
254            format!(
255                "Task timed out before parallel_task finished collecting child results after {} ms.",
256                timeout_ms.unwrap_or_default()
257            )
258        } else if returned_early {
259            format!(
260                "Task cancelled after parallel_task collected {success_count} successful child result(s)."
261            )
262        } else {
263            "Task did not return a result before parallel_task ended.".to_string()
264        };
265        if timed_out || returned_early || active_count > 0 {
266            let cancelled_indexes = active_indexes.values().copied().collect::<Vec<_>>();
267            parallel_cancellation.cancel();
268            settle_cancelled_parallel_tasks(&mut join_set, &mut active_indexes).await;
269            self.emit_abandoned_parallel_task_ends(
270                &cancelled_indexes,
271                &labels,
272                event_tx.as_ref(),
273                &unfinished_message,
274                Some(&parallel_lifecycle),
275            )
276            .await;
277        }
278
279        let results = results
280            .into_iter()
281            .enumerate()
282            .map(|(index, result)| {
283                result.unwrap_or_else(|| {
284                    let (task_id, agent) = labels
285                        .get(index)
286                        .cloned()
287                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
288                    TaskResult::from(StepOutcome::failed(
289                        task_id,
290                        agent,
291                        unfinished_message.clone(),
292                    ))
293                })
294            })
295            .collect();
296
297        ParallelTaskRun {
298            results,
299            timed_out,
300            returned_early,
301            timeout_ms,
302            min_success_count,
303        }
304    }
305
306    /// Emit a terminal lifecycle event for a child that could not settle
307    /// within the bounded cancellation grace period. A parent fan-out still
308    /// returns a deterministic failed result for such a child, so its
309    /// `subagent_start` must not remain open in the event stream or tracker.
310    async fn emit_abandoned_parallel_task_ends(
311        &self,
312        indexes: &[usize],
313        labels: &[(String, String)],
314        event_tx: Option<&broadcast::Sender<AgentEvent>>,
315        output: &str,
316        lifecycle: Option<&ParallelTaskLifecycle>,
317    ) {
318        for &index in indexes {
319            let Some((task_id, agent)) = labels.get(index) else {
320                continue;
321            };
322            if let Some(lifecycle) = lifecycle {
323                if !lifecycle.is_started(task_id) || lifecycle.is_ended(task_id) {
324                    continue;
325                }
326            }
327            let event = AgentEvent::SubagentEnd {
328                task_id: task_id.clone(),
329                session_id: format!("task-run-{task_id}"),
330                agent: agent.clone(),
331                output: output.to_string(),
332                success: false,
333                finished_ms: epoch_ms(),
334            };
335            let event = self
336                .parent_context
337                .as_ref()
338                .and_then(|context| context.security_provider.as_deref())
339                .map(|provider| crate::security::sanitize_agent_event(provider, &event))
340                .unwrap_or(event);
341
342            if let Some(tracker) = &self.subagent_tracker {
343                // Preserve the explicit cancellation state in the materialized
344                // tracker when a child did not reach its own end-event path.
345                // `record_event` then fills in the terminal output/timestamp;
346                // a late child end cannot downgrade Cancelled.
347                let _ = tracker.cancel(task_id).await;
348                tracker.record_event(&event).await;
349                tracker.clear_canceller(task_id).await;
350            }
351            if let Some(tx) = event_tx {
352                let _ = tx.send(event);
353            }
354            if let Some(lifecycle) = lifecycle {
355                lifecycle.mark_ended(task_id);
356            }
357        }
358    }
359}
360
361async fn settle_cancelled_parallel_tasks(
362    join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
363    active_indexes: &mut HashMap<tokio::task::Id, usize>,
364) {
365    const SETTLEMENT_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
366    let deadline = tokio::time::Instant::now() + SETTLEMENT_GRACE;
367    while !join_set.is_empty() {
368        match tokio::time::timeout_at(deadline, join_set.join_next_with_id()).await {
369            Ok(Some(Ok((task_id, _)))) => {
370                active_indexes.remove(&task_id);
371            }
372            Ok(Some(Err(error))) => {
373                active_indexes.remove(&error.id());
374            }
375            Ok(None) => return,
376            Err(_) => break,
377        }
378    }
379
380    if join_set.is_empty() {
381        return;
382    }
383    join_set.abort_all();
384    while let Some(joined) = join_set.join_next_with_id().await {
385        match joined {
386            Ok((task_id, _)) => {
387                active_indexes.remove(&task_id);
388            }
389            Err(error) => {
390                active_indexes.remove(&error.id());
391            }
392        }
393    }
394    active_indexes.clear();
395}
396
397fn spawn_parallel_task_step(
398    join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
399    executor: Arc<dyn AgentExecutor>,
400    event_tx: Option<broadcast::Sender<AgentEvent>>,
401    index: usize,
402    spec: AgentStepSpec,
403) -> tokio::task::Id {
404    join_set
405        .spawn(async move {
406            let outcome = AssertUnwindSafe(executor.execute_step(spec, event_tx))
407                .catch_unwind()
408                .await
409                .map_err(panic_payload_to_string);
410            (index, outcome)
411        })
412        .id()
413}
414
415fn take_parallel_task_index(
416    active_indexes: &mut HashMap<tokio::task::Id, usize>,
417    task_id: tokio::task::Id,
418) -> Option<usize> {
419    active_indexes.remove(&task_id)
420}
421
422fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
423    if let Some(message) = payload.downcast_ref::<&str>() {
424        return format!("parallel branch panicked: {message}");
425    }
426    if let Some(message) = payload.downcast_ref::<String>() {
427        return format!("parallel branch panicked: {message}");
428    }
429    "parallel branch panicked: unknown panic payload".to_string()
430}
431
432pub(super) struct ParallelTaskRun {
433    pub(super) results: Vec<TaskResult>,
434    pub(super) timed_out: bool,
435    pub(super) returned_early: bool,
436    pub(super) timeout_ms: Option<u64>,
437    pub(super) min_success_count: Option<usize>,
438}
439
440impl From<TaskResult> for StepOutcome {
441    fn from(r: TaskResult) -> Self {
442        StepOutcome {
443            task_id: r.task_id,
444            session_id: r.session_id,
445            agent: r.agent,
446            output: r.output,
447            success: r.success,
448            structured: r.structured,
449            source_anchors: r.source_anchors,
450        }
451    }
452}
453
454impl From<StepOutcome> for TaskResult {
455    fn from(o: StepOutcome) -> Self {
456        TaskResult {
457            output: o.output,
458            session_id: o.session_id,
459            agent: o.agent,
460            success: o.success,
461            task_id: o.task_id,
462            structured: o.structured,
463            source_anchors: o.source_anchors,
464            completion: crate::harness_loop::CompletionTerminal::Narrative,
465        }
466    }
467}
468
469/// The local, in-process executor: every step runs as a child `AgentLoop` on
470/// this node's tokio runtime. This is the default; a host substitutes
471/// its own [`AgentExecutor`] to place steps across a cluster.
472#[async_trait]
473impl AgentExecutor for TaskExecutor {
474    async fn execute_step(
475        &self,
476        spec: AgentStepSpec,
477        event_tx: Option<broadcast::Sender<AgentEvent>>,
478    ) -> StepOutcome {
479        self.execute_step_with_parent_cancellation(
480            spec,
481            event_tx,
482            self.parent_cancellation.as_ref(),
483            None,
484        )
485        .await
486    }
487
488    fn concurrency_hint(&self) -> usize {
489        self.max_parallel_tasks
490    }
491}
492
493impl TaskExecutor {
494    async fn execute_step_with_parent_cancellation(
495        &self,
496        spec: AgentStepSpec,
497        event_tx: Option<broadcast::Sender<AgentEvent>>,
498        parent_cancellation: Option<&CancellationToken>,
499        parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
500    ) -> StepOutcome {
501        let agent = spec.agent.clone();
502        let task_id = spec.task_id.clone();
503        let _permit = match self.acquire_parallel_permit(parent_cancellation).await {
504            Ok(permit) => permit,
505            Err(error) => return StepOutcome::failed(task_id, agent, error),
506        };
507        let params = TaskParams {
508            agent: spec.agent,
509            description: spec.description,
510            prompt: spec.prompt,
511            background: false,
512            max_steps: spec.max_steps,
513            output_schema: spec.output_schema,
514        };
515        match self
516            .execute_with_task_id_scoped(
517                task_id.clone(),
518                params,
519                ScopedTaskExecution {
520                    event_tx,
521                    parent_session_id: spec.parent_session_id.as_deref(),
522                    emit_start: true,
523                    parent_cancellation,
524                    admitted_capability_subtask: None,
525                    parallel_lifecycle,
526                },
527            )
528            .await
529        {
530            Ok(result) => result.into(),
531            Err(e) => StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
532        }
533    }
534
535    async fn acquire_parallel_permit(
536        &self,
537        parent_cancellation: Option<&CancellationToken>,
538    ) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, String> {
539        let acquire = Arc::clone(&self.parallel_permits).acquire_owned();
540        match parent_cancellation {
541            Some(cancellation) => {
542                tokio::select! {
543                    biased;
544                    _ = cancellation.cancelled() => {
545                        Err("Task cancelled while waiting for parallel provider capacity".to_string())
546                    }
547                    permit = acquire => permit.map_err(|error| {
548                        format!("Parallel provider capacity closed unexpectedly: {error}")
549                    }),
550                }
551            }
552            None => acquire.await.map_err(|error| {
553                format!("Parallel provider capacity closed unexpectedly: {error}")
554            }),
555        }
556    }
557
558    /// Coerce a step's free-text output into a JSON object validated against
559    /// `schema`, reusing the structured-output machinery with built-in repair.
560    /// This is one extra LLM call beyond the step's own run.
561    pub(super) async fn coerce_to_schema(
562        llm_client: &dyn LlmClient,
563        output: &str,
564        schema: serde_json::Value,
565        cancellation: &CancellationToken,
566    ) -> Result<serde_json::Value> {
567        let req = StructuredRequest {
568            prompt: format!(
569                "Convert the following task result into a single JSON object that conforms to \
570                 the required schema. Use only information present in the result.\n\n\
571                 --- TASK RESULT ---\n{output}"
572            ),
573            system: Some(
574                "You output exactly one JSON object matching the provided schema.".to_string(),
575            ),
576            schema,
577            schema_name: "step_output".to_string(),
578            schema_description: None,
579            // Request tool mode when available; unknown providers safely
580            // downgrade to prompt+schema parsing.
581            mode: StructuredMode::Tool,
582            max_repair_attempts: 2,
583        };
584        let result =
585            generate_blocking_with_cancellation(llm_client, &req, cancellation.clone()).await?;
586        Ok(result.object)
587    }
588
589    pub(super) async fn generate_structured_task(
590        llm_client: &dyn LlmClient,
591        prompt: &str,
592        system: Option<&str>,
593        schema: serde_json::Value,
594        cancellation: &CancellationToken,
595    ) -> Result<serde_json::Value> {
596        let req = StructuredRequest {
597            prompt: prompt.to_string(),
598            system: Some(format!(
599                "{}\n\nReturn exactly one JSON object matching the provided schema.",
600                system.unwrap_or("Make the requested structured decision without tools.")
601            )),
602            schema,
603            schema_name: "step_output".to_string(),
604            schema_description: None,
605            mode: StructuredMode::Tool,
606            max_repair_attempts: 2,
607        };
608        let result =
609            generate_blocking_with_cancellation(llm_client, &req, cancellation.clone()).await?;
610        Ok(result.object)
611    }
612}
613
614struct ScopedTaskExecutor {
615    executor: Arc<TaskExecutor>,
616    parent_cancellation: CancellationToken,
617    parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
618}
619
620#[async_trait]
621impl AgentExecutor for ScopedTaskExecutor {
622    async fn execute_step(
623        &self,
624        spec: AgentStepSpec,
625        event_tx: Option<broadcast::Sender<AgentEvent>>,
626    ) -> StepOutcome {
627        self.executor
628            .execute_step_with_parent_cancellation(
629                spec,
630                event_tx,
631                Some(&self.parent_cancellation),
632                self.parallel_lifecycle.clone(),
633            )
634            .await
635    }
636
637    fn concurrency_hint(&self) -> usize {
638        self.executor.max_parallel_tasks
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    struct NoopLlmClient;
647
648    #[async_trait::async_trait]
649    impl LlmClient for NoopLlmClient {
650        async fn complete(
651            &self,
652            _messages: &[crate::llm::Message],
653            _system: Option<&str>,
654            _tools: &[crate::llm::ToolDefinition],
655        ) -> anyhow::Result<crate::llm::LlmResponse> {
656            anyhow::bail!("NoopLlmClient must not be called")
657        }
658
659        async fn complete_streaming(
660            &self,
661            _messages: &[crate::llm::Message],
662            _system: Option<&str>,
663            _tools: &[crate::llm::ToolDefinition],
664            _cancel_token: CancellationToken,
665        ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
666            anyhow::bail!("NoopLlmClient must not be called")
667        }
668    }
669
670    #[tokio::test]
671    async fn aborted_join_keeps_the_spawned_branch_index() {
672        let mut join_set = JoinSet::new();
673        let handle = join_set.spawn(async {
674            std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
675        });
676        let mut active_indexes = HashMap::from([(handle.id(), 7)]);
677        handle.abort();
678
679        let error = join_set
680            .join_next_with_id()
681            .await
682            .expect("aborted task should settle")
683            .expect_err("aborted task should return JoinError");
684
685        assert_eq!(
686            take_parallel_task_index(&mut active_indexes, error.id()),
687            Some(7)
688        );
689        assert!(active_indexes.is_empty());
690    }
691
692    #[tokio::test]
693    async fn abandoned_parallel_settlement_emits_terminal_end_and_updates_tracker() {
694        use crate::subagent_task_tracker::{InMemorySubagentTaskTracker, SubagentStatus};
695
696        let tracker = Arc::new(InMemorySubagentTaskTracker::new());
697        let task_id = "task-abandoned".to_string();
698        let lifecycle = Arc::new(ParallelTaskLifecycle::default());
699        lifecycle.mark_started(&task_id);
700        tracker
701            .record_event(&AgentEvent::SubagentStart {
702                task_id: task_id.clone(),
703                session_id: format!("task-run-{task_id}"),
704                parent_session_id: "parent".to_string(),
705                agent: "worker".to_string(),
706                description: "abandoned branch".to_string(),
707                started_ms: 1,
708            })
709            .await;
710        tracker
711            .register_canceller(&task_id, CancellationToken::new())
712            .await;
713
714        let executor = TaskExecutor::new(
715            Arc::new(AgentRegistry::new()),
716            Arc::new(NoopLlmClient),
717            ".".to_string(),
718        )
719        .with_subagent_tracker(Arc::clone(&tracker));
720        let (event_tx, mut event_rx) = broadcast::channel(8);
721        executor
722            .emit_abandoned_parallel_task_ends(
723                &[0],
724                &[(task_id.clone(), "worker".to_string())],
725                Some(&event_tx),
726                "Task cancelled after parallel_task collected one successful child result(s).",
727                Some(&lifecycle),
728            )
729            .await;
730
731        let event = event_rx.try_recv().expect("synthetic end event");
732        match event {
733            AgentEvent::SubagentEnd {
734                task_id: event_task_id,
735                success,
736                output,
737                ..
738            } => {
739                assert_eq!(event_task_id, task_id);
740                assert!(!success);
741                assert!(output.contains("cancelled"));
742            }
743            other => panic!("expected SubagentEnd, got {other:?}"),
744        }
745        assert_eq!(
746            tracker.get(&task_id).await.unwrap().status,
747            SubagentStatus::Cancelled
748        );
749    }
750
751    #[tokio::test]
752    async fn cancelled_parallel_settlement_drains_join_set() {
753        let mut join_set = JoinSet::new();
754        let handle = join_set.spawn(async {
755            std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
756        });
757        let mut active_indexes = HashMap::from([(handle.id(), 3)]);
758
759        settle_cancelled_parallel_tasks(&mut join_set, &mut active_indexes).await;
760        assert!(join_set.is_empty());
761        assert!(active_indexes.is_empty());
762    }
763}