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}