test(jobs): add feature, hardening, and surface test matrix for background jobs
Covers the plan's T7 matrix: zero-diff invariants when jobs are off (byte-identical tool lists and prompts, None-vs-Some select_functions), validation hardening (shell/path-shaped/PATH-resolvable names, undeclared MCP servers, non-whitelisted and context-filtered tools, mapping-tool aliases, mid-batch tool-scope freshness), process lifecycle (grandchild process-group kill, pgid clear after normal completion, panic skips the completion notification), guardrail behavior (finished-job discard on force-terminate, bounded inject-then-terminate iteration burn), surface conformance (concrete_tool_names exclusion, toggle rejection, tools_info listing, infra preservation under empty filters), supervisor swaps (use_agent/exit_agent kill running jobs, child contexts cannot reach parent job ids), and graph-node job lifecycle with deferred notification drain.
This commit is contained in:
@@ -856,4 +856,86 @@ nodes:
|
||||
);
|
||||
assert!(err.contains("sleeper"), "error should name frontier: {err}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn background_job_survives_graph_node_execution() {
|
||||
if !cmd_available("bash") {
|
||||
eprintln!("skipping: bash not available");
|
||||
return;
|
||||
}
|
||||
let ws = TestWorkspace::new();
|
||||
ws.write_script("noop.sh", "#!/bin/bash\necho '{}'\n");
|
||||
|
||||
let yaml = r#"
|
||||
name: background_job_survival_test
|
||||
start: noop
|
||||
nodes:
|
||||
noop:
|
||||
type: script
|
||||
script: noop.sh
|
||||
state_updates: {}
|
||||
next: done
|
||||
done:
|
||||
type: end
|
||||
output: "done"
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let join_handle = rt.spawn(async {
|
||||
Ok(crate::supervisor::JobResult {
|
||||
output: Value::Null,
|
||||
exit_code: Some(0),
|
||||
output_bytes_captured: 0,
|
||||
})
|
||||
});
|
||||
std::mem::forget(rt);
|
||||
let handle = crate::supervisor::JobHandle {
|
||||
id: "job_bg".to_string(),
|
||||
tool: "execute_command".to_string(),
|
||||
started_at: Instant::now(),
|
||||
join_handle,
|
||||
abort_signal: create_abort_signal(),
|
||||
state: Arc::new(parking_lot::Mutex::new(crate::supervisor::JobState {
|
||||
status: crate::supervisor::JobStatus::Completed,
|
||||
pgid: None,
|
||||
})),
|
||||
output_buf: Arc::new(parking_lot::Mutex::new(
|
||||
crate::function::jobs::RingBuf::default(),
|
||||
)),
|
||||
no_change_checks: 0,
|
||||
last_check_state: None,
|
||||
};
|
||||
let mut sup = crate::supervisor::Supervisor::new(0, 3).with_max_concurrent_jobs(4);
|
||||
sup.register(handle).unwrap();
|
||||
|
||||
let mut ctx = make_ctx();
|
||||
ctx.supervisor = Some(Arc::new(parking_lot::RwLock::new(sup)));
|
||||
ctx.notification_queue
|
||||
.push(crate::supervisor::notification::job_notification(
|
||||
"job_bg",
|
||||
"execute_command",
|
||||
true,
|
||||
));
|
||||
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, &ws.dir)
|
||||
.execute(&mut ctx, abort)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||
assert_eq!(result, "done");
|
||||
|
||||
assert!(
|
||||
ctx.supervisor.as_ref().unwrap().read().has_job("job_bg"),
|
||||
"graph execution must not touch registered job handles"
|
||||
);
|
||||
let events = ctx.notification_queue.drain();
|
||||
assert_eq!(events.len(), 1, "queued notification must survive the run");
|
||||
assert_eq!(events[0].id, "job_bg");
|
||||
assert_eq!(events[0].event, "job_completed");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user