Skip to main content

wm_tools/expansion/
coordination.rs

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