1use std::collections::HashMap;
28use std::future::Future;
29use std::pin::Pin;
30use std::sync::Arc;
31use std::sync::atomic::{AtomicU32, Ordering};
32
33use serde::{Deserialize, Serialize};
34use tokio::sync::Mutex as AsyncMutex;
35
36use crate::tenant::TenantContext;
37
38use super::checkpoint::{CheckpointError, CheckpointStore, GraphRunRecord, RunStatus};
39use super::model::{GraphConfig, GraphError, NodeKind};
40use super::router::route;
41use super::state::{GraphRole, GraphRunState};
42
43pub type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
54
55#[derive(Debug, Clone)]
61pub struct AgentTurnRequest {
62 pub node_id: String,
64 pub system_prompt: String,
66 pub model: String,
68 pub state: GraphRunState,
70 pub provider: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AgentTurnResult {
79 pub reply: String,
81 pub resolved: bool,
83}
84
85#[derive(Debug, Clone)]
87pub struct ToolCallRequest {
88 pub node_id: String,
90 pub tool_name: String,
92 pub state: GraphRunState,
94}
95
96pub type AgentTurnFn = Arc<
102 dyn Fn(AgentTurnRequest) -> BoxFut<'static, Result<AgentTurnResult, GraphExecError>>
103 + Send
104 + Sync,
105>;
106
107pub type ToolFn = Arc<
109 dyn Fn(ToolCallRequest) -> BoxFut<'static, Result<serde_json::Value, GraphExecError>>
110 + Send
111 + Sync,
112>;
113
114#[derive(Debug, Clone)]
120pub struct SupervisorRequest {
121 pub node_id: String,
123 pub system_prompt: String,
125 pub model: String,
127 pub routes: Vec<crate::graph::model::SupervisorRoute>,
129 pub state: GraphRunState,
131 pub provider: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SupervisorResult {
140 pub branch: String,
142 pub raw_reply: String,
144}
145
146pub type SupervisorFn = Arc<
149 dyn Fn(SupervisorRequest) -> BoxFut<'static, Result<SupervisorResult, GraphExecError>>
150 + Send
151 + Sync,
152>;
153
154#[derive(Debug, Clone)]
160pub struct ApprovalRequest {
161 pub run_id: String,
163 pub node_id: String,
165 pub tenant: String,
167 pub title: String,
169 pub mode: String,
171 pub risk_threshold: Option<f64>,
173 pub confidence_threshold: Option<f64>,
175 pub deadline_ms: Option<u64>,
177 pub state: GraphRunState,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub enum ApprovalOutcome {
184 Awaiting,
188 Decided { branch: String },
191}
192
193pub type ApprovalFn = Arc<
197 dyn Fn(ApprovalRequest) -> BoxFut<'static, Result<ApprovalOutcome, GraphExecError>>
198 + Send
199 + Sync,
200>;
201
202#[derive(Debug, thiserror::Error)]
209pub enum GraphExecError {
210 #[error("graph run {run_id} exceeded the node-visit cap")]
211 IterationCap { run_id: String },
212
213 #[error("unknown node `{0}` (cursor corrupt or graph changed)")]
214 UnknownNode(String),
215
216 #[error("unknown run `{0}`")]
217 UnknownRun(String),
218
219 #[error("run `{0}` already completed")]
220 AlreadyCompleted(String),
221
222 #[error(transparent)]
223 Graph(#[from] GraphError),
224
225 #[error(transparent)]
226 Checkpoint(#[from] CheckpointError),
227
228 #[error("agent turn failed: {0}")]
229 AgentTurn(String),
230
231 #[error("tool call failed: {0}")]
232 Tool(String),
233
234 #[error("supervisor routing failed: {0}")]
235 Supervisor(String),
236}
237
238#[derive(Debug, Clone)]
244pub struct GraphRunOutcome {
245 pub status: RunStatus,
247 pub reply: String,
250 pub trail: Vec<serde_json::Value>,
261}
262
263pub const MAX_NODE_VISITS: u32 = 64;
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
291struct BranchCursor {
292 branch: String,
293 cursor: String,
294 state_json: String,
295 parked: bool,
296}
297
298struct FrontierCheckpoint {
341 run_id: String,
342 graph_json: String,
343 parallel_node: String,
344 trunk_state_json: String,
346 trunk_visits: HashMap<String, u32>,
347 inner: AsyncMutex<Vec<BranchCursor>>,
348 branch_visits: AsyncMutex<HashMap<String, u32>>,
351}
352
353pub struct GraphExecutor {
360 store: Arc<dyn CheckpointStore>,
361 agent_turn: AgentTurnFn,
362 tool: ToolFn,
363 supervisor: SupervisorFn,
364 approval: ApprovalFn,
365}
366
367impl GraphExecutor {
368 pub fn new(
370 store: Arc<dyn CheckpointStore>,
371 agent_turn: AgentTurnFn,
372 tool: ToolFn,
373 supervisor: SupervisorFn,
374 approval: ApprovalFn,
375 ) -> Self {
376 Self {
377 store,
378 agent_turn,
379 tool,
380 supervisor,
381 approval,
382 }
383 }
384
385 pub async fn start(
396 &self,
397 tenant: &TenantContext,
398 run_id: &str,
399 cfg: &GraphConfig,
400 user_text: &str,
401 ) -> Result<GraphRunOutcome, GraphExecError> {
402 if let Some(existing) = self.store.load(tenant, run_id).await? {
404 return match existing.status {
405 RunStatus::Running | RunStatus::AwaitingInput => {
412 self.drive_from_record(tenant, run_id, existing).await
414 }
415 RunStatus::Succeeded | RunStatus::Failed => {
416 Err(GraphExecError::AlreadyCompleted(run_id.to_owned()))
417 }
418 };
419 }
420
421 let mut state = GraphRunState::default();
423 state.push_message(GraphRole::User, user_text);
424
425 let graph_json = serde_json::to_string(cfg)
426 .map_err(|e| GraphExecError::Checkpoint(CheckpointError::Serde(e)))?;
427 let cursor = cfg.graph.entry.clone();
428 let visits: HashMap<String, u32> = HashMap::new();
429
430 let rec = build_record(
431 run_id,
432 &graph_json,
433 &cursor,
434 &state,
435 &visits,
436 RunStatus::Running,
437 )?;
438 self.store.save(tenant, &rec).await?;
439
440 self.drive(tenant, run_id, cfg.clone(), cursor, state, visits)
441 .await
442 }
443
444 pub async fn resume(
450 &self,
451 tenant: &TenantContext,
452 run_id: &str,
453 ) -> Result<GraphRunOutcome, GraphExecError> {
454 let rec = self
455 .store
456 .load(tenant, run_id)
457 .await?
458 .ok_or_else(|| GraphExecError::UnknownRun(run_id.to_owned()))?;
459
460 match rec.status {
461 RunStatus::Succeeded | RunStatus::Failed => {
462 let state: GraphRunState =
464 serde_json::from_str(&rec.state_json).map_err(CheckpointError::Serde)?;
465 let reply = last_assistant_message(&state);
466 let trail = rebuild_trail_from_state(&state);
467 Ok(GraphRunOutcome {
468 status: rec.status,
469 reply,
470 trail,
471 })
472 }
473 RunStatus::Running | RunStatus::AwaitingInput => {
477 self.drive_from_record(tenant, run_id, rec).await
478 }
479 }
480 }
481
482 async fn drive_from_record(
488 &self,
489 tenant: &TenantContext,
490 run_id: &str,
491 rec: GraphRunRecord,
492 ) -> Result<GraphRunOutcome, GraphExecError> {
493 let cfg: GraphConfig = GraphConfig::from_json(&rec.graph_json)?;
494 let state: GraphRunState =
495 serde_json::from_str(&rec.state_json).map_err(CheckpointError::Serde)?;
496 let visits: HashMap<String, u32> =
497 serde_json::from_str(&rec.visits_json).map_err(CheckpointError::Serde)?;
498
499 if let Some(frontier_json) = &rec.frontier_json {
504 let frontier: Vec<BranchCursor> =
505 serde_json::from_str(frontier_json).map_err(CheckpointError::Serde)?;
506 return self
507 .resume_parallel(tenant, run_id, &cfg, &rec.cursor, state, visits, frontier)
508 .await;
509 }
510
511 let cursor = rec.cursor.clone();
512 self.drive(tenant, run_id, cfg, cursor, state, visits).await
513 }
514
515 async fn drive(
526 &self,
527 tenant: &TenantContext,
528 run_id: &str,
529 cfg: GraphConfig,
530 mut cursor: String,
531 mut state: GraphRunState,
532 mut visits: HashMap<String, u32>,
533 ) -> Result<GraphRunOutcome, GraphExecError> {
534 let mut trail: Vec<serde_json::Value> = Vec::new();
535
536 for _ in 0..MAX_NODE_VISITS {
537 let node = cfg
538 .graph
539 .node(&cursor)
540 .ok_or_else(|| GraphExecError::UnknownNode(cursor.clone()))?
541 .clone();
542
543 match &node.kind {
544 NodeKind::Agent {
545 system_prompt,
546 model,
547 provider,
548 ..
549 } => {
550 let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
551
552 let node_id_for_err = cursor.clone();
554 let provider_clone = provider.clone();
555 let (raw, replayed) = self
556 .visit_effect(tenant, run_id, &cursor, attempt, || {
557 let req = AgentTurnRequest {
558 node_id: node_id_for_err.clone(),
559 system_prompt: system_prompt.clone(),
560 model: model.clone(),
561 state: state.clone(),
562 provider: provider_clone,
563 };
564 let fut = (self.agent_turn)(req);
565 Box::pin(async move {
566 let r = fut.await.map_err(|e| {
567 GraphExecError::AgentTurn(format!(
568 "node '{}' attempt {}: {}",
569 node_id_for_err, attempt, e
570 ))
571 })?;
572 serde_json::to_value(&r)
573 .map_err(CheckpointError::Serde)
574 .map_err(GraphExecError::Checkpoint)
575 })
576 })
577 .await?;
578
579 let result: AgentTurnResult =
580 serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
581
582 trail.push(serde_json::json!({
583 "node": cursor,
584 "kind": "agent",
585 "attempt": attempt,
586 "replayed": replayed,
587 }));
588
589 visits.insert(cursor.clone(), attempt);
591 state.iterations += 1;
592 state.push_message(GraphRole::Assistant, &result.reply);
593 if result.resolved {
594 state.resolved = true;
595 }
596
597 cursor = next_linear(&cfg, &cursor)?;
599
600 let rec = build_record(
602 run_id,
603 &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
604 &cursor,
605 &state,
606 &visits,
607 RunStatus::Running,
608 )?;
609 self.store.save(tenant, &rec).await?;
610 }
611
612 NodeKind::Tool { tool_name } => {
613 let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
614
615 let node_id_for_err = cursor.clone();
617 let (result, replayed) = self
618 .visit_effect(tenant, run_id, &cursor, attempt, || {
619 let req = ToolCallRequest {
620 node_id: node_id_for_err.clone(),
621 tool_name: tool_name.clone(),
622 state: state.clone(),
623 };
624 let fut = (self.tool)(req);
625 Box::pin(async move {
626 fut.await.map_err(|e| {
627 GraphExecError::Tool(format!(
628 "node '{}' attempt {}: {}",
629 node_id_for_err, attempt, e
630 ))
631 })
632 })
633 })
634 .await?;
635
636 trail.push(serde_json::json!({
637 "node": cursor,
638 "kind": "tool",
639 "attempt": attempt,
640 "replayed": replayed,
641 }));
642
643 visits.insert(cursor.clone(), attempt);
644 state.push_message(GraphRole::Tool, result.to_string());
645
646 cursor = next_linear(&cfg, &cursor)?;
647
648 let rec = build_record(
649 run_id,
650 &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
651 &cursor,
652 &state,
653 &visits,
654 RunStatus::Running,
655 )?;
656 self.store.save(tenant, &rec).await?;
657 }
658
659 NodeKind::Router { .. } => {
660 let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
661
662 let next = route(&cfg.graph, &cursor, &state)?;
663
664 trail.push(serde_json::json!({
665 "node": cursor,
666 "kind": "router",
667 "attempt": attempt,
668 "replayed": false,
669 }));
670
671 visits.insert(cursor.clone(), attempt);
672 cursor = next;
673
674 let rec = build_record(
675 run_id,
676 &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
677 &cursor,
678 &state,
679 &visits,
680 RunStatus::Running,
681 )?;
682 self.store.save(tenant, &rec).await?;
683 }
684
685 NodeKind::Respond => {
686 let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
687
688 trail.push(serde_json::json!({
689 "node": cursor,
690 "kind": "respond",
691 "attempt": attempt,
692 "replayed": false,
693 }));
694
695 visits.insert(cursor.clone(), attempt);
696
697 let rec = build_record(
699 run_id,
700 &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
701 &cursor,
702 &state,
703 &visits,
704 RunStatus::Succeeded,
705 )?;
706 self.store.save(tenant, &rec).await?;
707
708 let reply = last_assistant_message(&state);
709 return Ok(GraphRunOutcome {
710 status: RunStatus::Succeeded,
711 reply,
712 trail,
713 });
714 }
715
716 NodeKind::Supervisor {
717 system_prompt,
718 model,
719 routes,
720 provider,
721 } => {
722 let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
723 let node_id_for_err = cursor.clone();
724 let routes_clone = routes.clone();
725 let system_prompt_clone = system_prompt.clone();
726 let model_clone = model.clone();
727 let provider_clone = provider.clone();
728
729 let (raw, replayed) = self
730 .visit_effect(tenant, run_id, &cursor, attempt, || {
731 let req = SupervisorRequest {
732 node_id: node_id_for_err.clone(),
733 system_prompt: system_prompt_clone,
734 model: model_clone,
735 routes: routes_clone.clone(),
736 state: state.clone(),
737 provider: provider_clone,
738 };
739 let fut = (self.supervisor)(req);
740 Box::pin(async move {
741 let r = fut.await.map_err(|e| {
742 GraphExecError::Supervisor(format!(
743 "node '{}' attempt {}: {}",
744 node_id_for_err, attempt, e
745 ))
746 })?;
747 serde_json::to_value(&r)
748 .map_err(CheckpointError::Serde)
749 .map_err(GraphExecError::Checkpoint)
750 })
751 })
752 .await?;
753
754 let result: SupervisorResult =
755 serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
756
757 let branch = &result.branch;
760 let branch_is_valid_route = routes.iter().any(|r| &r.branch == branch);
761 let matching_edge = cfg
762 .graph
763 .edges_from(&cursor)
764 .find(|e| e.branch.as_deref() == Some(branch.as_str()));
765
766 let next_cursor = match (branch_is_valid_route, matching_edge) {
767 (true, Some(edge)) => edge.to.clone(),
768 _ => {
769 return Err(GraphExecError::Graph(super::model::GraphError::Invalid(
770 format!(
771 "supervisor node '{}': branch '{}' returned by supervisor \
772 does not match any declared route or outgoing edge",
773 cursor, branch
774 ),
775 )));
776 }
777 };
778
779 trail.push(serde_json::json!({
780 "node": cursor,
781 "kind": "supervisor",
782 "attempt": attempt,
783 "replayed": replayed,
784 "branch": result.branch,
785 }));
786
787 visits.insert(cursor.clone(), attempt);
788 state.push_message(GraphRole::Assistant, &result.raw_reply);
789
790 cursor = next_cursor;
792
793 let rec = build_record(
794 run_id,
795 &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
796 &cursor,
797 &state,
798 &visits,
799 RunStatus::Running,
800 )?;
801 self.store.save(tenant, &rec).await?;
802 }
803
804 NodeKind::Parallel => {
810 let (next_cursor, merged_state, merged_visits, region_trail) = self
811 .run_parallel_region(tenant, run_id, &cfg, &cursor, &state, &visits)
812 .await?;
813 trail.extend(region_trail);
814 cursor = next_cursor;
815 state = merged_state;
816 visits = merged_visits;
817 }
819
820 NodeKind::Join => {
827 tracing::warn!(
828 node = %cursor,
829 "trunk drive loop reached a join node directly; \
830 passing through to its successor (expected to be \
831 consumed by the parallel arm)"
832 );
833 cursor = next_linear(&cfg, &cursor)?;
834 }
835
836 NodeKind::Approval {
847 title,
848 mode,
849 risk_threshold,
850 confidence_threshold,
851 deadline_ms,
852 } => {
853 let req = ApprovalRequest {
854 run_id: run_id.to_string(),
855 node_id: cursor.clone(),
856 tenant: tenant.tenant_id.clone(),
857 title: title.clone(),
858 mode: mode.clone(),
859 risk_threshold: *risk_threshold,
860 confidence_threshold: *confidence_threshold,
861 deadline_ms: *deadline_ms,
862 state: state.clone(),
863 };
864
865 match (self.approval)(req).await? {
866 ApprovalOutcome::Awaiting => {
867 let rec = build_record(
872 run_id,
873 &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
874 &cursor,
875 &state,
876 &visits,
877 RunStatus::AwaitingInput,
878 )?;
879 self.store.save(tenant, &rec).await?;
880
881 let reply = last_assistant_message(&state);
882 return Ok(GraphRunOutcome {
883 status: RunStatus::AwaitingInput,
884 reply,
885 trail,
886 });
887 }
888 ApprovalOutcome::Decided { branch } => {
889 let matching_edge = cfg
893 .graph
894 .edges_from(&cursor)
895 .find(|e| e.branch.as_deref() == Some(branch.as_str()));
896 let next_cursor = match matching_edge {
897 Some(edge) => edge.to.clone(),
898 None => {
899 return Err(GraphExecError::Graph(GraphError::Invalid(
900 format!(
901 "approval node '{}': decision branch '{}' does not \
902 match any outgoing edge",
903 cursor, branch
904 ),
905 )));
906 }
907 };
908
909 let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
910 self.store
911 .record_node_visit(
912 tenant,
913 run_id,
914 &cursor,
915 attempt,
916 &serde_json::json!({"decision": branch}),
917 )
918 .await?;
919
920 trail.push(serde_json::json!({
921 "node": cursor,
922 "kind": "approval",
923 "attempt": attempt,
924 "replayed": false,
925 "branch": branch,
926 }));
927
928 visits.insert(cursor.clone(), attempt);
929 cursor = next_cursor;
930
931 let rec = build_record(
932 run_id,
933 &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
934 &cursor,
935 &state,
936 &visits,
937 RunStatus::Running,
938 )?;
939 self.store.save(tenant, &rec).await?;
940 }
941 }
942 }
943 }
944 }
945
946 let graph_json = serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?;
948 let rec = build_record(
949 run_id,
950 &graph_json,
951 &cursor,
952 &state,
953 &visits,
954 RunStatus::Failed,
955 )?;
956 self.store.save(tenant, &rec).await?;
957
958 Err(GraphExecError::IterationCap {
959 run_id: run_id.to_owned(),
960 })
961 }
962
963 async fn visit_effect(
975 &self,
976 tenant: &TenantContext,
977 run_id: &str,
978 node_id: &str,
979 attempt: u32,
980 invoke: impl FnOnce() -> BoxFut<'static, Result<serde_json::Value, GraphExecError>>,
981 ) -> Result<(serde_json::Value, bool), GraphExecError> {
982 if let Some(cached) = self
983 .store
984 .load_node_visit(tenant, run_id, node_id, attempt)
985 .await?
986 {
987 return Ok((cached, true));
988 }
989
990 let value = invoke().await?;
992 self.store
994 .record_node_visit(tenant, run_id, node_id, attempt, &value)
995 .await?;
996 Ok((value, false))
997 }
998
999 #[allow(clippy::type_complexity)]
1009 async fn run_parallel_region(
1010 &self,
1011 tenant: &TenantContext,
1012 run_id: &str,
1013 cfg: &GraphConfig,
1014 parallel_node: &str,
1015 trunk_state: &GraphRunState,
1016 trunk_visits: &HashMap<String, u32>,
1017 ) -> Result<
1018 (
1019 String,
1020 GraphRunState,
1021 HashMap<String, u32>,
1022 Vec<serde_json::Value>,
1023 ),
1024 GraphExecError,
1025 > {
1026 let mut branch_edges: Vec<(String, String)> = cfg
1028 .graph
1029 .edges_from(parallel_node)
1030 .filter_map(|e| e.branch.clone().map(|b| (b, e.to.clone())))
1031 .collect();
1032 branch_edges.sort_by(|a, b| a.0.cmp(&b.0));
1033
1034 let join_id = find_join_for_parallel(cfg, parallel_node)?;
1036
1037 let trunk_state_json =
1039 serde_json::to_string(trunk_state).map_err(CheckpointError::Serde)?;
1040 let frontier: Vec<BranchCursor> = branch_edges
1041 .iter()
1042 .map(|(branch, target)| BranchCursor {
1043 branch: branch.clone(),
1044 cursor: target.clone(),
1045 state_json: trunk_state_json.clone(),
1046 parked: false,
1047 })
1048 .collect();
1049
1050 self.drive_frontier(
1051 tenant,
1052 run_id,
1053 cfg,
1054 parallel_node,
1055 &join_id,
1056 trunk_state,
1057 trunk_visits,
1058 frontier,
1059 true,
1060 )
1061 .await
1062 }
1063
1064 #[allow(clippy::too_many_arguments, clippy::type_complexity)]
1069 async fn resume_parallel(
1070 &self,
1071 tenant: &TenantContext,
1072 run_id: &str,
1073 cfg: &GraphConfig,
1074 parallel_node: &str,
1075 trunk_state: GraphRunState,
1076 trunk_visits: HashMap<String, u32>,
1077 frontier: Vec<BranchCursor>,
1078 ) -> Result<GraphRunOutcome, GraphExecError> {
1079 let join_id = find_join_for_parallel(cfg, parallel_node)?;
1080
1081 let (next_cursor, merged_state, merged_visits, _region_trail) = self
1082 .drive_frontier(
1083 tenant,
1084 run_id,
1085 cfg,
1086 parallel_node,
1087 &join_id,
1088 &trunk_state,
1089 &trunk_visits,
1090 frontier,
1091 false,
1092 )
1093 .await?;
1094
1095 self.drive(
1098 tenant,
1099 run_id,
1100 cfg.clone(),
1101 next_cursor,
1102 merged_state,
1103 merged_visits,
1104 )
1105 .await
1106 }
1107
1108 #[allow(clippy::too_many_arguments, clippy::type_complexity)]
1114 async fn drive_frontier(
1115 &self,
1116 tenant: &TenantContext,
1117 run_id: &str,
1118 cfg: &GraphConfig,
1119 parallel_node: &str,
1120 join_id: &str,
1121 trunk_state: &GraphRunState,
1122 trunk_visits: &HashMap<String, u32>,
1123 frontier: Vec<BranchCursor>,
1124 persist_before_driving: bool,
1125 ) -> Result<
1126 (
1127 String,
1128 GraphRunState,
1129 HashMap<String, u32>,
1130 Vec<serde_json::Value>,
1131 ),
1132 GraphExecError,
1133 > {
1134 let graph_json = serde_json::to_string(cfg).map_err(CheckpointError::Serde)?;
1135 let trunk_state_json =
1136 serde_json::to_string(trunk_state).map_err(CheckpointError::Serde)?;
1137
1138 let coord = Arc::new(FrontierCheckpoint {
1139 run_id: run_id.to_owned(),
1140 graph_json: graph_json.clone(),
1141 parallel_node: parallel_node.to_owned(),
1142 trunk_state_json,
1143 trunk_visits: trunk_visits.clone(),
1144 inner: AsyncMutex::new(frontier.clone()),
1145 branch_visits: AsyncMutex::new(HashMap::new()),
1146 });
1147
1148 if persist_before_driving {
1150 coord.checkpoint(self.store.as_ref(), tenant).await?;
1151 }
1152
1153 let trunk_visit_total: u32 = trunk_visits.values().copied().sum();
1155 let global_visits = Arc::new(AtomicU32::new(trunk_visit_total));
1156
1157 let mut futs: Vec<BoxFut<'_, Result<BranchOutcome, GraphExecError>>> = Vec::new();
1159 for (slot, bc) in frontier.iter().enumerate() {
1160 if bc.parked {
1161 continue;
1162 }
1163 let bc = bc.clone();
1164 let coord = coord.clone();
1165 let global_visits = global_visits.clone();
1166 futs.push(Box::pin(self.drive_branch(
1167 tenant,
1168 run_id,
1169 cfg,
1170 join_id,
1171 slot,
1172 bc,
1173 trunk_visits.clone(),
1174 coord,
1175 global_visits,
1176 )));
1177 }
1178
1179 let results = futures::future::join_all(futs).await;
1188
1189 let mut first_err: Option<GraphExecError> = None;
1190 let mut trail: Vec<serde_json::Value> = Vec::new();
1191 let mut merged_visits = trunk_visits.clone();
1192 for r in results {
1193 match r {
1194 Ok(outcome) => {
1195 trail.extend(outcome.trail);
1196 for (k, v) in outcome.visits {
1198 merged_visits.insert(k, v);
1199 }
1200 }
1201 Err(e) if first_err.is_none() => first_err = Some(e),
1202 Err(_) => { }
1203 }
1204 }
1205 if let Some(e) = first_err {
1206 if matches!(e, GraphExecError::IterationCap { .. }) {
1212 let last_frontier = coord.inner.lock().await.clone();
1213 let frontier_json =
1214 Some(serde_json::to_string(&last_frontier).map_err(CheckpointError::Serde)?);
1215 let rec = build_record_with_frontier(
1216 run_id,
1217 &graph_json,
1218 parallel_node,
1219 trunk_state,
1220 trunk_visits,
1221 RunStatus::Failed,
1222 frontier_json,
1223 )?;
1224 self.store.save(tenant, &rec).await?;
1225 }
1226 return Err(e);
1227 }
1228
1229 let mut ordered = coord.inner.lock().await.clone();
1233 ordered.sort_by(|a, b| a.branch.cmp(&b.branch));
1234
1235 let snapshot_len = trunk_state.messages.len();
1236 let mut merged_state = trunk_state.clone();
1237
1238 for bc in &ordered {
1239 let branch_state: GraphRunState =
1240 serde_json::from_str(&bc.state_json).map_err(CheckpointError::Serde)?;
1241
1242 for msg in branch_state.messages.iter().skip(snapshot_len) {
1244 merged_state.messages.push(msg.clone());
1245 }
1246 if branch_state.resolved {
1248 merged_state.resolved = true;
1249 }
1250 merged_state.iterations = merged_state.iterations.max(branch_state.iterations);
1252 if !branch_state.scratchpad.is_null() {
1254 if !merged_state.scratchpad.is_object() {
1255 merged_state.scratchpad = serde_json::json!({});
1256 }
1257 if let Some(obj) = merged_state.scratchpad.as_object_mut() {
1258 let branches = obj
1259 .entry("branches")
1260 .or_insert_with(|| serde_json::json!({}));
1261 if let Some(branches_obj) = branches.as_object_mut() {
1262 branches_obj.insert(bc.branch.clone(), branch_state.scratchpad.clone());
1263 }
1264 }
1265 }
1266 }
1267
1268 for (k, v) in coord.collect_branch_visits().await {
1271 merged_visits.entry(k).or_insert(v);
1272 }
1273
1274 let next_cursor = next_linear(cfg, join_id)?;
1276
1277 let rec = build_record(
1280 run_id,
1281 &graph_json,
1282 &next_cursor,
1283 &merged_state,
1284 &merged_visits,
1285 RunStatus::Running,
1286 )?;
1287 self.store.save(tenant, &rec).await?;
1288
1289 Ok((next_cursor, merged_state, merged_visits, trail))
1290 }
1291
1292 #[allow(clippy::too_many_arguments)]
1296 fn drive_branch<'a>(
1297 &'a self,
1298 tenant: &'a TenantContext,
1299 run_id: &'a str,
1300 cfg: &'a GraphConfig,
1301 join_id: &'a str,
1302 slot: usize,
1303 mut bc: BranchCursor,
1304 mut visits: HashMap<String, u32>,
1305 coord: Arc<FrontierCheckpoint>,
1306 global_visits: Arc<AtomicU32>,
1307 ) -> BoxFut<'a, Result<BranchOutcome, GraphExecError>> {
1308 Box::pin(async move {
1309 let mut state: GraphRunState =
1310 serde_json::from_str(&bc.state_json).map_err(CheckpointError::Serde)?;
1311 let mut trail: Vec<serde_json::Value> = Vec::new();
1312 let branch_label = bc.branch.clone();
1313
1314 loop {
1315 if bc.cursor == join_id {
1317 bc.parked = true;
1318 bc.state_json =
1319 serde_json::to_string(&state).map_err(CheckpointError::Serde)?;
1320 coord
1321 .save_slot(self.store.as_ref(), tenant, slot, &bc)
1322 .await?;
1323 return Ok(BranchOutcome {
1324 branch: branch_label,
1325 visits,
1326 trail,
1327 });
1328 }
1329
1330 let total = global_visits.fetch_add(1, Ordering::SeqCst) + 1;
1337 if total > MAX_NODE_VISITS {
1338 return Err(GraphExecError::IterationCap {
1339 run_id: run_id.to_owned(),
1340 });
1341 }
1342
1343 let node = cfg
1344 .graph
1345 .node(&bc.cursor)
1346 .ok_or_else(|| GraphExecError::UnknownNode(bc.cursor.clone()))?
1347 .clone();
1348
1349 match &node.kind {
1350 NodeKind::Agent {
1351 system_prompt,
1352 model,
1353 provider,
1354 ..
1355 } => {
1356 let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1357 let node_id = bc.cursor.clone();
1358 let sp = system_prompt.clone();
1359 let md = model.clone();
1360 let pv = provider.clone();
1361 let state_for_call = state.clone();
1362 let (raw, replayed) = self
1363 .visit_effect(tenant, run_id, &bc.cursor, attempt, || {
1364 let req = AgentTurnRequest {
1365 node_id: node_id.clone(),
1366 system_prompt: sp,
1367 model: md,
1368 state: state_for_call,
1369 provider: pv,
1370 };
1371 let fut = (self.agent_turn)(req);
1372 Box::pin(async move {
1373 let r = fut.await.map_err(|e| {
1374 GraphExecError::AgentTurn(format!(
1375 "node '{}' attempt {}: {}",
1376 node_id, attempt, e
1377 ))
1378 })?;
1379 serde_json::to_value(&r)
1380 .map_err(CheckpointError::Serde)
1381 .map_err(GraphExecError::Checkpoint)
1382 })
1383 })
1384 .await?;
1385 let result: AgentTurnResult =
1386 serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
1387 trail.push(serde_json::json!({
1388 "node": bc.cursor, "kind": "agent",
1389 "attempt": attempt, "replayed": replayed, "branch": branch_label,
1390 }));
1391 visits.insert(bc.cursor.clone(), attempt);
1392 state.iterations += 1;
1393 state.push_message(GraphRole::Assistant, &result.reply);
1394 if result.resolved {
1395 state.resolved = true;
1396 }
1397 bc.cursor = next_linear(cfg, &bc.cursor)?;
1398 }
1399 NodeKind::Tool { tool_name } => {
1400 let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1401 let node_id = bc.cursor.clone();
1402 let tn = tool_name.clone();
1403 let state_for_call = state.clone();
1404 let (result, replayed) = self
1405 .visit_effect(tenant, run_id, &bc.cursor, attempt, || {
1406 let req = ToolCallRequest {
1407 node_id: node_id.clone(),
1408 tool_name: tn,
1409 state: state_for_call,
1410 };
1411 let fut = (self.tool)(req);
1412 Box::pin(async move {
1413 fut.await.map_err(|e| {
1414 GraphExecError::Tool(format!(
1415 "node '{}' attempt {}: {}",
1416 node_id, attempt, e
1417 ))
1418 })
1419 })
1420 })
1421 .await?;
1422 trail.push(serde_json::json!({
1423 "node": bc.cursor, "kind": "tool",
1424 "attempt": attempt, "replayed": replayed, "branch": branch_label,
1425 }));
1426 visits.insert(bc.cursor.clone(), attempt);
1427 state.push_message(GraphRole::Tool, result.to_string());
1428 bc.cursor = next_linear(cfg, &bc.cursor)?;
1429 }
1430 NodeKind::Router { .. } => {
1431 let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1432 let next = route(&cfg.graph, &bc.cursor, &state)?;
1433 trail.push(serde_json::json!({
1434 "node": bc.cursor, "kind": "router",
1435 "attempt": attempt, "replayed": false, "branch": branch_label,
1436 }));
1437 visits.insert(bc.cursor.clone(), attempt);
1438 bc.cursor = next;
1439 }
1440 NodeKind::Supervisor {
1441 system_prompt,
1442 model,
1443 routes,
1444 provider,
1445 } => {
1446 let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1447 let node_id = bc.cursor.clone();
1448 let sp = system_prompt.clone();
1449 let md = model.clone();
1450 let pv = provider.clone();
1451 let routes_clone = routes.clone();
1452 let state_for_call = state.clone();
1453 let (raw, replayed) = self
1454 .visit_effect(tenant, run_id, &bc.cursor, attempt, || {
1455 let req = SupervisorRequest {
1456 node_id: node_id.clone(),
1457 system_prompt: sp,
1458 model: md,
1459 routes: routes_clone.clone(),
1460 state: state_for_call,
1461 provider: pv,
1462 };
1463 let fut = (self.supervisor)(req);
1464 Box::pin(async move {
1465 let r = fut.await.map_err(|e| {
1466 GraphExecError::Supervisor(format!(
1467 "node '{}' attempt {}: {}",
1468 node_id, attempt, e
1469 ))
1470 })?;
1471 serde_json::to_value(&r)
1472 .map_err(CheckpointError::Serde)
1473 .map_err(GraphExecError::Checkpoint)
1474 })
1475 })
1476 .await?;
1477 let result: SupervisorResult =
1478 serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
1479 let branch = &result.branch;
1480 let matching_edge = cfg
1481 .graph
1482 .edges_from(&bc.cursor)
1483 .find(|e| e.branch.as_deref() == Some(branch.as_str()));
1484 let next_cursor =
1485 match (routes.iter().any(|r| &r.branch == branch), matching_edge) {
1486 (true, Some(edge)) => edge.to.clone(),
1487 _ => {
1488 return Err(GraphExecError::Graph(GraphError::Invalid(
1489 format!(
1490 "supervisor node '{}': branch '{}' does not match any \
1491 declared route or outgoing edge",
1492 bc.cursor, branch
1493 ),
1494 )));
1495 }
1496 };
1497 trail.push(serde_json::json!({
1498 "node": bc.cursor, "kind": "supervisor",
1499 "attempt": attempt, "replayed": replayed,
1500 "branch": result.branch, "branch_path": branch_label,
1501 }));
1502 visits.insert(bc.cursor.clone(), attempt);
1503 state.push_message(GraphRole::Assistant, &result.raw_reply);
1504 bc.cursor = next_cursor;
1505 }
1506 NodeKind::Respond => {
1507 return Err(GraphExecError::Graph(GraphError::Invalid(format!(
1509 "respond node '{}' inside parallel branch '{}' is not allowed",
1510 bc.cursor, branch_label
1511 ))));
1512 }
1513 NodeKind::Parallel | NodeKind::Join => {
1514 return Err(GraphExecError::Graph(GraphError::Invalid(format!(
1517 "branch '{}' reached unexpected '{}' node '{}'",
1518 branch_label,
1519 node.kind.kind_name(),
1520 bc.cursor
1521 ))));
1522 }
1523 NodeKind::Approval { .. } => {
1535 return Err(GraphExecError::Graph(GraphError::Invalid(format!(
1536 "approval node '{}' inside parallel branch '{}': not supported in v1 (parallel-branch approval parking is unimplemented)",
1537 bc.cursor, branch_label
1538 ))));
1539 }
1540 }
1541
1542 bc.state_json = serde_json::to_string(&state).map_err(CheckpointError::Serde)?;
1544 coord
1545 .save_slot(self.store.as_ref(), tenant, slot, &bc)
1546 .await?;
1547 coord.record_branch_visits(&visits).await;
1550 }
1551 })
1552 }
1553}
1554
1555struct BranchOutcome {
1561 #[allow(dead_code)]
1562 branch: String,
1563 visits: HashMap<String, u32>,
1565 trail: Vec<serde_json::Value>,
1567}
1568
1569impl FrontierCheckpoint {
1570 async fn save_slot(
1575 &self,
1576 store: &dyn CheckpointStore,
1577 tenant: &TenantContext,
1578 slot: usize,
1579 bc: &BranchCursor,
1580 ) -> Result<(), GraphExecError> {
1581 let mut guard = self.inner.lock().await;
1582 if let Some(existing) = guard.get_mut(slot) {
1583 *existing = bc.clone();
1584 }
1585 let frontier_json = Some(serde_json::to_string(&*guard).map_err(CheckpointError::Serde)?);
1586 let trunk_state: GraphRunState =
1587 serde_json::from_str(&self.trunk_state_json).map_err(CheckpointError::Serde)?;
1588 let rec = build_record_with_frontier(
1589 &self.run_id,
1590 &self.graph_json,
1591 &self.parallel_node,
1592 &trunk_state,
1593 &self.trunk_visits,
1594 RunStatus::Running,
1595 frontier_json,
1596 )?;
1597 store.save(tenant, &rec).await?;
1598 Ok(())
1599 }
1600
1601 async fn checkpoint(
1603 &self,
1604 store: &dyn CheckpointStore,
1605 tenant: &TenantContext,
1606 ) -> Result<(), GraphExecError> {
1607 let guard = self.inner.lock().await;
1608 let frontier_json = Some(serde_json::to_string(&*guard).map_err(CheckpointError::Serde)?);
1609 let trunk_state: GraphRunState =
1610 serde_json::from_str(&self.trunk_state_json).map_err(CheckpointError::Serde)?;
1611 let rec = build_record_with_frontier(
1612 &self.run_id,
1613 &self.graph_json,
1614 &self.parallel_node,
1615 &trunk_state,
1616 &self.trunk_visits,
1617 RunStatus::Running,
1618 frontier_json,
1619 )?;
1620 store.save(tenant, &rec).await?;
1621 Ok(())
1622 }
1623
1624 async fn record_branch_visits(&self, visits: &HashMap<String, u32>) {
1626 let mut guard = self.branch_visits.lock().await;
1627 for (k, v) in visits {
1628 guard.insert(k.clone(), *v);
1629 }
1630 }
1631
1632 async fn collect_branch_visits(&self) -> HashMap<String, u32> {
1634 self.branch_visits.lock().await.clone()
1635 }
1636}
1637
1638fn build_record(
1644 run_id: &str,
1645 graph_json: &str,
1646 cursor: &str,
1647 state: &GraphRunState,
1648 visits: &HashMap<String, u32>,
1649 status: RunStatus,
1650) -> Result<GraphRunRecord, GraphExecError> {
1651 build_record_with_frontier(run_id, graph_json, cursor, state, visits, status, None)
1652}
1653
1654fn build_record_with_frontier(
1657 run_id: &str,
1658 graph_json: &str,
1659 cursor: &str,
1660 state: &GraphRunState,
1661 visits: &HashMap<String, u32>,
1662 status: RunStatus,
1663 frontier_json: Option<String>,
1664) -> Result<GraphRunRecord, GraphExecError> {
1665 let state_json = serde_json::to_string(state).map_err(CheckpointError::Serde)?;
1666 let visits_json = serde_json::to_string(visits).map_err(CheckpointError::Serde)?;
1667 Ok(GraphRunRecord {
1668 run_id: run_id.to_owned(),
1669 graph_json: graph_json.to_owned(),
1670 cursor: cursor.to_owned(),
1671 state_json,
1672 status,
1673 visits_json,
1674 frontier_json,
1675 })
1676}
1677
1678fn next_linear(cfg: &GraphConfig, id: &str) -> Result<String, GraphExecError> {
1680 cfg.graph
1681 .edges_from(id)
1682 .next()
1683 .map(|e| e.to.clone())
1684 .ok_or_else(|| {
1685 GraphExecError::Graph(super::model::GraphError::Invalid(format!(
1686 "node '{id}' has no outgoing edge"
1687 )))
1688 })
1689}
1690
1691fn find_join_for_parallel(
1697 cfg: &GraphConfig,
1698 parallel_node: &str,
1699) -> Result<String, GraphExecError> {
1700 use std::collections::HashSet;
1701 let mut visited: HashSet<String> = HashSet::new();
1702 let mut queue: Vec<String> = cfg
1703 .graph
1704 .edges_from(parallel_node)
1705 .map(|e| e.to.clone())
1706 .collect();
1707 while let Some(current) = queue.pop() {
1708 if !visited.insert(current.clone()) {
1709 continue;
1710 }
1711 match cfg.graph.node(¤t) {
1712 Some(n) if matches!(n.kind, NodeKind::Join) => return Ok(current),
1713 Some(_) => {
1714 for e in cfg.graph.edges_from(¤t) {
1715 queue.push(e.to.clone());
1716 }
1717 }
1718 None => {
1719 return Err(GraphExecError::UnknownNode(current));
1720 }
1721 }
1722 }
1723 Err(GraphExecError::Graph(GraphError::Invalid(format!(
1724 "parallel node '{parallel_node}' has no reachable join node"
1725 ))))
1726}
1727
1728fn last_assistant_message(state: &GraphRunState) -> String {
1730 state
1731 .messages
1732 .iter()
1733 .rev()
1734 .find(|m| m.role == GraphRole::Assistant)
1735 .map(|m| m.content.clone())
1736 .unwrap_or_default()
1737}
1738
1739fn rebuild_trail_from_state(state: &GraphRunState) -> Vec<serde_json::Value> {
1742 state
1743 .messages
1744 .iter()
1745 .map(|m| {
1746 let kind = match m.role {
1747 GraphRole::User => "user",
1748 GraphRole::Assistant => "agent",
1749 GraphRole::Tool => "tool",
1750 };
1751 serde_json::json!({"kind": kind, "content": m.content})
1752 })
1753 .collect()
1754}
1755
1756#[cfg(test)]
1761#[allow(clippy::unwrap_used, clippy::expect_used)]
1762mod tests {
1763 use std::sync::Arc;
1764 use std::sync::atomic::{AtomicU32, Ordering};
1765
1766 use super::*;
1767 use crate::graph::test_fixtures::{parallel_json, supervisor_json, triage_json};
1768 use crate::graph::{GraphConfig, InMemoryCheckpointStore};
1769 use crate::tenant::TenantContext;
1770
1771 fn tenant() -> TenantContext {
1776 TenantContext::new("test", "dev")
1777 }
1778
1779 fn triage_cfg() -> GraphConfig {
1780 GraphConfig::from_json(&triage_json()).expect("fixture is valid")
1781 }
1782
1783 fn supervisor_cfg() -> GraphConfig {
1784 GraphConfig::from_json(&supervisor_json()).expect("supervisor fixture is valid")
1785 }
1786
1787 fn parallel_cfg() -> GraphConfig {
1788 GraphConfig::from_json(¶llel_json()).expect("parallel fixture is valid")
1789 }
1790
1791 fn agent_fn_resolves_on(counter: Arc<AtomicU32>, resolve_on_call: u32) -> AgentTurnFn {
1794 Arc::new(move |req: AgentTurnRequest| {
1795 let n = counter.fetch_add(1, Ordering::SeqCst) + 1; let resolved = n >= resolve_on_call;
1797 let reply = format!("reply-{n} from {}", req.node_id);
1798 Box::pin(async move { Ok(AgentTurnResult { reply, resolved }) })
1799 })
1800 }
1801
1802 fn tool_fn_counting(counter: Arc<AtomicU32>) -> ToolFn {
1804 Arc::new(move |_req: ToolCallRequest| {
1805 counter.fetch_add(1, Ordering::SeqCst);
1806 Box::pin(async move { Ok(serde_json::json!({"found": true})) })
1807 })
1808 }
1809
1810 fn supervisor_fn_unreachable() -> SupervisorFn {
1813 Arc::new(|_req: SupervisorRequest| {
1814 Box::pin(async move {
1815 Err(GraphExecError::Supervisor(
1816 "supervisor fn should not be called in this test".into(),
1817 ))
1818 })
1819 })
1820 }
1821
1822 fn supervisor_fn_always_routes_to(
1824 counter: Arc<AtomicU32>,
1825 branch: &'static str,
1826 ) -> SupervisorFn {
1827 Arc::new(move |req: SupervisorRequest| {
1828 counter.fetch_add(1, Ordering::SeqCst);
1829 let node = req.node_id.clone();
1830 Box::pin(async move {
1831 Ok(SupervisorResult {
1832 branch: branch.to_string(),
1833 raw_reply: format!("[[ROUTE:{branch}]] from supervisor at {node}"),
1834 })
1835 })
1836 })
1837 }
1838
1839 fn approval_fn_awaiting() -> ApprovalFn {
1842 Arc::new(|_req: ApprovalRequest| Box::pin(async move { Ok(ApprovalOutcome::Awaiting) }))
1843 }
1844
1845 fn approval_fn_decides(counter: Arc<AtomicU32>, branch: &'static str) -> ApprovalFn {
1848 Arc::new(move |_req: ApprovalRequest| {
1849 counter.fetch_add(1, Ordering::SeqCst);
1850 Box::pin(async move {
1851 Ok(ApprovalOutcome::Decided {
1852 branch: branch.to_string(),
1853 })
1854 })
1855 })
1856 }
1857
1858 #[tokio::test]
1865 async fn happy_path_resolves_first_pass() {
1866 let store = Arc::new(InMemoryCheckpointStore::default());
1867 let agent_count = Arc::new(AtomicU32::new(0));
1868 let tool_count = Arc::new(AtomicU32::new(0));
1869
1870 let exec = GraphExecutor::new(
1871 store.clone(),
1872 agent_fn_resolves_on(agent_count.clone(), 1),
1873 tool_fn_counting(tool_count.clone()),
1874 supervisor_fn_unreachable(),
1875 approval_fn_awaiting(),
1876 );
1877
1878 let outcome = exec
1879 .start(&tenant(), "run-happy", &triage_cfg(), "help me")
1880 .await
1881 .expect("should succeed");
1882
1883 assert_eq!(outcome.status, RunStatus::Succeeded, "status");
1884 assert!(
1885 outcome.reply.contains("reply-1"),
1886 "reply should contain agent output: {:?}",
1887 outcome.reply
1888 );
1889 assert_eq!(agent_count.load(Ordering::SeqCst), 1, "agent invoked once");
1890 assert_eq!(tool_count.load(Ordering::SeqCst), 1, "tool invoked once");
1891 assert_eq!(outcome.trail.len(), 4, "trail: {:?}", outcome.trail);
1893 }
1894
1895 #[tokio::test]
1902 async fn loops_until_router_cap_then_resolves_via_cap() {
1903 let store = Arc::new(InMemoryCheckpointStore::default());
1904 let agent_count = Arc::new(AtomicU32::new(0));
1905
1906 let exec = GraphExecutor::new(
1908 store.clone(),
1909 agent_fn_resolves_on(agent_count.clone(), u32::MAX),
1910 tool_fn_counting(Arc::new(AtomicU32::new(0))),
1911 supervisor_fn_unreachable(),
1912 approval_fn_awaiting(),
1913 );
1914
1915 let outcome = exec
1916 .start(&tenant(), "run-cap", &triage_cfg(), "loop me")
1917 .await
1918 .expect("should succeed via iteration cap");
1919
1920 assert_eq!(outcome.status, RunStatus::Succeeded);
1921 assert_eq!(
1922 agent_count.load(Ordering::SeqCst),
1923 3,
1924 "agent should be invoked exactly 3 times (maxIterations=3)"
1925 );
1926 }
1927
1928 #[tokio::test]
1933 async fn resume_on_succeeded_run_returns_stored_outcome_without_reinvoking() {
1934 let store = Arc::new(InMemoryCheckpointStore::default());
1935 let agent_count = Arc::new(AtomicU32::new(0));
1936 let tool_count = Arc::new(AtomicU32::new(0));
1937
1938 let exec = GraphExecutor::new(
1939 store.clone(),
1940 agent_fn_resolves_on(agent_count.clone(), 1),
1941 tool_fn_counting(tool_count.clone()),
1942 supervisor_fn_unreachable(),
1943 approval_fn_awaiting(),
1944 );
1945
1946 exec.start(&tenant(), "run-resume-done", &triage_cfg(), "hi")
1948 .await
1949 .expect("first run succeeds");
1950
1951 let after_start_agent = agent_count.load(Ordering::SeqCst);
1952 let after_start_tool = tool_count.load(Ordering::SeqCst);
1953
1954 let outcome = exec
1956 .resume(&tenant(), "run-resume-done")
1957 .await
1958 .expect("resume should succeed");
1959
1960 assert_eq!(outcome.status, RunStatus::Succeeded);
1961 assert_eq!(
1962 agent_count.load(Ordering::SeqCst),
1963 after_start_agent,
1964 "agent must NOT be called again on resume of terminal run"
1965 );
1966 assert_eq!(
1967 tool_count.load(Ordering::SeqCst),
1968 after_start_tool,
1969 "tool must NOT be called again on resume of terminal run"
1970 );
1971 }
1972
1973 #[tokio::test]
1980 async fn global_visit_cap_fails_run() {
1981 let store = Arc::new(InMemoryCheckpointStore::default());
1982
1983 let mut v: serde_json::Value = serde_json::from_str(&triage_json()).expect("fixture JSON");
1985 for node in v["nodes"].as_array_mut().expect("nodes array") {
1986 if node["kind"] == "router" {
1987 node["maxIterations"] = serde_json::json!(1000);
1988 }
1989 }
1990 let cfg = GraphConfig::from_json(&v.to_string()).expect("patched graph valid");
1991
1992 let exec = GraphExecutor::new(
1993 store.clone(),
1994 agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), u32::MAX),
1996 tool_fn_counting(Arc::new(AtomicU32::new(0))),
1997 supervisor_fn_unreachable(),
1998 approval_fn_awaiting(),
1999 );
2000
2001 let err = exec
2002 .start(&tenant(), "run-global-cap", &cfg, "infinite loop")
2003 .await
2004 .expect_err("should fail with IterationCap");
2005
2006 assert!(
2007 matches!(err, GraphExecError::IterationCap { .. }),
2008 "expected IterationCap, got {err:?}"
2009 );
2010
2011 let rec = store
2013 .load(&tenant(), "run-global-cap")
2014 .await
2015 .expect("store accessible")
2016 .expect("record must exist");
2017 assert_eq!(
2018 rec.status,
2019 RunStatus::Failed,
2020 "stored status must be Failed"
2021 );
2022 }
2023
2024 #[tokio::test]
2034 async fn effect_error_leaves_run_resumable() {
2035 let store = Arc::new(InMemoryCheckpointStore::default());
2036 let t = tenant();
2037
2038 let phase1_count = Arc::new(AtomicU32::new(0));
2040 {
2041 let pc = phase1_count.clone();
2042 let agent_phase1: AgentTurnFn = Arc::new(move |req: AgentTurnRequest| {
2043 let n = pc.fetch_add(1, Ordering::SeqCst) + 1;
2044 let node = req.node_id.clone();
2045 Box::pin(async move {
2046 if n == 1 {
2047 Ok(AgentTurnResult {
2048 reply: format!("pass-{n} from {node}"),
2049 resolved: false,
2050 })
2051 } else {
2052 Err(GraphExecError::AgentTurn(
2053 "simulated failure on attempt 2".into(),
2054 ))
2055 }
2056 })
2057 });
2058
2059 let tool_count = Arc::new(AtomicU32::new(0));
2060 let store_ref = store.clone();
2061 let exec = GraphExecutor::new(
2062 store_ref,
2063 agent_phase1,
2064 tool_fn_counting(tool_count.clone()),
2065 supervisor_fn_unreachable(),
2066 approval_fn_awaiting(),
2067 );
2068
2069 let err = exec
2070 .start(&t, "run-resumable", &triage_cfg(), "retry me")
2071 .await
2072 .expect_err("should fail on agent attempt 2");
2073
2074 assert!(
2075 matches!(err, GraphExecError::AgentTurn(_)),
2076 "expected AgentTurn error, got {err:?}"
2077 );
2078
2079 let rec = store
2081 .load(&t, "run-resumable")
2082 .await
2083 .expect("store ok")
2084 .expect("record exists");
2085 assert_eq!(
2086 rec.status,
2087 RunStatus::Running,
2088 "run should stay Running after effect error"
2089 );
2090 }
2091
2092 let phase2_count = Arc::new(AtomicU32::new(0));
2096 let tool_phase2_count = Arc::new(AtomicU32::new(0));
2097 {
2098 let pc2 = phase2_count.clone();
2099 let agent_phase2: AgentTurnFn = Arc::new(move |_req: AgentTurnRequest| {
2100 pc2.fetch_add(1, Ordering::SeqCst);
2101 Box::pin(async move {
2102 Ok(AgentTurnResult {
2103 reply: "resolved!".into(),
2104 resolved: true,
2105 })
2106 })
2107 });
2108
2109 let exec2 = GraphExecutor::new(
2110 store.clone(),
2111 agent_phase2,
2112 tool_fn_counting(tool_phase2_count.clone()),
2113 supervisor_fn_unreachable(),
2114 approval_fn_awaiting(),
2115 );
2116
2117 let outcome = exec2
2118 .resume(&t, "run-resumable")
2119 .await
2120 .expect("resume should succeed");
2121
2122 assert_eq!(outcome.status, RunStatus::Succeeded, "outcome status");
2123 }
2124
2125 assert_eq!(
2127 phase2_count.load(Ordering::SeqCst),
2128 1,
2129 "phase2 agent must be called exactly once (attempt-1 was replayed)"
2130 );
2131 assert_eq!(
2136 tool_phase2_count.load(Ordering::SeqCst),
2137 1,
2138 "phase2 tool must be called once (attempt-1 replayed, attempt-2 is fresh)"
2139 );
2140 }
2141
2142 #[tokio::test]
2147 async fn start_twice_with_same_run_id_after_completion_errors() {
2148 let store = Arc::new(InMemoryCheckpointStore::default());
2149
2150 let exec = GraphExecutor::new(
2151 store.clone(),
2152 agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
2153 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2154 supervisor_fn_unreachable(),
2155 approval_fn_awaiting(),
2156 );
2157
2158 exec.start(&tenant(), "run-dup", &triage_cfg(), "first")
2159 .await
2160 .expect("first start succeeds");
2161
2162 let err = exec
2163 .start(&tenant(), "run-dup", &triage_cfg(), "second attempt")
2164 .await
2165 .expect_err("second start must fail");
2166
2167 assert!(
2168 matches!(err, GraphExecError::AlreadyCompleted(_)),
2169 "expected AlreadyCompleted, got {err:?}"
2170 );
2171 }
2172
2173 #[tokio::test]
2192 async fn supervisor_routes_to_billing_branch() {
2193 let store = Arc::new(InMemoryCheckpointStore::default());
2194 let sup_count = Arc::new(AtomicU32::new(0));
2195 let agent_count = Arc::new(AtomicU32::new(0));
2196
2197 let exec = GraphExecutor::new(
2198 store.clone(),
2199 agent_fn_resolves_on(agent_count.clone(), 1),
2200 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2201 supervisor_fn_always_routes_to(sup_count.clone(), "billing"),
2202 approval_fn_awaiting(),
2203 );
2204
2205 let outcome = exec
2206 .start(
2207 &tenant(),
2208 "run-sup-billing",
2209 &supervisor_cfg(),
2210 "I have a billing question",
2211 )
2212 .await
2213 .expect("supervisor billing run should succeed");
2214
2215 assert_eq!(outcome.status, RunStatus::Succeeded, "status");
2216 assert_eq!(
2217 sup_count.load(Ordering::SeqCst),
2218 1,
2219 "supervisor invoked once"
2220 );
2221 assert_eq!(
2222 agent_count.load(Ordering::SeqCst),
2223 1,
2224 "agent invoked once on billing branch"
2225 );
2226
2227 let sup_entry = outcome.trail.iter().find(|e| e["kind"] == "supervisor");
2229 assert!(
2230 sup_entry.is_some(),
2231 "trail must contain a supervisor entry: {:?}",
2232 outcome.trail
2233 );
2234 let sup_entry = sup_entry.unwrap();
2235 assert_eq!(
2236 sup_entry["branch"], "billing",
2237 "supervisor trail branch must be 'billing'"
2238 );
2239 assert_eq!(
2240 sup_entry["replayed"], false,
2241 "fresh run: supervisor not replayed"
2242 );
2243 }
2244
2245 #[tokio::test]
2247 async fn supervisor_routes_to_tech_branch() {
2248 let store = Arc::new(InMemoryCheckpointStore::default());
2249 let sup_count = Arc::new(AtomicU32::new(0));
2250 let agent_count = Arc::new(AtomicU32::new(0));
2251
2252 let exec = GraphExecutor::new(
2253 store.clone(),
2254 agent_fn_resolves_on(agent_count.clone(), 1),
2255 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2256 supervisor_fn_always_routes_to(sup_count.clone(), "tech"),
2257 approval_fn_awaiting(),
2258 );
2259
2260 let outcome = exec
2261 .start(
2262 &tenant(),
2263 "run-sup-tech",
2264 &supervisor_cfg(),
2265 "I have a tech issue",
2266 )
2267 .await
2268 .expect("supervisor tech run should succeed");
2269
2270 assert_eq!(outcome.status, RunStatus::Succeeded, "status");
2271 assert_eq!(
2272 sup_count.load(Ordering::SeqCst),
2273 1,
2274 "supervisor invoked once"
2275 );
2276 assert_eq!(
2277 agent_count.load(Ordering::SeqCst),
2278 1,
2279 "agent invoked once on tech branch"
2280 );
2281
2282 let sup_entry = outcome.trail.iter().find(|e| e["kind"] == "supervisor");
2283 assert!(sup_entry.is_some(), "trail must have supervisor entry");
2284 assert_eq!(sup_entry.unwrap()["branch"], "tech");
2285 }
2286
2287 #[tokio::test]
2291 async fn supervisor_replay_determinism() {
2292 let store = Arc::new(InMemoryCheckpointStore::default());
2293 let sup_count = Arc::new(AtomicU32::new(0));
2294 let agent_count = Arc::new(AtomicU32::new(0));
2295
2296 let exec = GraphExecutor::new(
2297 store.clone(),
2298 agent_fn_resolves_on(agent_count.clone(), 1),
2299 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2300 supervisor_fn_always_routes_to(sup_count.clone(), "billing"),
2301 approval_fn_awaiting(),
2302 );
2303
2304 let first = exec
2306 .start(
2307 &tenant(),
2308 "run-sup-replay",
2309 &supervisor_cfg(),
2310 "billing question",
2311 )
2312 .await
2313 .expect("first run succeeds");
2314 assert_eq!(first.status, RunStatus::Succeeded);
2315
2316 let after_first_sup = sup_count.load(Ordering::SeqCst);
2317
2318 let second = exec
2321 .resume(&tenant(), "run-sup-replay")
2322 .await
2323 .expect("resume should succeed");
2324 assert_eq!(second.status, RunStatus::Succeeded);
2325 assert_eq!(
2326 sup_count.load(Ordering::SeqCst),
2327 after_first_sup,
2328 "supervisor must NOT be called again on resume of a terminal run"
2329 );
2330 }
2331
2332 #[tokio::test]
2335 async fn supervisor_trail_entry_has_branch_and_replayed_flag() {
2336 let store = Arc::new(InMemoryCheckpointStore::default());
2337 let sup_count = Arc::new(AtomicU32::new(0));
2338
2339 let exec = GraphExecutor::new(
2340 store.clone(),
2341 agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
2342 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2343 supervisor_fn_always_routes_to(sup_count.clone(), "tech"),
2344 approval_fn_awaiting(),
2345 );
2346
2347 let outcome = exec
2348 .start(&tenant(), "run-sup-trail", &supervisor_cfg(), "need help")
2349 .await
2350 .expect("run should succeed");
2351
2352 let sup_entry = outcome
2354 .trail
2355 .iter()
2356 .find(|e| e["kind"] == "supervisor")
2357 .expect("trail must contain a supervisor entry");
2358
2359 assert_eq!(sup_entry["node"], "sup", "supervisor node id");
2360 assert_eq!(sup_entry["kind"], "supervisor");
2361 assert_eq!(sup_entry["attempt"], 1u32);
2362 assert_eq!(sup_entry["replayed"], false);
2363 assert_eq!(sup_entry["branch"], "tech");
2364 }
2365
2366 #[tokio::test]
2374 async fn agent_turn_request_carries_node_provider_when_set() {
2375 use std::sync::Mutex;
2376
2377 let store = Arc::new(InMemoryCheckpointStore::default());
2378 let captured: Arc<Mutex<Option<Option<String>>>> = Arc::new(Mutex::new(None));
2379 let cap = captured.clone();
2380
2381 let agent: AgentTurnFn = Arc::new(move |req: AgentTurnRequest| {
2382 *cap.lock().unwrap() = Some(req.provider.clone());
2383 Box::pin(async move {
2384 Ok(AgentTurnResult {
2385 reply: "ok".into(),
2386 resolved: true,
2387 })
2388 })
2389 });
2390
2391 let cfg_json = serde_json::json!({
2393 "schemaVersion": 1,
2394 "entry": "agent",
2395 "nodes": [
2396 {
2397 "id": "agent",
2398 "kind": "agent",
2399 "systemPrompt": "You help.",
2400 "model": "claude-3-5-sonnet",
2401 "provider": "anthropic"
2402 },
2403 {"id": "respond", "kind": "respond"}
2404 ],
2405 "edges": [
2406 {"from": "agent", "to": "respond"}
2407 ]
2408 })
2409 .to_string();
2410 let cfg = GraphConfig::from_json(&cfg_json).expect("fixture valid");
2411
2412 let exec = GraphExecutor::new(
2413 store.clone(),
2414 agent,
2415 Arc::new(|_| Box::pin(async { Ok(serde_json::json!({})) })),
2416 supervisor_fn_unreachable(),
2417 approval_fn_awaiting(),
2418 );
2419 exec.start(&tenant(), "run-provider-set", &cfg, "hi")
2420 .await
2421 .expect("run should succeed");
2422
2423 let got = captured.lock().unwrap().take().expect("agent was called");
2424 assert_eq!(
2425 got,
2426 Some("anthropic".to_string()),
2427 "AgentTurnRequest.provider must be Some(\"anthropic\") when set on the node"
2428 );
2429 }
2430
2431 #[tokio::test]
2434 async fn agent_turn_request_provider_is_none_when_absent() {
2435 use std::sync::Mutex;
2436
2437 let store = Arc::new(InMemoryCheckpointStore::default());
2438 let captured: Arc<Mutex<Option<Option<String>>>> = Arc::new(Mutex::new(None));
2439 let cap = captured.clone();
2440
2441 let agent: AgentTurnFn = Arc::new(move |req: AgentTurnRequest| {
2442 *cap.lock().unwrap() = Some(req.provider.clone());
2443 Box::pin(async move {
2444 Ok(AgentTurnResult {
2445 reply: "ok".into(),
2446 resolved: true,
2447 })
2448 })
2449 });
2450
2451 let exec = GraphExecutor::new(
2453 store.clone(),
2454 agent,
2455 Arc::new(|_| Box::pin(async { Ok(serde_json::json!({})) })),
2456 supervisor_fn_unreachable(),
2457 approval_fn_awaiting(),
2458 );
2459 let cfg_json = serde_json::json!({
2460 "schemaVersion": 1,
2461 "entry": "agent",
2462 "nodes": [
2463 {
2464 "id": "agent",
2465 "kind": "agent",
2466 "systemPrompt": "You help.",
2467 "model": "gpt-4o-mini"
2468 },
2469 {"id": "respond", "kind": "respond"}
2470 ],
2471 "edges": [{"from": "agent", "to": "respond"}]
2472 })
2473 .to_string();
2474 let cfg = GraphConfig::from_json(&cfg_json).expect("fixture valid");
2475 exec.start(&tenant(), "run-provider-absent", &cfg, "hi")
2476 .await
2477 .expect("run should succeed");
2478
2479 let got = captured.lock().unwrap().take().expect("agent was called");
2480 assert_eq!(
2481 got, None,
2482 "AgentTurnRequest.provider must be None when the node has no provider field"
2483 );
2484 }
2485
2486 #[tokio::test]
2502 async fn parallel_happy_path_merges_both_branches() {
2503 let store = Arc::new(InMemoryCheckpointStore::default());
2504
2505 let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2507 let node = req.node_id.clone();
2508 Box::pin(async move {
2509 Ok(AgentTurnResult {
2510 reply: format!("agent-reply from {node}"),
2511 resolved: true,
2512 })
2513 })
2514 });
2515 let tool: ToolFn = Arc::new(|_req: ToolCallRequest| {
2516 Box::pin(async move { Ok(serde_json::json!({"branch_b": "done"})) })
2517 });
2518
2519 let exec = GraphExecutor::new(
2520 store.clone(),
2521 agent,
2522 tool,
2523 supervisor_fn_unreachable(),
2524 approval_fn_awaiting(),
2525 );
2526
2527 let outcome = exec
2528 .start(&tenant(), "run-par-happy", ¶llel_cfg(), "go")
2529 .await
2530 .expect("parallel run should succeed");
2531
2532 assert_eq!(outcome.status, RunStatus::Succeeded, "status");
2533
2534 let rec = store
2536 .load(&tenant(), "run-par-happy")
2537 .await
2538 .unwrap()
2539 .unwrap();
2540 assert_eq!(rec.frontier_json, None, "frontier cleared after merge");
2541 let state: GraphRunState = serde_json::from_str(&rec.state_json).unwrap();
2542 let contents: Vec<&str> = state.messages.iter().map(|m| m.content.as_str()).collect();
2543
2544 let a_idx = contents
2546 .iter()
2547 .position(|c| c.contains("agent-reply from agent_a"))
2548 .expect("branch a message present");
2549 let b_idx = contents
2550 .iter()
2551 .position(|c| c.contains("branch_b"))
2552 .expect("branch b message present");
2553 assert!(
2555 a_idx < b_idx,
2556 "branch a must merge before branch b: {contents:?}"
2557 );
2558 }
2559
2560 #[tokio::test]
2563 async fn parallel_branch_isolation_no_cross_bleed() {
2564 let store = Arc::new(InMemoryCheckpointStore::default());
2565
2566 let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2567 let node = req.node_id.clone();
2568 Box::pin(async move {
2569 let reply = if node == "agent_a" {
2573 "SECRET-A-MESSAGE from agent_a".to_string()
2574 } else {
2575 format!("neutral reply from {node}")
2576 };
2577 Ok(AgentTurnResult {
2578 reply,
2579 resolved: true,
2580 })
2581 })
2582 });
2583
2584 let saw_secret = Arc::new(std::sync::atomic::AtomicBool::new(false));
2585 let saw_secret_probe = saw_secret.clone();
2586 let tool: ToolFn = Arc::new(move |req: ToolCallRequest| {
2587 let leaked = req
2589 .state
2590 .messages
2591 .iter()
2592 .any(|m| m.content.contains("SECRET-A-MESSAGE"));
2593 if leaked {
2594 saw_secret_probe.store(true, Ordering::SeqCst);
2595 }
2596 Box::pin(async move { Ok(serde_json::json!({"branch_b": "done"})) })
2597 });
2598
2599 let exec = GraphExecutor::new(
2600 store.clone(),
2601 agent,
2602 tool,
2603 supervisor_fn_unreachable(),
2604 approval_fn_awaiting(),
2605 );
2606 exec.start(&tenant(), "run-par-iso", ¶llel_cfg(), "go")
2607 .await
2608 .expect("run should succeed");
2609
2610 assert!(
2611 !saw_secret.load(Ordering::SeqCst),
2612 "branch B observed branch A's message — isolation violated"
2613 );
2614 }
2615
2616 #[tokio::test]
2620 async fn parallel_merge_is_deterministic_under_delay() {
2621 for _ in 0..3 {
2622 let store = Arc::new(InMemoryCheckpointStore::default());
2623
2624 let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2627 let node = req.node_id.clone();
2628 Box::pin(async move {
2629 if node == "agent_a" {
2630 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
2631 }
2632 Ok(AgentTurnResult {
2633 reply: format!("reply from {node}"),
2634 resolved: true,
2635 })
2636 })
2637 });
2638 let tool: ToolFn = Arc::new(|_req: ToolCallRequest| {
2640 Box::pin(async move { Ok(serde_json::json!({"branch_b_fast": true})) })
2641 });
2642
2643 let exec = GraphExecutor::new(
2644 store.clone(),
2645 agent,
2646 tool,
2647 supervisor_fn_unreachable(),
2648 approval_fn_awaiting(),
2649 );
2650 exec.start(&tenant(), "run-par-det", ¶llel_cfg(), "go")
2651 .await
2652 .expect("run should succeed");
2653
2654 let rec = store.load(&tenant(), "run-par-det").await.unwrap().unwrap();
2655 let state: GraphRunState = serde_json::from_str(&rec.state_json).unwrap();
2656 let contents: Vec<&str> = state.messages.iter().map(|m| m.content.as_str()).collect();
2657 let a_idx = contents
2658 .iter()
2659 .position(|c| c.contains("reply from agent_a"))
2660 .expect("branch a present");
2661 let b_idx = contents
2662 .iter()
2663 .position(|c| c.contains("branch_b_fast"))
2664 .expect("branch b present");
2665 assert!(
2666 a_idx < b_idx,
2667 "slow branch a must still merge before fast branch b: {contents:?}"
2668 );
2669 }
2670 }
2671
2672 #[tokio::test]
2675 async fn parallel_global_visit_cap_across_branches_fails() {
2676 let v = serde_json::json!({
2680 "schemaVersion": 2,
2681 "entry": "fan",
2682 "nodes": [
2683 {"id": "fan", "kind": "parallel"},
2684 {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "m", "tools": []},
2685 {"id": "router_a", "kind": "router", "maxIterations": 1000},
2686 {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "m", "tools": []},
2687 {"id": "router_b", "kind": "router", "maxIterations": 1000},
2688 {"id": "meet", "kind": "join"},
2689 {"id": "respond", "kind": "respond"}
2690 ],
2691 "edges": [
2692 {"from": "fan", "to": "agent_a", "branch": "a"},
2693 {"from": "fan", "to": "agent_b", "branch": "b"},
2694 {"from": "agent_a", "to": "router_a"},
2695 {"from": "router_a", "to": "agent_a", "branch": "loop"},
2696 {"from": "router_a", "to": "meet", "branch": "resolved"},
2697 {"from": "agent_b", "to": "router_b"},
2698 {"from": "router_b", "to": "agent_b", "branch": "loop"},
2699 {"from": "router_b", "to": "meet", "branch": "resolved"},
2700 {"from": "meet", "to": "respond"}
2701 ]
2702 });
2703 let cfg = GraphConfig::from_json(&v.to_string()).expect("graph valid");
2704 let store = Arc::new(InMemoryCheckpointStore::default());
2705
2706 let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2708 let node = req.node_id.clone();
2709 Box::pin(async move {
2710 Ok(AgentTurnResult {
2711 reply: format!("loop from {node}"),
2712 resolved: false,
2713 })
2714 })
2715 });
2716 let exec = GraphExecutor::new(
2717 store.clone(),
2718 agent,
2719 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2720 supervisor_fn_unreachable(),
2721 approval_fn_awaiting(),
2722 );
2723
2724 let err = exec
2725 .start(&tenant(), "run-par-cap", &cfg, "loop forever")
2726 .await
2727 .expect_err("should hit global cap");
2728 assert!(
2729 matches!(err, GraphExecError::IterationCap { .. }),
2730 "expected IterationCap, got {err:?}"
2731 );
2732
2733 let rec = store.load(&tenant(), "run-par-cap").await.unwrap().unwrap();
2734 assert_eq!(rec.status, RunStatus::Failed, "run must be Failed on cap");
2735 }
2736
2737 #[tokio::test]
2740 async fn parallel_mid_branch_error_keeps_run_running_with_frontier() {
2741 let store = Arc::new(InMemoryCheckpointStore::default());
2742
2743 let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2745 let node = req.node_id.clone();
2746 Box::pin(async move {
2747 if node == "agent_a" {
2748 Err(GraphExecError::AgentTurn("branch a boom".into()))
2749 } else {
2750 Ok(AgentTurnResult {
2751 reply: format!("ok from {node}"),
2752 resolved: true,
2753 })
2754 }
2755 })
2756 });
2757 let tool: ToolFn = Arc::new(|_req: ToolCallRequest| {
2758 Box::pin(async move { Ok(serde_json::json!({"branch_b": "ok"})) })
2759 });
2760
2761 let exec = GraphExecutor::new(
2762 store.clone(),
2763 agent,
2764 tool,
2765 supervisor_fn_unreachable(),
2766 approval_fn_awaiting(),
2767 );
2768 let err = exec
2769 .start(&tenant(), "run-par-err", ¶llel_cfg(), "go")
2770 .await
2771 .expect_err("branch a error should propagate");
2772 assert!(
2773 matches!(err, GraphExecError::AgentTurn(_)),
2774 "expected AgentTurn error, got {err:?}"
2775 );
2776
2777 let rec = store.load(&tenant(), "run-par-err").await.unwrap().unwrap();
2778 assert_eq!(
2779 rec.status,
2780 RunStatus::Running,
2781 "run must stay Running after a mid-branch error"
2782 );
2783 let frontier_json = rec
2784 .frontier_json
2785 .as_ref()
2786 .expect("frontier must be persisted mid-parallel");
2787 let frontier: Vec<BranchCursor> = serde_json::from_str(frontier_json).unwrap();
2788 let a = frontier
2790 .iter()
2791 .find(|b| b.branch == "a")
2792 .expect("branch a slot present");
2793 assert_eq!(
2794 a.cursor, "agent_a",
2795 "failed branch cursor at last good node"
2796 );
2797 assert!(!a.parked, "failed branch must not be parked");
2798 }
2799
2800 #[tokio::test]
2803 async fn parallel_resume_after_branch_error_completes() {
2804 let store = Arc::new(InMemoryCheckpointStore::default());
2805 let t = tenant();
2806
2807 {
2809 let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2810 let node = req.node_id.clone();
2811 Box::pin(async move {
2812 if node == "agent_a" {
2813 Err(GraphExecError::AgentTurn("boom".into()))
2814 } else {
2815 Ok(AgentTurnResult {
2816 reply: format!("ok from {node}"),
2817 resolved: true,
2818 })
2819 }
2820 })
2821 });
2822 let tool: ToolFn =
2823 Arc::new(|_r| Box::pin(async move { Ok(serde_json::json!({"branch_b": "ok"})) }));
2824 let exec = GraphExecutor::new(
2825 store.clone(),
2826 agent,
2827 tool,
2828 supervisor_fn_unreachable(),
2829 approval_fn_awaiting(),
2830 );
2831 exec.start(&t, "run-par-resume", ¶llel_cfg(), "go")
2832 .await
2833 .expect_err("phase 1 errors");
2834 }
2835
2836 let branch_b_calls = Arc::new(AtomicU32::new(0));
2838 {
2839 let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2840 let node = req.node_id.clone();
2841 Box::pin(async move {
2842 Ok(AgentTurnResult {
2843 reply: format!("recovered from {node}"),
2844 resolved: true,
2845 })
2846 })
2847 });
2848 let bcalls = branch_b_calls.clone();
2849 let tool: ToolFn = Arc::new(move |_r| {
2850 bcalls.fetch_add(1, Ordering::SeqCst);
2851 Box::pin(async move { Ok(serde_json::json!({"branch_b": "ok"})) })
2852 });
2853 let exec = GraphExecutor::new(
2854 store.clone(),
2855 agent,
2856 tool,
2857 supervisor_fn_unreachable(),
2858 approval_fn_awaiting(),
2859 );
2860 let outcome = exec
2861 .resume(&t, "run-par-resume")
2862 .await
2863 .expect("resume should complete");
2864 assert_eq!(outcome.status, RunStatus::Succeeded, "resumed run succeeds");
2865 }
2866
2867 assert_eq!(
2870 branch_b_calls.load(Ordering::SeqCst),
2871 0,
2872 "already-parked branch b must replay, not re-invoke its tool"
2873 );
2874
2875 let rec = store.load(&t, "run-par-resume").await.unwrap().unwrap();
2877 assert_eq!(rec.status, RunStatus::Succeeded);
2878 assert_eq!(rec.frontier_json, None, "frontier cleared after merge");
2879 let state: GraphRunState = serde_json::from_str(&rec.state_json).unwrap();
2880 let contents: Vec<&str> = state.messages.iter().map(|m| m.content.as_str()).collect();
2881 assert!(
2882 contents
2883 .iter()
2884 .any(|c| c.contains("recovered from agent_a")),
2885 "branch a recovered: {contents:?}"
2886 );
2887 assert!(
2888 contents.iter().any(|c| c.contains("branch_b")),
2889 "branch b present: {contents:?}"
2890 );
2891 }
2892
2893 fn approval_json() -> String {
2901 serde_json::json!({
2902 "schemaVersion": 2,
2903 "entry": "start",
2904 "nodes": [
2905 {"id": "start", "kind": "agent", "systemPrompt": "greet the user", "model": "gpt-4o-mini"},
2906 {"id": "approval", "kind": "approval", "title": "Approve refund?", "mode": "always"},
2907 {"id": "respond", "kind": "respond"}
2908 ],
2909 "edges": [
2910 {"from": "start", "to": "approval"},
2911 {"from": "approval", "to": "respond", "branch": "approved"}
2912 ]
2913 })
2914 .to_string()
2915 }
2916
2917 fn approval_cfg() -> GraphConfig {
2918 GraphConfig::from_json(&approval_json()).expect("approval fixture is valid")
2919 }
2920
2921 #[tokio::test]
2925 async fn approval_parks_then_resumes() {
2926 let store = Arc::new(InMemoryCheckpointStore::default());
2927 let t = tenant();
2928 let agent_count = Arc::new(AtomicU32::new(0));
2929
2930 {
2932 let exec = GraphExecutor::new(
2933 store.clone(),
2934 agent_fn_resolves_on(agent_count.clone(), 1),
2935 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2936 supervisor_fn_unreachable(),
2937 approval_fn_awaiting(),
2938 );
2939
2940 let outcome = exec
2941 .start(&t, "run-approval", &approval_cfg(), "please approve this")
2942 .await
2943 .expect("start should return cleanly when parked, not error");
2944
2945 assert_eq!(
2946 outcome.status,
2947 RunStatus::AwaitingInput,
2948 "start() outcome must report AwaitingInput"
2949 );
2950
2951 let rec = store
2952 .load(&t, "run-approval")
2953 .await
2954 .expect("store accessible")
2955 .expect("record must exist");
2956 assert_eq!(
2957 rec.status,
2958 RunStatus::AwaitingInput,
2959 "persisted record must be AwaitingInput"
2960 );
2961 assert_eq!(
2962 rec.cursor, "approval",
2963 "cursor must stay at the approval node while parked"
2964 );
2965 }
2966
2967 let decision_count = Arc::new(AtomicU32::new(0));
2969 {
2970 let exec = GraphExecutor::new(
2971 store.clone(),
2972 agent_fn_resolves_on(agent_count.clone(), 1),
2973 tool_fn_counting(Arc::new(AtomicU32::new(0))),
2974 supervisor_fn_unreachable(),
2975 approval_fn_decides(decision_count.clone(), "approved"),
2976 );
2977
2978 let outcome = exec
2979 .resume(&t, "run-approval")
2980 .await
2981 .expect("resume with a decision should succeed");
2982
2983 assert_eq!(
2984 outcome.status,
2985 RunStatus::Succeeded,
2986 "resumed run must reach Succeeded at respond"
2987 );
2988 }
2989
2990 assert_eq!(
2991 decision_count.load(Ordering::SeqCst),
2992 1,
2993 "approval fn must be called exactly once on resume"
2994 );
2995
2996 let rec = store
2997 .load(&t, "run-approval")
2998 .await
2999 .unwrap()
3000 .expect("record must exist after resume");
3001 assert_eq!(rec.status, RunStatus::Succeeded);
3002 assert_eq!(rec.cursor, "respond", "cursor must land on respond node");
3003
3004 let visit = store
3006 .load_node_visit(&t, "run-approval", "approval", 1)
3007 .await
3008 .unwrap()
3009 .expect("approval node visit must be recorded");
3010 assert_eq!(visit["decision"], "approved");
3011 }
3012}