346 lines
14 KiB
Rust
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 后应已释放");
|
|
}
|
|
}
|