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:
Dark-Alex-17 committed 2026-09-16 14:02:42 -06:00
1 parent 4087f3b3d0
commit 6514588e05
12 files changed
+282 -191

No files matched your search

+6 -36
View File
@@ -4,7 +4,7 @@ use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{
Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike,
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::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::repl::DEFAULT_CONTINUATION_PROMPT;
use anyhow::{Context, Result, anyhow, bail};
use chrono::{SecondsFormat, Utc};
use chrono::Utc;
use indexmap::IndexMap;
use log::{debug, warn};
use parking_lot::RwLock;
@@ -997,36 +997,6 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
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
/// agent's result arms, captured from the child context before the spawn
/// task takes ownership of it. Resolving against the child keeps global
@@ -1070,7 +1040,7 @@ impl SpawnResultHooks {
hooks::fire_resolved(
event,
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,
None,
);
@@ -2377,8 +2347,8 @@ mod tests {
}
#[test]
fn hook_base_envs_carries_optional_names_only_when_present() {
let envs = hook_base_envs(HookEvent::AgentCompleted, None, None);
fn agent_event_base_envs_carry_optional_names_only_when_present() {
let envs = hooks::base_envs_parts(HookEvent::AgentCompleted, None, None);
let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
assert!(keys.contains(&"COYOTE_EVENT"));
assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
@@ -2390,7 +2360,7 @@ mod tests {
.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!(
envs.iter()
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
+16 -18
View File
@@ -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 {
buf: Vec<u8>,
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_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire(
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
fire_job_started(ctx, &job_id, &tool);
tokio::spawn(async move {
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
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_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire(
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
fire_job_started(ctx, &job_id, &tool);
tokio::spawn(async move {
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));
+25 -4
View File
@@ -4795,6 +4795,27 @@ mod tests {
fn built_shims_resolve_paths_at_runtime_after_relocation() {
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 home_a = root.join("home-a");
let config_a = home_a.join("coyote");
@@ -4858,14 +4879,14 @@ mod tests {
return;
}
let output = process::Command::new(&moved_shim)
let mut command = process::Command::new(&moved_shim);
command
.arg("{}")
.env_remove(&config_env)
.env_remove(&functions_env)
.env_remove("LLM_OUTPUT")
.env_remove("LLM_TOOL_DATA_FILE")
.output()
.unwrap();
.env_remove("LLM_TOOL_DATA_FILE");
let output = output_with_etxtbsy_retry(&mut command).unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),