添加qiming-rcoder模块
This commit is contained in:
File diff suppressed because it is too large
Load Diff
618
qiming-rcoder/crates/agent_abstraction/src/launcher/lifecycle.rs
Normal file
618
qiming-rcoder/crates/agent_abstraction/src/launcher/lifecycle.rs
Normal file
@@ -0,0 +1,618 @@
|
||||
//! Agent生命周期管理
|
||||
//!
|
||||
//! 基于RAII原则的简洁生命周期管理设计
|
||||
//!
|
||||
//! ## 僵尸进程问题解决方案
|
||||
//!
|
||||
//! 核心问题:Drop trait 是同步的,无法 await child.wait()
|
||||
//!
|
||||
//! 解决方案:
|
||||
//! 1. **后台回收任务**:立即启动后台任务 wait() 子进程
|
||||
//! 2. **进程组终止**:使用 nix::kill 发送信号到进程组
|
||||
//! 3. **三重保障**:PID 1 的 process_reaper 模块兜底
|
||||
//!
|
||||
//! ## 进程组说明
|
||||
//!
|
||||
//! 使用 `process-wrap` crate 创建真正的进程组:
|
||||
//! - 启动时使用 `ProcessGroup::leader()` 创建进程组
|
||||
//! - 终止时发送 `kill(-pgid, SIGKILL)` 到整个进程组
|
||||
//! - 能够正确清理子进程及其所有孙进程
|
||||
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use process_wrap::tokio::ChildWrapper;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use agent_client_protocol::schema::SessionId;
|
||||
use shared_types::{AgentLifecycle, ModelProviderConfig};
|
||||
|
||||
/// Agent生命周期守卫
|
||||
///
|
||||
/// 遵循RAII原则,当守卫被drop时自动清理agent资源
|
||||
///
|
||||
/// ## 僵尸进程避免机制
|
||||
///
|
||||
/// 1. **后台回收任务**:构造时立即启动 tokio::spawn 等待子进程
|
||||
/// 2. **进程组终止**:Drop 时发送信号到进程组(使用 nix::kill)
|
||||
/// 3. **PID 1 兜底**:process_reaper 模块自动回收所有孤儿进程
|
||||
///
|
||||
/// ## 进程组信号
|
||||
///
|
||||
/// 在 Unix 上,使用负的进程组 ID 发送信号:
|
||||
/// - `kill(-pgid, SIGKILL)` 杀死整个进程组
|
||||
/// - 这会终止子进程及其所有后代(如果子进程创建了真正的进程组)
|
||||
pub struct AgentLifecycleGuard {
|
||||
inner: Arc<AgentLifecycleInner>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentLifecycleGuard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgentLifecycleGuard")
|
||||
.field("project_id", &self.inner.project_id)
|
||||
.field("session_id", &self.inner.session_id)
|
||||
.field("pgid", &self.inner.pgid)
|
||||
.field("stopped", &self.inner.stopped.load(Ordering::SeqCst))
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentLifecycleInner {
|
||||
project_id: String,
|
||||
session_id: SessionId,
|
||||
/// 🔥 进程组 ID(当前实现:使用 child.pid 作为伪进程组)
|
||||
///
|
||||
/// 注意:当前实现使用子进程的 PID 作为 PGID。
|
||||
/// - 如果子进程通过 setsid() 创建了真正的进程组,kill(-pgid) 会杀死整个进程树
|
||||
/// - 如果子进程没有创建进程组,kill(-pgid) 只会杀死子进程本身
|
||||
/// - 未来可以使用 process-wrap 库创建真正的进程组
|
||||
pgid: u32,
|
||||
cancel_token: CancellationToken,
|
||||
resources: AgentResources,
|
||||
stopped: AtomicBool,
|
||||
/// 🔥 共享的 API 密钥管理器引用(用于自动清理)
|
||||
shared_api_key_manager: Option<Arc<DashMap<String, ModelProviderConfig>>>,
|
||||
/// 🔥 project_id -> service_uuid 映射(用于清理时查找 UUID)
|
||||
project_uuid_map: Option<Arc<DashMap<String, String>>>,
|
||||
/// 🔥 关联的 service_uuid(用于清理时定位配置)
|
||||
service_uuid: Option<String>,
|
||||
}
|
||||
|
||||
/// Agent资源管理枚举
|
||||
///
|
||||
/// ## 后台回收版本
|
||||
///
|
||||
/// 存储后台任务句柄,确保子进程被 wait() 回收
|
||||
enum AgentResources {
|
||||
Claude {
|
||||
/// stderr 任务句柄
|
||||
stderr_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
/// 后台回收任务(已启动,会 wait() 子进程)
|
||||
_reaper_task: JoinHandle<()>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AgentLifecycleGuard {
|
||||
/// 为Claude Agent创建生命周期守卫(兼容旧代码,默认无密钥管理器)
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `child_process` - 已启动的子进程(必须是进程组组长)
|
||||
/// * `stderr_task` - stderr 读取任务
|
||||
/// * `cancel_token` - 取消令牌
|
||||
///
|
||||
/// # 僵尸进程避免
|
||||
///
|
||||
/// 此函数会立即启动后台任务等待子进程,确保子进程退出时被回收。
|
||||
pub fn new_claude(
|
||||
project_id: String,
|
||||
session_id: SessionId,
|
||||
child_process: Box<dyn ChildWrapper>,
|
||||
stderr_task: JoinHandle<()>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Self {
|
||||
Self::new_claude_with_key_manager(
|
||||
project_id,
|
||||
session_id,
|
||||
child_process,
|
||||
stderr_task,
|
||||
cancel_token,
|
||||
None, // 默认无密钥管理器
|
||||
None, // 默认无 project_uuid_map
|
||||
None, // 默认无 service_uuid
|
||||
)
|
||||
}
|
||||
|
||||
/// 🔥 新增:带异常退出标志的构造函数
|
||||
///
|
||||
/// 创建生命周期守卫时传入共享的 `abnormal_exit_flag`,当子进程异常退出时设置此标志。
|
||||
/// 这使得 SACP 连接层可以检测到异常退出并发送相应的通知。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `abnormal_exit_flag` - 共享的原子布尔标志,子进程异常退出时设置为 true
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// 如果子进程 PID 无效(为 0 或 None),此函数会 panic,因为这意味着进程启动失败。
|
||||
pub fn new_claude_with_abnormal_flag(
|
||||
project_id: String,
|
||||
session_id: SessionId,
|
||||
child_process: Box<dyn ChildWrapper>,
|
||||
stderr_task: JoinHandle<()>,
|
||||
cancel_token: CancellationToken,
|
||||
abnormal_exit_flag: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
Self::new_claude_with_key_manager_and_abnormal_flag(
|
||||
project_id,
|
||||
session_id,
|
||||
child_process,
|
||||
stderr_task,
|
||||
cancel_token,
|
||||
None, // 默认无密钥管理器
|
||||
None, // 默认无 project_uuid_map
|
||||
None, // 默认无 service_uuid
|
||||
Some(abnormal_exit_flag),
|
||||
)
|
||||
}
|
||||
|
||||
/// 🔥 新增:带密钥管理器和异常退出标志的构造函数
|
||||
///
|
||||
/// 创建生命周期守卫时传入共享的 API 密钥管理器和 service_uuid,
|
||||
/// 当 Agent 停止时(Drop)会自动清理对应的 API 密钥配置。
|
||||
///
|
||||
/// # 进程组管理
|
||||
///
|
||||
/// 当前实现使用子进程的 PID 作为 PGID:
|
||||
/// - `pgid = child_pid`(使用子进程 PID 作为进程组 ID)
|
||||
/// - 终止时发送 `kill(-pgid, SIGKILL)` 到进程组
|
||||
/// - 使用 `process-wrap` 创建真正的进程组,能正确清理所有孙进程
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `shared_api_key_manager` - 共享的 DashMap,用于清理 API 密钥配置
|
||||
/// * `project_uuid_map` - project_id -> service_uuid 映射,用于查找 UUID
|
||||
/// * `service_uuid` - 与此 Agent 关联的 service UUID
|
||||
/// * `abnormal_exit_flag` - 共享的原子布尔标志,子进程异常退出时设置为 true
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// 如果子进程 PID 无效(为 0 或 None),此函数会 panic,因为这意味着进程启动失败。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_claude_with_key_manager(
|
||||
project_id: String,
|
||||
session_id: SessionId,
|
||||
child_process: Box<dyn ChildWrapper>,
|
||||
stderr_task: JoinHandle<()>,
|
||||
cancel_token: CancellationToken,
|
||||
shared_api_key_manager: Option<Arc<DashMap<String, ModelProviderConfig>>>,
|
||||
project_uuid_map: Option<Arc<DashMap<String, String>>>,
|
||||
service_uuid: Option<String>,
|
||||
) -> Self {
|
||||
Self::new_claude_with_key_manager_and_abnormal_flag(
|
||||
project_id,
|
||||
session_id,
|
||||
child_process,
|
||||
stderr_task,
|
||||
cancel_token,
|
||||
shared_api_key_manager,
|
||||
project_uuid_map,
|
||||
service_uuid,
|
||||
None, // 默认无异常退出标志
|
||||
)
|
||||
}
|
||||
|
||||
/// 🔥 完整构造函数:带密钥管理器和异常退出标志
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new_claude_with_key_manager_and_abnormal_flag(
|
||||
project_id: String,
|
||||
session_id: SessionId,
|
||||
mut child_process: Box<dyn ChildWrapper>,
|
||||
stderr_task: JoinHandle<()>,
|
||||
cancel_token: CancellationToken,
|
||||
shared_api_key_manager: Option<Arc<DashMap<String, ModelProviderConfig>>>,
|
||||
project_uuid_map: Option<Arc<DashMap<String, String>>>,
|
||||
service_uuid: Option<String>,
|
||||
abnormal_exit_flag: Option<Arc<AtomicBool>>,
|
||||
) -> Self {
|
||||
// 🔥 关键:PID 有效性检查
|
||||
// ChildWrapper 的 id() 返回 Option<u32>,当进程已终止或无效时返回 None
|
||||
// 如果 PID 无效,这是一个严重的初始化错误,应该 panic
|
||||
let pid = child_process.id().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"[LifecycleGuard] 子进程 PID 无效(None),进程可能已终止: project_id={}",
|
||||
project_id
|
||||
)
|
||||
});
|
||||
|
||||
// 🔥 额外检查:PID 不应该为 0
|
||||
// 虽然 id() 返回 Some(0) 理论上可能,但实际上 PID 0 是内核保留的
|
||||
if pid == 0 {
|
||||
panic!(
|
||||
"[LifecycleGuard] 子进程 PID 为 0,这是无效的 PID: project_id={}",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 进程组 ID 等于组长进程的 PID
|
||||
// process-wrap 的 ProcessGroup 使用 setpgid(0, 0) 创建新进程组,使进程成为组长
|
||||
let pgid = pid;
|
||||
let project_id_clone = project_id.clone();
|
||||
let session_id_str = session_id.0.to_string();
|
||||
|
||||
// 🔥 关键:立即启动后台回收任务
|
||||
// 这个任务会等待子进程退出,确保不会产生僵尸进程
|
||||
// 当子进程退出时,设置 abnormal_exit_flag 并触发 cancel_token
|
||||
// 让 SACP 连接层检测到并发送 SSE 通知
|
||||
let cancel_token_for_reaper = cancel_token.clone();
|
||||
let abnormal_exit_flag_clone = abnormal_exit_flag.clone();
|
||||
let project_id_for_reaper = project_id.clone();
|
||||
let reaper_task = tokio::spawn(async move {
|
||||
info!(
|
||||
"[ProcessReaper] 开始监控 Agent 进程: project_id={}, pid={}, pgid={}",
|
||||
project_id_for_reaper, pid, pgid
|
||||
);
|
||||
|
||||
// 🔥 优先等待子进程退出,而不是响应取消信号
|
||||
// 这确保了即使收到取消信号,也能正确检测进程是否已退出
|
||||
let wait_result = child_process.wait().await;
|
||||
|
||||
// 检查是否是外部取消(用户主动 stop)
|
||||
let was_cancelled = cancel_token_for_reaper.is_cancelled();
|
||||
|
||||
match wait_result {
|
||||
Ok(status) => {
|
||||
// 获取详细的退出信息
|
||||
let exit_code = status.code();
|
||||
#[cfg(unix)]
|
||||
let signal = {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
status.signal()
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let signal: Option<i32> = None;
|
||||
|
||||
if !status.success() {
|
||||
// 🔥 非零退出码或被信号杀死 = 异常退出
|
||||
if let Some(ref flag) = abnormal_exit_flag_clone {
|
||||
// 只有非用户主动取消时才标记为异常
|
||||
if !was_cancelled {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
warn!(
|
||||
"[ProcessReaper] Agent 进程异常退出: project_id={}, pid={}, pgid={}, exit_code={:?}, signal={:?}, was_cancelled={}",
|
||||
project_id_for_reaper, pid, pgid, exit_code, signal, was_cancelled
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"[ProcessReaper] Agent 进程正常退出: project_id={}, pid={}, pgid={}, exit_code={:?}",
|
||||
project_id_for_reaper, pid, pgid, exit_code
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// wait 失败,可能是进程已被其他方式回收
|
||||
if let Some(ref flag) = abnormal_exit_flag_clone {
|
||||
if !was_cancelled {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
warn!(
|
||||
"[ProcessReaper] Agent 进程 wait() 失败: project_id={}, pid={}, pgid={}, error={}, was_cancelled={}",
|
||||
project_id_for_reaper, pid, pgid, e, was_cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 关键:触发 cancel_token,通知 SACP 连接层进程已退出
|
||||
// 这会让 SACP 连接检测到并发送 SSE 错误通知,然后断开连接
|
||||
if !was_cancelled {
|
||||
info!(
|
||||
"[ProcessReaper] 触发 cancel_token,通知 SACP 连接断开: project_id={}, pid={}",
|
||||
project_id_for_reaper, pid
|
||||
);
|
||||
cancel_token_for_reaper.cancel();
|
||||
} else {
|
||||
debug!(
|
||||
"[ProcessReaper] cancel_token 已被外部取消,跳过: project_id={}, pid={}",
|
||||
project_id_for_reaper, pid
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let resources = AgentResources::Claude {
|
||||
stderr_task: Arc::new(Mutex::new(Some(stderr_task))),
|
||||
_reaper_task: reaper_task,
|
||||
};
|
||||
|
||||
let inner = Arc::new(AgentLifecycleInner {
|
||||
project_id: project_id_clone,
|
||||
session_id,
|
||||
pgid,
|
||||
cancel_token,
|
||||
resources,
|
||||
stopped: AtomicBool::new(false),
|
||||
shared_api_key_manager,
|
||||
project_uuid_map,
|
||||
service_uuid,
|
||||
});
|
||||
|
||||
info!(
|
||||
"[LifecycleGuard] 创建 Claude Agent 守卫: project_id={}, pgid={}, session_id={}",
|
||||
project_id, pgid, session_id_str
|
||||
);
|
||||
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// 优雅停止agent
|
||||
///
|
||||
/// 带超时机制(5秒),超时后强制 kill 进程组
|
||||
///
|
||||
/// ## 进程组终止
|
||||
///
|
||||
/// 使用 `process-wrap` 创建真正的进程组,发送信号到 `-pgid` 会终止:
|
||||
/// - 子进程(进程组组长)
|
||||
/// - 所有孙进程(同一进程组中的进程)
|
||||
pub async fn graceful_stop(&self) -> Result<()> {
|
||||
// 🔥 使用原子 CAS 操作确保只执行一次清理
|
||||
// compare_exchange 返回 Ok 表示成功将 false 改为 true,即当前线程获得清理权
|
||||
// 返回 Err 表示已经被其他地方清理(Drop 或其他 graceful_stop 调用)
|
||||
let should_cleanup = self
|
||||
.inner
|
||||
.stopped
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok();
|
||||
|
||||
if !should_cleanup {
|
||||
debug!("Agent already stopped, skipping graceful stop");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Gracefully stopping Claude agent for project: {}, pgid={}",
|
||||
self.inner.project_id, self.inner.pgid
|
||||
);
|
||||
|
||||
// 1. 发送取消信号
|
||||
self.inner.cancel_token.cancel();
|
||||
|
||||
// 2. 终止进程组
|
||||
self.kill_process_group(false).await?;
|
||||
|
||||
info!(
|
||||
"Gracefully stopped Claude agent for project: {}",
|
||||
self.inner.project_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送取消信号(非阻塞)
|
||||
pub fn cancel(&self) {
|
||||
debug!("Sending cancel signal to agent: {}", self.inner.project_id);
|
||||
self.inner.cancel_token.cancel();
|
||||
}
|
||||
|
||||
/// 检查是否已停止
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
self.inner.stopped.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 获取取消令牌
|
||||
pub fn cancellation_token(&self) -> &CancellationToken {
|
||||
&self.inner.cancel_token
|
||||
}
|
||||
|
||||
/// 🔥 终止进程组
|
||||
///
|
||||
/// 向 `-pgid` 发送信号,杀死整个进程组
|
||||
///
|
||||
/// # Unix 信号语义
|
||||
///
|
||||
/// - `kill(pgid, SIGTERM)` - 发送给单个进程
|
||||
/// - `kill(-pgid, SIGTERM)` - 发送给整个进程组
|
||||
/// - `kill(0, SIGTERM)` - 发送给调用者自己的进程组(危险!)
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `force` - 是否强制使用 SIGKILL(否则使用 SIGTERM)
|
||||
async fn kill_process_group(&self, force: bool) -> Result<()> {
|
||||
let pgid = self.inner.pgid;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::errno::Errno;
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
// 🔥 关键防御性检查:pgid 不能为 0
|
||||
// kill(0, SIGKILL) 会杀死调用者自己的进程组,这是危险的
|
||||
if pgid == 0 {
|
||||
warn!(
|
||||
"[LifecycleGuard] 进程组 ID 为 0,跳过进程组终止(可能是初始化失败): project_id={}",
|
||||
self.inner.project_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 🔥 关键:使用负的进程组 ID(真实的进程组 ID)
|
||||
// -pgid 表示发送信号到整个进程组,而不仅仅是进程组组长
|
||||
let target = Pid::from_raw(-(pgid as i32));
|
||||
|
||||
let signal = if force {
|
||||
Signal::SIGKILL
|
||||
} else {
|
||||
Signal::SIGTERM
|
||||
};
|
||||
|
||||
match kill(target, signal) {
|
||||
Ok(_) => {
|
||||
debug!("already sent signal: pgid={}, signal={:?}", pgid, signal);
|
||||
|
||||
// 如果是 SIGTERM,等待一段时间让进程优雅退出
|
||||
if !force {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// 强制杀死进程组
|
||||
let _ = kill(target, Signal::SIGKILL);
|
||||
debug!("already force killed: pgid={}", pgid);
|
||||
}
|
||||
}
|
||||
Err(Errno::ESRCH) => {
|
||||
// 进程组已退出,这是正常的
|
||||
debug!("process group already exited: pgid={}", pgid);
|
||||
}
|
||||
Err(Errno::EPERM) => {
|
||||
// 权限不足,无法终止进程组
|
||||
warn!(
|
||||
"[LifecycleGuard] 权限不足,无法终止进程组: pgid={}, project_id={}",
|
||||
pgid, self.inner.project_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// 其他错误(如 EINVAL、EFAULT 等)
|
||||
debug!(" kill failed: pgid={}, error={:?}", pgid, e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Claude process group stopped: pgid={}", pgid);
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
debug!("Unix platform, skipping process group stop");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for AgentLifecycleGuard {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgentLifecycleGuard {
|
||||
fn drop(&mut self) {
|
||||
let strong_count = Arc::strong_count(&self.inner);
|
||||
|
||||
debug!(
|
||||
"[Claude] AgentLifecycleGuard::drop 开始: project_id={}, pgid={}, strong_count={}",
|
||||
self.inner.project_id, self.inner.pgid, strong_count
|
||||
);
|
||||
|
||||
// 🔥 使用原子 CAS 操作确保只执行一次清理
|
||||
// 不再依赖引用计数,因为引用计数可能因为多处 clone 而不准确
|
||||
// compare_exchange 返回 Ok 表示成功将 false 改为 true,即当前线程获得清理权
|
||||
// 返回 Err 表示已经被其他线程清理,当前线程无需操作
|
||||
let should_cleanup = self
|
||||
.inner
|
||||
.stopped
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok();
|
||||
|
||||
if should_cleanup {
|
||||
debug!(
|
||||
"[Claude] AgentLifecycleGuard 获得清理权,开始清理资源: {}",
|
||||
self.inner.project_id
|
||||
);
|
||||
|
||||
// 发送取消信号
|
||||
self.inner.cancel_token.cancel();
|
||||
|
||||
// 注意:API 密钥配置的清理由 agent_runner 层的 stop_agent 方法统一负责
|
||||
// 包括:
|
||||
// - shared_api_key_manager 中的配置
|
||||
// - project_uuid_map 中的映射
|
||||
//
|
||||
// 这样避免双重清理,确保资源只被清理一次
|
||||
|
||||
// 🔥 同步终止进程组
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
let pgid = self.inner.pgid;
|
||||
|
||||
// 🔥 关键防御性检查:pgid 不能为 0
|
||||
// kill(0, SIGKILL) 会杀死调用者自己的进程组,这是危险的
|
||||
if pgid == 0 {
|
||||
debug!(
|
||||
"[Claude] 进程组 ID 为 0,跳过进程组终止: project_id={}",
|
||||
self.inner.project_id
|
||||
);
|
||||
} else {
|
||||
let target = Pid::from_raw(-(pgid as i32));
|
||||
|
||||
if let Err(e) = kill(target, Signal::SIGKILL) {
|
||||
// 进程可能已经退出,这是正常的
|
||||
debug!(
|
||||
"[Claude] 终止进程组失败(可能已退出): pgid={}, error={}",
|
||||
pgid, e
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"[Claude] 进程组已终止: pgid={}, project_id={}",
|
||||
pgid, self.inner.project_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
debug!("[Claude] Unix platform, skipping process group stop");
|
||||
}
|
||||
|
||||
// 注意:后台回收任务 (reaper_task) 会自动完成
|
||||
// 不需要在这里等待或取消
|
||||
|
||||
info!(
|
||||
"[Claude] AgentLifecycleGuard 清理完成: project_id={}",
|
||||
self.inner.project_id
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[Claude] AgentLifecycleGuard 跳过清理(已被其他引用清理): project_id={}",
|
||||
self.inner.project_id
|
||||
);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[Claude] AgentLifecycleGuard::drop 完成: project_id={}",
|
||||
self.inner.project_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 为AgentLifecycleGuard实现AgentLifecycle trait
|
||||
impl AgentLifecycle for AgentLifecycleGuard {
|
||||
fn graceful_stop(
|
||||
&self,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
|
||||
Box::pin(async move { AgentLifecycleGuard::graceful_stop(self).await })
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
AgentLifecycleGuard::cancel(self);
|
||||
}
|
||||
|
||||
fn is_stopped(&self) -> bool {
|
||||
AgentLifecycleGuard::is_stopped(self)
|
||||
}
|
||||
|
||||
fn cancellation_token(&self) -> &CancellationToken {
|
||||
AgentLifecycleGuard::cancellation_token(self)
|
||||
}
|
||||
}
|
||||
91
qiming-rcoder/crates/agent_abstraction/src/launcher/mod.rs
Normal file
91
qiming-rcoder/crates/agent_abstraction/src/launcher/mod.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! # Agent 启动器模块
|
||||
//!
|
||||
//! 提供 Agent 的启动、生命周期管理和通道处理功能。
|
||||
//!
|
||||
//! ## SACP 迁移说明
|
||||
//!
|
||||
//! 本模块已迁移至 SACP (Symposium ACP) 实现,主要优势:
|
||||
//! - **支持 Send trait**: 可使用标准 `tokio::spawn`,无需 `LocalSet` + `spawn_local`
|
||||
//! - **Builder 模式**: 更清晰的连接构建和配置
|
||||
//! - **回调式消息处理**: 通过 `on_receive_notification` / `on_receive_request` 宏
|
||||
//!
|
||||
//! ## 子模块职责
|
||||
//!
|
||||
//! | 子模块 | 职责 | 关键类型 |
|
||||
//! |--------|------|---------|
|
||||
//! | [`lifecycle`] | Agent 生命周期守卫(RAII 资源管理)| `AgentLifecycleGuard` |
|
||||
//! | [`claude_code_sacp`] | SACP 版本的 Claude Code Agent 启动器 | `SacpClaudeCodeLauncher` |
|
||||
//!
|
||||
//! ## Agent 启动流程 (SACP)
|
||||
//!
|
||||
//! ```text
|
||||
//! AcpSessionManager.create_session()
|
||||
//! │
|
||||
//! │ 1. 创建 SacpClaudeCodeLauncher
|
||||
//! ▼
|
||||
//! SacpClaudeCodeLauncher.launch()
|
||||
//! │
|
||||
//! │ 2. 启动 Claude Code 子进程
|
||||
//! │ 3. 使用 SACP Builder 建立连接
|
||||
//! │ 4. 创建 AgentLifecycleGuard
|
||||
//! ▼
|
||||
//! SacpLauncherConnectionInfo
|
||||
//! │
|
||||
//! │ 5. 返回 session_id, prompt_tx, cancel_tx, lifecycle_guard
|
||||
//! ▼
|
||||
//! 存入 SessionRegistry
|
||||
//! ```
|
||||
//!
|
||||
//! ## 与 shared_types::AgentLifecycleGuard 的关系
|
||||
//!
|
||||
//! `AgentLifecycleGuard` 的核心实现定义在 `shared_types::model::agent_model`。
|
||||
//! 本模块的 `lifecycle.rs` 提供 Re-export,
|
||||
//! 方便从 `agent_abstraction::launcher` 统一导入。
|
||||
//!
|
||||
//! ## 生命周期管理 (RAII)
|
||||
//!
|
||||
//! 当 `AgentLifecycleGuard` 被 drop 时:
|
||||
//! 1. 发送取消信号 (`cancel_token.cancel()`)
|
||||
//! 2. 终止子进程 (`child.kill()`)
|
||||
//! 3. 停止 stderr 任务
|
||||
//!
|
||||
//! 这确保了 Agent 资源的正确清理,即使在异常情况下也不会泄漏。
|
||||
|
||||
mod claude_code_sacp;
|
||||
pub mod lifecycle;
|
||||
#[cfg(windows)]
|
||||
mod windows_launch;
|
||||
|
||||
// ============================================================================
|
||||
// SACP 类型导出(推荐使用)
|
||||
// ============================================================================
|
||||
|
||||
pub use lifecycle::AgentLifecycleGuard;
|
||||
|
||||
// 直接导出 SACP 类型
|
||||
pub use claude_code_sacp::{
|
||||
SacpAgentLaunchConfig, SacpClaudeCodeLauncher, SacpLauncherConnectionInfo,
|
||||
convert_context_servers_sacp, get_default_sacp_agent_config, load_sacp_agent_config,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 兼容性类型别名(向后兼容旧代码)
|
||||
// ============================================================================
|
||||
|
||||
/// 兼容性别名:ClaudeCodeLauncher -> SacpClaudeCodeLauncher
|
||||
pub type ClaudeCodeLauncher<N> = SacpClaudeCodeLauncher<N>;
|
||||
|
||||
/// 兼容性别名:LauncherConnectionInfoComplete -> SacpLauncherConnectionInfo
|
||||
pub type LauncherConnectionInfoComplete = SacpLauncherConnectionInfo;
|
||||
|
||||
/// 兼容性别名:AgentLaunchConfig -> SacpAgentLaunchConfig
|
||||
pub type AgentLaunchConfig = SacpAgentLaunchConfig;
|
||||
|
||||
/// 兼容性别名:load_agent_config -> load_sacp_agent_config
|
||||
pub use claude_code_sacp::load_sacp_agent_config as load_agent_config;
|
||||
|
||||
/// 兼容性别名:get_default_agent_config -> get_default_sacp_agent_config
|
||||
pub use claude_code_sacp::get_default_sacp_agent_config as get_default_agent_config;
|
||||
|
||||
/// 兼容性别名:convert_context_servers -> convert_context_servers_sacp
|
||||
pub use claude_code_sacp::convert_context_servers_sacp as convert_context_servers;
|
||||
@@ -0,0 +1,411 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
pub const CREATE_NO_WINDOW_FLAG: u32 = 0x0800_0000;
|
||||
|
||||
fn resolve_windows_node_exe() -> Option<PathBuf> {
|
||||
// 1. 显式指定的 node 可执行文件路径(最高优先级)
|
||||
if let Ok(path) = std::env::var("NUWAX_NODE_PATH") {
|
||||
let node = PathBuf::from(path);
|
||||
if node.exists() {
|
||||
info!(
|
||||
"[SACP] Windows node resolved via NUWAX_NODE_PATH: {}",
|
||||
node.display()
|
||||
);
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5 Tauri 层设置的 NUWAX_NODE_EXE(sidecar node-runtime.exe)
|
||||
if let Ok(path) = std::env::var("NUWAX_NODE_EXE") {
|
||||
let node = PathBuf::from(&path);
|
||||
if node.exists() {
|
||||
info!(
|
||||
"[SACP] Windows node resolved via NUWAX_NODE_EXE: {}",
|
||||
node.display()
|
||||
);
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 应用集成的 runtime 路径(Tauri 打包后设置的 NUWAX_APP_RUNTIME_PATH,
|
||||
// 包含 runtime\node\bin 等目录,内含集成的 node.exe)
|
||||
if let Ok(runtime_path) = std::env::var("NUWAX_APP_RUNTIME_PATH") {
|
||||
for dir in runtime_path.split(';') {
|
||||
let dir = dir.trim();
|
||||
if dir.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let node = PathBuf::from(dir).join("node.exe");
|
||||
if node.exists() {
|
||||
info!(
|
||||
"[SACP] Windows node resolved via NUWAX_APP_RUNTIME_PATH: {}",
|
||||
node.display()
|
||||
);
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从 APPDATA 推导默认的应用 runtime 路径(NUWAX_APP_RUNTIME_PATH 未设置时的回退)
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
let default_node = PathBuf::from(&appdata)
|
||||
.join("com.nuwax.agent-tauri-client")
|
||||
.join("runtime")
|
||||
.join("node")
|
||||
.join("bin")
|
||||
.join("node.exe");
|
||||
if default_node.exists() {
|
||||
info!(
|
||||
"[SACP] Windows node resolved via APPDATA default path: {}",
|
||||
default_node.display()
|
||||
);
|
||||
return Some(default_node);
|
||||
}
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[SACP] Windows node resolution failed: NUWAX_NODE_PATH, NUWAX_NODE_EXE, NUWAX_APP_RUNTIME_PATH and APPDATA default paths all missed"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
fn npm_package_entry_from_dir(
|
||||
package_dir: &std::path::Path,
|
||||
package_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let package_json = package_dir.join("package.json");
|
||||
let content = std::fs::read_to_string(package_json).ok()?;
|
||||
let package_json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
let bin_field = package_json.get("bin")?;
|
||||
|
||||
let rel_entry = if let Some(bin_str) = bin_field.as_str() {
|
||||
Some(bin_str.to_string())
|
||||
} else if let Some(bin_map) = bin_field.as_object() {
|
||||
bin_map
|
||||
.get(package_name)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
bin_map
|
||||
.values()
|
||||
.find_map(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}?;
|
||||
|
||||
let entry = package_dir.join(rel_entry);
|
||||
if entry.exists() { Some(entry) } else { None }
|
||||
}
|
||||
|
||||
fn get_windows_cmd_script_path(path: &std::path::Path) -> Option<PathBuf> {
|
||||
match path.extension().and_then(|s| s.to_str()) {
|
||||
Some(ext) if ext.eq_ignore_ascii_case("cmd") => Some(path.to_path_buf()),
|
||||
None => {
|
||||
// 先检查直接路径(绝对路径 or 当前目录)
|
||||
let direct_candidate = path.with_extension("cmd");
|
||||
if direct_candidate.exists() {
|
||||
return Some(direct_candidate);
|
||||
}
|
||||
|
||||
// which 未命中时 path 为裸命令名,需要在已知目录中搜索
|
||||
let program_name = path.file_name().and_then(|s| s.to_str())?;
|
||||
|
||||
// 搜索应用自管的 node_modules/.bin
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
let app_private = PathBuf::from(&appdata)
|
||||
.join("com.nuwax.agent-tauri-client")
|
||||
.join("node_modules")
|
||||
.join(".bin")
|
||||
.join(format!("{}.cmd", program_name));
|
||||
if app_private.exists() {
|
||||
return Some(app_private);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索 NUWAX_APP_RUNTIME_PATH 中的每个目录
|
||||
if let Ok(runtime_path) = std::env::var("NUWAX_APP_RUNTIME_PATH") {
|
||||
for dir in runtime_path.split(';').filter(|s| !s.trim().is_empty()) {
|
||||
let candidate =
|
||||
std::path::Path::new(dir.trim()).join(format!("{}.cmd", program_name));
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_js_entry_from_cmd_shim(cmd_script: &std::path::Path) -> Option<PathBuf> {
|
||||
let content = match std::fs::read_to_string(cmd_script) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"[SACP] cmd shim read failed: {} ({})",
|
||||
cmd_script.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let base_dir = cmd_script.parent()?;
|
||||
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
// 支持多种 npm shim 格式:%~dp0(旧版)、%dp0%(新版 npm >=7)、%dp0(变体)
|
||||
let base_token = if line.contains("%~dp0") {
|
||||
"%~dp0"
|
||||
} else if line.contains("%dp0%") {
|
||||
"%dp0%"
|
||||
} else if line.contains("%dp0") {
|
||||
"%dp0"
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let clean = line.replace('"', "");
|
||||
let clean_lower = clean.to_ascii_lowercase();
|
||||
let ext_end = [".cjs", ".mjs", ".js"]
|
||||
.iter()
|
||||
.filter_map(|ext| clean_lower.find(ext).map(|pos| pos + ext.len()))
|
||||
.min();
|
||||
let Some(end) = ext_end else {
|
||||
continue;
|
||||
};
|
||||
let start = clean.find(base_token)?;
|
||||
if end <= start + base_token.len() || end > clean.len() {
|
||||
continue;
|
||||
}
|
||||
let rel = clean[start + base_token.len()..end].trim_start_matches(['\\', '/']);
|
||||
if rel.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rel = rel.replace('/', "\\");
|
||||
let entry = base_dir.join(std::path::Path::new(&rel));
|
||||
if entry.exists() {
|
||||
debug!(
|
||||
"[SACP] cmd shim resolved entry: {} -> {}",
|
||||
cmd_script.display(),
|
||||
entry.display()
|
||||
);
|
||||
return Some(entry);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[SACP] cmd shim not resolved: {}", cmd_script.display());
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn resolve_windows_node_cli_command(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Option<(String, Vec<String>)> {
|
||||
let command_path = which::which(command).unwrap_or_else(|_| PathBuf::from(command));
|
||||
let path = command_path.as_path();
|
||||
info!(
|
||||
"[SACP] Windows command resolution started: input={}, resolved={}",
|
||||
command,
|
||||
path.display()
|
||||
);
|
||||
let cmd_script = get_windows_cmd_script_path(path);
|
||||
|
||||
let node_exe = resolve_windows_node_exe()?;
|
||||
info!(
|
||||
"[SACP] Windows node.exe already resolved: {}",
|
||||
node_exe.display()
|
||||
);
|
||||
|
||||
if let Some(cmd_script) = cmd_script.as_ref() {
|
||||
info!("[SACP] using cmd shim: {}", cmd_script.display());
|
||||
if let Some(js_entry) = resolve_js_entry_from_cmd_shim(cmd_script) {
|
||||
let mut actual_args = Vec::with_capacity(args.len() + 1);
|
||||
actual_args.push(js_entry.to_string_lossy().to_string());
|
||||
actual_args.extend(args.iter().cloned());
|
||||
info!(
|
||||
"[SACP] cmd shim resolution succeeded: {} -> {}",
|
||||
cmd_script.display(),
|
||||
js_entry.display()
|
||||
);
|
||||
return Some((node_exe.to_string_lossy().to_string(), actual_args));
|
||||
}
|
||||
info!(
|
||||
"[SACP] cmd shim exists but JS entry not resolved, falling back to package.json bin: {}",
|
||||
cmd_script.display()
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"[SACP] cmd shim not found, falling back to package.json bin: command={}",
|
||||
command
|
||||
);
|
||||
}
|
||||
|
||||
let package_name = match path.extension().and_then(|s| s.to_str()) {
|
||||
Some(ext) if ext.eq_ignore_ascii_case("cmd") => path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(str::to_string),
|
||||
None => path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(str::to_string),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
let mut package_dirs: Vec<PathBuf> = Vec::new();
|
||||
|
||||
// 从 which 解析到的 .bin 目录向上推导 node_modules
|
||||
if let Some(parent) = path.parent() {
|
||||
let is_npm_bin = parent
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().eq_ignore_ascii_case(".bin"))
|
||||
.unwrap_or(false);
|
||||
if is_npm_bin {
|
||||
if let Some(node_modules_dir) = parent.parent() {
|
||||
package_dirs.push(node_modules_dir.join(&package_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 仅搜索应用自管的 node_modules
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
package_dirs.push(
|
||||
PathBuf::from(appdata)
|
||||
.join("com.nuwax.agent-tauri-client")
|
||||
.join("node_modules")
|
||||
.join(&package_name),
|
||||
);
|
||||
}
|
||||
|
||||
for package_dir in package_dirs {
|
||||
if !package_dir.exists() {
|
||||
info!(
|
||||
"[SACP] package directorynot found, skip: {}",
|
||||
package_dir.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(js_entry) = npm_package_entry_from_dir(&package_dir, &package_name) {
|
||||
let mut actual_args = Vec::with_capacity(args.len() + 1);
|
||||
actual_args.push(js_entry.to_string_lossy().to_string());
|
||||
actual_args.extend(args.iter().cloned());
|
||||
info!(
|
||||
"[SACP] package.json bin resolved: {} -> {}",
|
||||
package_name,
|
||||
js_entry.display()
|
||||
);
|
||||
return Some((node_exe.to_string_lossy().to_string(), actual_args));
|
||||
}
|
||||
info!(
|
||||
"[SACP] package.json bin resolution failed: package={}, dir={}",
|
||||
package_name,
|
||||
package_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[SACP] Windows command resolution failed, no JS entry found: command={}",
|
||||
command
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows 下将「命令 + 参数」规范化为不弹 CMD 窗口的形式。
|
||||
///
|
||||
/// 背景:Windows 上 npm 全局安装会创建 .cmd/.bat,直接执行会弹出 CMD 窗口。
|
||||
/// 本函数按扩展名检测并尽可能转换为 `node.exe + JS 入口`,避免弹窗。
|
||||
///
|
||||
/// 检查顺序(从优到劣):
|
||||
/// 1. `.exe` → 原生二进制,直接返回(不修改)
|
||||
/// 2. `.cmd` / `.bat` → 尝试解析为 node.exe + JS,成功则返回新 (path, args)
|
||||
/// 3. 其他扩展名 → 直接返回
|
||||
/// 4. 无扩展名 → 用 `which` 解析后再按 1–3 处理
|
||||
///
|
||||
/// 仅编译于 Windows;调用方需使用 `#[cfg(windows)]`。
|
||||
#[cfg(windows)]
|
||||
pub fn normalize_windows_command_for_no_window(
|
||||
command_path: String,
|
||||
command_args: Vec<String>,
|
||||
) -> (String, Vec<String>) {
|
||||
let mut path = command_path;
|
||||
let mut args = command_args;
|
||||
|
||||
let path_ref = Path::new(&path);
|
||||
let ext = path_ref
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
|
||||
match ext.as_deref() {
|
||||
Some("exe") => {
|
||||
info!(
|
||||
"[SACP] Windows detected native .exe: {} - no popup window",
|
||||
path
|
||||
);
|
||||
}
|
||||
Some("cmd" | "bat") => {
|
||||
info!("[SACP] 🔍 Windows detecting .cmd/.bat: {}", path);
|
||||
info!("[SACP] 🔄 converting to node.exe + JS ...");
|
||||
if let Some((node_path, js_args)) = resolve_windows_node_cli_command(&path, &args) {
|
||||
info!("[SACP] conversion succeeded: {} + {:?}", node_path, js_args);
|
||||
path = node_path;
|
||||
args = js_args;
|
||||
} else {
|
||||
warn!(
|
||||
"[SACP] ⚠️ conversion failed, will run original command (may show popup): {}",
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
info!(
|
||||
"[SACP] ℹ️ Windows detected other format .{}: {} - trying direct execution",
|
||||
other, path
|
||||
);
|
||||
}
|
||||
None => {
|
||||
info!("[SACP] 🔍 Windows detecting extension: {}", path);
|
||||
if let Ok(resolved) = which::which(&path) {
|
||||
let resolved_str = resolved.to_string_lossy().to_string();
|
||||
info!("[SACP] 🔄 resolved: {}", resolved_str);
|
||||
|
||||
let resolved_ext = resolved
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
|
||||
match resolved_ext.as_deref() {
|
||||
Some("exe") => {
|
||||
info!("[SACP] detected .exe - no popup window");
|
||||
path = resolved_str;
|
||||
}
|
||||
Some("cmd" | "bat") => {
|
||||
info!("[SACP] 🔍 detected .cmd/.bat, converting ...");
|
||||
if let Some((node_path, js_args)) =
|
||||
resolve_windows_node_cli_command(&resolved_str, &args)
|
||||
{
|
||||
info!("[SACP] conversion succeeded: {} + {:?}", node_path, js_args);
|
||||
path = node_path;
|
||||
args = js_args;
|
||||
} else {
|
||||
warn!("[SACP] ⚠️ conversion failed");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
info!("[SACP] ℹ️ unknown extension, keeping original");
|
||||
path = resolved_str;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("[SACP] ⚠️ unable to resolve path: {}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(path, args)
|
||||
}
|
||||
Reference in New Issue
Block a user