Files
qiming/qiming-rcoder/crates/rcoder/src/handler/agent_cancel_handler.rs
2026-06-01 13:54:52 +08:00

649 lines
23 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Agent任务取消处理器
//!
//! 转发取消请求到容器内的 agent_runner 服务
use axum::extract::State;
use axum::http::HeaderMap;
use serde::Deserialize;
use std::sync::Arc;
use tracing::{debug, error, info, instrument, warn};
use utoipa::ToSchema;
use crate::router::AppState;
use docker_manager::ContainerBasicInfo;
use shared_types::{AppError, AgentCancelRequest, AgentCancelResponse, ComputerAgentCancelRequest, HttpResult};
use super::utils::{I18nJsonOrQuery, extract_grpc_addr, get_locale_from_headers};
/// Computer Agent 取消任务的查询参数(仅用于测试)
#[derive(Debug, Deserialize, ToSchema)]
pub struct ComputerCancelQuery {
/// 用户ID用于标识特定的用户容器ComputerAgentRunner模式可与 pod_id 二选一)
#[schema(example = "user_123")]
pub user_id: Option<String>,
/// 项目ID必填用于标识要取消的特定项目的 agent
#[schema(example = "project456")]
pub project_id: String,
/// 会话ID用于标识要取消的会话可选
#[serde(default)]
#[schema(example = "session789")]
pub session_id: Option<String>,
/// Pod ID用于共享容器模式下的容器定位可选
#[serde(default)]
pub pod_id: Option<String>,
/// 租户ID可选
#[serde(default, deserialize_with = "shared_types::flexible_string::flexible_string")]
pub tenant_id: Option<String>,
/// 空间ID可选
#[serde(default, deserialize_with = "shared_types::flexible_string::flexible_string")]
pub space_id: Option<String>,
/// 隔离类型(可选),如 "project", "tenant", "space"
#[serde(default)]
pub isolation_type: Option<String>,
}
/// 取消操作的标识符
#[derive(Debug, Clone)]
enum CancelIdentifier {
/// RCoder 模式:使用 project_id
Project(String),
/// ComputerAgentRunner 模式:使用 user_id
User(String),
/// 共享容器模式:使用 pod_id
Pod(String),
}
/// 统一的容器查询函数 - 通过 DuckDB 查询
async fn get_container_for_cancel_duckdb(
state: &AppState,
identifier: &CancelIdentifier,
) -> Result<Option<ContainerBasicInfo>, AppError> {
let identifier_display = match identifier {
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
CancelIdentifier::User(uid) => format!("user_id={}", uid),
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
};
info!(
"🔍 [CANCEL_CONTAINER_DUCKDB] Looking up container: {}",
identifier_display
);
let container_info = match identifier {
CancelIdentifier::Project(project_id) => {
// RCoder 模式:直接通过 project_id 查询
// ProjectAdapter.get() 内部会调用 get_container_for_project
state
.get_project(project_id)
.and_then(|info| info.container().cloned())
}
CancelIdentifier::User(user_id) => {
// ComputerAgentRunner 模式:通过 user_id 查询容器
// 使用新添加的 get_container_by_user_id 方法
state.projects.get_container_by_user_id(user_id)
}
CancelIdentifier::Pod(pod_id) => {
// 共享容器模式:通过 pod_id 查询容器
// 目前暂时使用 get_container_by_user_id 作为占位,后续需要实现 get_container_by_pod_id
state.projects.get_container_by_pod_id(pod_id)
}
};
if let Some(ref info) = container_info {
info!(
"✅ [CANCEL_CONTAINER_DUCKDB] Container found: {}, container_id={}, service_url={}",
identifier_display, info.container_id, info.service_url
);
} else {
info!(
" [CANCEL_CONTAINER_DUCKDB] Container not found: {}, no need to cancel",
identifier_display
);
}
Ok(container_info)
}
/// 转发取消请求到容器内的 agent_runner 服务
///
/// 🎯 使用 gRPC CancelSession RPC 替代 HTTP 转发
async fn forward_cancel_request_to_container_service(
project_id: &str,
session_id: Option<&str>,
container_info: &ContainerBasicInfo,
grpc_pool: &Arc<crate::grpc::GrpcChannelPool>,
locale: &'static str,
rcoder_prefix: &str,
computer_prefix: &str,
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
let session_id_display = session_id
.map(|s| s.to_string())
.unwrap_or_else(|| "None".to_string());
info!(
"📤 [CANCEL_FORWARD] Forwarding cancel request to container (gRPC): project_id={}, session_id={}, container_id={}",
project_id, session_id_display, container_info.container_id
);
// 🎯 使用 gRPC 替代 HTTP
// 从 service_url 提取 gRPC 地址
let grpc_addr = extract_grpc_addr(&container_info.service_url)?;
info!(
"📡 [CANCEL_FORWARD] Sending gRPC cancel request to: {}, session_id={}",
grpc_addr, session_id_display
);
// 构建 session_id如果未提供则使用空字符串由 Agent Runner 根据 project_id 查找)
let session_id_str = session_id.unwrap_or("").to_string();
let reason = "User requested cancellation".to_string();
// 调用 gRPC CancelSession
match crate::grpc::grpc_cancel_session_with_pool(
grpc_pool,
&grpc_addr,
session_id_str.clone(),
reason,
project_id.to_string(),
)
.await
{
Ok(grpc_response) => {
if grpc_response.success {
info!(
"✅ [CANCEL_FORWARD] gRPC cancel succeeded: session_id={}",
session_id_str
);
Ok(HttpResult::success(AgentCancelResponse {
success: true,
session_id: session_id_str,
}))
} else {
let error_msg = grpc_response
.message
.unwrap_or_else(|| "Unknown error".to_string());
error!("[CANCEL_FORWARD] gRPC cancelfailed: {}", error_msg);
Ok(HttpResult::error_with_locale(
shared_types::error_codes::ERR_CANCEL_FAILED,
locale,
))
}
}
Err(e) => {
error!("[CANCEL_FORWARD] gRPC call failed: {}", e);
// 检查特定的 gRPC 错误码并分类处理
if let Some(status) = crate::grpc::extract_grpc_status(&e) {
use tonic::Code;
match status.code() {
Code::NotFound => {
// 会话或 Agent 不存在,返回成功(幂等设计)
info!("[CANCEL_FORWARD] Session not found, cancel succeeded");
return Ok(HttpResult::success(AgentCancelResponse {
success: true,
session_id: session_id.unwrap_or("").to_string(),
}));
}
Code::Unavailable => {
// Agent Worker 不可用,需要判断是容器已销毁还是临时故障
// 通过 Docker API 检查容器是否真的存在
let container_exists = check_container_exists_by_info(container_info, rcoder_prefix, computer_prefix).await;
if !container_exists {
// 容器已销毁,取消目标已达成(幂等设计)
info!(
"[CANCEL_FORWARD] container already destroyed, cancel request already completed"
);
return Ok(HttpResult::success(AgentCancelResponse {
success: true,
session_id: session_id.unwrap_or("").to_string(),
}));
} else {
// 容器存在但服务不可用(可能是临时故障),返回错误
warn!(
"[CANCEL_FORWARD] Agent Worker unavailable (container exists, may be temporary failure)"
);
return Ok(HttpResult::error_with_locale(
shared_types::error_codes::ERR_SERVICE_UNAVAILABLE,
locale,
));
}
}
other_code => {
// 其他 gRPC 状态码
error!("[CANCEL_FORWARD] gRPC error code: {:?}", other_code);
}
}
}
// 其他 gRPC 通信失败(网络错误等)
return Ok(HttpResult::error_with_locale(
shared_types::error_codes::ERR_GRPC_ERROR,
locale,
));
}
}
}
/// 检查容器是否真实存在(通过 Docker API
///
/// 用于区分 Unavailable 错误的原因:
/// - 容器已销毁 → 返回 false取消目标已达成
/// - 容器存在但服务不可用 → 返回 true临时故障
///
/// 使用容器名称而非 ID因为容器重启后 ID 会变,但名称不变
async fn check_container_exists_by_info(
container_info: &ContainerBasicInfo,
rcoder_prefix: &str,
computer_prefix: &str,
) -> bool {
match docker_manager::runtime::RuntimeManager::get().await {
Ok(runtime) => {
// 使用配置化的前缀,而不是硬编码的 ServiceType::container_prefix()
let query = if let Some(identifier) = container_info
.container_name
.strip_prefix(&format!("{}-", computer_prefix))
{
runtime
.get_container_info_by_identifier(
identifier,
&shared_types::ServiceType::ComputerAgentRunner,
)
.await
} else if let Some(identifier) = container_info
.container_name
.strip_prefix(&format!("{}-", rcoder_prefix))
{
runtime
.get_container_info_by_identifier(identifier, &shared_types::ServiceType::RCoder)
.await
} else {
return true;
};
match query {
Ok(Some(info)) => {
debug!(
"🔍 [CANCEL_FORWARD] Runtime container exists: name={}, id={}",
info.container_name, info.container_id
);
true
}
Ok(None) => {
info!(
"🔍 [CANCEL_FORWARD] Runtime container not found (already destroyed): {}",
container_info.container_name
);
false
}
Err(e) => {
warn!(
"⚠️ [CANCEL_FORWARD] Failed to query runtime container status: {}, conservatively assuming container exists",
e
);
true
}
}
}
Err(e) => {
// 无法获取 runtime保守地认为容器存在
warn!(
"[CANCEL_FORWARD] Failed to get runtime: {}, conservatively assuming container exists",
e
);
true
}
}
}
/// 内部核心处理函数 v2处理会话取消请求支持多种服务类型
///
/// 使用 DuckDB 统一查询,支持 RCoder 和 ComputerAgentRunner 两种模式
async fn handle_session_cancel_internal_v2(
state: &AppState,
identifier: CancelIdentifier,
project_id: String, // 必填:传递给 agent_runner 的项目ID
session_id: Option<String>, // 可选会话ID
locale: &'static str,
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
let session_id_display = session_id
.as_deref()
.map(|s| s.to_string())
.unwrap_or_else(|| "None".to_string());
let identifier_display = match &identifier {
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
CancelIdentifier::User(uid) => format!("user_id={}", uid),
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
};
info!(
"🛑 [CANCEL_FORWARD_V2] Received cancel task request: session_id={}, project_id={}, {}",
session_id_display, project_id, identifier_display
);
// 获取容器(不创建)
let container_info = get_container_for_cancel_duckdb(state, &identifier).await?;
// 如果容器不存在,说明任务已经结束或从未启动,直接返回成功
let Some(container_info) = container_info else {
info!(
"✅ [CANCEL_FORWARD_V2] Container not found, cancel target already achieved: {}",
identifier_display
);
return Ok(HttpResult::success(AgentCancelResponse {
success: true,
session_id: session_id.unwrap_or_else(|| "all".to_string()),
}));
};
// 转发取消请求到容器服务
let result = forward_cancel_request_to_container_service(
&project_id, // 使用传入的 project_id
session_id.as_deref(),
&container_info,
&state.grpc_pool,
locale,
&state.container_prefix_rcoder,
&state.container_prefix_computer,
)
.await;
match &result {
Ok(_) => {
info!(
"✅ [CANCEL_FORWARD_V2] Cancel request handled successfully: project_id={}, {}",
project_id, identifier_display
);
}
Err(e) => {
error!(
"❌ [CANCEL_FORWARD_V2] Cancel request handling failed: project_id={}, {}, error={}",
project_id, identifier_display, e
);
}
}
result
}
/// 处理agent任务取消请求
///
/// 转发取消请求到容器内的 agent_runner 服务(使用 gRPC
#[utoipa::path(
post,
path = "/agent/session/cancel",
request_body = AgentCancelRequest,
responses(
(
status = 200,
description = "成功转发取消请求到容器",
body = HttpResult<AgentCancelResponse>,
example = json!({
"success": true,
"data": {
"success": true,
"session_id": "session456"
},
"error": null
})
),
(
status = 400,
description = "请求参数错误",
body = HttpResult<String>,
example = json!({
"success": false,
"data": null,
"error": {
"code": "INVALID_PARAMS",
"message": "Invalid project_id or session_id"
}
})
),
(
status = 401,
description = "API Key 鉴权失败",
body = HttpResult<String>
),
(
status = 404,
description = "未找到对应的项目或会话",
body = HttpResult<String>,
example = json!({
"success": false,
"data": null,
"error": {
"code": "PROJECT_NOT_FOUND",
"message": "Project or session not found"
}
})
),
(
status = 500,
description = "转发取消请求失败",
body = HttpResult<String>,
example = json!({
"success": false,
"data": null,
"error": {
"code": "CANCEL_FAILED",
"message": "Failed to forward cancel request to container"
}
})
)
),
tag = "agent",
operation_id = "agent_session_cancel",
summary = "转发Agent任务取消请求gRPC",
description = "将取消请求通过 gRPC 转发到容器内的 agent_runner 服务"
)]
#[instrument(skip(state))]
pub async fn agent_session_cancel(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
I18nJsonOrQuery(request): I18nJsonOrQuery<AgentCancelRequest>,
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
let locale = get_locale_from_headers(&headers);
// 使用 garde 进行字段校验
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
info!(
"🚫 [CANCEL] Agent cancel request: project_id={}, session_id={:?}",
project_id, request.session_id
);
handle_session_cancel_internal_v2(
&state,
CancelIdentifier::Project(project_id.to_string()),
project_id.to_string(),
request.session_id,
locale,
)
.await
}
/// 处理 Computer Agent 任务取消请求
///
/// 转发取消请求到容器内的 agent_runner 服务(使用 gRPC
#[utoipa::path(
post,
path = "/computer/agent/session/cancel",
request_body = ComputerAgentCancelRequest,
responses(
(
status = 200,
description = "成功转发取消请求到容器",
body = HttpResult<AgentCancelResponse>,
example = json!({
"success": true,
"data": {
"success": true,
"session_id": "session456"
},
"error": null
})
),
(
status = 400,
description = "请求参数错误",
body = HttpResult<String>,
example = json!({
"success": false,
"data": null,
"error": {
"code": "ERR_VALIDATION",
"message": "user_id 或 pod_id is required"
}
})
),
(
status = 401,
description = "API Key 鉴权失败",
body = HttpResult<String>
),
(
status = 404,
description = "未找到对应的用户容器或会话",
body = HttpResult<String>,
example = json!({
"success": false,
"data": null,
"error": {
"code": "CONTAINER_NOT_FOUND",
"message": "User container not found"
}
})
),
(
status = 500,
description = "转发取消请求失败",
body = HttpResult<String>,
example = json!({
"success": false,
"data": null,
"error": {
"code": "CANCEL_FAILED",
"message": "Failed to forward cancel request to container"
}
})
)
),
tag = "computer",
operation_id = "computer_agent_session_cancel",
summary = "转发 Computer Agent 任务取消请求(支持 user_id",
description = "将 Computer Agent 取消请求通过 gRPC 转发到容器内的 agent_runner 服务,支持通过 user_id 或 pod_id 定位用户容器"
)]
#[instrument(skip(state), fields(user_id = ?request.user_id.as_ref().map(|s| s.as_str()), project_id = %request.project_id, pod_id = ?request.pod_id.as_ref().map(|s| s.as_str())))]
pub async fn computer_agent_session_cancel(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentCancelRequest>,
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
let locale = get_locale_from_headers(&headers);
// 验证 user_id 或 pod_id 至少有一个
let has_user_id = request.user_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
let has_pod_id = request.pod_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
if !has_user_id && !has_pod_id {
error!("[COMPUTER_CANCEL] user_id or pod_id is required");
return Ok(HttpResult::error_with_locale(
shared_types::error_codes::ERR_VALIDATION,
locale,
));
}
// 验证 project_id 不为空
if request.project_id.trim().is_empty() {
error!("[COMPUTER_CANCEL] project_id is required");
return Ok(HttpResult::error_with_locale(
shared_types::error_codes::ERR_VALIDATION,
locale,
));
}
info!(
"🚀 [COMPUTER_CANCEL] Starting to process cancel request: user_id={:?}, pod_id={:?}, project_id={}, session_id={:?}",
request.user_id, request.pod_id, request.project_id, request.session_id
);
// 使用 user_id 或 pod_id 来构建 CancelIdentifier
let identifier = if has_user_id {
CancelIdentifier::User(request.user_id.clone().unwrap())
} else {
CancelIdentifier::Pod(request.pod_id.clone().unwrap())
};
handle_session_cancel_internal_v2(
&state,
identifier,
request.project_id, // 必填的 project_id
request.session_id,
locale,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_computer_cancel_query_deserialization() {
// 测试 ComputerCancelQuery 反序列化
let query_json = json!({
"user_id": "user_123",
"project_id": "project_456",
"session_id": "session_789"
});
let query: ComputerCancelQuery = serde_json::from_value(query_json).unwrap();
assert_eq!(query.user_id, Some("user_123".to_string()));
assert_eq!(query.project_id, "project_456");
assert_eq!(query.session_id, Some("session_789".to_string()));
}
#[test]
fn test_computer_cancel_query_optional_session() {
// 测试不带 session_id 的情况
let query_json = json!({
"user_id": "user_123",
"project_id": "project_456"
});
let query: ComputerCancelQuery = serde_json::from_value(query_json).unwrap();
assert_eq!(query.user_id, Some("user_123".to_string()));
assert_eq!(query.project_id, "project_456");
assert_eq!(query.session_id, None);
}
#[test]
fn test_cancel_identifier_display() {
// 测试 CancelIdentifier 的显示格式
let project_id = CancelIdentifier::Project("test_project".to_string());
let user_id = CancelIdentifier::User("test_user".to_string());
let pod_id = CancelIdentifier::Pod("test_pod".to_string());
let display = match &project_id {
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
_ => unreachable!(),
};
assert_eq!(display, "project_id=test_project");
let display = match &user_id {
CancelIdentifier::User(uid) => format!("user_id={}", uid),
_ => unreachable!(),
};
assert_eq!(display, "user_id=test_user");
let display = match &pod_id {
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
_ => unreachable!(),
};
assert_eq!(display, "pod_id=test_pod");
}
}