修复: spawn panic 兜底 + namespace 淘汰提示 + run_command 超时与破坏命令信任门控

This commit is contained in:
lxy
2026-08-01 11:10:28 +08:00
parent 468950616b
commit c4ba920cf5
7 changed files with 145 additions and 11 deletions
@@ -120,8 +120,9 @@ pub(super) async fn maybe_auto_compress(
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
// 同 agentic/mod.rs 的 build_for_request 出口展开。未展开则压缩 LLM 只看到
// "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(重启后 namespace
// 清空/残留引用)→ 保留占位符降级,非崩溃。
// "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(条目被 LRU 淘汰 /
// 重启后 namespace 清空 / 跨会话残留引用)→ 替换为 EVICTED_PLACEHOLDER 提示文案
// (非保留无意义 namespace:// URI)+ warn 日志。
{
let session = session_arc.lock().await;
for m in &mut active_msgs {
@@ -129,6 +130,13 @@ pub(super) async fn maybe_auto_compress(
let path = m.content.clone();
if let Some(original) = session.namespace_store.read_only(&path) {
m.content = original.to_string();
} else {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
}
}
}
+16 -2
View File
@@ -809,7 +809,13 @@ pub(crate) async fn run_agentic_loop(
// conv_id 来源:run_agentic_loop 入参(loop 启动快照,与 guard 一致)。
let (stop_flag, notify) = {
let session = session_arc.lock().await;
let conv = session.conv_read(&conv_id).expect("[F-09] loop 入口桥接后 per_conv 必存在");
// panic-guard:原裸 .expect 在 conv 已删(并发删除/状态竞态)时 panic,
// guard 不 reset/终态不发/registry 不清致永久卡。改为 None 显式退出(对齐同函数其他 return 点;
// guard Drop 兜底复位 generating)。
let Some(conv) = session.conv_read(&conv_id) else {
tracing::warn!(stale_conv = %conv_id, "[ai] loop 入口 conv 已删,退出");
return;
};
(conv.stop_flag.clone(), conv.notify.clone())
};
@@ -1215,12 +1221,20 @@ pub(crate) async fn run_agentic_loop(
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
// read_only None(重启后 namespace 清空 / 跨会话残留引用)→ 保留占位符降级,非崩溃。
// read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→
// 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。
for m in &mut history_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
if let Some(original) = session.namespace_store.read_only(&path) {
m.content = original.to_string();
} else {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
}
}
}
+17 -1
View File
@@ -19,7 +19,7 @@ use super::cache::{find_cached_high_risk_result, pending_placeholder_for};
use super::diff::build_write_file_diff;
use super::reason::build_approval_reason;
use super::record::audit_tool_call;
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, trust_key_for, TrustKey};
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, is_destructive_command, trust_key_for, TrustKey};
/// 按 risk_level + auto_exec_mode 判定是否自动执行。
///
@@ -102,6 +102,22 @@ pub(super) async fn check_trust_hits(
conv_id: &str,
) -> Option<TrustKey> {
let key = trust_key_for(&draft.name, args)?;
// 安全门控(run_command 会话信任按目录不按命令的补丁):
// run_command 的信任 key 只用 working_dir,一次批准 → 同目录任意命令 auto。
// 破坏性命令(rm/del/format/... 不可逆)绝不 auto 放行,即使同目录已批准过,
// 也必须每轮走正常审批。命中黑名单直接返 None,让流程继续走 pending 审批。
if matches!(key, TrustKey::Execute { .. }) {
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
if is_destructive_command(cmd) {
tracing::info!(
tool = %draft.name,
new_tool_call_id = %draft.id,
"[会话信任] 命中破坏性命令黑名单,跳过 auto 放行(走正常审批)"
);
return None;
}
}
}
// 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key
let hit = {
let session = session_arc.lock().await;
+21 -5
View File
@@ -139,17 +139,33 @@ async fn execute_with_heartbeat(
let _guard = HeartbeatGuard { stop, handle: heartbeat };
// tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
// 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted
// 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
// 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)
match tokio::time::timeout(Duration::from_secs(60), tools.execute(name, args)).await {
// 永不发 → 前端"回答完卡住/超时清空"。外层 timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
// 心跳 30s 续命前端 watchdog。
//
// 外层 timeout 取值(BUF-run-cmd-timeout):原硬编码 60s 会先于 run_command 自带 timeout_secs
// (上限 600s)drop,致 run_command 跑长构建(cargo/npm)永远到不了用户配的 timeout_secs。
// run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707
// run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
// 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。
let outer_secs: u64 = if name == "run_command" {
args.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(60)
.clamp(60, 600)
} else {
60
};
match tokio::time::timeout(Duration::from_secs(outer_secs), tools.execute(name, args)).await {
Ok(result) => result,
Err(_elapsed) => {
tracing::error!(
conv_id = %conv_id,
tool = %name,
"[ai] 工具执行超时(60s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)"
outer_secs,
"[ai] 工具执行超时({}s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)",
outer_secs
);
Err(anyhow::anyhow!("工具执行超时(60s),已中止(防死锁)"))
Err(anyhow::anyhow!("工具执行超时({}s),已中止(防死锁)", outer_secs))
}
}
}
+20
View File
@@ -489,6 +489,26 @@ fn dir_of_path_normalized(path: &str) -> String {
}
}
/// 判定 run_command 的命令串是否「破坏性」(rm/del/format/... 等不可逆操作)。
///
/// 用于会话信任放行门控:破坏性命令**永不**走 session_trust auto 放行,即使同会话
/// 已批准过同目录的 run_command,也必须每轮走正常审批(避免「一次批准同目录 →
/// rm -rf 任意文件被自动放行」的安全漏洞)。
///
/// 判定规则:取命令**首 token** 精确匹配,避免前缀法误伤 delta/delve 等以 del 开头的
/// 合法命令。黑名单只覆盖「不可逆破坏」类;curl/wget/scp 等下载拷贝类的风险(数据外泄/
/// 恶意脚本)由 run_command 的 High risk 审批(用户每轮看完整命令)覆盖,不进黑名单,
/// 避免误伤频繁合法下载。
/// 局限:不解析 sudo/doas/env 等提权前缀(`sudo rm` 首 token 是 sudo 不命中)——本层是
/// 「尽力」补充,主防线仍是 High risk 审批。
pub fn is_destructive_command(cmd: &str) -> bool {
const DESTRUCTIVE_VERBS: &[&str] = &[
"rm", "rmdir", "del", "erase", "format", "remove-item", "dd", "mkfs",
];
let first = cmd.trim().split_whitespace().next().unwrap_or("").to_lowercase();
DESTRUCTIVE_VERBS.iter().any(|v| first == *v)
}
/// run_command / write_file 共用的目录 key 规范化:
/// 尝试 canonicalize(去 symlink / .. / 大小写归一),失败回退原字面量 trim。
fn normalize_dir_key(dir: &str) -> String {
+55 -1
View File
@@ -270,7 +270,21 @@ pub async fn run_workflow_inner(
// F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用
let cb_task_id = task_id.clone();
let cb_target_status = target_status.clone();
tauri::async_runtime::spawn(async move {
// panic-guard:执行 spawn 闭包最外层包 catch_unwind(AssertUnwindSafe(..))。
// panic 时走已存在 failed 分支(update status=failed + emit WorkflowFailed + state_registry.remove),
// 防 panic 后终态不发/registry 不清永久卡。主逻辑/调度零改动,仅在 panic 时补发终态。
// 注:依赖 panic=unwind(tokio 默认),若构建设 panic=abort 则 catch_unwind 不生效(尽力兜底)。
tauri::async_runtime::spawn({
// panic 兜底需要这些引用:move 进 catch_unwind 闭包前各取一份 clone 供 panic 分支使用。
let panic_exec_id = exec_id.clone();
let panic_db = db.clone();
let panic_event_bus = event_bus.clone();
let panic_registry = state_registry.clone();
async move {
use std::panic::AssertUnwindSafe;
use futures::FutureExt;
// 原闭包主体包进 catch_unwind:panic 以 Err(Box<dyn Any>) 返回,Ok 走正常路径。
let outcome = AssertUnwindSafe(async move {
let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone());
// 注册执行器状态机:StateMachine 内部 Arc<Mutex>clone 共享底层 HashMap
// cancel_workflow_node IPC 经 execution_id 取此引用 set_cancelled,直达运行中 HumanNode
@@ -364,6 +378,46 @@ pub async fn run_workflow_inner(
})
.await;
}
}) // 闭 catch_unwind 内 async move
.catch_unwind()
.await;
// panic 分支:catch_unwind 返回 Err(Box<dyn Any + Send>),走已存在 failed 清理路径。
match outcome {
Ok(_) => {}
Err(panic_payload) => {
// 提取 panic 消息(String/&'static str 常见,其他类型用兜底文案)
let msg = panic_payload
.downcast_ref::<String>()
.map(|s| s.clone())
.or_else(|| panic_payload.downcast_ref::<&'static str>().map(|s| s.to_string()))
.unwrap_or_else(|| "工作流执行 panic".to_string());
tracing::error!(
execution_id = %panic_exec_id,
"工作流执行 panic,走 failed 分支兜底: {}",
msg
);
let workflows = WorkflowRepo::new(&panic_db);
if let Err(e) = workflows.update_field(&panic_exec_id, "status", "failed").await {
tracing::error!("更新工作流状态失败(panic 兜底): {}", e);
}
if let Err(e) = workflows
.update_field(&panic_exec_id, "completed_at", &now_millis())
.await
{
tracing::error!("更新工作流完成时间失败(panic 兜底): {}", e);
}
panic_registry.lock().await.remove(&panic_exec_id);
panic_event_bus
.send(WorkflowEvent::WorkflowFailed {
execution_id: panic_exec_id.clone(),
error: msg,
failed_node: String::new(),
})
.await;
}
}
}
});
Ok(execution_id.to_string())