Skip to main content

agentmux_srv/backend/
memory_dir_claims.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Is a memory directory this agent's alone?
5//! (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.2, phase M3b.)
6//!
7//! A Claude memory directory is keyed by account + working directory, not by
8//! agent, so two agents can share one — the same account and working
9//! directory, or two unlinked agents with the same working directory. The
10//! memory record may project into, capture from, or delete in a directory
11//! only when it is proven exclusive; a shared one is left exactly as it is.
12//!
13//! A directory is **shared unless proven exclusive**:
14//! 1. **Known shared locations** — a blank working directory (the
15//!    `~/.agentmux/agents/<slug>` default, shared across same-name tabs),
16//!    `$HOME`, or an ancestor of another agent's working directory. And any
17//!    folder the CLI keys by a whole repository rather than this working
18//!    directory alone: a subdirectory or linked worktree of a repository, or
19//!    a main checkout with linked worktrees
20//!    ([`crate::backend::claude_layout::memory_project_root`]).
21//! 2. **Vetoes** — any other agent whose directory resolves, by any route,
22//!    to the same canonical directory. Guesses count here (a veto only ever
23//!    removes a directory; the resolver never uses one to *find* memory).
24//! 3. **A machine-wide claim** — zone `memory-dir:<hash>` in the global
25//!    store. Every spawn that uses the folder records one, a shared one
26//!    too: channels share `projects/` folders, and an agent in another
27//!    channel is visible here only through its claim. A claim by another
28//!    UID makes the folder shared. A claim is released only by the channel
29//!    store that wrote it, once that store no longer has the agent and no
30//!    active shared definition does — another channel can't tell a live
31//!    agent of its neighbour's from a deleted one.
32
33use 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    /// A blank working directory, `$HOME`, or an ancestor of another
55    /// agent's working directory.
56    KnownSharedLocation,
57    /// The CLI keys this folder by more than the agent's own working
58    /// directory: it is a subdirectory or linked worktree of a repository
59    /// (memory is keyed by the main checkout), or the main checkout of one
60    /// with linked worktrees. Whoever works in the rest of that repository —
61    /// a person, an agent in another channel — shares it unseen.
62    RepositoryWide,
63    /// Another agent resolves to the same directory.
64    Vetoed { other_uid: String },
65    /// Another live agent has claimed the directory.
66    Claimed { other_uid: String },
67    /// The checks didn't finish before the deadline; unproven means shared.
68    Unproven,
69}
70
71/// A directory's canonical form: symlinks resolved as far as the path
72/// exists (the per-channel `projects/` links into the shared store), the
73/// rest kept as given. Two routes to one folder get one id.
74pub(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    /// The folder (its [`dir_id`]), so a claim can be shown to a human —
104    /// the zone name is only its hash.
105    #[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    /// The channel store the claiming agent lives in ([`Store::origin_id`]).
116    #[serde(default)]
117    origin: String,
118}
119
120/// Whether a claim can be shown to be stale: only the store that wrote it
121/// can say its agent is gone.
122fn 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
126/// Decide whether `dir` — the memory directory `uid`'s spawn uses, from
127/// working directory `cwd` — is exclusive to `uid`, recording `uid`'s claim.
128/// Checks that can't finish by `deadline` leave the directory `Unproven`,
129/// i.e. shared.
130pub(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    // Claimed first, whatever the verdict: a spawn using a folder it may
140    // not write still has to be visible to the agents that might.
141    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
167/// Whether `uid` still holds the only claim on `dir` — the re-check before
168/// every capture while the agent runs (spec §2.1.3). Read-only: the full
169/// check ran at spawn; this sees any agent that has claimed it since.
170pub(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
180/// Whether another agent uses `dir`: it has claimed it (in any channel), or
181/// resolves to it in this channel's store. Read-only. For offering a folder
182/// for adoption (spec §2.1.4), which must exclude folders shared with
183/// another agent.
184pub(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/// A folder an agent has claimed, for the human "release this folder"
199/// action (spec §2.1.2).
200#[derive(Debug, Clone, PartialEq)]
201pub(crate) struct ClaimedFolder {
202    /// The claims zone: what a release names.
203    pub zone: String,
204    /// The folder, when the claim recorded it (claims from before it did
205    /// don't).
206    pub dir: Option<String>,
207    pub claimed_at_ms: i64,
208    /// Other agents claiming it too.
209    pub others: usize,
210}
211
212/// Every folder `uid` holds a claim on, on this machine.
213pub(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
234/// Remove `uid`'s claim in `zone` — a human released the folder. Returns
235/// whether there was one. The agent re-claims it at its next spawn there.
236pub(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
252/// Rules 1 and 2: no store writes.
253fn 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    /// Two agents claiming one directory — neither resolvable to it by the
375    /// other's rows, e.g. a race across channels — both see it shared.
376    #[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    /// A claim by an agent that no longer exists anywhere is released, so a
396    /// re-created agent reusing the directory isn't stranded.
397    #[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    /// A claim from another channel's store is never released here, though
419    /// this store has no row for its agent: only its own store can tell.
420    /// And a shared verdict still records the claim.
421    #[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}