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