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