新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
lxy
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11931 additions and 1033 deletions
+35 -4
View File
@@ -9,13 +9,40 @@ use df_storage::crud::AiToolExecutionRepo;
use super::super::AiSession;
/// Med/High 工具挂起审批时回填的占位 tool_result 内容。
/// Med/High 工具挂起审批时回填的占位 tool_result 内容(基础文本,不含 tc_id 标记)
///
/// 单一常量避免「写入处」(process_tool_calls) 与「去重排查处」
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
///
/// **阶段2(占位配对完整性)**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
/// `__PENDING__:tc_id` 标记的完整占位文本,供 sanitize 识别"占位不可裁 + 出口断言自愈补头"。
/// 本常量保留为基础文本(向前兼容 + 去重匹配基准)。
pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等待确认";
/// 构造带唯一标记(`__PENDING__:tc_id`)的审批挂起占位 tool_result 内容。
///
/// 阶段2 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
/// 据此把占位豁免保留(不丢),出口断言 `assert_placeholder_pairing` 据此补 TOOL_MISSING_PREFIX
/// 占位头自愈,使占位 result 与(可能被裁掉的)tool_call 头闭合配对,防 provider 400 orphan。
///
/// 标记格式:`{PENDING_APPROVAL_PLACEHOLDER}{PENDING_MARKER_PREFIX}{tc_id}`,
/// 如 "需要用户审批,等待确认__PENDING__:call_abc123"。tc_id 为空时退回基础文本(老格式,向前兼容)。
pub(crate) fn pending_placeholder_for(tc_id: &str) -> String {
if tc_id.is_empty() {
return PENDING_APPROVAL_PLACEHOLDER.to_string();
}
format!("{}{}{}", PENDING_APPROVAL_PLACEHOLDER, df_ai::context_helpers::PENDING_MARKER_PREFIX, tc_id)
}
/// 判定 tool_result content 是否为审批挂起占位(含基础文本或带 __PENDING__ 标记)。
///
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(阶段2 占位带 tc_id 标记后
/// 不再精确等于基础文本,需用子串/标记匹配)。兼容老占位(纯文本)与新占位(带标记)。
pub(crate) fn is_pending_placeholder(content: &str) -> bool {
df_ai::context_helpers::is_pending_placeholder(content)
}
/// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。
///
/// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具
@@ -41,7 +68,7 @@ pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
///
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
/// conv_id 来源:process_tool_calls 入参 → 由 agentic.rs run_agentic_loop 入参透传。
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
pub(crate) async fn find_cached_high_risk_result(
session: &AiSession,
conv_id: &str,
@@ -98,8 +125,12 @@ pub(crate) async fn find_cached_high_risk_result(
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
continue;
}
// 命中旧 tool_result:排除 pending 占位(内容固定为 PENDING_APPROVAL_PLACEHOLDER
if msg.content == PENDING_APPROVAL_PLACEHOLDER {
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
if is_pending_placeholder(&msg.content) {
return None;
}
// SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
@@ -163,6 +163,7 @@ mod idea_source_test_helpers {
promoted_to: None,
ai_analysis: None,
scores: None,
related_ids: None,
created_at: now.clone(),
updated_at: now,
})
+183 -21
View File
@@ -15,7 +15,7 @@ use crate::state::AppState;
use crate::commands::err_str;
use super::{AiChatEvent, AiSession, PathAuthRequest, PendingApproval, ToolCallDraft};
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
// tool_registry::generate_diff 经 audit/diff.rs 直接 usebuild_write_file_diff 内调用),
// 本文件不再裸名用 generate_diff,故不再在此 use(避免 unused import warning)。
@@ -116,9 +116,10 @@ pub(crate) use finalize::{audit_finalize, audit_tool_call};
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
// 第三批从本文件抽离,行为零变更。pub(super) use 供本文件 process_tool_calls 裸名调用
// (find_cached_high_risk_result),PENDING_APPROVAL_PLACEHOLDER 供 process_tool_calls 占位回填共享。
// (find_cached_high_risk_result)。阶段2:prior placeholder 现经 pending_placeholder_for
// (内嵌 __PENDING__:tc_id 标记)生成,PENDING_APPROVAL_PLACEHOLDER 基础常量留在 cache.rs 内部。
mod cache;
pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDER};
pub(super) use cache::{find_cached_high_risk_result, pending_placeholder_for};
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
@@ -135,7 +136,7 @@ pub(crate) use idea_source::maybe_fill_idea_source;
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files)
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
/// 取 args["path"]
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
@@ -152,7 +153,7 @@ fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<Str
v
}
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
| "append_file" | "delete_file" | "search_files" => {
| "append_file" | "delete_file" | "search_files" | "grep" => {
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
}
_ => Vec::new(),
@@ -182,21 +183,66 @@ fn check_file_tool_auth(
// 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err)
return FileToolAuthOutcome::Authorized;
}
let mut pending_dir: Option<std::path::PathBuf> = None;
// L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重),
// 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。
let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new();
for p in &paths {
match check_path_authorization(p, allowed) {
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
PathAuthDecision::NeedsAuthorization { dir } => {
if pending_dir.is_none() {
pending_dir = Some(dir);
if !pending_dirs.contains(&dir) {
pending_dirs.push(dir);
}
}
PathAuthDecision::Authorized => {}
}
}
match pending_dir {
Some(dir) => FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dir, raw_paths: paths }),
None => FileToolAuthOutcome::Authorized,
if pending_dirs.is_empty() {
FileToolAuthOutcome::Authorized
} else if tool_name == "search_files" {
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
// 其他文件工具(read_file/write_file/list_directory/patch_file/file_info/
// append_file/delete_file/rename_file)保持原 NeedsAuth 弹窗逻辑完全不变。
FileToolAuthOutcome::Denied(
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
)
} else {
FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths })
}
}
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
///
/// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed,
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
///
/// 实现:查 `find_by_tool_call_id`,status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
///
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
Ok(opt) => match opt {
Some(r) => r,
None => return 0, // 无记录 = 首次
},
Err(e) => {
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
return 0;
}
};
// pending = 首次挂起审批(尚未落定);其余落定状态 = 已执行过 → 计 1 次重试
if rec.status == "pending" {
0
} else {
1
}
}
@@ -223,7 +269,7 @@ pub(crate) async fn process_tool_calls(
let audit_repo = AiToolExecutionRepo::new(db);
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
// 调用前 agentic.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
@@ -298,8 +344,37 @@ pub(crate) async fn process_tool_calls(
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
for (draft, args, req) in path_auth_pending {
pending_count += 1;
let dir_str = req.dir.to_string_lossy().to_string();
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
// ai_authorize_dir 消费时遍历所有 dirs 写白名单。
let dir_str = req.dirs.first()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
// 同 tool_call_id 在审计表已有一条落定记录(executed/failed/rejected,即已审批执行过一次)
// → 推算 retry_count≥1,跳过 insert pending + emit Completed 带"已跳过重试"提示,
// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为(正常挂起审批)。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
draft.id
);
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()),
});
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
current_message_id,
).await;
continue;
}
session.pending_approvals.insert(
draft.id.clone(),
PendingApproval {
@@ -308,12 +383,16 @@ pub(crate) async fn process_tool_calls(
arguments: args.clone(),
conversation_id: Some(conv_id.to_string()),
recovered: false,
diff: None,
path_auth: Some(req),
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
kind: ApprovalKind::Path(req),
retry_count,
},
);
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果
// 阶段2:用 pending_placeholder_for 内嵌 __PENDING__:tc_id 标记,供 sanitize 反向 orphan
// 检测豁免保留 + 出口断言自愈补头(防占位头被裁后 orphan result 触发 provider 400)。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiDirAuthRequired {
id: draft.id.clone(),
tool: draft.name.clone(),
@@ -413,16 +492,38 @@ pub(crate) async fn process_tool_calls(
} else {
None
};
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
// 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配
// (High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
draft.id
);
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()),
});
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
continue;
}
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
tool_call_id: draft.id.clone(),
tool_name: draft.name.clone(),
arguments: args.clone(),
conversation_id: Some(conv_id.to_string()),
recovered: false,
diff: approval_diff.clone(),
path_auth: None,
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
retry_count,
});
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
id: draft.id.clone(),
@@ -438,7 +539,7 @@ pub(crate) async fn process_tool_calls(
}
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"——
// session 锁仍由调用方 agentic.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述),
// session 锁仍由调用方 agentic/mod.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述),
// push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。
@@ -551,5 +652,66 @@ pub(crate) async fn process_tool_calls(
}
}
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
let (path_count, risk_count) = session.pending_approvals.values()
.fold((0usize, 0usize), |(p, r), a| match a.kind {
ApprovalKind::Path(_) => (p + 1, r),
ApprovalKind::Risk { .. } => (p, r + 1),
});
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
pending_count
}
#[cfg(test)]
mod tests {
use super::*;
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
///
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
#[test]
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
// 空白名单(不含 workspace_root 的目录):任何路径都未授权 → NeedsAuth
let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
let outcome = check_file_tool_auth("grep", &args, &allowed);
match outcome {
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
FileToolAuthOutcome::Denied(_) => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Denied(grep 不应像 search_files 盲拒)"),
}
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
let search_outcome = check_file_tool_auth("search_files", &args, &allowed);
match search_outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
FileToolAuthOutcome::Authorized => panic!("search_files 未授权应返 Denied(盲拒),实际 Authorized"),
}
}
/// grep 黑名单路径(.ssh 等)直接 Denied(不申请授权),对齐 read_file。
#[test]
fn test_grep_blacklisted_path_denied() {
let allowed = crate::state::AllowedDirs::default_with_root();
// .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
let outcome = check_file_tool_auth("grep", &args, &allowed);
match outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
FileToolAuthOutcome::Authorized => panic!("grep 黑名单路径应返 Denied,实际 Authorized"),
}
}
/// extract_file_tool_paths:grep 取 args["path"](单路径分支)
#[test]
fn test_extract_grep_path() {
let args = serde_json::json!({ "pattern": "foo", "path": "src/main.rs", "glob": "*.rs" });
let paths = extract_file_tool_paths("grep", &args);
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
}
}
+30 -13
View File
@@ -7,7 +7,7 @@ use std::collections::HashSet;
use crate::state::AppState;
use super::super::PendingApproval;
use super::super::{ApprovalKind, PendingApproval};
use super::risk_from_str;
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
@@ -64,19 +64,26 @@ pub async fn restore_pending_approvals(state: &AppState) {
arguments: args,
conversation_id: rec.conversation_id,
recovered: true,
// 阶段3a 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
//
// **path 审批不恢复的决策(语义保留)**:路径授权挂起是会话级状态,重启后 session
// 重建,无法恢复挂起语义;且 path 的"always"决策已写入持久白名单(Settings KV),
// 重启后白名单仍生效(文件工具路径预校验会直接 Authorized,不再挂起)。故 path 审批
// 无需恢复 —— 恢复 risk 即可覆盖所有需人工决策的积压。
//
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
// 前端见 diff=None 时回退显新 content。
diff: None,
// F-260619-03 Phase B: 重启恢复的审批一律视为普通 RiskLevel 审批(非路径授权挂起)。
// 路径授权挂起是会话级状态,重启后 session 重建,无法恢复挂起语义
path_auth: None,
kind: ApprovalKind::Risk { diff: None },
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路
retry_count: 0,
},
);
}
tracing::info!(
"启动恢复: {} 条 pending 工具审批重建到内存, 分布 {} 个 conv 的 per_conv",
"启动恢复: {} 条 pending 工具审批重建到内存(pending_approvals), 分布 {} 个 conv 的 per_conv",
session.pending_approvals.len(),
convs_restored.len()
);
@@ -84,7 +91,7 @@ pub async fn restore_pending_approvals(state: &AppState) {
#[cfg(test)]
mod tests_f09_batch8_restore {
use super::super::PendingApproval;
use super::super::{ApprovalKind, PendingApproval};
use super::*;
use crate::commands::ai::AiSession;
@@ -114,7 +121,7 @@ mod tests_f09_batch8_restore {
let _ = session.conv(cid); // 惰性建
}
}
// pending 入单层 HashMap(不进 per_conv,设计 §2.1.2)
// 阶段3a 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk
session.pending_approvals.insert(
tool_call_id.clone(),
PendingApproval {
@@ -123,8 +130,9 @@ mod tests_f09_batch8_restore {
arguments: serde_json::Value::Null,
conversation_id: conversation_id.clone(),
recovered: true,
diff: None,
path_auth: None,
kind: ApprovalKind::Risk { diff: None },
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
retry_count: 0,
},
);
}
@@ -138,8 +146,17 @@ mod tests_f09_batch8_restore {
"未恢复的 conv 不应被惰性建"
);
// 不变量 2:pending_approvals 单层 HashMap 4 条(含无主),conversation_id 保留(业务语义)。
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入单层 HashMap");
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
// 阶段3a:全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
// 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造
// 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量——
// 对齐真实 restore_pending_approvals 的构造(实现 L78 一律 kind=Risk,无 Path 分支)。
// 语义:首次 restore 无历史(Path 审批是会话级状态不恢复,恢复侧构造时本就不产出 Path)。
let path_restored = session.pending_approvals.values()
.filter(|a| matches!(a.kind, ApprovalKind::Path(_)))
.count();
assert_eq!(path_restored, 0, "构造期恒等式:恢复侧构造全部 kind=Risk,无 Path(首次 restore 无历史)");
let conv_a_pending: Vec<_> = session
.pending_approvals
.values()
@@ -157,7 +174,7 @@ mod tests_f09_batch8_restore {
.values()
.filter(|a| a.conversation_id.is_none())
.collect();
assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入单层表(R-9)");
assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入 pending_approvals(R-9)");
// 不变量 3:同 conv 多条 pending 复用同一 PerConvState(conv() 幂等,不重复建)。
let conv_a_ptr = session.conv("conv-a") as *const _;