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