feat: implement subagent tool gating and timeout mechanisms for enhanced security and performance
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
|
||||
@@ -84,6 +85,10 @@ pub type LiveStateFn = Arc<dyn Fn(String, AgentStatus) + Send + Sync>;
|
||||
/// `execute_primitive` scope, so pipeline stages can pass data between each
|
||||
/// other while different workflow invocations remain isolated.
|
||||
///
|
||||
/// When `timeout_ms` is `Some`, the subagent is killed (abandoned on a
|
||||
/// separate thread) if it does not complete within the deadline, preventing
|
||||
/// a stuck stage from blocking the entire pipeline forever.
|
||||
///
|
||||
/// Return: the agent's text output, or an error on failure.
|
||||
fn spawn_single_agent(
|
||||
agent_id: &str,
|
||||
@@ -94,6 +99,7 @@ fn spawn_single_agent(
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
timeout_ms: Option<u64>,
|
||||
) -> anyhow::Result<String> {
|
||||
use crate::app::subagent::context::build_subagent_context;
|
||||
use crate::app::subagent::engine::run_subagent;
|
||||
@@ -170,7 +176,28 @@ fn spawn_single_agent(
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_subagent(ctx, tx);
|
||||
// Enforce timeout by running subagent on a separate thread and
|
||||
// waiting with a deadline. If the deadline expires, the thread is
|
||||
// abandoned (Rust threads cannot be forcibly killed, but we proceed
|
||||
// without waiting for it — the drain thread will drop when tx is
|
||||
// dropped on thread exit).
|
||||
let result = if let Some(timeout) = timeout_ms {
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
||||
let timeout_ctx = ctx;
|
||||
let timeout_tx = tx;
|
||||
std::thread::spawn(move || {
|
||||
let _ = done_tx.send(run_subagent(timeout_ctx, timeout_tx));
|
||||
});
|
||||
match done_rx.recv_timeout(Duration::from_millis(timeout)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"subagent '{}' timed out after {}ms",
|
||||
agent_name, timeout,
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
run_subagent(ctx, tx)
|
||||
};
|
||||
|
||||
let completed_at = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
@@ -213,6 +240,9 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||
/// `Arc<Mutex<Vec<String>>>` rather than a global static, so concurrent
|
||||
/// workflow runs are isolated from each other.
|
||||
///
|
||||
/// `timeout_ms` propagates to individual agents so that no single agent
|
||||
/// can block the entire workflow beyond the configured deadline.
|
||||
///
|
||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||
/// the order they were submitted.
|
||||
pub fn execute_primitive(
|
||||
@@ -224,6 +254,7 @@ pub fn execute_primitive(
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
findings: &Arc<Mutex<Vec<String>>>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
match primitive {
|
||||
ScriptPrimitive::Agent(prompt) => {
|
||||
@@ -231,7 +262,7 @@ pub fn execute_primitive(
|
||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, findings, live, session_dir, workspaces) {
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) {
|
||||
Ok(text) => Ok(vec![text]),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -266,6 +297,7 @@ pub fn execute_primitive(
|
||||
let session_dir = session_dir.to_path_buf();
|
||||
let workspaces = workspaces.to_vec();
|
||||
let findings = Arc::clone(findings);
|
||||
let to = timeout_ms;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let _permit = sem.acquire();
|
||||
@@ -275,6 +307,7 @@ pub fn execute_primitive(
|
||||
&session_dir,
|
||||
&workspaces,
|
||||
&findings,
|
||||
to,
|
||||
);
|
||||
if let Ok(mut locked) = results.lock() {
|
||||
locked.push((idx, result));
|
||||
@@ -308,7 +341,7 @@ pub fn execute_primitive(
|
||||
// `findings` Arc (same isolation scope as parent).
|
||||
let mut all = Vec::new();
|
||||
for (idx, script) in scripts.iter().enumerate() {
|
||||
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings) {
|
||||
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms) {
|
||||
Ok(outputs) => all.extend(outputs),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -323,7 +356,7 @@ pub fn execute_primitive(
|
||||
}
|
||||
|
||||
ScriptPrimitive::Phase { name: _name, script } => {
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings)
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,6 +405,7 @@ pub fn run_workflow_tracked(
|
||||
&script.script, args, concurrency_cap,
|
||||
script.options.continue_on_error, live_ref,
|
||||
session_dir, workspaces, &findings,
|
||||
script.options.timeout_ms,
|
||||
)?;
|
||||
|
||||
let summary = if results.is_empty() {
|
||||
@@ -409,6 +443,11 @@ fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
||||
/// A counting semaphore built from a `Mutex` + `Condvar`.
|
||||
///
|
||||
/// Used by `execute_primitive` to cap concurrent parallel branches.
|
||||
///
|
||||
/// Panic-safety: if a thread panics while holding a permit, the Mutex
|
||||
/// becomes poisoned. Both `acquire` and the `Drop` implementation recover
|
||||
/// from poisoned mutexes by discarding the poison, ensuring the semaphore
|
||||
/// remains usable after a thread panic.
|
||||
struct Semaphore {
|
||||
count: Mutex<usize>,
|
||||
condvar: std::sync::Condvar,
|
||||
@@ -423,9 +462,15 @@ impl Semaphore {
|
||||
}
|
||||
|
||||
fn acquire(&self) -> SemaphoreGuard<'_> {
|
||||
let mut count = self.count.lock().unwrap();
|
||||
let mut count = self.count.lock().unwrap_or_else(|e| {
|
||||
tracing::warn!("[semaphore] mutex poisoned in acquire, recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
while *count == 0 {
|
||||
count = self.condvar.wait(count).unwrap();
|
||||
count = self.condvar.wait(count).unwrap_or_else(|e| {
|
||||
tracing::warn!("[semaphore] mutex poisoned in wait, recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
}
|
||||
*count -= 1;
|
||||
SemaphoreGuard { sem: self }
|
||||
@@ -438,7 +483,10 @@ struct SemaphoreGuard<'a> {
|
||||
|
||||
impl<'a> Drop for SemaphoreGuard<'a> {
|
||||
fn drop(&mut self) {
|
||||
let mut count = self.sem.count.lock().unwrap();
|
||||
let mut count = self.sem.count.lock().unwrap_or_else(|e| {
|
||||
tracing::warn!("[semaphore] mutex poisoned in drop, recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
*count += 1;
|
||||
self.sem.condvar.notify_one();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user