fix(TASK-046): engine polish + wizard bypass + ETXTBSY retry
- unify hook base-env assembly on hooks::base_envs_parts (drop the hand-copied hook_base_envs in function/agents.rs; graph executor and spawn-result hooks now share the single construction site, test-pinned) - factor the duplicated job.started dispatch in jobs.rs handle_start into one fire_job_started helper - COYOTE_LLM_ERROR now carries the immediate error (to_string()), matching COYOTE_TOOL_ERROR; test pins the new format - remove all stale #[allow(dead_code)] markers in src/hooks.rs - test infra: rename install_warn_collector -> install_log_collector, add poison-symmetric warn_snapshot/debug_snapshot helpers, hoist a shared EnvVarGuard (TestConfigDirGuard now builds on it), add a same-fire payload-uniqueness test, and give global_tools env overrides the same malformed-JSON debug diagnostic as global_hooks - inspection-only flags (--info et al) bypass the first-run wizard: missing config + info_flag returns Config::default() without writing anything, in both the sandbox and interactive arms (tests included) - bound the shim-relocation test's exec in an ETXTBSY retry (10x50ms) - document the intended 'roles' reserved-name asymmetry at resolve_bundle_name's gate
This commit is contained in:
12 files changed
+282
-191
No files matched your search
@@ -579,9 +579,9 @@ fn llm_failed_extras(
|
|||||||
}
|
}
|
||||||
extras.push((
|
extras.push((
|
||||||
"COYOTE_LLM_ERROR",
|
"COYOTE_LLM_ERROR",
|
||||||
// `{:#}` renders the whole context chain, so the root cause survives
|
// The immediate error only, matching COYOTE_TOOL_ERROR; the context
|
||||||
// the `context(...)` wrapping the client trait defaults apply.
|
// chain below it stays out of the env.
|
||||||
err.map(|err| format!("{err:#}"))
|
err.map(|err| err.to_string())
|
||||||
.unwrap_or_else(|| "Aborted.".to_string()),
|
.unwrap_or_else(|| "Aborted.".to_string()),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -1862,12 +1862,10 @@ mod tests {
|
|||||||
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
|
failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str),
|
||||||
Some("429")
|
Some("429")
|
||||||
);
|
);
|
||||||
assert!(
|
assert_eq!(
|
||||||
failed
|
failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str),
|
||||||
.envs
|
Some("Failed to call chat-completions api"),
|
||||||
.get("COYOTE_LLM_ERROR")
|
"the env must carry the immediate error only, not the context chain"
|
||||||
.unwrap()
|
|
||||||
.contains("error (status: 429)")
|
|
||||||
);
|
);
|
||||||
assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS"));
|
assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS"));
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -1019,10 +1019,13 @@ impl AgentConfig {
|
|||||||
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
|
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
|
||||||
self.reasoning_effort = v;
|
self.reasoning_effort = v;
|
||||||
}
|
}
|
||||||
if let Ok(v) = env::var(with_prefix("global_tools"))
|
if let Ok(v) = env::var(with_prefix("global_tools")) {
|
||||||
&& let Ok(v) = serde_json::from_str(&v)
|
match serde_json::from_str(&v) {
|
||||||
{
|
Ok(v) => self.global_tools = v,
|
||||||
self.global_tools = v;
|
Err(err) => {
|
||||||
|
debug!("Ignoring malformed global_tools env override for agent '{name}': {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Ok(v) = env::var(with_prefix("global_hooks")) {
|
if let Ok(v) = env::var(with_prefix("global_hooks")) {
|
||||||
match serde_json::from_str(&v) {
|
match serde_json::from_str(&v) {
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ impl BundleStore {
|
|||||||
same_name_other_source: None,
|
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 reserved = AssetCategory::parse(&base).is_some();
|
||||||
let collision = self.source_of_other_bundle(&base, &canonical);
|
let collision = self.source_of_other_bundle(&base, &canonical);
|
||||||
if reserved || collision.is_some() {
|
if reserved || collision.is_some() {
|
||||||
|
|||||||
@@ -734,6 +734,11 @@ impl Config {
|
|||||||
|
|
||||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
|
// Inspection-only flags are look-don't-touch: no first-run
|
||||||
|
// config creation, nothing written.
|
||||||
|
if info_flag {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
create_config_file(&config_path).await?;
|
create_config_file(&config_path).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +753,12 @@ impl Config {
|
|||||||
{
|
{
|
||||||
Some(v) => (Self::load_dynamic(&v)?, String::new()),
|
Some(v) => (Self::load_dynamic(&v)?, String::new()),
|
||||||
None => {
|
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 info_flag {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
if *IS_STDOUT_TERMINAL {
|
if *IS_STDOUT_TERMINAL {
|
||||||
create_config_file(&config_path).await?;
|
create_config_file(&config_path).await?;
|
||||||
}
|
}
|
||||||
@@ -1640,6 +1651,60 @@ hooks:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn info_flag_with_missing_config_skips_wizard_and_writes_nothing() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let tmp_dir = env::temp_dir().join(format!("coyote-info-bypass-{unique}"));
|
||||||
|
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 _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).await;
|
||||||
|
|
||||||
|
let leftover: Vec<_> = std::fs::read_dir(&tmp_dir)
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().file_name())
|
||||||
|
.collect();
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
result.expect("inspection flags must load a default config without a config file");
|
||||||
|
assert!(
|
||||||
|
leftover.is_empty(),
|
||||||
|
"inspection flags must not write anything: {leftover:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn sandbox_info_flag_with_missing_config_writes_nothing() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-info-bypass-{unique}"));
|
||||||
|
create_dir_all(&tmp_dir).unwrap();
|
||||||
|
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 result = Config::load_with_interpolation(true).await;
|
||||||
|
|
||||||
|
let created = config_path.exists();
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
result.expect("inspection flags must load a default config in sandbox mode too");
|
||||||
|
assert!(
|
||||||
|
!created,
|
||||||
|
"inspection flags must not create the sandbox config file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unloadable_override_is_stale() {
|
fn unloadable_override_is_stale() {
|
||||||
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
|
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
|
||||||
|
|||||||
@@ -918,7 +918,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::mcp_tool_policy::LayerSource;
|
use crate::config::mcp_tool_policy::LayerSource;
|
||||||
use crate::function::ToolCall;
|
use crate::function::ToolCall;
|
||||||
use crate::testing::{install_warn_collector, warn_messages};
|
use crate::testing::{install_log_collector, warn_snapshot};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1083,7 +1083,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn catalog_items_warns_when_resource_listing_fails() {
|
async fn catalog_items_warns_when_resource_listing_fails() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let fixture = FixtureServer {
|
let fixture = FixtureServer {
|
||||||
resources_capability: true,
|
resources_capability: true,
|
||||||
fail_resource_listings: true,
|
fail_resource_listings: true,
|
||||||
@@ -1093,7 +1093,7 @@ mod tests {
|
|||||||
|
|
||||||
runtime.catalog_items("fixture").await.unwrap();
|
runtime.catalog_items("fixture").await.unwrap();
|
||||||
|
|
||||||
let messages = warn_messages().lock().unwrap();
|
let messages = warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
messages
|
messages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1547,7 +1547,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn prompt_catalog_degrades_when_one_server_fails() {
|
async fn prompt_catalog_degrades_when_one_server_fails() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let healthy = FixtureServer {
|
let healthy = FixtureServer {
|
||||||
prompts_capability: true,
|
prompts_capability: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1564,7 +1564,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(items.len(), 1);
|
assert_eq!(items.len(), 1);
|
||||||
assert_eq!(items[0].server, "fixture");
|
assert_eq!(items[0].server, "fixture");
|
||||||
let messages = warn_messages().lock().unwrap();
|
let messages = warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
messages
|
messages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1794,7 +1794,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn catalog_items_warns_on_dead_context_patterns() {
|
async fn catalog_items_warns_on_dead_context_patterns() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await;
|
let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await;
|
||||||
let mut filter = single_layer_filter(LayerSource::Global, &["*"]);
|
let mut filter = single_layer_filter(LayerSource::Global, &["*"]);
|
||||||
filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]);
|
filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]);
|
||||||
@@ -1802,7 +1802,7 @@ mod tests {
|
|||||||
|
|
||||||
runtime.catalog_items("fixture").await.unwrap();
|
runtime.catalog_items("fixture").await.unwrap();
|
||||||
|
|
||||||
let messages = warn_messages().lock().unwrap();
|
let messages = warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
messages.iter().any(|msg| msg.contains("'zzz_*'")
|
messages.iter().any(|msg| msg.contains("'zzz_*'")
|
||||||
&& msg.contains("session (.set)")
|
&& msg.contains("session (.set)")
|
||||||
|
|||||||
+6
-36
@@ -4,7 +4,7 @@ use crate::client::{Model, ModelType, call_chat_completions};
|
|||||||
use crate::config::{
|
use crate::config::{
|
||||||
Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike,
|
Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike,
|
||||||
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
|
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
|
||||||
list_agents_with_descriptions, load_agent_variables, paths,
|
list_agents_with_descriptions, load_agent_variables,
|
||||||
};
|
};
|
||||||
use crate::hooks::{self, HookEvent, ResolvedHook};
|
use crate::hooks::{self, HookEvent, ResolvedHook};
|
||||||
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
|
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
|
||||||
@@ -15,7 +15,7 @@ use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_use
|
|||||||
use crate::graph;
|
use crate::graph;
|
||||||
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
|
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::Utc;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
@@ -997,36 +997,6 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors the base-env contract `hooks::fire` assembles from a live context,
|
|
||||||
/// for detached call sites that dispatch pre-resolved hook snapshots after
|
|
||||||
/// the context they were resolved from is gone. The names are captured by
|
|
||||||
/// the caller when it takes the snapshot; the timestamp is taken here, at
|
|
||||||
/// fire time, so it reflects when the event actually happened.
|
|
||||||
pub(crate) fn hook_base_envs(
|
|
||||||
event: HookEvent,
|
|
||||||
session_name: Option<&str>,
|
|
||||||
agent_name: Option<&str>,
|
|
||||||
) -> Vec<(String, String)> {
|
|
||||||
let mut envs = vec![
|
|
||||||
("COYOTE_EVENT".to_string(), event.as_str().to_string()),
|
|
||||||
(
|
|
||||||
"COYOTE_CONFIG_DIR".to_string(),
|
|
||||||
paths::config_dir().display().to_string(),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"COYOTE_EVENT_TIMESTAMP".to_string(),
|
|
||||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if let Some(name) = session_name {
|
|
||||||
envs.push(("COYOTE_SESSION_ID".to_string(), name.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(name) = agent_name {
|
|
||||||
envs.push(("COYOTE_AGENT_NAME".to_string(), name.to_string()));
|
|
||||||
}
|
|
||||||
envs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-resolved `agent.completed`/`agent.failed` snapshots for a spawned
|
/// Pre-resolved `agent.completed`/`agent.failed` snapshots for a spawned
|
||||||
/// agent's result arms, captured from the child context before the spawn
|
/// agent's result arms, captured from the child context before the spawn
|
||||||
/// task takes ownership of it. Resolving against the child keeps global
|
/// task takes ownership of it. Resolving against the child keeps global
|
||||||
@@ -1070,7 +1040,7 @@ impl SpawnResultHooks {
|
|||||||
hooks::fire_resolved(
|
hooks::fire_resolved(
|
||||||
event,
|
event,
|
||||||
resolved,
|
resolved,
|
||||||
hook_base_envs(event, self.session_name.as_deref(), Some(&self.agent_name)),
|
hooks::base_envs_parts(event, self.session_name.as_deref(), Some(&self.agent_name)),
|
||||||
&extras,
|
&extras,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -2377,8 +2347,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hook_base_envs_carries_optional_names_only_when_present() {
|
fn agent_event_base_envs_carry_optional_names_only_when_present() {
|
||||||
let envs = hook_base_envs(HookEvent::AgentCompleted, None, None);
|
let envs = hooks::base_envs_parts(HookEvent::AgentCompleted, None, None);
|
||||||
let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
|
let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
|
||||||
assert!(keys.contains(&"COYOTE_EVENT"));
|
assert!(keys.contains(&"COYOTE_EVENT"));
|
||||||
assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
|
assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
|
||||||
@@ -2390,7 +2360,7 @@ mod tests {
|
|||||||
.any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed")
|
.any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed")
|
||||||
);
|
);
|
||||||
|
|
||||||
let envs = hook_base_envs(HookEvent::AgentFailed, Some("sess"), Some("bot"));
|
let envs = hooks::base_envs_parts(HookEvent::AgentFailed, Some("sess"), Some("bot"));
|
||||||
assert!(
|
assert!(
|
||||||
envs.iter()
|
envs.iter()
|
||||||
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
|
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
|
||||||
|
|||||||
+16
-18
@@ -111,6 +111,20 @@ 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,
|
||||||
|
ctx,
|
||||||
|
&[
|
||||||
|
("COYOTE_JOB_ID", job_id.to_string()),
|
||||||
|
("COYOTE_TOOL_NAME", tool.to_string()),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RingBuf {
|
pub struct RingBuf {
|
||||||
buf: Vec<u8>,
|
buf: Vec<u8>,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
@@ -529,15 +543,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
let notify_id = job_id.clone();
|
let notify_id = job_id.clone();
|
||||||
let notify_tool = tool.clone();
|
let notify_tool = tool.clone();
|
||||||
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
|
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
|
||||||
hooks::fire(
|
fire_job_started(ctx, &job_id, &tool);
|
||||||
HookEvent::JobStarted,
|
|
||||||
ctx,
|
|
||||||
&[
|
|
||||||
("COYOTE_JOB_ID", job_id.clone()),
|
|
||||||
("COYOTE_TOOL_NAME", tool.clone()),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
|
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
|
||||||
let success = result.is_ok();
|
let success = result.is_ok();
|
||||||
@@ -559,15 +565,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
let notify_id = job_id.clone();
|
let notify_id = job_id.clone();
|
||||||
let notify_tool = tool.clone();
|
let notify_tool = tool.clone();
|
||||||
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
|
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
|
||||||
hooks::fire(
|
fire_job_started(ctx, &job_id, &tool);
|
||||||
HookEvent::JobStarted,
|
|
||||||
ctx,
|
|
||||||
&[
|
|
||||||
("COYOTE_JOB_ID", job_id.clone()),
|
|
||||||
("COYOTE_TOOL_NAME", tool.clone()),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await;
|
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await;
|
||||||
let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
|
let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
|
||||||
|
|||||||
+25
-4
@@ -4795,6 +4795,27 @@ mod tests {
|
|||||||
fn built_shims_resolve_paths_at_runtime_after_relocation() {
|
fn built_shims_resolve_paths_at_runtime_after_relocation() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
// 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> {
|
||||||
|
let mut attempts = 0;
|
||||||
|
loop {
|
||||||
|
match command.output() {
|
||||||
|
Err(err)
|
||||||
|
if err.kind() == std::io::ErrorKind::ExecutableFileBusy
|
||||||
|
&& attempts < 10 =>
|
||||||
|
{
|
||||||
|
attempts += 1;
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
ret => return ret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let root = temp_file("-shim-relocate-", "");
|
let root = temp_file("-shim-relocate-", "");
|
||||||
let home_a = root.join("home-a");
|
let home_a = root.join("home-a");
|
||||||
let config_a = home_a.join("coyote");
|
let config_a = home_a.join("coyote");
|
||||||
@@ -4858,14 +4879,14 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = process::Command::new(&moved_shim)
|
let mut command = process::Command::new(&moved_shim);
|
||||||
|
command
|
||||||
.arg("{}")
|
.arg("{}")
|
||||||
.env_remove(&config_env)
|
.env_remove(&config_env)
|
||||||
.env_remove(&functions_env)
|
.env_remove(&functions_env)
|
||||||
.env_remove("LLM_OUTPUT")
|
.env_remove("LLM_OUTPUT")
|
||||||
.env_remove("LLM_TOOL_DATA_FILE")
|
.env_remove("LLM_TOOL_DATA_FILE");
|
||||||
.output()
|
let output = output_with_etxtbsy_retry(&mut command).unwrap();
|
||||||
.unwrap();
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
assert!(
|
assert!(
|
||||||
output.status.success(),
|
output.status.success(),
|
||||||
|
|||||||
+7
-13
@@ -340,7 +340,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{AppState, WorkingMode, default_max_agent_depth};
|
use crate::config::{AppState, WorkingMode, default_max_agent_depth};
|
||||||
use crate::supervisor::mailbox::{Inbox, PeerRegistry, graph_agent_id};
|
use crate::supervisor::mailbox::{Inbox, PeerRegistry, graph_agent_id};
|
||||||
use crate::testing::{install_warn_collector, warn_messages};
|
use crate::testing::{install_log_collector, warn_snapshot};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -676,7 +676,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn retry_transient_warns_once_per_retried_attempt_with_the_context_chain() {
|
async fn retry_transient_warns_once_per_retried_attempt_with_the_context_chain() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let mut ctx = plain_ctx();
|
let mut ctx = plain_ctx();
|
||||||
let node = retryable_node(3);
|
let node = retryable_node(3);
|
||||||
let mut attempts = 0u32;
|
let mut attempts = 0u32;
|
||||||
@@ -700,12 +700,9 @@ mod tests {
|
|||||||
.expect_err("all attempts fail");
|
.expect_err("all attempts fail");
|
||||||
|
|
||||||
assert_eq!(attempts, 3);
|
assert_eq!(attempts, 3);
|
||||||
let warns: Vec<String> = warn_messages()
|
let warns: Vec<String> = warn_snapshot()
|
||||||
.lock()
|
.into_iter()
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|m| m.contains("'warn_capture'"))
|
.filter(|m| m.contains("'warn_capture'"))
|
||||||
.cloned()
|
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
warns.len(),
|
warns.len(),
|
||||||
@@ -861,7 +858,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn outcome_from_fallback_warn_names_the_node_and_the_fallback_target() {
|
fn outcome_from_fallback_warn_names_the_node_and_the_fallback_target() {
|
||||||
install_warn_collector();
|
install_log_collector();
|
||||||
let mut node = node_with("hi", None);
|
let mut node = node_with("hi", None);
|
||||||
node.fallback = Some("recover".into());
|
node.fallback = Some("recover".into());
|
||||||
let mut state = manager_with(&[]);
|
let mut state = manager_with(&[]);
|
||||||
@@ -875,12 +872,9 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into()));
|
assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into()));
|
||||||
let warns: Vec<String> = warn_messages()
|
let warns: Vec<String> = warn_snapshot()
|
||||||
.lock()
|
.into_iter()
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|m| m.contains("'fallback_warn_capture'"))
|
.filter(|m| m.contains("'fallback_warn_capture'"))
|
||||||
.cloned()
|
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}");
|
assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
|
|||||||
use super::validator::{AgentValidationContext, GraphValidator};
|
use super::validator::{AgentValidationContext, GraphValidator};
|
||||||
use super::wall_clock;
|
use super::wall_clock;
|
||||||
use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext};
|
use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext};
|
||||||
use crate::function::agents::hook_base_envs;
|
|
||||||
use crate::hooks::{self, HookEvent, ResolvedHook};
|
use crate::hooks::{self, HookEvent, ResolvedHook};
|
||||||
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
|
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
|
||||||
use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt};
|
use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt};
|
||||||
@@ -550,7 +549,7 @@ impl NodeHookEmitter {
|
|||||||
hooks::fire_resolved(
|
hooks::fire_resolved(
|
||||||
event,
|
event,
|
||||||
resolved.to_vec(),
|
resolved.to_vec(),
|
||||||
hook_base_envs(
|
hooks::base_envs_parts(
|
||||||
event,
|
event,
|
||||||
self.session_name.as_deref(),
|
self.session_name.as_deref(),
|
||||||
self.agent_name.as_deref(),
|
self.agent_name.as_deref(),
|
||||||
@@ -1619,7 +1618,7 @@ nodes:
|
|||||||
/// debug log, and a well-formed sibling on the same event still fires.
|
/// debug log, and a well-formed sibling on the same event still fires.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
|
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let _sink = hooks::test_sink::install();
|
let _sink = hooks::test_sink::install();
|
||||||
let mut ctx = make_ctx();
|
let mut ctx = make_ctx();
|
||||||
|
|
||||||
@@ -1660,10 +1659,7 @@ nodes:
|
|||||||
assert_eq!(valid.len(), 1, "{valid:?}");
|
assert_eq!(valid.len(), 1, "{valid:?}");
|
||||||
assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done");
|
assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done");
|
||||||
|
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+69
-87
@@ -73,7 +73,6 @@ pub type HooksMap = IndexMap<String, Vec<HookDef>>;
|
|||||||
|
|
||||||
/// Every event hooks can be attached to. `as_str` yields the dotted name used
|
/// Every event hooks can be attached to. `as_str` yields the dotted name used
|
||||||
/// as the key in `hooks:` maps and in `global_hooks` whitelist entries.
|
/// as the key in `hooks:` maps and in `global_hooks` whitelist entries.
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum HookEvent {
|
pub enum HookEvent {
|
||||||
TurnStarted,
|
TurnStarted,
|
||||||
@@ -150,7 +149,6 @@ impl RequestContext {
|
|||||||
/// when the active role was moved into (or restored by) a session, from
|
/// when the active role was moved into (or restored by) a session, from
|
||||||
/// the hooks snapshot the session captured via `Session::set_role` /
|
/// the hooks snapshot the session captured via `Session::set_role` /
|
||||||
/// `Session::load_from_ctx`.
|
/// `Session::load_from_ctx`.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn resolved_hooks(&self, event: HookEvent) -> Vec<ResolvedHook> {
|
pub fn resolved_hooks(&self, event: HookEvent) -> Vec<ResolvedHook> {
|
||||||
resolve_hooks(
|
resolve_hooks(
|
||||||
event,
|
event,
|
||||||
@@ -248,7 +246,6 @@ fn push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec<Resol
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn fire(
|
pub fn fire(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
@@ -265,7 +262,6 @@ pub fn fire(
|
|||||||
|
|
||||||
/// Dispatches already-resolved hooks, for call sites that outlive the context
|
/// Dispatches already-resolved hooks, for call sites that outlive the context
|
||||||
/// borrow they resolved from. Same fire-and-forget semantics as [`fire`].
|
/// borrow they resolved from. Same fire-and-forget semantics as [`fire`].
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn fire_resolved(
|
pub fn fire_resolved(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
resolved: Vec<ResolvedHook>,
|
resolved: Vec<ResolvedHook>,
|
||||||
@@ -352,7 +348,6 @@ impl Drop for SpawnAckGuard {
|
|||||||
/// or until `timeout` elapses. For exit-path call sites only (session end /
|
/// or until `timeout` elapses. For exit-path call sites only (session end /
|
||||||
/// final turn): this waits for child spawn, never completion. Hook children
|
/// final turn): this waits for child spawn, never completion. Hook children
|
||||||
/// deliberately outlive coyote as orphans.
|
/// deliberately outlive coyote as orphans.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn drain_pending(timeout: Duration) {
|
pub async fn drain_pending(timeout: Duration) {
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
while PENDING_SPAWNS.load(Ordering::SeqCst) > 0 {
|
while PENDING_SPAWNS.load(Ordering::SeqCst) > 0 {
|
||||||
@@ -379,6 +374,11 @@ 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(
|
pub(crate) fn base_envs_parts(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
session_name: Option<&str>,
|
session_name: Option<&str>,
|
||||||
@@ -513,7 +513,6 @@ async fn run_hook(
|
|||||||
/// truncation pass, because that value is engine-generated, inherently short,
|
/// truncation pass, because that value is engine-generated, inherently short,
|
||||||
/// and a truncated path would be corrupt rather than merely trimmed. Exported
|
/// and a truncated path would be corrupt rather than merely trimmed. Exported
|
||||||
/// for call sites that need the bound before dispatch.
|
/// for call sites that need the bound before dispatch.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn truncate_env_value(value: &str) -> &str {
|
pub fn truncate_env_value(value: &str) -> &str {
|
||||||
const MAX_ENV_VALUE_BYTES: usize = 2048;
|
const MAX_ENV_VALUE_BYTES: usize = 2048;
|
||||||
if value.len() <= MAX_ENV_VALUE_BYTES {
|
if value.len() <= MAX_ENV_VALUE_BYTES {
|
||||||
@@ -604,7 +603,6 @@ pub(crate) mod test_sink {
|
|||||||
|
|
||||||
/// A copy of everything recorded so far, for tests that filter by a hook
|
/// A copy of everything recorded so far, for tests that filter by a hook
|
||||||
/// name unique to their fixture.
|
/// name unique to their fixture.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn snapshot() -> Vec<Capture> {
|
pub fn snapshot() -> Vec<Capture> {
|
||||||
CAPTURES
|
CAPTURES
|
||||||
.lock()
|
.lock()
|
||||||
@@ -649,19 +647,16 @@ mod tests {
|
|||||||
#[serial]
|
#[serial]
|
||||||
fn install_builtin_hooks_installs_executable_scripts_and_honors_force() {
|
fn install_builtin_hooks_installs_executable_scripts_and_honors_force() {
|
||||||
let env_name = crate::utils::get_env_name("hooks_dir");
|
let env_name = crate::utils::get_env_name("hooks_dir");
|
||||||
let prev = env::var_os(&env_name);
|
|
||||||
let unique = std::time::SystemTime::now()
|
let unique = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}"));
|
let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}"));
|
||||||
unsafe {
|
let env_guard = crate::testing::EnvVarGuard::set(&env_name, &root);
|
||||||
env::set_var(&env_name, &root);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture every outcome first and assert only after the env var is
|
// Capture every outcome first and assert only after cleanup: the
|
||||||
// restored, so a failed assertion cannot leave the hooks-dir override
|
// guard restores the env var even on panic, but the temp dir removal
|
||||||
// pointing at a deleted temp dir for subsequent tests.
|
// below still has to run before any assertion can bail out.
|
||||||
let notify = root.join("notify.sh");
|
let notify = root.join("notify.sh");
|
||||||
let log_events = root.join("log-events.sh");
|
let log_events = root.join("log-events.sh");
|
||||||
let fresh = install_builtin_hooks(false);
|
let fresh = install_builtin_hooks(false);
|
||||||
@@ -680,12 +675,7 @@ mod tests {
|
|||||||
let force = install_builtin_hooks(true);
|
let force = install_builtin_hooks(true);
|
||||||
let after_force = std::fs::read_to_string(¬ify);
|
let after_force = std::fs::read_to_string(¬ify);
|
||||||
|
|
||||||
unsafe {
|
drop(env_guard);
|
||||||
match prev {
|
|
||||||
Some(v) => env::set_var(&env_name, v),
|
|
||||||
None => env::remove_var(&env_name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
|
||||||
fresh.unwrap();
|
fresh.unwrap();
|
||||||
@@ -897,7 +887,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_whitelist_entries_log_debug_and_stay_inert() {
|
fn unknown_whitelist_entries_log_debug_and_stay_inert() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let global = hooks_map("turn.started", &[("a", "cmd-a")]);
|
let global = hooks_map("turn.started", &[("a", "cmd-a")]);
|
||||||
let gate = vec![
|
let gate = vec![
|
||||||
"turn.started.a".to_string(),
|
"turn.started.a".to_string(),
|
||||||
@@ -913,10 +903,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(names(&resolved), ["a"]);
|
assert_eq!(names(&resolved), ["a"]);
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("turn.started.nonexistent-marker-xyz")
|
message.contains("turn.started.nonexistent-marker-xyz")
|
||||||
&& message.contains("for agent 'gate-agent-xyz'")
|
&& message.contains("for agent 'gate-agent-xyz'")
|
||||||
@@ -925,7 +912,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_global_map_short_circuits_whitelist_diagnostics() {
|
fn empty_global_map_short_circuits_whitelist_diagnostics() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let global = HooksMap::default();
|
let global = HooksMap::default();
|
||||||
let role = hooks_map("turn.started", &[("role-only", "role-cmd")]);
|
let role = hooks_map("turn.started", &[("role-only", "role-cmd")]);
|
||||||
let gate = vec!["turn.started.x-shortcircuit-marker-p3q".to_string()];
|
let gate = vec!["turn.started.x-shortcircuit-marker-p3q".to_string()];
|
||||||
@@ -939,10 +926,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(names(&resolved), ["role-only"]);
|
assert_eq!(names(&resolved), ["role-only"]);
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1171,15 +1155,12 @@ mod tests {
|
|||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn drain_pending_logs_when_the_timeout_expires() {
|
async fn drain_pending_logs_when_the_timeout_expires() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let pending = SpawnAckGuard::register();
|
let pending = SpawnAckGuard::register();
|
||||||
|
|
||||||
drain_pending(Duration::from_millis(50)).await;
|
drain_pending(Duration::from_millis(50)).await;
|
||||||
|
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1206,7 +1187,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn fire_resolved_without_runtime_skips_instead_of_panicking() {
|
fn fire_resolved_without_runtime_skips_instead_of_panicking() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let hook = ResolvedHook {
|
let hook = ResolvedHook {
|
||||||
name: "no-runtime-marker-a7c".to_string(),
|
name: "no-runtime-marker-a7c".to_string(),
|
||||||
full_name: "turn.failed.no-runtime-marker-a7c".to_string(),
|
full_name: "turn.failed.no-runtime-marker-a7c".to_string(),
|
||||||
@@ -1216,10 +1197,7 @@ mod tests {
|
|||||||
|
|
||||||
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
|
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
|
||||||
|
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("no-runtime-marker-a7c")
|
message.contains("no-runtime-marker-a7c")
|
||||||
&& message.contains("no Tokio runtime on this thread")
|
&& message.contains("no Tokio runtime on this thread")
|
||||||
@@ -1229,7 +1207,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn fire_without_runtime_skips_instead_of_panicking() {
|
fn fire_without_runtime_skips_instead_of_panicking() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let ctx = ctx_with_global_hooks(hooks_map(
|
let ctx = ctx_with_global_hooks(hooks_map(
|
||||||
"turn.failed",
|
"turn.failed",
|
||||||
&[("fire-no-runtime-marker-b8d", "true")],
|
&[("fire-no-runtime-marker-b8d", "true")],
|
||||||
@@ -1237,10 +1215,7 @@ mod tests {
|
|||||||
|
|
||||||
fire(HookEvent::TurnFailed, &ctx, &[], None);
|
fire(HookEvent::TurnFailed, &ctx, &[], None);
|
||||||
|
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("fire-no-runtime-marker-b8d")
|
message.contains("fire-no-runtime-marker-b8d")
|
||||||
&& message.contains("no Tokio runtime on this thread")
|
&& message.contains("no Tokio runtime on this thread")
|
||||||
@@ -1249,7 +1224,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_capture_excludes_non_hooks_targets() {
|
fn debug_capture_excludes_non_hooks_targets() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let hook = ResolvedHook {
|
let hook = ResolvedHook {
|
||||||
name: "included-marker-k2v".to_string(),
|
name: "included-marker-k2v".to_string(),
|
||||||
full_name: "turn.failed.included-marker-k2v".to_string(),
|
full_name: "turn.failed.included-marker-k2v".to_string(),
|
||||||
@@ -1267,10 +1242,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
|
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
|
||||||
|
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1291,7 +1263,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn debug_capture_recovers_after_buffer_poisoning() {
|
fn debug_capture_recovers_after_buffer_poisoning() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
let _held = crate::testing::debug_messages().lock().unwrap();
|
let _held = crate::testing::debug_messages().lock().unwrap();
|
||||||
panic!("poison the debug buffer");
|
panic!("poison the debug buffer");
|
||||||
@@ -1342,27 +1314,21 @@ mod tests {
|
|||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
struct TestConfigDirGuard {
|
struct TestConfigDirGuard {
|
||||||
key: String,
|
_env: crate::testing::EnvVarGuard,
|
||||||
previous: Option<std::ffi::OsString>,
|
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestConfigDirGuard {
|
impl TestConfigDirGuard {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
let key = get_env_name("config_dir");
|
let key = get_env_name("config_dir");
|
||||||
let previous = env::var_os(&key);
|
|
||||||
let unique = SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let path = env::temp_dir().join(format!("coyote-hooks-tests-{unique}"));
|
let path = env::temp_dir().join(format!("coyote-hooks-tests-{unique}"));
|
||||||
create_dir_all(&path).unwrap();
|
create_dir_all(&path).unwrap();
|
||||||
unsafe {
|
|
||||||
env::set_var(&key, &path);
|
|
||||||
}
|
|
||||||
Self {
|
Self {
|
||||||
key,
|
_env: crate::testing::EnvVarGuard::set(key, &path),
|
||||||
previous,
|
|
||||||
path,
|
path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1370,15 +1336,6 @@ mod tests {
|
|||||||
|
|
||||||
impl Drop for TestConfigDirGuard {
|
impl Drop for TestConfigDirGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(previous) = &self.previous {
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&self.key, previous);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsafe {
|
|
||||||
env::remove_var(&self.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = remove_dir_all(&self.path);
|
let _ = remove_dir_all(&self.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1548,7 +1505,7 @@ mod tests {
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn error_paths_log_debug_only() {
|
async fn error_paths_log_debug_only() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let empty_cwd = env::temp_dir();
|
let empty_cwd = env::temp_dir();
|
||||||
let hooks = vec![
|
let hooks = vec![
|
||||||
ResolvedHook {
|
ResolvedHook {
|
||||||
@@ -1568,10 +1525,7 @@ mod tests {
|
|||||||
fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None);
|
fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None);
|
||||||
drain_pending(Duration::from_secs(10)).await;
|
drain_pending(Duration::from_secs(10)).await;
|
||||||
|
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
let empty_cwd_display = empty_cwd.display().to_string();
|
let empty_cwd_display = empty_cwd.display().to_string();
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("empty-marker-f5b")
|
message.contains("empty-marker-f5b")
|
||||||
@@ -1582,17 +1536,14 @@ mod tests {
|
|||||||
message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'")
|
message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'")
|
||||||
&& message.contains("/nonexistent/coyote-badcwd-marker-f5b")
|
&& message.contains("/nonexistent/coyote-badcwd-marker-f5b")
|
||||||
}));
|
}));
|
||||||
let warns: Vec<String> = crate::testing::warn_messages()
|
let warns = crate::testing::warn_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(warns.iter().all(|message| !message.contains("marker-f5b")));
|
assert!(warns.iter().all(|message| !message.contains("marker-f5b")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn failing_hook_never_disturbs_the_engine() {
|
async fn failing_hook_never_disturbs_the_engine() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let guard = TestConfigDirGuard::new();
|
let guard = TestConfigDirGuard::new();
|
||||||
let first = guard.path.join("first-out");
|
let first = guard.path.join("first-out");
|
||||||
let second = guard.path.join("second-out");
|
let second = guard.path.join("second-out");
|
||||||
@@ -1618,10 +1569,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
wait_for("second hook output", || second.exists()).await;
|
wait_for("second hook output", || second.exists()).await;
|
||||||
|
|
||||||
let warns: Vec<String> = crate::testing::warn_messages()
|
let warns = crate::testing::warn_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(
|
assert!(
|
||||||
warns
|
warns
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1665,7 +1613,7 @@ mod tests {
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn payload_write_failure_still_runs_hook_without_payload_env() {
|
async fn payload_write_failure_still_runs_hook_without_payload_env() {
|
||||||
crate::testing::install_warn_collector();
|
crate::testing::install_log_collector();
|
||||||
let guard = TestConfigDirGuard::new();
|
let guard = TestConfigDirGuard::new();
|
||||||
let out = guard.path.join("env-out");
|
let out = guard.path.join("env-out");
|
||||||
let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir"));
|
let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir"));
|
||||||
@@ -1689,10 +1637,7 @@ mod tests {
|
|||||||
.lines()
|
.lines()
|
||||||
.all(|line| !line.starts_with("COYOTE_HOOK_PAYLOAD_FILE="))
|
.all(|line| !line.starts_with("COYOTE_HOOK_PAYLOAD_FILE="))
|
||||||
);
|
);
|
||||||
let debugs: Vec<String> = crate::testing::debug_messages()
|
let debugs = crate::testing::debug_snapshot();
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.clone();
|
|
||||||
assert!(debugs.iter().any(|message| message.contains(
|
assert!(debugs.iter().any(|message| message.contains(
|
||||||
"Failed to write payload file for hook 'tool.started.payload-fallback-marker'"
|
"Failed to write payload file for hook 'tool.started.payload-fallback-marker'"
|
||||||
)));
|
)));
|
||||||
@@ -1729,6 +1674,43 @@ mod tests {
|
|||||||
assert_ne!(first_path, second_path);
|
assert_ne!(first_path, second_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn payload_paths_differ_within_one_fire() {
|
||||||
|
let guard = TestConfigDirGuard::new();
|
||||||
|
let first = guard.path.join("first-path");
|
||||||
|
let second = guard.path.join("second-path");
|
||||||
|
let command_first = r#"printf '%s' "$COYOTE_HOOK_PAYLOAD_FILE" > "$HOOK_PATH_FIRST.tmp" && mv "$HOOK_PATH_FIRST.tmp" "$HOOK_PATH_FIRST""#;
|
||||||
|
let command_second = r#"printf '%s' "$COYOTE_HOOK_PAYLOAD_FILE" > "$HOOK_PATH_SECOND.tmp" && mv "$HOOK_PATH_SECOND.tmp" "$HOOK_PATH_SECOND""#;
|
||||||
|
let ctx = ctx_with_global_hooks(hooks_map(
|
||||||
|
"tool.started",
|
||||||
|
&[
|
||||||
|
("same-fire-a", command_first),
|
||||||
|
("same-fire-b", command_second),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
fire(
|
||||||
|
HookEvent::ToolStarted,
|
||||||
|
&ctx,
|
||||||
|
&[
|
||||||
|
("HOOK_PATH_FIRST", first.display().to_string()),
|
||||||
|
("HOOK_PATH_SECOND", second.display().to_string()),
|
||||||
|
],
|
||||||
|
Some("{}".to_string()),
|
||||||
|
);
|
||||||
|
wait_for("both payload paths", || first.exists() && second.exists()).await;
|
||||||
|
|
||||||
|
let first_path = std::fs::read_to_string(&first).unwrap();
|
||||||
|
let second_path = std::fs::read_to_string(&second).unwrap();
|
||||||
|
assert!(!first_path.is_empty());
|
||||||
|
assert!(!second_path.is_empty());
|
||||||
|
assert_ne!(
|
||||||
|
first_path, second_path,
|
||||||
|
"each hook of one fire must get its own payload file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn resolved_hooks_gates_globals_and_adds_agent_hooks_through_context() {
|
async fn resolved_hooks_gates_globals_and_adds_agent_hooks_through_context() {
|
||||||
|
|||||||
+66
-6
@@ -1,6 +1,7 @@
|
|||||||
//! Test-only helpers shared across modules' unit tests.
|
//! Test-only helpers shared across modules' unit tests.
|
||||||
|
|
||||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::sync::{Mutex, Once, OnceLock};
|
use std::sync::{Mutex, Once, OnceLock};
|
||||||
|
|
||||||
struct TestLogCollector;
|
struct TestLogCollector;
|
||||||
@@ -44,6 +45,25 @@ pub(crate) fn debug_messages() -> &'static Mutex<Vec<String>> {
|
|||||||
DEBUG_MESSAGES.get_or_init(Mutex::default)
|
DEBUG_MESSAGES.get_or_init(Mutex::default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A copy of everything in [`warn_messages`], recovering from poisoning the
|
||||||
|
/// same way the collector itself does.
|
||||||
|
pub(crate) fn warn_snapshot() -> Vec<String> {
|
||||||
|
snapshot_of(warn_messages())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of everything in [`debug_messages`], recovering from poisoning the
|
||||||
|
/// same way the collector itself does.
|
||||||
|
pub(crate) fn debug_snapshot() -> Vec<String> {
|
||||||
|
snapshot_of(debug_messages())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_of(buffer: &Mutex<Vec<String>>) -> Vec<String> {
|
||||||
|
buffer
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
impl Log for TestLogCollector {
|
impl Log for TestLogCollector {
|
||||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
captures_warn(metadata) || captures_hooks_debug(metadata)
|
captures_warn(metadata) || captures_hooks_debug(metadata)
|
||||||
@@ -68,15 +88,55 @@ impl Log for TestLogCollector {
|
|||||||
fn flush(&self) {}
|
fn flush(&self) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs the process-wide log collector. `log::set_logger` accepts one
|
/// Installs the process-wide log collector capturing warn-level messages and
|
||||||
/// logger per process, so every test that captures warns or hooks debug
|
/// hooks-module debug messages. `log::set_logger` accepts one logger per
|
||||||
/// output must install through this shared entry point. The max level is
|
/// process, so every test that captures either stream must install through
|
||||||
/// Debug so the hooks debug capture sees its records; warn capture is
|
/// this shared entry point. The max level is Debug so the hooks debug capture
|
||||||
/// unaffected.
|
/// sees its records; warn capture is unaffected.
|
||||||
pub(crate) fn install_warn_collector() {
|
pub(crate) fn install_log_collector() {
|
||||||
static INSTALL: Once = Once::new();
|
static INSTALL: Once = Once::new();
|
||||||
INSTALL.call_once(|| {
|
INSTALL.call_once(|| {
|
||||||
log::set_logger(&TestLogCollector).expect("no other logger should be installed");
|
log::set_logger(&TestLogCollector).expect("no other logger should be installed");
|
||||||
log::set_max_level(LevelFilter::Debug);
|
log::set_max_level(LevelFilter::Debug);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets (or removes) an environment variable for the guard's lifetime and
|
||||||
|
/// restores the previous value on drop, including on panic, so a failing
|
||||||
|
/// assertion cannot leak the override into subsequent tests. Tests using it
|
||||||
|
/// must serialize (`#[serial]`) — the process environment is global.
|
||||||
|
pub(crate) struct EnvVarGuard {
|
||||||
|
key: String,
|
||||||
|
previous: Option<OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvVarGuard {
|
||||||
|
pub(crate) fn set(key: impl Into<String>, value: impl AsRef<OsStr>) -> Self {
|
||||||
|
let key = key.into();
|
||||||
|
let previous = std::env::var_os(&key);
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(&key, value);
|
||||||
|
}
|
||||||
|
Self { key, previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unset(key: impl Into<String>) -> Self {
|
||||||
|
let key = key.into();
|
||||||
|
let previous = std::env::var_os(&key);
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var(&key);
|
||||||
|
}
|
||||||
|
Self { key, previous }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvVarGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
match &self.previous {
|
||||||
|
Some(previous) => std::env::set_var(&self.key, previous),
|
||||||
|
None => std::env::remove_var(&self.key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user