修复: PowerShell 路径转义 + shell 失败提示 + 标题合并同 role + L0 防抖

- run_command 路径反斜杠传递修正
- 命令失败追加 PowerShell 适配提示引导 LLM 改正
- 标题生成前合并连续同 role 消息避免 Anthropic 1214
- V33 迁移追加 workflow_executions.updated_at 列
- ai-client-ready 3 秒防抖避免重复握手
This commit is contained in:
2026-06-28 13:57:53 +08:00
parent 6ffcba7e4d
commit 7adaf97377
5 changed files with 103 additions and 6 deletions

View File

@@ -937,6 +937,25 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
} else { } else {
tracing::info!("v33: pending_approvals 列已存在,跳过"); tracing::info!("v33: pending_approvals 列已存在,跳过");
} }
// 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。
// 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。
let has_updated_at: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_updated_at {
// workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御)
// pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。
match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") {
Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"),
Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e),
}
} else {
tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
tracing::info!("迁移 v33 完成"); tracing::info!("迁移 v33 完成");
Ok(()) Ok(())

View File

@@ -660,7 +660,12 @@ pub async fn ai_approve(
state.ai_tools.execute(&approval.tool_name, args.clone()), state.ai_tools.execute(&approval.tool_name, args.clone()),
).await { ).await {
Ok(r) => r, Ok(r) => r,
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)), // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name,
if approval.tool_name == "run_command" && cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
} else { "" })),
} }
}; };
// 工具失败不 return Err把错误包成 tool_result落库 + emit completed + 续循环全走通。 // 工具失败不 return Err把错误包成 tool_result落库 + emit completed + 续循环全走通。

View File

@@ -76,6 +76,12 @@ pub(crate) async fn ensure_conversation_title(
return; return;
} }
// 任务3: 合并相邻同 role 消息(防 Anthropic 协议 1214 错误)。
// session.messages 历史可能存在连续同 role(如 user 失败重提、助手多轮中断后续接、tool_result 被过滤后
// user 连续),Anthropic API 严格校验「messages 必须 user/assistant 交替」,连续同 role 直接拒 1214。
// 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。
let summary_msgs = merge_consecutive_roles(summary_msgs);
// B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。 // B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。
// 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住 // 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住
// (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台 // (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台
@@ -208,3 +214,36 @@ pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
if chars.next().is_some() { format!("{}...", t) } else { t } if chars.next().is_some() { format!("{}...", t) } else { t }
}) })
} }
/// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。
/// role 比较: System/User/Assistant/Tool 中,User 和 Assistant 交替校验最严,
/// 不同 role 不合并(保留边界),仅合并连续同 role。
fn merge_consecutive_roles(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
if msgs.is_empty() {
return msgs;
}
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
for m in msgs {
if let Some(last) = out.last_mut() {
// role 比较(MessageRole 未派生 PartialEq,用 matches!按变体逐个比)。
let same_role = match (&last.role, &m.role) {
(MessageRole::System, MessageRole::System)
| (MessageRole::User, MessageRole::User)
| (MessageRole::Assistant, MessageRole::Assistant)
| (MessageRole::Tool, MessageRole::Tool) => true,
_ => false,
};
if same_role {
// 连续同 role: 拼接 content(中间补换行,避免语义粘连),保留首条元数据(id/timestamp)。
// 不拼接 reasoning_content(标题摘要场景该字段无用,且不同 turn reasoning 串一起无意义)。
if !m.content.is_empty() {
last.content.push('\n');
last.content.push_str(&m.content);
}
continue;
}
}
out.push(m);
}
out
}

View File

@@ -2316,8 +2316,13 @@ fn register_file_tools(
]), ]),
RiskLevel::High, RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move { Box::new(|args: serde_json::Value| Box::pin(async move {
// BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致
// JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str()
// 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸
// 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。
let command = args["command"].as_str() let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
.to_string();
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
@@ -2332,7 +2337,7 @@ fn register_file_tools(
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest { let request = ShellRequest {
command: command.to_string(), command: command.clone(),
working_dir: Some(working_dir.clone()), working_dir: Some(working_dir.clone()),
env: HashMap::new(), env: HashMap::new(),
timeout_secs: Some(timeout_secs), timeout_secs: Some(timeout_secs),
@@ -2344,15 +2349,24 @@ fn register_file_tools(
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
//
// 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`),
// LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、
// 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。
let shell_hint = if cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\"\"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)"
} else {
""
};
let result = execute(request).await.map_err(|e| { let result = execute(request).await.map_err(|e| {
let msg = e.to_string(); let msg = e.to_string();
if msg.contains("命令执行超时") { if msg.contains("命令执行超时") {
anyhow::anyhow!( anyhow::anyhow!(
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。", "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}",
timeout_secs timeout_secs, shell_hint
) )
} else { } else {
e anyhow::anyhow!("{}{}", msg, shell_hint)
} }
})?; })?;

View File

@@ -57,11 +57,31 @@ pub fn run() {
app.manage(app_state); app.manage(app_state);
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态 // B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
// 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。
let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new(
std::time::Instant::now() - std::time::Duration::from_secs(3600),
));
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
app.listen("ai-client-ready", move |_event| { app.listen("ai-client-ready", move |_event| {
let session_arc = session_for_handshake.clone(); let session_arc = session_for_handshake.clone();
let app_h = app_handle.clone(); let app_h = app_handle.clone();
let last_at = last_handshake_at.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。
{
let mut last = last_at.lock().await;
let elapsed = last.elapsed();
if elapsed < std::time::Duration::from_secs(3) {
tracing::info!(
"[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)",
elapsed.as_millis()
);
return;
}
*last = std::time::Instant::now();
}
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。 // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。