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:
2026-08-25 20:33:10 -06:00
parent 6256b5fcfa
commit 28018f33c9
6 changed files with 862 additions and 0 deletions
+82
View File
@@ -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");
}
}