feat(TASK-049): harden hook dispatch against NUL env values and nested-supervisor latch misses
- strip interior NUL bytes from hook env values in fire_resolved so agent-authored text (e.g. escalation questions) cannot suppress a hook via a failed spawn; raise the spawn-failure log to warn! - make Supervisor::has_active_tasks recurse into child supervisors, mirroring cancel_recursive, so a prompt ctrl-c that cancels only a grandchild still latches as an interruption - document the signal-first vs error-first classification asymmetry at top_level_agent_finished / SpawnResultHooks::fire and the deliberate REPL teardown cancel; pin ctrlc-with-no-error => agent.interrupted - probe Scenario J: pty-driven ctrl-c during a live headless --agent run fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR
This commit is contained in:
6 files changed
+305
-9
No files matched your search
+46
-1
@@ -192,8 +192,18 @@ impl Supervisor {
|
||||
|
||||
/// True when any registered agent or job is still running -- i.e. a
|
||||
/// cancellation right now would actually interrupt in-flight work.
|
||||
/// Mirrors `cancel_recursive`'s traversal: work buried in a nested child
|
||||
/// supervisor still counts, so a prompt ctrl-c that cancels only a
|
||||
/// grandchild latches like any other interruption.
|
||||
pub fn has_active_tasks(&self) -> bool {
|
||||
self.effective_active_count() > 0 || self.active_job_count() > 0
|
||||
self.effective_active_count() > 0
|
||||
|| self.active_job_count() > 0
|
||||
|| self.agents().any(|agent| {
|
||||
agent
|
||||
.child_supervisor
|
||||
.as_ref()
|
||||
.is_some_and(|child_sup| child_sup.read().has_active_tasks())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn max_concurrent(&self) -> usize {
|
||||
@@ -499,6 +509,41 @@ mod tests {
|
||||
assert!(sup.has_active_tasks());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_active_tasks_true_with_only_grandchild_activity() {
|
||||
// Keep the runtime alive so the grandchild task is never polled and
|
||||
// counts as running.
|
||||
let rt = Builder::new_current_thread().enable_all().build().unwrap();
|
||||
let join_handle = rt.spawn(async {
|
||||
Ok::<AgentResult, Error>(AgentResult {
|
||||
id: "done".into(),
|
||||
agent_name: "test".into(),
|
||||
output: "result".into(),
|
||||
exit_status: AgentExitStatus::Completed,
|
||||
})
|
||||
});
|
||||
let grandchild = AgentHandle {
|
||||
id: "g1".to_string(),
|
||||
agent_name: "worker".to_string(),
|
||||
depth: 2,
|
||||
inbox: Arc::new(Inbox::new()),
|
||||
abort_signal: create_abort_signal(),
|
||||
join_handle,
|
||||
child_supervisor: None,
|
||||
};
|
||||
let mut child_sup = Supervisor::new(4, 3);
|
||||
child_sup.register(grandchild).unwrap();
|
||||
|
||||
// make_handle's direct task is already finished, so the only live
|
||||
// work sits one supervisor level down.
|
||||
let mut parent_handle = make_handle("a1", "explore", 1);
|
||||
parent_handle.child_supervisor = Some(Arc::new(RwLock::new(child_sup)));
|
||||
let mut sup = Supervisor::new(4, 3);
|
||||
sup.register(parent_handle).unwrap();
|
||||
|
||||
assert!(sup.has_active_tasks());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_register_rejects_exceeding_depth() {
|
||||
let mut sup = Supervisor::new(4, 2);
|
||||
|
||||
Reference in new issue
Block a user