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 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(¶llel_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 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 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#[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 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 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}