Skip to main content

agentmux_srv/backend/
memory_release.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The human "release this folder" action
5//! (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.2, phase M3c).
6//!
7//! An agent's claim on a memory folder makes that folder shared for every
8//! other agent, so nothing else's memory is written or captured there. A
9//! claim is released on its own when the agent is deleted — but not when
10//! it merely moves on (a new account, working directory or channel), and a
11//! stale claim then keeps the folder shared for good. A human can release
12//! it.
13//!
14//! Releasing can make the folder exclusive to another agent, whose next
15//! spawn then writes and deletes in it, so it is confirmed the same way as
16//! adoption: [`list`] issues the agent's claims under a `list_id`; the
17//! release itself ([`release`]) is reached only through the host's
18//! confirmation window (`memoryadopt.Release`, host secret), naming
19//! `(list_id, index)`. If the agent still uses the folder, its next spawn
20//! there simply claims it again.
21
22use std::collections::HashMap;
23use std::sync::Mutex;
24use std::time::{Duration, Instant};
25
26use serde::Serialize;
27
28use crate::backend::memory_dir_claims as claims;
29use crate::backend::storage::error::StoreError;
30use crate::backend::storage::filestore::FileStore;
31
32const LIST_TTL: Duration = Duration::from_secs(30 * 60);
33
34#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
35#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryClaimedFolder")]
36pub(crate) struct ClaimedFolderView {
37    pub index: usize,
38    /// The folder, when the claim recorded it.
39    pub dir: Option<String>,
40    #[ts(type = "number")]
41    pub claimed_at_ms: i64,
42    /// Other agents claiming it too (it is shared while they do).
43    pub others: usize,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
47#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryClaimList")]
48pub(crate) struct ClaimList {
49    pub list_id: String,
50    pub agent_uid: String,
51    pub folders: Vec<ClaimedFolderView>,
52}
53
54#[derive(Debug, PartialEq)]
55pub(crate) enum ReleaseError {
56    UnknownList,
57    BadChoice,
58    Store(String),
59}
60
61impl From<StoreError> for ReleaseError {
62    fn from(e: StoreError) -> Self {
63        ReleaseError::Store(e.to_string())
64    }
65}
66
67struct Issued {
68    agent_uid: String,
69    zones: Vec<String>,
70    at: Instant,
71}
72
73fn issued() -> &'static Mutex<HashMap<String, Issued>> {
74    static ISSUED: std::sync::OnceLock<Mutex<HashMap<String, Issued>>> = std::sync::OnceLock::new();
75    ISSUED.get_or_init(|| Mutex::new(HashMap::new()))
76}
77
78/// The folders `agent_uid` has claimed, under a new `list_id`.
79pub(crate) fn list(fs: &FileStore, agent_uid: &str) -> Result<ClaimList, StoreError> {
80    let found = claims::claims_of(fs, agent_uid)?;
81    let folders = found
82        .iter()
83        .enumerate()
84        .map(|(index, c)| ClaimedFolderView { index, dir: c.dir.clone(), claimed_at_ms: c.claimed_at_ms, others: c.others })
85        .collect();
86    let list_id = uuid::Uuid::new_v4().simple().to_string();
87    let mut map = issued().lock().unwrap_or_else(|e| e.into_inner());
88    map.retain(|_, i| i.at.elapsed() < LIST_TTL);
89    map.insert(
90        list_id.clone(),
91        Issued { agent_uid: agent_uid.to_string(), zones: found.into_iter().map(|c| c.zone).collect(), at: Instant::now() },
92    );
93    Ok(ClaimList { list_id, agent_uid: agent_uid.to_string(), folders })
94}
95
96/// Release `agent_uid`'s claim on folder `index` of list `list_id`. Returns
97/// whether a claim was removed (it may already be gone).
98pub(crate) fn release(fs: &FileStore, agent_uid: &str, list_id: &str, index: usize) -> Result<bool, ReleaseError> {
99    let zone = {
100        let map = issued().lock().unwrap_or_else(|e| e.into_inner());
101        let list = map
102            .get(list_id)
103            .filter(|l| l.agent_uid == agent_uid && l.at.elapsed() < LIST_TTL)
104            .ok_or(ReleaseError::UnknownList)?;
105        list.zones.get(index).cloned().ok_or(ReleaseError::BadChoice)?
106    };
107    Ok(claims::release(fs, agent_uid, &zone)?)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::backend::storage::store::Store;
114
115    /// Agents need rows: a claim by an agent this store has no row for is
116    /// released as stale by the next claim.
117    fn claim(fs: &FileStore, store: &Store, uid: &str, dir: &std::path::Path) {
118        if store.agent_def_get(uid).ok().flatten().is_none() {
119            let mut def = crate::backend::storage::agents::test_agent_def(uid, uid, "claude", "agent", 1, "");
120            def.working_directory = format!("/work/{uid}");
121            store.agent_def_insert(&mut def).unwrap();
122        }
123        claims::check_and_claim(fs, store, uid, dir, "/work/x", Instant::now() + Duration::from_secs(10)).unwrap();
124    }
125
126    #[test]
127    fn an_agents_claims_are_listed_with_their_folders_and_released_by_list_index() {
128        let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
129        let fs = FileStore::open_in_memory().unwrap();
130        let store = Store::open_in_memory().unwrap();
131        let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
132        claim(&fs, &store, "agent-rel", a.path());
133        claim(&fs, &store, "agent-rel", b.path());
134        claim(&fs, &store, "agent-other", b.path());
135        let list = list(&fs, "agent-rel").unwrap();
136        assert_eq!(list.folders.len(), 2);
137        let b_dir = claims::dir_id(b.path());
138        let b_entry = list.folders.iter().find(|f| f.dir.as_deref() == Some(b_dir.as_str())).unwrap();
139        assert_eq!(b_entry.others, 1);
140
141        assert_eq!(release(&fs, "agent-rel", &list.list_id, b_entry.index), Ok(true));
142        let after = claims::claims_of(&fs, "agent-rel").unwrap();
143        assert_eq!(after.len(), 1);
144        assert_ne!(after[0].dir.as_deref(), Some(b_dir.as_str()));
145        // The other agent's claim on it is untouched: now it alone holds it.
146        assert!(claims::held_exclusively(&fs, "agent-other", b.path()).unwrap());
147    }
148
149    #[test]
150    fn only_the_agents_own_list_releases_its_claims() {
151        let _g = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
152        let fs = FileStore::open_in_memory().unwrap();
153        let store = Store::open_in_memory().unwrap();
154        let a = tempfile::tempdir().unwrap();
155        claim(&fs, &store, "agent-rel", a.path());
156        let list = list(&fs, "agent-rel").unwrap();
157        assert_eq!(release(&fs, "agent-else", &list.list_id, 0), Err(ReleaseError::UnknownList));
158        assert_eq!(release(&fs, "agent-rel", "no-such-list", 0), Err(ReleaseError::UnknownList));
159        assert_eq!(release(&fs, "agent-rel", &list.list_id, 9), Err(ReleaseError::BadChoice));
160        assert_eq!(claims::claims_of(&fs, "agent-rel").unwrap().len(), 1);
161    }
162}