Skip to main content

lean_ctx/core/
agent_lease.rs

1//! Bounded, payload-free ownership leases for multi-agent mutation planning.
2//!
3//! Gives callers an idempotent path/symbol ownership primitive. Does not perform
4//! a mutation, resolve a path, or authorize an agent; policy and transport bind
5//! it later. Uses caller-provided time for deterministic expiry boundaries.
6//!
7//! `acquire_shared`/`release_shared` persist the registry in
8//! `<data_dir>/agents/leases.json` under an exclusive file lock, so every
9//! lean-ctx process on the machine (each agent's MCP server, the daemon, the
10//! CLI) sees the same holders (#1913).
11//!
12//! Path and symbol resources are bound to one project store: the Work Graph
13//! and `ctx_agent` both name a path as `project-path:<project>/<relative>`, so
14//! the same relative path in two projects never collides and a relative and
15//! an absolute spelling of one file always meet. The unbound legacy `path:`
16//! namespace (stores written before) is a separate namespace and never
17//! compared with it; such leases simply expire.
18//!
19//! Leases are cooperative: the edit tools do not consult them. They exclude
20//! agents that acquire before they write; an agent that never asks is not
21//! stopped by them.
22
23use std::collections::BTreeMap;
24use std::fmt::Write as _;
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29pub const AGENT_LEASE_SCHEMA_VERSION: u16 = 1;
30const DEFAULT_MAX_LEASES: usize = 1_024;
31const MAX_LEASE_DURATION_MS: u64 = 60 * 60 * 1_000;
32const MAX_RESOURCE_REF_BYTES: usize = 256;
33/// Path namespaces whose references nest by `/`: the project-bound one and
34/// the unbound legacy one.
35const PROJECT_PATH_SCHEME: &str = "project-path:";
36const HIERARCHICAL_PATH_SCHEMES: [&str; 2] = [PROJECT_PATH_SCHEME, "path:"];
37
38#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum AgentLeaseResourceKindV1 {
41    Path,
42    Symbol,
43    /// Fences one claimed local Work Graph node during external execution.
44    WorkGraphNode,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct AgentLeaseRequestV1 {
50    pub schema_version: u16,
51    pub lease_request_ref: String,
52    pub resource_kind: AgentLeaseResourceKindV1,
53    pub resource_ref: String,
54    pub owner_agent_id: String,
55    pub duration_ms: u64,
56}
57
58impl AgentLeaseRequestV1 {
59    pub fn validate(&self) -> Result<(), AgentLeaseError> {
60        if self.schema_version != AGENT_LEASE_SCHEMA_VERSION {
61            return Err(AgentLeaseError::UnsupportedVersion(self.schema_version));
62        }
63        opaque_ref("lease_request_ref", &self.lease_request_ref)?;
64        opaque_ref("resource_ref", &self.resource_ref)?;
65        agent_id(&self.owner_agent_id)?;
66        if self.duration_ms == 0 || self.duration_ms > MAX_LEASE_DURATION_MS {
67            return Err(AgentLeaseError::Invalid(format!(
68                "duration_ms must be between 1 and {MAX_LEASE_DURATION_MS}"
69            )));
70        }
71        Ok(())
72    }
73}
74
75#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct AgentLeaseV1 {
78    pub schema_version: u16,
79    pub lease_ref: String,
80    pub request: AgentLeaseRequestV1,
81    pub expires_at_epoch_ms: u64,
82}
83
84impl AgentLeaseV1 {
85    pub fn is_active_at(&self, now_epoch_ms: u64) -> bool {
86        now_epoch_ms < self.expires_at_epoch_ms
87    }
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub enum AgentLeaseAcquireV1 {
92    Granted(AgentLeaseV1),
93    HeldBy {
94        owner_agent_id: String,
95        lease_ref: String,
96        expires_at_epoch_ms: u64,
97    },
98}
99
100/// A waiter keeps its place while it asks again within this time.
101const WAITER_TTL_MS: u64 = 2 * 60 * 1_000;
102/// While another agent waits, a holder may renew only until it has held the
103/// lease this long since its grant; the lease then runs out (at most
104/// `MAX_LEASE_DURATION_MS` later) and goes to the oldest waiter.
105const MAX_CONTENDED_HOLD_MS: u64 = 60 * 60 * 1_000;
106/// `lease_ref` a reservation is reported under (`HeldBy`): it fences nothing.
107const RESERVED_LEASE_REF: &str = "reserved-for-waiter";
108
109/// Fairness state (O04), kept beside the lease store in its own file, so the
110/// lease file keeps its exact schema: processes that predate it read the
111/// lease file as before and simply do not honour reservations.
112#[derive(Clone, Debug, Default, Serialize, Deserialize)]
113struct LeaseFairnessV1 {
114    #[serde(default)]
115    waiters: Vec<LeaseWaiterV1>,
116    /// `lease_ref` → epoch ms of the grant that started the hold.
117    #[serde(default)]
118    held_since: BTreeMap<String, u64>,
119}
120
121#[derive(Clone, Debug, Serialize, Deserialize)]
122struct LeaseWaiterV1 {
123    resource_kind: AgentLeaseResourceKindV1,
124    resource_ref: String,
125    owner_agent_id: String,
126    since_epoch_ms: u64,
127    seen_epoch_ms: u64,
128}
129
130fn resources_overlap(
131    kind: AgentLeaseResourceKindV1,
132    held: &str,
133    other_kind: AgentLeaseResourceKindV1,
134    other: &str,
135) -> bool {
136    kind == other_kind
137        && (held == other
138            || (kind == AgentLeaseResourceKindV1::Path
139                && HIERARCHICAL_PATH_SCHEMES.into_iter().any(|scheme| {
140                    held.starts_with(scheme)
141                        && other.starts_with(scheme)
142                        && crate::core::work_graph::path_claims_overlap(held, other)
143                })))
144}
145
146/// Local registry with caller-provided clock for deterministic tests.
147pub struct AgentLeaseRegistryV1 {
148    leases: BTreeMap<(AgentLeaseResourceKindV1, String), AgentLeaseV1>,
149    max_leases: usize,
150    fairness: LeaseFairnessV1,
151}
152
153impl Default for AgentLeaseRegistryV1 {
154    fn default() -> Self {
155        Self::new(DEFAULT_MAX_LEASES)
156    }
157}
158
159impl AgentLeaseRegistryV1 {
160    #[must_use]
161    pub fn new(max_leases: usize) -> Self {
162        Self {
163            leases: BTreeMap::new(),
164            max_leases: max_leases.max(1),
165            fairness: LeaseFairnessV1::default(),
166        }
167    }
168
169    /// Bounded waiting (O04): every refused request records its agent as a
170    /// waiter for the resource. A free resource goes to the agent that has
171    /// waited longest and is still asking (within `WAITER_TTL_MS`); anyone
172    /// else is told it is held for that waiter. An agent that stops asking
173    /// loses its place after the TTL, so a gone waiter blocks nothing for
174    /// longer than that.
175    pub fn acquire(
176        &mut self,
177        request: AgentLeaseRequestV1,
178        now_epoch_ms: u64,
179    ) -> Result<AgentLeaseAcquireV1, AgentLeaseError> {
180        request.validate()?;
181        self.remove_expired(now_epoch_ms);
182        let acquired = self.acquire_unfair(&request, now_epoch_ms)?;
183        Ok(match acquired {
184            AgentLeaseAcquireV1::Granted(lease) => {
185                let owner = &request.owner_agent_id;
186                self.fairness.waiters.retain(|waiter| {
187                    &waiter.owner_agent_id != owner
188                        || waiter.resource_kind != request.resource_kind
189                        || waiter.resource_ref != request.resource_ref
190                });
191                self.fairness
192                    .held_since
193                    .entry(lease.lease_ref.clone())
194                    .or_insert(now_epoch_ms);
195                AgentLeaseAcquireV1::Granted(lease)
196            }
197            held @ AgentLeaseAcquireV1::HeldBy { .. } => {
198                self.wait(request, now_epoch_ms);
199                held
200            }
201        })
202    }
203
204    fn wait(&mut self, request: AgentLeaseRequestV1, now_epoch_ms: u64) {
205        let waiters = &mut self.fairness.waiters;
206        let known = waiters.iter().position(|waiter| {
207            waiter.owner_agent_id == request.owner_agent_id
208                && waiter.resource_kind == request.resource_kind
209                && waiter.resource_ref == request.resource_ref
210        });
211        match known {
212            Some(index) => waiters[index].seen_epoch_ms = now_epoch_ms,
213            // Bounded like the leases: a full list refuses no one, it only
214            // stops recording new waiters.
215            None if waiters.len() < self.max_leases => waiters.push(LeaseWaiterV1 {
216                resource_kind: request.resource_kind,
217                resource_ref: request.resource_ref,
218                owner_agent_id: request.owner_agent_id,
219                since_epoch_ms: now_epoch_ms,
220                seen_epoch_ms: now_epoch_ms,
221            }),
222            None => {}
223        }
224    }
225
226    /// The longest-waiting other agent whose resource overlaps `request`.
227    fn waiting_before(&self, request: &AgentLeaseRequestV1) -> Option<&LeaseWaiterV1> {
228        let own_since = self
229            .fairness
230            .waiters
231            .iter()
232            .filter(|waiter| {
233                waiter.owner_agent_id == request.owner_agent_id
234                    && waiter.resource_kind == request.resource_kind
235                    && waiter.resource_ref == request.resource_ref
236            })
237            .map(|waiter| waiter.since_epoch_ms)
238            .min()
239            .unwrap_or(u64::MAX);
240        self.fairness
241            .waiters
242            .iter()
243            .filter(|waiter| {
244                waiter.owner_agent_id != request.owner_agent_id
245                    && waiter.since_epoch_ms < own_since
246                    && resources_overlap(
247                        waiter.resource_kind,
248                        &waiter.resource_ref,
249                        request.resource_kind,
250                        &request.resource_ref,
251                    )
252            })
253            .min_by_key(|waiter| waiter.since_epoch_ms)
254    }
255
256    fn acquire_unfair(
257        &mut self,
258        request: &AgentLeaseRequestV1,
259        now_epoch_ms: u64,
260    ) -> Result<AgentLeaseAcquireV1, AgentLeaseError> {
261        let request = request.clone();
262        let key = (request.resource_kind, request.resource_ref.clone());
263        if let Some(existing) = self.leases.get(&key) {
264            if existing.request.owner_agent_id == request.owner_agent_id
265                && existing.request.lease_request_ref == request.lease_request_ref
266            {
267                return Ok(AgentLeaseAcquireV1::Granted(existing.clone()));
268            }
269            return Ok(AgentLeaseAcquireV1::HeldBy {
270                owner_agent_id: existing.request.owner_agent_id.clone(),
271                lease_ref: existing.lease_ref.clone(),
272                expires_at_epoch_ms: existing.expires_at_epoch_ms,
273            });
274        }
275        // Only the path namespaces have hierarchical semantics, each compared
276        // with itself alone; other references remain opaque. Exact replays
277        // were handled above.
278        if request.resource_kind == AgentLeaseResourceKindV1::Path
279            && let Some(scheme) = HIERARCHICAL_PATH_SCHEMES
280                .into_iter()
281                .find(|scheme| request.resource_ref.starts_with(scheme))
282            && let Some(existing) = self.leases.values().find(|lease| {
283                lease.request.resource_kind == AgentLeaseResourceKindV1::Path
284                    && lease.request.resource_ref.starts_with(scheme)
285                    && crate::core::work_graph::path_claims_overlap(
286                        &lease.request.resource_ref,
287                        &request.resource_ref,
288                    )
289            })
290        {
291            return Ok(AgentLeaseAcquireV1::HeldBy {
292                owner_agent_id: existing.request.owner_agent_id.clone(),
293                lease_ref: existing.lease_ref.clone(),
294                expires_at_epoch_ms: existing.expires_at_epoch_ms,
295            });
296        }
297        if let Some(waiter) = self.waiting_before(&request) {
298            return Ok(AgentLeaseAcquireV1::HeldBy {
299                owner_agent_id: waiter.owner_agent_id.clone(),
300                lease_ref: RESERVED_LEASE_REF.to_string(),
301                expires_at_epoch_ms: waiter.seen_epoch_ms.saturating_add(WAITER_TTL_MS),
302            });
303        }
304        if self.leases.len() >= self.max_leases {
305            return Err(AgentLeaseError::CapacityExceeded(self.max_leases));
306        }
307        let expires_at_epoch_ms = now_epoch_ms.saturating_add(request.duration_ms);
308        let lease_ref = compute_lease_ref(&request, expires_at_epoch_ms)?;
309        let lease = AgentLeaseV1 {
310            schema_version: AGENT_LEASE_SCHEMA_VERSION,
311            lease_ref,
312            request,
313            expires_at_epoch_ms,
314        };
315        self.leases.insert(key, lease.clone());
316        Ok(AgentLeaseAcquireV1::Granted(lease))
317    }
318
319    pub fn release(
320        &mut self,
321        resource_kind: AgentLeaseResourceKindV1,
322        resource_ref: &str,
323        owner_agent_id: &str,
324        lease_ref: &str,
325        now_epoch_ms: u64,
326    ) -> Result<bool, AgentLeaseError> {
327        opaque_ref("resource_ref", resource_ref)?;
328        agent_id(owner_agent_id)?;
329        self.remove_expired(now_epoch_ms);
330        let key = (resource_kind, resource_ref.to_string());
331        let Some(existing) = self.leases.get(&key) else {
332            return Ok(false);
333        };
334        if existing.request.owner_agent_id != owner_agent_id || existing.lease_ref != lease_ref {
335            return Err(AgentLeaseError::NotOwner);
336        }
337        self.leases.remove(&key);
338        Ok(true)
339    }
340
341    /// Release every lease of one execution incarnation: its node lease
342    /// (`lease_request_ref == fence`) and its path leases (`<fence>:<n>`).
343    /// Selected by owner and fence, never by resource name.
344    pub fn release_fence(
345        &mut self,
346        owner_agent_id: &str,
347        fence: &str,
348        now_epoch_ms: u64,
349    ) -> Result<usize, AgentLeaseError> {
350        opaque_ref("lease_request_ref", fence)?;
351        agent_id(owner_agent_id)?;
352        self.remove_expired(now_epoch_ms);
353        let path_prefix = format!("{fence}:");
354        let before = self.leases.len();
355        self.leases.retain(|_, lease| {
356            let request = &lease.request;
357            request.owner_agent_id != owner_agent_id
358                || (request.lease_request_ref != fence
359                    && !request.lease_request_ref.starts_with(&path_prefix))
360        });
361        Ok(before - self.leases.len())
362    }
363
364    /// Extend an active lease without changing its fencing token.
365    ///
366    /// Renewals are owner- and token-bound, so a delayed worker cannot extend
367    /// a lease that has already been reclaimed. Keeping the token stable makes
368    /// periodic heartbeats idempotent; a reclaim after expiry always receives
369    /// a new token from `acquire`.
370    pub fn renew(
371        &mut self,
372        resource_kind: AgentLeaseResourceKindV1,
373        resource_ref: &str,
374        owner_agent_id: &str,
375        lease_ref: &str,
376        duration_ms: u64,
377        now_epoch_ms: u64,
378    ) -> Result<AgentLeaseV1, AgentLeaseError> {
379        opaque_ref("resource_ref", resource_ref)?;
380        agent_id(owner_agent_id)?;
381        if duration_ms == 0 || duration_ms > MAX_LEASE_DURATION_MS {
382            return Err(AgentLeaseError::Invalid(format!(
383                "duration_ms must be between 1 and {MAX_LEASE_DURATION_MS}"
384            )));
385        }
386        let key = (resource_kind, resource_ref.to_string());
387        let Some(existing) = self.leases.get(&key) else {
388            return Err(AgentLeaseError::NotFound);
389        };
390        if !existing.is_active_at(now_epoch_ms) {
391            self.leases.remove(&key);
392            return Err(AgentLeaseError::Expired);
393        }
394        if existing.request.owner_agent_id != owner_agent_id || existing.lease_ref != lease_ref {
395            return Err(AgentLeaseError::NotOwner);
396        }
397        self.remove_expired(now_epoch_ms);
398        let held_since = self.fairness.held_since.get(lease_ref).copied();
399        if held_since
400            .is_some_and(|since| now_epoch_ms.saturating_sub(since) >= MAX_CONTENDED_HOLD_MS)
401            && self
402                .waiting_before(&self.leases[&key].request.clone())
403                .is_some()
404        {
405            return Err(AgentLeaseError::Invalid(
406                "another agent is waiting and this lease reached its maximum hold; \
407                 it is not renewed and goes to the waiter when it runs out"
408                    .into(),
409            ));
410        }
411        let existing = self.leases.get_mut(&key).expect("lease checked above");
412        existing.request.duration_ms = duration_ms;
413        existing.expires_at_epoch_ms = now_epoch_ms.saturating_add(duration_ms);
414        Ok(existing.clone())
415    }
416
417    #[must_use]
418    pub fn active_count(&self, now_epoch_ms: u64) -> usize {
419        self.leases
420            .values()
421            .filter(|l| l.is_active_at(now_epoch_ms))
422            .count()
423    }
424
425    fn remove_expired(&mut self, now_epoch_ms: u64) {
426        self.leases.retain(|_, l| l.is_active_at(now_epoch_ms));
427        let held: std::collections::BTreeSet<&str> =
428            self.leases.values().map(|l| l.lease_ref.as_str()).collect();
429        self.fairness
430            .held_since
431            .retain(|lease_ref, _| held.contains(lease_ref.as_str()));
432        self.fairness
433            .waiters
434            .retain(|waiter| now_epoch_ms < waiter.seen_epoch_ms.saturating_add(WAITER_TTL_MS));
435    }
436}
437
438// ─── Machine-Wide Shared Registry ───────────────────────────────────────────
439
440/// On-disk form of the registry. A list rather than a map: the registry key is
441/// a `(kind, ref)` tuple, which JSON objects cannot key on.
442#[derive(Serialize, Deserialize)]
443#[serde(deny_unknown_fields)]
444struct AgentLeaseFileV1 {
445    schema_version: u16,
446    leases: Vec<AgentLeaseV1>,
447}
448
449fn now_epoch_ms() -> u64 {
450    std::time::SystemTime::now()
451        .duration_since(std::time::UNIX_EPOCH)
452        .unwrap_or_default()
453        .as_millis() as u64
454}
455
456fn shared_dir() -> Result<PathBuf, AgentLeaseError> {
457    crate::core::data_dir::lean_ctx_data_dir()
458        .map(|dir| dir.join("agents"))
459        .map_err(AgentLeaseError::Store)
460}
461
462fn load_registry(path: &Path) -> Result<AgentLeaseRegistryV1, AgentLeaseError> {
463    let mut registry = AgentLeaseRegistryV1::default();
464    let content = match std::fs::read_to_string(path) {
465        Ok(content) => content,
466        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(registry),
467        Err(error) => {
468            return Err(AgentLeaseError::Store(format!(
469                "read {}: {error}",
470                path.display()
471            )));
472        }
473    };
474    // A corrupt file fails closed: treating it as empty would hand out
475    // resources another agent still holds.
476    let file: AgentLeaseFileV1 = serde_json::from_str(&content).map_err(|error| {
477        AgentLeaseError::Store(format!(
478            "lease store is corrupt at {}: {error}",
479            path.display()
480        ))
481    })?;
482    if file.schema_version != AGENT_LEASE_SCHEMA_VERSION {
483        return Err(AgentLeaseError::UnsupportedVersion(file.schema_version));
484    }
485    for lease in file.leases {
486        let key = (
487            lease.request.resource_kind,
488            lease.request.resource_ref.clone(),
489        );
490        registry.leases.insert(key, lease);
491    }
492    // Fairness is an ordering among waiters, not an exclusion: a missing or
493    // unreadable file only means no one waits yet.
494    registry.fairness = std::fs::read_to_string(fairness_path(path))
495        .ok()
496        .and_then(|json| serde_json::from_str(&json).ok())
497        .unwrap_or_default();
498    Ok(registry)
499}
500
501fn fairness_path(lease_store: &Path) -> PathBuf {
502    lease_store.with_file_name("lease-waiters.json")
503}
504
505fn save_registry(path: &Path, registry: &AgentLeaseRegistryV1) -> Result<(), AgentLeaseError> {
506    let file = AgentLeaseFileV1 {
507        schema_version: AGENT_LEASE_SCHEMA_VERSION,
508        leases: registry.leases.values().cloned().collect(),
509    };
510    let json = serde_json::to_string_pretty(&file)
511        .map_err(|error| AgentLeaseError::Serialize(error.to_string()))?;
512    crate::config_io::write_atomic(path, &json)
513        .map_err(|error| AgentLeaseError::Store(format!("persist {}: {error}", path.display())))?;
514    let fairness = serde_json::to_string(&registry.fairness)
515        .map_err(|error| AgentLeaseError::Serialize(error.to_string()))?;
516    let fairness_path = fairness_path(path);
517    crate::config_io::write_atomic(&fairness_path, &fairness).map_err(|error| {
518        AgentLeaseError::Store(format!("persist {}: {error}", fairness_path.display()))
519    })
520}
521
522/// Runs `mutate` against the shared registry while holding the store lock, so
523/// a read-check-write from one process can never interleave with another's.
524fn with_shared_registry<T>(
525    mutate: impl FnOnce(&mut AgentLeaseRegistryV1, u64) -> Result<T, AgentLeaseError>,
526) -> Result<T, AgentLeaseError> {
527    let dir = shared_dir()?;
528    std::fs::create_dir_all(&dir).map_err(|error| {
529        AgentLeaseError::Store(format!("create lease directory {}: {error}", dir.display()))
530    })?;
531    let _lock = crate::core::agents::FileLock::acquire(&dir.join("leases.lock"))
532        .map_err(AgentLeaseError::Store)?;
533    let path = dir.join("leases.json");
534    let mut registry = load_registry(&path)?;
535    let result = mutate(&mut registry, now_epoch_ms())?;
536    save_registry(&path, &registry)?;
537    Ok(result)
538}
539
540/// Acquires a lease visible to every lean-ctx process that shares the data dir.
541pub fn acquire_shared(
542    request: AgentLeaseRequestV1,
543) -> Result<AgentLeaseAcquireV1, AgentLeaseError> {
544    with_shared_registry(|registry, now| registry.acquire(request, now))
545}
546
547/// Releases a lease from the machine-wide registry.
548pub fn release_shared(
549    resource_kind: AgentLeaseResourceKindV1,
550    resource_ref: &str,
551    owner_agent_id: &str,
552    lease_ref: &str,
553) -> Result<bool, AgentLeaseError> {
554    with_shared_registry(|registry, now| {
555        registry.release(resource_kind, resource_ref, owner_agent_id, lease_ref, now)
556    })
557}
558
559/// Drops the lease `lease_ref` whoever holds it: an organization veto took
560/// it (Hive bus control, SPEC-ENGINE §3.7.4). Returns the dropped lease,
561/// `None` when no such lease is held.
562pub fn revoke_shared(lease_ref: &str) -> Result<Option<AgentLeaseV1>, AgentLeaseError> {
563    with_shared_registry(|registry, _| {
564        let key = registry
565            .leases
566            .iter()
567            .find(|(_, lease)| lease.lease_ref == lease_ref)
568            .map(|(key, _)| key.clone());
569        Ok(key.and_then(|key| registry.leases.remove(&key)))
570    })
571}
572
573/// Releases every lease one execution incarnation of `owner_agent_id` holds,
574/// under a single store lock; leases of other incarnations stay.
575pub fn release_fence_shared(owner_agent_id: &str, fence: &str) -> Result<usize, AgentLeaseError> {
576    with_shared_registry(|registry, now| registry.release_fence(owner_agent_id, fence, now))
577}
578
579/// Renews an owned lease in the machine-wide registry without rotating its
580/// fencing token. Renewal must use the same store as acquisition, otherwise a
581/// lease held by another process could never be renewed.
582pub fn renew_shared(
583    resource_kind: AgentLeaseResourceKindV1,
584    resource_ref: &str,
585    owner_agent_id: &str,
586    lease_ref: &str,
587    duration_ms: u64,
588) -> Result<AgentLeaseV1, AgentLeaseError> {
589    with_shared_registry(|registry, now| {
590        registry.renew(
591            resource_kind,
592            resource_ref,
593            owner_agent_id,
594            lease_ref,
595            duration_ms,
596            now,
597        )
598    })
599}
600
601// ─── Project-Bound Resources ─────────────────────────────────────────────────
602
603/// Paths name files case-insensitively on the default macOS and Windows file
604/// systems; folding there makes `Src/Lib.rs` and `src/lib.rs` one resource.
605/// On a case-sensitive volume of those systems this only over-excludes.
606const CASE_INSENSITIVE_PATHS: bool = cfg!(any(target_os = "macos", windows));
607
608/// Lease resource of one project-relative path (`src/core`, no `.`/`..`).
609/// Nested paths of one project overlap; paths of different projects never do.
610/// The Work Graph and `ctx_agent` both name their path leases through here.
611pub fn project_path_resource(
612    project_root: &str,
613    relative: &str,
614) -> Result<String, AgentLeaseError> {
615    let mut relative = relative.replace('\\', "/");
616    if CASE_INSENSITIVE_PATHS {
617        relative = relative.to_lowercase();
618    }
619    if relative.is_empty()
620        || relative.starts_with('/')
621        || relative
622            .split('/')
623            .any(|part| part.is_empty() || part == "." || part == "..")
624        || relative.chars().any(char::is_control)
625    {
626        return Err(AgentLeaseError::Invalid(
627            "lease path must be a normalized path inside the project".into(),
628        ));
629    }
630    let scope = crate::core::project_hash::hash_project_root(project_root);
631    let mut resource = format!("{PROJECT_PATH_SCHEME}{scope}/");
632    for byte in relative.bytes() {
633        if byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'-' | b'_') {
634            resource.push(byte as char);
635        } else {
636            let _ = write!(resource, "%{byte:02X}");
637        }
638    }
639    // Keep the bounded reference contract for unusually long paths. The hash
640    // stays project-bound but cannot express nesting.
641    if resource.len() > MAX_RESOURCE_REF_BYTES {
642        resource = format!(
643            "project-pathref:{}",
644            scoped_digest(&scope, relative.as_bytes())
645        );
646    }
647    Ok(resource)
648}
649
650/// Lease resource of a caller-named path: relative to the project root, or
651/// absolute inside it (Windows verbatim prefixes, drive-letter case and
652/// separators normalized). Both spellings of one file name the same resource.
653/// Like a Work Graph path claim, the path may not traverse a symlink or
654/// leave the project, and the project root itself cannot be leased.
655pub fn project_path_target_resource(
656    project_root: &str,
657    target: &str,
658) -> Result<String, AgentLeaseError> {
659    let invalid = |reason: &str| AgentLeaseError::Invalid(reason.into());
660    // Lexical only: resolving the target would follow the very symlinks the
661    // walk below must refuse.
662    let target = crate::core::pathutil::normalize_tool_path_lexical(target);
663    let root = crate::core::pathutil::normalize_tool_path_lexical(project_root);
664    let canonical_root = crate::core::pathutil::safe_canonicalize_or_self(Path::new(project_root));
665    let canonical_root_text =
666        crate::core::pathutil::normalize_tool_path_lexical(&canonical_root.to_string_lossy());
667    let relative = if Path::new(&target).is_absolute() || target.starts_with('/') {
668        [root.as_str(), canonical_root_text.as_str()]
669            .into_iter()
670            .find_map(|root| strip_root(&target, root))
671            .ok_or_else(|| invalid("lease path must be inside the project root"))?
672    } else {
673        target.as_str()
674    };
675    let mut parts = Vec::new();
676    for part in relative.split('/') {
677        match part {
678            "" | "." => {}
679            ".." => return Err(invalid("lease path must not leave the project root")),
680            part => parts.push(part),
681        }
682    }
683    if parts.is_empty() {
684        return Err(invalid(
685            "the project root itself cannot be leased; lease a path inside it",
686        ));
687    }
688    let mut cursor = canonical_root;
689    for part in &parts {
690        cursor.push(part);
691        if std::fs::symlink_metadata(&cursor).is_ok_and(|meta| meta.file_type().is_symlink()) {
692            return Err(invalid("lease path traverses a symlink"));
693        }
694    }
695    project_path_resource(project_root, &parts.join("/"))
696}
697
698/// `target` below `root` (both lexically normalized), compared the way the
699/// platform's default file system compares names.
700fn strip_root<'a>(target: &'a str, root: &str) -> Option<&'a str> {
701    let root = root.trim_end_matches('/');
702    let head = target.get(..root.len())?;
703    let same = if CASE_INSENSITIVE_PATHS {
704        head.eq_ignore_ascii_case(root)
705    } else {
706        head == root
707    };
708    let rest = &target[root.len()..];
709    (same && (rest.is_empty() || rest.starts_with('/'))).then_some(rest)
710}
711
712/// Lease resource of a symbol name within one project.
713pub fn project_symbol_resource(project_root: &str, symbol: &str) -> String {
714    let scope = crate::core::project_hash::hash_project_root(project_root);
715    format!(
716        "project-symbolref:{}",
717        scoped_digest(&scope, symbol.as_bytes())
718    )
719}
720
721fn scoped_digest(scope: &str, value: &[u8]) -> String {
722    let mut hash = blake3::Hasher::new();
723    hash.update(scope.as_bytes());
724    hash.update(b"\0");
725    hash.update(value);
726    hash.finalize().to_hex().to_string()
727}
728
729// ─── Helpers ─────────────────────────────────────────────────────────────────
730
731fn compute_lease_ref(
732    request: &AgentLeaseRequestV1,
733    expires_at_epoch_ms: u64,
734) -> Result<String, AgentLeaseError> {
735    let bytes = serde_json::to_vec(&(request, expires_at_epoch_ms))
736        .map_err(|e| AgentLeaseError::Serialize(e.to_string()))?;
737    Ok(format!("lease:{}", blake3::hash(&bytes).to_hex()))
738}
739
740fn opaque_ref(label: &str, value: &str) -> Result<(), AgentLeaseError> {
741    let (scheme, identifier) = value.split_once(':').ok_or_else(|| {
742        AgentLeaseError::Invalid(format!("{label} must use scheme:identifier form"))
743    })?;
744    let scheme_valid = !scheme.is_empty()
745        && scheme
746            .bytes()
747            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
748    let identifier_valid = !identifier.is_empty()
749        && value.len() <= MAX_RESOURCE_REF_BYTES
750        && identifier.bytes().all(|b| b.is_ascii_graphic());
751    (scheme_valid && identifier_valid)
752        .then_some(())
753        .ok_or_else(|| AgentLeaseError::Invalid(format!("invalid {label}")))
754}
755
756fn agent_id(value: &str) -> Result<(), AgentLeaseError> {
757    (!value.is_empty() && value.len() <= 256 && value.bytes().all(|b| b.is_ascii_graphic()))
758        .then_some(())
759        .ok_or_else(|| AgentLeaseError::Invalid("invalid owner_agent_id".into()))
760}
761
762// ─── Errors ──────────────────────────────────────────────────────────────────
763
764#[derive(Debug, thiserror::Error)]
765pub enum AgentLeaseError {
766    #[error("unsupported schema version {0}")]
767    UnsupportedVersion(u16),
768    #[error("invalid lease: {0}")]
769    Invalid(String),
770    #[error("registry at capacity {0}")]
771    CapacityExceeded(usize),
772    #[error("not owner or mismatched lease_ref")]
773    NotOwner,
774    #[error("lease not found")]
775    NotFound,
776    #[error("lease expired")]
777    Expired,
778    #[error("serialization failed: {0}")]
779    Serialize(String),
780    #[error("lease store: {0}")]
781    Store(String),
782}
783
784// ─── Tests ───────────────────────────────────────────────────────────────────
785
786#[cfg(test)]
787pub mod tests {
788    use super::*;
789
790    fn request(owner: &str, ref_id: &str) -> AgentLeaseRequestV1 {
791        AgentLeaseRequestV1 {
792            schema_version: AGENT_LEASE_SCHEMA_VERSION,
793            lease_request_ref: ref_id.to_string(),
794            resource_kind: AgentLeaseResourceKindV1::Path,
795            resource_ref: "pathref:src-core-main".to_string(),
796            owner_agent_id: owner.to_string(),
797            duration_ms: 100,
798        }
799    }
800
801    #[test]
802    fn idempotent_grant_blocks_foreign_owner() {
803        let mut reg = AgentLeaseRegistryV1::default();
804        let AgentLeaseAcquireV1::Granted(granted) =
805            reg.acquire(request("agent-a", "request:a"), 10).unwrap()
806        else {
807            panic!("expected grant")
808        };
809        assert_eq!(
810            reg.acquire(request("agent-a", "request:a"), 20).unwrap(),
811            AgentLeaseAcquireV1::Granted(granted)
812        );
813        assert!(matches!(
814            reg.acquire(request("agent-b", "request:b"), 20),
815            Ok(AgentLeaseAcquireV1::HeldBy { owner_agent_id, .. }) if owner_agent_id == "agent-a"
816        ));
817    }
818
819    #[test]
820    fn hierarchical_paths_block_both_orders_without_prefix_false_positives() {
821        for (held, incoming) in [
822            ("path:project/src", "path:project/src/main.rs"),
823            ("path:project/src/main.rs", "path:project/src"),
824        ] {
825            let mut reg = AgentLeaseRegistryV1::default();
826            let mut first = request("agent-a", "request:a");
827            first.resource_ref = held.into();
828            let granted = reg.acquire(first.clone(), 1).unwrap();
829            assert_eq!(reg.acquire(first, 2).unwrap(), granted);
830            for owner in ["agent-a", "agent-b"] {
831                let mut competing = request(owner, "request:b");
832                competing.resource_ref = incoming.into();
833                assert!(matches!(
834                    reg.acquire(competing, 2).unwrap(),
835                    AgentLeaseAcquireV1::HeldBy { .. }
836                ));
837            }
838            for independent in ["path:project/src-other", "path:other-project/src"] {
839                let mut next = request("agent-b", "request:c");
840                next.resource_ref = independent.into();
841                assert!(matches!(
842                    reg.acquire(next, 2).unwrap(),
843                    AgentLeaseAcquireV1::Granted(_)
844                ));
845            }
846        }
847    }
848
849    #[test]
850    fn expiry_frees_resource() {
851        let mut reg = AgentLeaseRegistryV1::default();
852        let _ = reg.acquire(request("agent-a", "request:a"), 10).unwrap();
853        assert!(matches!(
854            reg.acquire(request("agent-b", "request:b"), 111),
855            Ok(AgentLeaseAcquireV1::Granted(_))
856        ));
857    }
858
859    #[test]
860    fn release_requires_owner() {
861        let mut reg = AgentLeaseRegistryV1::default();
862        let AgentLeaseAcquireV1::Granted(granted) =
863            reg.acquire(request("agent-a", "request:a"), 10).unwrap()
864        else {
865            panic!("expected grant")
866        };
867        assert!(
868            reg.release(
869                AgentLeaseResourceKindV1::Path,
870                "pathref:src-core-main",
871                "agent-b",
872                &granted.lease_ref,
873                20
874            )
875            .is_err()
876        );
877        assert!(
878            reg.release(
879                AgentLeaseResourceKindV1::Path,
880                "pathref:src-core-main",
881                "agent-a",
882                &granted.lease_ref,
883                20
884            )
885            .unwrap()
886        );
887    }
888
889    #[test]
890    fn incarnation_release_never_frees_a_newer_holder() {
891        let mut reg = AgentLeaseRegistryV1::default();
892        let _ = reg.acquire(request("agent-a", "fence:old"), 10).unwrap();
893        let path_lease =
894            |owner: &str, lease_request_ref: &str, resource: &str| AgentLeaseRequestV1 {
895                resource_ref: resource.into(),
896                ..request(owner, lease_request_ref)
897            };
898        let _ = reg
899            .acquire(path_lease("agent-a", "fence:old:0", "pathref:docs"), 10)
900            .unwrap();
901        let _ = reg
902            .acquire(path_lease("agent-a", "fence:older", "pathref:other"), 10)
903            .unwrap();
904        let release = |reg: &mut AgentLeaseRegistryV1, owner: &str, fence: &str| {
905            reg.release_fence(owner, fence, 20).unwrap()
906        };
907        assert_eq!(release(&mut reg, "agent-b", "fence:old"), 0);
908        assert_eq!(release(&mut reg, "agent-a", "fence:new"), 0);
909        // The node lease and the incarnation's path lease; never `fence:older`.
910        assert_eq!(release(&mut reg, "agent-a", "fence:old"), 2);
911        assert_eq!(reg.active_count(20), 1);
912        let _ = reg.acquire(request("agent-a", "fence:new"), 30).unwrap();
913        assert_eq!(release(&mut reg, "agent-a", "fence:old"), 0);
914        assert!(matches!(
915            reg.acquire(request("agent-b", "request:b"), 40),
916            Ok(AgentLeaseAcquireV1::HeldBy { .. })
917        ));
918    }
919
920    #[test]
921    fn renew_extends_owned_lease_without_rotating_fence() {
922        let mut reg = AgentLeaseRegistryV1::default();
923        let AgentLeaseAcquireV1::Granted(granted) =
924            reg.acquire(request("agent-a", "request:a"), 10).unwrap()
925        else {
926            panic!("expected grant")
927        };
928        let renewed = reg
929            .renew(
930                AgentLeaseResourceKindV1::Path,
931                "pathref:src-core-main",
932                "agent-a",
933                &granted.lease_ref,
934                500,
935                50,
936            )
937            .unwrap();
938        assert_eq!(renewed.lease_ref, granted.lease_ref);
939        assert_eq!(renewed.expires_at_epoch_ms, 550);
940        assert!(renewed.is_active_at(549));
941    }
942
943    #[test]
944    fn renew_rejects_expired_token_and_allows_reclaim() {
945        let mut reg = AgentLeaseRegistryV1::default();
946        let AgentLeaseAcquireV1::Granted(granted) =
947            reg.acquire(request("agent-a", "request:a"), 10).unwrap()
948        else {
949            panic!("expected grant")
950        };
951        assert!(matches!(
952            reg.renew(
953                AgentLeaseResourceKindV1::Path,
954                "pathref:src-core-main",
955                "agent-a",
956                &granted.lease_ref,
957                500,
958                110,
959            ),
960            Err(AgentLeaseError::Expired)
961        ));
962        assert!(matches!(
963            reg.acquire(request("agent-b", "request:b"), 111),
964            Ok(AgentLeaseAcquireV1::Granted(_))
965        ));
966    }
967
968    #[test]
969    fn capacity_enforced() {
970        let mut reg = AgentLeaseRegistryV1::new(1);
971        let _ = reg.acquire(request("agent-a", "request:a"), 1).unwrap();
972        let second = AgentLeaseRequestV1 {
973            resource_ref: "symbolref:main".to_string(),
974            resource_kind: AgentLeaseResourceKindV1::Symbol,
975            ..request("agent-b", "request:b")
976        };
977        assert!(matches!(
978            reg.acquire(second, 1),
979            Err(AgentLeaseError::CapacityExceeded(1))
980        ));
981    }
982
983    fn long_request(owner: &str, ref_id: &str) -> AgentLeaseRequestV1 {
984        AgentLeaseRequestV1 {
985            duration_ms: 60_000,
986            ..request(owner, ref_id)
987        }
988    }
989
990    /// The shared registry keeps no in-memory state: every call reloads the
991    /// store under its lock, which is exactly what a second process sees.
992    #[test]
993    fn shared_registry_is_visible_across_calls_and_persisted() {
994        let iso = crate::core::data_dir::isolated_data_dir();
995        let AgentLeaseAcquireV1::Granted(granted) =
996            acquire_shared(long_request("agent-a", "request:a")).unwrap()
997        else {
998            panic!("expected grant")
999        };
1000        let store = iso.path().join("agents").join("leases.json");
1001        assert!(store.exists(), "lease store is written to the data dir");
1002        assert!(matches!(
1003            acquire_shared(long_request("agent-b", "request:b")),
1004            Ok(AgentLeaseAcquireV1::HeldBy { owner_agent_id, .. }) if owner_agent_id == "agent-a"
1005        ));
1006        assert!(
1007            release_shared(
1008                AgentLeaseResourceKindV1::Path,
1009                "pathref:src-core-main",
1010                "agent-a",
1011                &granted.lease_ref,
1012            )
1013            .unwrap()
1014        );
1015        assert!(matches!(
1016            acquire_shared(long_request("agent-b", "request:b")),
1017            Ok(AgentLeaseAcquireV1::Granted(_))
1018        ));
1019    }
1020
1021    /// O04: a holder that releases and asks again at once, or renews for
1022    /// ever, cannot starve an agent that keeps asking. The waiter gets the
1023    /// freed lease; a waiter that stops asking loses its place after the
1024    /// TTL; past the maximum hold a contended lease is not renewed.
1025    #[test]
1026    fn a_waiting_agent_is_served_before_a_holder_asking_again() {
1027        let mut reg = AgentLeaseRegistryV1::default();
1028        let grant = |reg: &mut AgentLeaseRegistryV1, owner: &str, now| match reg
1029            .acquire(long_request(owner, &format!("request:{owner}")), now)
1030            .unwrap()
1031        {
1032            AgentLeaseAcquireV1::Granted(lease) => Some(lease),
1033            AgentLeaseAcquireV1::HeldBy { .. } => None,
1034        };
1035        let held = grant(&mut reg, "agent-a", 0).expect("free");
1036        assert!(grant(&mut reg, "agent-b", 1).is_none(), "b waits");
1037        assert!(
1038            reg.release(
1039                held.request.resource_kind,
1040                &held.request.resource_ref,
1041                "agent-a",
1042                &held.lease_ref,
1043                2
1044            )
1045            .unwrap()
1046        );
1047        // a asks again first: the lease is kept for b, who waited.
1048        assert!(matches!(
1049            reg.acquire(long_request("agent-a", "request:a2"), 3).unwrap(),
1050            AgentLeaseAcquireV1::HeldBy { owner_agent_id, lease_ref, .. }
1051                if owner_agent_id == "agent-b" && lease_ref == RESERVED_LEASE_REF
1052        ));
1053        let b = grant(&mut reg, "agent-b", 4).expect("b is served");
1054        // a now waits behind b; c, who never waited, does not pass a.
1055        assert!(grant(&mut reg, "agent-a", 5).is_none());
1056        reg.release(
1057            b.request.resource_kind,
1058            &b.request.resource_ref,
1059            "agent-b",
1060            &b.lease_ref,
1061            6,
1062        )
1063        .unwrap();
1064        assert!(grant(&mut reg, "agent-c", 7).is_none());
1065        // a stops asking: after the TTL its place is gone.
1066        assert!(grant(&mut reg, "agent-c", 5 + WAITER_TTL_MS).is_some());
1067
1068        // A holder renews while nobody waits, but not past the maximum hold
1069        // once someone does.
1070        let mut reg = AgentLeaseRegistryV1::default();
1071        let held = grant(&mut reg, "agent-a", 0).expect("free");
1072        let renew = |reg: &mut AgentLeaseRegistryV1, now| {
1073            reg.renew(
1074                held.request.resource_kind,
1075                &held.request.resource_ref,
1076                "agent-a",
1077                &held.lease_ref,
1078                60_000,
1079                now,
1080            )
1081        };
1082        let late = MAX_CONTENDED_HOLD_MS + 30_000;
1083        let mut now = 30_000;
1084        while now < late {
1085            renew(&mut reg, now).expect("uncontended renew");
1086            now += 30_000;
1087        }
1088        assert!(grant(&mut reg, "agent-b", now).is_none());
1089        assert!(
1090            renew(&mut reg, now + 1).is_err(),
1091            "contended past the maximum"
1092        );
1093        assert!(
1094            grant(&mut reg, "agent-b", now + 60_000).is_some(),
1095            "runs out to b"
1096        );
1097    }
1098
1099    /// O04: two humans with two agents each contend for one path of P1
1100    /// while a lease on the same path of P2 is held throughout. Released
1101    /// together; each holder keeps the lease until every agent still waiting
1102    /// has been refused once. Exactly one holder at a time, P2 blocks
1103    /// nothing, and every agent is granted after at most three others
1104    /// (bounded bypass: no agent starves while the others finish).
1105    #[test]
1106    fn four_contending_agents_each_get_the_lease_after_at_most_three_others() {
1107        use std::collections::BTreeSet;
1108        use std::sync::atomic::{AtomicUsize, Ordering};
1109        use std::sync::{Barrier, Mutex};
1110
1111        let _iso = crate::core::data_dir::isolated_data_dir();
1112        let (p1, p2) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1113        let resource = |project: &tempfile::TempDir| {
1114            project_path_resource(project.path().to_str().unwrap(), "src/lib.rs").unwrap()
1115        };
1116        let lease = |owner: &str, resource_ref: String| AgentLeaseRequestV1 {
1117            resource_ref,
1118            ..long_request(owner, &format!("request:{owner}"))
1119        };
1120        assert!(matches!(
1121            acquire_shared(lease("human-c-agent-1", resource(&p2))),
1122            Ok(AgentLeaseAcquireV1::Granted(_))
1123        ));
1124
1125        let agents = [
1126            "human-a-agent-1",
1127            "human-a-agent-2",
1128            "human-b-agent-1",
1129            "human-b-agent-2",
1130        ];
1131        let start = Barrier::new(agents.len());
1132        let waiting: Mutex<BTreeSet<&str>> = Mutex::new(agents.into_iter().collect());
1133        let refused: Mutex<BTreeSet<&str>> = Mutex::new(BTreeSet::new());
1134        let (holders, granted) = (AtomicUsize::new(0), AtomicUsize::new(0));
1135        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
1136        let bypassed = std::thread::scope(|scope| {
1137            let contend = |owner: &'static str| {
1138                let (start, waiting, refused) = (&start, &waiting, &refused);
1139                let (holders, granted, lease) = (&holders, &granted, &lease);
1140                let target = resource(&p1);
1141                move || {
1142                    start.wait();
1143                    let before = granted.load(Ordering::SeqCst);
1144                    let held = loop {
1145                        assert!(std::time::Instant::now() < deadline, "{owner} starved");
1146                        match acquire_shared(lease(owner, target.clone())) {
1147                            Ok(AgentLeaseAcquireV1::Granted(held)) => break held,
1148                            Ok(AgentLeaseAcquireV1::HeldBy { .. }) => {
1149                                refused.lock().unwrap().insert(owner);
1150                            }
1151                            // The store lock is bounded: busy is a retry.
1152                            Err(AgentLeaseError::Store(_)) => {}
1153                            Err(error) => panic!("{owner}: {error:?}"),
1154                        }
1155                        std::thread::sleep(std::time::Duration::from_millis(2));
1156                    };
1157                    assert_eq!(holders.fetch_add(1, Ordering::SeqCst), 0, "two holders");
1158                    let bypassed = granted.fetch_add(1, Ordering::SeqCst) - before;
1159                    waiting.lock().unwrap().remove(owner);
1160                    refused.lock().unwrap().clear();
1161                    // Hold until every agent still waiting was refused once.
1162                    while !waiting.lock().unwrap().is_subset(&refused.lock().unwrap()) {
1163                        assert!(std::time::Instant::now() < deadline, "waiters never asked");
1164                        std::thread::yield_now();
1165                    }
1166                    holders.fetch_sub(1, Ordering::SeqCst);
1167                    while !matches!(
1168                        release_shared(
1169                            AgentLeaseResourceKindV1::Path,
1170                            &target,
1171                            owner,
1172                            &held.lease_ref
1173                        ),
1174                        Ok(true)
1175                    ) {
1176                        assert!(std::time::Instant::now() < deadline, "release never landed");
1177                    }
1178                    bypassed
1179                }
1180            };
1181            let threads: Vec<_> = agents
1182                .into_iter()
1183                .map(|owner| scope.spawn(contend(owner)))
1184                .collect();
1185            threads
1186                .into_iter()
1187                .map(|thread| thread.join().expect("agent"))
1188                .collect::<Vec<_>>()
1189        });
1190        assert_eq!(granted.load(Ordering::SeqCst), agents.len());
1191        assert!(bypassed.iter().all(|&n| n < agents.len()), "{bypassed:?}");
1192        // P2's holder kept its lease the whole time.
1193        assert!(matches!(
1194            acquire_shared(lease("human-a-agent-1", resource(&p2))),
1195            Ok(AgentLeaseAcquireV1::HeldBy { owner_agent_id, .. })
1196                if owner_agent_id == "human-c-agent-1"
1197        ));
1198    }
1199
1200    #[test]
1201    fn corrupt_shared_store_fails_closed() {
1202        let iso = crate::core::data_dir::isolated_data_dir();
1203        let dir = iso.path().join("agents");
1204        std::fs::create_dir_all(&dir).unwrap();
1205        std::fs::write(dir.join("leases.json"), "{not json").unwrap();
1206        assert!(matches!(
1207            acquire_shared(long_request("agent-a", "request:a")),
1208            Err(AgentLeaseError::Store(message)) if message.contains("corrupt")
1209        ));
1210        assert_eq!(
1211            std::fs::read_to_string(dir.join("leases.json")).unwrap(),
1212            "{not json",
1213            "a corrupt store is never overwritten"
1214        );
1215    }
1216
1217    /// AP07-G1: `path:src` collided across projects, and a relative and an
1218    /// absolute spelling of one file never met.
1219    #[test]
1220    fn path_leases_are_bound_to_their_project() {
1221        let project_a = tempfile::tempdir().unwrap();
1222        let project_b = tempfile::tempdir().unwrap();
1223        let root_a = project_a.path().to_str().unwrap();
1224        let root_b = project_b.path().to_str().unwrap();
1225        let path = |owner: &str, resource_ref: String| AgentLeaseRequestV1 {
1226            resource_ref,
1227            ..request(owner, &format!("request:{owner}"))
1228        };
1229        let mut reg = AgentLeaseRegistryV1::default();
1230        let held = project_path_target_resource(root_a, "src").unwrap();
1231        assert!(matches!(
1232            reg.acquire(path("agent-a", held), 1),
1233            Ok(AgentLeaseAcquireV1::Granted(_))
1234        ));
1235        let other_project = project_path_target_resource(root_b, "./src").unwrap();
1236        assert!(matches!(
1237            reg.acquire(path("agent-b", other_project), 1),
1238            Ok(AgentLeaseAcquireV1::Granted(_))
1239        ));
1240        let absolute_child =
1241            project_path_target_resource(root_a, &format!("{root_a}/src/lib.rs")).unwrap();
1242        assert_eq!(
1243            absolute_child,
1244            project_path_resource(root_a, "src/lib.rs").unwrap()
1245        );
1246        assert!(matches!(
1247            reg.acquire(path("agent-c", absolute_child), 1),
1248            Ok(AgentLeaseAcquireV1::HeldBy { owner_agent_id, .. }) if owner_agent_id == "agent-a"
1249        ));
1250        // A lease of the unbound legacy namespace neither blocks nor is blocked.
1251        assert!(matches!(
1252            reg.acquire(path("agent-d", "path:src".into()), 1),
1253            Ok(AgentLeaseAcquireV1::Granted(_))
1254        ));
1255        for outside in ["../src".to_string(), format!("{root_b}/src"), root_a.into()] {
1256            assert!(project_path_target_resource(root_a, &outside).is_err());
1257        }
1258        // A symlink inside the project may point anywhere: refused like a
1259        // Work Graph path claim, whether spelled relative or absolute.
1260        #[cfg(unix)]
1261        {
1262            std::os::unix::fs::symlink(project_b.path(), project_a.path().join("link")).unwrap();
1263            for through_link in ["link/src".to_string(), format!("{root_a}/link/src")] {
1264                assert!(project_path_target_resource(root_a, &through_link).is_err());
1265            }
1266        }
1267    }
1268}
1269// SPDX-License-Identifier: Apache-2.0