feat(graph): treat max_loop_iterations: 0 as no visit cap

Both enforcement sites (frontier loop and map chain step) skip the
comparison when the cap is 0; visits are still counted and logged.
Adds an executor test that loops past the default cap with 0, a test
pinning the frontier cap message for a non-zero value, and a map-chain
twin at 0. (design §3.14 / C22)
This commit is contained in:
Dark-Alex-17 committed 2026-09-11 10:56:27 -06:00
1 parent 4dee1a6849
commit 3aa35e1259
2 files changed
+137 -2

No files matched your search

+91 -1
View File
@@ -143,10 +143,11 @@ impl GraphExecutor {
// Loop-count and visit tracking on live state, BEFORE forking. // Loop-count and visit tracking on live state, BEFORE forking.
// This counts every entry to a node toward max_loop_iterations // This counts every entry to a node toward max_loop_iterations
// regardless of how many parallel branches converged on it. // regardless of how many parallel branches converged on it.
// A cap of 0 disables the check.
for node_id in &frontier { for node_id in &frontier {
state.state_mut().visit_node(node_id); state.state_mut().visit_node(node_id);
let visits = state.state().loop_count(node_id); let visits = state.state().loop_count(node_id);
if visits > max_iterations { if max_iterations > 0 && visits > max_iterations {
bail!( bail!(
"Node '{}' visited {} times (max_loop_iterations={}). \ "Node '{}' visited {} times (max_loop_iterations={}). \
Possible infinite loop.", Possible infinite loop.",
@@ -1256,6 +1257,95 @@ nodes:
result.unwrap_or_else(|e| panic!("timeout: 0 should not bound the run: {e:#}")); 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)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn background_job_survives_graph_node_execution() { async fn background_job_survives_graph_node_execution() {
+46 -1
View File
@@ -240,7 +240,7 @@ async fn run_chain_step(
state.state_mut().visit_node(current); state.state_mut().visit_node(current);
let visits = state.state().loop_count(current); let visits = state.state().loop_count(current);
let max_loops = step_ctx.graph.settings.max_loop_iterations; 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})"); 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}"); 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] #[tokio::test]
async fn map_chain_next_outside_subgraph_fails_item() { async fn map_chain_next_outside_subgraph_fails_item() {
if !cmd_available("python3") { if !cmd_available("python3") {