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:
2 files changed
+137
-2
No files matched your search
+91
-1
@@ -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() {
|
||||
|
||||
+46
-1
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user