Skip to main content

ri_agent_graph/
engine.rs

1#![allow(deprecated)] // Constructs GraphEvent with legacy trace_id/attempt fields during migration
2
3use crate::checkpoint::Checkpoint;
4use crate::checkpoint_store::{CheckpointStore, RunStatus, RunSummary};
5use crate::command::{Navigation, NodeOutput};
6use crate::config::GraphConfig;
7use crate::edge::EdgeType;
8use crate::error::{AgentGraphError, CheckpointStoreOperation, Result};
9use crate::event_sink::{EventSink, GraphEvent, NodeOutcomeKind};
10use crate::graph::{AgentGraph, END, START};
11use crate::interrupt::{ExecutionResult, InterruptCheckpoint};
12use crate::retry::RetryPolicy;
13use crate::router::RouterOutput;
14use crate::state::AgentState;
15use crate::stream::StreamEvent;
16use serde_json::Value;
17use std::collections::{HashMap, HashSet};
18use std::future::Future;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use tokio::sync::mpsc;
22
23fn checkpoint_store_error(
24    operation: CheckpointStoreOperation,
25    error: AgentGraphError,
26) -> AgentGraphError {
27    AgentGraphError::CheckpointStore {
28        operation,
29        message: error.to_string(),
30    }
31}
32
33/// Execution-related methods on AgentGraph.
34impl AgentGraph {
35    fn unstarted_run_summary(&self, trace_ctx: stack_ids::TraceCtx) -> RunSummary {
36        let now = chrono::Utc::now();
37        RunSummary {
38            // An empty ID is reserved for a run that was never persisted.
39            run_id: String::new(),
40            graph_name: self
41                .graph_name
42                .clone()
43                .unwrap_or_else(|| "unnamed".to_string()),
44            status: RunStatus::Failed,
45            total_nodes_executed: 0,
46            total_attempts: 0,
47            failed_attempts: 0,
48            trace_id: Some(trace_ctx.to_legacy_trace_id().to_string()),
49            trace_ctx: Some(trace_ctx),
50            started_at: now,
51            finished_at: Some(now),
52        }
53    }
54
55    /// Execute the graph starting from a specific node.
56    pub async fn execute(&self, start_node: &str, state: AgentState) -> Result<AgentState> {
57        self.execute_with_config(start_node, state, GraphConfig::default())
58            .await
59    }
60
61    /// Execute the graph and return a structured run summary alongside the result.
62    pub async fn execute_with_summary(
63        &self,
64        start_node: &str,
65        state: AgentState,
66        config: GraphConfig,
67    ) -> (Result<AgentState>, RunSummary) {
68        self.register_reducers_on_state(&state).await;
69        let trace_ctx = config.resolve_trace_ctx();
70        let run_id = match self.create_run_id().await {
71            Ok(run_id) => run_id,
72            Err(error) => return (Err(error), self.unstarted_run_summary(trace_ctx)),
73        };
74        let event_sink = self.resolve_event_sink(None);
75        let cancel = Arc::new(AtomicBool::new(false));
76
77        let executor = GraphExecutor {
78            graph: self,
79            state,
80            config,
81            iteration: 0,
82            event_sink,
83            run_id,
84            trace_ctx,
85            cancel_flag: cancel,
86            started_at: chrono::Utc::now(),
87            total_attempts: 0,
88            failed_attempts: 0,
89            executed_nodes: HashSet::new(),
90        };
91
92        executor.execute(start_node).await
93    }
94
95    /// Execute the graph with a runtime config.
96    pub async fn execute_with_config(
97        &self,
98        start_node: &str,
99        state: AgentState,
100        config: GraphConfig,
101    ) -> Result<AgentState> {
102        let (result, _summary) = self.execute_with_summary(start_node, state, config).await;
103        result
104    }
105
106    /// Execute the graph with interrupt support.
107    pub async fn execute_with_interrupt(
108        &self,
109        start_node: &str,
110        state: AgentState,
111        config: GraphConfig,
112    ) -> ExecutionResult {
113        self.register_reducers_on_state(&state).await;
114        let state_clone = state.clone();
115        let trace_ctx = config.resolve_trace_ctx();
116        let run_id = match self.create_run_id().await {
117            Ok(run_id) => run_id,
118            Err(error) => {
119                return ExecutionResult::Failed {
120                    error,
121                    state: state_clone,
122                }
123            }
124        };
125        let event_sink = self.resolve_event_sink(None);
126        let cancel = Arc::new(AtomicBool::new(false));
127
128        let executor = GraphExecutor {
129            graph: self,
130            state,
131            config,
132            iteration: 0,
133            event_sink,
134            run_id,
135            trace_ctx,
136            cancel_flag: cancel,
137            started_at: chrono::Utc::now(),
138            total_attempts: 0,
139            failed_attempts: 0,
140            executed_nodes: HashSet::new(),
141        };
142
143        let (result, _summary) = executor.execute(start_node).await;
144        match result {
145            Ok(final_state) => ExecutionResult::Complete(final_state),
146            Err(AgentGraphError::InterruptError {
147                ref node,
148                ref value,
149            }) => ExecutionResult::Interrupted {
150                state: state_clone,
151                node: node.clone(),
152                interrupt_value: value.clone(),
153                checkpoint_data: Some(InterruptCheckpoint {
154                    resume_node: node.clone(),
155                    resume_before: false,
156                    iteration: 0,
157                    active_nodes: Vec::new(),
158                    graph_hash: Some(self.compute_graph_hash()),
159                }),
160            },
161            // AG-001: Preserve ordinary errors as Failed, not Complete.
162            // Only InterruptError maps to Interrupted; everything else
163            // is a real failure that must not be silently erased.
164            Err(e) => ExecutionResult::Failed {
165                error: e,
166                state: state_clone,
167            },
168        }
169    }
170
171    /// Execute with cancellation support using an Arc-wrapped graph.
172    pub fn execute_cancellable(
173        self: Arc<Self>,
174        start_node: &str,
175        state: AgentState,
176        config: GraphConfig,
177    ) -> (tokio::task::JoinHandle<Result<AgentState>>, Arc<AtomicBool>) {
178        let cancel = Arc::new(AtomicBool::new(false));
179        let cancel_clone = cancel.clone();
180        let start = start_node.to_string();
181        let graph = self;
182
183        let handle = tokio::spawn(async move {
184            graph.register_reducers_on_state(&state).await;
185            let trace_ctx = config.resolve_trace_ctx();
186            let run_id = graph.create_run_id().await?;
187            let event_sink = graph.resolve_event_sink(None);
188
189            let executor = GraphExecutor {
190                graph: &graph,
191                state,
192                config,
193                iteration: 0,
194                event_sink,
195                run_id,
196                trace_ctx,
197                cancel_flag: cancel_clone,
198                started_at: chrono::Utc::now(),
199                total_attempts: 0,
200                failed_attempts: 0,
201                executed_nodes: HashSet::new(),
202            };
203
204            let (result, _summary) = executor.execute(&start).await;
205            result
206        });
207
208        (handle, cancel)
209    }
210
211    /// Execute with streaming using Arc-wrapped graph.
212    /// Returns a join handle for the result and a receiver for events.
213    pub fn stream(
214        self: Arc<Self>,
215        start_node: &str,
216        state: AgentState,
217        config: GraphConfig,
218    ) -> (
219        tokio::task::JoinHandle<Result<AgentState>>,
220        mpsc::Receiver<StreamEvent>,
221    ) {
222        let (tx, rx) = mpsc::channel(256);
223        let start = start_node.to_string();
224        let graph = self;
225
226        let handle = tokio::spawn(async move {
227            graph.register_reducers_on_state(&state).await;
228            let trace_ctx = config.resolve_trace_ctx();
229            let run_id = graph.create_run_id().await?;
230            let event_sink = graph.resolve_event_sink(Some(tx));
231            let cancel = Arc::new(AtomicBool::new(false));
232
233            let executor = GraphExecutor {
234                graph: &graph,
235                state,
236                config,
237                iteration: 0,
238                event_sink,
239                run_id,
240                trace_ctx,
241                cancel_flag: cancel,
242                started_at: chrono::Utc::now(),
243                total_attempts: 0,
244                failed_attempts: 0,
245                executed_nodes: HashSet::new(),
246            };
247
248            let (result, _summary) = executor.execute(&start).await;
249            result
250        });
251
252        (handle, rx)
253    }
254}
255
256/// Internal executor that runs the graph using superstep-based execution.
257struct GraphExecutor<'a> {
258    graph: &'a AgentGraph,
259    state: AgentState,
260    config: GraphConfig,
261    iteration: usize,
262    event_sink: Arc<dyn EventSink>,
263    run_id: String,
264    /// Canonical trace context resolved from config at execution start.
265    /// The legacy `trace_id: String` is derived on demand via
266    /// `self.trace_ctx.to_legacy_trace_id().to_string()` at event emission sites.
267    trace_ctx: stack_ids::TraceCtx,
268    cancel_flag: Arc<AtomicBool>,
269    started_at: chrono::DateTime<chrono::Utc>,
270    total_attempts: usize,
271    failed_attempts: usize,
272    executed_nodes: HashSet<String>,
273}
274
275impl<'a> GraphExecutor<'a> {
276    /// Derive the legacy trace_id string from the canonical TraceCtx.
277    fn legacy_trace_id(&self) -> String {
278        self.trace_ctx.to_legacy_trace_id().to_string()
279    }
280
281    async fn execute(mut self, start_node: &str) -> (Result<AgentState>, RunSummary) {
282        // Derive legacy trace_id once at the run-level compatibility boundary.
283        let run_legacy_tid = self.legacy_trace_id();
284
285        // Emit run start
286        self.event_sink.emit(GraphEvent::RunStart {
287            run_id: self.run_id.clone(),
288            trace_id: run_legacy_tid.clone(),
289            trace_ctx: Some(self.trace_ctx.clone()),
290            graph_name: self.graph.graph_name.clone(),
291        });
292
293        let mut result = self.execute_inner(start_node).await;
294
295        // A configured checkpoint store is part of the execution contract.
296        // Do not report success (or a durable failure) if its terminal state
297        // could not be persisted.
298        if let Some(ref store) = self.graph.checkpoint_store {
299            let terminal_result = match &result {
300                Ok(_) => store.complete_run(&self.run_id).await.map_err(|error| {
301                    checkpoint_store_error(CheckpointStoreOperation::CompleteRun, error)
302                }),
303                Err(AgentGraphError::Cancelled) => store
304                    .fail_run(&self.run_id, "cancelled")
305                    .await
306                    .map_err(|error| {
307                        checkpoint_store_error(CheckpointStoreOperation::FailRun, error)
308                    }),
309                Err(error) => store
310                    .fail_run(&self.run_id, &error.to_string())
311                    .await
312                    .map_err(|error| {
313                        checkpoint_store_error(CheckpointStoreOperation::FailRun, error)
314                    }),
315            };
316            if let Err(error) = terminal_result {
317                result = Err(error);
318            }
319        }
320
321        let status = match &result {
322            Ok(_) => RunStatus::Completed,
323            Err(AgentGraphError::Cancelled) => RunStatus::Cancelled,
324            Err(AgentGraphError::InterruptError { .. }) => RunStatus::Interrupted,
325            Err(_) => RunStatus::Failed,
326        };
327
328        let summary = self.build_run_summary(status, run_legacy_tid.clone());
329
330        // Emit run end
331        self.event_sink.emit(GraphEvent::RunEnd {
332            run_id: self.run_id.clone(),
333            trace_id: run_legacy_tid,
334            trace_ctx: Some(self.trace_ctx.clone()),
335        });
336
337        (result, summary)
338    }
339
340    async fn execute_inner(&mut self, start_node: &str) -> Result<AgentState> {
341        let mut current_superstep = if start_node == START {
342            self.get_edge_targets(START).await?
343        } else {
344            vec![start_node.to_string()]
345        };
346
347        let mut step_number: usize = 0;
348        let max_iter = self.config.recursion_limit.min(self.graph.max_iterations);
349
350        // Derive legacy trace_id once for all superstep-level events in this execution.
351        let loop_legacy_tid = self.legacy_trace_id();
352
353        loop {
354            current_superstep.retain(|n| n != END);
355
356            if current_superstep.is_empty() {
357                break;
358            }
359
360            // Check cancellation
361            if self.cancel_flag.load(Ordering::Relaxed) {
362                return Err(AgentGraphError::Cancelled);
363            }
364
365            // Check max iterations
366            if self.iteration >= max_iter {
367                return Err(AgentGraphError::MaxIterationsExceeded {
368                    current: self.iteration,
369                    max: max_iter,
370                });
371            }
372
373            // Cycle detection
374            if self.graph.enable_cycle_detection && step_number > max_iter * 2 {
375                return Err(AgentGraphError::CycleDetected {
376                    path: current_superstep.clone(),
377                });
378            }
379
380            // Emit superstep start
381            self.event_sink.emit(GraphEvent::SuperstepStart {
382                run_id: self.run_id.clone(),
383                trace_id: loop_legacy_tid.clone(),
384                trace_ctx: Some(self.trace_ctx.clone()),
385                step: step_number,
386                nodes: current_superstep.clone(),
387            });
388
389            // Check interrupt_before
390            if let Some(ref interrupt_cfg) = self.graph.interrupt_config {
391                for node_name in &current_superstep {
392                    if interrupt_cfg.should_interrupt_before(node_name) {
393                        self.event_sink.emit(GraphEvent::InterruptRaised {
394                            run_id: self.run_id.clone(),
395                            trace_id: loop_legacy_tid.clone(),
396                            trace_ctx: Some(self.trace_ctx.clone()),
397                            node_id: node_name.clone(),
398                            kind: "before".to_string(),
399                            payload: Value::Null,
400                        });
401
402                        // Save legacy checkpoint
403                        if let Some(ref checkpointer) = self.graph.checkpointer {
404                            if let Some(ref thread_id) = self.config.thread_id {
405                                let cp = Checkpoint {
406                                    execution_id: thread_id.clone(),
407                                    timestamp: chrono::Utc::now(),
408                                    current_node: node_name.clone(),
409                                    iteration: self.iteration,
410                                    state: self.state.snapshot().await,
411                                    step_number,
412                                    active_nodes: current_superstep.clone(),
413                                };
414                                let _ = checkpointer.save(&cp).await;
415                            }
416                        }
417
418                        // Save state to checkpoint store
419                        if let Some(ref store) = self.graph.checkpoint_store {
420                            let state_data = self.state.export().await;
421                            store
422                                .save_state_snapshot(&self.run_id, &state_data)
423                                .await
424                                .map_err(|error| {
425                                    checkpoint_store_error(
426                                        CheckpointStoreOperation::SaveStateSnapshot,
427                                        error,
428                                    )
429                                })?;
430                        }
431
432                        return Err(AgentGraphError::InterruptError {
433                            node: node_name.clone(),
434                            value: None,
435                        });
436                    }
437                }
438            }
439
440            // Execute the superstep
441            let mut next_nodes = Vec::new();
442
443            if current_superstep.len() == 1 {
444                let node_name = &current_superstep[0];
445                let output = self.execute_single_node(node_name).await?;
446                let targets = self.resolve_output(node_name, output).await?;
447                next_nodes.extend(targets);
448            } else {
449                // Parallel execution
450                let snapshot_data = self.state.export().await;
451                let mut join_set = tokio::task::JoinSet::new();
452                let max_parallelism = self.config.max_parallelism.max(1);
453                let mut pending = current_superstep.iter();
454
455                for _ in 0..max_parallelism {
456                    let Some(node_name) = pending.next() else {
457                        break;
458                    };
459                    self.spawn_parallel_branch(&mut join_set, node_name).await?;
460                }
461
462                let mut branch_results = Vec::new();
463                let mut active_branches: std::collections::HashSet<String> = current_superstep
464                    .iter()
465                    .take(max_parallelism)
466                    .cloned()
467                    .collect();
468                while let Some(result) = join_set.join_next().await {
469                    let inner = result.map_err(|e| AgentGraphError::ExecutionError(e.to_string()));
470                    let branch = match inner {
471                        Ok(Ok(branch)) => {
472                            active_branches.remove(&branch.0);
473                            branch
474                        }
475                        Ok(Err(error)) => {
476                            self.failed_attempts += 1;
477                            // A branch failure is terminal for this superstep. Abort every
478                            // still-running sibling and drain the JoinSet before returning so
479                            // no task can continue producing effects after the parent fails.
480                            join_set.abort_all();
481                            while join_set.join_next().await.is_some() {}
482                            for node_id in active_branches {
483                                self.event_sink.emit(GraphEvent::NodeEnd {
484                                    run_id: self.run_id.clone(),
485                                    trace_id: self.legacy_trace_id(),
486                                    trace_ctx: Some(self.trace_ctx.clone()),
487                                    node_id,
488                                    outcome: NodeOutcomeKind::Interrupted,
489                                    attempt_id: None,
490                                    trial_id: None,
491                                });
492                            }
493                            self.event_sink.emit(GraphEvent::ParallelCancellation {
494                                run_id: self.run_id.clone(),
495                                trace_id: self.legacy_trace_id(),
496                                trace_ctx: Some(self.trace_ctx.clone()),
497                                external_effects_may_have_escaped: true,
498                            });
499                            return Err(error);
500                        }
501                        Err(error) => {
502                            self.failed_attempts += 1;
503                            join_set.abort_all();
504                            while join_set.join_next().await.is_some() {}
505                            for node_id in active_branches {
506                                self.event_sink.emit(GraphEvent::NodeEnd {
507                                    run_id: self.run_id.clone(),
508                                    trace_id: self.legacy_trace_id(),
509                                    trace_ctx: Some(self.trace_ctx.clone()),
510                                    node_id,
511                                    outcome: NodeOutcomeKind::Interrupted,
512                                    attempt_id: None,
513                                    trial_id: None,
514                                });
515                            }
516                            self.event_sink.emit(GraphEvent::ParallelCancellation {
517                                run_id: self.run_id.clone(),
518                                trace_id: self.legacy_trace_id(),
519                                trace_ctx: Some(self.trace_ctx.clone()),
520                                external_effects_may_have_escaped: true,
521                            });
522                            return Err(error);
523                        }
524                    };
525                    branch_results.push(branch);
526
527                    if let Some(node_name) = pending.next() {
528                        active_branches.insert(node_name.to_string());
529                        self.spawn_parallel_branch(&mut join_set, node_name).await?;
530                    }
531                }
532
533                let order: HashMap<&str, usize> = current_superstep
534                    .iter()
535                    .enumerate()
536                    .map(|(index, node)| (node.as_str(), index))
537                    .collect();
538                branch_results.sort_by_key(|(name, _, _)| {
539                    order.get(name.as_str()).copied().unwrap_or(usize::MAX)
540                });
541
542                self.merge_parallel_states(&snapshot_data, &branch_results)
543                    .await?;
544
545                for (name, _, output) in branch_results {
546                    let targets = self.resolve_output(&name, output).await?;
547                    next_nodes.extend(targets);
548                }
549            }
550
551            // Check interrupt_after
552            if let Some(ref interrupt_cfg) = self.graph.interrupt_config {
553                for node_name in &current_superstep {
554                    if interrupt_cfg.should_interrupt_after(node_name) {
555                        if let Some(ref checkpointer) = self.graph.checkpointer {
556                            if let Some(ref thread_id) = self.config.thread_id {
557                                let cp = Checkpoint {
558                                    execution_id: thread_id.clone(),
559                                    timestamp: chrono::Utc::now(),
560                                    current_node: node_name.clone(),
561                                    iteration: self.iteration,
562                                    state: self.state.snapshot().await,
563                                    step_number,
564                                    active_nodes: next_nodes.clone(),
565                                };
566                                let _ = checkpointer.save(&cp).await;
567                            }
568                        }
569
570                        if let Some(ref store) = self.graph.checkpoint_store {
571                            let state_data = self.state.export().await;
572                            store
573                                .save_state_snapshot(&self.run_id, &state_data)
574                                .await
575                                .map_err(|error| {
576                                    checkpoint_store_error(
577                                        CheckpointStoreOperation::SaveStateSnapshot,
578                                        error,
579                                    )
580                                })?;
581                        }
582
583                        return Err(AgentGraphError::InterruptError {
584                            node: node_name.clone(),
585                            value: None,
586                        });
587                    }
588                }
589            }
590
591            // Save checkpoint after superstep
592            if let Some(ref checkpointer) = self.graph.checkpointer {
593                if let Some(ref thread_id) = self.config.thread_id {
594                    let current = current_superstep.first().cloned().unwrap_or_default();
595                    let cp = Checkpoint {
596                        execution_id: thread_id.clone(),
597                        timestamp: chrono::Utc::now(),
598                        current_node: current,
599                        iteration: self.iteration,
600                        state: self.state.snapshot().await,
601                        step_number,
602                        active_nodes: next_nodes.clone(),
603                    };
604                    let _ = checkpointer.save(&cp).await;
605                }
606            }
607
608            // Save state snapshot to new checkpoint store
609            if let Some(ref store) = self.graph.checkpoint_store {
610                let state_data = self.state.export().await;
611                store
612                    .save_state_snapshot(&self.run_id, &state_data)
613                    .await
614                    .map_err(|error| {
615                        checkpoint_store_error(CheckpointStoreOperation::SaveStateSnapshot, error)
616                    })?;
617            }
618
619            // Emit superstep end
620            self.event_sink.emit(GraphEvent::SuperstepEnd {
621                run_id: self.run_id.clone(),
622                trace_id: loop_legacy_tid.clone(),
623                trace_ctx: Some(self.trace_ctx.clone()),
624                step: step_number,
625            });
626
627            // Deduplicate next nodes
628            let mut seen = std::collections::HashSet::new();
629            next_nodes.retain(|n| seen.insert(n.clone()));
630
631            self.iteration += 1;
632            step_number += 1;
633            current_superstep = next_nodes;
634        }
635
636        Ok(self.state.clone())
637    }
638
639    /// Execute a single node (sequential path).
640    async fn execute_single_node(&mut self, name: &str) -> Result<NodeOutput> {
641        self.total_attempts += 1;
642        self.executed_nodes.insert(name.to_string());
643        let node = self
644            .graph
645            .nodes
646            .get(name)
647            .cloned()
648            .ok_or_else(|| AgentGraphError::NodeNotFound(name.to_string()))?;
649
650        // AttemptId is stable across the whole retry family (I031).
651        let canonical_attempt_id = stack_ids::AttemptId::generate();
652        let family_attempt = self.total_attempts as u32;
653
654        // Derive legacy trace_id once at the compatibility boundary for all
655        // events emitted during this node execution.
656        let legacy_tid = self.legacy_trace_id();
657        let trace_ctx = Some(self.trace_ctx.clone());
658        let node_name = name.to_string();
659        let retry = self.graph.retry_policies.get(name).cloned();
660
661        let before = self.state.export().await;
662        let outcome = if let Some(ref executor) = self.graph.executor {
663            let executor = executor.clone();
664            let state = self.state.clone();
665            let config = self.config.clone();
666            execute_node_attempt_family(
667                move || {
668                    let executor = executor.clone();
669                    let node = node.clone();
670                    let state = state.clone();
671                    let config = config.clone();
672                    async move { executor.execute_node(node, state, config).await }
673                },
674                retry,
675                self.cancel_flag.clone(),
676                self.state.clone(),
677                self.event_sink.clone(),
678                self.graph.checkpoint_store.clone(),
679                self.run_id.clone(),
680                node_name.clone(),
681                legacy_tid.clone(),
682                trace_ctx.clone(),
683                family_attempt,
684                canonical_attempt_id.clone(),
685            )
686            .await
687        } else {
688            let state = self.state.clone();
689            let config = self.config.clone();
690            execute_node_attempt_family(
691                move || {
692                    let node = node.clone();
693                    let state = state.clone();
694                    let config = config.clone();
695                    async move { node.execute(&state, &config).await }
696                },
697                retry,
698                self.cancel_flag.clone(),
699                self.state.clone(),
700                self.event_sink.clone(),
701                self.graph.checkpoint_store.clone(),
702                self.run_id.clone(),
703                node_name.clone(),
704                legacy_tid.clone(),
705                trace_ctx.clone(),
706                family_attempt,
707                canonical_attempt_id.clone(),
708            )
709            .await
710        };
711
712        match outcome {
713            Ok(outcome) => {
714                // Track state updates for events
715                let after = self.state.export().await;
716                let mut updates = HashMap::new();
717                for (key, val) in &after {
718                    match before.get(key) {
719                        Some(old_val) if old_val != val => {
720                            updates.insert(key.clone(), val.clone());
721                        }
722                        None => {
723                            updates.insert(key.clone(), val.clone());
724                        }
725                        _ => {}
726                    }
727                }
728                if !updates.is_empty() {
729                    self.event_sink.emit(GraphEvent::StateUpdate {
730                        run_id: self.run_id.clone(),
731                        trace_id: legacy_tid.clone(),
732                        trace_ctx: trace_ctx.clone(),
733                        node_id: node_name.clone(),
734                        updates,
735                    });
736                }
737
738                self.event_sink.emit(GraphEvent::NodeEnd {
739                    run_id: self.run_id.clone(),
740                    trace_id: legacy_tid.clone(),
741                    trace_ctx,
742                    node_id: node_name,
743                    outcome: NodeOutcomeKind::Success,
744                    attempt_id: Some(canonical_attempt_id),
745                    trial_id: Some(outcome.trial_id),
746                });
747                Ok(outcome.output)
748            }
749            Err(failure) => {
750                self.failed_attempts += 1;
751                self.event_sink.emit(GraphEvent::NodeEnd {
752                    run_id: self.run_id.clone(),
753                    trace_id: legacy_tid,
754                    trace_ctx,
755                    node_id: node_name,
756                    outcome: failure.outcome,
757                    attempt_id: Some(canonical_attempt_id),
758                    trial_id: Some(failure.trial_id),
759                });
760                Err(failure.error)
761            }
762        }
763    }
764
765    /// Resolve the output of a node to a list of next node names.
766    async fn resolve_output(&self, node_name: &str, output: NodeOutput) -> Result<Vec<String>> {
767        match output {
768            NodeOutput::Done => self.get_edge_targets(node_name).await,
769            NodeOutput::Command(cmd) => match cmd.goto {
770                Navigation::Default => self.get_edge_targets(node_name).await,
771                Navigation::Node(n) => Ok(vec![n]),
772                Navigation::Nodes(ns) => Ok(ns),
773                Navigation::End => Ok(vec![END.to_string()]),
774                Navigation::Send(ops) => Ok(ops.into_iter().map(|op| op.node).collect()),
775            },
776        }
777    }
778
779    /// Get all edge targets from a node.
780    async fn get_edge_targets(&self, node_name: &str) -> Result<Vec<String>> {
781        let mut targets = Vec::new();
782        if let Some(edge_list) = self.graph.edges.get(node_name) {
783            for edge in edge_list {
784                match edge {
785                    EdgeType::Normal(to) => targets.push(to.clone()),
786                    EdgeType::Conditional(router) => {
787                        match router.route(&self.state, &self.config).await? {
788                            RouterOutput::Next(Some(n)) => targets.push(n),
789                            RouterOutput::Next(None) => {}
790                            RouterOutput::FanOut(ns) => targets.extend(ns),
791                        }
792                    }
793                }
794            }
795        }
796        Ok(targets)
797    }
798
799    /// Merge state changes from parallel branches back into the main state.
800    async fn merge_parallel_states(
801        &self,
802        snapshot: &HashMap<String, Value>,
803        branches: &[(String, AgentState, NodeOutput)],
804    ) -> Result<()> {
805        let mut changes: HashMap<String, Vec<Value>> = HashMap::new();
806
807        for (_, branch_state, _) in branches {
808            let branch_data = branch_state.export().await;
809            for (key, new_value) in &branch_data {
810                let changed = match snapshot.get(key) {
811                    Some(old_val) => old_val != new_value,
812                    None => true,
813                };
814                if changed {
815                    changes
816                        .entry(key.clone())
817                        .or_default()
818                        .push(new_value.clone());
819                }
820            }
821        }
822
823        for (key, values) in changes {
824            let base = snapshot.get(&key).cloned().unwrap_or(Value::Null);
825            let mut current = base;
826            for value in values {
827                current = self.state.apply_reducer(&key, &current, &value).await?;
828            }
829            self.state.set_raw(&key, current).await?;
830        }
831
832        Ok(())
833    }
834
835    async fn spawn_parallel_branch(
836        &mut self,
837        join_set: &mut tokio::task::JoinSet<Result<(String, AgentState, NodeOutput)>>,
838        node_name: &str,
839    ) -> Result<()> {
840        self.total_attempts += 1;
841        self.executed_nodes.insert(node_name.to_string());
842
843        let forked_state = self.state.fork().await;
844        let node = self
845            .graph
846            .nodes
847            .get(node_name)
848            .cloned()
849            .ok_or_else(|| AgentGraphError::NodeNotFound(node_name.to_string()))?;
850        let config = self.config.clone();
851        let name = node_name.to_string();
852        let retry_policy = self.graph.retry_policies.get(node_name).cloned();
853        let event_sink = self.event_sink.clone();
854        let run_id = self.run_id.clone();
855        let trace_id = self.legacy_trace_id();
856        let trace_ctx = Some(self.trace_ctx.clone());
857        let checkpoint_store = self.graph.checkpoint_store.clone();
858        let node_attempt_count = self.total_attempts as u32;
859        let cancel_flag = self.cancel_flag.clone();
860
861        if let Some(ref executor) = self.graph.executor {
862            let exec = executor.clone();
863            join_set.spawn(async move {
864                // AttemptId is stable across the retry family (I031).
865                let canonical_attempt_id = stack_ids::AttemptId::generate();
866
867                let before = forked_state.export().await;
868                let execution_state = forked_state.clone();
869                let outcome = execute_node_attempt_family(
870                    move || {
871                        let exec = exec.clone();
872                        let node = node.clone();
873                        let forked_state = execution_state.clone();
874                        let config = config.clone();
875                        async move { exec.execute_node(node, forked_state, config).await }
876                    },
877                    retry_policy,
878                    cancel_flag.clone(),
879                    forked_state.clone(),
880                    event_sink.clone(),
881                    checkpoint_store.clone(),
882                    run_id.clone(),
883                    name.clone(),
884                    trace_id.clone(),
885                    trace_ctx.clone(),
886                    node_attempt_count,
887                    canonical_attempt_id.clone(),
888                )
889                .await;
890
891                match outcome {
892                    Ok(outcome) => {
893                        let after = forked_state.export().await;
894                        let mut updates = HashMap::new();
895                        for (key, val) in &after {
896                            match before.get(key) {
897                                Some(old_val) if old_val != val => {
898                                    updates.insert(key.clone(), val.clone());
899                                }
900                                None => {
901                                    updates.insert(key.clone(), val.clone());
902                                }
903                                _ => {}
904                            }
905                        }
906                        if !updates.is_empty() {
907                            event_sink.emit(GraphEvent::StateUpdate {
908                                run_id: run_id.clone(),
909                                trace_id: trace_id.clone(),
910                                trace_ctx: trace_ctx.clone(),
911                                node_id: name.clone(),
912                                updates,
913                            });
914                        }
915
916                        event_sink.emit(GraphEvent::NodeEnd {
917                            run_id: run_id.clone(),
918                            trace_id: trace_id.clone(),
919                            trace_ctx: trace_ctx.clone(),
920                            node_id: name.clone(),
921                            outcome: NodeOutcomeKind::Success,
922                            attempt_id: Some(canonical_attempt_id),
923                            trial_id: Some(outcome.trial_id),
924                        });
925
926                        Ok::<_, AgentGraphError>((name, forked_state, outcome.output))
927                    }
928                    Err(failure) => {
929                        event_sink.emit(GraphEvent::NodeEnd {
930                            run_id: run_id.clone(),
931                            trace_id: trace_id.clone(),
932                            trace_ctx: trace_ctx.clone(),
933                            node_id: name.clone(),
934                            outcome: failure.outcome,
935                            attempt_id: Some(canonical_attempt_id),
936                            trial_id: Some(failure.trial_id),
937                        });
938                        Err(failure.error)
939                    }
940                }
941            });
942        } else {
943            join_set.spawn(async move {
944                // AttemptId is stable across the retry family (I031).
945                let canonical_attempt_id = stack_ids::AttemptId::generate();
946
947                let before = forked_state.export().await;
948                let execution_state = forked_state.clone();
949                let outcome = execute_node_attempt_family(
950                    move || {
951                        let node = node.clone();
952                        let forked_state = execution_state.clone();
953                        let config = config.clone();
954                        async move { node.execute(&forked_state, &config).await }
955                    },
956                    retry_policy,
957                    cancel_flag.clone(),
958                    forked_state.clone(),
959                    event_sink.clone(),
960                    checkpoint_store.clone(),
961                    run_id.clone(),
962                    name.clone(),
963                    trace_id.clone(),
964                    trace_ctx.clone(),
965                    node_attempt_count,
966                    canonical_attempt_id.clone(),
967                )
968                .await;
969
970                match outcome {
971                    Ok(outcome) => {
972                        let after = forked_state.export().await;
973                        let mut updates = HashMap::new();
974                        for (key, val) in &after {
975                            match before.get(key) {
976                                Some(old_val) if old_val != val => {
977                                    updates.insert(key.clone(), val.clone());
978                                }
979                                None => {
980                                    updates.insert(key.clone(), val.clone());
981                                }
982                                _ => {}
983                            }
984                        }
985                        if !updates.is_empty() {
986                            event_sink.emit(GraphEvent::StateUpdate {
987                                run_id: run_id.clone(),
988                                trace_id: trace_id.clone(),
989                                trace_ctx: trace_ctx.clone(),
990                                node_id: name.clone(),
991                                updates,
992                            });
993                        }
994
995                        event_sink.emit(GraphEvent::NodeEnd {
996                            run_id: run_id.clone(),
997                            trace_id: trace_id.clone(),
998                            trace_ctx: trace_ctx.clone(),
999                            node_id: name.clone(),
1000                            outcome: NodeOutcomeKind::Success,
1001                            attempt_id: Some(canonical_attempt_id),
1002                            trial_id: Some(outcome.trial_id),
1003                        });
1004
1005                        Ok::<_, AgentGraphError>((name, forked_state, outcome.output))
1006                    }
1007                    Err(failure) => {
1008                        event_sink.emit(GraphEvent::NodeEnd {
1009                            run_id: run_id.clone(),
1010                            trace_id: trace_id.clone(),
1011                            trace_ctx: trace_ctx.clone(),
1012                            node_id: name.clone(),
1013                            outcome: failure.outcome,
1014                            attempt_id: Some(canonical_attempt_id),
1015                            trial_id: Some(failure.trial_id),
1016                        });
1017                        Err(failure.error)
1018                    }
1019                }
1020            });
1021        }
1022
1023        Ok(())
1024    }
1025
1026    /// Build the run summary. `legacy_tid` is the pre-derived legacy trace ID
1027    /// from the run-level compatibility boundary.
1028    fn build_run_summary(&self, status: RunStatus, legacy_tid: String) -> RunSummary {
1029        RunSummary {
1030            run_id: self.run_id.clone(),
1031            graph_name: self
1032                .graph
1033                .graph_name
1034                .clone()
1035                .unwrap_or_else(|| "unnamed".to_string()),
1036            status,
1037            total_nodes_executed: self.executed_nodes.len(),
1038            total_attempts: self.total_attempts,
1039            failed_attempts: self.failed_attempts,
1040            trace_id: Some(legacy_tid),
1041            trace_ctx: Some(self.trace_ctx.clone()),
1042            started_at: self.started_at,
1043            finished_at: Some(chrono::Utc::now()),
1044        }
1045    }
1046}
1047
1048/// Successful execution outcome for a retry family.
1049struct AttemptFamilySuccess {
1050    output: NodeOutput,
1051    trial_id: stack_ids::TrialId,
1052}
1053
1054/// Final failure for a retry family after the last concrete trial ends.
1055struct AttemptFamilyFailure {
1056    error: AgentGraphError,
1057    outcome: NodeOutcomeKind,
1058    trial_id: stack_ids::TrialId,
1059}
1060
1061#[allow(clippy::too_many_arguments)]
1062async fn execute_node_attempt_family<ExecOnce, ExecFut>(
1063    mut exec_once: ExecOnce,
1064    retry: Option<RetryPolicy>,
1065    cancel_flag: Arc<AtomicBool>,
1066    state: AgentState,
1067    event_sink: Arc<dyn EventSink>,
1068    checkpoint_store: Option<Arc<dyn CheckpointStore>>,
1069    run_id: String,
1070    node_id: String,
1071    legacy_trace_id: String,
1072    trace_ctx: Option<stack_ids::TraceCtx>,
1073    family_attempt: u32,
1074    canonical_attempt_id: stack_ids::AttemptId,
1075) -> std::result::Result<AttemptFamilySuccess, AttemptFamilyFailure>
1076where
1077    ExecOnce: FnMut() -> ExecFut,
1078    ExecFut: Future<Output = Result<NodeOutput>>,
1079{
1080    let max_attempts = retry
1081        .as_ref()
1082        .map_or(1, |policy| policy.max_attempts.max(1));
1083
1084    for attempt_index in 0..max_attempts {
1085        if cancel_flag.load(Ordering::SeqCst) {
1086            return Err(AttemptFamilyFailure {
1087                error: AgentGraphError::Cancelled,
1088                outcome: NodeOutcomeKind::Interrupted,
1089                trial_id: stack_ids::TrialId::generate(),
1090            });
1091        }
1092        let trial_id = stack_ids::TrialId::generate();
1093        event_sink.emit(GraphEvent::NodeStart {
1094            run_id: run_id.clone(),
1095            trace_id: legacy_trace_id.clone(),
1096            trace_ctx: trace_ctx.clone(),
1097            node_id: node_id.clone(),
1098            attempt: family_attempt,
1099            attempt_id: Some(canonical_attempt_id.clone()),
1100            trial_id: Some(trial_id.clone()),
1101        });
1102
1103        let checkpoint_attempt_id = if let Some(ref store) = checkpoint_store {
1104            let state_val = serde_json::to_value(&state.export().await).unwrap_or(Value::Null);
1105            match store
1106                .record_attempt(&run_id, &node_id, attempt_index as u32, &state_val)
1107                .await
1108            {
1109                Ok(attempt_id) => Some(attempt_id),
1110                Err(error) => {
1111                    return Err(AttemptFamilyFailure {
1112                        error: checkpoint_store_error(
1113                            CheckpointStoreOperation::RecordAttempt,
1114                            error,
1115                        ),
1116                        outcome: NodeOutcomeKind::Failed,
1117                        trial_id,
1118                    });
1119                }
1120            }
1121        } else {
1122            None
1123        };
1124
1125        match exec_once().await {
1126            Ok(output) => {
1127                if let NodeOutput::Command(ref cmd) = output {
1128                    if let Some(ref updates) = cmd.update {
1129                        for (key, value) in updates {
1130                            if let Err(error) = state.set(key, value.clone()).await {
1131                                if let Some(ref store) = checkpoint_store {
1132                                    if let Some(ref attempt_id) = checkpoint_attempt_id {
1133                                        if let Err(store_error) =
1134                                            store.fail_attempt(attempt_id, &error.to_string()).await
1135                                        {
1136                                            return Err(AttemptFamilyFailure {
1137                                                error: checkpoint_store_error(
1138                                                    CheckpointStoreOperation::FailAttempt,
1139                                                    store_error,
1140                                                ),
1141                                                outcome: NodeOutcomeKind::Failed,
1142                                                trial_id: trial_id.clone(),
1143                                            });
1144                                        }
1145                                    }
1146                                }
1147                                return Err(AttemptFamilyFailure {
1148                                    error,
1149                                    outcome: NodeOutcomeKind::Failed,
1150                                    trial_id: trial_id.clone(),
1151                                });
1152                            }
1153                        }
1154                    }
1155                }
1156
1157                if let Some(ref store) = checkpoint_store {
1158                    if let Some(ref attempt_id) = checkpoint_attempt_id {
1159                        let mut meta = HashMap::new();
1160                        meta.insert(
1161                            "trace_id".to_string(),
1162                            Value::String(legacy_trace_id.clone()),
1163                        );
1164                        let state_val =
1165                            serde_json::to_value(&state.export().await).unwrap_or(Value::Null);
1166                        if let Err(error) =
1167                            store.complete_attempt(attempt_id, &state_val, &meta).await
1168                        {
1169                            return Err(AttemptFamilyFailure {
1170                                error: checkpoint_store_error(
1171                                    CheckpointStoreOperation::CompleteAttempt,
1172                                    error,
1173                                ),
1174                                outcome: NodeOutcomeKind::Failed,
1175                                trial_id: trial_id.clone(),
1176                            });
1177                        }
1178                    }
1179                }
1180
1181                return Ok(AttemptFamilySuccess { output, trial_id });
1182            }
1183            Err(error) => {
1184                if let Some(ref store) = checkpoint_store {
1185                    if let Some(ref attempt_id) = checkpoint_attempt_id {
1186                        if let Err(store_error) =
1187                            store.fail_attempt(attempt_id, &error.to_string()).await
1188                        {
1189                            return Err(AttemptFamilyFailure {
1190                                error: checkpoint_store_error(
1191                                    CheckpointStoreOperation::FailAttempt,
1192                                    store_error,
1193                                ),
1194                                outcome: NodeOutcomeKind::Failed,
1195                                trial_id,
1196                            });
1197                        }
1198                    }
1199                }
1200
1201                let outcome = if matches!(&error, AgentGraphError::InterruptError { .. }) {
1202                    NodeOutcomeKind::Interrupted
1203                } else {
1204                    NodeOutcomeKind::Failed
1205                };
1206
1207                let should_retry = retry.as_ref().is_some_and(|policy| {
1208                    attempt_index + 1 < max_attempts && policy.should_retry(&error)
1209                });
1210
1211                if should_retry {
1212                    event_sink.emit(GraphEvent::NodeEnd {
1213                        run_id: run_id.clone(),
1214                        trace_id: legacy_trace_id.clone(),
1215                        trace_ctx: trace_ctx.clone(),
1216                        node_id: node_id.clone(),
1217                        outcome: outcome.clone(),
1218                        attempt_id: Some(canonical_attempt_id.clone()),
1219                        trial_id: Some(trial_id.clone()),
1220                    });
1221                    let Some(policy) = retry.as_ref() else {
1222                        return Err(AttemptFamilyFailure {
1223                            error,
1224                            outcome,
1225                            trial_id,
1226                        });
1227                    };
1228                    let delay = policy.delay_for_attempt(attempt_index);
1229                    let mut remaining = delay;
1230                    loop {
1231                        if cancel_flag.load(Ordering::SeqCst) {
1232                            return Err(AttemptFamilyFailure {
1233                                error: AgentGraphError::Cancelled,
1234                                outcome: NodeOutcomeKind::Interrupted,
1235                                trial_id,
1236                            });
1237                        }
1238                        let tick = std::time::Duration::from_millis(10).min(remaining);
1239                        tokio::time::sleep(tick).await;
1240                        if remaining <= tick {
1241                            break;
1242                        }
1243                        remaining -= tick;
1244                    }
1245                } else {
1246                    return Err(AttemptFamilyFailure {
1247                        error,
1248                        outcome,
1249                        trial_id,
1250                    });
1251                }
1252            }
1253        }
1254    }
1255
1256    Err(AttemptFamilyFailure {
1257        error: AgentGraphError::ExecutionError("Retry exhausted with no error".to_string()),
1258        outcome: NodeOutcomeKind::Failed,
1259        trial_id: stack_ids::TrialId::generate(),
1260    })
1261}