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        }
465    }
466}
467
468/// The local, in-process executor: every step runs as a child `AgentLoop` on
469/// this node's tokio runtime. This is the default; a host substitutes
470/// its own [`AgentExecutor`] to place steps across a cluster.
471#[async_trait]
472impl AgentExecutor for TaskExecutor {
473    async fn execute_step(
474        &self,
475        spec: AgentStepSpec,
476        event_tx: Option<broadcast::Sender<AgentEvent>>,
477    ) -> StepOutcome {
478        self.execute_step_with_parent_cancellation(
479            spec,
480            event_tx,
481            self.parent_cancellation.as_ref(),
482            None,
483        )
484        .await
485    }
486
487    fn concurrency_hint(&self) -> usize {
488        self.max_parallel_tasks
489    }
490}
491
492impl TaskExecutor {
493    async fn execute_step_with_parent_cancellation(
494        &self,
495        spec: AgentStepSpec,
496        event_tx: Option<broadcast::Sender<AgentEvent>>,
497        parent_cancellation: Option<&CancellationToken>,
498        parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
499    ) -> StepOutcome {
500        let agent = spec.agent.clone();
501        let task_id = spec.task_id.clone();
502        let _permit = match self.acquire_parallel_permit(parent_cancellation).await {
503            Ok(permit) => permit,
504            Err(error) => return StepOutcome::failed(task_id, agent, error),
505        };
506        let params = TaskParams {
507            agent: spec.agent,
508            description: spec.description,
509            prompt: spec.prompt,
510            background: false,
511            max_steps: spec.max_steps,
512            output_schema: spec.output_schema,
513        };
514        match self
515            .execute_with_task_id_scoped(
516                task_id.clone(),
517                params,
518                ScopedTaskExecution {
519                    event_tx,
520                    parent_session_id: spec.parent_session_id.as_deref(),
521                    emit_start: true,
522                    parent_cancellation,
523                    admitted_capability_subtask: None,
524                    parallel_lifecycle,
525                },
526            )
527            .await
528        {
529            Ok(result) => result.into(),
530            Err(e) => StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
531        }
532    }
533
534    async fn acquire_parallel_permit(
535        &self,
536        parent_cancellation: Option<&CancellationToken>,
537    ) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, String> {
538        let acquire = Arc::clone(&self.parallel_permits).acquire_owned();
539        match parent_cancellation {
540            Some(cancellation) => {
541                tokio::select! {
542                    biased;
543                    _ = cancellation.cancelled() => {
544                        Err("Task cancelled while waiting for parallel provider capacity".to_string())
545                    }
546                    permit = acquire => permit.map_err(|error| {
547                        format!("Parallel provider capacity closed unexpectedly: {error}")
548                    }),
549                }
550            }
551            None => acquire.await.map_err(|error| {
552                format!("Parallel provider capacity closed unexpectedly: {error}")
553            }),
554        }
555    }
556
557    /// Coerce a step's free-text output into a JSON object validated against
558    /// `schema`, reusing the structured-output machinery with built-in repair.
559    /// This is one extra LLM call beyond the step's own run.
560    pub(super) async fn coerce_to_schema(
561        llm_client: &dyn LlmClient,
562        output: &str,
563        schema: serde_json::Value,
564        cancellation: &CancellationToken,
565    ) -> Result<serde_json::Value> {
566        let req = StructuredRequest {
567            prompt: format!(
568                "Convert the following task result into a single JSON object that conforms to \
569                 the required schema. Use only information present in the result.\n\n\
570                 --- TASK RESULT ---\n{output}"
571            ),
572            system: Some(
573                "You output exactly one JSON object matching the provided schema.".to_string(),
574            ),
575            schema,
576            schema_name: "step_output".to_string(),
577            schema_description: None,
578            // Request tool mode when available; unknown providers safely
579            // downgrade to prompt+schema parsing.
580            mode: StructuredMode::Tool,
581            max_repair_attempts: 2,
582        };
583        let result = tokio::select! {
584            biased;
585            _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
586            result = generate_blocking(llm_client, &req) => result?,
587        };
588        Ok(result.object)
589    }
590
591    pub(super) async fn generate_structured_task(
592        llm_client: &dyn LlmClient,
593        prompt: &str,
594        system: Option<&str>,
595        schema: serde_json::Value,
596        cancellation: &CancellationToken,
597    ) -> Result<serde_json::Value> {
598        let req = StructuredRequest {
599            prompt: prompt.to_string(),
600            system: Some(format!(
601                "{}\n\nReturn exactly one JSON object matching the provided schema.",
602                system.unwrap_or("Make the requested structured decision without tools.")
603            )),
604            schema,
605            schema_name: "step_output".to_string(),
606            schema_description: None,
607            mode: StructuredMode::Tool,
608            max_repair_attempts: 2,
609        };
610        let result = tokio::select! {
611            biased;
612            _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
613            result = generate_blocking(llm_client, &req) => result?,
614        };
615        Ok(result.object)
616    }
617}
618
619struct ScopedTaskExecutor {
620    executor: Arc<TaskExecutor>,
621    parent_cancellation: CancellationToken,
622    parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
623}
624
625#[async_trait]
626impl AgentExecutor for ScopedTaskExecutor {
627    async fn execute_step(
628        &self,
629        spec: AgentStepSpec,
630        event_tx: Option<broadcast::Sender<AgentEvent>>,
631    ) -> StepOutcome {
632        self.executor
633            .execute_step_with_parent_cancellation(
634                spec,
635                event_tx,
636                Some(&self.parent_cancellation),
637                self.parallel_lifecycle.clone(),
638            )
639            .await
640    }
641
642    fn concurrency_hint(&self) -> usize {
643        self.executor.max_parallel_tasks
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    struct NoopLlmClient;
652
653    #[async_trait::async_trait]
654    impl LlmClient for NoopLlmClient {
655        async fn complete(
656            &self,
657            _messages: &[crate::llm::Message],
658            _system: Option<&str>,
659            _tools: &[crate::llm::ToolDefinition],
660        ) -> anyhow::Result<crate::llm::LlmResponse> {
661            anyhow::bail!("NoopLlmClient must not be called")
662        }
663
664        async fn complete_streaming(
665            &self,
666            _messages: &[crate::llm::Message],
667            _system: Option<&str>,
668            _tools: &[crate::llm::ToolDefinition],
669            _cancel_token: CancellationToken,
670        ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
671            anyhow::bail!("NoopLlmClient must not be called")
672        }
673    }
674
675    #[tokio::test]
676    async fn aborted_join_keeps_the_spawned_branch_index() {
677        let mut join_set = JoinSet::new();
678        let handle = join_set.spawn(async {
679            std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
680        });
681        let mut active_indexes = HashMap::from([(handle.id(), 7)]);
682        handle.abort();
683
684        let error = join_set
685            .join_next_with_id()
686            .await
687            .expect("aborted task should settle")
688            .expect_err("aborted task should return JoinError");
689
690        assert_eq!(
691            take_parallel_task_index(&mut active_indexes, error.id()),
692            Some(7)
693        );
694        assert!(active_indexes.is_empty());
695    }
696
697    #[tokio::test]
698    async fn abandoned_parallel_settlement_emits_terminal_end_and_updates_tracker() {
699        use crate::subagent_task_tracker::{InMemorySubagentTaskTracker, SubagentStatus};
700
701        let tracker = Arc::new(InMemorySubagentTaskTracker::new());
702        let task_id = "task-abandoned".to_string();
703        let lifecycle = Arc::new(ParallelTaskLifecycle::default());
704        lifecycle.mark_started(&task_id);
705        tracker
706            .record_event(&AgentEvent::SubagentStart {
707                task_id: task_id.clone(),
708                session_id: format!("task-run-{task_id}"),
709                parent_session_id: "parent".to_string(),
710                agent: "worker".to_string(),
711                description: "abandoned branch".to_string(),
712                started_ms: 1,
713            })
714            .await;
715        tracker
716            .register_canceller(&task_id, CancellationToken::new())
717            .await;
718
719        let executor = TaskExecutor::new(
720            Arc::new(AgentRegistry::new()),
721            Arc::new(NoopLlmClient),
722            ".".to_string(),
723        )
724        .with_subagent_tracker(Arc::clone(&tracker));
725        let (event_tx, mut event_rx) = broadcast::channel(8);
726        executor
727            .emit_abandoned_parallel_task_ends(
728                &[0],
729                &[(task_id.clone(), "worker".to_string())],
730                Some(&event_tx),
731                "Task cancelled after parallel_task collected one successful child result(s).",
732                Some(&lifecycle),
733            )
734            .await;
735
736        let event = event_rx.try_recv().expect("synthetic end event");
737        match event {
738            AgentEvent::SubagentEnd {
739                task_id: event_task_id,
740                success,
741                output,
742                ..
743            } => {
744                assert_eq!(event_task_id, task_id);
745                assert!(!success);
746                assert!(output.contains("cancelled"));
747            }
748            other => panic!("expected SubagentEnd, got {other:?}"),
749        }
750        assert_eq!(
751            tracker.get(&task_id).await.unwrap().status,
752            SubagentStatus::Cancelled
753        );
754    }
755
756    #[tokio::test]
757    async fn cancelled_parallel_settlement_drains_join_set() {
758        let mut join_set = JoinSet::new();
759        let handle = join_set.spawn(async {
760            std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
761        });
762        let mut active_indexes = HashMap::from([(handle.id(), 3)]);
763
764        settle_cancelled_parallel_tasks(&mut join_set, &mut active_indexes).await;
765        assert!(join_set.is_empty());
766        assert!(active_indexes.is_empty());
767    }
768}