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
+6
-36
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user