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