优化: agentic防护与删除审批子代理域
- 循环主体 catch_unwind 防护,panic 走正典终态不再卡死 - 会话删除停 in-flight 循环+删除侧单点补发终态 - 审批超时到点主动收敛,执行等待循环加删除判死 - 子代理出口整轨迹落库,拒绝工具结构化+报告协议 - 压缩会话 id 修正,begin_turn 喂用户原文 - 远程桥会话列表按 origin 过滤子会话,新增中断续跑命令
This commit is contained in:
@@ -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, ¬ify,
|
||||
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, ¬ify,
|
||||
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 应执行");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user