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.
|
// 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
@@ -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") {
|
||||||
|
|||||||
Reference in new issue
Block a user