From cd0318f3847e80b7304bb44fb24da35c92f6534a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 13:55:56 -0600 Subject: [PATCH] feat(graph): warn when a timeout is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timeout: 0 on settings.timeout or on an agent/script/llm/rag node is the author's explicit opt-out of the wall-clock bound. It stays legal; the validator now emits one warning per zero so the choice is visible in --validate output. Non-zero and unset values do not warn (unset already means no bound for settings.timeout and llm nodes, matching the explicit-zero-only convention of the orchestration-limit warnings). Design ยง3.13 / C21. --- src/graph/validator.rs | 158 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/graph/validator.rs b/src/graph/validator.rs index 09d76d6..7612767 100644 --- a/src/graph/validator.rs +++ b/src/graph/validator.rs @@ -178,6 +178,7 @@ impl GraphValidator { self.validate_max_concurrency(graph, &mut result); self.validate_max_concurrency_template(graph, &mut result); self.validate_orchestration_limits(graph, &mut result); + self.validate_timeouts(graph, &mut result); self.validate_map_subgraphs(graph, &mut result); self.validate_parallel_user_interaction(graph, &mut result); self.validate_parallel_writes(graph, &mut result); @@ -584,6 +585,34 @@ impl GraphValidator { } } + /// A `timeout: 0` is the author's explicit opt-out of the wall-clock + /// bound. It is legal; the warning only makes the choice visible. + fn validate_timeouts(&self, graph: &Graph, result: &mut ValidationResult) { + if graph.settings.timeout == Some(0) { + result.warning(ValidationError::new( + "settings.timeout: 0 disables the graph wall-clock bound; the run \ + ends only when the graph completes, is aborted, or hits \ + max_loop_iterations", + )); + } + for (node_id, node) in &graph.nodes { + let kind = match &node.node_type { + NodeType::Agent(a) if a.timeout == Some(0) => "agent", + NodeType::Script(s) if s.timeout == 0 => "script", + NodeType::Llm(l) if l.timeout == Some(0) => "llm", + NodeType::Rag(r) if r.timeout == Some(0) => "rag", + _ => continue, + }; + result.warning(ValidationError::with_node( + node_id, + format!( + "timeout: 0 disables the wall-clock bound for {kind} node \ + '{node_id}'; it runs until it returns" + ), + )); + } + } + /// Rules for the per-item subgraph rooted at each map's `branch`. Every /// error is attributed to the map node; the offending branch node is /// named in the message. @@ -3170,6 +3199,135 @@ mod tests { ); } + fn timeout_warnings(result: &ValidationResult) -> Vec<&ValidationError> { + result + .warnings + .iter() + .filter(|w| w.message.contains("disables the")) + .collect() + } + + #[test] + fn settings_timeout_zero_warns() { + let mut graph = graph_with(vec![("e", end_node("e"))], "e"); + graph.settings.timeout = Some(0); + + let result = validator().validate(&graph); + + assert!(result.is_valid()); + let w = timeout_warnings(&result); + assert_eq!(w.len(), 1, "{:?}", result.warnings); + assert_eq!(w[0].node_id, None); + assert_eq!( + w[0].message, + "settings.timeout: 0 disables the graph wall-clock bound; the run ends only when \ + the graph completes, is aborted, or hits max_loop_iterations" + ); + } + + #[test] + fn agent_timeout_zero_warns() { + let mut a = agent_node("a", "worker", Some("end")); + if let NodeType::Agent(ref mut an) = a.node_type { + an.timeout = Some(0); + } + let graph = graph_with(vec![("a", a), ("end", end_node("end"))], "a"); + + let result = validator().validate(&graph); + + let w = timeout_warnings(&result); + assert_eq!(w.len(), 1, "{:?}", result.warnings); + assert_eq!(w[0].node_id.as_deref(), Some("a")); + assert_eq!( + w[0].message, + "timeout: 0 disables the wall-clock bound for agent node 'a'; it runs until it returns" + ); + } + + #[test] + fn script_timeout_zero_warns() { + let mut s = script_node("s", "does-not-exist.py", None); + if let NodeType::Script(ref mut sn) = s.node_type { + sn.timeout = 0; + } + s.next = Some("end".into()); + let graph = graph_with(vec![("s", s), ("end", end_node("end"))], "s"); + + let result = validator().validate(&graph); + + let w = timeout_warnings(&result); + assert_eq!(w.len(), 1, "{:?}", result.warnings); + assert_eq!(w[0].node_id.as_deref(), Some("s")); + assert_eq!( + w[0].message, + "timeout: 0 disables the wall-clock bound for script node 's'; it runs until it returns" + ); + } + + #[test] + fn llm_timeout_zero_warns() { + let mut l = llm_node("l", None, Some("end")); + if let NodeType::Llm(ref mut ln) = l.node_type { + ln.timeout = Some(0); + } + let graph = graph_with(vec![("l", l), ("end", end_node("end"))], "l"); + + let result = validator().validate(&graph); + + assert!(result.is_valid(), "errors: {:?}", result.errors); + let w = timeout_warnings(&result); + assert_eq!(w.len(), 1, "{:?}", result.warnings); + assert_eq!(w[0].node_id.as_deref(), Some("l")); + assert_eq!( + w[0].message, + "timeout: 0 disables the wall-clock bound for llm node 'l'; it runs until it returns" + ); + } + + #[test] + fn rag_timeout_zero_warns() { + let mut r = rag_node("r", &["docs/"], true); + if let NodeType::Rag(ref mut rn) = r.node_type { + rn.timeout = Some(0); + } + let graph = graph_with(vec![("r", r), ("end", end_node("end"))], "r"); + + let result = validator().validate(&graph); + + assert!(result.is_valid(), "errors: {:?}", result.errors); + let w = timeout_warnings(&result); + assert_eq!(w.len(), 1, "{:?}", result.warnings); + assert_eq!(w[0].node_id.as_deref(), Some("r")); + assert_eq!( + w[0].message, + "timeout: 0 disables the wall-clock bound for rag node 'r'; it runs until it returns" + ); + } + + #[test] + fn nonzero_and_unset_timeouts_do_not_warn() { + let mut a = agent_node("a", "worker", Some("s")); + if let NodeType::Agent(ref mut an) = a.node_type { + an.timeout = Some(600); + } + let mut s = script_node("s", "does-not-exist.py", None); + s.next = Some("l".into()); + let l = llm_node("l", None, Some("end")); + let mut graph = graph_with( + vec![("a", a), ("s", s), ("l", l), ("end", end_node("end"))], + "a", + ); + graph.settings.timeout = Some(30); + + let result = validator().validate(&graph); + + assert!( + timeout_warnings(&result).is_empty(), + "non-zero and unset timeouts should not warn: {:?}", + result.warnings + ); + } + #[test] fn map_max_concurrency_zero_errors() { let mut map = map_node_basic("m", "br", Some("end"));