test(graph): pin the per-item retry chain and bundled validator warning parity

Add the motivating map-chain integration test: draft -> check, where check
re-routes to draft via script `_next` until an item-local attempts counter
reaches 2, run with validate_before_run on. A sibling test drives the same
map node against a parent state to pin that only collect_into lands on the
parent and that no chain visit touches the parent's loop counts.

Extend bundled_graph_agents_parse_and_validate: the sorted (node, warning)
set of every bundled graph must equal the set the pre-subgraph validator
produced, and graph.example.yaml must load through GraphParser, validate
without errors, and declare a writer for its map's output_key.
This commit is contained in:
Dark-Alex-17 committed 2026-09-11 10:56:27 -06:00
1 parent b4a73aae7f
commit c10965cd71
2 files changed
+220 -1

No files matched your search

+113
View File
@@ -740,6 +740,119 @@ nodes:
assert_eq!(collected(&result), vec![json!(3), json!(5), json!(7)]);
}
const RETRY_CHAIN_GRAPH: &str = r#"
name: retry-chain
start: fan_out
settings:
validate_before_run: true
initial_state:
items: ["a", "b", "c"]
nodes:
fan_out:
type: map
over: "{{items}}"
as: item
branch: draft
output_key: result
collect_into: results
next: done
draft:
type: script
script: draft.py
next: check
check:
type: script
script: check.py
done:
type: end
output: "{{results}}"
"#;
fn write_retry_chain_scripts(ws: &TestWorkspace) {
ws.write_py(
"draft.py",
r##"attempts = state.get("attempts", 0) + 1
print(json.dumps({"attempts": attempts, "draft": f"{state['item']}#{attempts}"}))"##,
);
ws.write_py(
"check.py",
r#"if state["attempts"] < 2:
print(json.dumps({"_next": "draft"}))
else:
print(json.dumps({"result": {"item": state["item"], "draft": state["draft"], "attempts": state["attempts"]}}))"#,
);
}
fn retry_chain_results() -> Vec<Value> {
["a", "b", "c"]
.iter()
.map(|item| json!({"item": item, "draft": format!("{item}#2"), "attempts": 2}))
.collect()
}
#[tokio::test]
async fn map_chain_retries_via_script_next_until_check_accepts() {
if !cmd_available("python3") {
eprintln!("skipping: python3 not available");
return;
}
let ws = TestWorkspace::new();
write_retry_chain_scripts(&ws);
let result = run_graph(RETRY_CHAIN_GRAPH, &ws)
.await
.unwrap_or_else(|e| panic!("validated retry chain failed: {e:#}"));
assert_eq!(collected(&result), retry_chain_results());
}
#[tokio::test]
async fn map_chain_retries_leave_parent_state_and_loop_counts_untouched() {
if !cmd_available("python3") {
eprintln!("skipping: python3 not available");
return;
}
let ws = TestWorkspace::new();
write_retry_chain_scripts(&ws);
let graph: Arc<Graph> = Arc::new(serde_yaml::from_str(RETRY_CHAIN_GRAPH).unwrap());
let NodeType::Map(map_node) = &graph.get_node("fan_out").unwrap().node_type else {
panic!("fan_out should be a map node");
};
let script = ScriptExecutor::new(&ws.dir);
let abort = create_abort_signal();
let step_ctx = StepContext {
graph: Arc::clone(&graph),
script_executor: &script,
max_concurrency: 4,
abort_signal: &abort,
branch_mode: false,
};
let items = json!(["a", "b", "c"]);
let mut parent = StateManager::new(HashMap::from([("items".to_string(), items.clone())]));
let mut ctx = silent_ctx();
MapNodeExecutor::execute(map_node, &mut parent, &mut ctx, &step_ctx, "fan_out")
.await
.unwrap_or_else(|e| panic!("map failed: {e:#}"));
// Only `collect_into` lands on the parent: the per-item `attempts`,
// `draft` and `result` writes were fork-local scratch.
assert_eq!(
*parent.state().data(),
HashMap::from([
("items".to_string(), items),
("results".to_string(), Value::Array(retry_chain_results())),
])
);
for node in ["fan_out", "draft", "check"] {
assert_eq!(
parent.state().loop_count(node),
0,
"parent loop count for '{node}'"
);
}
}
#[tokio::test]
async fn map_chain_script_next_loop_is_bounded_by_max_loop_iterations() {
if !cmd_available("python3") {