From e746ca1f87cc7aa5ff3faca7318b5f6f12747068 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 11:55:28 -0600 Subject: [PATCH] fix(graph): clear peer identity fields after every map chain step Copilot review on PR #24. The fields were only ever cleared by run_agent_for_graph taking them, so a failure between arming and that take (e.g. prompt interpolation) left them set on the item's ctx. Reset both unconditionally once the step returns. --- src/graph/map.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/src/graph/map.rs b/src/graph/map.rs index bcbcc8e..b95966c 100644 --- a/src/graph/map.rs +++ b/src/graph/map.rs @@ -251,7 +251,11 @@ async fn run_chain_step( ctx.peer_assignment = Some(assignment.clone()); } - match step(node, state, ctx, step_ctx, current).await? { + let result = step(node, state, ctx, step_ctx, current).await; + ctx.peer_registry = None; + ctx.peer_assignment = None; + + match result? { StepResult::Continue(targets) => match targets.as_slice() { [] => { debug!( @@ -1687,6 +1691,81 @@ nodes: assert!(ctx.peer_registry.is_none() && ctx.peer_assignment.is_none()); } + #[tokio::test] + #[serial] + async fn run_item_chain_clears_peer_fields_after_flagged_agent_then_script() { + if !cmd_available("python3") { + eprintln!("skipping: python3 not available"); + return; + } + let _guard = TestConfigDirGuard::new(); + materialize_probe_agent(0.0); + let h = Harness::new(&format!( + r#" +name: t +start: worker +nodes: + worker: + type: agent + agent: {PROBE_AGENT} + prompt: "p" + teammates: true + state_updates: + output: "{{{{output}}}}" + next: finish + finish: + type: script + script: finish.py +"# + )); + h.ws.write_py( + "finish.py", + r#"print(json.dumps({"output": state["output"] + "+finished"}))"#, + ); + let peers = manual_peer("worker"); + let mut state = item_state(json!(0)); + let mut ctx = silent_ctx(); + + h.run("worker", Some(&peers), &mut state, &mut ctx) + .await + .unwrap_or_else(|e| panic!("chain failed: {e:#}")); + + assert_eq!(state.state().get("output"), Some(&json!("held+finished"))); + assert!(peers.0.is_finished(&peers.1.0)); + assert!(ctx.peer_registry.is_none() && ctx.peer_assignment.is_none()); + } + + /// The prompt fails to interpolate before `run_agent_for_graph` can take + /// the peer fields, so only the chain runner's reset can clear them. + #[tokio::test] + async fn run_item_chain_clears_peer_fields_when_agent_step_fails_before_takeover() { + let h = Harness::new( + r#" +name: t +start: worker +nodes: + worker: + type: agent + agent: no-such-agent + prompt: "{{nope}}" + teammates: true +"#, + ); + let peers = manual_peer("worker"); + let mut state = item_state(json!(0)); + let mut ctx = silent_ctx(); + + let chain = unwrap_err_chain(h.run("worker", Some(&peers), &mut state, &mut ctx).await); + + assert!( + chain.contains("Failed to interpolate prompt for agent 'no-such-agent'"), + "{chain}" + ); + assert!(chain.contains("'nope' not found in state"), "{chain}"); + assert!(ctx.peer_registry.is_none() && ctx.peer_assignment.is_none()); + assert!(peers.0.is_finished(&peers.1.0)); + } + #[tokio::test] async fn run_item_chain_reaches_flagged_agent_after_script_step() { if !cmd_available("python3") {