Skip to main content

wm_tools/expansion/
coordination.rs

1//! Multi-agent coordination — file-based claim leases (`code.claim` v0).
2//!
3//! Phase 2 of the v7→v9 strategy: the coordination substrate from the
4//! two-writer postmortem. One writer per checkout is the rule; these tools
5//! make the rule *visible*. Leases live in the git common dir
6//! (`$(git rev-parse --git-common-dir)/wm-leases.json`) so every worktree
7//! of a checkout reads and writes the same ledger.
8//!
9//! Design (from `planning/STRATEGY_V7_V9.md` Phase 2):
10//! - Advisory only — claims are coordination signals, not locks. No
11//!   enforcement hooks, no merge radar; those are v1/v2.
12//! - Entries carry a mandatory intent, an owner session, and a TTL.
13//!   Expired leases prune lazily and the scope frees itself — a dead
14//!   session cannot hold a scope forever.
15//! - All mutations are lockfile-guarded read-modify-write with atomic
16//!   rename, so two agents racing on one ledger cannot corrupt it.
17//! - Claim/release/denied publish to the Gan Ying bus when one is wired.
18//!
19//! Research surface: registered on the full profile only (curated stays
20//! the alpha contract surface).
21
22#![forbid(unsafe_code)]
23
24use async_trait::async_trait;
25
26use chrono::{DateTime, Duration, Utc};
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use std::path::{Path, PathBuf};
30use std::sync::{Arc, Mutex};
31use wm_cognitive::{EventType, GanYingBus};
32use wm_core::{Context, CoreError, EffectRow, Gana, Resource, Tool, ToolStats};
33
34/// Default lease TTL — one hour covers a focused work stretch; renewal is
35/// a single re-claim with the same owner.
36const DEFAULT_TTL_SECS: i64 = 3600;
37/// Maximum TTL — a claim is coordination state, not a tombstone.
38const MAX_TTL_SECS: i64 = 86_400;
39/// Lockfile older than this is stale (a crashed writer) and gets stolen.
40const STALE_LOCK_SECS: i64 = 30;
41const LOCK_ATTEMPTS: usize = 150;
42const LOCK_SLEEP_MS: u64 = 10;
43
44/// One claim lease in the shared ledger.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Lease {
47    /// What is claimed — a path, a subtree, or any resource label the
48    /// agents agree on (`"src/expansion/"`, `"benchmarks/harness"`, …).
49    pub scope: String,
50    /// Why the scope is claimed. Mandatory — an intent-less claim is
51    /// noise, and the conflict result must be able to explain the holder.
52    pub intent: String,
53    /// Claiming session (session id from `session.start`, or a stable
54    /// agent label). Release requires a matching owner.
55    pub owner_session: String,
56    /// RFC 3339 claim time.
57    pub claimed_at: String,
58    /// RFC 3339 expiry — past this, the lease prunes lazily.
59    pub expires_at: String,
60    /// Requested TTL in seconds (recorded for diagnosis).
61    pub ttl_secs: i64,
62}
63
64#[derive(Debug, Default, Serialize, Deserialize)]
65struct LeaseFile {
66    version: u8,
67    leases: Vec<Lease>,
68}
69
70/// The shared ledger: `<git-common-dir>/wm-leases.json`.
71#[derive(Debug, Clone)]
72pub struct LeaseLedger {
73    path: PathBuf,
74}
75
76impl LeaseLedger {
77    /// Discover the ledger for a repository root. Requires a git checkout
78    /// (worktree or bare-adjacent): the common dir is what makes leases
79    /// visible across all worktrees.
80    pub fn discover(root: &Path) -> wm_core::Result<Self> {
81        let out = std::process::Command::new("git")
82            .args(["rev-parse", "--git-common-dir"])
83            .current_dir(root)
84            .output()
85            .map_err(|e| {
86                CoreError::Tool(format!(
87                    "code.claim could not run git ({e}) — pass root=<repo path> or set WM_PROJECT_ROOT to a git checkout"
88                ))
89            })?;
90        if !out.status.success() {
91            return Err(CoreError::Tool(
92                "code.claim requires a git repository — pass root=<repo path> (or set WM_PROJECT_ROOT) pointing at a checkout; leases live in <git-common-dir>/wm-leases.json".into(),
93            ));
94        }
95        let dir = String::from_utf8_lossy(&out.stdout).trim().to_string();
96        if dir.is_empty() {
97            return Err(CoreError::Tool(
98                "git rev-parse --git-common-dir returned nothing for this root".into(),
99            ));
100        }
101        let common = PathBuf::from(&dir);
102        let common = if common.is_absolute() {
103            common
104        } else {
105            root.join(common)
106        };
107        Ok(Self {
108            path: common.join("wm-leases.json"),
109        })
110    }
111
112    #[must_use]
113    pub fn path(&self) -> &Path {
114        &self.path
115    }
116
117    fn lock_path(&self) -> PathBuf {
118        let name = self.path.file_name().map_or_else(
119            || "wm-leases.json.lock".to_string(),
120            |n| format!("{}.lock", n.to_string_lossy()),
121        );
122        self.path.with_file_name(name)
123    }
124
125    /// Acquire the ledger lock. create_new gives us an atomic test-and-set;
126    /// a lockfile older than `STALE_LOCK_SECS` belongs to a crashed writer
127    /// and is stolen.
128    fn acquire_lock(&self) -> wm_core::Result<()> {
129        let lock = self.lock_path();
130        for _ in 0..LOCK_ATTEMPTS {
131            match std::fs::OpenOptions::new()
132                .write(true)
133                .create_new(true)
134                .open(&lock)
135            {
136                Ok(_) => return Ok(()),
137                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
138                    if let Ok(meta) = std::fs::metadata(&lock) {
139                        if let Ok(modified) = meta.modified() {
140                            let age = DateTime::<Utc>::from(modified);
141                            if Utc::now() - age > Duration::seconds(STALE_LOCK_SECS) {
142                                let _ = std::fs::remove_file(&lock);
143                                continue;
144                            }
145                        }
146                    }
147                    std::thread::sleep(std::time::Duration::from_millis(LOCK_SLEEP_MS));
148                }
149                Err(e) => {
150                    return Err(CoreError::Tool(format!(
151                        "could not create lease lock {}: {e}",
152                        lock.display()
153                    )));
154                }
155            }
156        }
157        Err(CoreError::Tool(format!(
158            "lease ledger is busy (lock held past {}s) — retry shortly: {}",
159            (LOCK_ATTEMPTS as u64 * LOCK_SLEEP_MS) / 1000,
160            lock.display()
161        )))
162    }
163
164    fn release_lock(&self) {
165        let _ = std::fs::remove_file(self.lock_path());
166    }
167
168    fn parse_file(&self) -> Vec<Lease> {
169        let Ok(raw) = std::fs::read_to_string(&self.path) else {
170            return Vec::new();
171        };
172        let Ok(parsed) = serde_json::from_str::<LeaseFile>(&raw) else {
173            // A corrupt ledger must never wedge coordination: treat it as
174            // empty (advisory system; the next successful write rebuilds).
175            tracing::warn!(
176                path = %self.path.display(),
177                "wm-leases.json unreadable — treating ledger as empty"
178            );
179            return Vec::new();
180        };
181        parsed.leases
182    }
183
184    /// Run `f` against the active leases under the ledger lock, prune
185    /// expired entries, and atomically persist the result. Expired leases
186    /// are returned so callers can surface the transition.
187    fn mutate<T>(
188        &self,
189        f: impl FnOnce(&mut Vec<Lease>, &[Lease]) -> wm_core::Result<T>,
190    ) -> wm_core::Result<T> {
191        if let Some(parent) = self.path.parent() {
192            std::fs::create_dir_all(parent).map_err(|e| {
193                CoreError::Tool(format!("could not create {}: {e}", parent.display()))
194            })?;
195        }
196        self.acquire_lock()?;
197        let result = (|| {
198            let now = Utc::now();
199            let all = self.parse_file();
200            let (mut active, expired): (Vec<Lease>, Vec<Lease>) =
201                all.into_iter()
202                    .partition(|l| match DateTime::parse_from_rfc3339(&l.expires_at) {
203                        Ok(exp) => exp.with_timezone(&Utc) > now,
204                        Err(_) => false, // unparseable expiry = expired
205                    });
206            let pre = active.clone();
207            let out = f(&mut active, &expired)?;
208            // Persist only on real change — pure reads (check/list on a
209            // stable ledger) must not rewrite the file, and an expired
210            // lease is reported once, at its discovery transition.
211            if active == pre && expired.is_empty() {
212                return Ok(out);
213            }
214            let file = LeaseFile {
215                version: 1,
216                leases: active,
217            };
218            let tmp = self
219                .path
220                .with_file_name(format!("wm-leases.json.tmp.{}", std::process::id()));
221            let body = serde_json::to_string_pretty(&file)
222                .map_err(|e| CoreError::Tool(format!("lease serialization failed: {e}")))?;
223            std::fs::write(&tmp, body)
224                .map_err(|e| CoreError::Tool(format!("lease write failed: {e}")))?;
225            std::fs::rename(&tmp, &self.path)
226                .map_err(|e| CoreError::Tool(format!("lease atomic rename failed: {e}")))?;
227            Ok(out)
228        })();
229        self.release_lock();
230        result
231    }
232
233    /// Active leases plus anything that expired since the last write.
234    ///
235    /// Mutate-based (prunes on read): retained for tests that exercise the
236    /// observation-transition semantics; production reads use
237    /// [`Self::snapshot_readonly`] so strict-mode `check`/`list` never mutate.
238    #[cfg(test)]
239    pub(crate) fn snapshot(&self) -> wm_core::Result<(Vec<Lease>, Vec<Lease>)> {
240        self.mutate(|active, expired| Ok((active.clone(), expired.to_vec())))
241    }
242
243    /// Read-only snapshot (AHIMSA Target A, regression 3): reads and filters
244    /// the ledger without creating a lock or temporary file and without
245    /// persisting expiry pruning. Expired leases are logically absent (they
246    /// are still returned in the second vec for observability); physical
247    /// pruning waits for the next permitted mutation.
248    pub(crate) fn snapshot_readonly(&self) -> wm_core::Result<(Vec<Lease>, Vec<Lease>)> {
249        let now = Utc::now();
250        let all = self.parse_file();
251        let (active, expired): (Vec<Lease>, Vec<Lease>) =
252            all.into_iter()
253                .partition(|l| match DateTime::parse_from_rfc3339(&l.expires_at) {
254                    Ok(exp) => exp.with_timezone(&Utc) > now,
255                    Err(_) => false, // unparseable expiry = expired
256                });
257        Ok((active, expired))
258    }
259
260    // ── Bridge API (F-1): mesh-side scope coordination ────────────────
261    //
262    // `wm-leases.json` is the durable, cross-process substrate for scope
263    // coordination; the sangha `ResourceLockManager` is the per-process
264    // mesh-runtime view. These methods let mesh tools record their scope
265    // claims in the durable ledger so agents coordinating through either
266    // surface see the same truth (`code.list`, `code.check`, or plain bash
267    // against the ledger file).
268
269    /// Claim a scope on behalf of `owner`, atomically.
270    ///
271    /// Same-owner re-claim is a renewal; a live claim by another owner is a
272    /// conflict naming the holder. Expired entries prune lazily as in every
273    /// other mutate.
274    pub fn try_claim(
275        &self,
276        scope: &str,
277        intent: &str,
278        owner: &str,
279        ttl_secs: i64,
280    ) -> wm_core::Result<Result<Lease, Lease>> {
281        let claimed_at = now_rfc3339();
282        let expires_at = (Utc::now() + Duration::seconds(ttl_secs))
283            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
284        self.mutate(|leases, _expired| {
285            if let Some(existing) = leases.iter_mut().find(|l| l.scope == scope) {
286                if existing.owner_session == owner {
287                    existing.intent = intent.to_string();
288                    existing.claimed_at.clone_from(&claimed_at);
289                    existing.expires_at.clone_from(&expires_at);
290                    existing.ttl_secs = ttl_secs;
291                    return Ok(Ok(existing.clone()));
292                }
293                return Ok(Err(existing.clone()));
294            }
295            let lease = Lease {
296                scope: scope.to_string(),
297                intent: intent.to_string(),
298                owner_session: owner.to_string(),
299                claimed_at: claimed_at.clone(),
300                expires_at: expires_at.clone(),
301                ttl_secs,
302            };
303            leases.push(lease.clone());
304            Ok(Ok(lease))
305        })
306    }
307
308    /// Release a scope owner-matched. `Ok(Ok(true))` = released;
309    /// `Ok(Ok(false))` = scope was already free (idempotent);
310    /// `Ok(Err(holder))` = live claim held by another owner.
311    pub fn release_scope(&self, scope: &str, owner: &str) -> wm_core::Result<Result<bool, Lease>> {
312        self.mutate(|leases, _expired| {
313            let Some(pos) = leases.iter().position(|l| l.scope == scope) else {
314                return Ok(Ok(false));
315            };
316            if leases[pos].owner_session != owner {
317                return Ok(Err(leases[pos].clone()));
318            }
319            leases.remove(pos);
320            Ok(Ok(true))
321        })
322    }
323
324    /// Force-release every live claim held by `peer` whose scope starts with
325    /// `prefix`. This is the community override behind the bad-apple rule:
326    /// quarantine must not leave a cut-off peer holding shared scopes.
327    /// Deliberately bypasses owner matching — the mesh revoked the peer.
328    pub fn force_release_peer(&self, peer: &str, prefix: &str) -> wm_core::Result<Vec<String>> {
329        self.mutate(|leases, _expired| {
330            let mut freed = Vec::new();
331            leases.retain(|l| {
332                if l.owner_session == peer && l.scope.starts_with(prefix) {
333                    freed.push(l.scope.clone());
334                    return false;
335                }
336                true
337            });
338            Ok(freed)
339        })
340    }
341}
342
343/// RFC 3339 at second precision — the human-facing lease ledger format
344/// (convention: `wm_core::time`, unit registry in
345/// `docs/TIMESTAMP_CONVENTIONS.md`).
346fn now_rfc3339() -> String {
347    wm_core::time::now_rfc3339()
348}
349
350pub(crate) fn clamp_ttl(ttl: i64) -> wm_core::Result<i64> {
351    if !(1..=MAX_TTL_SECS).contains(&ttl) {
352        return Err(CoreError::InvalidArgs(format!(
353            "ttl_secs must be between 1 and {MAX_TTL_SECS} (a claim is coordination state, not a tombstone)"
354        )));
355    }
356    Ok(ttl)
357}
358
359pub(crate) fn require_str(args: &Value, key: &str) -> wm_core::Result<String> {
360    args.get(key)
361        .and_then(|v| v.as_str())
362        .map(str::trim)
363        .filter(|s| !s.is_empty())
364        .map(str::to_string)
365        .ok_or_else(|| {
366            CoreError::InvalidArgs(format!(
367                "'{key}' is required and must be a non-empty string"
368            ))
369        })
370}
371
372pub(crate) fn resolve_root(args: &Value) -> wm_core::Result<PathBuf> {
373    args.get("root")
374        .and_then(|v| v.as_str())
375        .map(str::trim)
376        .filter(|s| !s.is_empty())
377        .map(PathBuf::from)
378        .or_else(|| {
379            std::env::var("WM_PROJECT_ROOT")
380                .ok()
381                .filter(|s| !s.trim().is_empty())
382                .map(PathBuf::from)
383        })
384        .ok_or_else(|| {
385            CoreError::InvalidArgs(
386                "no repository root — pass root=<repo path> or set WM_PROJECT_ROOT".into(),
387            )
388        })
389}
390
391fn emit(gan_ying: Option<&Arc<Mutex<GanYingBus>>>, event_type: EventType, payload: Value) {
392    if let Some(bus) = gan_ying {
393        if let Ok(mut gy) = bus.lock() {
394            gy.emit(event_type, "code.coordination", payload);
395        }
396    }
397}
398
399fn lease_json(lease: &Lease) -> Value {
400    json!({
401        "lease_id": lease.scope,
402        "scope": lease.scope,
403        "intent": lease.intent,
404        "owner_session": lease.owner_session,
405        "claimed_at": lease.claimed_at,
406        "expires_at": lease.expires_at,
407        "ttl_secs": lease.ttl_secs,
408    })
409}
410
411const CONFLICT_NEXT_ACTION: &str =
412    "wait for expiry, ask the holder to code.release the scope, or claim a different scope";
413
414// ── code.claim ────────────────────────────────────────────────────────
415
416/// `code.claim` — claim a scope before shared-tree edits.
417pub struct CodeClaimTool {
418    stats: ToolStats,
419    effects: EffectRow,
420    gan_ying: Option<Arc<Mutex<GanYingBus>>>,
421}
422
423impl CodeClaimTool {
424    #[must_use]
425    pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
426        Self {
427            stats: ToolStats::default(),
428            // EffectRow declares data-plane effects (the ledger file), per
429            // the session-tools precedent: the fixed-argv `git rev-parse`
430            // lookup is the same trust class as checkpoint's git capture,
431            // and declaring spawns would push every coordination call
432            // against the Yama spawn budget (found live: a two-agent
433            // negotiation burst exceeds 6 spawns/min). The lease effect is
434            // strict-denied (AHIMSA Target A): stress refuses new claims and
435            // renewals; the TTL frees held ones.
436            effects: EffectRow {
437                reads: vec![Resource::Filesystem],
438                writes: vec![Resource::CoordinationLease],
439                ..Default::default()
440            },
441            gan_ying,
442        }
443    }
444}
445
446#[async_trait]
447impl Tool for CodeClaimTool {
448    fn name(&self) -> &str {
449        "code.claim"
450    }
451    fn gana(&self) -> Gana {
452        Gana::Room
453    }
454    fn effects(&self) -> &EffectRow {
455        &self.effects
456    }
457    fn input_schema(&self) -> Value {
458        super::common::schema(
459            &json!({
460                "scope": super::common::str_prop("Scope to claim — path, subtree, or resource label (e.g. 'src/expansion/')"),
461                "intent": super::common::str_prop("Why this scope is claimed (mandatory — surfaced to conflicting agents)"),
462                "owner_session": super::common::str_prop("Claiming session id (session.start result) or stable agent label"),
463                "ttl_secs": super::common::int_prop("Lease TTL in seconds (default 3600, max 86400; expired claims free themselves)"),
464                "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
465            }),
466            &["scope", "intent", "owner_session"],
467        )
468    }
469    fn description(&self) -> &str {
470        "Claim a scope before shared-tree edits (advisory file-based lease with TTL in the git common dir; visible to every worktree). Conflict results name the holder and their intent."
471    }
472    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
473        let scope = require_str(&args, "scope")?;
474        let intent = require_str(&args, "intent")?;
475        let owner = require_str(&args, "owner_session")?;
476        let ttl = match args.get("ttl_secs").and_then(serde_json::Value::as_i64) {
477            Some(t) => clamp_ttl(t)?,
478            None => DEFAULT_TTL_SECS,
479        };
480        let root = resolve_root(&args)?;
481        let ledger = LeaseLedger::discover(&root)?;
482        let claimed_at = now_rfc3339();
483        let expires_at = (Utc::now() + Duration::seconds(ttl))
484            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
485
486        let mut newly_expired: Vec<Lease> = Vec::new();
487        let result = ledger.mutate(|leases, expired| {
488            newly_expired = expired.to_vec();
489            if let Some(existing) = leases.iter_mut().find(|l| l.scope == scope) {
490                if existing.owner_session == owner {
491                    // Renewal: refresh intent and TTL, keep the claim.
492                    existing.intent.clone_from(&intent);
493                    existing.claimed_at.clone_from(&claimed_at);
494                    existing.expires_at.clone_from(&expires_at);
495                    existing.ttl_secs = ttl;
496                    return Ok(json!({
497                        "status": "success",
498                        "renewed": true,
499                        "lease_id": scope,
500                        "scope": scope,
501                        "intent": intent,
502                        "owner_session": owner,
503                        "claimed_at": claimed_at,
504                        "expires_at": expires_at,
505                        "ttl_secs": ttl,
506                        "note": "advisory lease renewed — release with code.release when done"
507                    }));
508                }
509                let holder = existing.clone();
510                return Ok(json!({
511                    "status": "conflict",
512                    "scope": scope,
513                    "requested_by": owner,
514                    "holder": holder.owner_session,
515                    "holder_intent": holder.intent,
516                    "claimed_at": holder.claimed_at,
517                    "expires_at": holder.expires_at,
518                    "next_action": CONFLICT_NEXT_ACTION,
519                    "advisory": true,
520                }));
521            }
522            leases.push(Lease {
523                scope: scope.clone(),
524                intent: intent.clone(),
525                owner_session: owner.clone(),
526                claimed_at: claimed_at.clone(),
527                expires_at: expires_at.clone(),
528                ttl_secs: ttl,
529            });
530            Ok(json!({
531                "status": "success",
532                "lease_id": scope,
533                "scope": scope,
534                "intent": intent,
535                "owner_session": owner,
536                "claimed_at": claimed_at,
537                "expires_at": expires_at,
538                "ttl_secs": ttl,
539                "note": "advisory lease — release with code.release when done"
540            }))
541        });
542
543        for lease in &newly_expired {
544            emit(
545                self.gan_ying.as_ref(),
546                EventType::CoordinationClaimExpired,
547                json!({"scope": lease.scope, "owner_session": lease.owner_session}),
548            );
549        }
550
551        let result = result?;
552        if result["status"] == "success" {
553            emit(
554                self.gan_ying.as_ref(),
555                EventType::CoordinationClaimAcquired,
556                json!({"scope": scope, "owner_session": owner, "intent": intent}),
557            );
558        } else if result["status"] == "conflict" {
559            emit(
560                self.gan_ying.as_ref(),
561                EventType::CoordinationClaimDenied,
562                json!({"scope": scope, "requested_by": owner, "holder": result["holder"]}),
563            );
564        }
565        Ok(result)
566    }
567    fn stats(&self) -> &ToolStats {
568        &self.stats
569    }
570}
571
572// ── code.check ────────────────────────────────────────────────────────
573
574/// `code.check` — is a scope claimed?
575pub struct CodeCheckTool {
576    stats: ToolStats,
577    effects: EffectRow,
578    gan_ying: Option<Arc<Mutex<GanYingBus>>>,
579}
580
581impl CodeCheckTool {
582    #[must_use]
583    pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
584        Self {
585            stats: ToolStats::default(),
586            effects: EffectRow {
587                reads: vec![Resource::Filesystem],
588                ..Default::default()
589            },
590            gan_ying,
591        }
592    }
593}
594
595#[async_trait]
596impl Tool for CodeCheckTool {
597    fn name(&self) -> &str {
598        "code.check"
599    }
600    fn gana(&self) -> Gana {
601        Gana::Room
602    }
603    fn effects(&self) -> &EffectRow {
604        &self.effects
605    }
606    fn input_schema(&self) -> Value {
607        super::common::schema(
608            &json!({
609                "scope": super::common::str_prop("Scope to check"),
610                "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
611            }),
612            &["scope"],
613        )
614    }
615    fn description(&self) -> &str {
616        "Check whether a scope is claimed — reports the holder, their intent, and expiry when claimed; 'free' means no active lease."
617    }
618    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
619        let scope = require_str(&args, "scope")?;
620        let root = resolve_root(&args)?;
621        let ledger = LeaseLedger::discover(&root)?;
622
623        // True read-only snapshot (AHIMSA Target A, regression 3): no lock
624        // file, no temporary file, no persisted pruning.
625        let (active, newly_expired) = ledger.snapshot_readonly()?;
626        let holder = active.iter().find(|l| l.scope == scope).map(lease_json);
627
628        for lease in &newly_expired {
629            emit(
630                self.gan_ying.as_ref(),
631                EventType::CoordinationClaimExpired,
632                json!({"scope": lease.scope, "owner_session": lease.owner_session}),
633            );
634        }
635
636        match holder {
637            Some(h) => Ok(json!({
638                "status": "success",
639                "scope": scope,
640                "state": "claimed",
641                "holder": h["owner_session"],
642                "intent": h["intent"],
643                "expires_at": h["expires_at"],
644                "next_action": CONFLICT_NEXT_ACTION,
645            })),
646            None => Ok(json!({
647                "status": "success",
648                "scope": scope,
649                "state": "free",
650            })),
651        }
652    }
653    fn stats(&self) -> &ToolStats {
654        &self.stats
655    }
656}
657
658// ── code.release ──────────────────────────────────────────────────────
659
660/// `code.release` — release a claimed scope (owner must match).
661pub struct CodeReleaseTool {
662    stats: ToolStats,
663    effects: EffectRow,
664    gan_ying: Option<Arc<Mutex<GanYingBus>>>,
665    /// The configured repository for the strict-mode cleanup exception
666    /// (`WM_PROJECT_ROOT` at construction). When set, an alternate `root`
667    /// argument is refused: only the configured repository's fixed ledger is
668    /// eligible (AHIMSA Target A, regression 4).
669    configured_root: Option<PathBuf>,
670}
671
672impl CodeReleaseTool {
673    #[must_use]
674    pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
675        Self {
676            stats: ToolStats::default(),
677            // Owner cleanup declares the dedicated release effect (never a
678            // generic filesystem write): strict mode admits exactly this
679            // shape so stress cannot trap a held lease (AHIMSA Target A).
680            effects: EffectRow {
681                reads: vec![Resource::Filesystem],
682                writes: vec![Resource::CoordinationRelease],
683                ..Default::default()
684            },
685            gan_ying,
686            configured_root: std::env::var("WM_PROJECT_ROOT")
687                .ok()
688                .map(|s| s.trim().to_string())
689                .filter(|s| !s.is_empty())
690                .map(PathBuf::from),
691        }
692    }
693
694    /// Inject the configured repository (tests / embedders). `None` restores
695    /// legacy behavior: the supplied root is honored as-is.
696    #[must_use]
697    pub fn with_configured_root(mut self, root: Option<PathBuf>) -> Self {
698        self.configured_root = root;
699        self
700    }
701}
702
703#[async_trait]
704impl Tool for CodeReleaseTool {
705    fn name(&self) -> &str {
706        "code.release"
707    }
708    fn gana(&self) -> Gana {
709        Gana::Room
710    }
711    fn effects(&self) -> &EffectRow {
712        &self.effects
713    }
714    fn input_schema(&self) -> Value {
715        super::common::schema(
716            &json!({
717                "scope": super::common::str_prop("Scope to release (the claim's lease_id)"),
718                "owner_session": super::common::str_prop("Releasing session id — must match the claim's owner"),
719                "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
720            }),
721            &["scope", "owner_session"],
722        )
723    }
724    fn description(&self) -> &str {
725        "Release a claimed scope when work is done — only the owning session can release; releasing a free scope is an idempotent no-op."
726    }
727    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
728        let scope = require_str(&args, "scope")?;
729        let owner = require_str(&args, "owner_session")?;
730        let root = resolve_root(&args)?;
731        let ledger = LeaseLedger::discover(&root)?;
732        // AHIMSA Target A, regression 4: owner cleanup may act only on the
733        // configured repository's fixed ledger. When a configured root is
734        // known (WM_PROJECT_ROOT, or injected), an alternate root that
735        // resolves to a different git common dir is refused.
736        if let Some(configured) = &self.configured_root {
737            if let Ok(configured_ledger) = LeaseLedger::discover(configured) {
738                if configured_ledger.path() != ledger.path() {
739                    return Err(CoreError::Tool(format!(
740                        "code.release refuses an alternate root — cleanup is permitted only against the configured repository's ledger {}",
741                        configured_ledger.path().display()
742                    )));
743                }
744            }
745        }
746
747        let outcome = ledger.mutate(|leases, _expired| {
748            let Some(pos) = leases.iter().position(|l| l.scope == scope) else {
749                return Ok(json!({
750                    "status": "success",
751                    "scope": scope,
752                    "state": "free",
753                    "note": "no active claim on this scope (already released or expired)"
754                }));
755            };
756            let lease = &leases[pos];
757            if lease.owner_session != owner {
758                return Ok(json!({
759                    "status": "not_owner",
760                    "scope": scope,
761                    "holder": lease.owner_session,
762                    "holder_intent": lease.intent,
763                    "expires_at": lease.expires_at,
764                    "note": "only the owning session can release a claim",
765                }));
766            }
767            leases.remove(pos);
768            Ok(json!({
769                "status": "success",
770                "scope": scope,
771                "state": "released",
772                "owner_session": owner,
773            }))
774        })?;
775
776        if outcome["status"] == "success" && outcome["state"] == "released" {
777            emit(
778                self.gan_ying.as_ref(),
779                EventType::CoordinationClaimReleased,
780                json!({"scope": scope, "owner_session": owner}),
781            );
782        }
783        Ok(outcome)
784    }
785    fn stats(&self) -> &ToolStats {
786        &self.stats
787    }
788}
789
790// ── code.list ─────────────────────────────────────────────────────────
791
792/// `code.list` — list claims in the shared ledger.
793pub struct CodeListTool {
794    stats: ToolStats,
795    effects: EffectRow,
796}
797
798impl CodeListTool {
799    #[must_use]
800    pub fn new() -> Self {
801        Self {
802            stats: ToolStats::default(),
803            effects: EffectRow {
804                reads: vec![Resource::Filesystem],
805                ..Default::default()
806            },
807        }
808    }
809}
810
811impl Default for CodeListTool {
812    fn default() -> Self {
813        Self::new()
814    }
815}
816
817#[async_trait]
818impl Tool for CodeListTool {
819    fn name(&self) -> &str {
820        "code.list"
821    }
822    fn gana(&self) -> Gana {
823        Gana::Room
824    }
825    fn effects(&self) -> &EffectRow {
826        &self.effects
827    }
828    fn input_schema(&self) -> Value {
829        super::common::schema(
830            &json!({
831                "include_expired": super::common::bool_prop("Include expired leases in the listing (default false)"),
832                "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
833            }),
834            &[],
835        )
836    }
837    fn description(&self) -> &str {
838        "List active claims in the shared lease ledger — what each agent is holding and until when."
839    }
840    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
841        let include_expired = args
842            .get("include_expired")
843            .and_then(serde_json::Value::as_bool)
844            .unwrap_or(false);
845        let root = resolve_root(&args)?;
846        let ledger = LeaseLedger::discover(&root)?;
847        // True read-only snapshot: expired leases are logically absent and
848        // nothing is persisted (AHIMSA Target A, regression 3).
849        let (active, expired) = ledger.snapshot_readonly()?;
850        let mut leases: Vec<Value> = active.iter().map(lease_json).collect();
851        if include_expired {
852            let mut expired_json: Vec<Value> = expired
853                .iter()
854                .map(|l| {
855                    let mut v = lease_json(l);
856                    v["expired"] = json!(true);
857                    v
858                })
859                .collect();
860            leases.append(&mut expired_json);
861        }
862        let count = leases.len();
863        Ok(json!({
864            "status": "success",
865            "count": count,
866            "leases": leases,
867            "file": ledger.path().display().to_string(),
868        }))
869    }
870    fn stats(&self) -> &ToolStats {
871        &self.stats
872    }
873}
874
875/// Register the coordination tools (full-profile surface).
876#[must_use]
877pub fn register_coordination(
878    registry: &wm_dispatch::ToolRegistry,
879    gan_ying_bus: Option<&Arc<Mutex<GanYingBus>>>,
880) -> wm_dispatch::ToolRegistry {
881    registry
882        .register(Arc::new(CodeClaimTool::new(gan_ying_bus.cloned())))
883        .register(Arc::new(CodeCheckTool::new(gan_ying_bus.cloned())))
884        .register(Arc::new(CodeReleaseTool::new(gan_ying_bus.cloned())))
885        .register(Arc::new(CodeListTool::new()))
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    /// Fresh local git repository with one empty initial commit.
893    fn git_repo() -> (tempfile::TempDir, PathBuf) {
894        let dir = tempfile::tempdir().unwrap();
895        let root = dir.path().to_path_buf();
896        let run = |args: &[&str]| {
897            std::process::Command::new("git")
898                .args(args)
899                .current_dir(&root)
900                .output()
901                .expect("git must be available")
902        };
903        assert!(run(&["init", "-q"]).status.success());
904        assert!(run(&["config", "user.email", "t@t"]).status.success());
905        assert!(run(&["config", "user.name", "t"]).status.success());
906        assert!(
907            run(&["commit", "--allow-empty", "-m", "c1"])
908                .status
909                .success()
910        );
911        (dir, root)
912    }
913
914    fn root_str(root: &Path) -> Value {
915        json!(root.display().to_string())
916    }
917
918    #[tokio::test]
919    async fn claim_then_conflict_names_holder_and_intent() {
920        let (_guard, root) = git_repo();
921        let a = CodeClaimTool::new(None);
922        let b = CodeClaimTool::new(None);
923        let mut ctx = Context::default();
924
925        let first = a
926            .call(
927                &mut ctx,
928                json!({
929                    "scope": "src/expansion/",
930                    "intent": "refactoring session tools",
931                    "owner_session": "session-aaa",
932                    "root": root_str(&root),
933                }),
934            )
935            .await
936            .unwrap();
937        assert_eq!(first["status"], "success", "got: {first}");
938        assert_eq!(first["lease_id"], "src/expansion/");
939        assert_eq!(first["owner_session"], "session-aaa");
940
941        let second = b
942            .call(
943                &mut ctx,
944                json!({
945                    "scope": "src/expansion/",
946                    "intent": "unrelated edits",
947                    "owner_session": "session-bbb",
948                    "root": root_str(&root),
949                }),
950            )
951            .await
952            .unwrap();
953        assert_eq!(second["status"], "conflict", "got: {second}");
954        assert_eq!(second["holder"], "session-aaa");
955        assert_eq!(second["holder_intent"], "refactoring session tools");
956        assert!(
957            second["next_action"]
958                .as_str()
959                .unwrap()
960                .contains("code.release")
961        );
962    }
963
964    #[tokio::test]
965    async fn check_reports_claimed_then_free_zero_false_free() {
966        let (_guard, root) = git_repo();
967        let claim = CodeClaimTool::new(None);
968        let check = CodeCheckTool::new(None);
969        let mut ctx = Context::default();
970
971        let free = check
972            .call(&mut ctx, json!({"scope": "docs/", "root": root_str(&root)}))
973            .await
974            .unwrap();
975        assert_eq!(free["state"], "free");
976
977        claim
978            .call(
979                &mut ctx,
980                json!({
981                    "scope": "docs/",
982                    "intent": "doc rewrite",
983                    "owner_session": "session-aaa",
984                    "root": root_str(&root),
985                }),
986            )
987            .await
988            .unwrap();
989
990        let claimed = check
991            .call(&mut ctx, json!({"scope": "docs/", "root": root_str(&root)}))
992            .await
993            .unwrap();
994        assert_eq!(claimed["state"], "claimed", "got: {claimed}");
995        assert_eq!(claimed["holder"], "session-aaa");
996        assert_eq!(claimed["intent"], "doc rewrite");
997    }
998
999    #[tokio::test]
1000    async fn release_requires_owner_then_scope_frees() {
1001        let (_guard, root) = git_repo();
1002        let claim = CodeClaimTool::new(None);
1003        let release = CodeReleaseTool::new(None);
1004        let check = CodeCheckTool::new(None);
1005        let mut ctx = Context::default();
1006
1007        claim
1008            .call(
1009                &mut ctx,
1010                json!({
1011                    "scope": "src/foo.rs",
1012                    "intent": "bugfix",
1013                    "owner_session": "session-aaa",
1014                    "root": root_str(&root),
1015                }),
1016            )
1017            .await
1018            .unwrap();
1019
1020        let wrong = release
1021            .call(
1022                &mut ctx,
1023                json!({
1024                    "scope": "src/foo.rs",
1025                    "owner_session": "session-bbb",
1026                    "root": root_str(&root),
1027                }),
1028            )
1029            .await
1030            .unwrap();
1031        assert_eq!(wrong["status"], "not_owner", "got: {wrong}");
1032        assert_eq!(wrong["holder"], "session-aaa");
1033
1034        let still = check
1035            .call(
1036                &mut ctx,
1037                json!({"scope": "src/foo.rs", "root": root_str(&root)}),
1038            )
1039            .await
1040            .unwrap();
1041        assert_eq!(
1042            still["state"], "claimed",
1043            "release must not free others' claims"
1044        );
1045
1046        let right = release
1047            .call(
1048                &mut ctx,
1049                json!({
1050                    "scope": "src/foo.rs",
1051                    "owner_session": "session-aaa",
1052                    "root": root_str(&root),
1053                }),
1054            )
1055            .await
1056            .unwrap();
1057        assert_eq!(right["status"], "success");
1058        assert_eq!(right["state"], "released");
1059
1060        let freed = check
1061            .call(
1062                &mut ctx,
1063                json!({"scope": "src/foo.rs", "root": root_str(&root)}),
1064            )
1065            .await
1066            .unwrap();
1067        assert_eq!(
1068            freed["state"], "free",
1069            "zero false free: freed after owner release"
1070        );
1071
1072        // Release again — idempotent no-op.
1073        let again = release
1074            .call(
1075                &mut ctx,
1076                json!({
1077                    "scope": "src/foo.rs",
1078                    "owner_session": "session-aaa",
1079                    "root": root_str(&root),
1080                }),
1081            )
1082            .await
1083            .unwrap();
1084        assert_eq!(again["status"], "success");
1085        assert_eq!(again["state"], "free");
1086    }
1087
1088    #[tokio::test]
1089    async fn expired_lease_frees_scope() {
1090        let (_guard, root) = git_repo();
1091        let claim = CodeClaimTool::new(None);
1092        let check = CodeCheckTool::new(None);
1093        let mut ctx = Context::default();
1094
1095        claim
1096            .call(
1097                &mut ctx,
1098                json!({
1099                    "scope": "src/stale.rs",
1100                    "intent": "dead session's claim",
1101                    "owner_session": "session-dead",
1102                    "root": root_str(&root),
1103                }),
1104            )
1105            .await
1106            .unwrap();
1107
1108        // Backdate the expiry in the ledger directly — simulates the passage
1109        // of time without a sleep.
1110        let path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1111        let raw = std::fs::read_to_string(&path).unwrap();
1112        let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1113        file.leases[0].expires_at = "2020-01-01T00:00:00Z".into();
1114        std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
1115
1116        let freed = check
1117            .call(
1118                &mut ctx,
1119                json!({"scope": "src/stale.rs", "root": root_str(&root)}),
1120            )
1121            .await
1122            .unwrap();
1123        assert_eq!(freed["state"], "free", "expired claims free the scope");
1124
1125        // A different owner can claim the freed scope immediately.
1126        let other = CodeClaimTool::new(None);
1127        let taken = other
1128            .call(
1129                &mut ctx,
1130                json!({
1131                    "scope": "src/stale.rs",
1132                    "intent": "rescued work",
1133                    "owner_session": "session-live",
1134                    "root": root_str(&root),
1135                }),
1136            )
1137            .await
1138            .unwrap();
1139        assert_eq!(taken["status"], "success", "got: {taken}");
1140    }
1141
1142    #[tokio::test]
1143    async fn ledger_visible_across_independent_tool_instances() {
1144        // Two "agents" = two tool instances with no shared state except the
1145        // file in the git common dir (the deployment shape: separate server
1146        // processes over one checkout).
1147        let (_guard, root) = git_repo();
1148        let claim_a = CodeClaimTool::new(None);
1149        let list_b = CodeListTool::new();
1150        let check_b = CodeCheckTool::new(None);
1151        let mut ctx = Context::default();
1152
1153        claim_a
1154            .call(
1155                &mut ctx,
1156                json!({
1157                    "scope": "worktree-A",
1158                    "intent": "agent A rewriting the harness",
1159                    "owner_session": "session-aaa",
1160                    "root": root_str(&root),
1161                }),
1162            )
1163            .await
1164            .unwrap();
1165
1166        let listed = list_b
1167            .call(&mut ctx, json!({"root": root_str(&root)}))
1168            .await
1169            .unwrap();
1170        assert_eq!(listed["status"], "success");
1171        assert_eq!(listed["count"], 1, "got: {listed}");
1172        assert_eq!(listed["leases"][0]["scope"], "worktree-A");
1173        assert_eq!(
1174            listed["leases"][0]["intent"],
1175            "agent A rewriting the harness"
1176        );
1177        assert!(listed["file"].as_str().unwrap().contains("wm-leases.json"));
1178
1179        let seen = check_b
1180            .call(
1181                &mut ctx,
1182                json!({"scope": "worktree-A", "root": root_str(&root)}),
1183            )
1184            .await
1185            .unwrap();
1186        assert_eq!(seen["state"], "claimed");
1187        assert_eq!(seen["holder"], "session-aaa");
1188    }
1189
1190    #[tokio::test]
1191    async fn renew_own_claim_keeps_single_entry() {
1192        let (_guard, root) = git_repo();
1193        let claim = CodeClaimTool::new(None);
1194        let list = CodeListTool::new();
1195        let mut ctx = Context::default();
1196
1197        for ttl in [7200i64, 60i64] {
1198            let r = claim
1199                .call(
1200                    &mut ctx,
1201                    json!({
1202                        "scope": "src/renewed.rs",
1203                        "intent": "long-running refactor",
1204                        "owner_session": "session-aaa",
1205                        "ttl_secs": ttl,
1206                        "root": root_str(&root),
1207                    }),
1208                )
1209                .await
1210                .unwrap();
1211            assert_eq!(r["status"], "success", "got: {r}");
1212        }
1213        let listed = list
1214            .call(&mut ctx, json!({"root": root_str(&root)}))
1215            .await
1216            .unwrap();
1217        assert_eq!(listed["count"], 1, "renewal must not duplicate entries");
1218        assert_eq!(listed["leases"][0]["ttl_secs"], 60);
1219    }
1220
1221    #[tokio::test]
1222    async fn claim_rejects_missing_intent_and_bad_ttl() {
1223        let (_guard, root) = git_repo();
1224        let claim = CodeClaimTool::new(None);
1225        let mut ctx = Context::default();
1226
1227        let no_intent = claim
1228            .call(
1229                &mut ctx,
1230                json!({
1231                    "scope": "src/x.rs",
1232                    "owner_session": "s",
1233                    "root": root_str(&root),
1234                }),
1235            )
1236            .await;
1237        assert!(no_intent.is_err(), "intent is mandatory");
1238
1239        let bad_ttl = claim
1240            .call(
1241                &mut ctx,
1242                json!({
1243                    "scope": "src/x.rs",
1244                    "intent": "y",
1245                    "owner_session": "s",
1246                    "ttl_secs": 0,
1247                    "root": root_str(&root),
1248                }),
1249            )
1250            .await;
1251        assert!(bad_ttl.is_err(), "ttl_secs must be >= 1");
1252    }
1253
1254    #[tokio::test]
1255    async fn non_git_root_is_a_clear_error() {
1256        let dir = tempfile::tempdir().unwrap();
1257        let claim = CodeClaimTool::new(None);
1258        let mut ctx = Context::default();
1259        let err = claim
1260            .call(
1261                &mut ctx,
1262                json!({
1263                    "scope": "src/x.rs",
1264                    "intent": "y",
1265                    "owner_session": "s",
1266                    "root": dir.path().display().to_string(),
1267                }),
1268            )
1269            .await
1270            .unwrap_err();
1271        assert!(err.to_string().contains("git repository"), "got: {err}");
1272    }
1273
1274    #[tokio::test]
1275    async fn list_hides_expired_by_default_but_can_include_them() {
1276        let (_guard, root) = git_repo();
1277        let claim = CodeClaimTool::new(None);
1278        let list = CodeListTool::new();
1279        let mut ctx = Context::default();
1280
1281        claim
1282            .call(
1283                &mut ctx,
1284                json!({
1285                    "scope": "src/old.rs",
1286                    "intent": "long gone",
1287                    "owner_session": "session-old",
1288                    "root": root_str(&root),
1289                }),
1290            )
1291            .await
1292            .unwrap();
1293
1294        let path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1295        let raw = std::fs::read_to_string(&path).unwrap();
1296        let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1297        file.leases[0].expires_at = "2020-01-01T00:00:00Z".into();
1298        std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
1299
1300        // First observation discovers the expiry (and prunes it), so the
1301        // inclusive listing must come first.
1302        let with_expired = list
1303            .call(
1304                &mut ctx,
1305                json!({"root": root_str(&root), "include_expired": true}),
1306            )
1307            .await
1308            .unwrap();
1309        assert_eq!(with_expired["count"], 1);
1310        assert_eq!(with_expired["leases"][0]["expired"], true);
1311
1312        let default_list = list
1313            .call(&mut ctx, json!({"root": root_str(&root)}))
1314            .await
1315            .unwrap();
1316        assert_eq!(default_list["count"], 0, "expired leases hidden by default");
1317    }
1318
1319    #[test]
1320    fn coordination_effect_shapes_are_dedicated() {
1321        let claim = CodeClaimTool::new(None);
1322        assert!(claim.effects().acquires_coordination_lease());
1323        assert!(!claim.effects().is_coordination_cleanup());
1324        assert!(
1325            !claim.effects().destructive,
1326            "claims must not be confirm-gated"
1327        );
1328
1329        let release = CodeReleaseTool::new(None);
1330        assert!(release.effects().is_coordination_cleanup());
1331        assert!(!release.effects().acquires_coordination_lease());
1332        assert!(
1333            !release.effects().destructive,
1334            "cleanup must not be confirm-gated"
1335        );
1336
1337        // Observations stay read-only.
1338        let check = CodeCheckTool::new(None);
1339        assert!(check.effects().writes.is_empty());
1340        assert!(check.effects().is_available_in(wm_core::BrainWave::Gamma));
1341        let list = CodeListTool::new();
1342        assert!(list.effects().writes.is_empty());
1343    }
1344
1345    #[tokio::test]
1346    async fn strict_snapshot_is_read_only_and_leaves_no_lock_or_tmp_files() {
1347        let (_guard, root) = git_repo();
1348        let claim = CodeClaimTool::new(None);
1349        let mut ctx = Context::default();
1350        claim
1351            .call(
1352                &mut ctx,
1353                json!({"scope": "readonly/", "intent": "t", "owner_session": "owner-a", "root": root_str(&root)}),
1354            )
1355            .await
1356            .unwrap();
1357
1358        // Add one logically expired lease without any tool call.
1359        let ledger_path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1360        let raw = std::fs::read_to_string(&ledger_path).unwrap();
1361        let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1362        file.leases.push(Lease {
1363            scope: "expired/".into(),
1364            intent: "old".into(),
1365            owner_session: "owner-b".into(),
1366            claimed_at: "2020-01-01T00:00:00Z".into(),
1367            expires_at: "2020-01-01T00:00:01Z".into(),
1368            ttl_secs: 1,
1369        });
1370        std::fs::write(&ledger_path, serde_json::to_string(&file).unwrap()).unwrap();
1371
1372        let before = std::fs::read(&ledger_path).unwrap();
1373        let before_mtime = std::fs::metadata(&ledger_path).unwrap().modified().unwrap();
1374
1375        let check = CodeCheckTool::new(None);
1376        let r = check
1377            .call(
1378                &mut ctx,
1379                json!({"scope": "expired/", "root": root_str(&root)}),
1380            )
1381            .await
1382            .unwrap();
1383        assert_eq!(
1384            r["state"], "free",
1385            "expired leases are logically absent: {r}"
1386        );
1387
1388        let list = CodeListTool::new();
1389        let r = list
1390            .call(&mut ctx, json!({"root": root_str(&root)}))
1391            .await
1392            .unwrap();
1393        assert_eq!(r["count"], 1, "only the active lease is listed: {r}");
1394        let r = list
1395            .call(
1396                &mut ctx,
1397                json!({"root": root_str(&root), "include_expired": true}),
1398            )
1399            .await
1400            .unwrap();
1401        assert_eq!(r["count"], 2, "expired leases stay reportable: {r}");
1402
1403        assert_eq!(
1404            std::fs::read(&ledger_path).unwrap(),
1405            before,
1406            "check/list must not rewrite the ledger"
1407        );
1408        assert_eq!(
1409            std::fs::metadata(&ledger_path).unwrap().modified().unwrap(),
1410            before_mtime,
1411            "check/list must not touch the ledger mtime"
1412        );
1413        assert!(
1414            !std::fs::read_dir(ledger_path.parent().unwrap())
1415                .unwrap()
1416                .any(|e| {
1417                    let name = e.unwrap().file_name().to_string_lossy().to_string();
1418                    name.contains("wm-leases.json.lock") || name.contains("wm-leases.json.tmp")
1419                }),
1420            "read-only snapshot must not create lock or temp files"
1421        );
1422    }
1423
1424    #[tokio::test]
1425    async fn release_refuses_alternate_root_when_configured() {
1426        let (_guard_a, root_a) = git_repo();
1427        let (_guard_b, root_b) = git_repo();
1428        let mut ctx = Context::default();
1429
1430        let claim = CodeClaimTool::new(None);
1431        claim
1432            .call(
1433                &mut ctx,
1434                json!({"scope": "cleanup/", "intent": "t", "owner_session": "owner-a", "root": root_str(&root_a)}),
1435            )
1436            .await
1437            .unwrap();
1438        claim
1439            .call(
1440                &mut ctx,
1441                json!({"scope": "cleanup/", "intent": "t", "owner_session": "owner-a", "root": root_str(&root_b)}),
1442            )
1443            .await
1444            .unwrap();
1445
1446        let release = CodeReleaseTool::new(None).with_configured_root(Some(root_a.clone()));
1447        let refused = release
1448            .call(
1449                &mut ctx,
1450                json!({"scope": "cleanup/", "owner_session": "owner-a", "root": root_str(&root_b)}),
1451            )
1452            .await;
1453        let err = refused.unwrap_err().to_string();
1454        assert!(err.contains("alternate root"), "{err}");
1455        let (active_b, _) = LeaseLedger::discover(&root_b)
1456            .unwrap()
1457            .snapshot_readonly()
1458            .unwrap();
1459        assert_eq!(
1460            active_b.len(),
1461            1,
1462            "a refused alternate-root cleanup must not mutate the other ledger"
1463        );
1464
1465        // The configured repository's fixed ledger is eligible.
1466        let ok = release
1467            .call(
1468                &mut ctx,
1469                json!({"scope": "cleanup/", "owner_session": "owner-a", "root": root_str(&root_a)}),
1470            )
1471            .await
1472            .unwrap();
1473        assert_eq!(ok["state"], "released");
1474    }
1475}