use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::pin::Pin;
use futures_util::future::BoxFuture;
use futures_util::FutureExt;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::event::SessionId;
use crate::tool::ToolResult;
pub type AsyncTaskWork = BoxFuture<'static, ToolResult>;
pub struct AsyncTaskDone {
pub task_id: String,
pub tool_call_id: String,
pub session_id: SessionId,
pub tool_name: String,
pub result: ToolResult,
pub cancelled: bool,
}
#[derive(Clone)]
pub struct AsyncTaskRunner {
done_tx: mpsc::Sender<AsyncTaskDone>,
}
impl AsyncTaskRunner {
pub fn new(done_tx: mpsc::Sender<AsyncTaskDone>) -> Self {
Self { done_tx }
}
pub fn submit(
&self,
tool_call_id: String,
session_id: SessionId,
tool_name: String,
work: AsyncTaskWork,
cancel: CancellationToken,
) -> String {
let task_id = uuid::Uuid::new_v4().to_string();
let done_tx = self.done_tx.clone();
let tid = task_id.clone();
let cancelled_msg = format!("异步任务 {} 已取消", tid);
tokio::spawn(async move {
let (result, cancelled) = tokio::select! {
biased;
_ = cancel.cancelled() => (ToolResult::error(cancelled_msg), true),
r = AssertUnwindSafe(work).catch_unwind() => match r {
Ok(tool_result) => (tool_result, false),
Err(panic_payload) => {
let msg = panic_payload_to_string(&panic_payload);
tracing::error!(
"[async] background task panicked: task_id={}, tool={}, session={}, panic={}",
tid, tool_name, session_id, msg
);
(
ToolResult::error(format!("异步任务执行时发生 panic: {}", msg)),
false,
)
}
},
};
let done = AsyncTaskDone {
task_id: tid,
tool_call_id,
session_id,
tool_name,
result,
cancelled,
};
if let Err(send_err) = done_tx.send(done).await {
let err_msg = send_err.to_string();
let lost = send_err.0;
tracing::error!(
"[async] FAILED to deliver task result to Agent (Agent likely exited): \
task_id={}, tool={}, session={}, cancelled={}, error={}. \
The task result is permanently lost.",
lost.task_id, lost.tool_name, lost.session_id, lost.cancelled, err_msg
);
}
});
task_id
}
}
fn panic_payload_to_string(payload: &Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
#[allow(dead_code)]
type _AnyFuture = Pin<Box<dyn Future<Output = ToolResult> + Send>>;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex;
async fn recv_n(rx: &Mutex<mpsc::Receiver<AsyncTaskDone>>, n: usize) -> Vec<AsyncTaskDone> {
let mut out = Vec::new();
for _ in 0..n {
match rx.lock().await.recv().await {
Some(d) => out.push(d),
None => break,
}
}
out
}
#[tokio::test]
async fn submit_returns_completed_result() {
let (tx, rx) = mpsc::channel(8);
let runner = AsyncTaskRunner::new(tx);
let counter: Arc<StdMutex<u32>> = Arc::new(StdMutex::new(0));
let c = counter.clone();
let task_id = runner.submit(
"tc1".into(),
"sess".into(),
"test_tool".into(),
Box::pin(async move {
*c.lock().unwrap() += 1;
ToolResult::success("done")
}),
CancellationToken::new(),
);
assert!(!task_id.is_empty());
let results = recv_n(&Mutex::new(rx), 1).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].task_id, task_id);
assert!(!results[0].result.is_error);
assert_eq!(results[0].result.content, "done");
assert_eq!(*counter.lock().unwrap(), 1);
}
#[tokio::test]
async fn cancel_returns_cancelled_result() {
let (tx, rx) = mpsc::channel(8);
let runner = AsyncTaskRunner::new(tx);
let cancel = CancellationToken::new();
let started = Arc::new(tokio::sync::Notify::new());
let started2 = started.clone();
let task_id = runner.submit(
"tc2".into(),
"sess".into(),
"test_tool".into(),
Box::pin(async move {
started2.notify_one();
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
ToolResult::success("should not reach")
}),
cancel.clone(),
);
started.notified().await;
cancel.cancel();
let results = recv_n(&Mutex::new(rx), 1).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].task_id, task_id);
assert!(results[0].result.is_error);
assert!(results[0].result.content.contains("已取消"));
assert!(results[0].cancelled);
}
#[tokio::test]
async fn panicking_work_delivers_error_result() {
let (tx, rx) = mpsc::channel(8);
let runner = AsyncTaskRunner::new(tx);
let task_id = runner.submit(
"tc3".into(),
"sess".into(),
"test_tool".into(),
Box::pin(async {
panic!("boom from inside work");
}),
CancellationToken::new(),
);
let results = recv_n(&Mutex::new(rx), 1).await;
assert_eq!(results.len(), 1, "panic should still deliver a result");
assert_eq!(results[0].task_id, task_id);
assert!(results[0].result.is_error);
assert!(
results[0].result.content.contains("panic"),
"content was: {}",
results[0].result.content
);
assert!(
results[0].result.content.contains("boom from inside work"),
"content was: {}",
results[0].result.content
);
assert!(!results[0].cancelled);
}
#[test]
fn panic_payload_to_string_handles_common_payloads() {
let s: Box<dyn std::any::Any + Send> = Box::new("static literal");
assert_eq!(panic_payload_to_string(&s), "static literal");
let s: Box<dyn std::any::Any + Send> = Box::new("owned".to_string());
assert_eq!(panic_payload_to_string(&s), "owned");
let s: Box<dyn std::any::Any + Send> = Box::new(42i32);
assert_eq!(panic_payload_to_string(&s), "<non-string panic payload>");
}
}