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