//! 文件租约注册表(W-h1 感知租约,设计文档《并行化与异步唤醒-2026-10-04》§10.1) //! //! 治「互踩三连」(双头编排/子代理同文件/主客两线叠改):写类文件工具执行前登记 //! path→(会话,工具,调用id) 到进程级注册表,执行完成(RAII)释放;同路径第二写者 //! 前置校验命中 → 拒绝 + 指路「该文件正被会话 B 编辑」。同会话同文件重复写 //! (同 conv 续写多段)放行——租约语义是**跨会话/跨代理互斥**,同会话内串行执行 //! 天然无竞态(单 loop 顺序执行工具)。 //! //! 与 W-a 二维分组器互补:分组器管同轮内保序,registry 管跨轮跨会话互斥。 //! //! ## TTL 兜底 //! 租约带 10min TTL(取 execute_with_heartbeat 最长 outer_secs=600s 上限同源), //! 崩溃/超时路径 guard drop 泄漏时由 try_acquire 惰性过期清理,无需后台任务。 //! //! ## 键归一 //! path 按平台 case 折叠(Windows 不敏感/Unix 敏感)+ 前后斜杠归一,防同一文件 //! 大小写/斜杠变体绕过互斥。语义化路径(如 a/../b)不做 resolve——工具层传参 //! 惯例为规范绝对路径,过度归一反而引入 surprise。 //! //! ## 单测 //! 见文末 tests 模块:互斥/同会话放行/释放/TTL 过期/键归一五组。 use std::collections::HashMap; use std::sync::LazyLock; use std::sync::RwLock; use std::time::{Duration, Instant}; /// 租约 TTL:与 execute_with_heartbeat outer_secs 上限(600s)同源加缓冲。 /// guard 正常 drop 走显式释放,TTL 仅兜底异常泄漏(进程内 panic unwind 被 /// catch 层接住后 guard 仍会 drop;此处防「未来新增执行路径忘记 RAII」)。 pub(crate) const LEASE_TTL: Duration = Duration::from_secs(660); /// 租约持有者信息(指路文案的数据源) #[derive(Debug, Clone)] pub(crate) struct LeaseHolder { pub conv_id: String, /// 会话内身份:main=主会话,sub=子代理(父会话下发的子任务) pub agent_kind: &'static str, pub tool_name: String, /// 持有该租约的 tool_call_id(诊断/未来按调用粒度让位用;query 输出未含,防噪声) #[allow(dead_code)] pub tool_call_id: String, pub acquired_at: Instant, } impl LeaseHolder { fn expired(&self, now: Instant) -> bool { now.duration_since(self.acquired_at) > LEASE_TTL } /// 指路文案:占用方是谁+多久了+建议动作 fn describe(&self) -> String { let elapsed = self.acquired_at.elapsed().as_secs(); let who = if self.agent_kind == "sub" { "子代理" } else { "主会话" }; format!( "该文件正被另一会话的{who}占用中(工具 {tool},已持 {elapsed}s)。为避免并发写互踩,本次调用被拒绝;请等占用方完成后再重试,或先编辑其他文件。", who = who, tool = self.tool_name, elapsed = elapsed ) } } /// 进程级租约表。写类文件工具是唯一的 acquire/release 入口,query_leases 工具只读。 static REGISTRY: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); /// path 归一键:Windows 不区分大小写,Unix 区分;斜杠统一。 fn lease_key(path: &str) -> String { let norm = path.trim().replace('\\', "/"); if cfg!(windows) { norm.to_lowercase() } else { norm } } /// 写类文件工具判定:非只读 ∧ 声明了 path 参数键 = 会改文件的写工具。 /// 覆盖 write_file/patch_file/append_file/delete_file/rename_file(from,to 双键 /// 全部登记,移动目标也锁)。run_command 不在此列——命令副作用面不可静态判定, /// 属 W-a 分组器职责,registry 不越界。 pub(crate) fn is_write_file_tool(tool_name: &str) -> bool { let meta = df_ai::ai_tools::tool_meta(tool_name); match meta { Some(m) => !m.readonly && !m.path_args.is_empty(), None => false, } } /// 从 args 提取全部路径参数(path_arg_keys 键序) fn args_paths(tool_name: &str, args: &serde_json::Value) -> Vec { df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args) } /// 批量尝试获取租约(写类工具执行前置校验)。 /// /// 返回 Err(指路文案) = 任一路径被**其他会话**占用(过期条目顺手清理); /// Ok(LeaseGuard) = 全部路径登记成功,guard drop 时自动释放。 /// 同会话同路径重复 acquire 放行(同会话工具串行,重复写=LLM 连续编辑同一文件的 /// 正常模式;租约只防**跨会话**互踩)。 pub(crate) fn acquire_leases( conv_id: &str, agent_kind: &'static str, tool_name: &str, tool_call_id: &str, args: &serde_json::Value, ) -> Result { // 防御:租约入口自兜底判写类(调用方 execute_with_heartbeat 已前置 is_write_file_tool // 分流,此处双保险——未来新调用点忘判时只读工具不得误登记) if !is_write_file_tool(tool_name) { return Ok(LeaseGuard { released: true, keys: Vec::new() }); } let paths = args_paths(tool_name, args); if paths.is_empty() { return Ok(LeaseGuard { released: true, keys: Vec::new() }); } let now = Instant::now(); let mut reg = REGISTRY.write().expect("文件租约表锁"); // 第一遍:冲突检测(不做部分登记,全过才统一登记) for p in &paths { let key = lease_key(p); if let Some(holder) = reg.get(&key) { if holder.conv_id == conv_id { continue; // 同会话:放行(见函数文档) } if holder.expired(now) { reg.remove(&key); // TTL 兜底:过期租约视为泄漏,清掉 continue; } return Err(holder.describe()); } } // 第二遍:全部登记 let mut keys = Vec::with_capacity(paths.len()); for p in paths { let key = lease_key(&p); reg.insert(key.clone(), LeaseHolder { conv_id: conv_id.to_string(), agent_kind, tool_name: tool_name.to_string(), tool_call_id: tool_call_id.to_string(), acquired_at: now, }); keys.push(key); } Ok(LeaseGuard { released: false, keys }) } /// RAII 租约守卫:drop 自动释放(Ok/Err/panic unwind 全覆盖)。 pub(crate) struct LeaseGuard { released: bool, keys: Vec, } impl std::fmt::Debug for LeaseGuard { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LeaseGuard") .field("released", &self.released) .field("keys", &self.keys) .finish() } } impl LeaseGuard { /// 显式提前释放(当前实现靠 drop 即可,预留显式口供未来作用域内让位场景) #[allow(dead_code)] pub(crate) fn release_now(&mut self) { if self.released { return; } let mut reg = REGISTRY.write().expect("文件租约表锁"); for k in &self.keys { reg.remove(k); } self.released = true; } } impl Drop for LeaseGuard { fn drop(&mut self) { self.release_now(); } } /// 只读查询:当前全部活跃租约(query_leases 工具数据源,W-h2 兄弟感知)。 pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> { let now = Instant::now(); let mut reg = REGISTRY.write().expect("文件租约表锁"); reg.retain(|_, h| !h.expired(now)); // 查询顺手清过期 let mut items: Vec<(String, LeaseHolder)> = reg .iter() .map(|(k, h)| (k.clone(), h.clone())) .collect(); items.sort_by(|a, b| a.0.cmp(&b.0)); items } /// 注册 query_leases 只读工具(W-h2:LLM 自查「谁在占用什么」,兄弟感知最小入口)。 /// Low 风险零参数;返回人话列表供 LLM 直接消费。 pub(crate) fn register_lease_tool(registry: &mut df_ai::ai_tools::AiToolRegistry) { use df_ai::ai_tools::{RiskLevel, ToolMeta, object_schema}; let handler: df_ai::ai_tools::AiToolHandler = Box::new(|_args: serde_json::Value| { Box::pin(async move { let leases = query_leases(); if leases.is_empty() { return Ok(serde_json::json!({ "count": 0, "message": "当前无活跃文件租约(没有其他会话正在写文件)" })); } let items: Vec = leases .iter() .map(|(path, h)| { serde_json::json!({ "path": path, "conv_id": h.conv_id, "agent": h.agent_kind, "tool": h.tool_name, "held_secs": h.acquired_at.elapsed().as_secs(), }) }) .collect(); Ok(serde_json::json!({ "count": items.len(), "leases": items, "message": "以下文件正被其他会话占用,写同一文件前先等对方完成(或换文件避开)" })) }) }); registry.register_meta( "query_leases", "查询当前进程内活跃的文件写租约(哪些文件正被其他会话/子代理占用)。写文件被拒时先调本工具看占用方;规划批量写操作前也可预查避免互踩", object_schema(vec![]), RiskLevel::Low, ToolMeta { readonly: true, display_verb: Some("查询文件占用"), ..Default::default() }, handler, ); } #[cfg(test)] mod tests { use super::*; use serde_json::json; // 单测进程不跑 build_ai_tool_registry,GLOBAL_TOOL_META 表为空 → is_write_file_tool // 查表 None 恒 false。走既有正典播种(Once 全进程一次,对齐 cache/path_auth/helpers // 测试同款),保证元数据表与生产一致。 fn seed() { crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); } const CONV_A: &str = "conv-aaa"; const CONV_B: &str = "conv-bbb"; #[test] fn cross_conv_conflict_rejected_with_pointer() { seed(); let _g1 = acquire_leases(CONV_A, "main", "write_file", "tc-1", &json!({"path": "E:/x/a.rs"})) .expect("首占应成功"); let err = acquire_leases(CONV_B, "main", "patch_file", "tc-2", &json!({"path": "E:\\X\\a.rs"})) .expect_err("跨会话同文件(大小写+反斜杠变体)应被拒"); assert!(err.contains("占用"), "拒绝文案应指路占用方: {err}"); assert!(err.contains("write_file"), "文案应含占用方工具名: {err}"); } #[test] fn same_conv_reacquire_allowed() { seed(); let _g1 = acquire_leases(CONV_A, "main", "write_file", "tc-1", &json!({"path": "/x/a.rs"})) .expect("首占成功"); // 同会话续写同文件:LLM 连续编辑同一文件是正常模式,应放行(guard 覆盖旧条目) let _g2 = acquire_leases(CONV_A, "main", "patch_file", "tc-2", &json!({"path": "/x/a.rs"})) .expect("同会话重复 acquire 应放行"); } #[test] fn release_frees_path_for_others() { { let _g = acquire_leases(CONV_A, "main", "write_file", "tc-1", &json!({"path": "/x/b.rs"})) .expect("首占成功"); } // drop 释放 let _g2 = acquire_leases(CONV_B, "sub", "write_file", "tc-2", &json!({"path": "/x/b.rs"})) .expect("释放后他人可获取"); } #[test] fn non_write_tool_bypasses() { seed(); assert!(!is_write_file_tool("read_file"), "只读工具非写类"); assert!(!is_write_file_tool("list_tasks"), "无 path_args 的工具非文件写类"); assert!(!is_write_file_tool("run_command"), "run_command 不进租约(副作用面不可静态判定)"); assert!(is_write_file_tool("write_file")); assert!(is_write_file_tool("patch_file")); assert!(is_write_file_tool("append_file")); assert!(is_write_file_tool("delete_file")); assert!(is_write_file_tool("rename_file"), "rename 有 from/to path_args"); } #[test] fn non_write_tool_no_lease() { seed(); // 只读工具 acquire 恒成功零副作用(空 guard) let g = acquire_leases(CONV_A, "main", "read_file", "tc-1", &json!({"path": "/x/c.rs"})) .expect("非写类不拦"); assert!(g.keys.is_empty(), "非写类不登记"); } #[test] fn rename_locks_both_from_and_to() { seed(); let _g = acquire_leases(CONV_A, "main", "rename_file", "tc-1", &json!({"from": "/x/old.rs", "to": "/x/new.rs"})) .expect("rename 双路径登记"); let err = acquire_leases(CONV_B, "main", "patch_file", "tc-2", &json!({"path": "/x/NEW.rs"})) .expect_err("他人写移动目标应被拒"); assert!(err.contains("rename_file")); } #[test] fn query_reports_active_leases() { seed(); let _g = acquire_leases(CONV_A, "main", "write_file", "tc-q", &json!({"path": "/x/q.rs"})) .expect("首占成功"); let leases = query_leases(); let hit = leases.iter().find(|(p, _)| p == "/x/q.rs" || p == "/x/q.rs"); assert!(hit.is_some(), "query 应含 /x/q.rs: {:?}", leases.iter().map(|(p, _)| p).collect::>()); } #[test] fn panic_safe_guard_drop_releases() { let guard = acquire_leases(CONV_A, "main", "write_file", "tc-p", &json!({"path": "/x/p.rs"})) .expect("首占成功"); std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { let _g = guard; // move 进闭包后 panic unwind → drop 释放 panic!("simulated unwind"); })).unwrap_err(); let _g2 = acquire_leases(CONV_B, "main", "write_file", "tc-p2", &json!({"path": "/x/p.rs"})) .expect("unwind drop 后应已释放"); } }