1use std::collections::{HashMap, HashSet};
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde_json::to_value;
8use tokio::task::{Id, JoinSet};
9use tokio::time::timeout;
10use tracing::{error, info};
11use uuid::Uuid;
12
13use ironflow_core::error::{AgentError, OperationError};
14use ironflow_store::models::{NewStep, StepStatus, StepUpdate, step_trace_id};
15
16use crate::budget::step_budget_usd;
17use crate::config::StepConfig;
18use crate::context::WorkflowContext;
19use crate::context::failure::{
20 allowed_failure_output, extract_debug_messages_from_error, extract_partial_usage_from_error,
21 extract_raw_response_from_error, replayed_failure_output,
22};
23use crate::context::lifecycle::check_replay_identity;
24use crate::error::EngineError;
25use crate::executor::{
26 ParallelStepResult, StepArtifacts, StepOutput, StepResult, execute_step_config_intercepted,
27};
28use crate::log_sender::StepLogSender;
29use crate::notify::{WorkflowAgentStepTokensUsedEvent, WorkflowEvent};
30use crate::plan::{lock_plan, planned_output};
31
32impl WorkflowContext {
33 pub async fn parallel(
82 &mut self,
83 steps: Vec<(&str, StepConfig)>,
84 fail_fast: bool,
85 ) -> Result<Vec<ParallelStepResult>, EngineError> {
86 if steps.is_empty() {
87 return Ok(Vec::new());
88 }
89 reject_duplicate_names(&steps)?;
90
91 if let Some(plan) = self.plan().cloned() {
94 self.position += 1;
95 let mut results = Vec::with_capacity(steps.len());
96 let mut names = Vec::with_capacity(steps.len());
97 let capped = {
98 let mut recorder = lock_plan(&plan);
99 let group = recorder.next_group();
100 let mut capped = None;
101 for (name, config) in &steps {
102 let wave = Some(group.clone());
103 if let Err(err) =
104 recorder.record_or_stop(name, config.kind(), &self.workflow_name, wave)
105 {
106 capped = Some(err);
107 break;
108 }
109 names.push((*name).to_string());
110 let estimate = recorder.estimate_for(name);
111 let mut output = planned_output(config, estimate);
112 output.artifacts = StepArtifacts::new(name, None, config.declared_outputs());
113 results.push(ParallelStepResult {
114 name: (*name).to_string(),
115 output,
116 step_id: Uuid::now_v7(),
117 });
118 }
119 recorder.set_last(names);
120 capped
121 };
122 return match capped {
124 Some(err) => Err(err),
125 None => Ok(results),
126 };
127 }
128
129 self.check_guard_timeout()?;
131
132 let wave_position = self.position;
133 self.position += 1;
134
135 let mut slots = self.replay_wave(wave_position, &steps)?;
141 if slots.iter().all(Option::is_some) {
142 let results: Vec<ParallelStepResult> = slots.into_iter().flatten().collect();
143 self.last_step_ids = results.iter().map(|r| r.step_id).collect();
144 return Ok(results);
145 }
146
147 let wave_budget: Decimal = steps
150 .iter()
151 .zip(&slots)
152 .filter(|(_, slot)| slot.is_none())
153 .filter_map(|((_, config), _)| match config {
154 StepConfig::Agent(agent_config) => Some(agent_config.max_budget_usd),
155 _ => None,
156 })
157 .map(step_budget_usd)
158 .sum();
159 let _reservation = self.check_run_budget(wave_budget)?;
161
162 let now = Utc::now();
163 let mut step_records: Vec<(Uuid, Uuid, String, StepConfig)> =
164 Vec::with_capacity(steps.len());
165 let mut record_slots: Vec<usize> = Vec::with_capacity(steps.len());
167
168 for (slot, (name, config)) in steps.iter().enumerate() {
169 if slots[slot].is_some() {
170 continue;
171 }
172 let kind = config.kind();
173 let trace_id = step_trace_id(self.run_id, name, wave_position);
174 let step = self
175 .store
176 .create_step(NewStep {
177 run_id: self.run_id,
178 trace_id,
179 name: name.to_string(),
180 kind,
181 position: wave_position,
182 input: Some(to_value(config)?),
183 is_error_handler: false,
184 })
185 .await?;
186
187 self.start_step(step.id, now).await?;
188
189 if let Err(err) = self.prepare_step_inputs(config, wave_position).await {
192 self.fail_step(step.id, &err).await;
193 if !config.allow_failure() {
194 return Err(err);
195 }
196 self.has_allowed_failure = true;
197 info!(
198 run_id = %self.run_id,
199 step = %name,
200 error = %err,
201 "parallel step input preparation failed but allow_failure is set, skipping"
202 );
203 continue;
204 }
205
206 let mut config_with_trace = config.clone();
207 self.scope_step_config(&mut config_with_trace, name);
208 self.carry_capacity_wait_since(&mut config_with_trace, wave_position, name)
209 .await?;
210 self.assign_agent_session(&mut config_with_trace, step.id, wave_position, name)
211 .await?;
212 step_records.push((step.id, trace_id, name.to_string(), config_with_trace));
213 record_slots.push(slot);
214 }
215
216 let mut join_set = JoinSet::new();
217 let mut task_index: HashMap<Id, usize> = HashMap::new();
218 let parallel_limit = self.guard_time_limit();
219 for (idx, (step_id, _trace_id, step_name, config)) in step_records.iter().enumerate() {
220 let provider = self.provider.clone();
221 let interceptor = self.interceptor.clone();
224 let config = config.clone();
225 let step_log_sender = self
226 .log_sender
227 .as_ref()
228 .map(|s| StepLogSender::new(s.clone(), self.run_id, *step_id, step_name.clone()));
229 let limit = parallel_limit.clone();
230 let handle = join_set.spawn(async move {
231 let result = match limit {
232 Some((dur, rejection)) => {
233 match timeout(
234 dur,
235 execute_step_config_intercepted(
236 &config,
237 &provider,
238 step_log_sender,
239 interceptor.as_ref(),
240 ),
241 )
242 .await
243 {
244 Ok(r) => r,
245 Err(_elapsed) => Err(EngineError::from(rejection)),
246 }
247 }
248 None => {
249 execute_step_config_intercepted(
250 &config,
251 &provider,
252 step_log_sender,
253 interceptor.as_ref(),
254 )
255 .await
256 }
257 };
258 (idx, result)
259 });
260 task_index.insert(handle.id(), idx);
261 }
262
263 let mut indexed_results: Vec<Option<Result<StepOutput, String>>> =
265 vec![None; step_records.len()];
266 let mut first_error: Option<EngineError> = None;
267 let mut capacity_wait: Option<(Uuid, String, DateTime<Utc>)> = None;
270
271 while let Some(join_result) = join_set.join_next().await {
272 let (idx, step_result) = match join_result {
273 Ok(r) => r,
274 Err(e) => {
275 let error_msg = format!("join error: {e}");
276 if let Some(&idx) = task_index.get(&e.id()) {
277 let (step_id, _, step_name, _) = &step_records[idx];
278 let completed_at = Utc::now();
279 error!(
280 run_id = %self.run_id,
281 step = %step_name,
282 error = %error_msg,
283 "parallel step panicked or was cancelled"
284 );
285 if let Err(store_err) = self
286 .store
287 .update_step(
288 *step_id,
289 StepUpdate {
290 status: Some(StepStatus::Failed),
291 error: Some(error_msg.clone()),
292 completed_at: Some(completed_at),
293 ..StepUpdate::default()
294 },
295 )
296 .await
297 {
298 error!(
299 run_id = %self.run_id,
300 step_id = %step_id,
301 error = %store_err,
302 "failed to persist JoinError for step"
303 );
304 }
305 indexed_results[idx] = Some(Err(error_msg.clone()));
306 }
307 if first_error.is_none() {
308 first_error = Some(EngineError::StepConfig(error_msg));
309 }
310 if fail_fast {
311 join_set.abort_all();
312 }
313 continue;
314 }
315 };
316
317 let (step_id, step_trace, step_name, step_config) = &step_records[idx];
318 let completed_at = Utc::now();
319
320 if let Err(err) = self
321 .store_step_outputs(step_config, *step_id, step_name, step_result.is_ok())
322 .await
323 {
324 self.fail_step(*step_id, &err).await;
325 indexed_results[idx] = Some(Err(err.to_string()));
326 if first_error.is_none() {
327 first_error = Some(err);
328 }
329 if fail_fast {
330 join_set.abort_all();
331 }
332 continue;
333 }
334
335 match step_result {
336 Ok(output) => {
337 self.charge_cost(output.cost_usd);
338 self.total_duration_ms += output.duration_ms;
339
340 if matches!(step_config, StepConfig::Agent(_)) {
342 let tokens = output.total_tokens();
343 if tokens > 0
344 && let Err(guard_err) = self.guard_record_tokens(tokens)
345 {
346 if first_error.is_none() {
347 first_error = Some(guard_err);
348 }
349 if fail_fast {
350 join_set.abort_all();
351 }
352 }
353 }
354
355 let debug_messages_json = output.debug_messages_json();
356
357 self.store
358 .update_step(
359 *step_id,
360 StepUpdate {
361 status: Some(StepStatus::Completed),
362 output: Some(output.output.clone()),
363 duration_ms: Some(output.duration_ms),
364 cost_usd: Some(output.cost_usd),
365 input_tokens: output.input_tokens,
366 cache_read_input_tokens: output.cache_read_input_tokens,
367 cache_creation_input_tokens: output.cache_creation_input_tokens,
368 output_tokens: output.output_tokens,
369 completed_at: Some(completed_at),
370 debug_messages: debug_messages_json,
371 account_id: output.account_id,
372 environment_id: output.environment_id.clone(),
373 ..StepUpdate::default()
374 },
375 )
376 .await?;
377
378 self.step_results.push(StepResult::from_success(
379 *step_trace,
380 step_name,
381 &output,
382 ));
383
384 if let Some(ref bus) = self.event_bus
385 && matches!(step_config, StepConfig::Agent(_))
386 {
387 let tokens = output.total_tokens();
388 bus.publish(
389 self.run_id,
390 WorkflowEvent::AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent {
391 step_name: step_name.clone(),
392 tokens,
393 cost_usd: output.cost_usd,
394 }),
395 );
396 }
397
398 info!(
399 run_id = %self.run_id,
400 step = %step_name,
401 trace_id = %step_trace,
402 duration_ms = output.duration_ms,
403 "parallel step completed"
404 );
405
406 indexed_results[idx] = Some(Ok(output));
407 }
408 Err(EngineError::Operation(OperationError::Agent(AgentError::CapacityWait {
409 kind,
410 wake_at,
411 }))) => {
412 self.park_capacity_step(*step_id).await?;
414 info!(
415 run_id = %self.run_id,
416 step = %step_name,
417 kind = %kind,
418 wake_at = %wake_at,
419 "no provider capacity, parallel step parked until the run wakes"
420 );
421 if capacity_wait
422 .as_ref()
423 .is_none_or(|(_, _, earliest)| wake_at < *earliest)
424 {
425 capacity_wait = Some((*step_id, kind, wake_at));
426 }
427 }
428 Err(err) => {
429 let err_msg = err.to_string();
430 let debug_messages_json = extract_debug_messages_from_error(&err);
431 let partial = extract_partial_usage_from_error(&err);
432 let raw_response_output = extract_raw_response_from_error(&err);
433
434 if let Some(ref usage) = partial {
435 if let Some(cost) = usage.cost_usd {
436 self.charge_cost(cost);
437 }
438 if let Some(dur) = usage.duration_ms {
439 self.total_duration_ms += dur;
440 }
441 }
442
443 if let Err(store_err) = self
444 .store
445 .update_step(
446 *step_id,
447 StepUpdate {
448 status: Some(StepStatus::Failed),
449 error: Some(err_msg.clone()),
450 output: raw_response_output.clone(),
451 completed_at: Some(completed_at),
452 debug_messages: debug_messages_json,
453 duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
454 cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
455 input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
456 cache_read_input_tokens: partial
457 .as_ref()
458 .and_then(|p| p.cache_read_input_tokens),
459 cache_creation_input_tokens: partial
460 .as_ref()
461 .and_then(|p| p.cache_creation_input_tokens),
462 output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
463 ..StepUpdate::default()
464 },
465 )
466 .await
467 {
468 error!(
469 step_id = %step_id,
470 error = %store_err,
471 "failed to persist parallel step failure"
472 );
473 }
474
475 let err_duration = partial.as_ref().and_then(|p| p.duration_ms).unwrap_or(0);
476 let err_cost = partial
477 .as_ref()
478 .and_then(|p| p.cost_usd)
479 .unwrap_or(Decimal::ZERO);
480 self.step_results.push(StepResult::from_failure(
481 *step_trace,
482 step_name,
483 &err_msg,
484 err_duration,
485 err_cost,
486 ));
487
488 if step_config.allow_failure() {
489 self.has_allowed_failure = true;
490 info!(
491 run_id = %self.run_id,
492 step = %step_name,
493 error = %err_msg,
494 "parallel step failed but allow_failure is set, continuing"
495 );
496 indexed_results[idx] = Some(Ok(allowed_failure_output(
497 &err_msg,
498 raw_response_output,
499 partial.as_ref(),
500 )));
501 } else {
502 indexed_results[idx] = Some(Err(err_msg.clone()));
503
504 if first_error.is_none() {
505 first_error = Some(err);
506 }
507
508 if fail_fast {
509 join_set.abort_all();
510 }
511 }
512 }
513 }
514 }
515
516 if let Some(err) = first_error {
517 return Err(err);
518 }
519
520 if let Some((step_id, kind, wake_at)) = capacity_wait {
521 return Err(EngineError::CapacitySleeping {
522 run_id: self.run_id,
523 step_id,
524 kind,
525 wake_at,
526 });
527 }
528
529 self.persist_progress().await;
530
531 for (idx, (step_id, _trace_id, name, config)) in step_records.iter().enumerate() {
533 let mut output = match indexed_results[idx].take() {
534 Some(Ok(o)) => o,
535 _ => unreachable!("all steps succeeded if no error returned"),
536 };
537 output.artifacts = StepArtifacts::new(name, Some(*step_id), config.declared_outputs());
538 slots[record_slots[idx]] = Some(ParallelStepResult {
539 name: name.clone(),
540 output,
541 step_id: *step_id,
542 });
543 }
544 let results: Vec<ParallelStepResult> = slots.into_iter().flatten().collect();
545 self.last_step_ids = results.iter().map(|r| r.step_id).collect();
546
547 Ok(results)
548 }
549
550 fn replay_wave(
559 &mut self,
560 position: u32,
561 steps: &[(&str, StepConfig)],
562 ) -> Result<Vec<Option<ParallelStepResult>>, EngineError> {
563 let mut slots = Vec::with_capacity(steps.len());
564 for (name, config) in steps {
565 let Some(step) = self.replay_wave_steps.get(&(position, (*name).to_string())) else {
566 slots.push(None);
567 continue;
568 };
569 check_replay_identity(step, position, name, &config.kind())?;
570 let mut output = match step.status.state {
571 StepStatus::Completed => StepOutput::from(step),
572 StepStatus::Failed if config.allow_failure() => {
573 let Some(output) = replayed_failure_output(step) else {
574 slots.push(None);
575 continue;
576 };
577 self.has_allowed_failure = true;
578 output
579 }
580 _ => {
581 slots.push(None);
582 continue;
583 }
584 };
585 let step_id = step.id;
586 self.total_duration_ms += output.duration_ms;
590 output.artifacts = StepArtifacts::new(name, Some(step_id), config.declared_outputs());
591
592 info!(
593 run_id = %self.run_id,
594 step = %name,
595 position,
596 "step replayed from previous execution"
597 );
598
599 slots.push(Some(ParallelStepResult {
600 name: (*name).to_string(),
601 output,
602 step_id,
603 }));
604 }
605 Ok(slots)
606 }
607}
608
609fn reject_duplicate_names(steps: &[(&str, StepConfig)]) -> Result<(), EngineError> {
615 let mut seen = HashSet::with_capacity(steps.len());
616 for (name, _) in steps {
617 if !seen.insert(*name) {
618 return Err(EngineError::StepConfig(format!(
619 "parallel wave has two steps named {name:?}; each step of a wave needs its own name"
620 )));
621 }
622 }
623 Ok(())
624}