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