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:
Dark-Alex-17 committed 2026-09-16 20:31:36 -06:00
1 parent a3b71a6889
commit c73d5317a0
6 files changed
+305 -9

No files matched your search

+46 -1
View File
@@ -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);