Skip to main content

objectiveai_cli/command/agents/
locks.rs

1//! Lock coordinates for the per-agent lockfiles.
2//!
3//! Two families, both under the per-state locks root and both held
4//! through [`objectiveai_sdk::lockfile`] (contents are always empty —
5//! these locks carry liveness, not data):
6//!
7//! - **Per-instance**: `<state>/locks/agents/instances/<…>` — the
8//!   `agent_instance_hierarchy` is split on `/`; every segment but
9//!   the last becomes a literal subdirectory, the last segment is the
10//!   lockfile key (the SDK escapes the key; directory segments ride
11//!   raw). A held instance lock ⇔ a live process owns that agent.
12//! - **Per-tag**: `<state>/locks/agents/tags` keyed by the tag name —
13//!   held while a spawn is materializing an un-upgraded (GROUPED)
14//!   tag, released once the spawn claims its minted AIH lock.
15//!
16//! No `create_dir_all` here — the SDK's acquire functions create the
17//! directory chain themselves.
18//!
19//! ## In-process gate
20//!
21//! [`objectiveai_sdk::lockfile`] is a cross-PROCESS mutex that is REENTRANT
22//! in-process (its `HELD` map refcounts per process), so two concurrent
23//! in-process tasks acquiring the SAME agent key both succeed reentrantly
24//! instead of mutually excluding. [`AgentLock`] closes that gap: every agent
25//! lock is taken through [`try_acquire`]/[`wait_acquire`] here, which lock a
26//! per-key in-process [`tokio::sync::Mutex`] (the [`AgentLockMap`] on
27//! [`crate::context::Context`]) FIRST, then the lockfile — and release the
28//! lockfile claim FIRST, then the in-process guard LAST. The in-process lock
29//! both precedes and succeeds the cross-process one.
30
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33
34use dashmap::DashMap;
35use objectiveai_sdk::lockfile::LockClaim;
36use tokio::sync::{Mutex, OwnedMutexGuard};
37
38/// `(lock_dir, key)` for an `agent_instance_hierarchy`.
39pub fn agent_instance_lock(state_dir: &Path, agent_instance_hierarchy: &str) -> (PathBuf, String) {
40    let mut dir = state_dir.join("locks").join("agents").join("instances");
41    let mut segments = agent_instance_hierarchy.split('/').peekable();
42    let mut key = String::new();
43    while let Some(segment) = segments.next() {
44        if segments.peek().is_some() {
45            dir.push(segment);
46        } else {
47            key = segment.to_string();
48        }
49    }
50    (dir, key)
51}
52
53/// `(lock_dir, key)` for an agent tag.
54pub fn agent_tag_lock(state_dir: &Path, agent_tag: &str) -> (PathBuf, String) {
55    (
56        state_dir.join("locks").join("agents").join("tags"),
57        agent_tag.to_string(),
58    )
59}
60
61/// Per-key in-process gate for agent locks, keyed by the SAME `(dir, key)` the
62/// lockfile uses. Lives on [`crate::context::Context`], shared across clones.
63pub type AgentLockMap = DashMap<(PathBuf, String), Arc<Mutex<()>>>;
64
65/// A held agent lock: the in-process [`Mutex`] guard PLUS the cross-process
66/// lockfile [`LockClaim`]. Release order is enforced — lockfile claim first,
67/// in-process guard last (the guard field is dropped after `claim` is taken).
68pub struct AgentLock {
69    /// `None` once the claim has been taken for a child-process transfer.
70    claim: Option<LockClaim>,
71    /// Dropped LAST — frees the per-key in-process [`Mutex`].
72    _guard: OwnedMutexGuard<()>,
73}
74
75impl AgentLock {
76    /// Explicit release: lockfile claim first, then the guard drops at the end
77    /// of scope. (Dropping a [`LockClaim`] does NOT release it — we must call
78    /// `release`; the [`Drop`] impl below covers the implicit path.)
79    pub fn release(mut self) -> std::io::Result<()> {
80        match self.claim.take() {
81            Some(claim) => claim.release(),
82            None => Ok(()),
83        }
84    }
85
86    /// Hand the cross-process claim to a child-process transfer, keeping the
87    /// in-process guard alive in the caller until the transfer completes. After
88    /// this, [`Drop`]/[`release`](Self::release) is a guard-only no-op.
89    pub fn take_claim(&mut self) -> Option<LockClaim> {
90        self.claim.take()
91    }
92}
93
94impl Drop for AgentLock {
95    fn drop(&mut self) {
96        // Scope-bound release (e.g. the registry's drop): claim first, then the
97        // guard drops immediately after.
98        if let Some(claim) = self.claim.take() {
99            let _ = claim.release();
100        }
101    }
102}
103
104/// Get-or-insert the per-key in-process mutex, returning a cloned `Arc`. The
105/// `DashMap` entry guard is dropped before returning — NEVER held across the
106/// `.await` in the acquire fns.
107fn mutex_for(map: &AgentLockMap, dir: &Path, key: &str) -> Arc<Mutex<()>> {
108    map.entry((dir.to_path_buf(), key.to_string()))
109        .or_insert_with(|| Arc::new(Mutex::new(())))
110        .clone()
111}
112
113/// Acquire an agent lock NON-BLOCKING. Returns `None` if the key is busy
114/// in-process (another task holds the guard) OR held cross-process. On the
115/// cross-process miss the in-process guard is dropped before returning, so it
116/// never lingers.
117pub async fn try_acquire(map: &AgentLockMap, dir: &Path, key: &str) -> Option<AgentLock> {
118    let guard = mutex_for(map, dir, key).try_lock_owned().ok()?;
119    match objectiveai_sdk::lockfile::try_acquire(dir, key, "").await {
120        Some(claim) => Some(AgentLock {
121            claim: Some(claim),
122            _guard: guard,
123        }),
124        None => None,
125    }
126}
127
128/// Acquire an agent lock, BLOCKING at both layers: the in-process [`Mutex`]
129/// first, then the cross-process lockfile.
130pub async fn wait_acquire(
131    map: &AgentLockMap,
132    dir: &Path,
133    key: &str,
134) -> std::io::Result<AgentLock> {
135    let guard = mutex_for(map, dir, key).lock_owned().await;
136    let claim = objectiveai_sdk::lockfile::wait_acquire(dir, key, "").await?;
137    Ok(AgentLock {
138        claim: Some(claim),
139        _guard: guard,
140    })
141}
142
143/// The lock "family" of an agent target — the set of locks a live agent must
144/// hold so that NONE of its tags can be relocated (`tags apply`) or have labs
145/// detached (`laboratories detach`) while it is active.
146#[derive(Clone)]
147pub enum Family {
148    /// A GROUPED tag: every tag in the group (they upgrade together).
149    Group(i64),
150    /// A bound tag or an AIH: the AIH lock + every tag bound to that AIH.
151    Hierarchy(String),
152}
153
154/// An acquired lock [`Family`], partitioned for the registry.
155pub struct AcquiredFamily {
156    /// `(hierarchy, lock)` for the AIH lock — `None` for a GROUPED family.
157    pub aih: Option<(String, AgentLock)>,
158    /// The tag-lock family (group members, or the AIH's bound tags).
159    pub tags: Vec<AgentLock>,
160}
161
162impl AcquiredFamily {
163    /// Flatten to the raw lock list (AIH lock first, then tags) — for callers
164    /// that transfer or hold the whole family rather than partitioning it into a
165    /// registry (e.g. `agents message`, which transfers the family to the child).
166    pub fn into_locks(self) -> Vec<AgentLock> {
167        let mut locks = Vec::with_capacity(self.tags.len() + 1);
168        if let Some((_, aih)) = self.aih {
169            locks.push(aih);
170        }
171        locks.extend(self.tags);
172        locks
173    }
174}
175
176/// Resolve + acquire a whole [`Family`] NON-BLOCKING, all-or-nothing, and
177/// partition it for the registry (AIH lock split out from the tag locks).
178/// `Ok(None)` if any member is busy. Used by spawn / deliver, which resolve the
179/// family up front and acquire it in one shot.
180pub async fn try_acquire_family(
181    map: &AgentLockMap,
182    pool: &crate::db::Pool,
183    state_dir: &Path,
184    family: Family,
185) -> Result<Option<AcquiredFamily>, crate::error::Error> {
186    // Keep the AIH string for the partition (family is consumed below).
187    let aih = match &family {
188        Family::Group(_) => None,
189        Family::Hierarchy(h) => Some(h.clone()),
190    };
191    let coords = family_coords(pool, state_dir, family).await?;
192    let Some(mut locks) = try_acquire_all(map, &coords).await else {
193        return Ok(None);
194    };
195    // `family_coords` puts the AIH coord FIRST for a Hierarchy family.
196    Ok(Some(match aih {
197        Some(hierarchy) => AcquiredFamily {
198            aih: Some((hierarchy, locks.remove(0))),
199            tags: locks,
200        },
201        None => AcquiredFamily { aih: None, tags: locks },
202    }))
203}
204
205/// Resolve a [`Family`] to its lock coordinates. `Group` → a tag lock per group
206/// member; `Hierarchy` → the AIH instance lock FIRST, then a tag lock per bound
207/// tag.
208pub async fn family_coords(
209    pool: &crate::db::Pool,
210    state_dir: &Path,
211    family: Family,
212) -> Result<Vec<(PathBuf, String)>, crate::error::Error> {
213    let mut coords = Vec::new();
214    match family {
215        Family::Group(tag_group) => {
216            for tag in crate::db::tags::tags_for_group(pool, tag_group).await? {
217                coords.push(agent_tag_lock(state_dir, &tag));
218            }
219        }
220        Family::Hierarchy(agent_instance_hierarchy) => {
221            coords.push(agent_instance_lock(state_dir, &agent_instance_hierarchy));
222            for tag in crate::db::tags::tags_for_hierarchy(pool, &agent_instance_hierarchy).await? {
223                coords.push(agent_tag_lock(state_dir, &tag));
224            }
225        }
226    }
227    Ok(coords)
228}
229
230/// Acquire EVERY coord NON-BLOCKING, all-or-nothing, concurrently. `None` if any
231/// coord is busy (in-process guard taken OR cross-process held) — the ones that
232/// were acquired drop here, releasing them. Deadlock-free: every leg is a
233/// non-blocking `try`, so acquisition order never matters.
234pub async fn try_acquire_all(
235    map: &AgentLockMap,
236    coords: &[(PathBuf, String)],
237) -> Option<Vec<AgentLock>> {
238    let got = futures::future::join_all(
239        coords.iter().map(|(dir, key)| try_acquire(map, dir, key)),
240    )
241    .await;
242    if got.iter().all(Option::is_some) {
243        Some(got.into_iter().flatten().collect())
244    } else {
245        None
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn agent_instance_lock_splits_hierarchy() {
255        let state = Path::new("state");
256        let (dir, key) = agent_instance_lock(state, "root/child-1/leaf-2");
257        assert_eq!(
258            dir,
259            Path::new("state")
260                .join("locks")
261                .join("agents")
262                .join("instances")
263                .join("root")
264                .join("child-1"),
265        );
266        assert_eq!(key, "leaf-2");
267
268        // Single-segment hierarchy: no subdirectories, whole string
269        // is the key.
270        let (dir, key) = agent_instance_lock(state, "UNKNOWN");
271        assert_eq!(
272            dir,
273            Path::new("state").join("locks").join("agents").join("instances"),
274        );
275        assert_eq!(key, "UNKNOWN");
276    }
277}