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