优化: agentic防护与删除审批子代理域

- 循环主体 catch_unwind 防护,panic 走正典终态不再卡死
- 会话删除停 in-flight 循环+删除侧单点补发终态
- 审批超时到点主动收敛,执行等待循环加删除判死
- 子代理出口整轨迹落库,拒绝工具结构化+报告协议
- 压缩会话 id 修正,begin_turn 喂用户原文
- 远程桥会话列表按 origin 过滤子会话,新增中断续跑命令
This commit is contained in:
lxy
2026-10-03 15:35:08 +08:00
parent c1e5b96d75
commit d406a31722
12 changed files with 1139 additions and 306 deletions
+334 -170
View File
@@ -984,7 +984,7 @@ pub(crate) async fn run_agentic_loop(
&coord_usage,
// Coordinator 路径无 round(未 stream),无估算语义 → false。
false,
None, None, true,
None, None, None, true,
&pinned_goals_snapshot,
&app_handle,
&loop_epoch_arc, my_epoch,
@@ -1004,7 +1004,34 @@ pub(crate) async fn run_agentic_loop(
// permits=2 防单对话内并发 LLM 调用失控。
// - permit 绑 guard(函数返回)Drop 自动释放——各 return 点退出即释放槽位。
// retry 同 loop 内,持 per_conv 合理。
let _conv_per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await;
//
// acquire 包 30s 超时:阻塞式 acquire 在同 conv 配额被占满时永久 parked(如竞态双 spawn 的
// 第三个 loop),而 heartbeat 任务已在此前启动持续喂前端看门狗 → UI 永远转圈无终止事件。
// 超时走 AiError 收敛,范式对齐上方 provider 解析 30s 超时:guard.reset → epoch 检查 →
// 落库 user 消息 → emit → return。
let _conv_per_conv_permit = match tokio::time::timeout(
std::time::Duration::from_secs(30),
llm_concurrency.acquire_per_conv(&conv_id),
).await {
Ok(permit) => permit,
Err(_elapsed) => {
guard.reset().await;
// 旧 loop 不 emit(被新 loop 接管时,错误由 owner loop 负责)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
// 入口超时早退也落库 user 消息(调用方已 push 内存,防重启/切会话丢末条 user)。
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
tracing::error!(conv_id = %conv_id, "[ai] 等待 per_conv 并发配额超时(30s),同对话 loop 疑似积压");
let err_msg = "等待对话并发配额超时(30s),请稍后重试".to_string();
emit_ai_chat_event(&app_handle, AiChatEvent::AiError {
error: err_msg,
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.clone()),
});
return;
}
};
// 防瞬时抖动(G4.1):per-provider 连续饱和计数(跨迭代累计)。
// 候选循环内 Exhausted +1 / Acquired 清零;链首(主 provider)连续
@@ -1014,121 +1041,212 @@ pub(crate) async fn run_agentic_loop(
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations };
for iteration in start_iteration..effective_max {
// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
// + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ──
// (实现见下方 prepare_round_inputs,Exit → 本轮终止 return,与原 early-return 等价)
let (messages, estimated_prompt, agentic_req) = match prepare_round_inputs(
&session_arc, &db, &app_handle, &conv_id, iteration,
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &provider_config, &llm_concurrency,
&pinned_goals_snapshot, sys_tokens, &system_prompt, &volatile_tail, &intent,
).await {
PreparedRound::Ready { messages, estimated_prompt, agentic_req } => {
(messages, estimated_prompt, agentic_req)
}
PreparedRound::Exit => return,
};
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)──
// 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续;
// StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。
let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round(
&session_arc, &db, &conv_id,
&messages, &tool_defs, &app_handle,
&stop_flag, &notify,
max_retries, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config,
&mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
&pinned_goals_snapshot,
&loop_epoch_arc, my_epoch,
).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
(full_text, tool_calls, round_usage, round_is_estimated)
}
RoundOutcome::StaleAfterStream => return,
RoundOutcome::MidStreamSaved => return,
RoundOutcome::Fatal => return,
};
// global/per_conv permit 已上移 loop 入口整 loop 持有,
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit
// (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。
// panic 防护:loop 主体(for + 达上限收尾 + 正常完成收尾)整体包 catch_unwind。
// 背景:本函数由 IPC 层 tauri::async_runtime::spawn 托管,主体 panic 时任务静默死亡,
// conv 卡 Generating(无终态事件,前端无法恢复)。捕获后经 unwind_to_result 把 panic 转
// 受控 Err,走既有 teardown_round 正典出口补发终态;ConvState 复位由 guard 的 Drop 兜底
// 在 unwind 时完成,此处补齐 save 落库与 AiCompleted emit。
// 借用结构:async 块非 move,guard/tokens/converged 等仍由外层函数持有,unwind 销毁的是
// 块帧,外层变量完好,panic 分支才能用同一份 guard/tokens 走 teardown_round。
// 范围:仅 loop 主体;provider 解析/配额获取等已有独立错误分支的段落不包,避免语义重叠。
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max {
// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
// + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ──
// (实现见下方 prepare_round_inputs,Exit → 本轮终止 return,与原 early-return 等价)
let (messages, estimated_prompt, agentic_req) = match prepare_round_inputs(
&session_arc, &db, &app_handle, &conv_id, iteration,
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &provider_config, &llm_concurrency,
&pinned_goals_snapshot, sys_tokens, &system_prompt, &volatile_tail, &intent,
).await {
PreparedRound::Ready { messages, estimated_prompt, agentic_req } => {
(messages, estimated_prompt, agentic_req)
}
PreparedRound::Exit => return,
};
// ── 轮收尾段:token 累加 + assistant push(+ G1 目标提取)+ 落库 + stop 收尾退出
// + 收敛/目标验证续轮判定 ──
// (实现见下方 finalize_round_output;Converged → 正常收敛,置 converged 后 break)
match finalize_round_output(
&session_arc, &db, &app_handle, &conv_id, iteration,
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &resolved_model, &provider_config, &llm_concurrency,
&pinned_goals_snapshot,
&full_text, &tool_calls_acc, &round_usage, round_is_estimated,
&last_reasoning_content,
&mut goal_verify_rounds,
).await {
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
RoundEpilogue::RunTools => {}
RoundEpilogue::Converged => {
// 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变)
converged = true;
break;
}
RoundEpilogue::Exit => return,
};
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)──
// 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续;
// StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。
let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round(
&session_arc, &db, &conv_id,
&messages, &tool_defs, &app_handle,
&stop_flag, &notify,
max_retries, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config,
&mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
&pinned_goals_snapshot,
&loop_epoch_arc, my_epoch,
).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
(full_text, tool_calls, round_usage, round_is_estimated)
}
RoundOutcome::StaleAfterStream => return,
RoundOutcome::MidStreamSaved => return,
RoundOutcome::Fatal => return,
};
// global/per_conv permit 已上移 loop 入口整 loop 持有,
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit
// (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。
// ── 工具调用段:工具执行(Low 自动 / Medium+High 待审批)+ 三熔断(L1 失败/G2 探索/
// 写删回环)+ 审批暂停 ──
// (实现见下方 process_round_tools,Exit → 本轮终止 return)
if let ToolsOutcome::Exit = process_round_tools(
&session_arc, &tools_arc, &db, &app_handle, &conv_id, iteration,
&loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
&resolved_model,
tool_calls_acc,
&mut fail_window, &mut breaker_warned,
&mut stall_count, &mut stall_warned, &mut loop_count, &mut loop_warned,
).await {
// ── 轮收尾段:token 累加 + assistant push(+ G1 目标提取)+ 落库 + stop 收尾退出
// + 收敛/目标验证续轮判定 ──
// (实现见下方 finalize_round_output;Converged → 正常收敛,置 converged 后 break)
match finalize_round_output(
&session_arc, &db, &app_handle, &conv_id, iteration,
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &resolved_model, &provider_config, &llm_concurrency,
&pinned_goals_snapshot,
&full_text, &tool_calls_acc, &round_usage, round_is_estimated,
&last_reasoning_content,
&mut goal_verify_rounds,
).await {
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
RoundEpilogue::RunTools => {}
RoundEpilogue::Converged => {
// 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变)
converged = true;
break;
}
RoundEpilogue::Exit => return,
};
// ── 工具调用段:工具执行(Low 自动 / Medium+High 待审批)+ 三熔断(L1 失败/G2 探索/
// 写删回环)+ 审批暂停 ──
// (实现见下方 process_round_tools,Exit → 本轮终止 return)
if let ToolsOutcome::Exit = process_round_tools(
&session_arc, &tools_arc, &db, &app_handle, &conv_id, iteration,
&loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
&resolved_model,
tool_calls_acc,
&mut fail_window, &mut breaker_warned,
&mut stall_count, &mut stall_warned, &mut loop_count, &mut loop_warned,
).await {
return;
}
// 全部自动执行完成 → 继续下一轮
}
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):自动完成收尾。
// 历史为暂停态(emit AiMaxRoundsReached + 保持 generating=true 仿审批等待,等用户点继续/
// 停止),因操作卡不可达时 generating 永真无声卡死,已改为 guard 复位 + teardown_round 发
// AiCompleted(incomplete=true) 自动终止;前端续/停操作卡(pendingMaxRounds)随之成死链。
// ai_continue_loop/ai_stop_loop IPC 仍保留,try_continue 续跑 iteration 由调用方传
// start_iteration 决定:审批续跑累计(防多次审批反复跑满 max 致 token 失控,传 session.iteration_used),
// 达 max 续跑重计(用户点继续=授权重来,传 0 + 重置 iteration_used)。
// 注:max_iterations=0(不限)时 effective_max=usize::MAX,for 不会正常结束至此,不触发本分支(靠 stop/收敛/审批退出)
if !converged {
tracing::warn!(
conv_id = %conv_id,
max_iter = max_iterations,
"[ai] agentic 循环达最大轮次仍未收敛,自动完成(incomplete=true)",
);
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
// P1 bg 化:达轮次上限属需介入(等用户点继续或停止),系统通知补位(条件发送)。
// 本出口 teardown 通知关(false)防同一事件双弹。
crate::commands::ai::notify::notify_session_event(
&app_handle,
crate::commands::ai::notify::NotifyKind::NeedAction,
&conv_id,
"已达最大轮次",
"AI 循环未收敛,已达最大轮次,已自动收尾",
);
// 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false,
&provider_config, &llm_concurrency,
&mut guard,
&emit_usage,
last_round_estimated,
Some(true), None, Some("max_rounds"), false,
&pinned_goals_snapshot,
&app_handle,
&loop_epoch_arc, my_epoch,
false,
).await;
return;
}
// 全部自动执行完成 → 继续下一轮
}
// 正常完成
// F1 并发 epoch:正常完成前若已被新 loop 接管,不 spawn 后台 save/标题/提炼、不 reset/emit
// (owner loop 负责收尾),直接退出。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return;
}
// save_usage 增量(自上次 save 后新增,正常收敛时上轮 save 已落,此处通常为 0);
// emit_usage 用 tokens 累计快照。normal_usage.total 直接取 tokens.total() 保持三字段一致。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
{
let session_arc = session_arc.clone();
let db = db.clone();
let conv_id = conv_id.clone();
let provider_config = provider_config.clone();
let knowledge_config = knowledge_config.clone();
let app_handle = app_handle.clone();
let llm_concurrency = llm_concurrency.clone();
let resolved_model = resolved_model.clone();
tauri::async_runtime::spawn(async move {
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
// 知识提炼:需读已落库的对话消息,故在 save 之后
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
tracing::warn!("知识提炼触发失败(非阻断): {}", e);
}
ensure_conversation_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, llm_concurrency).await;
});
}
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):转入暂停态询问用户
// emit AiMaxRoundsReached + 保持 generating=true(仿审批等待),
// 等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮)
// 或点停止(ai_stop_loop → 走完成流程)。try_continue 续跑 iteration 由调用方传 start_iteration 决定:
// 审批续跑累计(防多次审批反复跑满 max 致 token 失控,传 session.iteration_used),
// 达 max 续跑重计(用户点继续=授权重来,传 0 + 重置 iteration_used)。
// 注:max_iterations=0(不限)时 effective_max=usize::MAX,for 不会正常结束至此,故不触发暂停(靠 stop/收敛/审批退出)
if !converged {
tracing::warn!(
conv_id = %conv_id,
max_iter = max_iterations,
"[ai] agentic 循环达最大轮次仍未收敛,自动完成(incomplete=true)",
);
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
// F1 并发 epoch:emit 前 final stale 检查(防上方后台 spawn 块期间被 force_send 接管后仍 emit
// 重复 AiCompleted)。guard.reset 本身 epoch-aware(stale 跳过迁移),此处主要防 emit。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return;
}
guard.reset().await;
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
// (正常完成路径 save+extract+title 已在上方 spawn 异步,此处仅 emit,故直接调 emit_ai_completed_once)
// emit_usage=tokens 快照(prompt/completion/total 全从 tokens 取,对齐原 usage_total=tokens.total())
let normal_usage = tokens_snapshot(&tokens);
// F1 并发 epoch:emit 前最后一次 stale 检查(guard.reset 已幂等,此处防 emit 重复 AiCompleted)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage,
last_round_estimated,
None, None, None, true,
&pinned_goals_snapshot,
true,
).await;
}).await;
// panic 分支:记 error(conv_id + panic 消息,不取全 backtrace),再走与既有异常分支
// (达 MAX 自动收尾)同构的 teardown_round。emit 字段对齐 try_continue 正典:AiCompleted
// 收敛信号(incomplete 三元 None + do_publish=true + notify=false,非裸 emit),职责是
// 解卡前端并清占位 tool_result 与审批残留,不代表内容真实完成。旧 loop 被 force_send
// 接管的场景由 teardown_round 内部 epoch 守卫兜住(只 disarm 不 save/emit)。
if let Err(panic_msg) = outcome {
tracing::error!(conv_id = %conv_id, "[ai] agentic loop panic,走受控收尾: {}", panic_msg);
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
// P1 bg 化:达轮次上限属需介入(等用户点继续或停止),系统通知补位(条件发送)。
// 本出口 teardown 通知关(false)防同一事件双弹。
crate::commands::ai::notify::notify_session_event(
&app_handle,
crate::commands::ai::notify::NotifyKind::NeedAction,
&conv_id,
"已达最大轮次",
"AI 循环未收敛已暂停,等待继续或停止指令",
);
// 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
@@ -1137,66 +1255,35 @@ pub(crate) async fn run_agentic_loop(
&mut guard,
&emit_usage,
last_round_estimated,
Some(true), None, false,
None, None, None, true,
&pinned_goals_snapshot,
&app_handle,
&loop_epoch_arc, my_epoch,
false,
).await;
return;
}
}
// 正常完成
// F1 并发 epoch:正常完成前若已被新 loop 接管,不 spawn 后台 save/标题/提炼、不 reset/emit
// (owner loop 负责收尾),直接退出。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return;
}
// save_usage 增量(自上次 save 后新增,正常收敛时上轮 save 已落,此处通常为 0);
// emit_usage 用 tokens 累计快照。normal_usage.total 直接取 tokens.total() 保持三字段一致。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
{
let session_arc = session_arc.clone();
let db = db.clone();
let conv_id = conv_id.clone();
let provider_config = provider_config.clone();
let knowledge_config = knowledge_config.clone();
let app_handle = app_handle.clone();
let llm_concurrency = llm_concurrency.clone();
let resolved_model = resolved_model.clone();
tauri::async_runtime::spawn(async move {
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
// 知识提炼:需读已落库的对话消息,故在 save 之后
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
tracing::warn!("知识提炼触发失败(非阻断): {}", e);
}
ensure_conversation_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, llm_concurrency).await;
});
}
// panic 载荷转受控错误消息:只认 String 与 &'static str 两种常见载荷,其余类型走兜底文案
// (不放全 backtrace,定位靠 conv_id + 消息;载荷不可字符串化时保语义不保细节)。
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&'static str>().map(|s| s.to_string()))
.unwrap_or_else(|| "agentic loop panic(非字符串载荷)".to_string())
}
// F1 并发 epoch:emit 前 final stale 检查(防上方后台 spawn 块期间被 force_send 接管后仍 emit
// 重复 AiCompleted)。guard.reset 本身 epoch-aware(stale 跳过迁移),此处主要防 emit。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return;
// loop 主体 panic 防护薄包装:Future 正常完成走 Ok,panic 转受控 Err(消息)。
// 纯函数层无 Tauri/session 依赖,场景回放见 panic_guard_tests;生产侧 run_agentic_loop 把
// loop 主体 async 块传入,Err 分支走 teardown_round 正典出口。未经过 tokio spawn,不触碰
// JoinError 语义,外层 spawn 边界行为不变。
async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, String> {
use std::panic::AssertUnwindSafe;
match futures::FutureExt::catch_unwind(AssertUnwindSafe(fut)).await {
Ok(v) => Ok(v),
Err(p) => Err(panic_message(&*p)),
}
guard.reset().await;
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
// (正常完成路径 save+extract+title 已在上方 spawn 异步,此处仅 emit,故直接调 emit_ai_completed_once)
// emit_usage=tokens 快照(prompt/completion/total 全从 tokens 取,对齐原 usage_total=tokens.total())
let normal_usage = tokens_snapshot(&tokens);
// F1 并发 epoch:emit 前最后一次 stale 检查(guard.reset 已幂等,此处防 emit 重复 AiCompleted)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage,
last_round_estimated,
None, None, true,
&pinned_goals_snapshot,
true,
).await;
}
@@ -1285,7 +1372,7 @@ async fn prepare_round_inputs(
&emit_usage,
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
last_round_estimated,
None, None, true,
None, None, None, true,
pinned_goals_snapshot,
app_handle,
loop_epoch_arc, my_epoch,
@@ -1649,7 +1736,7 @@ async fn finalize_round_output(
guard,
&emit_usage,
last_round_estimated,
None, None, true,
None, None, None, true,
pinned_goals_snapshot,
app_handle,
loop_epoch_arc, my_epoch,
@@ -2183,12 +2270,18 @@ async fn fetch_goal_summary(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str)
if goals.is_empty() {
return String::new();
}
let goal_summary = goals.iter()
.map(|g| g.text.trim())
let mut items: Vec<String> = goals.iter()
.map(|g| g.text.trim().to_string())
.filter(|g| !g.is_empty())
.collect::<Vec<_>>()
.join("; ");
format!("(当前目标: {})", goal_summary)
.collect();
// 超过 3 条截断:全量目标已在 system_prompt「## 当前目标」段锚定,警示里只做提醒,
// 全列会让文案膨胀(实测 5 条目标拼接占满整块警示气泡)。
if items.len() > 3 {
let total = items.len();
items.truncate(3);
items.push(format!("…等共 {} 条", total));
}
format!("(当前目标: {})", items.join("; "))
}
// ── insert_tool_call_over_limit_warning: AC-2 ① 单轮工具调用数超限软提示 ──
@@ -2263,13 +2356,16 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro
// emit 单点(teardown_round 各出口 + 正常完成直调),通知收敛此处避免双写;
// 用户主动 stop 出口(teardown_round 转发)传 false 关闭。通知内部条件发送
// (notify.rs:主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默)。
async fn emit_ai_completed_once(
// pub(crate):conv 删除(ai_conversation_delete)在 epoch 递增后单点补发删除终态,
// loop 侧各退出路径因非 owner 静默,删除侧是唯一发出者(调用方无 epoch 守卫,见其注释)。
pub(crate) async fn emit_ai_completed_once(
app_handle: &AppHandle,
conv_id: &str,
usage: &df_ai::provider::TokenUsage,
is_estimated: bool,
emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>,
incomplete_reason: Option<&str>,
do_publish: bool,
pinned_goals: &[super::GoalEntry],
notify: bool,
@@ -2288,7 +2384,7 @@ async fn emit_ai_completed_once(
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为);
// MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次
// emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。
let ev = completed_event(usage, is_estimated, emit_incomplete, conv_id, pinned_goals);
let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
if do_publish && emit_incomplete == publish_incomplete {
emit_ai_chat_event(app_handle, ev);
return;
@@ -2296,7 +2392,7 @@ async fn emit_ai_completed_once(
let _ = app_handle.emit("ai-chat-event", ev);
if do_publish {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
completed_event(usage, is_estimated, publish_incomplete, conv_id, pinned_goals),
completed_event(usage, is_estimated, publish_incomplete, None, conv_id, pinned_goals),
);
}
// P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭)
@@ -2349,10 +2445,13 @@ async fn last_active_assistant_text(
}
/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。
/// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧
/// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。
fn completed_event(
usage: &df_ai::provider::TokenUsage,
is_estimated: bool,
incomplete: Option<bool>,
incomplete_reason: Option<&str>,
conv_id: &str,
pinned_goals: &[super::GoalEntry],
) -> AiChatEvent {
@@ -2365,6 +2464,7 @@ fn completed_event(
reasoning_tokens: usage.reasoning_tokens,
is_estimated,
incomplete,
incomplete_reason: incomplete_reason.map(str::to_string),
conversation_id: Some(conv_id.to_string()),
pinned_goals: pinned_goals.to_vec(),
}
@@ -2599,6 +2699,9 @@ pub(crate) async fn teardown_round(
emit_estimated: bool,
emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>,
// 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾,
// Some("network")=流中途网络中断保文,None=完整/未分类。口径见 AiCompleted 字段文档。
incomplete_reason: Option<&str>,
do_publish: bool,
pinned_goals: &[super::GoalEntry],
app_handle: &AppHandle,
@@ -2629,7 +2732,7 @@ pub(crate) async fn teardown_round(
// generating 复位后再 emit Completed(对齐原各路径顺序)
emit_ai_completed_once(
app_handle, conv_id, emit_usage, emit_estimated,
emit_incomplete, publish_incomplete, do_publish,
emit_incomplete, publish_incomplete, incomplete_reason, do_publish,
pinned_goals,
notify,
).await;
@@ -2810,3 +2913,64 @@ fn build_plan_layers_payload(
})
.collect()
}
// panic 防护场景回放:构造 loop 体 panic 的最小等价场景,验证 unwind_to_result 把 panic
// 转为受控 Err、外层状态不随 unwind 丢失、panic 前构造的 RAII 在 unwind 时执行 Drop
// (对应生产侧 GeneratingGuard Drop 复位 ConvState 的兜底)。先于实现落盘,红转绿。
#[cfg(test)]
mod panic_guard_tests {
use super::*;
// 正常完成:Ok 直通,包装层不得吞输出或改变返回值
#[tokio::test]
async fn catch_unwind_ok_path_unchanged() {
let r = unwind_to_result(async { 41 + 1 }).await;
assert_eq!(r, Ok(42));
}
// String 载荷 panic 转受控 Err,消息原样保留(生产侧凭此记 tracing::error)
#[tokio::test]
async fn catch_unwind_string_panic_to_controlled_err() {
let r = unwind_to_result(async { panic!("boom {}", 7); }).await;
assert_eq!(r, Err("boom 7".to_string()));
}
// &'static str 载荷同样收敛
#[tokio::test]
async fn catch_unwind_str_panic_to_controlled_err() {
let r = unwind_to_result(async { panic!("static boom"); }).await;
assert_eq!(r, Err("static boom".to_string()));
}
// 非字符串载荷(panic_any)收敛为兜底文案,终态不因载荷类型丢失
#[tokio::test]
async fn catch_unwind_nonstring_panic_to_fallback_err() {
let r = unwind_to_result(async { std::panic::panic_any(42u32); }).await;
assert!(matches!(r, Err(ref m) if m.contains("panic")), "应走兜底文案: {:?}", r);
}
// 无泄漏断言:panic 前 mutate 的共享状态对包装层外可见(生产侧对应 panic 分支仍能用
// 外层 guard/tokens 走 teardown);panic 前构造的 RAII 在 unwind 时执行 Drop
// (对应 GeneratingGuard 在 unwind 路径复位 ConvState 的既有兜底)
#[tokio::test]
async fn catch_unwind_outer_state_survives_and_drop_runs_on_panic() {
struct DropFlag(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let outer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let outer_in_fut = outer.clone();
let dropped_in_fut = dropped.clone();
let r = unwind_to_result(async move {
let _raii = DropFlag(dropped_in_fut);
outer_in_fut.store(true, std::sync::atomic::Ordering::SeqCst);
panic!("mid teardown");
}).await;
assert!(r.is_err());
assert!(outer.load(std::sync::atomic::Ordering::SeqCst), "panic 前的外层 mutate 应保留");
assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行");
}
}