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

+44 -1
View File
@@ -4972,7 +4972,14 @@ impl RequestContext {
};
// The abort signal, never the error text, decides interrupted: a
// ctrl-c'd run is not a failure, so no error env rides along.
// ctrl-c'd run is not a failure, so no error env rides along. The
// gate is deliberately signal-first -- a latched ctrl-c means
// interrupted even with no error in hand -- unlike
// SpawnResultHooks::fire, which is error-first: a child that finished
// cleanly stays agent.completed under a latched signal because its
// output is collectible. Blind spot: a mid-stream ctrl-d aborts live
// work via poll_abort_signal yet still classifies as agent.completed,
// because ctrl-d always means a deliberate exit (accepted).
if abort_signal.is_some_and(|signal| signal.aborted_ctrlc()) {
hooks::fire(
HookEvent::AgentInterrupted,
@@ -8619,6 +8626,42 @@ mod tests {
}));
}
#[test]
#[serial]
fn top_level_agent_ctrlc_without_error_fires_agent_interrupted() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "agent-top-int-noerr-v4d";
let agent_name = unique_name("hook_agent");
seed_agent_with_hooks(&agent_name, marker);
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, None, abort.clone())).unwrap();
ctx.top_level_agent_started();
abort.set_ctrlc();
// Signal-first: a latched ctrl-c means interrupted even when the run
// surfaced no error at all.
ctx.top_level_agent_finished(None, Some(&abort));
let captures = marker_captures(marker);
assert_eq!(
captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-agent.interrupted"))
.count(),
1
);
assert!(
captures
.iter()
.all(|capture| capture.hook_name != format!("{marker}-agent.completed")),
"a ctrl-c'd run must not report completion"
);
}
/// The REPL's advertised exit path -- ctrl-c at an idle prompt (which
/// prints "To exit, press Ctrl+D"), then ctrl-d -- is a deliberate exit:
/// with no active tasks to cancel the prompt latch stays clear, so