Skip to main content

beam_core/
workflow_orchestrator.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use serde_json::Value;
4
5use crate::workflow_snapshot::{ActivityStatus, NodeStatus};
6use crate::{
7    ActivityState, HumanGate, LoopIterationStatus, LoopNode, LoopStatus, NodeState, RunSnapshotDTO,
8    WorkflowDefinition, WorkflowNode, WorkflowOutputRef,
9};
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum OrchestratorAction {
13    DispatchGate {
14        node_id: String,
15        activity_id: String,
16        human_gate: HumanGate,
17    },
18    DispatchWork {
19        node_id: String,
20        activity_id: String,
21        node: WorkflowNode,
22    },
23    CompleteNodeSucceeded {
24        node_id: String,
25        last_activity_id: String,
26        output_ref: Option<WorkflowOutputRef>,
27    },
28    CompleteNodeFailed {
29        node_id: String,
30        last_activity_id: String,
31        error_class: String,
32    },
33    CompleteRunSucceeded {
34        output_ref: WorkflowOutputRef,
35        sink_node_id: String,
36    },
37    CompleteRunFailed {
38        failed_node_id: String,
39    },
40    /// Begin executing a loop node (writes `loopStarted`).
41    StartLoop {
42        node_id: String,
43        max_iterations: u64,
44    },
45    /// Begin a single iteration of a loop (writes `loopIterationStarted`).
46    StartLoopIteration {
47        node_id: String,
48        iteration: u64,
49    },
50    /// Finish a single iteration of a loop (writes `loopIterationFinished`).
51    FinishLoopIteration {
52        node_id: String,
53        iteration: u64,
54        /// Resolution for this iteration: `approved`, `rejected`, `failed`, or `cancelled`.
55        resolution: String,
56        decision_activity_id: Option<String>,
57        wait_resolved_event_id: Option<String>,
58        by: Option<String>,
59        comment: Option<String>,
60        timed_out: Option<bool>,
61    },
62    /// Finish the entire loop node (writes `loopFinished`).
63    FinishLoop {
64        node_id: String,
65        final_iteration: u64,
66        /// Resolution for the overall loop: `approved`, `failed`, or `cancelled`.
67        resolution: String,
68        output_ref: Option<WorkflowOutputRef>,
69        error_code: Option<String>,
70        error_class: Option<String>,
71    },
72}
73
74impl OrchestratorAction {
75    pub fn is_dispatch(&self) -> bool {
76        matches!(self, Self::DispatchGate { .. } | Self::DispatchWork { .. })
77    }
78}
79
80pub fn decide_next_actions(
81    snapshot: &RunSnapshotDTO,
82    def: &WorkflowDefinition,
83) -> Vec<OrchestratorAction> {
84    if matches!(
85        snapshot.run.status,
86        crate::RunStatus::Succeeded | crate::RunStatus::Failed | crate::RunStatus::Cancelled
87    ) {
88        return Vec::new();
89    }
90    if snapshot.run.cancelled_run_intent.is_some() {
91        return Vec::new();
92    }
93
94    let order = topological_order(def);
95    let body_owner = build_body_owner_map(def);
96    let mut actions = Vec::new();
97    let mut failed_node_id: Option<String> = None;
98    let mut pending_count: usize = 0;
99
100    for node_id in order {
101        if body_owner.contains_key(&node_id) {
102            continue;
103        }
104        let Some(node) = def.nodes.get(&node_id) else {
105            continue;
106        };
107        // ── Loop dispatch ──
108        if let WorkflowNode::Loop(loop_node) = node {
109            let deps_ok = node_depends(node)
110                .iter()
111                .all(|dep| dependency_is_succeeded(snapshot, dep));
112            if !deps_ok {
113                pending_count += 1;
114                continue;
115            }
116            let advance = decide_loop_advancement(snapshot, def, &node_id, loop_node);
117            if advance.is_succeeded {
118                actions.extend(advance.actions);
119                continue;
120            }
121            if advance.is_failed {
122                failed_node_id.get_or_insert(node_id.clone());
123                actions.extend(advance.actions);
124                continue;
125            }
126            if advance.actions.is_empty() {
127                pending_count += 1;
128            } else {
129                actions.extend(advance.actions);
130            }
131            continue;
132        }
133
134        if let Some(node_state) = node_state(snapshot, &node_id) {
135            if matches!(
136                node_state.status,
137                NodeStatus::Succeeded | NodeStatus::Skipped | NodeStatus::Cancelled
138            ) {
139                continue;
140            }
141            if node_state.status == NodeStatus::Failed {
142                failed_node_id.get_or_insert(node_id.clone());
143                continue;
144            }
145        }
146
147        let deps_ok = node_depends(node)
148            .iter()
149            .all(|dep| dependency_is_succeeded(snapshot, dep));
150        if !deps_ok {
151            pending_count += 1;
152            continue;
153        }
154
155        let gate_id = gate_activity_id(&snapshot.run.run_id, &node_id);
156        let work_id = work_activity_id(&snapshot.run.run_id, &node_id);
157        let advance = decide_node_advancement(snapshot, node, &node_id, &gate_id, &work_id);
158        if advance.is_succeeded {
159            actions.extend(advance.actions);
160            continue;
161        }
162        if advance.is_failed {
163            actions.extend(advance.actions);
164            continue;
165        }
166        if advance.actions.is_empty() {
167            pending_count += 1;
168        } else {
169            actions.extend(advance.actions);
170        }
171    }
172
173    if actions.is_empty() {
174        if let Some(node_id) = failed_node_id {
175            return vec![OrchestratorAction::CompleteRunFailed {
176                failed_node_id: node_id,
177            }];
178        }
179        if pending_count == 0 {
180            let sinks = find_sinks(def);
181            if sinks.len() == 1 {
182                let sink_id = sinks[0].clone();
183                let sink_output_id = format!("{}::work::{}", snapshot.run.run_id, sink_id);
184                if let Some(output_ref) = snapshot.outputs.get(&sink_output_id) {
185                    return vec![OrchestratorAction::CompleteRunSucceeded {
186                        output_ref: output_ref.clone(),
187                        sink_node_id: sink_id,
188                    }];
189                }
190            }
191        }
192    }
193
194    actions
195}
196
197#[derive(Debug, Clone, PartialEq)]
198struct AdvanceDecision {
199    actions: Vec<OrchestratorAction>,
200    is_succeeded: bool,
201    is_failed: bool,
202}
203
204fn decide_node_advancement(
205    snapshot: &RunSnapshotDTO,
206    node: &WorkflowNode,
207    node_id: &str,
208    gate_activity_id: &str,
209    work_activity_id: &str,
210) -> AdvanceDecision {
211    match node {
212        WorkflowNode::Decision(node) => {
213            let Some(gate) = activity_state(snapshot, gate_activity_id) else {
214                let Some(gate_cfg) = node.base.human_gate.as_ref() else {
215                    return AdvanceDecision {
216                        actions: Vec::new(),
217                        is_succeeded: false,
218                        is_failed: false,
219                    };
220                };
221                return AdvanceDecision {
222                    actions: vec![OrchestratorAction::DispatchGate {
223                        node_id: node_id.to_string(),
224                        activity_id: gate_activity_id.to_string(),
225                        human_gate: gate_cfg.clone(),
226                    }],
227                    is_succeeded: false,
228                    is_failed: false,
229                };
230            };
231            match gate.status {
232                ActivityStatus::Succeeded => AdvanceDecision {
233                    actions: vec![OrchestratorAction::CompleteNodeSucceeded {
234                        node_id: node_id.to_string(),
235                        last_activity_id: gate_activity_id.to_string(),
236                        output_ref: None,
237                    }],
238                    is_succeeded: true,
239                    is_failed: false,
240                },
241                ActivityStatus::Failed | ActivityStatus::TimedOut => AdvanceDecision {
242                    actions: vec![OrchestratorAction::CompleteNodeFailed {
243                        node_id: node_id.to_string(),
244                        last_activity_id: gate_activity_id.to_string(),
245                        error_class: if gate.status == ActivityStatus::TimedOut {
246                            "userFault".to_string()
247                        } else {
248                            derive_error_class(gate)
249                        },
250                    }],
251                    is_succeeded: false,
252                    is_failed: true,
253                },
254                _ => AdvanceDecision {
255                    actions: Vec::new(),
256                    is_succeeded: false,
257                    is_failed: false,
258                },
259            }
260        }
261        WorkflowNode::Loop(_) => AdvanceDecision {
262            actions: Vec::new(),
263            is_succeeded: false,
264            is_failed: false,
265        },
266        WorkflowNode::Subagent(node) => decide_plain_node(
267            snapshot,
268            node.base.human_gate.as_ref(),
269            node_id,
270            gate_activity_id,
271            work_activity_id,
272            WorkflowNode::Subagent(node.clone()),
273        ),
274        WorkflowNode::HostExecutor(node) => decide_plain_node(
275            snapshot,
276            node.base.human_gate.as_ref(),
277            node_id,
278            gate_activity_id,
279            work_activity_id,
280            WorkflowNode::HostExecutor(node.clone()),
281        ),
282    }
283}
284
285fn decide_plain_node(
286    snapshot: &RunSnapshotDTO,
287    human_gate: Option<&HumanGate>,
288    node_id: &str,
289    gate_activity_id: &str,
290    work_activity_id: &str,
291    node: WorkflowNode,
292) -> AdvanceDecision {
293    if let Some(gate_cfg) = human_gate {
294        let Some(gate) = activity_state(snapshot, gate_activity_id) else {
295            return AdvanceDecision {
296                actions: vec![OrchestratorAction::DispatchGate {
297                    node_id: node_id.to_string(),
298                    activity_id: gate_activity_id.to_string(),
299                    human_gate: gate_cfg.clone(),
300                }],
301                is_succeeded: false,
302                is_failed: false,
303            };
304        };
305        match gate.status {
306            ActivityStatus::Failed | ActivityStatus::TimedOut => {
307                return AdvanceDecision {
308                    actions: vec![OrchestratorAction::CompleteNodeFailed {
309                        node_id: node_id.to_string(),
310                        last_activity_id: gate_activity_id.to_string(),
311                        error_class: if gate.status == ActivityStatus::TimedOut {
312                            "userFault".to_string()
313                        } else {
314                            derive_error_class(gate)
315                        },
316                    }],
317                    is_succeeded: false,
318                    is_failed: true,
319                };
320            }
321            ActivityStatus::Succeeded => {}
322            _ => {
323                return AdvanceDecision {
324                    actions: Vec::new(),
325                    is_succeeded: false,
326                    is_failed: false,
327                };
328            }
329        }
330    }
331
332    let Some(work) = activity_state(snapshot, work_activity_id) else {
333        return AdvanceDecision {
334            actions: vec![OrchestratorAction::DispatchWork {
335                node_id: node_id.to_string(),
336                activity_id: work_activity_id.to_string(),
337                node,
338            }],
339            is_succeeded: false,
340            is_failed: false,
341        };
342    };
343    match work.status {
344        ActivityStatus::Succeeded => {
345            let output_ref = snapshot.outputs.get(work_activity_id).cloned();
346            AdvanceDecision {
347                actions: vec![OrchestratorAction::CompleteNodeSucceeded {
348                    node_id: node_id.to_string(),
349                    last_activity_id: work_activity_id.to_string(),
350                    output_ref,
351                }],
352                is_succeeded: true,
353                is_failed: false,
354            }
355        }
356        ActivityStatus::Failed | ActivityStatus::TimedOut => AdvanceDecision {
357            actions: vec![OrchestratorAction::CompleteNodeFailed {
358                node_id: node_id.to_string(),
359                last_activity_id: work_activity_id.to_string(),
360                error_class: if work.status == ActivityStatus::TimedOut {
361                    "retryable".to_string()
362                } else {
363                    derive_error_class(work)
364                },
365            }],
366            is_succeeded: false,
367            is_failed: true,
368        },
369        _ => AdvanceDecision {
370            actions: Vec::new(),
371            is_succeeded: false,
372            is_failed: false,
373        },
374    }
375}
376
377/// Extract wait-resolution metadata from a gate activity for populating
378/// `FinishLoopIteration` payload fields.
379fn extract_wait_resolution_meta(
380    snapshot: &RunSnapshotDTO,
381    gate_activity_id: &str,
382) -> (Option<String>, Option<String>, Option<String>, Option<bool>) {
383    let Some(activity) = snapshot
384        .activities
385        .iter()
386        .find(|a| a.activity_id == gate_activity_id)
387    else {
388        return (None, None, None, None);
389    };
390    let Some(latest) = activity.attempts.last() else {
391        return (None, None, None, None);
392    };
393    let Some(wait) = latest.wait.as_ref() else {
394        return (None, None, None, None);
395    };
396    let Some(resolution) = wait.resolution.as_ref() else {
397        return (None, None, None, None);
398    };
399    let timed_out = match resolution.kind.as_str() {
400        "deadlineExceeded" => Some(true),
401        _ => Some(false),
402    };
403    (
404        resolution.event_id.clone(),
405        resolution.by.clone(),
406        resolution.comment.clone(),
407        timed_out,
408    )
409}
410
411/// Decide the state-transition actions for a loop node.
412///
413/// Checks whether the loop should start, dispatch body nodes for the running
414/// iteration, or transition to the next / final state.
415fn decide_loop_advancement(
416    snapshot: &RunSnapshotDTO,
417    def: &WorkflowDefinition,
418    loop_id: &str,
419    loop_node: &LoopNode,
420) -> AdvanceDecision {
421    let run_id = &snapshot.run.run_id;
422    let loop_state = snapshot.loops.as_ref().and_then(|loops| loops.get(loop_id));
423
424    match loop_state {
425        None => {
426            // Loop hasn't started → StartLoop + StartLoopIteration(1)
427            AdvanceDecision {
428                actions: vec![
429                    OrchestratorAction::StartLoop {
430                        node_id: loop_id.to_string(),
431                        max_iterations: loop_node.max_iterations,
432                    },
433                    OrchestratorAction::StartLoopIteration {
434                        node_id: loop_id.to_string(),
435                        iteration: 1,
436                    },
437                ],
438                is_succeeded: false,
439                is_failed: false,
440            }
441        }
442        Some(ls) => match ls.status {
443            LoopStatus::Running => {
444                // Find the currently-running iteration.
445                let running = ls
446                    .iterations
447                    .iter()
448                    .find(|it| matches!(it.status, LoopIterationStatus::Running));
449                match running {
450                    Some(iter) => process_loop_iteration_body(
451                        snapshot,
452                        def,
453                        run_id,
454                        loop_id,
455                        loop_node,
456                        iter.iteration,
457                    ),
458                    None => {
459                        // No running iteration but loop is Running — safety:
460                        // if the last iteration was just finished (rejected), a
461                        // new StartLoopIteration was already emitted.  Fallback
462                        // to checking whether we should start the next.
463                        let last_iter = ls.iteration;
464                        if last_iter < loop_node.max_iterations {
465                            AdvanceDecision {
466                                actions: vec![OrchestratorAction::StartLoopIteration {
467                                    node_id: loop_id.to_string(),
468                                    iteration: last_iter + 1,
469                                }],
470                                is_succeeded: false,
471                                is_failed: false,
472                            }
473                        } else {
474                            AdvanceDecision {
475                                actions: vec![],
476                                is_succeeded: false,
477                                is_failed: false,
478                            }
479                        }
480                    }
481                }
482            }
483            LoopStatus::Succeeded => AdvanceDecision {
484                actions: vec![],
485                is_succeeded: true,
486                is_failed: false,
487            },
488            LoopStatus::Failed | LoopStatus::Cancelled => AdvanceDecision {
489                actions: vec![],
490                is_succeeded: false,
491                is_failed: true,
492            },
493        },
494    }
495}
496
497/// Process the body nodes of a single loop iteration.
498///
499/// Walks body nodes in topological order.  Returns the first actionable
500/// decision: dispatch gate/work, finish the iteration, or finish the loop.
501fn process_loop_iteration_body(
502    snapshot: &RunSnapshotDTO,
503    def: &WorkflowDefinition,
504    run_id: &str,
505    loop_id: &str,
506    loop_node: &LoopNode,
507    iteration: u64,
508) -> AdvanceDecision {
509    let body_order = body_topological_order(def, &loop_node.body);
510    let terminate_node_id = &loop_node.terminate.node;
511
512    for node_id in &body_order {
513        let Some(node) = def.nodes.get(node_id) else {
514            continue;
515        };
516
517        // Check if this node's intra-body depends are met.
518        let deps_ok = node_depends(node).iter().all(|dep| {
519            if loop_node.body.contains(dep) {
520                // Intra-body dependency → check loop-scoped work activity.
521                let work_id = loop_work_activity_id(run_id, loop_id, iteration, dep);
522                activity_state(snapshot, &work_id)
523                    .map(|a| a.status == ActivityStatus::Succeeded)
524                    .unwrap_or(false)
525            } else {
526                // External dependency → check globally.
527                dependency_is_succeeded(snapshot, dep)
528            }
529        });
530        if !deps_ok {
531            return AdvanceDecision {
532                actions: vec![],
533                is_succeeded: false,
534                is_failed: false,
535            };
536        }
537
538        let is_terminate = node_id == terminate_node_id;
539
540        if is_terminate {
541            // ── Terminate / decision node ──
542            match node {
543                WorkflowNode::Decision(decision_node) => {
544                    let gate_cfg = match decision_node.base.human_gate.as_ref() {
545                        Some(cfg) => cfg,
546                        None => {
547                            // Decision node without humanGate — shouldn't happen
548                            // per validation, but skip silently.
549                            continue;
550                        }
551                    };
552                    let gate_id = loop_gate_activity_id(run_id, loop_id, iteration, node_id);
553                    let Some(gate) = activity_state(snapshot, &gate_id) else {
554                        return AdvanceDecision {
555                            actions: vec![OrchestratorAction::DispatchGate {
556                                node_id: node_id.clone(),
557                                activity_id: gate_id,
558                                human_gate: gate_cfg.clone(),
559                            }],
560                            is_succeeded: false,
561                            is_failed: false,
562                        };
563                    };
564                    match gate.status {
565                        ActivityStatus::Succeeded => {
566                            // Approved → iteration + loop succeeded.
567                            let loop_output = loop_node.output.as_ref().and_then(|out| {
568                                let source_work_id =
569                                    loop_work_activity_id(run_id, loop_id, iteration, &out.from);
570                                snapshot.outputs.get(&source_work_id).cloned()
571                            });
572                            let (wait_resolved_event_id, by, comment, timed_out) =
573                                extract_wait_resolution_meta(snapshot, &gate_id);
574                            return AdvanceDecision {
575                                actions: vec![
576                                    OrchestratorAction::FinishLoopIteration {
577                                        node_id: loop_id.to_string(),
578                                        iteration,
579                                        resolution: "approved".to_string(),
580                                        decision_activity_id: Some(gate_id),
581                                        wait_resolved_event_id,
582                                        by,
583                                        comment,
584                                        timed_out,
585                                    },
586                                    OrchestratorAction::FinishLoop {
587                                        node_id: loop_id.to_string(),
588                                        final_iteration: iteration,
589                                        resolution: "approved".to_string(),
590                                        output_ref: loop_output,
591                                        error_code: None,
592                                        error_class: None,
593                                    },
594                                ],
595                                is_succeeded: false,
596                                is_failed: false,
597                            };
598                        }
599                        ActivityStatus::Failed | ActivityStatus::TimedOut => {
600                            // Rejected/timed-out → next iteration or loop failed.
601                            if iteration >= loop_node.max_iterations {
602                                // Max iterations reached → loop failed.
603                                let (wait_resolved_event_id, by, comment, timed_out) =
604                                    extract_wait_resolution_meta(snapshot, &gate_id);
605                                return AdvanceDecision {
606                                    actions: vec![
607                                        OrchestratorAction::FinishLoopIteration {
608                                            node_id: loop_id.to_string(),
609                                            iteration,
610                                            resolution: "rejected".to_string(),
611                                            decision_activity_id: Some(gate_id),
612                                            wait_resolved_event_id,
613                                            by,
614                                            comment,
615                                            timed_out,
616                                        },
617                                        OrchestratorAction::FinishLoop {
618                                            node_id: loop_id.to_string(),
619                                            final_iteration: iteration,
620                                            resolution: "failed".to_string(),
621                                            output_ref: None,
622                                            error_code: Some("MaxIterationsReached".to_string()),
623                                            error_class: Some("fatal".to_string()),
624                                        },
625                                    ],
626                                    is_succeeded: false,
627                                    is_failed: false,
628                                };
629                            }
630                            // Start next iteration.
631                            let (wait_resolved_event_id, by, comment, timed_out) =
632                                extract_wait_resolution_meta(snapshot, &gate_id);
633                            return AdvanceDecision {
634                                actions: vec![
635                                    OrchestratorAction::FinishLoopIteration {
636                                        node_id: loop_id.to_string(),
637                                        iteration,
638                                        resolution: "rejected".to_string(),
639                                        decision_activity_id: Some(gate_id),
640                                        wait_resolved_event_id,
641                                        by,
642                                        comment,
643                                        timed_out,
644                                    },
645                                    OrchestratorAction::StartLoopIteration {
646                                        node_id: loop_id.to_string(),
647                                        iteration: iteration + 1,
648                                    },
649                                ],
650                                is_succeeded: false,
651                                is_failed: false,
652                            };
653                        }
654                        _ => {
655                            // Gate is in progress (Waiting, etc.).
656                            return AdvanceDecision {
657                                actions: vec![],
658                                is_succeeded: false,
659                                is_failed: false,
660                            };
661                        }
662                    }
663                }
664                _ => {
665                    // Terminate node must be Decision type.  Skip for now
666                    // (full validation in Task 8.3).
667                    continue;
668                }
669            }
670        } else {
671            // ── Regular body node (subagent / hostExecutor) ──
672
673            // Process gate if required.
674            if let Some(human_gate) = node_human_gate(node) {
675                let gate_id = loop_gate_activity_id(run_id, loop_id, iteration, node_id);
676                let Some(gate) = activity_state(snapshot, &gate_id) else {
677                    return AdvanceDecision {
678                        actions: vec![OrchestratorAction::DispatchGate {
679                            node_id: node_id.clone(),
680                            activity_id: gate_id,
681                            human_gate: human_gate.clone(),
682                        }],
683                        is_succeeded: false,
684                        is_failed: false,
685                    };
686                };
687                match gate.status {
688                    ActivityStatus::Failed | ActivityStatus::TimedOut => {
689                        // Gate failed → body node failed → loop failed.
690                        return AdvanceDecision {
691                            actions: vec![
692                                OrchestratorAction::FinishLoopIteration {
693                                    node_id: loop_id.to_string(),
694                                    iteration,
695                                    resolution: "failed".to_string(),
696                                    decision_activity_id: None,
697                                    wait_resolved_event_id: None,
698                                    by: None,
699                                    comment: None,
700                                    timed_out: None,
701                                },
702                                OrchestratorAction::FinishLoop {
703                                    node_id: loop_id.to_string(),
704                                    final_iteration: iteration,
705                                    resolution: "failed".to_string(),
706                                    output_ref: None,
707                                    error_code: Some("BodyNodeGateFailed".to_string()),
708                                    error_class: Some("fatal".to_string()),
709                                },
710                            ],
711                            is_succeeded: false,
712                            is_failed: false,
713                        };
714                    }
715                    ActivityStatus::Succeeded => {
716                        // Gate passed, proceed to work.
717                    }
718                    _ => {
719                        return AdvanceDecision {
720                            actions: vec![],
721                            is_succeeded: false,
722                            is_failed: false,
723                        };
724                    }
725                }
726            }
727
728            // Dispatch / check work activity.
729            let work_id = loop_work_activity_id(run_id, loop_id, iteration, node_id);
730            let Some(work) = activity_state(snapshot, &work_id) else {
731                return AdvanceDecision {
732                    actions: vec![OrchestratorAction::DispatchWork {
733                        node_id: node_id.clone(),
734                        activity_id: work_id,
735                        node: node.clone(),
736                    }],
737                    is_succeeded: false,
738                    is_failed: false,
739                };
740            };
741
742            match work.status {
743                ActivityStatus::Succeeded => {
744                    // Body node done → continue to next.
745                    continue;
746                }
747                ActivityStatus::Failed | ActivityStatus::TimedOut => {
748                    // Body node failed → loop failed.
749                    return AdvanceDecision {
750                        actions: vec![
751                            OrchestratorAction::FinishLoopIteration {
752                                node_id: loop_id.to_string(),
753                                iteration,
754                                resolution: "failed".to_string(),
755                                decision_activity_id: None,
756                                wait_resolved_event_id: None,
757                                by: None,
758                                comment: None,
759                                timed_out: None,
760                            },
761                            OrchestratorAction::FinishLoop {
762                                node_id: loop_id.to_string(),
763                                final_iteration: iteration,
764                                resolution: "failed".to_string(),
765                                output_ref: None,
766                                error_code: Some("BodyNodeFailed".to_string()),
767                                error_class: Some("fatal".to_string()),
768                            },
769                        ],
770                        is_succeeded: false,
771                        is_failed: false,
772                    };
773                }
774                _ => {
775                    // Work in progress.
776                    return AdvanceDecision {
777                        actions: vec![],
778                        is_succeeded: false,
779                        is_failed: false,
780                    };
781                }
782            }
783        }
784    }
785
786    // All body nodes done — nothing actionable right now.
787    AdvanceDecision {
788        actions: vec![],
789        is_succeeded: false,
790        is_failed: false,
791    }
792}
793
794pub fn topological_order(def: &WorkflowDefinition) -> Vec<String> {
795    let mut indegree: BTreeMap<String, usize> = def
796        .nodes
797        .keys()
798        .map(|node_id| (node_id.clone(), 0))
799        .collect();
800    let mut outgoing: BTreeMap<String, Vec<String>> = BTreeMap::new();
801    for (node_id, node) in &def.nodes {
802        for dep in node_depends(node) {
803            *indegree.entry(node_id.clone()).or_insert(0) += 1;
804            outgoing
805                .entry(dep.clone())
806                .or_default()
807                .push(node_id.clone());
808        }
809    }
810
811    let mut ready: BTreeSet<String> = indegree
812        .iter()
813        .filter_map(|(node_id, degree)| (*degree == 0).then_some(node_id.clone()))
814        .collect();
815    let mut order = Vec::with_capacity(def.nodes.len());
816    while let Some(node_id) = ready.iter().next().cloned() {
817        ready.take(&node_id);
818        order.push(node_id.clone());
819        if let Some(children) = outgoing.get(&node_id) {
820            for child in children {
821                if let Some(entry) = indegree.get_mut(child) {
822                    *entry = entry.saturating_sub(1);
823                    if *entry == 0 {
824                        ready.insert(child.clone());
825                    }
826                }
827            }
828        }
829    }
830    if order.len() != def.nodes.len() {
831        return def.nodes.keys().cloned().collect();
832    }
833    order
834}
835
836fn build_body_owner_map(def: &WorkflowDefinition) -> HashMap<String, String> {
837    let mut owner = HashMap::new();
838    for (loop_id, node) in &def.nodes {
839        if let WorkflowNode::Loop(loop_node) = node {
840            for body_id in &loop_node.body {
841                owner.insert(body_id.clone(), loop_id.clone());
842            }
843        }
844    }
845    owner
846}
847
848fn find_sinks(def: &WorkflowDefinition) -> Vec<String> {
849    let body_owner = build_body_owner_map(def);
850    let mut referenced = BTreeSet::new();
851    for (node_id, node) in &def.nodes {
852        if body_owner.contains_key(node_id) {
853            continue;
854        }
855        for dep in node_depends(node) {
856            referenced.insert(dep.clone());
857        }
858    }
859    def.nodes
860        .iter()
861        .filter_map(|(node_id, node)| {
862            if body_owner.contains_key(node_id) {
863                return None;
864            }
865            if matches!(node, WorkflowNode::Decision(_)) {
866                return None;
867            }
868            (!referenced.contains(node_id)).then_some(node_id.clone())
869        })
870        .collect()
871}
872
873fn dependency_is_succeeded(snapshot: &RunSnapshotDTO, node_id: &str) -> bool {
874    if let Some(node) = node_state(snapshot, node_id) {
875        if node.status == NodeStatus::Succeeded {
876            return true;
877        }
878    }
879    if let Some(loop_state) = snapshot.loops.as_ref().and_then(|loops| loops.get(node_id)) {
880        return matches!(loop_state.status, LoopStatus::Succeeded);
881    }
882    false
883}
884
885fn node_state<'a>(snapshot: &'a RunSnapshotDTO, node_id: &str) -> Option<&'a NodeState> {
886    snapshot.nodes.iter().find(|node| node.node_id == node_id)
887}
888
889fn activity_state<'a>(
890    snapshot: &'a RunSnapshotDTO,
891    activity_id: &str,
892) -> Option<&'a ActivityState> {
893    snapshot
894        .activities
895        .iter()
896        .find(|activity| activity.activity_id == activity_id)
897}
898
899fn node_depends(node: &WorkflowNode) -> &[String] {
900    match node {
901        WorkflowNode::Subagent(node) => node.base.depends.as_deref().unwrap_or(&[]),
902        WorkflowNode::HostExecutor(node) => node.base.depends.as_deref().unwrap_or(&[]),
903        WorkflowNode::Loop(node) => node.base.depends.as_deref().unwrap_or(&[]),
904        WorkflowNode::Decision(node) => node.base.depends.as_deref().unwrap_or(&[]),
905    }
906}
907
908fn derive_error_class(activity: &ActivityState) -> String {
909    let Some(last) = activity.attempts.last() else {
910        return "fatal".to_string();
911    };
912    last.error
913        .as_ref()
914        .and_then(|value| value.get("errorClass"))
915        .and_then(Value::as_str)
916        .unwrap_or("fatal")
917        .to_string()
918}
919
920fn gate_activity_id(run_id: &str, node_id: &str) -> String {
921    format!("{run_id}::gate::{node_id}")
922}
923
924fn work_activity_id(run_id: &str, node_id: &str) -> String {
925    format!("{run_id}::work::{node_id}")
926}
927
928/// Build a loop-scoped gate activity id:
929///   `<runId>::loop::<loopId>.<N>::gate::<bodyNodeId>`
930fn loop_gate_activity_id(run_id: &str, loop_id: &str, iteration: u64, node_id: &str) -> String {
931    format!("{run_id}::loop::{loop_id}.{iteration}::gate::{node_id}")
932}
933
934/// Build a loop-scoped work activity id:
935///   `<runId>::loop::<loopId>.<N>::work::<bodyNodeId>`
936fn loop_work_activity_id(run_id: &str, loop_id: &str, iteration: u64, node_id: &str) -> String {
937    format!("{run_id}::loop::{loop_id}.{iteration}::work::{node_id}")
938}
939
940/// Return the HumanGate config for any node type, if present.
941fn node_human_gate(node: &WorkflowNode) -> Option<&HumanGate> {
942    match node {
943        WorkflowNode::Subagent(n) => n.base.human_gate.as_ref(),
944        WorkflowNode::HostExecutor(n) => n.base.human_gate.as_ref(),
945        WorkflowNode::Loop(n) => n.base.human_gate.as_ref(),
946        WorkflowNode::Decision(n) => n.base.human_gate.as_ref(),
947    }
948}
949
950/// Compute a stable topological order for the given body nodes, considering
951/// only edges where both ends are in the body set.
952fn body_topological_order(def: &WorkflowDefinition, body_nodes: &[String]) -> Vec<String> {
953    let body_set: BTreeSet<&String> = body_nodes.iter().collect();
954    let mut indegree: BTreeMap<&String, usize> = body_nodes.iter().map(|id| (id, 0)).collect();
955    let mut outgoing: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
956
957    for node_id in body_nodes {
958        let Some(node) = def.nodes.get(node_id) else {
959            continue;
960        };
961        for dep in node_depends(node) {
962            if body_set.contains(dep) {
963                *indegree.get_mut(node_id).unwrap() += 1;
964                outgoing.entry(dep).or_default().push(node_id);
965            }
966        }
967    }
968
969    // Kahn's algorithm
970    let mut ready: BTreeSet<&String> = indegree
971        .iter()
972        .filter(|(_, d)| **d == 0)
973        .map(|(id, _)| *id)
974        .collect();
975    let mut order = Vec::with_capacity(body_nodes.len());
976
977    while let Some(id) = ready.iter().next().cloned() {
978        ready.take(id);
979        order.push(id.clone());
980        if let Some(children) = outgoing.get(id) {
981            for child in children {
982                if let Some(entry) = indegree.get_mut(child) {
983                    *entry = entry.saturating_sub(1);
984                    if *entry == 0 {
985                        ready.insert(child);
986                    }
987                }
988            }
989        }
990    }
991
992    if order.len() != body_nodes.len() {
993        // Fallback: use original order if cycle detected or some nodes unreachable
994        return body_nodes.to_vec();
995    }
996    order
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use crate::workflow_definition::NodeBase;
1003    use crate::workflow_snapshot::{ActivityStatus, NodeStatus};
1004    use crate::{ActivityState, NodeState, RunChatBinding, RunState, RunStatus, WorkflowOutputRef};
1005    use crate::{LoopIterationState, LoopSnapshotDTO};
1006
1007    fn output_ref(name: &str) -> WorkflowOutputRef {
1008        WorkflowOutputRef {
1009            output_hash: format!("sha256:{name}"),
1010            output_path: format!("/tmp/{name}.json"),
1011            output_bytes: 4,
1012            output_schema_version: 1,
1013            content_type: Some("application/json".to_string()),
1014        }
1015    }
1016
1017    fn snapshot() -> RunSnapshotDTO {
1018        RunSnapshotDTO {
1019            run_id: "run-1".to_string(),
1020            run: RunState {
1021                run_id: "run-1".to_string(),
1022                status: RunStatus::Running,
1023                workflow_id: Some("flow-a".to_string()),
1024                revision_id: Some("rev-a".to_string()),
1025                initiator: Some("cli".to_string()),
1026                input: None,
1027                output: None,
1028                failed_node_id: None,
1029                root_cause_event_id: None,
1030                cancel_origin_event_id: None,
1031                bot_snapshots: None,
1032                cancelled_run_intent: None,
1033                cancelled_node_intents: BTreeMap::new(),
1034            },
1035            last_seq: 2,
1036            nodes: Vec::new(),
1037            activities: Vec::new(),
1038            loops: None,
1039            dangling: crate::DanglingSnapshot {
1040                activities: Vec::new(),
1041                effect_attempted: Vec::new(),
1042                waits: Vec::new(),
1043                wait_resolutions: Vec::new(),
1044                cancels: Vec::new(),
1045            },
1046            outputs: BTreeMap::new(),
1047            attempt_io: BTreeMap::new(),
1048            chat_binding: Some(RunChatBinding {
1049                chat_id: "chat-1".to_string(),
1050                lark_app_id: "app-1".to_string(),
1051            }),
1052            updated_at: 1,
1053        }
1054    }
1055
1056    fn subagent_node(depends: &[&str]) -> WorkflowNode {
1057        WorkflowNode::Subagent(crate::SubagentNode {
1058            base: NodeBase {
1059                description: None,
1060                depends: Some(depends.iter().map(|s| s.to_string()).collect()),
1061                human_gate: None,
1062                retry_policy: None,
1063                timeout_ms: None,
1064                max_output_bytes: None,
1065                output_schema: None,
1066                unsafe_allow_ungated: None,
1067            },
1068            bot: "bot-a".to_string(),
1069            prompt: Value::String("hi".to_string()),
1070            working_dir: None,
1071            model_overrides: None,
1072            tool_policy: None,
1073        })
1074    }
1075
1076    fn host_node(depends: &[&str]) -> WorkflowNode {
1077        WorkflowNode::HostExecutor(crate::HostExecutorNode {
1078            base: NodeBase {
1079                description: None,
1080                depends: Some(depends.iter().map(|s| s.to_string()).collect()),
1081                human_gate: None,
1082                retry_policy: None,
1083                timeout_ms: None,
1084                max_output_bytes: None,
1085                output_schema: None,
1086                unsafe_allow_ungated: None,
1087            },
1088            executor: "feishu-send".to_string(),
1089            input: Value::Null,
1090        })
1091    }
1092
1093    fn gate_node(depends: &[&str]) -> WorkflowNode {
1094        WorkflowNode::Subagent(crate::SubagentNode {
1095            base: NodeBase {
1096                description: None,
1097                depends: Some(depends.iter().map(|s| s.to_string()).collect()),
1098                human_gate: Some(HumanGate {
1099                    stage: "before".to_string(),
1100                    prompt: Value::String("approve?".to_string()),
1101                    approvers: None,
1102                    deadline_ms: None,
1103                    on_timeout: None,
1104                }),
1105                retry_policy: None,
1106                timeout_ms: None,
1107                max_output_bytes: None,
1108                output_schema: None,
1109                unsafe_allow_ungated: None,
1110            },
1111            bot: "bot-a".to_string(),
1112            prompt: Value::String("hi".to_string()),
1113            working_dir: None,
1114            model_overrides: None,
1115            tool_policy: None,
1116        })
1117    }
1118
1119    #[test]
1120    fn decide_next_actions_dispatches_root_work_then_downstream() {
1121        let def = WorkflowDefinition {
1122            workflow_id: "flow-a".to_string(),
1123            version: 1,
1124            params: None,
1125            defaults: None,
1126            nodes: BTreeMap::from([
1127                ("a".to_string(), subagent_node(&[])),
1128                ("b".to_string(), host_node(&["a"])),
1129            ]),
1130        };
1131        let mut snap = snapshot();
1132        let actions = decide_next_actions(&snap, &def);
1133        assert!(matches!(
1134            actions.as_slice(),
1135            [OrchestratorAction::DispatchWork { node_id, activity_id, .. }]
1136            if node_id == "a" && activity_id == "run-1::work::a"
1137        ));
1138
1139        snap.nodes = vec![NodeState {
1140            node_id: "a".to_string(),
1141            status: NodeStatus::Succeeded,
1142            activity_id: Some("run-1::work::a".to_string()),
1143            retry_count: 0,
1144            next_attempt_at: None,
1145            error_class: None,
1146            condition_event_id: None,
1147            cancel_origin_event_id: None,
1148        }];
1149        snap.activities = vec![ActivityState {
1150            activity_id: "run-1::work::a".to_string(),
1151            attempts: vec![],
1152            status: ActivityStatus::Succeeded,
1153            current_attempt_id: None,
1154            owner_node_id: Some("a".to_string()),
1155        }];
1156        snap.outputs
1157            .insert("run-1::work::a".to_string(), output_ref("a"));
1158        let actions = decide_next_actions(&snap, &def);
1159        assert!(matches!(
1160            actions.as_slice(),
1161            [OrchestratorAction::DispatchWork { node_id, activity_id, .. }]
1162            if node_id == "b" && activity_id == "run-1::work::b"
1163        ));
1164    }
1165
1166    #[test]
1167    fn decide_next_actions_completes_simple_run_when_single_sink_has_output() {
1168        let def = WorkflowDefinition {
1169            workflow_id: "flow-a".to_string(),
1170            version: 1,
1171            params: None,
1172            defaults: None,
1173            nodes: BTreeMap::from([
1174                ("a".to_string(), subagent_node(&[])),
1175                ("b".to_string(), host_node(&["a"])),
1176            ]),
1177        };
1178        let mut snap = snapshot();
1179        snap.nodes = vec![
1180            NodeState {
1181                node_id: "a".to_string(),
1182                status: NodeStatus::Succeeded,
1183                activity_id: Some("run-1::work::a".to_string()),
1184                retry_count: 0,
1185                next_attempt_at: None,
1186                error_class: None,
1187                condition_event_id: None,
1188                cancel_origin_event_id: None,
1189            },
1190            NodeState {
1191                node_id: "b".to_string(),
1192                status: NodeStatus::Succeeded,
1193                activity_id: Some("run-1::work::b".to_string()),
1194                retry_count: 0,
1195                next_attempt_at: None,
1196                error_class: None,
1197                condition_event_id: None,
1198                cancel_origin_event_id: None,
1199            },
1200        ];
1201        snap.activities = vec![ActivityState {
1202            activity_id: "run-1::work::b".to_string(),
1203            attempts: vec![],
1204            status: ActivityStatus::Succeeded,
1205            current_attempt_id: None,
1206            owner_node_id: Some("b".to_string()),
1207        }];
1208        snap.outputs
1209            .insert("run-1::work::b".to_string(), output_ref("b"));
1210        let actions = decide_next_actions(&snap, &def);
1211        assert!(matches!(
1212            actions.as_slice(),
1213            [OrchestratorAction::CompleteRunSucceeded { sink_node_id, .. }]
1214            if sink_node_id == "b"
1215        ));
1216    }
1217
1218    #[test]
1219    fn decide_next_actions_reports_node_failure_before_run_failure() {
1220        let def = WorkflowDefinition {
1221            workflow_id: "flow-a".to_string(),
1222            version: 1,
1223            params: None,
1224            defaults: None,
1225            nodes: BTreeMap::from([
1226                ("a".to_string(), subagent_node(&[])),
1227                ("b".to_string(), host_node(&["a"])),
1228            ]),
1229        };
1230        let mut snap = snapshot();
1231        snap.nodes = vec![NodeState {
1232            node_id: "a".to_string(),
1233            status: NodeStatus::Failed,
1234            activity_id: Some("run-1::work::a".to_string()),
1235            retry_count: 0,
1236            next_attempt_at: None,
1237            error_class: Some("fatal".to_string()),
1238            condition_event_id: None,
1239            cancel_origin_event_id: None,
1240        }];
1241        let actions = decide_next_actions(&snap, &def);
1242        assert!(matches!(
1243            actions.as_slice(),
1244            [OrchestratorAction::CompleteRunFailed { failed_node_id }]
1245            if failed_node_id == "a"
1246        ));
1247    }
1248
1249    #[test]
1250    fn decide_next_actions_dispatches_gate_before_work() {
1251        let def = WorkflowDefinition {
1252            workflow_id: "flow-a".to_string(),
1253            version: 1,
1254            params: None,
1255            defaults: None,
1256            nodes: BTreeMap::from([("a".to_string(), gate_node(&[]))]),
1257        };
1258        let snap = snapshot();
1259        let actions = decide_next_actions(&snap, &def);
1260        assert!(matches!(
1261            actions.as_slice(),
1262            [OrchestratorAction::DispatchGate { node_id, activity_id, .. }]
1263            if node_id == "a" && activity_id == "run-1::gate::a"
1264        ));
1265    }
1266
1267    // ── Loop dispatch tests (Task 8.2) ──
1268
1269    fn decision_node(human_gate: HumanGate) -> WorkflowNode {
1270        WorkflowNode::Decision(crate::DecisionNode {
1271            base: NodeBase {
1272                description: None,
1273                depends: None,
1274                human_gate: Some(human_gate),
1275                retry_policy: None,
1276                timeout_ms: None,
1277                max_output_bytes: None,
1278                output_schema: None,
1279                unsafe_allow_ungated: None,
1280            },
1281        })
1282    }
1283
1284    fn loop_node_with_body(body: &[&str], max_iterations: u64) -> WorkflowNode {
1285        WorkflowNode::Loop(LoopNode {
1286            base: NodeBase {
1287                description: None,
1288                depends: None,
1289                human_gate: None,
1290                retry_policy: None,
1291                timeout_ms: None,
1292                max_output_bytes: None,
1293                output_schema: None,
1294                unsafe_allow_ungated: None,
1295            },
1296            max_iterations,
1297            body: body.iter().map(|s| s.to_string()).collect(),
1298            terminate: crate::LoopTerminate {
1299                node: body.last().unwrap_or(&"").to_string(),
1300                via: "humanGate".to_string(),
1301            },
1302            output: None,
1303        })
1304    }
1305
1306    impl Default for HumanGate {
1307        fn default() -> Self {
1308            HumanGate {
1309                stage: "before".to_string(),
1310                prompt: Value::String("approve?".to_string()),
1311                approvers: None,
1312                deadline_ms: None,
1313                on_timeout: None,
1314            }
1315        }
1316    }
1317
1318    #[test]
1319    fn loop_start_when_deps_met_and_no_loop_state() {
1320        let def = WorkflowDefinition {
1321            workflow_id: "flow-a".to_string(),
1322            version: 1,
1323            params: None,
1324            defaults: None,
1325            nodes: BTreeMap::from([
1326                ("d".to_string(), decision_node(HumanGate::default())),
1327                ("l".to_string(), loop_node_with_body(&["d"], 3)),
1328            ]),
1329        };
1330        let snap = snapshot();
1331        let actions = decide_next_actions(&snap, &def);
1332        assert!(
1333            actions.len() >= 2,
1334            "expected StartLoop and StartLoopIteration, got {:?}",
1335            actions
1336        );
1337        assert!(
1338            actions
1339                .iter()
1340                .any(|a| matches!(a, OrchestratorAction::StartLoop { .. }))
1341        );
1342        assert!(actions.iter().any(|a| matches!(
1343            a,
1344            OrchestratorAction::StartLoopIteration { iteration: 1, .. }
1345        )));
1346    }
1347
1348    #[test]
1349    fn loop_with_succeeded_decision_gate_finishes_loop() {
1350        // Create a snapshot where:
1351        // - The loop has been started (loop=Running, iteration 1 Running)
1352        // - The body node (decision) gate activity has Succeeded
1353        // The orchestrator should emit FinishLoopIteration(approved) + FinishLoop(approved).
1354        let def = WorkflowDefinition {
1355            workflow_id: "flow-a".to_string(),
1356            version: 1,
1357            params: None,
1358            defaults: None,
1359            nodes: BTreeMap::from([
1360                ("d".to_string(), decision_node(HumanGate::default())),
1361                ("l".to_string(), loop_node_with_body(&["d"], 3)),
1362            ]),
1363        };
1364
1365        let mut snap = snapshot();
1366        let run_id = &snap.run.run_id;
1367        let decision_gate_id = format!("{run_id}::loop::l.1::gate::d");
1368
1369        // Set up loop state
1370        snap.loops = Some(BTreeMap::from([(
1371            "l".to_string(),
1372            LoopSnapshotDTO {
1373                loop_id: "l".to_string(),
1374                status: LoopStatus::Running,
1375                iteration: 1,
1376                max_iterations: 3,
1377                iterations: vec![LoopIterationState {
1378                    iteration: 1,
1379                    status: LoopIterationStatus::Running,
1380                    body_activity_ids: vec![decision_gate_id.clone()],
1381                    decision_activity_id: None,
1382                    wait_resolved_event_id: None,
1383                    decision_by: None,
1384                    decision_comment: None,
1385                    timed_out: None,
1386                }],
1387                output: None,
1388                error_code: None,
1389                error_class: None,
1390            },
1391        )]));
1392
1393        // Set the gate activity as Succeeded
1394        snap.activities = vec![ActivityState {
1395            activity_id: decision_gate_id.clone(),
1396            attempts: vec![],
1397            status: ActivityStatus::Succeeded,
1398            current_attempt_id: None,
1399            owner_node_id: Some("d".to_string()),
1400        }];
1401
1402        let actions = decide_next_actions(&snap, &def);
1403        assert!(
1404            actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoopIteration { resolution, .. } if resolution == "approved")),
1405            "expected FinishLoopIteration(approved), got: {actions:?}"
1406        );
1407        assert!(
1408            actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoop { resolution, .. } if resolution == "approved")),
1409            "expected FinishLoop(approved), got: {actions:?}"
1410        );
1411    }
1412
1413    #[test]
1414    fn loop_with_failed_decision_gate_max_iter_reached_fails_loop() {
1415        let def = WorkflowDefinition {
1416            workflow_id: "flow-a".to_string(),
1417            version: 1,
1418            params: None,
1419            defaults: None,
1420            nodes: BTreeMap::from([
1421                ("d".to_string(), decision_node(HumanGate::default())),
1422                ("l".to_string(), loop_node_with_body(&["d"], 2)),
1423            ]),
1424        };
1425
1426        let mut snap = snapshot();
1427        let run_id = &snap.run.run_id;
1428        let decision_gate_id = format!("{run_id}::loop::l.2::gate::d");
1429
1430        // Loop at iteration 2 (max_iterations=2) with Failed gate
1431        snap.loops = Some(BTreeMap::from([(
1432            "l".to_string(),
1433            LoopSnapshotDTO {
1434                loop_id: "l".to_string(),
1435                status: LoopStatus::Running,
1436                iteration: 2,
1437                max_iterations: 2,
1438                iterations: vec![LoopIterationState {
1439                    iteration: 2,
1440                    status: LoopIterationStatus::Running,
1441                    body_activity_ids: vec![decision_gate_id.clone()],
1442                    decision_activity_id: None,
1443                    wait_resolved_event_id: None,
1444                    decision_by: None,
1445                    decision_comment: None,
1446                    timed_out: None,
1447                }],
1448                output: None,
1449                error_code: None,
1450                error_class: None,
1451            },
1452        )]));
1453
1454        snap.activities = vec![ActivityState {
1455            activity_id: decision_gate_id,
1456            attempts: vec![],
1457            status: ActivityStatus::Failed,
1458            current_attempt_id: None,
1459            owner_node_id: Some("d".to_string()),
1460        }];
1461
1462        let actions = decide_next_actions(&snap, &def);
1463        assert!(
1464            actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoop { resolution, .. } if resolution == "failed")),
1465            "expected FinishLoop(failed), got: {actions:?}"
1466        );
1467        assert!(
1468            actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoop { error_code, .. } if error_code.as_deref() == Some("MaxIterationsReached"))),
1469            "expected MaxIterationsReached, got: {actions:?}"
1470        );
1471    }
1472
1473    #[test]
1474    fn loop_with_rejected_decision_below_max_starts_next_iteration() {
1475        let def = WorkflowDefinition {
1476            workflow_id: "flow-a".to_string(),
1477            version: 1,
1478            params: None,
1479            defaults: None,
1480            nodes: BTreeMap::from([
1481                ("d".to_string(), decision_node(HumanGate::default())),
1482                ("l".to_string(), loop_node_with_body(&["d"], 3)),
1483            ]),
1484        };
1485
1486        let mut snap = snapshot();
1487        let run_id = &snap.run.run_id;
1488        let decision_gate_id = format!("{run_id}::loop::l.1::gate::d");
1489
1490        snap.loops = Some(BTreeMap::from([(
1491            "l".to_string(),
1492            LoopSnapshotDTO {
1493                loop_id: "l".to_string(),
1494                status: LoopStatus::Running,
1495                iteration: 1,
1496                max_iterations: 3,
1497                iterations: vec![LoopIterationState {
1498                    iteration: 1,
1499                    status: LoopIterationStatus::Running,
1500                    body_activity_ids: vec![decision_gate_id.clone()],
1501                    decision_activity_id: None,
1502                    wait_resolved_event_id: None,
1503                    decision_by: None,
1504                    decision_comment: None,
1505                    timed_out: None,
1506                }],
1507                output: None,
1508                error_code: None,
1509                error_class: None,
1510            },
1511        )]));
1512
1513        snap.activities = vec![ActivityState {
1514            activity_id: decision_gate_id,
1515            attempts: vec![],
1516            status: ActivityStatus::Failed,
1517            current_attempt_id: None,
1518            owner_node_id: Some("d".to_string()),
1519        }];
1520
1521        let actions = decide_next_actions(&snap, &def);
1522        assert!(
1523            actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoopIteration { resolution, .. } if resolution == "rejected")),
1524            "expected FinishLoopIteration(rejected), got: {actions:?}"
1525        );
1526        assert!(
1527            actions.iter().any(|a| matches!(
1528                a,
1529                OrchestratorAction::StartLoopIteration { iteration: 2, .. }
1530            )),
1531            "expected StartLoopIteration(2), got: {actions:?}"
1532        );
1533    }
1534}