feat(TASK-049): add escalation.raised/answered, session.resumed, and agent.interrupted hook events

- escalation.raised fires in the child ctx after queue submit; escalation.answered
  fires in the parent ctx from the taken request, so both agree on origin envs
- escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist
  never applies to these root-level events
- session.resumed fires on the use_session resume leg, inverse of session.started
- agent.interrupted replaces agent.completed/failed when the abort signal was
  ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm
  (agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal;
  exit_agent and the shell-exit path stay non-interrupted by passing no signal
- document the deliberate non-fire paths (direct prompts, escalation timeout and
  parent-drop, graph approval nodes)
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 19:30:59 -06:00
1 parent 1744d2ba52
commit 739c1be8bb
8 files changed
+468 -41

No files matched your search

+55
View File
@@ -119,6 +119,7 @@ pub enum HookEvent {
TurnInterrupted,
TurnFailed,
SessionStarted,
SessionResumed,
SessionEnded,
SessionCompressed,
ToolStarted,
@@ -129,7 +130,10 @@ pub enum HookEvent {
LlmRequestFailed,
AgentStarted,
AgentCompleted,
AgentInterrupted,
AgentFailed,
EscalationRaised,
EscalationAnswered,
GraphNodeStarted,
GraphNodeCompleted,
GraphNodeFailed,
@@ -146,6 +150,7 @@ impl HookEvent {
HookEvent::TurnInterrupted => "turn.interrupted",
HookEvent::TurnFailed => "turn.failed",
HookEvent::SessionStarted => "session.started",
HookEvent::SessionResumed => "session.resumed",
HookEvent::SessionEnded => "session.ended",
HookEvent::SessionCompressed => "session.compressed",
HookEvent::ToolStarted => "tool.started",
@@ -156,7 +161,10 @@ impl HookEvent {
HookEvent::LlmRequestFailed => "llm.request.failed",
HookEvent::AgentStarted => "agent.started",
HookEvent::AgentCompleted => "agent.completed",
HookEvent::AgentInterrupted => "agent.interrupted",
HookEvent::AgentFailed => "agent.failed",
HookEvent::EscalationRaised => "escalation.raised",
HookEvent::EscalationAnswered => "escalation.answered",
HookEvent::GraphNodeStarted => "graph.node.started",
HookEvent::GraphNodeCompleted => "graph.node.completed",
HookEvent::GraphNodeFailed => "graph.node.failed",
@@ -220,6 +228,17 @@ fn resolve_hooks(
let event_name = event.as_str();
let mut resolved = Vec::new();
// Escalations are a root/user-level concern: the user wires these hooks
// to hear about children asking for help, so requiring every agent to
// whitelist them through `global_hooks` would silence exactly the events
// they were set up for. The per-agent gate never applies here (this also
// skips the unknown-entry diagnostics: whitelisting an escalation hook is
// harmless and needs no warning).
let agent_gate = match event {
HookEvent::EscalationRaised | HookEvent::EscalationAnswered => None,
_ => agent_gate,
};
if let Some(defs) = global_hooks.get(event_name) {
let cwd = paths::config_dir();
for def in defs {
@@ -1326,6 +1345,7 @@ mod tests {
(HookEvent::TurnInterrupted, "turn.interrupted"),
(HookEvent::TurnFailed, "turn.failed"),
(HookEvent::SessionStarted, "session.started"),
(HookEvent::SessionResumed, "session.resumed"),
(HookEvent::SessionEnded, "session.ended"),
(HookEvent::SessionCompressed, "session.compressed"),
(HookEvent::ToolStarted, "tool.started"),
@@ -1336,7 +1356,10 @@ mod tests {
(HookEvent::LlmRequestFailed, "llm.request.failed"),
(HookEvent::AgentStarted, "agent.started"),
(HookEvent::AgentCompleted, "agent.completed"),
(HookEvent::AgentInterrupted, "agent.interrupted"),
(HookEvent::AgentFailed, "agent.failed"),
(HookEvent::EscalationRaised, "escalation.raised"),
(HookEvent::EscalationAnswered, "escalation.answered"),
(HookEvent::GraphNodeStarted, "graph.node.started"),
(HookEvent::GraphNodeCompleted, "graph.node.completed"),
(HookEvent::GraphNodeFailed, "graph.node.failed"),
@@ -1376,6 +1399,38 @@ mod tests {
assert_eq!(names(&resolved), ["own"]);
}
#[test]
fn escalation_events_bypass_the_agent_whitelist_gate() {
let mut global = hooks_map("escalation.raised", &[("watch", "cmd-raised")]);
global.extend(hooks_map(
"escalation.answered",
&[("watch", "cmd-answered")],
));
global.extend(hooks_map("tool.started", &[("watch", "cmd-tool")]));
for event in [HookEvent::EscalationRaised, HookEvent::EscalationAnswered] {
let resolved = resolve_hooks(event, &global, Some((&[], "gated-agent")), None, None);
assert_eq!(
names(&resolved),
["watch"],
"{} must resolve without a whitelist entry",
event.as_str()
);
}
let resolved = resolve_hooks(
HookEvent::ToolStarted,
&global,
Some((&[], "gated-agent")),
None,
None,
);
assert!(
resolved.is_empty(),
"non-escalation events stay behind the whitelist gate"
);
}
#[test]
fn whitelist_admits_only_exact_global_entries() {
let global = hooks_map(