use std::sync::Arc;
use std::sync::atomic::Ordering;
use rust_decimal::Decimal;
use serde_json::json;
use tokio::spawn;
use tokio::time::timeout;
use ironflow_engine::engine::EnqueueOptions;
use ironflow_engine::error::{EngineError, RUN_BUDGET_EXCEEDED_CODE};
use ironflow_engine::guard::{WorkflowGuardConfig, WorkflowRejection};
use ironflow_engine::plan::PlanOptions;
use ironflow_store::memory::InMemoryStore;
use ironflow_store::models::{RunStatus, StepKind, StepStatus};
use crate::fixture::{
Action, GatedProvider, Kit, LEAF, SPEND_USD, TEST_TIMEOUT, WAVE, WORKER, load_run,
mock_provider, runs_of, start, task, wait_until, wave_engine, worker_run, workflow_steps,
};
fn completed(output: &str) -> String {
format!("{}:{output}", RunStatus::Completed)
}
#[tokio::test]
async fn concurrent_children_of_a_wave_each_wait_for_the_other_to_start() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(2);
let entries = vec![task("alice", Action::Meet), task("bob", Action::Meet)];
let (engine, outcomes) = wave_engine(&store, mock_provider(), &kit, entries, false);
let (parent_id, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
result.expect("the wave completes");
assert_eq!(
*outcomes.lock().expect("outcomes"),
vec![format!("{},{}", completed("alice"), completed("bob"))]
);
let steps = workflow_steps(&store, parent_id).await;
let names: Vec<&str> = steps.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["delegate-alice", "delegate-bob"]);
assert!(steps.iter().all(|s| s.position == 0), "one wave position");
assert!(
steps
.iter()
.all(|s| s.status.state == StepStatus::Completed)
);
assert_eq!(runs_of(&store, WORKER).await.len(), 2);
assert_eq!(
load_run(&store, parent_id).await.status.state,
RunStatus::Completed
);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn duplicate_step_names_refuse_the_wave_before_anything_runs() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let (alice, options) = task("alice", Action::Done);
let (bob, _) = task("bob", Action::Done);
let entries = vec![(alice, options.clone()), (bob, options)];
let (engine, outcomes) = wave_engine(&store, mock_provider(), &kit, entries, false);
let (parent_id, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
match result {
Err(EngineError::StepConfig(message)) => {
assert!(message.contains("delegate-alice"), "{message}");
}
other => panic!("expected a step config error, got {other:?}"),
}
assert!(workflow_steps(&store, parent_id).await.is_empty());
assert!(runs_of(&store, WORKER).await.is_empty());
assert!(outcomes.lock().expect("outcomes").is_empty());
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn an_empty_wave_records_nothing_and_the_parent_goes_on() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let (engine, outcomes) = wave_engine(&store, mock_provider(), &kit, Vec::new(), false);
let (parent_id, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
result.expect("the parent completes");
assert!(workflow_steps(&store, parent_id).await.is_empty());
assert_eq!(*outcomes.lock().expect("outcomes"), vec![String::new()]);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn a_key_held_by_an_earlier_sibling_gives_a_conflict_for_the_later_one() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let entries = [("alice", Action::Done), ("bob", Action::Done)]
.into_iter()
.map(|(agent, action)| {
let (input, options) = task(agent, action);
(input, options.concurrency_key("issue:7"))
})
.collect();
let (engine, outcomes) = wave_engine(&store, mock_provider(), &kit, entries, false);
let (parent_id, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
result.expect("a conflict does not fail the parent");
let alice = worker_run(&store, "alice").await;
assert_eq!(runs_of(&store, WORKER).await.len(), 1, "no run for bob");
assert_eq!(
*outcomes.lock().expect("outcomes"),
vec![format!("{},conflict:{}", completed("alice"), alice.id)]
);
let bob_step = workflow_steps(&store, parent_id).await.remove(1);
assert_eq!(bob_step.name, "delegate-bob");
assert_eq!(bob_step.status.state, StepStatus::Completed);
assert_eq!(
bob_step.output.expect("output")["concurrency_conflict"]["run_id"],
json!(alice.id)
);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn a_cap_with_room_for_one_child_cancels_the_other_with_run_budget_exceeded() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let provider = GatedProvider::new(&kit);
let entries = vec![
task("alice", Action::Spend { after_first: false }),
task("bob", Action::Spend { after_first: true }),
];
let (engine, _) = wave_engine(&store, provider.clone(), &kit, entries, false);
let engine = Arc::new(engine);
let cap = Decimal::new(15, 2);
let options = EnqueueOptions {
max_cost_usd: Some(cap),
..EnqueueOptions::default()
};
let wave = {
let engine = engine.clone();
let store = store.clone();
spawn(async move { start(&engine, &store, WAVE, options).await })
};
wait_until(|| async {
runs_of(&store, WORKER)
.await
.iter()
.any(|r| r.payload["agent"] == "bob" && r.status.state == RunStatus::Cancelled)
})
.await;
provider.release();
let (parent_id, result) = wave.await.expect("the wave task");
assert!(matches!(result, Err(EngineError::RunBudgetExceeded { .. })));
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
let alice = worker_run(&store, "alice").await;
assert_eq!(alice.status.state, RunStatus::Completed);
let bob = worker_run(&store, "bob").await;
assert_eq!(bob.status.state, RunStatus::Cancelled);
assert!(
bob.error
.as_deref()
.is_some_and(|e| e.contains(RUN_BUDGET_EXCEEDED_CODE)),
"{:?}",
bob.error
);
let parent = load_run(&store, parent_id).await;
assert_eq!(parent.status.state, RunStatus::Cancelled);
assert!(parent.cost_usd <= cap, "spent {}", parent.cost_usd);
assert_eq!(
parent.cost_usd,
Decimal::try_from(SPEND_USD).expect("decimal")
);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn a_failure_not_tolerated_is_returned_after_every_sibling_is_recorded() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let (alice, options) = task("alice", Action::Fail);
let entries = vec![
(alice, options.allow_failure()),
task("bob", Action::Fail),
task("carol", Action::Report),
];
let (engine, outcomes) = wave_engine(&store, mock_provider(), &kit, entries, false);
let (parent_id, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
match result {
Err(EngineError::InvalidWorkflow(message)) => assert_eq!(message, "bob refused"),
other => panic!("expected bob's failure, got {other:?}"),
}
let steps = workflow_steps(&store, parent_id).await;
assert_eq!(steps.len(), 3);
assert_eq!(steps[0].status.state, StepStatus::Completed);
let tolerated = steps[0].output.as_ref().expect("alice's output");
assert_eq!(tolerated["status"], json!(RunStatus::Failed));
assert_eq!(steps[1].status.state, StepStatus::Failed);
assert!(
steps[1]
.error
.as_deref()
.is_some_and(|e| e.contains("bob refused"))
);
assert_eq!(steps[2].status.state, StepStatus::Completed);
assert_eq!(
worker_run(&store, "carol").await.status.state,
RunStatus::Completed
);
assert_eq!(
load_run(&store, parent_id).await.status.state,
RunStatus::Failed
);
assert!(outcomes.lock().expect("outcomes").is_empty());
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn plan_records_the_wave_as_one_parallel_group_with_every_child_expanded() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let entries = vec![task("alice", Action::Report), task("bob", Action::Report)];
let (engine, _) = wave_engine(&store, mock_provider(), &kit, entries, false);
let plan = engine
.plan_handler(WAVE, json!({}), PlanOptions::default())
.await
.expect("plan");
let waves: Vec<_> = plan
.steps
.iter()
.filter(|s| s.kind == StepKind::Workflow)
.collect();
assert_eq!(waves.len(), 2);
assert_eq!(waves[0].name, "delegate-alice");
assert_eq!(waves[1].name, "delegate-bob");
assert!(waves[0].parallel_group.is_some());
assert_eq!(waves[0].parallel_group, waves[1].parallel_group);
assert!(
waves[1].depends_on.is_empty(),
"siblings do not wait on each other"
);
let reports: Vec<_> = plan.steps.iter().filter(|s| s.name == "report").collect();
assert_eq!(reports.len(), 2, "every child is expanded");
assert_eq!(reports[0].depends_on, vec!["delegate-alice".to_string()]);
assert_eq!(reports[1].depends_on, vec!["delegate-bob".to_string()]);
assert!(reports.iter().all(|s| s.depth == 1 && s.workflow == WORKER));
assert!(runs_of(&store, WORKER).await.is_empty());
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn the_guard_counts_each_child_and_refuses_a_wave_past_max_fan_out() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let entries = vec![task("alice", Action::Done), task("bob", Action::Done)];
let (engine, _) = wave_engine(&store, mock_provider(), &kit, entries, false);
let engine = engine.with_guard_config(WorkflowGuardConfig::new().with_max_fan_out(1));
let (parent_id, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
match result {
Err(EngineError::WorkflowGuardRejected(WorkflowRejection::MaxFanOutExceeded {
invocations,
max,
})) => assert_eq!((invocations, max), (2, 1)),
other => panic!("expected a fan-out rejection, got {other:?}"),
}
assert!(workflow_steps(&store, parent_id).await.is_empty());
assert!(runs_of(&store, WORKER).await.is_empty());
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn the_guard_gives_each_sibling_its_own_depth_and_chain_and_shares_the_totals() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let entries = vec![
task("alice", Action::Nest),
task("bob", Action::Nest),
task("carol", Action::Nest),
];
let (engine, _) = wave_engine(&store, mock_provider(), &kit, entries, false);
let config = WorkflowGuardConfig::new()
.with_max_depth(2)
.with_max_fan_out(6);
let engine = engine.with_guard_config(config);
let (_, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
result.expect("every sibling fits on its own branch");
assert_eq!(runs_of(&store, WORKER).await.len(), 3);
assert_eq!(runs_of(&store, LEAF).await.len(), 3);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn the_guard_counts_the_children_of_siblings_against_one_fan_out() {
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let kit = Kit::new(1);
let entries = vec![task("alice", Action::Nest), task("bob", Action::Nest)];
let (engine, _) = wave_engine(&store, mock_provider(), &kit, entries, false);
let engine = engine.with_guard_config(WorkflowGuardConfig::new().with_max_fan_out(3));
let (parent_id, result) = start(&engine, &store, WAVE, EnqueueOptions::default()).await;
match result {
Err(EngineError::WorkflowGuardRejected(WorkflowRejection::MaxFanOutExceeded {
invocations,
max,
})) => assert_eq!((invocations, max), (3, 3)),
other => panic!("expected the second leaf to be refused, got {other:?}"),
}
assert_eq!(runs_of(&store, LEAF).await.len(), 1);
assert_eq!(
load_run(&store, parent_id).await.status.state,
RunStatus::Cancelled
);
})
.await
.expect("test timed out");
}