feat(graph): warn when a timeout is disabled

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.
This commit is contained in:
Dark-Alex-17 committed 2026-09-11 10:56:27 -06:00
1 parent d630c7e30b
commit cd0318f384
1 file changed
+158
+158
View File
@@ -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"));