fix(TASK-037): cover genuine node failure payload and malformed graph.yaml hook degradation
Two test gaps: no test fired a genuine graph.node.failed and inspected its payload (the only node-failure test asserted the hook's absence because a fallback absorbed the error), and no test drove a malformed hook definition through the new graph seams. The first test fails an agent node with no fallback and asserts the captured envs carry the node id, node type, a numeric duration, and a non-empty error while the failure still propagates out of the executor. The second routes a blank-command hook from graph.yaml's top level through the executor seam and proves it degrades safely: the node completes, the malformed hook is skipped with a debug log, and a well-formed sibling on the same event still fires.
This commit is contained in:
1 file changed
+114
@@ -1565,6 +1565,120 @@ nodes:
|
||||
);
|
||||
}
|
||||
|
||||
/// A node that genuinely fails — no fallback to absorb the error — fires
|
||||
/// `graph.node.failed` with the full payload observers rely on: the node's
|
||||
/// id and type, a numeric duration, and the error text, even though the
|
||||
/// failure then propagates out of the executor.
|
||||
#[tokio::test]
|
||||
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
|
||||
let _sink = crate::hooks::test_sink::install();
|
||||
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
|
||||
"graph.node.failed",
|
||||
"t037_gf_node_failed",
|
||||
)]));
|
||||
// Forces the agent node to fail before it ever loads the (absent)
|
||||
// agent from disk, keeping the test hermetic.
|
||||
ctx.current_depth = crate::config::default_max_agent_depth();
|
||||
|
||||
let yaml = r#"
|
||||
name: t
|
||||
settings:
|
||||
validate_before_run: false
|
||||
start: worker
|
||||
nodes:
|
||||
worker:
|
||||
type: agent
|
||||
agent: missing_agent
|
||||
prompt: p
|
||||
next: done
|
||||
done:
|
||||
type: end
|
||||
output: done
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, ".")
|
||||
.execute(&mut ctx, abort)
|
||||
.await;
|
||||
let error = result.expect_err("a node failure without a fallback must propagate");
|
||||
|
||||
let failed = hook_captures_named("t037_gf_node_failed");
|
||||
assert_eq!(failed.len(), 1, "{failed:?}");
|
||||
let envs = &failed[0].envs;
|
||||
assert_eq!(envs["COYOTE_EVENT"], "graph.node.failed");
|
||||
assert_eq!(envs["COYOTE_NODE_ID"], "worker");
|
||||
assert_eq!(envs["COYOTE_NODE_TYPE"], "agent");
|
||||
let duration = &envs["COYOTE_NODE_DURATION_MS"];
|
||||
let _: u128 = duration
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("non-numeric duration: {duration}"));
|
||||
assert!(!envs["COYOTE_NODE_ERROR"].is_empty(), "{envs:?}");
|
||||
assert!(
|
||||
format!("{error:#}").contains("Max agent depth exceeded"),
|
||||
"unexpected executor error: {error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed hook definition reaching the executor seam through
|
||||
/// graph.yaml's top level — an entry whose command is blank — degrades
|
||||
/// safely: the node still runs, the malformed hook is skipped with a
|
||||
/// debug log, and a well-formed sibling on the same event still fires.
|
||||
#[tokio::test]
|
||||
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
|
||||
crate::testing::install_warn_collector();
|
||||
let _sink = crate::hooks::test_sink::install();
|
||||
let mut ctx = make_ctx();
|
||||
|
||||
let yaml = r#"
|
||||
name: hooked_graph
|
||||
settings:
|
||||
validate_before_run: false
|
||||
start: done
|
||||
hooks:
|
||||
graph.node.completed:
|
||||
- name: t037_mal_empty
|
||||
command: " "
|
||||
- name: t037_mal_valid
|
||||
command: "true"
|
||||
nodes:
|
||||
done:
|
||||
type: end
|
||||
output: ok
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
ctx.agent = Some(Agent::test_new(AgentConfig::from_graph(
|
||||
"hooked_graph",
|
||||
&graph,
|
||||
)));
|
||||
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, ".")
|
||||
.execute(&mut ctx, abort)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||
assert_eq!(result, "ok");
|
||||
|
||||
assert!(
|
||||
hook_captures_named("t037_mal_empty").is_empty(),
|
||||
"a blank-command hook must be skipped, not dispatched"
|
||||
);
|
||||
let valid = hook_captures_named("t037_mal_valid");
|
||||
assert_eq!(valid.len(), 1, "{valid:?}");
|
||||
assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done");
|
||||
|
||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone();
|
||||
assert!(
|
||||
debugs
|
||||
.iter()
|
||||
.any(|message| message.contains("t037_mal_empty")
|
||||
&& message.contains("empty command")),
|
||||
"expected a debug log for the skipped hook: {debugs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Declared variables are seeded into state at run start, so the agent
|
||||
/// node's strict prompt interpolation of `{{foo}}` succeeds instead of
|
||||
/// bailing with "not found in state". `ctx.agent` is None here, so each
|
||||
|
||||
Reference in new issue
Block a user