Files
DevFlow/src-tauri/src/commands/ai/lease.rs
T

346 lines
14 KiB
Rust

//! 文件租约注册表(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<RwLock<HashMap<String, LeaseHolder>>> =
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<String> {
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<LeaseGuard, String> {
// 防御:租约入口自兜底判写类(调用方 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<String>,
}
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<serde_json::Value> = 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::<Vec<_>>());
}
#[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 后应已释放");
}
}