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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user