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

+4 -1
View File
@@ -1029,7 +1029,10 @@ impl SpawnResultHooks {
/// Fires `agent.completed`, or — for a failed child — `agent.interrupted`
/// when its abort signal was ctrl-c'd (REPL ctrl-c or `agent__cancel`),
/// else `agent.failed`. The signal, never the error text, decides:
/// cancellation is not a failure, so no error env rides along.
/// cancellation is not a failure, so no error env rides along. Unlike the
/// signal-first `top_level_agent_finished` gate, completion wins over a
/// latched ctrl-c at this seam — a finished child's output is collectible,
/// so the asymmetry is intentional.
fn fire(self, error: Option<&str>, child_abort: &AbortSignal) {
let mut extras = vec![
("COYOTE_AGENT_ID", self.agent_id),