//! 命令执行互斥锁 — 防止多 Agent 并行时同目录的同名命令资源竞争 //! //! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.3 //! //! 场景:多 Agent 并行时,两个 SubTask 同时跑 `npm install` 争抢 node_modules。 //! 方案:按 (working_dir, command_name) 加 mutex,同 key 串行化。 use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::Semaphore; /// 全局命令锁注册表 /// key = "{working_dir}::{command_first_word}" /// 每个 key 一个 Semaphore(1),同 key 并发请求串行化 #[allow(dead_code)] #[derive(Debug, Clone)] pub struct CommandLockRegistry { inner: Arc>>>, } impl CommandLockRegistry { #[allow(dead_code)] pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(HashMap::new())), } } /// 获取(或创建)指定 key 的信号量。 /// 返回 Arc,调用方 await acquire 后执行命令,drop permit 后释放。 #[allow(dead_code)] pub fn get_or_create(&self, working_dir: &str, command: &str) -> Arc { let cmd_first = command.split_whitespace().next().unwrap_or(command); let key = format!("{}::{}", working_dir, cmd_first); let mut guard = self.inner.lock().unwrap(); guard .entry(key) .or_insert_with(|| Arc::new(Semaphore::new(1))) .clone() } /// 当前注册的锁数量(调试/监控用) #[allow(dead_code)] pub fn lock_count(&self) -> usize { self.inner.lock().unwrap().len() } } impl Default for CommandLockRegistry { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn lck_01_same_dir_same_cmd_serialized() { let registry = CommandLockRegistry::new(); let sem1 = registry.get_or_create("/project", "npm install"); let sem2 = registry.get_or_create("/project", "npm install"); // 同 key 应返回同一个 Semaphore assert!(Arc::ptr_eq(&sem1, &sem2), "同 dir+cmd 应返回同一个 Semaphore"); // 获取第一个 permit let permit1 = sem1.acquire().await.unwrap(); // 第二个应等待(非阻塞测试:try_acquire 应失败) let try_result = sem2.try_acquire(); assert!(try_result.is_err(), "同 key 第二个应被阻塞"); drop(permit1); // 释放后第二个应能获取 let permit2 = sem2.acquire().await; assert!(permit2.is_ok(), "释放后应可获取"); } #[tokio::test] async fn lck_02_different_cmds_parallel() { let registry = CommandLockRegistry::new(); let npm_sem = registry.get_or_create("/project", "npm install"); let cargo_sem = registry.get_or_create("/project", "cargo build"); // 不同命令应是不同的 Semaphore assert!(!Arc::ptr_eq(&npm_sem, &cargo_sem), "不同 cmd 应是不同 Semaphore"); // 两个都能同时获取 let p1 = npm_sem.acquire().await.unwrap(); let p2 = cargo_sem.acquire().await.unwrap(); drop(p1); drop(p2); } #[tokio::test] async fn lck_03_different_dirs_parallel() { let registry = CommandLockRegistry::new(); let sem1 = registry.get_or_create("/project-a", "npm install"); let sem2 = registry.get_or_create("/project-b", "npm install"); // 不同目录应是不同的 Semaphore assert!(!Arc::ptr_eq(&sem1, &sem2), "不同 dir 应是不同 Semaphore"); // 两个都能同时获取 let p1 = sem1.acquire().await.unwrap(); let p2 = sem2.acquire().await.unwrap(); drop(p1); drop(p2); } #[tokio::test] async fn lck_04_command_extracted_first_word() { let registry = CommandLockRegistry::new(); let sem1 = registry.get_or_create("/proj", "npm install --force"); let sem2 = registry.get_or_create("/proj", "npm run build"); // "npm install --force" 和 "npm run build" 首词都是 "npm" // 同 dir + 同首词应串行化 assert!(Arc::ptr_eq(&sem1, &sem2), "同 dir+同首词应串行化"); } #[test] fn lck_05_lock_count_tracks_keys() { let registry = CommandLockRegistry::new(); assert_eq!(registry.lock_count(), 0); registry.get_or_create("/a", "npm"); assert_eq!(registry.lock_count(), 1); registry.get_or_create("/a", "cargo"); assert_eq!(registry.lock_count(), 2); registry.get_or_create("/a", "npm"); // 已存在 assert_eq!(registry.lock_count(), 2); } }