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
70#[derive(Debug, Clone)]
72pub struct LeaseLedger {
73 path: PathBuf,
74}
75
76impl LeaseLedger {
77 pub fn discover(root: &Path) -> wm_core::Result<Self> {
90 let common = git_common_dir(root).ok_or_else(|| {
91 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 Ok(Self {
96 path: common.join("wm-leases.json"),
97 })
98 }
99
100 #[must_use]
101 pub fn path(&self) -> &Path {
102 &self.path
103 }
104
105 fn lock_path(&self) -> PathBuf {
106 let name = self.path.file_name().map_or_else(
107 || "wm-leases.json.lock".to_string(),
108 |n| format!("{}.lock", n.to_string_lossy()),
109 );
110 self.path.with_file_name(name)
111 }
112
113 fn acquire_lock(&self) -> wm_core::Result<()> {
117 let lock = self.lock_path();
118 for _ in 0..LOCK_ATTEMPTS {
119 match std::fs::OpenOptions::new()
120 .write(true)
121 .create_new(true)
122 .open(&lock)
123 {
124 Ok(_) => return Ok(()),
125 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
126 if let Ok(meta) = std::fs::metadata(&lock) {
127 if let Ok(modified) = meta.modified() {
128 let age = DateTime::<Utc>::from(modified);
129 if Utc::now() - age > Duration::seconds(STALE_LOCK_SECS) {
130 let _ = std::fs::remove_file(&lock);
131 continue;
132 }
133 }
134 }
135 std::thread::sleep(std::time::Duration::from_millis(LOCK_SLEEP_MS));
136 }
137 Err(e) => {
138 return Err(CoreError::Tool(format!(
139 "could not create lease lock {}: {e}",
140 lock.display()
141 )));
142 }
143 }
144 }
145 Err(CoreError::Tool(format!(
146 "lease ledger is busy (lock held past {}s) — retry shortly: {}",
147 (LOCK_ATTEMPTS as u64 * LOCK_SLEEP_MS) / 1000,
148 lock.display()
149 )))
150 }
151
152 fn release_lock(&self) {
153 let _ = std::fs::remove_file(self.lock_path());
154 }
155
156 fn parse_file(&self) -> Vec<Lease> {
157 let Ok(raw) = std::fs::read_to_string(&self.path) else {
158 return Vec::new();
159 };
160 let Ok(parsed) = serde_json::from_str::<LeaseFile>(&raw) else {
161 tracing::warn!(
164 path = %self.path.display(),
165 "wm-leases.json unreadable — treating ledger as empty"
166 );
167 return Vec::new();
168 };
169 parsed.leases
170 }
171
172 fn mutate<T>(
176 &self,
177 f: impl FnOnce(&mut Vec<Lease>, &[Lease]) -> wm_core::Result<T>,
178 ) -> wm_core::Result<T> {
179 if let Some(parent) = self.path.parent() {
180 std::fs::create_dir_all(parent).map_err(|e| {
181 CoreError::Tool(format!("could not create {}: {e}", parent.display()))
182 })?;
183 }
184 self.acquire_lock()?;
185 let result = (|| {
186 let now = Utc::now();
187 let all = self.parse_file();
188 let (mut active, expired): (Vec<Lease>, Vec<Lease>) =
189 all.into_iter()
190 .partition(|l| match DateTime::parse_from_rfc3339(&l.expires_at) {
191 Ok(exp) => exp.with_timezone(&Utc) > now,
192 Err(_) => false, });
194 let pre = active.clone();
195 let out = f(&mut active, &expired)?;
196 if active == pre && expired.is_empty() {
200 return Ok(out);
201 }
202 let file = LeaseFile {
203 version: 1,
204 leases: active,
205 };
206 let tmp = self
207 .path
208 .with_file_name(format!("wm-leases.json.tmp.{}", std::process::id()));
209 let body = serde_json::to_string_pretty(&file)
210 .map_err(|e| CoreError::Tool(format!("lease serialization failed: {e}")))?;
211 std::fs::write(&tmp, body)
212 .map_err(|e| CoreError::Tool(format!("lease write failed: {e}")))?;
213 std::fs::rename(&tmp, &self.path)
214 .map_err(|e| CoreError::Tool(format!("lease atomic rename failed: {e}")))?;
215 Ok(out)
216 })();
217 self.release_lock();
218 result
219 }
220
221 #[cfg(test)]
227 pub(crate) fn snapshot(&self) -> wm_core::Result<(Vec<Lease>, Vec<Lease>)> {
228 self.mutate(|active, expired| Ok((active.clone(), expired.to_vec())))
229 }
230
231 pub(crate) fn snapshot_readonly(&self) -> wm_core::Result<(Vec<Lease>, Vec<Lease>)> {
237 let now = Utc::now();
238 let all = self.parse_file();
239 let (active, expired): (Vec<Lease>, Vec<Lease>) =
240 all.into_iter()
241 .partition(|l| match DateTime::parse_from_rfc3339(&l.expires_at) {
242 Ok(exp) => exp.with_timezone(&Utc) > now,
243 Err(_) => false, });
245 Ok((active, expired))
246 }
247
248 pub fn try_claim(
263 &self,
264 scope: &str,
265 intent: &str,
266 owner: &str,
267 ttl_secs: i64,
268 ) -> wm_core::Result<Result<Lease, Lease>> {
269 let claimed_at = now_rfc3339();
270 let expires_at = (Utc::now() + Duration::seconds(ttl_secs))
271 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
272 self.mutate(|leases, _expired| {
273 if let Some(existing) = leases.iter_mut().find(|l| l.scope == scope) {
274 if existing.owner_session == owner {
275 existing.intent = intent.to_string();
276 existing.claimed_at.clone_from(&claimed_at);
277 existing.expires_at.clone_from(&expires_at);
278 existing.ttl_secs = ttl_secs;
279 return Ok(Ok(existing.clone()));
280 }
281 return Ok(Err(existing.clone()));
282 }
283 let lease = Lease {
284 scope: scope.to_string(),
285 intent: intent.to_string(),
286 owner_session: owner.to_string(),
287 claimed_at: claimed_at.clone(),
288 expires_at: expires_at.clone(),
289 ttl_secs,
290 };
291 leases.push(lease.clone());
292 Ok(Ok(lease))
293 })
294 }
295
296 pub fn release_scope(&self, scope: &str, owner: &str) -> wm_core::Result<Result<bool, Lease>> {
300 self.mutate(|leases, _expired| {
301 let Some(pos) = leases.iter().position(|l| l.scope == scope) else {
302 return Ok(Ok(false));
303 };
304 if leases[pos].owner_session != owner {
305 return Ok(Err(leases[pos].clone()));
306 }
307 leases.remove(pos);
308 Ok(Ok(true))
309 })
310 }
311
312 pub fn force_release_peer(&self, peer: &str, prefix: &str) -> wm_core::Result<Vec<String>> {
317 self.mutate(|leases, _expired| {
318 let mut freed = Vec::new();
319 leases.retain(|l| {
320 if l.owner_session == peer && l.scope.starts_with(prefix) {
321 freed.push(l.scope.clone());
322 return false;
323 }
324 true
325 });
326 Ok(freed)
327 })
328 }
329}
330
331fn now_rfc3339() -> String {
335 wm_core::time::now_rfc3339()
336}
337
338pub(crate) fn clamp_ttl(ttl: i64) -> wm_core::Result<i64> {
339 if !(1..=MAX_TTL_SECS).contains(&ttl) {
340 return Err(CoreError::InvalidArgs(format!(
341 "ttl_secs must be between 1 and {MAX_TTL_SECS} (a claim is coordination state, not a tombstone)"
342 )));
343 }
344 Ok(ttl)
345}
346
347pub(crate) fn require_str(args: &Value, key: &str) -> wm_core::Result<String> {
348 args.get(key)
349 .and_then(|v| v.as_str())
350 .map(str::trim)
351 .filter(|s| !s.is_empty())
352 .map(str::to_string)
353 .ok_or_else(|| {
354 CoreError::InvalidArgs(format!(
355 "'{key}' is required and must be a non-empty string"
356 ))
357 })
358}
359
360fn git_common_dir(root: &Path) -> Option<PathBuf> {
374 let start = if root.is_absolute() {
375 root.to_path_buf()
376 } else {
377 std::env::current_dir().ok()?.join(root)
378 };
379 let mut dir = start.as_path();
380 loop {
381 let dot_git = dir.join(".git");
382 if dot_git.is_dir() {
383 return Some(canonical_or(dot_git));
384 }
385 if dot_git.is_file() {
386 let text = std::fs::read_to_string(&dot_git).ok()?;
387 let target = text.lines().find_map(|l| l.strip_prefix("gitdir:"))?.trim();
388 if target.is_empty() {
389 return None;
390 }
391 let target_path = Path::new(target);
392 let gitdir = if target_path.is_absolute() {
393 target_path.to_path_buf()
394 } else {
395 dir.join(target_path)
396 };
397 let commondir_file = gitdir.join("commondir");
398 let common = match std::fs::read_to_string(&commondir_file) {
399 Ok(text) => {
400 let target = text.trim();
401 if target.is_empty() {
402 return None;
403 }
404 let p = Path::new(target);
405 if p.is_absolute() {
406 p.to_path_buf()
407 } else {
408 gitdir.join(p)
409 }
410 }
411 Err(_) => gitdir,
412 };
413 return Some(canonical_or(common));
414 }
415 if dir.join("HEAD").is_file() && dir.join("objects").is_dir() {
416 return Some(canonical_or(dir.to_path_buf()));
417 }
418 dir = dir.parent()?;
419 }
420}
421
422fn canonical_or(path: PathBuf) -> PathBuf {
425 std::fs::canonicalize(&path).unwrap_or(path)
426}
427
428pub(crate) fn resolve_root(args: &Value) -> wm_core::Result<PathBuf> {
429 args.get("root")
430 .and_then(|v| v.as_str())
431 .map(str::trim)
432 .filter(|s| !s.is_empty())
433 .map(PathBuf::from)
434 .or_else(|| {
435 std::env::var("WM_PROJECT_ROOT")
436 .ok()
437 .filter(|s| !s.trim().is_empty())
438 .map(PathBuf::from)
439 })
440 .ok_or_else(|| {
441 CoreError::InvalidArgs(
442 "no repository root — pass root=<repo path> or set WM_PROJECT_ROOT".into(),
443 )
444 })
445}
446
447fn emit(gan_ying: Option<&Arc<Mutex<GanYingBus>>>, event_type: EventType, payload: Value) {
448 if let Some(bus) = gan_ying {
449 if let Ok(mut gy) = bus.lock() {
450 gy.emit(event_type, "code.coordination", payload);
451 }
452 }
453}
454
455fn lease_json(lease: &Lease) -> Value {
456 json!({
457 "lease_id": lease.scope,
458 "scope": lease.scope,
459 "intent": lease.intent,
460 "owner_session": lease.owner_session,
461 "claimed_at": lease.claimed_at,
462 "expires_at": lease.expires_at,
463 "ttl_secs": lease.ttl_secs,
464 })
465}
466
467const CONFLICT_NEXT_ACTION: &str =
468 "wait for expiry, ask the holder to code.release the scope, or claim a different scope";
469
470pub struct CodeClaimTool {
474 stats: ToolStats,
475 effects: EffectRow,
476 gan_ying: Option<Arc<Mutex<GanYingBus>>>,
477}
478
479impl CodeClaimTool {
480 #[must_use]
481 pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
482 Self {
483 stats: ToolStats::default(),
484 effects: EffectRow {
490 reads: vec![Resource::Filesystem],
491 writes: vec![Resource::CoordinationLease],
492 ..Default::default()
493 },
494 gan_ying,
495 }
496 }
497}
498
499#[async_trait]
500impl Tool for CodeClaimTool {
501 fn name(&self) -> &str {
502 "code.claim"
503 }
504 fn gana(&self) -> Gana {
505 Gana::Room
506 }
507 fn effects(&self) -> &EffectRow {
508 &self.effects
509 }
510 fn input_schema(&self) -> Value {
511 super::common::schema(
512 &json!({
513 "scope": super::common::str_prop("Scope to claim — path, subtree, or resource label (e.g. 'src/expansion/')"),
514 "intent": super::common::str_prop("Why this scope is claimed (mandatory — surfaced to conflicting agents)"),
515 "owner_session": super::common::str_prop("Claiming session id (session.start result) or stable agent label"),
516 "ttl_secs": super::common::int_prop("Lease TTL in seconds (default 3600, max 86400; expired claims free themselves)"),
517 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
518 }),
519 &["scope", "intent", "owner_session"],
520 )
521 }
522 fn description(&self) -> &str {
523 "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."
524 }
525 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
526 let scope = require_str(&args, "scope")?;
527 let intent = require_str(&args, "intent")?;
528 let owner = require_str(&args, "owner_session")?;
529 let ttl = match args.get("ttl_secs").and_then(serde_json::Value::as_i64) {
530 Some(t) => clamp_ttl(t)?,
531 None => DEFAULT_TTL_SECS,
532 };
533 let root = resolve_root(&args)?;
534 let ledger = LeaseLedger::discover(&root)?;
535 let claimed_at = now_rfc3339();
536 let expires_at = (Utc::now() + Duration::seconds(ttl))
537 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
538
539 let mut newly_expired: Vec<Lease> = Vec::new();
540 let result = ledger.mutate(|leases, expired| {
541 newly_expired = expired.to_vec();
542 if let Some(existing) = leases.iter_mut().find(|l| l.scope == scope) {
543 if existing.owner_session == owner {
544 existing.intent.clone_from(&intent);
546 existing.claimed_at.clone_from(&claimed_at);
547 existing.expires_at.clone_from(&expires_at);
548 existing.ttl_secs = ttl;
549 return Ok(json!({
550 "status": "success",
551 "renewed": true,
552 "lease_id": scope,
553 "scope": scope,
554 "intent": intent,
555 "owner_session": owner,
556 "claimed_at": claimed_at,
557 "expires_at": expires_at,
558 "ttl_secs": ttl,
559 "note": "advisory lease renewed — release with code.release when done"
560 }));
561 }
562 let holder = existing.clone();
563 return Ok(json!({
564 "status": "conflict",
565 "scope": scope,
566 "requested_by": owner,
567 "holder": holder.owner_session,
568 "holder_intent": holder.intent,
569 "claimed_at": holder.claimed_at,
570 "expires_at": holder.expires_at,
571 "next_action": CONFLICT_NEXT_ACTION,
572 "advisory": true,
573 }));
574 }
575 leases.push(Lease {
576 scope: scope.clone(),
577 intent: intent.clone(),
578 owner_session: owner.clone(),
579 claimed_at: claimed_at.clone(),
580 expires_at: expires_at.clone(),
581 ttl_secs: ttl,
582 });
583 Ok(json!({
584 "status": "success",
585 "lease_id": scope,
586 "scope": scope,
587 "intent": intent,
588 "owner_session": owner,
589 "claimed_at": claimed_at,
590 "expires_at": expires_at,
591 "ttl_secs": ttl,
592 "note": "advisory lease — release with code.release when done"
593 }))
594 });
595
596 for lease in &newly_expired {
597 emit(
598 self.gan_ying.as_ref(),
599 EventType::CoordinationClaimExpired,
600 json!({"scope": lease.scope, "owner_session": lease.owner_session}),
601 );
602 }
603
604 let result = result?;
605 if result["status"] == "success" {
606 emit(
607 self.gan_ying.as_ref(),
608 EventType::CoordinationClaimAcquired,
609 json!({"scope": scope, "owner_session": owner, "intent": intent}),
610 );
611 } else if result["status"] == "conflict" {
612 emit(
613 self.gan_ying.as_ref(),
614 EventType::CoordinationClaimDenied,
615 json!({"scope": scope, "requested_by": owner, "holder": result["holder"]}),
616 );
617 }
618 Ok(result)
619 }
620 fn stats(&self) -> &ToolStats {
621 &self.stats
622 }
623}
624
625pub struct CodeCheckTool {
629 stats: ToolStats,
630 effects: EffectRow,
631 gan_ying: Option<Arc<Mutex<GanYingBus>>>,
632}
633
634impl CodeCheckTool {
635 #[must_use]
636 pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
637 Self {
638 stats: ToolStats::default(),
639 effects: EffectRow {
640 reads: vec![Resource::Filesystem],
641 ..Default::default()
642 },
643 gan_ying,
644 }
645 }
646}
647
648#[async_trait]
649impl Tool for CodeCheckTool {
650 fn name(&self) -> &str {
651 "code.check"
652 }
653 fn gana(&self) -> Gana {
654 Gana::Room
655 }
656 fn effects(&self) -> &EffectRow {
657 &self.effects
658 }
659 fn input_schema(&self) -> Value {
660 super::common::schema(
661 &json!({
662 "scope": super::common::str_prop("Scope to check"),
663 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
664 }),
665 &["scope"],
666 )
667 }
668 fn description(&self) -> &str {
669 "Check whether a scope is claimed — reports the holder, their intent, and expiry when claimed; 'free' means no active lease."
670 }
671 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
672 let scope = require_str(&args, "scope")?;
673 let root = resolve_root(&args)?;
674 let ledger = LeaseLedger::discover(&root)?;
675
676 let (active, newly_expired) = ledger.snapshot_readonly()?;
679 let holder = active.iter().find(|l| l.scope == scope).map(lease_json);
680
681 for lease in &newly_expired {
682 emit(
683 self.gan_ying.as_ref(),
684 EventType::CoordinationClaimExpired,
685 json!({"scope": lease.scope, "owner_session": lease.owner_session}),
686 );
687 }
688
689 match holder {
690 Some(h) => Ok(json!({
691 "status": "success",
692 "scope": scope,
693 "state": "claimed",
694 "holder": h["owner_session"],
695 "intent": h["intent"],
696 "expires_at": h["expires_at"],
697 "next_action": CONFLICT_NEXT_ACTION,
698 })),
699 None => Ok(json!({
700 "status": "success",
701 "scope": scope,
702 "state": "free",
703 })),
704 }
705 }
706 fn stats(&self) -> &ToolStats {
707 &self.stats
708 }
709}
710
711pub struct CodeReleaseTool {
715 stats: ToolStats,
716 effects: EffectRow,
717 gan_ying: Option<Arc<Mutex<GanYingBus>>>,
718 configured_root: Option<PathBuf>,
723}
724
725impl CodeReleaseTool {
726 #[must_use]
727 pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
728 Self {
729 stats: ToolStats::default(),
730 effects: EffectRow {
734 reads: vec![Resource::Filesystem],
735 writes: vec![Resource::CoordinationRelease],
736 ..Default::default()
737 },
738 gan_ying,
739 configured_root: std::env::var("WM_PROJECT_ROOT")
740 .ok()
741 .map(|s| s.trim().to_string())
742 .filter(|s| !s.is_empty())
743 .map(PathBuf::from),
744 }
745 }
746
747 #[must_use]
750 pub fn with_configured_root(mut self, root: Option<PathBuf>) -> Self {
751 self.configured_root = root;
752 self
753 }
754}
755
756#[async_trait]
757impl Tool for CodeReleaseTool {
758 fn name(&self) -> &str {
759 "code.release"
760 }
761 fn gana(&self) -> Gana {
762 Gana::Room
763 }
764 fn effects(&self) -> &EffectRow {
765 &self.effects
766 }
767 fn input_schema(&self) -> Value {
768 super::common::schema(
769 &json!({
770 "scope": super::common::str_prop("Scope to release (the claim's lease_id)"),
771 "owner_session": super::common::str_prop("Releasing session id — must match the claim's owner"),
772 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
773 }),
774 &["scope", "owner_session"],
775 )
776 }
777 fn description(&self) -> &str {
778 "Release a claimed scope when work is done — only the owning session can release; releasing a free scope is an idempotent no-op."
779 }
780 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
781 let scope = require_str(&args, "scope")?;
782 let owner = require_str(&args, "owner_session")?;
783 let root = resolve_root(&args)?;
784 let ledger = LeaseLedger::discover(&root)?;
785 if let Some(configured) = &self.configured_root {
790 if let Ok(configured_ledger) = LeaseLedger::discover(configured) {
791 if configured_ledger.path() != ledger.path() {
792 return Err(CoreError::Tool(format!(
793 "code.release refuses an alternate root — cleanup is permitted only against the configured repository's ledger {}",
794 configured_ledger.path().display()
795 )));
796 }
797 }
798 }
799
800 let outcome = ledger.mutate(|leases, _expired| {
801 let Some(pos) = leases.iter().position(|l| l.scope == scope) else {
802 return Ok(json!({
803 "status": "success",
804 "scope": scope,
805 "state": "free",
806 "note": "no active claim on this scope (already released or expired)"
807 }));
808 };
809 let lease = &leases[pos];
810 if lease.owner_session != owner {
811 return Ok(json!({
812 "status": "not_owner",
813 "scope": scope,
814 "holder": lease.owner_session,
815 "holder_intent": lease.intent,
816 "expires_at": lease.expires_at,
817 "note": "only the owning session can release a claim",
818 }));
819 }
820 leases.remove(pos);
821 Ok(json!({
822 "status": "success",
823 "scope": scope,
824 "state": "released",
825 "owner_session": owner,
826 }))
827 })?;
828
829 if outcome["status"] == "success" && outcome["state"] == "released" {
830 emit(
831 self.gan_ying.as_ref(),
832 EventType::CoordinationClaimReleased,
833 json!({"scope": scope, "owner_session": owner}),
834 );
835 }
836 Ok(outcome)
837 }
838 fn stats(&self) -> &ToolStats {
839 &self.stats
840 }
841}
842
843pub struct CodeListTool {
847 stats: ToolStats,
848 effects: EffectRow,
849}
850
851impl CodeListTool {
852 #[must_use]
853 pub fn new() -> Self {
854 Self {
855 stats: ToolStats::default(),
856 effects: EffectRow {
857 reads: vec![Resource::Filesystem],
858 ..Default::default()
859 },
860 }
861 }
862}
863
864impl Default for CodeListTool {
865 fn default() -> Self {
866 Self::new()
867 }
868}
869
870#[async_trait]
871impl Tool for CodeListTool {
872 fn name(&self) -> &str {
873 "code.list"
874 }
875 fn gana(&self) -> Gana {
876 Gana::Room
877 }
878 fn effects(&self) -> &EffectRow {
879 &self.effects
880 }
881 fn input_schema(&self) -> Value {
882 super::common::schema(
883 &json!({
884 "include_expired": super::common::bool_prop("Include expired leases in the listing (default false)"),
885 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
886 }),
887 &[],
888 )
889 }
890 fn description(&self) -> &str {
891 "List active claims in the shared lease ledger — what each agent is holding and until when."
892 }
893 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
894 let include_expired = args
895 .get("include_expired")
896 .and_then(serde_json::Value::as_bool)
897 .unwrap_or(false);
898 let root = resolve_root(&args)?;
899 let ledger = LeaseLedger::discover(&root)?;
900 let (active, expired) = ledger.snapshot_readonly()?;
903 let mut leases: Vec<Value> = active.iter().map(lease_json).collect();
904 if include_expired {
905 let mut expired_json: Vec<Value> = expired
906 .iter()
907 .map(|l| {
908 let mut v = lease_json(l);
909 v["expired"] = json!(true);
910 v
911 })
912 .collect();
913 leases.append(&mut expired_json);
914 }
915 let count = leases.len();
916 Ok(json!({
917 "status": "success",
918 "count": count,
919 "leases": leases,
920 "file": ledger.path().display().to_string(),
921 }))
922 }
923 fn stats(&self) -> &ToolStats {
924 &self.stats
925 }
926}
927
928#[must_use]
930pub fn register_coordination(
931 registry: &wm_dispatch::ToolRegistry,
932 gan_ying_bus: Option<&Arc<Mutex<GanYingBus>>>,
933) -> wm_dispatch::ToolRegistry {
934 registry
935 .register(Arc::new(CodeClaimTool::new(gan_ying_bus.cloned())))
936 .register(Arc::new(CodeCheckTool::new(gan_ying_bus.cloned())))
937 .register(Arc::new(CodeReleaseTool::new(gan_ying_bus.cloned())))
938 .register(Arc::new(CodeListTool::new()))
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944
945 fn git_repo() -> (tempfile::TempDir, PathBuf) {
947 let dir = tempfile::tempdir().unwrap();
948 let root = dir.path().to_path_buf();
949 let run = |args: &[&str]| {
950 std::process::Command::new("git")
951 .args(args)
952 .current_dir(&root)
953 .output()
954 .expect("git must be available")
955 };
956 assert!(run(&["init", "-q"]).status.success());
957 assert!(run(&["config", "user.email", "t@t"]).status.success());
958 assert!(run(&["config", "user.name", "t"]).status.success());
959 assert!(
960 run(&["commit", "--allow-empty", "-m", "c1"])
961 .status
962 .success()
963 );
964 (dir, root)
965 }
966
967 fn root_str(root: &Path) -> Value {
968 json!(root.display().to_string())
969 }
970
971 fn fake_repo(root: &Path) {
974 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
975 std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
976 }
977
978 #[test]
979 fn discover_walks_up_without_spawning_git() {
980 let dir = tempfile::tempdir().unwrap();
981 let repo = dir.path().join("repo");
982 fake_repo(&repo);
983 let nested = repo.join("crates/inner/src");
984 std::fs::create_dir_all(&nested).unwrap();
985
986 let from_root = LeaseLedger::discover(&repo).unwrap().path().to_path_buf();
987 let from_nested = LeaseLedger::discover(&nested).unwrap().path().to_path_buf();
988 assert!(from_root.ends_with(".git/wm-leases.json"), "{from_root:?}");
989 assert_eq!(
990 from_root, from_nested,
991 "any path inside the repo resolves to one ledger"
992 );
993 }
994
995 #[test]
996 fn discover_follows_worktree_commondir() {
997 let dir = tempfile::tempdir().unwrap();
998 let main = dir.path().join("main");
999 fake_repo(&main);
1000 let wt = dir.path().join("worktree");
1001 std::fs::create_dir_all(&wt).unwrap();
1002 let wt_gitdir = main.join(".git/worktrees/wt");
1003 std::fs::create_dir_all(&wt_gitdir).unwrap();
1004 std::fs::write(wt_gitdir.join("commondir"), "../..\n").unwrap();
1005 std::fs::write(
1006 wt.join(".git"),
1007 format!("gitdir: {}\n", wt_gitdir.display()),
1008 )
1009 .unwrap();
1010
1011 let main_ledger = LeaseLedger::discover(&main).unwrap().path().to_path_buf();
1012 let wt_ledger = LeaseLedger::discover(&wt).unwrap().path().to_path_buf();
1013 assert_eq!(
1014 main_ledger, wt_ledger,
1015 "worktree leases share the main checkout's ledger"
1016 );
1017 assert!(wt_ledger.ends_with(".git/wm-leases.json"), "{wt_ledger:?}");
1018 }
1019
1020 #[test]
1021 fn discover_rejects_paths_outside_a_repository() {
1022 let dir = tempfile::tempdir().unwrap();
1023 let err = LeaseLedger::discover(dir.path()).unwrap_err().to_string();
1024 assert!(err.contains("requires a git repository"), "{err}");
1025 }
1026
1027 #[tokio::test]
1028 async fn claim_then_conflict_names_holder_and_intent() {
1029 let (_guard, root) = git_repo();
1030 let a = CodeClaimTool::new(None);
1031 let b = CodeClaimTool::new(None);
1032 let mut ctx = Context::default();
1033
1034 let first = a
1035 .call(
1036 &mut ctx,
1037 json!({
1038 "scope": "src/expansion/",
1039 "intent": "refactoring session tools",
1040 "owner_session": "session-aaa",
1041 "root": root_str(&root),
1042 }),
1043 )
1044 .await
1045 .unwrap();
1046 assert_eq!(first["status"], "success", "got: {first}");
1047 assert_eq!(first["lease_id"], "src/expansion/");
1048 assert_eq!(first["owner_session"], "session-aaa");
1049
1050 let second = b
1051 .call(
1052 &mut ctx,
1053 json!({
1054 "scope": "src/expansion/",
1055 "intent": "unrelated edits",
1056 "owner_session": "session-bbb",
1057 "root": root_str(&root),
1058 }),
1059 )
1060 .await
1061 .unwrap();
1062 assert_eq!(second["status"], "conflict", "got: {second}");
1063 assert_eq!(second["holder"], "session-aaa");
1064 assert_eq!(second["holder_intent"], "refactoring session tools");
1065 assert!(
1066 second["next_action"]
1067 .as_str()
1068 .unwrap()
1069 .contains("code.release")
1070 );
1071 }
1072
1073 #[tokio::test]
1074 async fn check_reports_claimed_then_free_zero_false_free() {
1075 let (_guard, root) = git_repo();
1076 let claim = CodeClaimTool::new(None);
1077 let check = CodeCheckTool::new(None);
1078 let mut ctx = Context::default();
1079
1080 let free = check
1081 .call(&mut ctx, json!({"scope": "docs/", "root": root_str(&root)}))
1082 .await
1083 .unwrap();
1084 assert_eq!(free["state"], "free");
1085
1086 claim
1087 .call(
1088 &mut ctx,
1089 json!({
1090 "scope": "docs/",
1091 "intent": "doc rewrite",
1092 "owner_session": "session-aaa",
1093 "root": root_str(&root),
1094 }),
1095 )
1096 .await
1097 .unwrap();
1098
1099 let claimed = check
1100 .call(&mut ctx, json!({"scope": "docs/", "root": root_str(&root)}))
1101 .await
1102 .unwrap();
1103 assert_eq!(claimed["state"], "claimed", "got: {claimed}");
1104 assert_eq!(claimed["holder"], "session-aaa");
1105 assert_eq!(claimed["intent"], "doc rewrite");
1106 }
1107
1108 #[tokio::test]
1109 async fn release_requires_owner_then_scope_frees() {
1110 let (_guard, root) = git_repo();
1111 let claim = CodeClaimTool::new(None);
1112 let release = CodeReleaseTool::new(None);
1113 let check = CodeCheckTool::new(None);
1114 let mut ctx = Context::default();
1115
1116 claim
1117 .call(
1118 &mut ctx,
1119 json!({
1120 "scope": "src/foo.rs",
1121 "intent": "bugfix",
1122 "owner_session": "session-aaa",
1123 "root": root_str(&root),
1124 }),
1125 )
1126 .await
1127 .unwrap();
1128
1129 let wrong = release
1130 .call(
1131 &mut ctx,
1132 json!({
1133 "scope": "src/foo.rs",
1134 "owner_session": "session-bbb",
1135 "root": root_str(&root),
1136 }),
1137 )
1138 .await
1139 .unwrap();
1140 assert_eq!(wrong["status"], "not_owner", "got: {wrong}");
1141 assert_eq!(wrong["holder"], "session-aaa");
1142
1143 let still = check
1144 .call(
1145 &mut ctx,
1146 json!({"scope": "src/foo.rs", "root": root_str(&root)}),
1147 )
1148 .await
1149 .unwrap();
1150 assert_eq!(
1151 still["state"], "claimed",
1152 "release must not free others' claims"
1153 );
1154
1155 let right = release
1156 .call(
1157 &mut ctx,
1158 json!({
1159 "scope": "src/foo.rs",
1160 "owner_session": "session-aaa",
1161 "root": root_str(&root),
1162 }),
1163 )
1164 .await
1165 .unwrap();
1166 assert_eq!(right["status"], "success");
1167 assert_eq!(right["state"], "released");
1168
1169 let freed = check
1170 .call(
1171 &mut ctx,
1172 json!({"scope": "src/foo.rs", "root": root_str(&root)}),
1173 )
1174 .await
1175 .unwrap();
1176 assert_eq!(
1177 freed["state"], "free",
1178 "zero false free: freed after owner release"
1179 );
1180
1181 let again = release
1183 .call(
1184 &mut ctx,
1185 json!({
1186 "scope": "src/foo.rs",
1187 "owner_session": "session-aaa",
1188 "root": root_str(&root),
1189 }),
1190 )
1191 .await
1192 .unwrap();
1193 assert_eq!(again["status"], "success");
1194 assert_eq!(again["state"], "free");
1195 }
1196
1197 #[tokio::test]
1198 async fn expired_lease_frees_scope() {
1199 let (_guard, root) = git_repo();
1200 let claim = CodeClaimTool::new(None);
1201 let check = CodeCheckTool::new(None);
1202 let mut ctx = Context::default();
1203
1204 claim
1205 .call(
1206 &mut ctx,
1207 json!({
1208 "scope": "src/stale.rs",
1209 "intent": "dead session's claim",
1210 "owner_session": "session-dead",
1211 "root": root_str(&root),
1212 }),
1213 )
1214 .await
1215 .unwrap();
1216
1217 let path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1220 let raw = std::fs::read_to_string(&path).unwrap();
1221 let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1222 file.leases[0].expires_at = "2020-01-01T00:00:00Z".into();
1223 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
1224
1225 let freed = check
1226 .call(
1227 &mut ctx,
1228 json!({"scope": "src/stale.rs", "root": root_str(&root)}),
1229 )
1230 .await
1231 .unwrap();
1232 assert_eq!(freed["state"], "free", "expired claims free the scope");
1233
1234 let other = CodeClaimTool::new(None);
1236 let taken = other
1237 .call(
1238 &mut ctx,
1239 json!({
1240 "scope": "src/stale.rs",
1241 "intent": "rescued work",
1242 "owner_session": "session-live",
1243 "root": root_str(&root),
1244 }),
1245 )
1246 .await
1247 .unwrap();
1248 assert_eq!(taken["status"], "success", "got: {taken}");
1249 }
1250
1251 #[tokio::test]
1252 async fn ledger_visible_across_independent_tool_instances() {
1253 let (_guard, root) = git_repo();
1257 let claim_a = CodeClaimTool::new(None);
1258 let list_b = CodeListTool::new();
1259 let check_b = CodeCheckTool::new(None);
1260 let mut ctx = Context::default();
1261
1262 claim_a
1263 .call(
1264 &mut ctx,
1265 json!({
1266 "scope": "worktree-A",
1267 "intent": "agent A rewriting the harness",
1268 "owner_session": "session-aaa",
1269 "root": root_str(&root),
1270 }),
1271 )
1272 .await
1273 .unwrap();
1274
1275 let listed = list_b
1276 .call(&mut ctx, json!({"root": root_str(&root)}))
1277 .await
1278 .unwrap();
1279 assert_eq!(listed["status"], "success");
1280 assert_eq!(listed["count"], 1, "got: {listed}");
1281 assert_eq!(listed["leases"][0]["scope"], "worktree-A");
1282 assert_eq!(
1283 listed["leases"][0]["intent"],
1284 "agent A rewriting the harness"
1285 );
1286 assert!(listed["file"].as_str().unwrap().contains("wm-leases.json"));
1287
1288 let seen = check_b
1289 .call(
1290 &mut ctx,
1291 json!({"scope": "worktree-A", "root": root_str(&root)}),
1292 )
1293 .await
1294 .unwrap();
1295 assert_eq!(seen["state"], "claimed");
1296 assert_eq!(seen["holder"], "session-aaa");
1297 }
1298
1299 #[tokio::test]
1300 async fn renew_own_claim_keeps_single_entry() {
1301 let (_guard, root) = git_repo();
1302 let claim = CodeClaimTool::new(None);
1303 let list = CodeListTool::new();
1304 let mut ctx = Context::default();
1305
1306 for ttl in [7200i64, 60i64] {
1307 let r = claim
1308 .call(
1309 &mut ctx,
1310 json!({
1311 "scope": "src/renewed.rs",
1312 "intent": "long-running refactor",
1313 "owner_session": "session-aaa",
1314 "ttl_secs": ttl,
1315 "root": root_str(&root),
1316 }),
1317 )
1318 .await
1319 .unwrap();
1320 assert_eq!(r["status"], "success", "got: {r}");
1321 }
1322 let listed = list
1323 .call(&mut ctx, json!({"root": root_str(&root)}))
1324 .await
1325 .unwrap();
1326 assert_eq!(listed["count"], 1, "renewal must not duplicate entries");
1327 assert_eq!(listed["leases"][0]["ttl_secs"], 60);
1328 }
1329
1330 #[tokio::test]
1331 async fn claim_rejects_missing_intent_and_bad_ttl() {
1332 let (_guard, root) = git_repo();
1333 let claim = CodeClaimTool::new(None);
1334 let mut ctx = Context::default();
1335
1336 let no_intent = claim
1337 .call(
1338 &mut ctx,
1339 json!({
1340 "scope": "src/x.rs",
1341 "owner_session": "s",
1342 "root": root_str(&root),
1343 }),
1344 )
1345 .await;
1346 assert!(no_intent.is_err(), "intent is mandatory");
1347
1348 let bad_ttl = claim
1349 .call(
1350 &mut ctx,
1351 json!({
1352 "scope": "src/x.rs",
1353 "intent": "y",
1354 "owner_session": "s",
1355 "ttl_secs": 0,
1356 "root": root_str(&root),
1357 }),
1358 )
1359 .await;
1360 assert!(bad_ttl.is_err(), "ttl_secs must be >= 1");
1361 }
1362
1363 #[tokio::test]
1364 async fn non_git_root_is_a_clear_error() {
1365 let dir = tempfile::tempdir().unwrap();
1366 let claim = CodeClaimTool::new(None);
1367 let mut ctx = Context::default();
1368 let err = claim
1369 .call(
1370 &mut ctx,
1371 json!({
1372 "scope": "src/x.rs",
1373 "intent": "y",
1374 "owner_session": "s",
1375 "root": dir.path().display().to_string(),
1376 }),
1377 )
1378 .await
1379 .unwrap_err();
1380 assert!(err.to_string().contains("git repository"), "got: {err}");
1381 }
1382
1383 #[tokio::test]
1384 async fn list_hides_expired_by_default_but_can_include_them() {
1385 let (_guard, root) = git_repo();
1386 let claim = CodeClaimTool::new(None);
1387 let list = CodeListTool::new();
1388 let mut ctx = Context::default();
1389
1390 claim
1391 .call(
1392 &mut ctx,
1393 json!({
1394 "scope": "src/old.rs",
1395 "intent": "long gone",
1396 "owner_session": "session-old",
1397 "root": root_str(&root),
1398 }),
1399 )
1400 .await
1401 .unwrap();
1402
1403 let path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1404 let raw = std::fs::read_to_string(&path).unwrap();
1405 let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1406 file.leases[0].expires_at = "2020-01-01T00:00:00Z".into();
1407 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
1408
1409 let with_expired = list
1412 .call(
1413 &mut ctx,
1414 json!({"root": root_str(&root), "include_expired": true}),
1415 )
1416 .await
1417 .unwrap();
1418 assert_eq!(with_expired["count"], 1);
1419 assert_eq!(with_expired["leases"][0]["expired"], true);
1420
1421 let default_list = list
1422 .call(&mut ctx, json!({"root": root_str(&root)}))
1423 .await
1424 .unwrap();
1425 assert_eq!(default_list["count"], 0, "expired leases hidden by default");
1426 }
1427
1428 #[test]
1429 fn coordination_effect_shapes_are_dedicated() {
1430 let claim = CodeClaimTool::new(None);
1431 assert!(claim.effects().acquires_coordination_lease());
1432 assert!(!claim.effects().is_coordination_cleanup());
1433 assert!(
1434 !claim.effects().destructive,
1435 "claims must not be confirm-gated"
1436 );
1437
1438 let release = CodeReleaseTool::new(None);
1439 assert!(release.effects().is_coordination_cleanup());
1440 assert!(!release.effects().acquires_coordination_lease());
1441 assert!(
1442 !release.effects().destructive,
1443 "cleanup must not be confirm-gated"
1444 );
1445
1446 let check = CodeCheckTool::new(None);
1448 assert!(check.effects().writes.is_empty());
1449 assert!(check.effects().is_available_in(wm_core::BrainWave::Gamma));
1450 let list = CodeListTool::new();
1451 assert!(list.effects().writes.is_empty());
1452 }
1453
1454 #[tokio::test]
1455 async fn strict_snapshot_is_read_only_and_leaves_no_lock_or_tmp_files() {
1456 let (_guard, root) = git_repo();
1457 let claim = CodeClaimTool::new(None);
1458 let mut ctx = Context::default();
1459 claim
1460 .call(
1461 &mut ctx,
1462 json!({"scope": "readonly/", "intent": "t", "owner_session": "owner-a", "root": root_str(&root)}),
1463 )
1464 .await
1465 .unwrap();
1466
1467 let ledger_path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1469 let raw = std::fs::read_to_string(&ledger_path).unwrap();
1470 let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1471 file.leases.push(Lease {
1472 scope: "expired/".into(),
1473 intent: "old".into(),
1474 owner_session: "owner-b".into(),
1475 claimed_at: "2020-01-01T00:00:00Z".into(),
1476 expires_at: "2020-01-01T00:00:01Z".into(),
1477 ttl_secs: 1,
1478 });
1479 std::fs::write(&ledger_path, serde_json::to_string(&file).unwrap()).unwrap();
1480
1481 let before = std::fs::read(&ledger_path).unwrap();
1482 let before_mtime = std::fs::metadata(&ledger_path).unwrap().modified().unwrap();
1483
1484 let check = CodeCheckTool::new(None);
1485 let r = check
1486 .call(
1487 &mut ctx,
1488 json!({"scope": "expired/", "root": root_str(&root)}),
1489 )
1490 .await
1491 .unwrap();
1492 assert_eq!(
1493 r["state"], "free",
1494 "expired leases are logically absent: {r}"
1495 );
1496
1497 let list = CodeListTool::new();
1498 let r = list
1499 .call(&mut ctx, json!({"root": root_str(&root)}))
1500 .await
1501 .unwrap();
1502 assert_eq!(r["count"], 1, "only the active lease is listed: {r}");
1503 let r = list
1504 .call(
1505 &mut ctx,
1506 json!({"root": root_str(&root), "include_expired": true}),
1507 )
1508 .await
1509 .unwrap();
1510 assert_eq!(r["count"], 2, "expired leases stay reportable: {r}");
1511
1512 assert_eq!(
1513 std::fs::read(&ledger_path).unwrap(),
1514 before,
1515 "check/list must not rewrite the ledger"
1516 );
1517 assert_eq!(
1518 std::fs::metadata(&ledger_path).unwrap().modified().unwrap(),
1519 before_mtime,
1520 "check/list must not touch the ledger mtime"
1521 );
1522 assert!(
1523 !std::fs::read_dir(ledger_path.parent().unwrap())
1524 .unwrap()
1525 .any(|e| {
1526 let name = e.unwrap().file_name().to_string_lossy().to_string();
1527 name.contains("wm-leases.json.lock") || name.contains("wm-leases.json.tmp")
1528 }),
1529 "read-only snapshot must not create lock or temp files"
1530 );
1531 }
1532
1533 #[tokio::test]
1534 async fn release_refuses_alternate_root_when_configured() {
1535 let (_guard_a, root_a) = git_repo();
1536 let (_guard_b, root_b) = git_repo();
1537 let mut ctx = Context::default();
1538
1539 let claim = CodeClaimTool::new(None);
1540 claim
1541 .call(
1542 &mut ctx,
1543 json!({"scope": "cleanup/", "intent": "t", "owner_session": "owner-a", "root": root_str(&root_a)}),
1544 )
1545 .await
1546 .unwrap();
1547 claim
1548 .call(
1549 &mut ctx,
1550 json!({"scope": "cleanup/", "intent": "t", "owner_session": "owner-a", "root": root_str(&root_b)}),
1551 )
1552 .await
1553 .unwrap();
1554
1555 let release = CodeReleaseTool::new(None).with_configured_root(Some(root_a.clone()));
1556 let refused = release
1557 .call(
1558 &mut ctx,
1559 json!({"scope": "cleanup/", "owner_session": "owner-a", "root": root_str(&root_b)}),
1560 )
1561 .await;
1562 let err = refused.unwrap_err().to_string();
1563 assert!(err.contains("alternate root"), "{err}");
1564 let (active_b, _) = LeaseLedger::discover(&root_b)
1565 .unwrap()
1566 .snapshot_readonly()
1567 .unwrap();
1568 assert_eq!(
1569 active_b.len(),
1570 1,
1571 "a refused alternate-root cleanup must not mutate the other ledger"
1572 );
1573
1574 let ok = release
1576 .call(
1577 &mut ctx,
1578 json!({"scope": "cleanup/", "owner_session": "owner-a", "root": root_str(&root_a)}),
1579 )
1580 .await
1581 .unwrap();
1582 assert_eq!(ok["state"], "released");
1583 }
1584}