feat(TASK-034): hook dispatch engine in src/hooks.rs

- HookEvent (22 dotted variants) + owned ResolvedHook snapshots
- RequestContext::resolved_hooks: global (gated by agent global_hooks)
  -> role (additive) -> agent precedence; per-scope cwds; unknown
  whitelist entries inert + debug-logged with agent name
- fire/fire_resolved: fire-and-forget detached dispatch via platform
  shell (sh -c / cmd /C raw_arg), null stdio, fresh unix process group,
  reap-only await, no-runtime guard (skip + debug, never panic)
- tool.* payload files: crypto-random nonce, create_new + 0600, verbatim
  JSON, best-effort delete after reap, hook still runs on write failure
- base env (COYOTE_EVENT/HOOK_NAME/CONFIG_DIR/EVENT_TIMESTAMP RFC3339
  UTC, SESSION_ID/AGENT_NAME when present) + per-event extras; every
  value truncated engine-side to 2048 bytes (UTF-8-safe; payload path
  documented exempt)
- drain_pending(timeout): opt-in exit-path seam with spawn-ack
  semantics (Drop-guard counter; never waits for child completion)
- #[cfg(test)] synchronous capture sink (records instead of spawning)
  + hooks-scoped debug capture in src/testing.rs (poison-proof,
  boundary-aware target prefix)
- Agent::hooks()/global_hooks() accessors; Role::hooks() dead_code
  marker removed

Engine compiles unused by design; call sites land in TASK-035/036/037.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:34 -06:00
1 parent b020e1b75e
commit 0c4d0d777c
4 files changed
+1580 -14

No files matched your search

+8
View File
@@ -640,6 +640,14 @@ impl Agent {
self.config.compression_model.as_deref()
}
pub fn hooks(&self) -> &HooksMap {
&self.config.hooks
}
pub fn global_hooks(&self) -> &[String] {
&self.config.global_hooks
}
pub fn is_dynamic_instructions(&self) -> bool {
self.config.dynamic_instructions
}
-1
View File
@@ -388,7 +388,6 @@ impl Role {
self.memory
}
#[allow(dead_code)]
pub fn hooks(&self) -> Option<&HooksMap> {
self.hooks.as_ref()
}
+1520
View File
File diff suppressed because it is too large. Load diff
+52 -13
View File
@@ -3,27 +3,64 @@
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::sync::{Mutex, Once, OnceLock};
struct WarnCollector;
struct TestLogCollector;
static WARN_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
static DEBUG_MESSAGES: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
/// Every warn-level message captured since the collector was installed. The
/// buffer is process-global and shared by all tests, so assert by filtering
/// for a marker unique to the test rather than on the whole buffer.
/// Debug capture is restricted to the hooks module so the buffer is not
/// flooded by debug output from the rest of the crate. Matching is
/// module-boundary-aware: only this exact module or its `::` submodules
/// qualify, so a sibling like `hooks_registry` would not.
const DEBUG_TARGET_PREFIX: &str = concat!(env!("CARGO_CRATE_NAME"), "::hooks");
fn captures_warn(metadata: &Metadata) -> bool {
metadata.level() <= Level::Warn
}
fn captures_hooks_debug(metadata: &Metadata) -> bool {
if metadata.level() != Level::Debug {
return false;
}
let target = metadata.target();
target == DEBUG_TARGET_PREFIX
|| target
.strip_prefix(DEBUG_TARGET_PREFIX)
.is_some_and(|rest| rest.starts_with("::"))
}
/// Every warn- or error-level message captured since the collector was
/// installed. The buffer is process-global and shared by all tests, so assert
/// by filtering for a marker unique to the test rather than on the whole
/// buffer.
pub(crate) fn warn_messages() -> &'static Mutex<Vec<String>> {
WARN_MESSAGES.get_or_init(Mutex::default)
}
impl Log for WarnCollector {
/// Every debug-level message from the hooks module captured since the
/// collector was installed. Same marker-filtering discipline as
/// [`warn_messages`].
pub(crate) fn debug_messages() -> &'static Mutex<Vec<String>> {
DEBUG_MESSAGES.get_or_init(Mutex::default)
}
impl Log for TestLogCollector {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= Level::Warn
captures_warn(metadata) || captures_hooks_debug(metadata)
}
// A test that panics while holding a buffer poisons its mutex; the logger
// recovers via `into_inner` so every later test can still log.
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
if captures_warn(record.metadata()) {
warn_messages()
.lock()
.unwrap()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(record.args().to_string());
} else if captures_hooks_debug(record.metadata()) {
debug_messages()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(record.args().to_string());
}
}
@@ -31,13 +68,15 @@ impl Log for WarnCollector {
fn flush(&self) {}
}
/// Installs the process-wide warn collector. `log::set_logger` accepts one
/// logger per process, so every test that captures warns must install through
/// this shared entry point.
/// Installs the process-wide log collector. `log::set_logger` accepts one
/// logger per process, so every test that captures warns or hooks debug
/// output must install through this shared entry point. The max level is
/// Debug so the hooks debug capture sees its records; warn capture is
/// unaffected.
pub(crate) fn install_warn_collector() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
log::set_logger(&WarnCollector).expect("no other logger should be installed");
log::set_max_level(LevelFilter::Warn);
log::set_logger(&TestLogCollector).expect("no other logger should be installed");
log::set_max_level(LevelFilter::Debug);
});
}