1use std::collections::BTreeMap;
34use std::path::Path;
35
36use serde::{Deserialize, Serialize};
37
38use crate::backend::claude_layout as layout;
39use crate::backend::memory_record::sha256_hex;
40use crate::backend::storage::error::StoreError;
41use crate::backend::storage::filestore::FileStore;
42use crate::backend::storage::store::Store;
43
44const CLAIMS_FILE: &str = "claims.json";
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub(crate) enum Exclusivity {
48 Exclusive,
49 Shared(SharedReason),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub(crate) enum SharedReason {
54 KnownSharedLocation,
57 RepositoryWide,
63 Vetoed { other_uid: String },
65 Claimed { other_uid: String },
67 Unproven,
69}
70
71pub(crate) fn dir_id(path: &Path) -> String {
75 let mut existing = path.to_path_buf();
76 let mut rest: Vec<std::ffi::OsString> = Vec::new();
77 loop {
78 if let Ok(c) = existing.canonicalize() {
79 let mut out = c;
80 for part in rest.iter().rev() {
81 out.push(part);
82 }
83 return out.to_string_lossy().into_owned();
84 }
85 match (existing.file_name().map(|n| n.to_os_string()), existing.parent()) {
86 (Some(name), Some(parent)) => {
87 rest.push(name);
88 existing = parent.to_path_buf();
89 }
90 _ => return path.to_string_lossy().into_owned(),
91 }
92 }
93}
94
95fn claims_zone(dir_id: &str) -> String {
96 format!("memory-dir:{}", &sha256_hex(dir_id.as_bytes())[..32])
97}
98
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100struct Claims {
101 #[serde(default)]
102 uids: BTreeMap<String, Claim>,
103 #[serde(default, skip_serializing_if = "String::is_empty")]
106 dir: String,
107 #[serde(flatten)]
108 extra: serde_json::Map<String, serde_json::Value>,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112struct Claim {
113 #[serde(default)]
114 at_ms: i64,
115 #[serde(default)]
117 origin: String,
118}
119
120fn claim_is_stale(mstore: &Store, origin: &str, defs: Option<&crate::registry::DefinitionStore>, uid: &str, c: &Claim) -> bool {
123 c.origin == origin && !matches!(mstore.agent_def_get(uid), Ok(Some(_))) && !defs.is_some_and(|d| d.exists(uid))
124}
125
126pub(crate) fn check_and_claim(
131 fs: &FileStore,
132 mstore: &Store,
133 uid: &str,
134 dir: &Path,
135 cwd: &str,
136 deadline: std::time::Instant,
137) -> Result<Exclusivity, StoreError> {
138 let id = dir_id(dir);
139 let defs = crate::registry::resolve_shared_definitions_dir()
142 .and_then(|dir| crate::registry::DefinitionStore::open(dir).ok());
143 let origin = mstore.origin_id();
144 let other = fs.zone_txn(&claims_zone(&id), |z| {
145 let mut claims: Claims = match z.read(CLAIMS_FILE)? {
146 None => Claims::default(),
147 Some(b) => serde_json::from_slice(&b)
148 .map_err(|e| StoreError::Other(format!("memory dir claims unreadable: {e}")))?,
149 };
150 claims.uids.retain(|claimant, c| claimant == uid || !claim_is_stale(mstore, &origin, defs.as_ref(), claimant, c));
151 claims.dir = id.clone();
152 let mine = claims.uids.entry(uid.to_string()).or_insert_with(|| Claim { at_ms: agentmux_common::time::now_ms(), origin: String::new() });
153 mine.origin = origin.clone();
154 let other = claims.uids.keys().find(|c| c.as_str() != uid).cloned();
155 z.put(CLAIMS_FILE, &serde_json::to_vec(&claims).map_err(|e| StoreError::Other(e.to_string()))?)?;
156 Ok(other)
157 })?;
158 if let Some(reason) = known_shared_or_vetoed(mstore, uid, &id, cwd, deadline) {
159 return Ok(Exclusivity::Shared(reason));
160 }
161 Ok(match other {
162 Some(other_uid) => Exclusivity::Shared(SharedReason::Claimed { other_uid }),
163 None => Exclusivity::Exclusive,
164 })
165}
166
167pub(crate) fn held_exclusively(fs: &FileStore, uid: &str, dir: &Path) -> Result<bool, StoreError> {
171 let zone = claims_zone(&dir_id(dir));
172 let Some(bytes) = fs.read_files_consistent(&zone, &[CLAIMS_FILE])?.pop().flatten() else {
173 return Ok(false);
174 };
175 let claims: Claims =
176 serde_json::from_slice(&bytes).map_err(|e| StoreError::Other(format!("memory dir claims unreadable: {e}")))?;
177 Ok(claims.uids.contains_key(uid) && claims.uids.len() == 1)
178}
179
180pub(crate) fn used_by_another(fs: &FileStore, mstore: &Store, uid: &str, dir: &Path) -> Result<bool, StoreError> {
185 let id = dir_id(dir);
186 if let Some(bytes) = fs.read_files_consistent(&claims_zone(&id), &[CLAIMS_FILE])?.pop().flatten() {
187 let claims: Claims =
188 serde_json::from_slice(&bytes).map_err(|e| StoreError::Other(format!("memory dir claims unreadable: {e}")))?;
189 if claims.uids.keys().any(|c| c != uid) {
190 return Ok(true);
191 }
192 }
193 Ok(mstore.agent_def_list().unwrap_or_default().iter().filter(|a| a.id != uid).any(|other| {
194 crate::server::native_memory_handlers::resolve_memory_dir_by_id(mstore, other).is_some_and(|r| dir_id(&r.path) == id)
195 }))
196}
197
198#[derive(Debug, Clone, PartialEq)]
201pub(crate) struct ClaimedFolder {
202 pub zone: String,
204 pub dir: Option<String>,
207 pub claimed_at_ms: i64,
208 pub others: usize,
210}
211
212pub(crate) fn claims_of(fs: &FileStore, uid: &str) -> Result<Vec<ClaimedFolder>, StoreError> {
214 let mut out = Vec::new();
215 for zone in fs.get_all_zone_ids()? {
216 if !zone.starts_with("memory-dir:") {
217 continue;
218 }
219 let Some(bytes) = fs.read_files_consistent(&zone, &[CLAIMS_FILE])?.pop().flatten() else { continue };
220 let Ok(claims) = serde_json::from_slice::<Claims>(&bytes) else { continue };
221 if let Some(mine) = claims.uids.get(uid) {
222 out.push(ClaimedFolder {
223 zone: zone.clone(),
224 dir: (!claims.dir.is_empty()).then(|| claims.dir.clone()),
225 claimed_at_ms: mine.at_ms,
226 others: claims.uids.len() - 1,
227 });
228 }
229 }
230 out.sort_by(|a, b| a.dir.cmp(&b.dir).then(a.zone.cmp(&b.zone)));
231 Ok(out)
232}
233
234pub(crate) fn release(fs: &FileStore, uid: &str, zone: &str) -> Result<bool, StoreError> {
237 if !zone.starts_with("memory-dir:") {
238 return Ok(false);
239 }
240 fs.zone_txn(zone, |z| {
241 let Some(bytes) = z.read(CLAIMS_FILE)? else { return Ok(false) };
242 let mut claims: Claims =
243 serde_json::from_slice(&bytes).map_err(|e| StoreError::Other(format!("memory dir claims unreadable: {e}")))?;
244 if claims.uids.remove(uid).is_none() {
245 return Ok(false);
246 }
247 z.put(CLAIMS_FILE, &serde_json::to_vec(&claims).map_err(|e| StoreError::Other(e.to_string()))?)?;
248 Ok(true)
249 })
250}
251
252fn known_shared_or_vetoed(
254 mstore: &Store,
255 uid: &str,
256 dir_id: &str,
257 cwd: &str,
258 deadline: std::time::Instant,
259) -> Option<SharedReason> {
260 let own = mstore.agent_def_get(uid).ok().flatten();
261 if own.as_ref().is_none_or(|a| a.working_directory.trim().is_empty()) {
262 return Some(SharedReason::KnownSharedLocation);
263 }
264 let cwd = crate::backend::base::expand_home_dir_safe(cwd);
265 let root = layout::memory_project_root(&cwd);
266 if root != layout::physical_dir(&cwd) || layout::has_linked_worktrees(&root) {
267 return Some(SharedReason::RepositoryWide);
268 }
269 if dirs::home_dir().is_some_and(|h| h == cwd || layout::physical_dir(&h) == root) {
270 return Some(SharedReason::KnownSharedLocation);
271 }
272 let agents = mstore.agent_def_list().unwrap_or_default();
273 for other in agents.iter().filter(|a| a.id != uid) {
274 if std::time::Instant::now() > deadline {
275 return Some(SharedReason::Unproven);
276 }
277 if !other.working_directory.trim().is_empty() {
278 let other_cwd = crate::backend::base::expand_home_dir_safe(&other.working_directory);
279 if other_cwd != cwd && other_cwd.starts_with(&cwd) {
280 return Some(SharedReason::KnownSharedLocation);
281 }
282 let other_physical = layout::physical_dir(&other_cwd);
283 if other_physical != root && other_physical.starts_with(&root) {
284 return Some(SharedReason::KnownSharedLocation);
285 }
286 }
287 let resolved = crate::server::native_memory_handlers::resolve_memory_dir_by_id(mstore, other);
288 if resolved.is_some_and(|r| self::dir_id(&r.path) == dir_id) {
289 return Some(SharedReason::Vetoed { other_uid: other.id.clone() });
290 }
291 }
292 None
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::backend::storage::AgentDefinition;
299 use std::path::PathBuf;
300
301 fn agent(store: &Store, id: &str, wd: &str) -> AgentDefinition {
302 let mut def = crate::backend::storage::agents::test_agent_def(id, id, "claude", "agent", 1, "");
303 def.working_directory = wd.to_string();
304 store.agent_def_insert(&mut def).unwrap();
305 def
306 }
307
308 fn check_and_claim_now(fs: &FileStore, store: &Store, uid: &str, dir: &Path, cwd: &str) -> Result<Exclusivity, StoreError> {
309 check_and_claim(fs, store, uid, dir, cwd, std::time::Instant::now() + std::time::Duration::from_secs(30))
310 }
311
312 #[test]
313 fn checks_past_the_deadline_leave_the_directory_unproven() {
314 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
315 let fs = FileStore::open_in_memory().unwrap();
316 let store = Store::open_in_memory().unwrap();
317 agent(&store, "agent-a", "/work/a");
318 agent(&store, "agent-b", "/work/b");
319 let dir = tempfile::tempdir().unwrap();
320 let past = std::time::Instant::now() - std::time::Duration::from_secs(1);
321 assert_eq!(
322 check_and_claim(&fs, &store, "agent-a", dir.path(), "/work/a", past).unwrap(),
323 Exclusivity::Shared(SharedReason::Unproven)
324 );
325 }
326
327 fn memory_dir(store: &Store, def: &AgentDefinition) -> PathBuf {
328 crate::server::native_memory_handlers::memory_dir_for_agent_by_id(store, def).unwrap()
329 }
330
331 #[test]
332 fn an_agent_alone_in_its_directory_is_exclusive_and_claims_it() {
333 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
334 let fs = FileStore::open_in_memory().unwrap();
335 let store = Store::open_in_memory().unwrap();
336 let a = agent(&store, "agent-a", "/work/a");
337 agent(&store, "agent-b", "/work/b");
338 let dir = memory_dir(&store, &a);
339 assert_eq!(check_and_claim_now(&fs, &store, "agent-a", &dir, "/work/a").unwrap(), Exclusivity::Exclusive);
340 assert_eq!(check_and_claim_now(&fs, &store, "agent-a", &dir, "/work/a").unwrap(), Exclusivity::Exclusive, "re-claiming is idempotent");
341 }
342
343 #[test]
344 fn another_agent_resolving_to_the_same_directory_vetoes_it() {
345 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
346 let fs = FileStore::open_in_memory().unwrap();
347 let store = Store::open_in_memory().unwrap();
348 let a = agent(&store, "agent-a", "/work/shared");
349 agent(&store, "agent-b", "/work/shared");
350 let dir = memory_dir(&store, &a);
351 assert_eq!(
352 check_and_claim_now(&fs, &store, "agent-a", &dir, "/work/shared").unwrap(),
353 Exclusivity::Shared(SharedReason::Vetoed { other_uid: "agent-b".into() })
354 );
355 }
356
357 #[test]
358 fn blank_home_and_ancestor_directories_are_known_shared() {
359 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
360 let fs = FileStore::open_in_memory().unwrap();
361 let store = Store::open_in_memory().unwrap();
362 agent(&store, "blank", "");
363 agent(&store, "parent", "/work/repo");
364 agent(&store, "child", "/work/repo/sub");
365 let tmp = tempfile::tempdir().unwrap();
366 let shared = Exclusivity::Shared(SharedReason::KnownSharedLocation);
367 assert_eq!(check_and_claim_now(&fs, &store, "blank", tmp.path(), "~/.agentmux/agents/blank").unwrap(), shared);
368 assert_eq!(check_and_claim_now(&fs, &store, "parent", tmp.path(), "/work/repo").unwrap(), shared);
369 let home = dirs::home_dir().unwrap();
370 agent(&store, "homey", &home.to_string_lossy());
371 assert_eq!(check_and_claim_now(&fs, &store, "homey", tmp.path(), &home.to_string_lossy()).unwrap(), shared);
372 }
373
374 #[test]
377 fn a_second_live_claim_makes_it_shared_for_both() {
378 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
379 let fs = FileStore::open_in_memory().unwrap();
380 let store = Store::open_in_memory().unwrap();
381 agent(&store, "agent-a", "/work/a");
382 agent(&store, "agent-b", "/work/b");
383 let dir = tempfile::tempdir().unwrap();
384 assert_eq!(check_and_claim_now(&fs, &store, "agent-a", dir.path(), "/work/a").unwrap(), Exclusivity::Exclusive);
385 assert_eq!(
386 check_and_claim_now(&fs, &store, "agent-b", dir.path(), "/work/b").unwrap(),
387 Exclusivity::Shared(SharedReason::Claimed { other_uid: "agent-a".into() })
388 );
389 assert_eq!(
390 check_and_claim_now(&fs, &store, "agent-a", dir.path(), "/work/a").unwrap(),
391 Exclusivity::Shared(SharedReason::Claimed { other_uid: "agent-b".into() })
392 );
393 }
394
395 #[test]
398 fn a_deleted_agents_claim_is_released() {
399 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
400 let prev = std::env::var_os("AGENTMUX_SHARED_DIR");
401 let shared = tempfile::tempdir().unwrap();
402 std::env::set_var("AGENTMUX_SHARED_DIR", shared.path());
403 let fs = FileStore::open_in_memory().unwrap();
404 let store = Store::open_in_memory().unwrap();
405 agent(&store, "old", "/work/old");
406 agent(&store, "new", "/work/new");
407 let dir = tempfile::tempdir().unwrap();
408 check_and_claim_now(&fs, &store, "old", dir.path(), "/work/old").unwrap();
409 store.agent_def_delete("old").unwrap();
410 let got = check_and_claim_now(&fs, &store, "new", dir.path(), "/work/new").unwrap();
411 match prev {
412 Some(v) => std::env::set_var("AGENTMUX_SHARED_DIR", v),
413 None => std::env::remove_var("AGENTMUX_SHARED_DIR"),
414 }
415 assert_eq!(got, Exclusivity::Exclusive);
416 }
417
418 #[test]
422 fn another_channels_claim_is_kept_and_a_shared_spawn_still_claims() {
423 let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
424 let prev = std::env::var_os("AGENTMUX_SHARED_DIR");
425 let shared = tempfile::tempdir().unwrap();
426 std::env::set_var("AGENTMUX_SHARED_DIR", shared.path());
427 let fs = FileStore::open_in_memory().unwrap();
428 let (ch1, ch2) = (Store::open_in_memory().unwrap(), Store::open_in_memory().unwrap());
429 agent(&ch1, "agent-a", "/work/a");
430 agent(&ch2, "agent-b", "");
431 let dir = tempfile::tempdir().unwrap();
432 let b = check_and_claim_now(&fs, &ch2, "agent-b", dir.path(), "~/.agentmux/agents/agent-b").unwrap();
433 let a = check_and_claim_now(&fs, &ch1, "agent-a", dir.path(), "/work/a").unwrap();
434 match prev {
435 Some(v) => std::env::set_var("AGENTMUX_SHARED_DIR", v),
436 None => std::env::remove_var("AGENTMUX_SHARED_DIR"),
437 }
438 assert_eq!(b, Exclusivity::Shared(SharedReason::KnownSharedLocation));
439 assert_eq!(a, Exclusivity::Shared(SharedReason::Claimed { other_uid: "agent-b".into() }));
440 }
441
442 #[test]
443 fn dir_id_resolves_symlinks_even_for_a_folder_not_created_yet() {
444 let tmp = tempfile::tempdir().unwrap();
445 let real = tmp.path().join("shared-projects");
446 std::fs::create_dir_all(&real).unwrap();
447 #[cfg(unix)]
448 let link = tmp.path().join("channel-projects");
449 #[cfg(unix)]
450 std::os::unix::fs::symlink(&real, &link).unwrap();
451 #[cfg(unix)]
452 assert_eq!(dir_id(&link.join("x").join("memory")), dir_id(&real.join("x").join("memory")));
453 }
454}