diff --git a/src/graph/executor.rs b/src/graph/executor.rs index 937e778..21fc42a 100644 --- a/src/graph/executor.rs +++ b/src/graph/executor.rs @@ -143,10 +143,11 @@ impl GraphExecutor { // Loop-count and visit tracking on live state, BEFORE forking. // This counts every entry to a node toward max_loop_iterations // regardless of how many parallel branches converged on it. + // A cap of 0 disables the check. for node_id in &frontier { state.state_mut().visit_node(node_id); let visits = state.state().loop_count(node_id); - if visits > max_iterations { + if max_iterations > 0 && visits > max_iterations { bail!( "Node '{}' visited {} times (max_loop_iterations={}). \ Possible infinite loop.", @@ -1256,6 +1257,95 @@ nodes: result.unwrap_or_else(|e| panic!("timeout: 0 should not bound the run: {e:#}")); } + /// Bash script that bumps `n` in state and re-enters `looper` while + /// `n < stop`; once `n == stop` it omits `_next` so the static + /// `next: done` edge is taken. + fn looper_script(stop: usize) -> String { + format!( + "#!/bin/bash\n\ + t=${{GRAPH_STATE#*'\"n\":'}}\n\ + n=${{t%%[!0-9]*}}\n\ + n=$((n + 1))\n\ + if (( n < {stop} )); then\n\ + printf '{{\"n\": %d, \"_next\": \"looper\"}}' \"$n\"\n\ + else\n\ + printf '{{\"n\": %d}}' \"$n\"\n\ + fi\n" + ) + } + + fn loop_cap_graph(max_loop_iterations: usize) -> Graph { + let yaml = format!( + r#" +name: loop_cap_test +start: looper +initial_state: + n: 0 +settings: + max_loop_iterations: {max_loop_iterations} +nodes: + looper: + type: script + script: looper.sh + next: done + done: + type: end + output: "{{{{n}}}}" +"# + ); + serde_yaml::from_str(&yaml).unwrap() + } + + #[tokio::test] + async fn max_loop_iterations_zero_is_unbounded() { + if !cmd_available("bash") { + eprintln!("skipping: bash not available"); + return; + } + // Loop past the default cap so a fallback-to-default implementation + // would be caught, not just a lifted small cap. + let stop = crate::graph::DEFAULT_MAX_LOOP_ITERATIONS + 1; + let ws = TestWorkspace::new(); + ws.write_script("looper.sh", &looper_script(stop)); + + let graph = loop_cap_graph(0); + let mut ctx = make_ctx(); + let abort = create_abort_signal(); + let result = GraphExecutor::new(graph, &ws.dir) + .execute(&mut ctx, abort) + .await + .unwrap_or_else(|e| panic!("max_loop_iterations: 0 should not cap visits: {e:#}")); + + assert_eq!(result, stop.to_string()); + } + + #[tokio::test] + async fn max_loop_iterations_nonzero_still_bounds() { + if !cmd_available("bash") { + eprintln!("skipping: bash not available"); + return; + } + let ws = TestWorkspace::new(); + // Always re-enter `looper`: only the cap can end this run. + ws.write_script("looper.sh", "#!/bin/bash\necho '{\"_next\": \"looper\"}'\n"); + + let graph = loop_cap_graph(2); + let mut ctx = make_ctx(); + let abort = create_abort_signal(); + let result = GraphExecutor::new(graph, &ws.dir) + .execute(&mut ctx, abort) + .await; + + assert!(result.is_err(), "expected the visit cap to abort the run"); + let err = format!("{:#}", result.unwrap_err()); + assert!( + err.contains( + "Node 'looper' visited 3 times (max_loop_iterations=2). Possible infinite loop." + ), + "error should report the visit cap: {err}" + ); + } + #[cfg(unix)] #[tokio::test] async fn background_job_survives_graph_node_execution() { diff --git a/src/graph/map.rs b/src/graph/map.rs index a0af2e6..ffa1341 100644 --- a/src/graph/map.rs +++ b/src/graph/map.rs @@ -240,7 +240,7 @@ async fn run_chain_step( state.state_mut().visit_node(current); let visits = state.state().loop_count(current); let max_loops = step_ctx.graph.settings.max_loop_iterations; - if visits > max_loops { + if max_loops > 0 && visits > max_loops { bail!("node '{current}' visited {visits} times (max_loop_iterations={max_loops})"); } @@ -954,6 +954,51 @@ nodes: assert_eq!(chain.matches("sub-branch [").count(), 1, "{chain}"); } + #[tokio::test] + async fn map_chain_script_next_loop_with_zero_cap_runs_to_completion() { + if !cmd_available("python3") { + eprintln!("skipping: python3 not available"); + return; + } + let ws = TestWorkspace::new(); + // Re-enter `looper` until n reaches 5; omitting `_next` ends the item. + ws.write_py( + "looper.py", + r#"n = state.get("n", 0) + 1 +out = {"n": n, "output": n} +if n < 5: + out["_next"] = "looper" +print(json.dumps(out))"#, + ); + + let yaml = r#" +name: chain +start: fan_out +settings: + max_loop_iterations: 0 + validate_before_run: false +initial_state: + items: [1] +nodes: + fan_out: + type: map + over: "{{items}}" + as: item + branch: looper + collect_into: results + next: done + looper: + type: script + script: looper.py + done: + type: end + output: "{{results}}" +"#; + let result = run_graph(yaml, &ws).await; + + assert_eq!(collected(&result.unwrap()), vec![json!(5)]); + } + #[tokio::test] async fn map_chain_next_outside_subgraph_fails_item() { if !cmd_available("python3") {