feat: generalize supervisor registry to TaskHandle enum with job scaffolding, kill discipline, and max_concurrent_jobs config

Implements T1 of plans/background-jobs-design.md (§6, R7/R8/R9):

- Supervisor.handles is now HashMap<String, TaskHandle> where
  TaskHandle = Agent(AgentHandle) | Job(JobHandle); agent-facing
  accessors (active_count, effective_active_count, is_finished, take,
  inbox, abort_signal_for, list_agents) match only Agent variants,
  preserving all existing external behavior byte-for-byte.
- New JobHandle/JobState/JobStatus/JobResult types with pgid-guarded
  process-group kill discipline: Drop and cancel_all/cancel_recursive
  kill the group only while state.pgid is still set (pid-reuse guard),
  via libc::killpg on unix and JoinHandle::abort elsewhere.
- Per-kind job capacity: Supervisor carries max_concurrent_jobs
  (builder-set, default 0); job registration rejects at capacity.
- Cross-kind teaching errors at the four agent-lookup miss sites
  (agent__check/collect/cancel/send_message) when the id is a
  registered job or job_-prefixed; genuinely-unknown ids keep their
  existing messages.
- Supervisor init condition is now can_spawn_agents || jobs_enabled in
  use_agent and both child-agent spawn paths, with agent capacity 0 in
  jobs-only contexts; use_agent cancels the old supervisor recursively
  before replacing it.
- max_concurrent_jobs config plumbing: global Config field, AgentConfig
  override + accessor, all four AppConfig touch points including the
  COYOTE_MAX_CONCURRENT_JOBS env override; shared
  effective_max_concurrent_jobs/jobs_enabled predicates
  (agent override -> global -> default 5; 0 disables).
- Stage dependency-free RingBuf (64 KiB default) in src/function/jobs.rs
  for the upcoming job output pump.
