style: Cleaned up unnecessary path prefixes, imports, stray scripts, and some documentation

This commit is contained in:
Dark-Alex-17 committed 2026-09-17 12:00:16 -06:00
1 parent 5bd1174ddd
commit fcdcb303a3
26 files changed
+132 -1286

No files matched your search

-2
View File
@@ -50,8 +50,6 @@ pub(super) fn model_completer(current: &OsStr) -> Vec<CompletionCandidate> {
fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
let h = tokio::runtime::Handle::try_current().ok();
// Tab completion is a read-only inspection surface: never run the
// first-run wizard or write anything from a completer.
let cfg = match h {
Some(handle) => tokio::task::block_in_place(|| {
handle.block_on(Config::load_with_interpolation(true, true))
+17 -26
View File
@@ -113,7 +113,7 @@ pub(crate) fn install_and_reconcile_agent_hooks(
/// swept, while files absent from the manifest always survive.
/// Best-effort: a failure here must not abort the install.
fn sweep_removed_agent_hooks(bundled_files: &HashMap<String, HashSet<String>>) {
let entries = match std::fs::read_dir(paths::agents_data_dir()) {
let entries = match read_dir(paths::agents_data_dir()) {
Ok(entries) => entries,
Err(err) => {
warn!("Failed to scan agents dir for removed-agent hooks: {err}");
@@ -156,7 +156,7 @@ fn sweep_removed_agent_hooks(bundled_files: &HashMap<String, HashSet<String>>) {
}
// remove_dir only succeeds on an empty directory, so a user file
// left behind keeps the directory in place.
let _ = std::fs::remove_dir(&hooks_dir);
let _ = fs::remove_dir(&hooks_dir);
}
}
@@ -184,9 +184,6 @@ impl Agent {
paths::agents_data_dir().display()
);
// Whole-agent installs run Skip (startup) or Force (reinstall); a
// Prompt caller would still get coherent per-file conflict handling,
// scoped to this run.
let mut sticky = StickyMode::None;
let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new();
for file in AgentAssets::iter() {
@@ -283,15 +280,7 @@ impl Agent {
Ok(())
}
/// Refreshes only the bundled agents' hook scripts, leaving every other
/// agent file alone: the hooks asset category must be able to update all
/// hook locations without reinstalling whole agents.
/// Every bundled agent's hooks/ directory reconciles, even when the
/// current embed ships no hooks for it.
pub fn install_builtin_agent_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> {
// Every bundled agent gets an entry, hooks or not: an agent whose
// hook assets were all dropped still needs its hooks/ directory
// reconciled so manifest-owned leftovers are removed.
let mut shipped: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
for file in AgentAssets::iter() {
let Some((agent, rest)) = file.as_ref().split_once('/') else {
@@ -311,6 +300,7 @@ impl Agent {
for (agent, files) in &shipped {
install_and_reconcile_agent_hooks(agent, files, mode, sticky)?;
}
Ok(())
}
@@ -1496,6 +1486,7 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>
#[cfg(test)]
mod tests {
use super::*;
use crate::testing;
#[test]
fn parse_direct_hook_path_accepts_only_direct_hook_children() {
@@ -1610,12 +1601,12 @@ global_hooks:
#[test]
#[serial_test::serial]
fn load_envs_ignores_malformed_global_tools_and_logs_debug() {
crate::testing::install_log_collector();
testing::install_log_collector();
let name = "gt-malformed-envtest";
let yaml = format!("name: {name}\ninstructions: hi\nglobal_tools:\n - keep_tool.sh\n");
let mut config: AgentConfig = serde_yaml::from_str(&yaml).unwrap();
let env_name = normalize_env_name(&format!("{name}_global_tools"));
let _guard = crate::testing::EnvVarGuard::set(&env_name, "{not-json");
let _guard = testing::EnvVarGuard::set(&env_name, "{not-json");
config.load_envs(&AppConfig::default());
@@ -1624,7 +1615,7 @@ global_hooks:
vec!["keep_tool.sh"],
"a malformed override must leave the existing value untouched"
);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(
debugs.iter().any(|message| {
message.contains("Ignoring malformed global_tools env override")
@@ -2076,9 +2067,9 @@ nodes: {}
let _guard = TestConfigDirGuard::new("agent-hooks-prompt");
let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]);
let dir = paths::agents_data_dir().join("probe").join("hooks");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.sh"), "local a").unwrap();
std::fs::write(dir.join("b.sh"), "local b").unwrap();
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "local a").unwrap();
fs::write(dir.join("b.sh"), "local b").unwrap();
// First conflict answered per-file, second by the sticky replace-all.
let script = prompt_script::install(&["replace-all"]);
@@ -2098,7 +2089,7 @@ nodes: {}
// A sticky mode carried in from an earlier location suppresses all
// prompting: `--install-builtins hooks` shares one sticky scope
// across the global, role, and agent hook locations.
std::fs::write(dir.join("a.sh"), "local again").unwrap();
fs::write(dir.join("a.sh"), "local again").unwrap();
let script = prompt_script::install(&[]);
let mut sticky = StickyMode::KeepAll;
install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky)
@@ -2121,7 +2112,7 @@ nodes: {}
&mut StickyMode::None,
)
.unwrap();
std::fs::write(dir.join("user.sh"), "user-owned").unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
install_and_reconcile_agent_hooks(
"probe",
@@ -2182,7 +2173,7 @@ nodes: {}
&mut StickyMode::None,
)
.unwrap();
std::fs::write(dir.join("user.sh"), "user-owned").unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
Agent::install_builtin_agent_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
@@ -2200,14 +2191,14 @@ nodes: {}
let guard = TestConfigDirGuard::new("agent-sweep-symlink");
let real = guard.path.join("agent-elsewhere");
let hooks = real.join("hooks");
std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(
create_dir_all(&hooks).unwrap();
fs::write(
hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE),
"shipped.sh\n",
)
.unwrap();
std::fs::write(hooks.join("shipped.sh"), "stale").unwrap();
std::fs::create_dir_all(paths::agents_data_dir()).unwrap();
fs::write(hooks.join("shipped.sh"), "stale").unwrap();
create_dir_all(paths::agents_data_dir()).unwrap();
std::os::unix::fs::symlink(&real, paths::agents_data_dir().join("linked")).unwrap();
sweep_removed_agent_hooks(&HashMap::new());
-2
View File
@@ -75,8 +75,6 @@ impl AppState {
}
}
// Inspection-only runs are look-don't-touch readouts: skip the
// function-binary prune/build, which writes into the config dir.
let mut functions = if inspection {
Functions::default()
} else {
+1 -1
View File
@@ -24,7 +24,7 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
}
/// NTFS 8.3 short names (`BUILTI~1`, `builti~1.sh`) can alias any long
/// filename on the same volume — including the manifest itself — so an entry
/// filename on the same volume, including the manifest itself, so an entry
/// shaped like one is never accepted: it could direct a deletion at a file
/// the manifest never named.
fn is_ntfs_short_name_alias(name: &str) -> bool {
-4
View File
@@ -241,10 +241,6 @@ impl BundleStore {
same_name_other_source: None,
};
// Reserved names are the `--install-builtins` categories. `roles` is
// deliberately NOT among them: builtin roles are not installable that
// way (some are coupled to flag-specific coyote behavior), so the
// asymmetry with InstallFilter is intended.
let reserved = AssetCategory::parse(&base).is_some();
let collision = self.source_of_other_bundle(&base, &canonical);
if reserved || collision.is_some() {
+7 -22
View File
@@ -2,7 +2,7 @@
//! silently clobber locally modified files. The bundle installer and the
//! builtin hook installers all funnel per-file conflicts through
//! [`resolve_conflict`], so the prompt wording and the sticky
//! keep-all/replace-all semantics are identical everywhere — including when
//! keep-all/replace-all semantics are identical everywhere, including when
//! one sticky scope spans several install locations in a single run.
use crate::utils::IS_STDOUT_TERMINAL;
@@ -12,17 +12,10 @@ use inquire::Select;
use std::fs;
use std::path::Path;
/// How an installer treats a destination file that already exists.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallMode {
/// Leave existing files untouched. The only mode startup ever uses:
/// startup runs on every launch — including the REPL, which is a
/// terminal — so it must never prompt.
Skip,
/// Overwrite existing files unconditionally.
Force,
/// Write missing files, silently skip identical ones, and ask per
/// differing file.
Prompt,
}
@@ -42,20 +35,12 @@ pub(crate) enum ConflictAction {
Replace,
}
/// What [`resolve_conflict`] does without a terminal: bundle installs bail
/// (an unattended install must not half-apply a plan silently), builtin hook
/// refreshes keep the local file (a scripted refresh must neither clobber
/// local changes nor fail).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NonInteractive {
Bail,
Keep,
}
/// Decides whether an installer should overwrite the existing file at `dst`
/// with `content` under `mode`. Only called for files that already exist;
/// missing files are always written without prompting. In `Prompt` mode an
/// unreadable local file counts as differing, so the user decides.
pub(crate) fn should_replace_existing(
dst: &Path,
content: &str,
@@ -337,9 +322,9 @@ mod tests {
fn should_replace_existing_skips_identical_content_without_prompting() {
let _script = prompt_script::install(&[]);
let dir = crate::utils::temp_file("conflict-identical-", "");
std::fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(&dir).unwrap();
let path = dir.join("hook.sh");
std::fs::write(&path, "same content").unwrap();
fs::write(&path, "same content").unwrap();
let mut sticky = StickyMode::None;
let replace = should_replace_existing(
@@ -351,7 +336,7 @@ mod tests {
)
.unwrap();
let _ = std::fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&dir);
assert!(!replace);
assert_eq!(prompt_script::prompts_asked(), 0);
}
@@ -361,9 +346,9 @@ mod tests {
fn should_replace_existing_never_prompts_in_skip_or_force_mode() {
let _script = prompt_script::install(&[]);
let dir = crate::utils::temp_file("conflict-modes-", "");
std::fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(&dir).unwrap();
let path = dir.join("hook.sh");
std::fs::write(&path, "local edit").unwrap();
fs::write(&path, "local edit").unwrap();
let mut sticky = StickyMode::None;
let skip =
@@ -371,7 +356,7 @@ mod tests {
let force =
should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky);
let _ = std::fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&dir);
assert!(!skip.unwrap());
assert!(force.unwrap());
assert_eq!(prompt_script::prompts_asked(), 0);
-9
View File
@@ -1860,12 +1860,6 @@ fn print_plan_summary(plan: &InstallPlan) {
/// Bundle-shipped hook scripts execute on the user's machine once wired into
/// config, so every planned file landing in a hook location is called out
/// individually. Visibility only: installing them needs no extra approval.
///
/// Only the locations the engine actually resolves hooks from are announced:
/// the top-level hooks category, direct children of `agents/<name>/hooks/`,
/// and direct children of `roles/hooks/`. A `hooks/` directory anywhere else
/// (e.g. inside a skill or macro subtree) is inert data and announcing it
/// would be noise.
fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
plan.files
.iter()
@@ -2864,9 +2858,6 @@ mod tests {
file("x/config.yaml", TopCategory::Agents),
file("reviewer.md", TopCategory::Roles),
file("hello.yaml", TopCategory::Macros),
// Only the locations the engine resolves hooks from announce:
// a hooks/ directory nested deeper or under another category
// is inert data.
file("x/hooks/nested/deep.sh", TopCategory::Agents),
file("x/sub/hooks/pre.sh", TopCategory::Agents),
file("sub/hooks/extra.sh", TopCategory::Roles),
-5
View File
@@ -83,11 +83,6 @@ pub async fn macro_execute(
.bootstrap_tools(app.as_ref(), true, abort_signal.clone())
.await?;
// An in-macro `.agent` step opens the top-level agent bracket on this
// fresh context, so it must also close here — on the step-failure path
// too, or the run would leak `agent.started` with no terminal event.
// Signal-aware like the headless dispatch arms: a ctrl-c'd macro
// classifies as interrupted, not failed.
let result = async {
for step in &macro_value.steps {
let command = Macro::interpolate_command(step, &variables);
+25 -36
View File
@@ -419,9 +419,6 @@ impl Default for Config {
}
pub fn install_builtins() -> Result<()> {
// Best-effort housekeeping riding along with the startup installers:
// payload files orphaned by crashed processes accumulate in a shared
// temp dir and nothing else ever revisits them.
hooks::sweep_stale_payload_files();
Functions::install_builtin_global_tools(false)?;
Agent::install_builtin_agents(InstallMode::Skip)?;
@@ -530,9 +527,6 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
AssetCategory::Hooks => {
// One sticky scope spans all three hook locations, so a
// keep-all/replace-all answer carries through the whole refresh,
// matching bundle-install semantics.
let mut sticky = StickyMode::None;
hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?;
Role::install_builtin_role_hooks(InstallMode::Prompt, &mut sticky)?;
@@ -759,8 +753,6 @@ impl Config {
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
if !config_path.exists() {
// Inspection-only flags are look-don't-touch: no first-run
// config creation, nothing written.
if inspection {
return Ok(Self::default());
}
@@ -778,9 +770,6 @@ impl Config {
{
Some(v) => (Self::load_dynamic(&v)?, String::new()),
None => {
// Same look-don't-touch rule: never run the interactive
// first-run wizard (or write a config) for a flag that
// only inspects state.
if inspection {
return Ok(Self::default());
}
@@ -1655,12 +1644,12 @@ hooks:
use std::fs;
use std::time;
let unique = time::SystemTime::now()
let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
fs::create_dir_all(&tmp_dir).unwrap();
let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
create_dir_all(&tmp_dir).unwrap();
let config_path = tmp_dir.join("config.yaml");
fs::write(
@@ -1670,12 +1659,12 @@ hooks:
.unwrap();
let config_env = get_env_name("config_file");
let prev_config = std::env::var_os(&config_env);
let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG);
let prev_config = env::var_os(&config_env);
let prev_sandbox = env::var_os(SANDBOX_ENV_FLAG);
unsafe {
std::env::set_var(&config_env, &config_path);
std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1");
env::set_var(&config_env, &config_path);
env::set_var(SANDBOX_ENV_FLAG, "1");
}
let result = Config::load_with_interpolation(false, false).await;
@@ -1683,12 +1672,12 @@ hooks:
unsafe {
match prev_config {
Some(v) => std::env::set_var(&config_env, v),
None => std::env::remove_var(&config_env),
Some(v) => env::set_var(&config_env, v),
None => env::remove_var(&config_env),
}
match prev_sandbox {
Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v),
None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG),
Some(v) => env::set_var(SANDBOX_ENV_FLAG, v),
None => env::remove_var(SANDBOX_ENV_FLAG),
}
}
let _ = fs::remove_dir_all(&tmp_dir);
@@ -1713,17 +1702,17 @@ hooks:
create_dir_all(&tmp_dir).unwrap();
let _config_dir = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &tmp_dir);
let _config_file = crate::testing::EnvVarGuard::unset(get_env_name("config_file"));
let _sandbox = crate::testing::EnvVarGuard::unset(crate::sandbox::SANDBOX_ENV_FLAG);
let _sandbox = crate::testing::EnvVarGuard::unset(SANDBOX_ENV_FLAG);
let _provider = crate::testing::EnvVarGuard::unset(get_env_name("provider"));
let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform"));
let result = Config::load_with_interpolation(true, true).await;
let leftover: Vec<_> = std::fs::read_dir(&tmp_dir)
let leftover: Vec<_> = read_dir(&tmp_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
let _ = std::fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&tmp_dir);
result.expect("inspection flags must load a default config without a config file");
assert!(
leftover.is_empty(),
@@ -1743,12 +1732,12 @@ hooks:
let config_path = tmp_dir.join("config.yaml");
let _config_file =
crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path);
let _sandbox = crate::testing::EnvVarGuard::set(crate::sandbox::SANDBOX_ENV_FLAG, "1");
let _sandbox = crate::testing::EnvVarGuard::set(SANDBOX_ENV_FLAG, "1");
let result = Config::load_with_interpolation(true, true).await;
let created = config_path.exists();
let _ = std::fs::remove_dir_all(&tmp_dir);
let _ = fs::remove_dir_all(&tmp_dir);
result.expect("inspection flags must load a default config in sandbox mode too");
assert!(
!created,
@@ -1835,7 +1824,7 @@ hooks:
let written = write_models_override(vec![entry()]).unwrap();
assert_eq!(written, path);
let mut tmp_name = path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", std::process::id()));
tmp_name.push(format!(".{}.tmp", process::id()));
assert!(!PathBuf::from(tmp_name).exists());
let loaded = paths::local_models_override().unwrap();
assert_eq!(loaded.len(), 1);
@@ -1910,16 +1899,16 @@ hooks:
assert_eq!(prompt_script::prompts_asked(), 1);
assert_eq!(sticky, StickyMode::ReplaceAll);
assert!(
fs::read_to_string(&notify)
read_to_string(&notify)
.unwrap()
.starts_with("#!/usr/bin/env bash")
);
assert_eq!(
fs::read_to_string(role_dir.join("hook.sh")).unwrap(),
read_to_string(role_dir.join("hook.sh")).unwrap(),
"shipped\n"
);
assert_eq!(
fs::read_to_string(agent_dir.join("hook.sh")).unwrap(),
read_to_string(agent_dir.join("hook.sh")).unwrap(),
"shipped\n"
);
drop(script);
@@ -1931,13 +1920,13 @@ hooks:
refresh(&mut sticky);
assert_eq!(prompt_script::prompts_asked(), 1);
assert_eq!(sticky, StickyMode::KeepAll);
assert_eq!(fs::read_to_string(&notify).unwrap(), "local global");
assert_eq!(read_to_string(&notify).unwrap(), "local global");
assert_eq!(
fs::read_to_string(role_dir.join("hook.sh")).unwrap(),
read_to_string(role_dir.join("hook.sh")).unwrap(),
"local role"
);
assert_eq!(
fs::read_to_string(agent_dir.join("hook.sh")).unwrap(),
read_to_string(agent_dir.join("hook.sh")).unwrap(),
"local agent"
);
drop(script);
@@ -1958,12 +1947,12 @@ hooks:
let script = prompt_script::install(&[]);
install_builtins().unwrap();
assert_eq!(prompt_script::prompts_asked(), 0);
assert_eq!(fs::read_to_string(&notify).unwrap(), "# local edit");
assert_eq!(read_to_string(&notify).unwrap(), "# local edit");
drop(script);
let _script = prompt_script::install_non_interactive();
install_builtins().unwrap();
assert_eq!(prompt_script::prompts_asked(), 0);
assert_eq!(fs::read_to_string(&notify).unwrap(), "# local edit");
assert_eq!(read_to_string(&notify).unwrap(), "# local edit");
}
}
+1 -12
View File
@@ -417,8 +417,6 @@ impl RequestContext {
working_mode: WorkingMode,
info_flag: bool,
) -> Result<Self> {
// A lenient AppConfig (inspection-only run, no resolvable model)
// leaves model_id empty; render those readouts with a default model.
let model = if info_flag && app.config.model_id.is_empty() {
Model::default()
} else {
@@ -4971,15 +4969,6 @@ impl RequestContext {
return;
};
// The abort signal, never the error text, decides interrupted: a
// 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,
@@ -10449,7 +10438,7 @@ mod tests {
// test deadlines. Every spawned script gets its own throwaway lock path.
fn adversary_script_command(script: &str, state: &serde_json::Value) -> Command {
static STACK_LOCK_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/agents/adversary/scripts")
.join(script);
let lock_path = env::temp_dir().join(format!(
+2 -16
View File
@@ -1026,13 +1026,6 @@ 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. 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),
@@ -1783,13 +1776,6 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result<Val
let from_agent = request.from_agent_name.clone();
let question = request.question.clone();
let _ = request.reply_tx.send(reply.to_string());
// Origin envs come from the taken request, not this (replying)
// context, so `escalation.answered` and `escalation.raised`
// describe the same agent for one escalation id. "Answered"
// means a parent submitted an answer, not that the asker
// received it: the reply_tx.send failure is ignored by design,
// so the event also fires for an escalation whose asker already
// timed out or was cancelled.
hooks::fire(
HookEvent::EscalationAnswered,
ctx,
@@ -3414,10 +3400,10 @@ mod tests {
fn reply_escalation_fires_answered_once_and_never_on_failure_arms() {
let _sink = hooks::test_sink::install();
let marker = "t049_answered_once";
let mut hooks_map = crate::hooks::HooksMap::default();
let mut hooks_map = hooks::HooksMap::default();
hooks_map.insert(
"escalation.answered".to_string(),
vec![crate::hooks::HookDef {
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
-2
View File
@@ -111,8 +111,6 @@ impl JobHookSnapshot {
}
}
/// Fires `job.started` for a freshly accepted job; both `handle_start`
/// branches (MCP and process) dispatch through here.
fn fire_job_started(ctx: &RequestContext, job_id: &str, tool: &str) {
hooks::fire(
HookEvent::JobStarted,
+13 -17
View File
@@ -2694,7 +2694,7 @@ impl ToolCallTracker {
}
}
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::Value) -> String {
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &Value) -> String {
if *NO_COLOR {
return format!("Call {cmd_name} {}", cmd_args.join(" "));
}
@@ -2713,8 +2713,8 @@ fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::
)
}
fn format_json_colored_keys(value: &serde_json::Value) -> String {
let serde_json::Value::Object(map) = value else {
fn format_json_colored_keys(value: &Value) -> String {
let Value::Object(map) = value else {
return dimmed_text(&value.to_string());
};
if map.is_empty() {
@@ -3486,16 +3486,15 @@ mod tests {
#[test]
fn bundled_bash_tools_generate_declarations() {
let tools_dir =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
let tools_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
let mut checked = Vec::new();
for entry in std::fs::read_dir(&tools_dir).unwrap() {
for entry in fs::read_dir(&tools_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(OsStr::to_str) != Some("sh") {
continue;
}
let name = path.file_stem().unwrap().to_string_lossy().to_string();
let src = std::fs::read_to_string(&path).unwrap();
let src = fs::read_to_string(&path).unwrap();
let (_, declarations) = bash::build_bash_tool(&src, &name)
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
assert!(
@@ -4673,7 +4672,7 @@ mod tests {
.unwrap()
.expect("nonzero exit must return an error payload");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let json: Value = serde_json::from_str(&result).unwrap();
assert!(
json["tool_call_error"]
.as_str()
@@ -4702,7 +4701,7 @@ mod tests {
.unwrap()
.expect("nonzero exit must return an error payload");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let json: Value = serde_json::from_str(&result).unwrap();
assert!(
json["tool_call_error"]
.as_str()
@@ -4741,7 +4740,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(elapsed < Duration::from_secs(1), "took {elapsed:?}");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let json: Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted");
}
@@ -4826,18 +4825,15 @@ mod tests {
// A shim written moments ago can still be open for writing somewhere
// in the process (schedule-dependent), making exec fail with
// ETXTBSY; retry briefly instead of flaking.
fn output_with_etxtbsy_retry(
command: &mut process::Command,
) -> std::io::Result<process::Output> {
fn output_with_etxtbsy_retry(command: &mut Command) -> io::Result<process::Output> {
let mut attempts = 0;
loop {
match command.output() {
Err(err)
if err.kind() == std::io::ErrorKind::ExecutableFileBusy
&& attempts < 10 =>
if err.kind() == io::ErrorKind::ExecutableFileBusy && attempts < 10 =>
{
attempts += 1;
std::thread::sleep(Duration::from_millis(50));
thread::sleep(Duration::from_millis(50));
}
ret => return ret,
}
@@ -4907,7 +4903,7 @@ mod tests {
return;
}
let mut command = process::Command::new(&moved_shim);
let mut command = Command::new(&moved_shim);
command
.arg("{}")
.env_remove(&config_env)
-2
View File
@@ -180,8 +180,6 @@ fn handle_headless(action: &str, args: &Value) -> Value {
})
}
// The root agent prompting its own user is a direct interaction, not an
// escalation: no escalation event fires on this path.
fn handle_direct(action: &str, args: &Value) -> Result<Value> {
match action {
"select" => handle_direct_ask(args),
-8
View File
@@ -50,14 +50,6 @@ impl AgentNodeExecutor {
)
.await;
if let Err(e) = &result {
// The child's own abort signal dies inside `run`; the session
// signal is what a user interrupt fires (the executor bridges it
// onto the graph abort), so it is the only honest
// interrupted-vs-failed discriminator at this seam. Blind spot:
// agent__cancel sets the child handle's signal, which never
// reaches parent_ctx.session_abort, so a node failing during
// such a cancellation stays agent.failed while the outer bracket
// reports agent.interrupted (narrow window, accepted).
if parent_ctx
.session_abort
.as_ref()
-4
View File
@@ -21,10 +21,6 @@ impl ApprovalNodeExecutor {
.interpolate(&node.question)
.context("Failed to interpolate approval question")?;
// No escalation event fires at this seam: a root graph prompts its
// user directly (outside the escalation queue), and a child graph's
// escalation fires inside `handle_escalated` like any other user__*
// call.
let response = handle_user_tool(
ctx,
&format!("{USER_FUNCTION_PREFIX}select"),
+64 -116
View File
@@ -14,7 +14,7 @@ use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use std::time::{Duration, SystemTime};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::Notify;
@@ -60,16 +60,10 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu
}
}
// Only direct children of the hooks dir reconcile through the builtin
// manifest — the only shape it tracks — so a hook a release stops
// shipping is removed while user scripts in the same directory are
// never touched.
let shipped: BTreeSet<String> = HookAssets::iter()
.map(|file| file.as_ref().to_string())
.filter(|name| !name.contains('/'))
.collect();
// Reconciliation is best-effort housekeeping: a failure here must not
// abort startup (install_builtins), matching the role/agent-side policy.
if let Err(err) =
builtin_manifest::reconcile_builtin_dir(&paths::hooks_dir(), &shipped, &written)
{
@@ -79,8 +73,6 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu
);
}
// Both sides canonicalize so an override that merely spells the default
// path differently (symlinks, `..`, trailing components) does not warn.
let hooks_dir = canonical_or_original(&paths::hooks_dir());
let default_hooks_dir = canonical_or_original(&paths::config_dir().join("hooks"));
if wrote_any && hooks_dir != default_hooks_dir {
@@ -174,9 +166,6 @@ impl HookEvent {
}
}
/// Every event, in declaration order. Dotted names stay single-sourced
/// through `as_str`; this exists so whitelist-glob validation can tell a
/// real `<event>.*` from a typo over a nonexistent event.
const ALL: [HookEvent; 26] = [
HookEvent::TurnStarted,
HookEvent::TurnCompleted,
@@ -260,12 +249,6 @@ 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,
@@ -296,12 +279,6 @@ fn resolve_hooks(
{
let prefix = format!("{event_name}.");
for entry in gate {
// Entries containing `*` are grammatical only in the wildcard
// forms `*`, `*.*`, `*.<name>`, and `<event>.*` over a real
// event. Anything else (`tool.st*`, `ag*.completed.mark`) is a
// typo that admits nothing on any event and never extends a full
// event name, so the prefix check below would stay silent about
// it — flag it here instead.
if entry.contains('*') {
if !entry_is_valid_wildcard(entry) {
debug!(
@@ -341,12 +318,6 @@ fn resolve_hooks(
resolved
}
/// Whitelist grammar for a `global_hooks` entry: `<event>.<name>` exact,
/// `<event>.*` (every hook of that event), `*.<name>` (that hook name on
/// every event), and `*` / `*.*` (everything). Event names contain dots
/// while hook names never do, so `*.<name>` compares everything after the
/// leading `*.` against the hook name alone. Anything else containing `*`
/// (a partial glob like `tool.st*`) is not a wildcard and admits nothing.
fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: &str) -> bool {
if entry == full_name || entry == "*" || entry == "*.*" {
return true;
@@ -357,12 +328,6 @@ fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name:
entry.strip_suffix(".*") == Some(event_name)
}
/// True when a `*`-containing gate entry is one of the wildcard forms
/// `gate_entry_admits` understands. `*.<name>` takes a literal hook name
/// (globbing inside the name is not supported), and `<event>.*` requires a
/// real event: event names contain dots, so e.g. `tool.*` names the
/// nonexistent event `tool` rather than a prefix over `tool.started` and
/// `tool.completed`.
fn entry_is_valid_wildcard(entry: &str) -> bool {
if entry == "*" || entry == "*.*" {
return true;
@@ -520,11 +485,6 @@ fn base_envs(event: HookEvent, ctx: &RequestContext) -> Vec<(String, String)> {
)
}
/// The single construction site for the base env set every hook receives.
/// [`base_envs`] feeds it from a live context; detached call sites that
/// dispatch pre-resolved snapshots after their context is gone pass the
/// names they captured with the snapshot. The timestamp is taken here, at
/// fire time, so it reflects when the event actually happened.
pub(crate) fn base_envs_parts(
event: HookEvent,
session_name: Option<&str>,
@@ -596,12 +556,6 @@ fn payload_dir() -> PathBuf {
const STALE_PAYLOAD_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
/// Removes payload files left in the shared payload directory by crashed
/// processes — `run_hook` deletes its own file after the hook exits, so
/// anything older than [`STALE_PAYLOAD_MAX_AGE`] is an orphan. Runs once per
/// startup from `install_builtins`. Only exact `coyote-hook-*.json` names are
/// candidates; the directory is shared, so nothing else may ever be touched.
/// Best-effort: failures log at debug and never abort startup.
pub fn sweep_stale_payload_files() {
sweep_payload_files_older_than(STALE_PAYLOAD_MAX_AGE);
}
@@ -618,7 +572,7 @@ fn sweep_payload_files_older_than(max_age: Duration) {
return;
}
};
let now = std::time::SystemTime::now();
let now = SystemTime::now();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
@@ -643,6 +597,7 @@ fn sweep_payload_files_older_than(max_age: Duration) {
if !stale {
continue;
}
match std::fs::remove_file(entry.path()) {
Ok(()) => debug!("Removed stale hook payload file {}", entry.path().display()),
Err(err) => debug!(
@@ -840,9 +795,11 @@ mod tests {
use super::*;
use crate::config::conflict::prompt_script;
use crate::config::{AppConfig, AppState, Role, WorkingMode};
use crate::{testing, utils};
use serial_test::serial;
use std::env;
use std::sync::Arc;
use std::time::UNIX_EPOCH;
fn hooks_map(event: &str, defs: &[(&str, &str)]) -> HooksMap {
let defs = defs
@@ -871,22 +828,19 @@ mod tests {
/// Points the hooks dir at a fresh temp directory for the guard's
/// lifetime and removes it on drop. Tests using it must serialize.
struct HooksDirGuard {
_env: crate::testing::EnvVarGuard,
_env: testing::EnvVarGuard,
root: PathBuf,
}
impl HooksDirGuard {
fn new(label: &str) -> Self {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-{label}-{unique}"));
Self {
_env: crate::testing::EnvVarGuard::set(
crate::utils::get_env_name("hooks_dir"),
&root,
),
_env: testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &root),
root,
}
}
@@ -938,8 +892,8 @@ mod tests {
}
fn fresh_payload_dir(label: &str) -> PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = env::temp_dir().join(format!("coyote-{label}-{unique}"));
@@ -1026,13 +980,13 @@ mod tests {
#[test]
#[serial]
fn install_builtin_hooks_installs_executable_scripts_and_honors_force() {
let env_name = crate::utils::get_env_name("hooks_dir");
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let env_name = utils::get_env_name("hooks_dir");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}"));
let env_guard = crate::testing::EnvVarGuard::set(&env_name, &root);
let env_guard = testing::EnvVarGuard::set(&env_name, &root);
// Capture every outcome first and assert only after cleanup: the
// guard restores the env var even on panic, but the temp dir removal
@@ -1222,16 +1176,16 @@ mod tests {
#[test]
#[serial]
fn install_builtin_hooks_respelled_default_hooks_dir_does_not_warn() {
crate::testing::install_log_collector();
let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-respelled");
testing::install_log_collector();
let guard = testing::TestConfigDirGuard::new("hooks-warn-respelled");
let guard_path_display = guard.path.display().to_string();
let env_name = crate::utils::get_env_name("hooks_dir");
let env_name = utils::get_env_name("hooks_dir");
// An override that reaches the default `config_dir()/hooks` through a
// `..` hop is the default path in disguise: the canonicalized compare
// must stay quiet even though the strings differ.
let alias = guard.path.join("hooks").join("..").join("hooks");
let env_guard = crate::testing::EnvVarGuard::set(&env_name, &alias);
let env_guard = testing::EnvVarGuard::set(&env_name, &alias);
install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
drop(env_guard);
@@ -1249,7 +1203,7 @@ mod tests {
let link = guard.path.join("hooks-link");
std::os::unix::fs::symlink(guard.path.join("hooks"), &link).unwrap();
std::fs::remove_file(&default_notify).unwrap();
let _env_guard = crate::testing::EnvVarGuard::set(&env_name, &link);
let _env_guard = testing::EnvVarGuard::set(&env_name, &link);
install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
assert!(
default_notify.is_file(),
@@ -1259,7 +1213,7 @@ mod tests {
// The warn buffer is process-global, so scope the check to messages
// naming this test's unique directory.
let warns = crate::testing::warn_snapshot();
let warns = testing::warn_snapshot();
assert!(
warns.iter().all(|message| {
!(message.contains("overrides the hooks dir")
@@ -1272,13 +1226,10 @@ mod tests {
#[test]
#[serial]
fn install_builtin_hooks_genuine_override_warns_when_default_dir_is_missing() {
crate::testing::install_log_collector();
let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-genuine");
testing::install_log_collector();
let guard = testing::TestConfigDirGuard::new("hooks-warn-genuine");
let override_dir = guard.path.join("elsewhere-hooks");
let _env_guard = crate::testing::EnvVarGuard::set(
crate::utils::get_env_name("hooks_dir"),
&override_dir,
);
let _env_guard = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &override_dir);
install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
@@ -1294,7 +1245,7 @@ mod tests {
"scripts must land in the override dir, so wrote_any was true"
);
let override_display = override_dir.display().to_string();
let warns = crate::testing::warn_snapshot();
let warns = testing::warn_snapshot();
assert!(
warns.iter().any(|message| {
message.contains("overrides the hooks dir") && message.contains(&override_display)
@@ -1683,7 +1634,7 @@ mod tests {
#[test]
fn wildcard_whitelist_entries_skip_unknown_entry_diagnostics() {
crate::testing::install_log_collector();
testing::install_log_collector();
let global = hooks_map("turn.started", &[("a", "cmd-a")]);
let gate = vec![
"turn.started.*".to_string(),
@@ -1701,7 +1652,7 @@ mod tests {
);
assert_eq!(names(&resolved), ["a"]);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(
debugs
.iter()
@@ -1712,7 +1663,7 @@ mod tests {
#[test]
fn partial_glob_whitelist_entries_stay_flagged_as_unknown() {
crate::testing::install_log_collector();
testing::install_log_collector();
let global = hooks_map("turn.started", &[("a", "cmd-a")]);
let gate = vec!["turn.started.st*-marker-k4w".to_string()];
@@ -1725,7 +1676,7 @@ mod tests {
);
assert!(resolved.is_empty(), "an invalid glob must admit nothing");
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().any(|message| {
message.contains("turn.started.st*-marker-k4w")
&& message.contains("for agent 'glob-flag-agent-k4w'")
@@ -1734,7 +1685,7 @@ mod tests {
#[test]
fn partial_glob_over_event_prefix_logs_invalid_glob_diagnostic() {
crate::testing::install_log_collector();
testing::install_log_collector();
let global = hooks_map("tool.started", &[("st", "cmd-st")]);
// `tool.st*` extends no full event name (`tool` is not an event), so
// the event-prefix diagnostic can never see it; the glob check must
@@ -1750,7 +1701,7 @@ mod tests {
);
assert!(resolved.is_empty(), "a partial glob admits nothing");
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().any(|message| {
message.contains("tool.st*") && message.contains("for agent 'prefix-glob-agent-t7c'")
}));
@@ -1758,7 +1709,7 @@ mod tests {
#[test]
fn inner_glob_entries_log_invalid_glob_diagnostic() {
crate::testing::install_log_collector();
testing::install_log_collector();
let global = hooks_map("agent.completed", &[("mark", "cmd-mark")]);
let gate = vec!["ag*.completed.mark".to_string()];
@@ -1771,7 +1722,7 @@ mod tests {
);
assert!(resolved.is_empty(), "an inner glob admits nothing");
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().any(|message| {
message.contains("ag*.completed.mark")
&& message.contains("for agent 'inner-glob-agent-m2r'")
@@ -1780,7 +1731,7 @@ mod tests {
#[test]
fn unknown_whitelist_entries_log_debug_and_stay_inert() {
crate::testing::install_log_collector();
testing::install_log_collector();
let global = hooks_map("turn.started", &[("a", "cmd-a")]);
let gate = vec![
"turn.started.a".to_string(),
@@ -1796,7 +1747,7 @@ mod tests {
);
assert_eq!(names(&resolved), ["a"]);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().any(|message| {
message.contains("turn.started.nonexistent-marker-xyz")
&& message.contains("for agent 'gate-agent-xyz'")
@@ -1805,7 +1756,7 @@ mod tests {
#[test]
fn empty_global_map_short_circuits_whitelist_diagnostics() {
crate::testing::install_log_collector();
testing::install_log_collector();
let global = HooksMap::default();
let role = hooks_map("turn.started", &[("role-only", "role-cmd")]);
let gate = vec!["turn.started.x-shortcircuit-marker-p3q".to_string()];
@@ -1819,7 +1770,7 @@ mod tests {
);
assert_eq!(names(&resolved), ["role-only"]);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(
debugs
.iter()
@@ -2084,12 +2035,12 @@ mod tests {
#[tokio::test(start_paused = true)]
#[serial]
async fn drain_pending_logs_when_the_timeout_expires() {
crate::testing::install_log_collector();
testing::install_log_collector();
let pending = SpawnAckGuard::register();
drain_pending(Duration::from_millis(50)).await;
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(
debugs
.iter()
@@ -2116,7 +2067,7 @@ mod tests {
#[test]
#[serial]
fn fire_resolved_without_runtime_skips_instead_of_panicking() {
crate::testing::install_log_collector();
testing::install_log_collector();
let hook = ResolvedHook {
name: "no-runtime-marker-a7c".to_string(),
full_name: "turn.failed.no-runtime-marker-a7c".to_string(),
@@ -2126,7 +2077,7 @@ mod tests {
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().any(|message| {
message.contains("no-runtime-marker-a7c")
&& message.contains("no Tokio runtime on this thread")
@@ -2136,7 +2087,7 @@ mod tests {
#[test]
#[serial]
fn fire_without_runtime_skips_instead_of_panicking() {
crate::testing::install_log_collector();
testing::install_log_collector();
let ctx = ctx_with_global_hooks(hooks_map(
"turn.failed",
&[("fire-no-runtime-marker-b8d", "true")],
@@ -2144,7 +2095,7 @@ mod tests {
fire(HookEvent::TurnFailed, &ctx, &[], None);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().any(|message| {
message.contains("fire-no-runtime-marker-b8d")
&& message.contains("no Tokio runtime on this thread")
@@ -2153,7 +2104,7 @@ mod tests {
#[test]
fn debug_capture_excludes_non_hooks_targets() {
crate::testing::install_log_collector();
testing::install_log_collector();
let hook = ResolvedHook {
name: "included-marker-k2v".to_string(),
full_name: "turn.failed.included-marker-k2v".to_string(),
@@ -2171,7 +2122,7 @@ mod tests {
);
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(
debugs
.iter()
@@ -2192,9 +2143,9 @@ mod tests {
#[test]
#[serial]
fn debug_capture_recovers_after_buffer_poisoning() {
crate::testing::install_log_collector();
testing::install_log_collector();
std::thread::spawn(|| {
let _held = crate::testing::debug_messages().lock().unwrap();
let _held = testing::debug_messages().lock().unwrap();
panic!("poison the debug buffer");
})
.join()
@@ -2208,7 +2159,7 @@ mod tests {
};
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
let mut debugs = crate::testing::debug_messages()
let mut debugs = testing::debug_messages()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert!(debugs.iter().any(|message| {
@@ -2255,7 +2206,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn spawn_failure_removes_the_payload_file() {
crate::testing::install_log_collector();
testing::install_log_collector();
let guard = TestConfigDirGuard::new("hooks-dispatch");
let payload_dir = guard.path.join("payloads");
create_dir_all(&payload_dir).unwrap();
@@ -2281,12 +2232,12 @@ mod tests {
// does the empty directory below demonstrate the orphan unlink
// rather than a payload file that never existed.
wait_for("spawn failure log", || {
crate::testing::debug_snapshot().iter().any(|message| {
testing::debug_snapshot().iter().any(|message| {
message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'")
})
})
.await;
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().all(|message| {
!(message.contains("spawnfail-marker-c9d")
&& message.contains("Failed to write payload file"))
@@ -2305,10 +2256,10 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn error_paths_log_debug_only() {
crate::testing::install_log_collector();
testing::install_log_collector();
let empty_cwd = env::temp_dir();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let bad_cwd = env::temp_dir().join(format!("coyote-badcwd-marker-f5b-{unique}"));
@@ -2330,7 +2281,7 @@ mod tests {
fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None);
drain_pending(Duration::from_secs(10)).await;
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
let empty_cwd_display = empty_cwd.display().to_string();
assert!(debugs.iter().any(|message| {
message.contains("empty-marker-f5b")
@@ -2342,7 +2293,7 @@ mod tests {
message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'")
&& message.contains(bad_cwd_display.as_str())
}));
let warns = crate::testing::warn_snapshot();
let warns = testing::warn_snapshot();
assert!(warns.iter().all(|message| !message.contains("marker-f5b")));
}
}
@@ -2488,7 +2439,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn failing_hook_never_disturbs_the_engine() {
crate::testing::install_log_collector();
testing::install_log_collector();
let guard = TestConfigDirGuard::new("hooks-dispatch");
let first = guard.path.join("first-out");
let second = guard.path.join("second-out");
@@ -2514,7 +2465,7 @@ mod tests {
);
wait_for("second hook output", || second.exists()).await;
let warns = crate::testing::warn_snapshot();
let warns = testing::warn_snapshot();
assert!(
warns
.iter()
@@ -2558,7 +2509,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn payload_write_failure_still_runs_hook_without_payload_env() {
crate::testing::install_log_collector();
testing::install_log_collector();
let guard = TestConfigDirGuard::new("hooks-dispatch");
let out = guard.path.join("env-out");
let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir"));
@@ -2582,7 +2533,7 @@ mod tests {
.lines()
.all(|line| !line.starts_with("COYOTE_HOOK_PAYLOAD_FILE="))
);
let debugs = crate::testing::debug_snapshot();
let debugs = testing::debug_snapshot();
assert!(debugs.iter().any(|message| message.contains(
"Failed to write payload file for hook 'tool.started.payload-fallback-marker'"
)));
@@ -2674,7 +2625,7 @@ mod tests {
)
.unwrap();
ctx.use_agent(&app, agent_name, None, crate::utils::create_abort_signal())
ctx.use_agent(&app, agent_name, None, utils::create_abort_signal())
.await
.unwrap();
@@ -2818,18 +2769,15 @@ mod tests {
#[test]
#[serial]
fn install_builtin_hooks_lands_under_a_backslashed_override() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let hooks_dir = PathBuf::from(format!(
"{}\\coyote-win-bs-{unique}\\hooks",
env::temp_dir().display()
));
let _env = crate::testing::EnvVarGuard::set(
crate::utils::get_env_name("hooks_dir"),
&hooks_dir,
);
let _env = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &hooks_dir);
let result = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None);
+1 -30
View File
@@ -58,15 +58,6 @@ use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::{env, fs, process, sync::Arc};
/// Stack headroom for the thread that polls the root async future, and for
/// the runtime's worker threads.
///
/// Windows links the process main thread with a ~1 MiB stack (unix defaults
/// to 8 MiB), and the root future is a compiler-generated state machine
/// whose debug-build poll frames blow through 1 MiB the moment it is
/// polled — every binary invocation dies with STATUS_STACK_OVERFLOW
/// (0xC00000FD) before doing any work. 16 MiB gives comfortable headroom
/// over the deepest observed frames on every platform.
const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024;
/// Bootstraps the async entry point on a dedicated thread with an explicit
@@ -151,10 +142,6 @@ async fn async_main() -> Result<()> {
|| cli.list_macros
|| cli.list_skills
|| cli.list_sessions;
// Read-only whitelist: --sync-models is excluded because it downloads
// and writes models-override.yaml; combined with a readout flag the run
// still writes (run() handles --sync-models first), so it disqualifies
// the whole invocation.
let readout_flag = (cli.info
|| cli.list_models
|| cli.list_roles
@@ -191,10 +178,6 @@ async fn async_main() -> Result<()> {
return config::list_installed_bundles();
}
// Inspection flags are look-don't-touch readouts: skip the builtins
// bootstrap so a pristine config dir stays empty. On such a dir the
// list flags list nothing because nothing is installed, by design.
// Agent/role runs still bootstrap: they load builtin tool definitions.
let mcp_inspect =
cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none();
let vault_inspect = cli.list_secrets
@@ -297,10 +280,6 @@ async fn async_main() -> Result<()> {
} else {
AppConfig::from_config(cfg)?
};
// Listing servers reads no secrets, so an unconfigured vault (no
// Listing servers reads no secrets, so an unconfigured vault (no
// password file) must not block the readout; every other MCP flag
// (get/remove/add) stays on the strict init.
let vault = if mcp_inspect {
Vault::init_lenient(&app_config)
} else {
@@ -351,9 +330,6 @@ async fn async_main() -> Result<()> {
);
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
let app_config = Arc::clone(&ctx.app.config);
// Rebuilding the tool scope prunes and builds function binaries;
// inspection-only runs are readouts that never execute tools, so skip
// it there too.
if !inspection_only {
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
.await?;
@@ -488,7 +464,7 @@ async fn run(
// return early without running the agent, and an open bracket on
// those paths would leak `agent.started` with no terminal event.
// It opens at the dispatch sites further down, where a run is
// actually committed — including the --macro arm, which executes
// actually committed, including the --macro arm, which executes
// rather than inspects.
} else {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
@@ -622,8 +598,6 @@ async fn run(
}
}
if let Some(name) = &cli.macro_name {
// Dispatch committed: open the top-level agent bracket only now,
// past every inspection-flag early return above.
ctx.top_level_agent_started();
let result = macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await;
ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal));
@@ -807,9 +781,6 @@ async fn shell_execute(
if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code);
}
// Executing the generated command is a normal exit: no
// abort signal on purpose, this seam can never report an
// interruption.
ctx.top_level_agent_finished(None, None);
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
process::exit(code);
-10
View File
@@ -527,9 +527,6 @@ Type ".help" for additional help.
}
if let Some(supervisor) = self.ctx.read().supervisor.clone() {
// Leaving the loop is a deliberate exit, never an interruption:
// cancelling straggler children here does not latch the abort
// signal, so teardown still classifies as agent.completed.
supervisor.read().cancel_recursive();
}
@@ -1718,13 +1715,6 @@ fn reset_continuation(ctx: &mut RequestContext) {
ctx.reset_continuation_count();
}
/// Ctrl-C at the prompt: cancel background work, but only latch the abort
/// signal when there was live work to cancel. An idle-prompt ctrl-c is a
/// step on the advertised exit path ("To exit, press Ctrl+D"), not an
/// interruption, so teardown after the following ctrl-d classifies the run
/// as completed. Mid-turn ctrl-c never passes through here -- it latches
/// the signal directly (wait_user_interrupt / poll_abort_signal), so an
/// interrupted turn followed by ctrl-d still reports agent.interrupted.
pub(crate) fn latch_prompt_interrupt(abort_signal: &AbortSignal, supervisor: &RwLock<Supervisor>) {
let supervisor = supervisor.read();
if supervisor.has_active_tasks() {
-5
View File
@@ -197,11 +197,6 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
) {
out.push(read_mixin(path)?);
}
// Hooks directories are deliberately scanned: hook scripts run inside the
// sandbox too, so a sidecar mixin next to one can declare its needs. The
// agents scan above never descends into `agents/<name>/hooks/`, and the
// `.builtin-manifest` living in these dirs is not matched by the
// `*.sbx-mixin.yaml` suffix filter.
for agent_dir in subdirs_of(&paths::agents_data_dir()) {
for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
-5
View File
@@ -190,11 +190,6 @@ impl Supervisor {
.count()
}
/// True when any registered agent or job is still running -- i.e. a
/// cancellation right now would actually interrupt in-flight work.
/// Mirrors `cancel_recursive`'s traversal: work buried in a nested child
/// supervisor still counts, so a prompt ctrl-c that cancels only a
/// grandchild latches like any other interruption.
pub fn has_active_tasks(&self) -> bool {
self.effective_active_count() > 0
|| self.active_job_count() > 0
-2
View File
@@ -332,8 +332,6 @@ impl Vault {
}
if cli.list_secrets {
// Listing is an inspection readout: report sandbox unavailability
// instead of failing like the mutating vault flags do.
if vault.sandbox_mode {
println!(
"The vault is unavailable in sandbox mode. Use `coyote --list-secrets` on your host."