Skip to main content

a3s_code_core/tools/task/
parallel_execution.rs

1use super::*;
2use std::collections::HashMap;
3
4const MAX_TRANSIENT_PARALLEL_RETRIES: u8 = 1;
5#[cfg(not(test))]
6const PARALLEL_RETRY_BASE_DELAY_MS: u64 = 750;
7#[cfg(test)]
8const PARALLEL_RETRY_BASE_DELAY_MS: u64 = 1;
9
10pub(super) struct ParallelToolOptions<'a> {
11    pub(super) parent_session_id: Option<&'a str>,
12    pub(super) timeout_ms: Option<u64>,
13    pub(super) min_success_count: Option<usize>,
14    pub(super) allow_partial_failure: bool,
15    pub(super) parent_cancellation: Option<&'a CancellationToken>,
16}
17
18pub(super) struct ParallelRetryOptions<'a> {
19    pub(super) event_tx: Option<broadcast::Sender<AgentEvent>>,
20    pub(super) parent_session_id: Option<&'a str>,
21    pub(super) parent_cancellation: &'a CancellationToken,
22    pub(super) total_timeout_ms: Option<u64>,
23    pub(super) started_at: std::time::Instant,
24}
25
26#[derive(Debug, Default)]
27pub(super) struct ParallelRetrySummary {
28    pub(super) attempts_by_index: Vec<u8>,
29    pub(super) retry_attempt_count: usize,
30    pub(super) retried_task_count: usize,
31    pub(super) recovered_task_count: usize,
32}
33
34impl TaskExecutor {
35    /// Execute multiple tasks in parallel.
36    ///
37    /// Spawns all tasks concurrently and waits for all to complete.
38    /// Returns results in the same order as the input tasks. Routed through
39    /// the [`AgentExecutor`] seam so the
40    /// same fan-out works whether steps run locally (default) or are placed
41    /// on remote nodes by a host.
42    pub async fn execute_parallel(
43        self: &Arc<Self>,
44        tasks: Vec<TaskParams>,
45        event_tx: Option<broadcast::Sender<AgentEvent>>,
46        parent_session_id: Option<&str>,
47    ) -> Vec<TaskResult> {
48        self.execute_parallel_with_parent_cancellation(
49            tasks,
50            event_tx,
51            parent_session_id,
52            self.parent_cancellation.as_ref(),
53        )
54        .await
55    }
56
57    async fn execute_parallel_with_parent_cancellation(
58        self: &Arc<Self>,
59        tasks: Vec<TaskParams>,
60        event_tx: Option<broadcast::Sender<AgentEvent>>,
61        parent_session_id: Option<&str>,
62        parent_cancellation: Option<&CancellationToken>,
63    ) -> Vec<TaskResult> {
64        let parent = parent_session_id.map(|s| s.to_string());
65        let specs = tasks
66            .into_iter()
67            .map(|params| AgentStepSpec {
68                task_id: format!("task-{}", uuid::Uuid::new_v4()),
69                agent: params.agent,
70                description: params.description,
71                prompt: params.prompt,
72                max_steps: params.max_steps,
73                parent_session_id: parent.clone(),
74                output_schema: params.output_schema,
75            })
76            .collect();
77
78        let executor: Arc<dyn AgentExecutor> = match parent_cancellation {
79            Some(cancellation) => Arc::new(ScopedTaskExecutor {
80                executor: Arc::clone(self),
81                parent_cancellation: cancellation.clone(),
82            }),
83            None => Arc::<Self>::clone(self),
84        };
85        crate::orchestration::execute_steps_parallel(executor, specs, event_tx)
86            .await
87            .into_iter()
88            .map(TaskResult::from)
89            .collect()
90    }
91
92    pub(super) async fn execute_parallel_for_tool(
93        self: &Arc<Self>,
94        tasks: Vec<TaskParams>,
95        event_tx: Option<broadcast::Sender<AgentEvent>>,
96        options: ParallelToolOptions<'_>,
97    ) -> ParallelTaskRun {
98        let ParallelToolOptions {
99            parent_session_id,
100            timeout_ms,
101            min_success_count,
102            allow_partial_failure,
103            parent_cancellation,
104        } = options;
105        let parallel_cancellation = parent_cancellation
106            .map(CancellationToken::child_token)
107            .unwrap_or_default();
108        let should_return_early = allow_partial_failure && min_success_count.is_some();
109        if timeout_ms.is_none() && !should_return_early {
110            return ParallelTaskRun {
111                results: self
112                    .execute_parallel_with_parent_cancellation(
113                        tasks,
114                        event_tx,
115                        parent_session_id,
116                        Some(&parallel_cancellation),
117                    )
118                    .await,
119                timed_out: false,
120                returned_early: false,
121                timeout_ms: None,
122                min_success_count: None,
123            };
124        }
125
126        let task_count = tasks.len();
127        let parent = parent_session_id.map(ToString::to_string);
128        let specs = tasks
129            .into_iter()
130            .map(|params| AgentStepSpec {
131                task_id: format!("task-{}", uuid::Uuid::new_v4()),
132                agent: params.agent,
133                description: params.description,
134                prompt: params.prompt,
135                max_steps: params.max_steps,
136                parent_session_id: parent.clone(),
137                output_schema: params.output_schema,
138            })
139            .collect::<Vec<_>>();
140        let labels = specs
141            .iter()
142            .map(|spec| (spec.task_id.clone(), spec.agent.clone()))
143            .collect::<Vec<_>>();
144        let target_successes = min_success_count
145            .unwrap_or(task_count)
146            .clamp(1, task_count.max(1));
147
148        let max_concurrency = self.max_parallel_tasks.max(1);
149        let scoped_executor: Arc<dyn AgentExecutor> = Arc::new(ScopedTaskExecutor {
150            executor: Arc::clone(self),
151            parent_cancellation: parallel_cancellation.clone(),
152        });
153        let mut pending = specs.into_iter().enumerate();
154        let mut join_set = JoinSet::new();
155        let mut active_indexes = HashMap::new();
156        let mut active_count = 0usize;
157        while active_count < max_concurrency {
158            let Some((index, spec)) = pending.next() else {
159                break;
160            };
161            let task_id = spawn_parallel_task_step(
162                &mut join_set,
163                Arc::clone(&scoped_executor),
164                event_tx.clone(),
165                index,
166                spec,
167            );
168            active_indexes.insert(task_id, index);
169            active_count += 1;
170        }
171
172        let mut results: Vec<Option<TaskResult>> = vec![None; task_count];
173        let mut completed_count = 0usize;
174        let mut success_count = 0usize;
175        let mut timed_out = false;
176        let mut returned_early = false;
177        let deadline = timeout_ms.map(|timeout| {
178            tokio::time::Instant::now() + std::time::Duration::from_millis(timeout.max(1))
179        });
180
181        while completed_count < task_count {
182            if should_return_early && success_count >= target_successes {
183                returned_early = true;
184                break;
185            }
186
187            let next = match deadline {
188                Some(deadline) => {
189                    tokio::select! {
190                        result = join_set.join_next_with_id() => result,
191                        _ = tokio::time::sleep_until(deadline) => {
192                            timed_out = true;
193                            break;
194                        }
195                    }
196                }
197                None => join_set.join_next_with_id().await,
198            };
199
200            let Some(joined) = next else {
201                break;
202            };
203            active_count = active_count.saturating_sub(1);
204            let (index, outcome) = match joined {
205                Ok((task_id, (reported_index, Ok(outcome)))) => {
206                    let index = take_parallel_task_index(&mut active_indexes, task_id)
207                        .unwrap_or(reported_index);
208                    if index != reported_index {
209                        tracing::error!(
210                            tracked_index = index,
211                            reported_index,
212                            "parallel branch returned a mismatched task index"
213                        );
214                    }
215                    (index, outcome)
216                }
217                Ok((task_id, (reported_index, Err(error)))) => {
218                    let index = take_parallel_task_index(&mut active_indexes, task_id)
219                        .unwrap_or(reported_index);
220                    let (task_id, agent) = labels
221                        .get(index)
222                        .cloned()
223                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
224                    (index, StepOutcome::failed(task_id, agent, error))
225                }
226                Err(error) => {
227                    let index = take_parallel_task_index(&mut active_indexes, error.id())
228                        .unwrap_or_else(|| {
229                        tracing::error!(%error, "parallel branch join failed without a tracked index");
230                        usize::MAX
231                    });
232                    let (task_id, agent) = labels
233                        .get(index)
234                        .cloned()
235                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
236                    (
237                        index,
238                        StepOutcome::failed(task_id, agent, error.to_string()),
239                    )
240                }
241            };
242            let accepted = index < task_count && results[index].is_none();
243            if accepted {
244                if outcome.success {
245                    success_count += 1;
246                }
247                results[index] = Some(TaskResult::from(outcome));
248                completed_count += 1;
249            }
250
251            if accepted && should_return_early && success_count >= target_successes {
252                returned_early = true;
253                break;
254            }
255
256            while active_count < max_concurrency {
257                let Some((index, spec)) = pending.next() else {
258                    break;
259                };
260                let task_id = spawn_parallel_task_step(
261                    &mut join_set,
262                    Arc::clone(&scoped_executor),
263                    event_tx.clone(),
264                    index,
265                    spec,
266                );
267                active_indexes.insert(task_id, index);
268                active_count += 1;
269            }
270        }
271
272        if timed_out || returned_early || active_count > 0 {
273            parallel_cancellation.cancel();
274            settle_cancelled_parallel_tasks(&mut join_set).await;
275        }
276
277        let unfinished_message = if timed_out {
278            format!(
279                "Task timed out before parallel_task finished collecting child results after {} ms.",
280                timeout_ms.unwrap_or_default()
281            )
282        } else if returned_early {
283            format!(
284                "Task cancelled after parallel_task collected {success_count} successful child result(s)."
285            )
286        } else {
287            "Task did not return a result before parallel_task ended.".to_string()
288        };
289        let results = results
290            .into_iter()
291            .enumerate()
292            .map(|(index, result)| {
293                result.unwrap_or_else(|| {
294                    let (task_id, agent) = labels
295                        .get(index)
296                        .cloned()
297                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
298                    TaskResult::from(StepOutcome::failed(
299                        task_id,
300                        agent,
301                        unfinished_message.clone(),
302                    ))
303                })
304            })
305            .collect();
306
307        ParallelTaskRun {
308            results,
309            timed_out,
310            returned_early,
311            timeout_ms,
312            min_success_count,
313        }
314    }
315
316    /// Retry only failed branches that are both side-effect-safe and clearly
317    /// transient. Successful branches are retained in place. This second
318    /// boundary is intentionally narrower than the provider and child-loop
319    /// retry layers: it exists for account-backed providers that can reject an
320    /// individual child after a concurrent burst, without replaying maker or
321    /// implementation work that may already have changed the workspace.
322    pub(super) async fn retry_transient_parallel_failures(
323        self: &Arc<Self>,
324        tasks: &[TaskParams],
325        options: ParallelRetryOptions<'_>,
326        run: &mut ParallelTaskRun,
327    ) -> ParallelRetrySummary {
328        let ParallelRetryOptions {
329            event_tx,
330            parent_session_id,
331            parent_cancellation,
332            total_timeout_ms,
333            started_at,
334        } = options;
335        let mut summary = ParallelRetrySummary {
336            attempts_by_index: vec![0; tasks.len()],
337            ..ParallelRetrySummary::default()
338        };
339        if run.timed_out || run.returned_early || parent_cancellation.is_cancelled() {
340            return summary;
341        }
342
343        for attempt in 0..MAX_TRANSIENT_PARALLEL_RETRIES {
344            let retry_indexes = tasks
345                .iter()
346                .zip(run.results.iter())
347                .enumerate()
348                .filter_map(|(index, (task, result))| {
349                    (!result.success
350                        && self.is_parallel_retry_safe(task)
351                        && is_transient_parallel_failure(&result.output))
352                    .then_some(index)
353                })
354                .collect::<Vec<_>>();
355            if retry_indexes.is_empty() {
356                break;
357            }
358
359            let delay = parallel_retry_delay(attempt);
360            if let Some(remaining) = remaining_parallel_timeout(total_timeout_ms, started_at) {
361                if remaining.is_zero() || delay >= remaining {
362                    break;
363                }
364            }
365            let retry_allowed = tokio::select! {
366                biased;
367                _ = parent_cancellation.cancelled() => false,
368                _ = tokio::time::sleep(delay) => true,
369            };
370            if !retry_allowed {
371                break;
372            }
373
374            let retry_timeout_ms = match remaining_parallel_timeout(total_timeout_ms, started_at) {
375                Some(remaining) if remaining.is_zero() => break,
376                Some(remaining) => {
377                    Some(remaining.as_millis().min(u128::from(u64::MAX)).max(1) as u64)
378                }
379                None => None,
380            };
381            let retry_tasks = retry_indexes
382                .iter()
383                .filter_map(|index| tasks.get(*index).cloned())
384                .collect::<Vec<_>>();
385            let retry_run = self
386                .execute_parallel_for_tool(
387                    retry_tasks,
388                    event_tx.clone(),
389                    ParallelToolOptions {
390                        parent_session_id,
391                        timeout_ms: retry_timeout_ms,
392                        min_success_count: None,
393                        allow_partial_failure: true,
394                        parent_cancellation: Some(parent_cancellation),
395                    },
396                )
397                .await;
398
399            summary.retry_attempt_count = summary
400                .retry_attempt_count
401                .saturating_add(retry_indexes.len());
402            for (index, retry_result) in retry_indexes.into_iter().zip(retry_run.results) {
403                summary.attempts_by_index[index] =
404                    summary.attempts_by_index[index].saturating_add(1);
405                if retry_result.success && !run.results[index].success {
406                    summary.recovered_task_count = summary.recovered_task_count.saturating_add(1);
407                }
408                run.results[index] = retry_result;
409            }
410            if retry_run.timed_out {
411                run.timed_out = true;
412                break;
413            }
414        }
415
416        summary.retried_task_count = summary
417            .attempts_by_index
418            .iter()
419            .filter(|attempts| **attempts > 0)
420            .count();
421        summary
422    }
423
424    fn is_parallel_retry_safe(&self, task: &TaskParams) -> bool {
425        let Some(agent) = self.registry.get(&task.agent) else {
426            return false;
427        };
428        if agent.tool_free {
429            return true;
430        }
431
432        // A retry starts a fresh child run. Require an explicit deny for every
433        // mutation-capable tool; Ask is not safe because a parent confirmation
434        // policy may auto-approve it. This keeps retries to read-only agents.
435        let empty_args = serde_json::json!({});
436        ["write", "edit", "patch", "batch", "bash", "git"]
437            .iter()
438            .all(|tool| agent.permissions.is_denied(tool, &empty_args))
439    }
440}
441
442fn remaining_parallel_timeout(
443    total_timeout_ms: Option<u64>,
444    started_at: std::time::Instant,
445) -> Option<std::time::Duration> {
446    total_timeout_ms.map(|timeout_ms| {
447        std::time::Duration::from_millis(timeout_ms.max(1)).saturating_sub(started_at.elapsed())
448    })
449}
450
451fn parallel_retry_delay(attempt: u8) -> std::time::Duration {
452    let base = PARALLEL_RETRY_BASE_DELAY_MS.saturating_mul(1_u64 << u32::from(attempt.min(3)));
453    let jitter_range = (base / 4).max(1);
454    let entropy = std::time::SystemTime::now()
455        .duration_since(std::time::UNIX_EPOCH)
456        .map(|duration| u64::from(duration.subsec_nanos()))
457        .unwrap_or_default();
458    let jitter = entropy % (jitter_range.saturating_mul(2).saturating_add(1));
459    std::time::Duration::from_millis(base.saturating_sub(jitter_range).saturating_add(jitter))
460}
461
462fn is_transient_parallel_failure(message: &str) -> bool {
463    let lower = message.to_ascii_lowercase();
464    if [
465        "cancelled",
466        "canceled",
467        "permission denied",
468        "not permitted",
469        "unknown agent",
470        "invalid api key",
471        "unauthorized",
472        "forbidden",
473        "quota exhausted",
474        "context length",
475        "structured output failed",
476    ]
477    .iter()
478    .any(|marker| lower.contains(marker))
479    {
480        return false;
481    }
482
483    crate::retry::is_transient_error(&lower)
484        || [
485            "rate limit",
486            "too many requests",
487            "overloaded",
488            "server busy",
489            "temporarily unavailable",
490            "service unavailable",
491            "bad gateway",
492            "gateway timeout",
493            "status 408",
494            "status: 408",
495            "status 429",
496            "status: 429",
497            "status 500",
498            "status: 500",
499            "status 502",
500            "status: 502",
501            "status 503",
502            "status: 503",
503            "status 504",
504            "status: 504",
505            "status 529",
506            "status: 529",
507        ]
508        .iter()
509        .any(|marker| lower.contains(marker))
510}
511
512async fn settle_cancelled_parallel_tasks(
513    join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
514) {
515    const SETTLEMENT_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
516    let deadline = tokio::time::Instant::now() + SETTLEMENT_GRACE;
517    while !join_set.is_empty() {
518        match tokio::time::timeout_at(deadline, join_set.join_next()).await {
519            Ok(Some(_)) => {}
520            Ok(None) => return,
521            Err(_) => break,
522        }
523    }
524
525    if join_set.is_empty() {
526        return;
527    }
528    join_set.abort_all();
529    while join_set.join_next().await.is_some() {}
530}
531
532fn spawn_parallel_task_step(
533    join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
534    executor: Arc<dyn AgentExecutor>,
535    event_tx: Option<broadcast::Sender<AgentEvent>>,
536    index: usize,
537    spec: AgentStepSpec,
538) -> tokio::task::Id {
539    join_set
540        .spawn(async move {
541            let outcome = AssertUnwindSafe(executor.execute_step(spec, event_tx))
542                .catch_unwind()
543                .await
544                .map_err(panic_payload_to_string);
545            (index, outcome)
546        })
547        .id()
548}
549
550fn take_parallel_task_index(
551    active_indexes: &mut HashMap<tokio::task::Id, usize>,
552    task_id: tokio::task::Id,
553) -> Option<usize> {
554    active_indexes.remove(&task_id)
555}
556
557fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
558    if let Some(message) = payload.downcast_ref::<&str>() {
559        return format!("parallel branch panicked: {message}");
560    }
561    if let Some(message) = payload.downcast_ref::<String>() {
562        return format!("parallel branch panicked: {message}");
563    }
564    "parallel branch panicked: unknown panic payload".to_string()
565}
566
567pub(super) struct ParallelTaskRun {
568    pub(super) results: Vec<TaskResult>,
569    pub(super) timed_out: bool,
570    pub(super) returned_early: bool,
571    pub(super) timeout_ms: Option<u64>,
572    pub(super) min_success_count: Option<usize>,
573}
574
575impl From<TaskResult> for StepOutcome {
576    fn from(r: TaskResult) -> Self {
577        StepOutcome {
578            task_id: r.task_id,
579            session_id: r.session_id,
580            agent: r.agent,
581            output: r.output,
582            success: r.success,
583            structured: r.structured,
584            source_anchors: r.source_anchors,
585        }
586    }
587}
588
589impl From<StepOutcome> for TaskResult {
590    fn from(o: StepOutcome) -> Self {
591        TaskResult {
592            output: o.output,
593            session_id: o.session_id,
594            agent: o.agent,
595            success: o.success,
596            task_id: o.task_id,
597            structured: o.structured,
598            source_anchors: o.source_anchors,
599        }
600    }
601}
602
603/// The local, in-process executor: every step runs as a child `AgentLoop` on
604/// this node's tokio runtime. This is the default; a host substitutes
605/// its own [`AgentExecutor`] to place steps across a cluster.
606#[async_trait]
607impl AgentExecutor for TaskExecutor {
608    async fn execute_step(
609        &self,
610        spec: AgentStepSpec,
611        event_tx: Option<broadcast::Sender<AgentEvent>>,
612    ) -> StepOutcome {
613        self.execute_step_with_parent_cancellation(
614            spec,
615            event_tx,
616            self.parent_cancellation.as_ref(),
617        )
618        .await
619    }
620
621    fn concurrency_hint(&self) -> usize {
622        self.max_parallel_tasks
623    }
624}
625
626impl TaskExecutor {
627    async fn execute_step_with_parent_cancellation(
628        &self,
629        spec: AgentStepSpec,
630        event_tx: Option<broadcast::Sender<AgentEvent>>,
631        parent_cancellation: Option<&CancellationToken>,
632    ) -> StepOutcome {
633        let agent = spec.agent.clone();
634        let task_id = spec.task_id.clone();
635        let _permit = match self.acquire_parallel_permit(parent_cancellation).await {
636            Ok(permit) => permit,
637            Err(error) => return StepOutcome::failed(task_id, agent, error),
638        };
639        let params = TaskParams {
640            agent: spec.agent,
641            description: spec.description,
642            prompt: spec.prompt,
643            background: false,
644            max_steps: spec.max_steps,
645            output_schema: spec.output_schema,
646        };
647        match self
648            .execute_with_task_id_scoped(
649                task_id.clone(),
650                params,
651                event_tx,
652                spec.parent_session_id.as_deref(),
653                true,
654                parent_cancellation,
655            )
656            .await
657        {
658            Ok(result) => result.into(),
659            Err(e) => StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
660        }
661    }
662
663    async fn acquire_parallel_permit(
664        &self,
665        parent_cancellation: Option<&CancellationToken>,
666    ) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, String> {
667        let acquire = Arc::clone(&self.parallel_permits).acquire_owned();
668        match parent_cancellation {
669            Some(cancellation) => {
670                tokio::select! {
671                    biased;
672                    _ = cancellation.cancelled() => {
673                        Err("Task cancelled while waiting for parallel provider capacity".to_string())
674                    }
675                    permit = acquire => permit.map_err(|error| {
676                        format!("Parallel provider capacity closed unexpectedly: {error}")
677                    }),
678                }
679            }
680            None => acquire.await.map_err(|error| {
681                format!("Parallel provider capacity closed unexpectedly: {error}")
682            }),
683        }
684    }
685
686    /// Coerce a step's free-text output into a JSON object validated against
687    /// `schema`, reusing the structured-output machinery with built-in repair.
688    /// This is one extra LLM call beyond the step's own run.
689    pub(super) async fn coerce_to_schema(
690        llm_client: &dyn LlmClient,
691        output: &str,
692        schema: serde_json::Value,
693        cancellation: &CancellationToken,
694    ) -> Result<serde_json::Value> {
695        let req = StructuredRequest {
696            prompt: format!(
697                "Convert the following task result into a single JSON object that conforms to \
698                 the required schema. Use only information present in the result.\n\n\
699                 --- TASK RESULT ---\n{output}"
700            ),
701            system: Some(
702                "You output exactly one JSON object matching the provided schema.".to_string(),
703            ),
704            schema,
705            schema_name: "step_output".to_string(),
706            schema_description: None,
707            // Request tool mode when available; unknown providers safely
708            // downgrade to prompt+schema parsing.
709            mode: StructuredMode::Tool,
710            max_repair_attempts: 2,
711        };
712        let result = tokio::select! {
713            biased;
714            _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
715            result = generate_blocking(llm_client, &req) => result?,
716        };
717        Ok(result.object)
718    }
719
720    pub(super) async fn generate_structured_task(
721        llm_client: &dyn LlmClient,
722        prompt: &str,
723        system: Option<&str>,
724        schema: serde_json::Value,
725        cancellation: &CancellationToken,
726    ) -> Result<serde_json::Value> {
727        let req = StructuredRequest {
728            prompt: prompt.to_string(),
729            system: Some(format!(
730                "{}\n\nReturn exactly one JSON object matching the provided schema.",
731                system.unwrap_or("Make the requested structured decision without tools.")
732            )),
733            schema,
734            schema_name: "step_output".to_string(),
735            schema_description: None,
736            mode: StructuredMode::Tool,
737            max_repair_attempts: 2,
738        };
739        let result = tokio::select! {
740            biased;
741            _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
742            result = generate_blocking(llm_client, &req) => result?,
743        };
744        Ok(result.object)
745    }
746}
747
748struct ScopedTaskExecutor {
749    executor: Arc<TaskExecutor>,
750    parent_cancellation: CancellationToken,
751}
752
753#[async_trait]
754impl AgentExecutor for ScopedTaskExecutor {
755    async fn execute_step(
756        &self,
757        spec: AgentStepSpec,
758        event_tx: Option<broadcast::Sender<AgentEvent>>,
759    ) -> StepOutcome {
760        self.executor
761            .execute_step_with_parent_cancellation(spec, event_tx, Some(&self.parent_cancellation))
762            .await
763    }
764
765    fn concurrency_hint(&self) -> usize {
766        self.executor.max_parallel_tasks
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[tokio::test]
775    async fn aborted_join_keeps_the_spawned_branch_index() {
776        let mut join_set = JoinSet::new();
777        let handle = join_set.spawn(async {
778            std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
779        });
780        let mut active_indexes = HashMap::from([(handle.id(), 7)]);
781        handle.abort();
782
783        let error = join_set
784            .join_next_with_id()
785            .await
786            .expect("aborted task should settle")
787            .expect_err("aborted task should return JoinError");
788
789        assert_eq!(
790            take_parallel_task_index(&mut active_indexes, error.id()),
791            Some(7)
792        );
793        assert!(active_indexes.is_empty());
794    }
795
796    #[test]
797    fn transient_retry_classifier_rejects_deterministic_failures() {
798        assert!(is_transient_parallel_failure(
799            "LLM request failed with status 529: overloaded"
800        ));
801        assert!(is_transient_parallel_failure("connection reset by peer"));
802        assert!(!is_transient_parallel_failure(
803            "permission denied while writing src/main.rs"
804        ));
805        assert!(!is_transient_parallel_failure(
806            "structured output failed schema validation"
807        ));
808        assert!(!is_transient_parallel_failure(
809            "invalid API key returned status 503"
810        ));
811    }
812}