- New sanctioned dependency: libc 0.2 under cfg(unix).
This commit is contained in:
2026-08-25 15:17:57 -06:00
parent bfc3b7bfea
commit 7f3f95d89d
10 changed files with 942 additions and 60 deletions
+6
View File
@@ -561,6 +561,10 @@ impl Agent {
self.config.max_tool_result_chars
}
pub fn max_concurrent_jobs(&self) -> Option<usize> {
self.config.max_concurrent_jobs
}
pub fn compression_keep_last(&self) -> Option<usize> {
self.config.compression_keep_last
}
@@ -735,6 +739,8 @@ pub struct AgentConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_result_chars: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_jobs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compression_keep_last: Option<usize>,
#[serde(default)]
pub description: String,
+49
View File
@@ -68,6 +68,7 @@ pub struct AppConfig {
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
pub max_concurrent_jobs: Option<usize>,
pub memory: Option<bool>,
pub memory_cap_with_tools: Option<usize>,
@@ -153,6 +154,7 @@ impl Default for AppConfig {
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
max_concurrent_jobs: None,
memory: None,
memory_cap_with_tools: None,
@@ -239,6 +241,7 @@ impl AppConfig {
summarization_prompt: config.summarization_prompt,
summary_context_prompt: config.summary_context_prompt,
max_tool_result_chars: config.max_tool_result_chars,
max_concurrent_jobs: config.max_concurrent_jobs,
memory: config.memory,
memory_cap_with_tools: config.memory_cap_with_tools,
@@ -574,6 +577,9 @@ impl AppConfig {
{
self.compression_threshold = v;
}
if let Some(v) = super::read_env_value::<usize>(&get_env_name("max_concurrent_jobs")) {
self.max_concurrent_jobs = v;
}
if let Some(v) = super::read_env_value::<String>(&get_env_name("summarization_prompt")) {
self.summarization_prompt = v;
}
@@ -844,6 +850,49 @@ mod tests {
}
}
#[test]
fn from_config_copies_max_concurrent_jobs() {
let cfg = Config {
model_id: "test-model".to_string(),
max_concurrent_jobs: Some(3),
clients: vec![ClientConfig::default()],
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.max_concurrent_jobs, Some(3));
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_max_concurrent_jobs() {
let env_name = get_env_name("max_concurrent_jobs");
let prev = std::env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { std::env::set_var(&env_name, "7") };
app.load_envs();
assert_eq!(app.max_concurrent_jobs, Some(7));
unsafe { std::env::set_var(&env_name, "0") };
app.load_envs();
assert_eq!(app.max_concurrent_jobs, Some(0));
unsafe { std::env::remove_var(&env_name) };
app.max_concurrent_jobs = Some(2);
app.load_envs();
assert_eq!(app.max_concurrent_jobs, Some(2));
unsafe {
match prev {
Some(v) => std::env::set_var(&env_name, v),
None => std::env::remove_var(&env_name),
}
}
}
#[test]
fn editor_returns_configured_value() {
let configured = cached_editor()
+6 -1
View File
@@ -42,7 +42,10 @@ pub use self::macro_policy::{
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
};
#[allow(unused_imports)]
pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_instructions};
pub use self::request_context::{
RenderMode, RequestContext, effective_max_concurrent_jobs, jobs_enabled,
should_inject_skill_instructions,
};
pub use self::role::{
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
};
@@ -264,6 +267,7 @@ pub struct Config {
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
pub max_concurrent_jobs: Option<usize>,
pub memory: Option<bool>,
pub memory_cap_with_tools: Option<usize>,
@@ -346,6 +350,7 @@ impl Default for Config {
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
max_concurrent_jobs: None,
memory: None,
memory_cap_with_tools: None,
+197 -4
View File
@@ -138,6 +138,17 @@ pub fn should_inject_skill_instructions(app: &AppConfig, policy: &SkillPolicy) -
app.function_calling_support && policy.skills_enabled && !policy.compatible_enabled.is_empty()
}
pub fn effective_max_concurrent_jobs(agent: Option<&Agent>, app: &AppConfig) -> usize {
agent
.and_then(|a| a.max_concurrent_jobs())
.or(app.max_concurrent_jobs)
.unwrap_or(5)
}
pub fn jobs_enabled(agent: Option<&Agent>, app: &AppConfig) -> bool {
app.function_calling_support && effective_max_concurrent_jobs(agent, app) > 0
}
fn print_asset_names(kind: &str, names: &[String]) -> Result<()> {
if names.is_empty() {
println!("No {kind} found.");
@@ -4046,6 +4057,11 @@ impl RequestContext {
Ok(())
}
#[allow(dead_code)]
pub fn jobs_enabled(&self) -> bool {
jobs_enabled(self.agent.as_ref(), &self.app.config)
}
pub async fn use_agent(
&mut self,
app: &AppConfig,
@@ -4136,11 +4152,20 @@ impl RequestContext {
);
}
let should_init_supervisor = agent.can_spawn_agents();
let max_concurrent = agent.max_concurrent_agents();
let jobs_enabled = jobs_enabled(Some(&agent), app);
let should_init_supervisor = agent.can_spawn_agents() || jobs_enabled;
let max_concurrent = if agent.can_spawn_agents() {
agent.max_concurrent_agents()
} else {
0
};
let max_depth = agent.max_agent_depth();
let supervisor = should_init_supervisor
.then(|| Arc::new(RwLock::new(Supervisor::new(max_concurrent, max_depth))));
let max_jobs = effective_max_concurrent_jobs(Some(&agent), app);
let supervisor = should_init_supervisor.then(|| {
Arc::new(RwLock::new(
Supervisor::new(max_concurrent, max_depth).with_max_concurrent_jobs(max_jobs),
))
});
self.rag = agent.rag();
// Keep `rag_key` in lockstep with `rag`. Agent RAGs are cached under
@@ -4152,6 +4177,9 @@ impl RequestContext {
.is_some()
.then(|| RagKey::Agent(agent.name().to_string()));
self.agent = Some(agent);
if let Some(old) = self.supervisor.as_ref() {
old.read().cancel_recursive();
}
self.supervisor = supervisor;
self.inbox = None;
self.escalation_queue = None;
@@ -5217,6 +5245,171 @@ mod tests {
assert_eq!(ctx.rag_key, None);
}
#[test]
fn effective_max_concurrent_jobs_resolution_precedence() {
let mut app = AppConfig::default();
assert_eq!(effective_max_concurrent_jobs(None, &app), 5);
app.max_concurrent_jobs = Some(9);
assert_eq!(effective_max_concurrent_jobs(None, &app), 9);
let agent = Agent::test_new(AgentConfig {
max_concurrent_jobs: Some(2),
..AgentConfig::default()
});
assert_eq!(effective_max_concurrent_jobs(Some(&agent), &app), 2);
}
#[test]
fn jobs_enabled_requires_function_calling_and_nonzero_capacity() {
let mut app = AppConfig::default();
assert!(jobs_enabled(None, &app));
app.max_concurrent_jobs = Some(0);
assert!(!jobs_enabled(None, &app));
app.max_concurrent_jobs = None;
app.function_calling_support = false;
assert!(!jobs_enabled(None, &app));
app.function_calling_support = true;
let agent = Agent::test_new(AgentConfig {
max_concurrent_jobs: Some(0),
..AgentConfig::default()
});
assert!(!jobs_enabled(Some(&agent), &app));
}
#[test]
#[serial]
fn use_agent_cancels_previous_supervisor() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let agent_name = format!(
"test_agent_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let agent_dir = paths::agent_data_dir(&agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("config.yaml"),
format!("name: {agent_name}\ninstructions: hi\n"),
)
.unwrap();
let old_sig = utils::create_abort_signal();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let join_handle = tokio::spawn(async {
Ok(crate::supervisor::AgentResult {
id: "a1".into(),
agent_name: "explore".into(),
output: String::new(),
exit_status: crate::supervisor::AgentExitStatus::Completed,
})
});
let handle = crate::supervisor::AgentHandle {
id: "a1".to_string(),
agent_name: "explore".to_string(),
depth: 1,
inbox: Arc::new(Inbox::new()),
abort_signal: old_sig.clone(),
join_handle,
child_supervisor: None,
};
let old_sup = Arc::new(RwLock::new(Supervisor::new(4, 3)));
old_sup.write().register(handle).unwrap();
ctx.supervisor = Some(old_sup);
ctx.use_agent(&app, &agent_name, None, utils::create_abort_signal())
.await
.unwrap();
});
assert!(old_sig.aborted());
assert!(ctx.supervisor.is_some());
}
#[test]
#[serial]
fn use_agent_inits_job_capable_supervisor_without_spawning() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let agent_name = format!(
"test_agent_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let agent_dir = paths::agent_data_dir(&agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("config.yaml"),
format!("name: {agent_name}\ninstructions: hi\n"),
)
.unwrap();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
ctx.use_agent(&app, &agent_name, None, utils::create_abort_signal())
.await
.unwrap();
});
let supervisor = ctx.supervisor.as_ref().expect("supervisor for jobs");
let supervisor = supervisor.read();
assert_eq!(supervisor.max_concurrent(), 0);
assert_eq!(supervisor.max_concurrent_jobs(), 5);
}
#[test]
#[serial]
fn use_agent_skips_supervisor_when_jobs_disabled() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
let mut app = ctx.app.config.as_ref().clone();
app.max_concurrent_jobs = Some(0);
let agent_name = format!(
"test_agent_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let agent_dir = paths::agent_data_dir(&agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("config.yaml"),
format!("name: {agent_name}\ninstructions: hi\n"),
)
.unwrap();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
ctx.use_agent(&app, &agent_name, None, utils::create_abort_signal())
.await
.unwrap();
});
assert!(ctx.supervisor.is_none());
}
#[test]
fn current_depth_default_is_zero() {
let ctx = create_test_ctx();