feat: add background job runner, job__* handlers, and start gates

Detached tokio::process runner with a frozen JobEnvSnapshot (env-derived
bin dirs, vault-interpolated agent envs, COYOTE_TOOL_TIMEOUT resolved at
start), process_group(0) with pgid-guarded SIGTERM/SIGKILL escalation,
capture-only ring-buffer telemetry, and LLM_OUTPUT read after wait().
MCP jobs snapshot a single-entry McpRuntime holding only the validated
server and render through the same free fn as the foreground path.

job__start enforces its gates synchronously before any spawn:
jobs_enabled, the backgroundable whitelist with directionality teaching
errors, the per-request declared-names stash captured in
before_chat_completion, then capacity (lazy supervisor get-or-init in
plain sessions). job__check/list read the shared JobState cell without
consuming; job__collect blocks with the escalation early-out and applies
a tail-biased char-boundary cap plus optional tail_lines; job__cancel
kills the group with a 5s grace.

Job declarations are injected iff jobs are enabled at agent init, the
plain-session function-init sites, and the exit_agent rebuild; job__ is
carved out of enabled_tools filtering and excluded from
concrete_tool_names so REPL toggles cannot grant or revoke it.
This commit is contained in:
2026-08-25 17:36:17 -06:00
parent fcc3756634
commit cb025b7fff
8 changed files with 1965 additions and 23 deletions
+1692 -8
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -28,6 +28,7 @@ use anyhow::{Context, Result, anyhow, bail};
use futures_util::future;
use indexmap::IndexMap;
use indoc::formatdoc;
use jobs::JOB_FUNCTION_PREFIX;
use memory::MEMORY_FUNCTION_PREFIX;
use rag_query::RAG_FUNCTION_PREFIX;
use rust_embed::Embed;
@@ -625,6 +626,10 @@ impl Functions {
.extend(supervisor::escalation_function_declarations());
}
pub fn append_job_functions(&mut self) {
self.declarations.extend(jobs::job_function_declarations());
}
pub fn append_teammate_functions(&mut self) {
self.declarations
.extend(supervisor::teammate_function_declarations());
@@ -1548,6 +1553,15 @@ impl ToolCall {
json!({"tool_call_error": error_msg})
})
}
_ if cmd_name.starts_with(JOB_FUNCTION_PREFIX) => {
jobs::handle_job_tool(ctx, &cmd_name, &json_data)
.await
.unwrap_or_else(|e| {
let error_msg = format!("Job tool failed: {e}");
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ => match run_llm_function(cmd_name, cmd_args, envs, agent_name) {
Ok(Some(contents)) => serde_json::from_str(&contents)
.ok()
@@ -2858,6 +2872,37 @@ mod tests {
assert!(f.contains("agent__reply_escalation"));
}
#[test]
fn functions_append_job_adds_declarations() {
let mut f = Functions::default();
f.append_job_functions();
assert!(f.contains("job__start"));
assert!(f.contains("job__check"));
assert!(f.contains("job__collect"));
assert!(f.contains("job__cancel"));
assert!(f.contains("job__list"));
}
#[test]
fn eval_routes_declared_job_calls_to_job_handlers() {
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
ctx.tool_scope.functions.append_job_functions();
let calls = vec![call("job__list", Some("id-1"))];
let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].output["active_jobs"], 0);
assert_eq!(results[0].output["jobs"], json!([]));
}
#[test]
fn eval_soft_fails_job_calls_when_jobs_not_declared() {
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
let calls = vec![call("job__start", Some("id-1"))];
let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap();
let err = results[0].output["tool_call_error"].as_str().unwrap();
assert!(err.contains("Unexpected call"));
}
#[test]
fn functions_append_teammate_adds_declarations() {
let mut f = Functions::default();