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 pub(crate) fn snapshot(&self) -> wm_core::Result<(Vec<Lease>, Vec<Lease>)> {
235 self.mutate(|active, expired| Ok((active.clone(), expired.to_vec())))
236 }
237
238 pub fn try_claim(
253 &self,
254 scope: &str,
255 intent: &str,
256 owner: &str,
257 ttl_secs: i64,
258 ) -> wm_core::Result<Result<Lease, Lease>> {
259 let claimed_at = now_rfc3339();
260 let expires_at = (Utc::now() + Duration::seconds(ttl_secs))
261 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
262 self.mutate(|leases, _expired| {
263 if let Some(existing) = leases.iter_mut().find(|l| l.scope == scope) {
264 if existing.owner_session == owner {
265 existing.intent = intent.to_string();
266 existing.claimed_at.clone_from(&claimed_at);
267 existing.expires_at.clone_from(&expires_at);
268 existing.ttl_secs = ttl_secs;
269 return Ok(Ok(existing.clone()));
270 }
271 return Ok(Err(existing.clone()));
272 }
273 let lease = Lease {
274 scope: scope.to_string(),
275 intent: intent.to_string(),
276 owner_session: owner.to_string(),
277 claimed_at: claimed_at.clone(),
278 expires_at: expires_at.clone(),
279 ttl_secs,
280 };
281 leases.push(lease.clone());
282 Ok(Ok(lease))
283 })
284 }
285
286 pub fn release_scope(&self, scope: &str, owner: &str) -> wm_core::Result<Result<bool, Lease>> {
290 self.mutate(|leases, _expired| {
291 let Some(pos) = leases.iter().position(|l| l.scope == scope) else {
292 return Ok(Ok(false));
293 };
294 if leases[pos].owner_session != owner {
295 return Ok(Err(leases[pos].clone()));
296 }
297 leases.remove(pos);
298 Ok(Ok(true))
299 })
300 }
301
302 pub fn force_release_peer(&self, peer: &str, prefix: &str) -> wm_core::Result<Vec<String>> {
307 self.mutate(|leases, _expired| {
308 let mut freed = Vec::new();
309 leases.retain(|l| {
310 if l.owner_session == peer && l.scope.starts_with(prefix) {
311 freed.push(l.scope.clone());
312 return false;
313 }
314 true
315 });
316 Ok(freed)
317 })
318 }
319}
320
321fn now_rfc3339() -> String {
325 wm_core::time::now_rfc3339()
326}
327
328pub(crate) fn clamp_ttl(ttl: i64) -> wm_core::Result<i64> {
329 if !(1..=MAX_TTL_SECS).contains(&ttl) {
330 return Err(CoreError::InvalidArgs(format!(
331 "ttl_secs must be between 1 and {MAX_TTL_SECS} (a claim is coordination state, not a tombstone)"
332 )));
333 }
334 Ok(ttl)
335}
336
337pub(crate) fn require_str(args: &Value, key: &str) -> wm_core::Result<String> {
338 args.get(key)
339 .and_then(|v| v.as_str())
340 .map(str::trim)
341 .filter(|s| !s.is_empty())
342 .map(str::to_string)
343 .ok_or_else(|| {
344 CoreError::InvalidArgs(format!(
345 "'{key}' is required and must be a non-empty string"
346 ))
347 })
348}
349
350pub(crate) fn resolve_root(args: &Value) -> wm_core::Result<PathBuf> {
351 args.get("root")
352 .and_then(|v| v.as_str())
353 .map(str::trim)
354 .filter(|s| !s.is_empty())
355 .map(PathBuf::from)
356 .or_else(|| {
357 std::env::var("WM_PROJECT_ROOT")
358 .ok()
359 .filter(|s| !s.trim().is_empty())
360 .map(PathBuf::from)
361 })
362 .ok_or_else(|| {
363 CoreError::InvalidArgs(
364 "no repository root — pass root=<repo path> or set WM_PROJECT_ROOT".into(),
365 )
366 })
367}
368
369fn emit(gan_ying: Option<&Arc<Mutex<GanYingBus>>>, event_type: EventType, payload: Value) {
370 if let Some(bus) = gan_ying {
371 if let Ok(mut gy) = bus.lock() {
372 gy.emit(event_type, "code.coordination", payload);
373 }
374 }
375}
376
377fn lease_json(lease: &Lease) -> Value {
378 json!({
379 "lease_id": lease.scope,
380 "scope": lease.scope,
381 "intent": lease.intent,
382 "owner_session": lease.owner_session,
383 "claimed_at": lease.claimed_at,
384 "expires_at": lease.expires_at,
385 "ttl_secs": lease.ttl_secs,
386 })
387}
388
389const CONFLICT_NEXT_ACTION: &str =
390 "wait for expiry, ask the holder to code.release the scope, or claim a different scope";
391
392pub struct CodeClaimTool {
396 stats: ToolStats,
397 effects: EffectRow,
398 gan_ying: Option<Arc<Mutex<GanYingBus>>>,
399}
400
401impl CodeClaimTool {
402 #[must_use]
403 pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
404 Self {
405 stats: ToolStats::default(),
406 effects: EffectRow {
413 reads: vec![Resource::Filesystem],
414 writes: vec![Resource::Filesystem],
415 ..Default::default()
416 },
417 gan_ying,
418 }
419 }
420}
421
422#[async_trait]
423impl Tool for CodeClaimTool {
424 fn name(&self) -> &str {
425 "code.claim"
426 }
427 fn gana(&self) -> Gana {
428 Gana::Room
429 }
430 fn effects(&self) -> &EffectRow {
431 &self.effects
432 }
433 fn input_schema(&self) -> Value {
434 super::common::schema(
435 &json!({
436 "scope": super::common::str_prop("Scope to claim — path, subtree, or resource label (e.g. 'src/expansion/')"),
437 "intent": super::common::str_prop("Why this scope is claimed (mandatory — surfaced to conflicting agents)"),
438 "owner_session": super::common::str_prop("Claiming session id (session.start result) or stable agent label"),
439 "ttl_secs": super::common::int_prop("Lease TTL in seconds (default 3600, max 86400; expired claims free themselves)"),
440 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
441 }),
442 &["scope", "intent", "owner_session"],
443 )
444 }
445 fn description(&self) -> &str {
446 "Claim a scope before shared-tree edits (advisory file-based lease with TTL in the git common dir; visible to every worktree). Conflict results name the holder and their intent."
447 }
448 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
449 let scope = require_str(&args, "scope")?;
450 let intent = require_str(&args, "intent")?;
451 let owner = require_str(&args, "owner_session")?;
452 let ttl = match args.get("ttl_secs").and_then(serde_json::Value::as_i64) {
453 Some(t) => clamp_ttl(t)?,
454 None => DEFAULT_TTL_SECS,
455 };
456 let root = resolve_root(&args)?;
457 let ledger = LeaseLedger::discover(&root)?;
458 let claimed_at = now_rfc3339();
459 let expires_at = (Utc::now() + Duration::seconds(ttl))
460 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
461
462 let mut newly_expired: Vec<Lease> = Vec::new();
463 let result = ledger.mutate(|leases, expired| {
464 newly_expired = expired.to_vec();
465 if let Some(existing) = leases.iter_mut().find(|l| l.scope == scope) {
466 if existing.owner_session == owner {
467 existing.intent.clone_from(&intent);
469 existing.claimed_at.clone_from(&claimed_at);
470 existing.expires_at.clone_from(&expires_at);
471 existing.ttl_secs = ttl;
472 return Ok(json!({
473 "status": "success",
474 "renewed": true,
475 "lease_id": scope,
476 "scope": scope,
477 "intent": intent,
478 "owner_session": owner,
479 "claimed_at": claimed_at,
480 "expires_at": expires_at,
481 "ttl_secs": ttl,
482 "note": "advisory lease renewed — release with code.release when done"
483 }));
484 }
485 let holder = existing.clone();
486 return Ok(json!({
487 "status": "conflict",
488 "scope": scope,
489 "requested_by": owner,
490 "holder": holder.owner_session,
491 "holder_intent": holder.intent,
492 "claimed_at": holder.claimed_at,
493 "expires_at": holder.expires_at,
494 "next_action": CONFLICT_NEXT_ACTION,
495 "advisory": true,
496 }));
497 }
498 leases.push(Lease {
499 scope: scope.clone(),
500 intent: intent.clone(),
501 owner_session: owner.clone(),
502 claimed_at: claimed_at.clone(),
503 expires_at: expires_at.clone(),
504 ttl_secs: ttl,
505 });
506 Ok(json!({
507 "status": "success",
508 "lease_id": scope,
509 "scope": scope,
510 "intent": intent,
511 "owner_session": owner,
512 "claimed_at": claimed_at,
513 "expires_at": expires_at,
514 "ttl_secs": ttl,
515 "note": "advisory lease — release with code.release when done"
516 }))
517 });
518
519 for lease in &newly_expired {
520 emit(
521 self.gan_ying.as_ref(),
522 EventType::CoordinationClaimExpired,
523 json!({"scope": lease.scope, "owner_session": lease.owner_session}),
524 );
525 }
526
527 let result = result?;
528 if result["status"] == "success" {
529 emit(
530 self.gan_ying.as_ref(),
531 EventType::CoordinationClaimAcquired,
532 json!({"scope": scope, "owner_session": owner, "intent": intent}),
533 );
534 } else if result["status"] == "conflict" {
535 emit(
536 self.gan_ying.as_ref(),
537 EventType::CoordinationClaimDenied,
538 json!({"scope": scope, "requested_by": owner, "holder": result["holder"]}),
539 );
540 }
541 Ok(result)
542 }
543 fn stats(&self) -> &ToolStats {
544 &self.stats
545 }
546}
547
548pub struct CodeCheckTool {
552 stats: ToolStats,
553 effects: EffectRow,
554 gan_ying: Option<Arc<Mutex<GanYingBus>>>,
555}
556
557impl CodeCheckTool {
558 #[must_use]
559 pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
560 Self {
561 stats: ToolStats::default(),
562 effects: EffectRow {
563 reads: vec![Resource::Filesystem],
564 ..Default::default()
565 },
566 gan_ying,
567 }
568 }
569}
570
571#[async_trait]
572impl Tool for CodeCheckTool {
573 fn name(&self) -> &str {
574 "code.check"
575 }
576 fn gana(&self) -> Gana {
577 Gana::Room
578 }
579 fn effects(&self) -> &EffectRow {
580 &self.effects
581 }
582 fn input_schema(&self) -> Value {
583 super::common::schema(
584 &json!({
585 "scope": super::common::str_prop("Scope to check"),
586 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
587 }),
588 &["scope"],
589 )
590 }
591 fn description(&self) -> &str {
592 "Check whether a scope is claimed — reports the holder, their intent, and expiry when claimed; 'free' means no active lease."
593 }
594 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
595 let scope = require_str(&args, "scope")?;
596 let root = resolve_root(&args)?;
597 let ledger = LeaseLedger::discover(&root)?;
598
599 let mut newly_expired: Vec<Lease> = Vec::new();
600 let holder = ledger.mutate(|leases, expired| {
601 newly_expired = expired.to_vec();
602 Ok(leases.iter().find(|l| l.scope == scope).map(lease_json))
603 })?;
604
605 for lease in &newly_expired {
606 emit(
607 self.gan_ying.as_ref(),
608 EventType::CoordinationClaimExpired,
609 json!({"scope": lease.scope, "owner_session": lease.owner_session}),
610 );
611 }
612
613 match holder {
614 Some(h) => Ok(json!({
615 "status": "success",
616 "scope": scope,
617 "state": "claimed",
618 "holder": h["owner_session"],
619 "intent": h["intent"],
620 "expires_at": h["expires_at"],
621 "next_action": CONFLICT_NEXT_ACTION,
622 })),
623 None => Ok(json!({
624 "status": "success",
625 "scope": scope,
626 "state": "free",
627 })),
628 }
629 }
630 fn stats(&self) -> &ToolStats {
631 &self.stats
632 }
633}
634
635pub struct CodeReleaseTool {
639 stats: ToolStats,
640 effects: EffectRow,
641 gan_ying: Option<Arc<Mutex<GanYingBus>>>,
642}
643
644impl CodeReleaseTool {
645 #[must_use]
646 pub fn new(gan_ying: Option<Arc<Mutex<GanYingBus>>>) -> Self {
647 Self {
648 stats: ToolStats::default(),
649 effects: EffectRow {
656 reads: vec![Resource::Filesystem],
657 writes: vec![Resource::Filesystem],
658 ..Default::default()
659 },
660 gan_ying,
661 }
662 }
663}
664
665#[async_trait]
666impl Tool for CodeReleaseTool {
667 fn name(&self) -> &str {
668 "code.release"
669 }
670 fn gana(&self) -> Gana {
671 Gana::Room
672 }
673 fn effects(&self) -> &EffectRow {
674 &self.effects
675 }
676 fn input_schema(&self) -> Value {
677 super::common::schema(
678 &json!({
679 "scope": super::common::str_prop("Scope to release (the claim's lease_id)"),
680 "owner_session": super::common::str_prop("Releasing session id — must match the claim's owner"),
681 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
682 }),
683 &["scope", "owner_session"],
684 )
685 }
686 fn description(&self) -> &str {
687 "Release a claimed scope when work is done — only the owning session can release; releasing a free scope is an idempotent no-op."
688 }
689 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
690 let scope = require_str(&args, "scope")?;
691 let owner = require_str(&args, "owner_session")?;
692 let root = resolve_root(&args)?;
693 let ledger = LeaseLedger::discover(&root)?;
694
695 let outcome = ledger.mutate(|leases, _expired| {
696 let Some(pos) = leases.iter().position(|l| l.scope == scope) else {
697 return Ok(json!({
698 "status": "success",
699 "scope": scope,
700 "state": "free",
701 "note": "no active claim on this scope (already released or expired)"
702 }));
703 };
704 let lease = &leases[pos];
705 if lease.owner_session != owner {
706 return Ok(json!({
707 "status": "not_owner",
708 "scope": scope,
709 "holder": lease.owner_session,
710 "holder_intent": lease.intent,
711 "expires_at": lease.expires_at,
712 "note": "only the owning session can release a claim",
713 }));
714 }
715 leases.remove(pos);
716 Ok(json!({
717 "status": "success",
718 "scope": scope,
719 "state": "released",
720 "owner_session": owner,
721 }))
722 })?;
723
724 if outcome["status"] == "success" && outcome["state"] == "released" {
725 emit(
726 self.gan_ying.as_ref(),
727 EventType::CoordinationClaimReleased,
728 json!({"scope": scope, "owner_session": owner}),
729 );
730 }
731 Ok(outcome)
732 }
733 fn stats(&self) -> &ToolStats {
734 &self.stats
735 }
736}
737
738pub struct CodeListTool {
742 stats: ToolStats,
743 effects: EffectRow,
744}
745
746impl CodeListTool {
747 #[must_use]
748 pub fn new() -> Self {
749 Self {
750 stats: ToolStats::default(),
751 effects: EffectRow {
752 reads: vec![Resource::Filesystem],
753 ..Default::default()
754 },
755 }
756 }
757}
758
759impl Default for CodeListTool {
760 fn default() -> Self {
761 Self::new()
762 }
763}
764
765#[async_trait]
766impl Tool for CodeListTool {
767 fn name(&self) -> &str {
768 "code.list"
769 }
770 fn gana(&self) -> Gana {
771 Gana::Room
772 }
773 fn effects(&self) -> &EffectRow {
774 &self.effects
775 }
776 fn input_schema(&self) -> Value {
777 super::common::schema(
778 &json!({
779 "include_expired": super::common::bool_prop("Include expired leases in the listing (default false)"),
780 "root": super::common::str_prop("Repository root (default: WM_PROJECT_ROOT env)"),
781 }),
782 &[],
783 )
784 }
785 fn description(&self) -> &str {
786 "List active claims in the shared lease ledger — what each agent is holding and until when."
787 }
788 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
789 let include_expired = args
790 .get("include_expired")
791 .and_then(serde_json::Value::as_bool)
792 .unwrap_or(false);
793 let root = resolve_root(&args)?;
794 let ledger = LeaseLedger::discover(&root)?;
795 let (active, expired) = ledger.snapshot()?;
796 let mut leases: Vec<Value> = active.iter().map(lease_json).collect();
797 if include_expired {
798 let mut expired_json: Vec<Value> = expired
799 .iter()
800 .map(|l| {
801 let mut v = lease_json(l);
802 v["expired"] = json!(true);
803 v
804 })
805 .collect();
806 leases.append(&mut expired_json);
807 }
808 let count = leases.len();
809 Ok(json!({
810 "status": "success",
811 "count": count,
812 "leases": leases,
813 "file": ledger.path().display().to_string(),
814 }))
815 }
816 fn stats(&self) -> &ToolStats {
817 &self.stats
818 }
819}
820
821#[must_use]
823pub fn register_coordination(
824 registry: &wm_dispatch::ToolRegistry,
825 gan_ying_bus: Option<&Arc<Mutex<GanYingBus>>>,
826) -> wm_dispatch::ToolRegistry {
827 registry
828 .register(Arc::new(CodeClaimTool::new(gan_ying_bus.cloned())))
829 .register(Arc::new(CodeCheckTool::new(gan_ying_bus.cloned())))
830 .register(Arc::new(CodeReleaseTool::new(gan_ying_bus.cloned())))
831 .register(Arc::new(CodeListTool::new()))
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 fn git_repo() -> (tempfile::TempDir, PathBuf) {
840 let dir = tempfile::tempdir().unwrap();
841 let root = dir.path().to_path_buf();
842 let run = |args: &[&str]| {
843 std::process::Command::new("git")
844 .args(args)
845 .current_dir(&root)
846 .output()
847 .expect("git must be available")
848 };
849 assert!(run(&["init", "-q"]).status.success());
850 assert!(run(&["config", "user.email", "t@t"]).status.success());
851 assert!(run(&["config", "user.name", "t"]).status.success());
852 assert!(
853 run(&["commit", "--allow-empty", "-m", "c1"])
854 .status
855 .success()
856 );
857 (dir, root)
858 }
859
860 fn root_str(root: &Path) -> Value {
861 json!(root.display().to_string())
862 }
863
864 #[tokio::test]
865 async fn claim_then_conflict_names_holder_and_intent() {
866 let (_guard, root) = git_repo();
867 let a = CodeClaimTool::new(None);
868 let b = CodeClaimTool::new(None);
869 let mut ctx = Context::default();
870
871 let first = a
872 .call(
873 &mut ctx,
874 json!({
875 "scope": "src/expansion/",
876 "intent": "refactoring session tools",
877 "owner_session": "session-aaa",
878 "root": root_str(&root),
879 }),
880 )
881 .await
882 .unwrap();
883 assert_eq!(first["status"], "success", "got: {first}");
884 assert_eq!(first["lease_id"], "src/expansion/");
885 assert_eq!(first["owner_session"], "session-aaa");
886
887 let second = b
888 .call(
889 &mut ctx,
890 json!({
891 "scope": "src/expansion/",
892 "intent": "unrelated edits",
893 "owner_session": "session-bbb",
894 "root": root_str(&root),
895 }),
896 )
897 .await
898 .unwrap();
899 assert_eq!(second["status"], "conflict", "got: {second}");
900 assert_eq!(second["holder"], "session-aaa");
901 assert_eq!(second["holder_intent"], "refactoring session tools");
902 assert!(
903 second["next_action"]
904 .as_str()
905 .unwrap()
906 .contains("code.release")
907 );
908 }
909
910 #[tokio::test]
911 async fn check_reports_claimed_then_free_zero_false_free() {
912 let (_guard, root) = git_repo();
913 let claim = CodeClaimTool::new(None);
914 let check = CodeCheckTool::new(None);
915 let mut ctx = Context::default();
916
917 let free = check
918 .call(&mut ctx, json!({"scope": "docs/", "root": root_str(&root)}))
919 .await
920 .unwrap();
921 assert_eq!(free["state"], "free");
922
923 claim
924 .call(
925 &mut ctx,
926 json!({
927 "scope": "docs/",
928 "intent": "doc rewrite",
929 "owner_session": "session-aaa",
930 "root": root_str(&root),
931 }),
932 )
933 .await
934 .unwrap();
935
936 let claimed = check
937 .call(&mut ctx, json!({"scope": "docs/", "root": root_str(&root)}))
938 .await
939 .unwrap();
940 assert_eq!(claimed["state"], "claimed", "got: {claimed}");
941 assert_eq!(claimed["holder"], "session-aaa");
942 assert_eq!(claimed["intent"], "doc rewrite");
943 }
944
945 #[tokio::test]
946 async fn release_requires_owner_then_scope_frees() {
947 let (_guard, root) = git_repo();
948 let claim = CodeClaimTool::new(None);
949 let release = CodeReleaseTool::new(None);
950 let check = CodeCheckTool::new(None);
951 let mut ctx = Context::default();
952
953 claim
954 .call(
955 &mut ctx,
956 json!({
957 "scope": "src/foo.rs",
958 "intent": "bugfix",
959 "owner_session": "session-aaa",
960 "root": root_str(&root),
961 }),
962 )
963 .await
964 .unwrap();
965
966 let wrong = release
967 .call(
968 &mut ctx,
969 json!({
970 "scope": "src/foo.rs",
971 "owner_session": "session-bbb",
972 "root": root_str(&root),
973 }),
974 )
975 .await
976 .unwrap();
977 assert_eq!(wrong["status"], "not_owner", "got: {wrong}");
978 assert_eq!(wrong["holder"], "session-aaa");
979
980 let still = check
981 .call(
982 &mut ctx,
983 json!({"scope": "src/foo.rs", "root": root_str(&root)}),
984 )
985 .await
986 .unwrap();
987 assert_eq!(
988 still["state"], "claimed",
989 "release must not free others' claims"
990 );
991
992 let right = release
993 .call(
994 &mut ctx,
995 json!({
996 "scope": "src/foo.rs",
997 "owner_session": "session-aaa",
998 "root": root_str(&root),
999 }),
1000 )
1001 .await
1002 .unwrap();
1003 assert_eq!(right["status"], "success");
1004 assert_eq!(right["state"], "released");
1005
1006 let freed = check
1007 .call(
1008 &mut ctx,
1009 json!({"scope": "src/foo.rs", "root": root_str(&root)}),
1010 )
1011 .await
1012 .unwrap();
1013 assert_eq!(
1014 freed["state"], "free",
1015 "zero false free: freed after owner release"
1016 );
1017
1018 let again = release
1020 .call(
1021 &mut ctx,
1022 json!({
1023 "scope": "src/foo.rs",
1024 "owner_session": "session-aaa",
1025 "root": root_str(&root),
1026 }),
1027 )
1028 .await
1029 .unwrap();
1030 assert_eq!(again["status"], "success");
1031 assert_eq!(again["state"], "free");
1032 }
1033
1034 #[tokio::test]
1035 async fn expired_lease_frees_scope() {
1036 let (_guard, root) = git_repo();
1037 let claim = CodeClaimTool::new(None);
1038 let check = CodeCheckTool::new(None);
1039 let mut ctx = Context::default();
1040
1041 claim
1042 .call(
1043 &mut ctx,
1044 json!({
1045 "scope": "src/stale.rs",
1046 "intent": "dead session's claim",
1047 "owner_session": "session-dead",
1048 "root": root_str(&root),
1049 }),
1050 )
1051 .await
1052 .unwrap();
1053
1054 let path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1057 let raw = std::fs::read_to_string(&path).unwrap();
1058 let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1059 file.leases[0].expires_at = "2020-01-01T00:00:00Z".into();
1060 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
1061
1062 let freed = check
1063 .call(
1064 &mut ctx,
1065 json!({"scope": "src/stale.rs", "root": root_str(&root)}),
1066 )
1067 .await
1068 .unwrap();
1069 assert_eq!(freed["state"], "free", "expired claims free the scope");
1070
1071 let other = CodeClaimTool::new(None);
1073 let taken = other
1074 .call(
1075 &mut ctx,
1076 json!({
1077 "scope": "src/stale.rs",
1078 "intent": "rescued work",
1079 "owner_session": "session-live",
1080 "root": root_str(&root),
1081 }),
1082 )
1083 .await
1084 .unwrap();
1085 assert_eq!(taken["status"], "success", "got: {taken}");
1086 }
1087
1088 #[tokio::test]
1089 async fn ledger_visible_across_independent_tool_instances() {
1090 let (_guard, root) = git_repo();
1094 let claim_a = CodeClaimTool::new(None);
1095 let list_b = CodeListTool::new();
1096 let check_b = CodeCheckTool::new(None);
1097 let mut ctx = Context::default();
1098
1099 claim_a
1100 .call(
1101 &mut ctx,
1102 json!({
1103 "scope": "worktree-A",
1104 "intent": "agent A rewriting the harness",
1105 "owner_session": "session-aaa",
1106 "root": root_str(&root),
1107 }),
1108 )
1109 .await
1110 .unwrap();
1111
1112 let listed = list_b
1113 .call(&mut ctx, json!({"root": root_str(&root)}))
1114 .await
1115 .unwrap();
1116 assert_eq!(listed["status"], "success");
1117 assert_eq!(listed["count"], 1, "got: {listed}");
1118 assert_eq!(listed["leases"][0]["scope"], "worktree-A");
1119 assert_eq!(
1120 listed["leases"][0]["intent"],
1121 "agent A rewriting the harness"
1122 );
1123 assert!(listed["file"].as_str().unwrap().contains("wm-leases.json"));
1124
1125 let seen = check_b
1126 .call(
1127 &mut ctx,
1128 json!({"scope": "worktree-A", "root": root_str(&root)}),
1129 )
1130 .await
1131 .unwrap();
1132 assert_eq!(seen["state"], "claimed");
1133 assert_eq!(seen["holder"], "session-aaa");
1134 }
1135
1136 #[tokio::test]
1137 async fn renew_own_claim_keeps_single_entry() {
1138 let (_guard, root) = git_repo();
1139 let claim = CodeClaimTool::new(None);
1140 let list = CodeListTool::new();
1141 let mut ctx = Context::default();
1142
1143 for ttl in [7200i64, 60i64] {
1144 let r = claim
1145 .call(
1146 &mut ctx,
1147 json!({
1148 "scope": "src/renewed.rs",
1149 "intent": "long-running refactor",
1150 "owner_session": "session-aaa",
1151 "ttl_secs": ttl,
1152 "root": root_str(&root),
1153 }),
1154 )
1155 .await
1156 .unwrap();
1157 assert_eq!(r["status"], "success", "got: {r}");
1158 }
1159 let listed = list
1160 .call(&mut ctx, json!({"root": root_str(&root)}))
1161 .await
1162 .unwrap();
1163 assert_eq!(listed["count"], 1, "renewal must not duplicate entries");
1164 assert_eq!(listed["leases"][0]["ttl_secs"], 60);
1165 }
1166
1167 #[tokio::test]
1168 async fn claim_rejects_missing_intent_and_bad_ttl() {
1169 let (_guard, root) = git_repo();
1170 let claim = CodeClaimTool::new(None);
1171 let mut ctx = Context::default();
1172
1173 let no_intent = claim
1174 .call(
1175 &mut ctx,
1176 json!({
1177 "scope": "src/x.rs",
1178 "owner_session": "s",
1179 "root": root_str(&root),
1180 }),
1181 )
1182 .await;
1183 assert!(no_intent.is_err(), "intent is mandatory");
1184
1185 let bad_ttl = claim
1186 .call(
1187 &mut ctx,
1188 json!({
1189 "scope": "src/x.rs",
1190 "intent": "y",
1191 "owner_session": "s",
1192 "ttl_secs": 0,
1193 "root": root_str(&root),
1194 }),
1195 )
1196 .await;
1197 assert!(bad_ttl.is_err(), "ttl_secs must be >= 1");
1198 }
1199
1200 #[tokio::test]
1201 async fn non_git_root_is_a_clear_error() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let claim = CodeClaimTool::new(None);
1204 let mut ctx = Context::default();
1205 let err = claim
1206 .call(
1207 &mut ctx,
1208 json!({
1209 "scope": "src/x.rs",
1210 "intent": "y",
1211 "owner_session": "s",
1212 "root": dir.path().display().to_string(),
1213 }),
1214 )
1215 .await
1216 .unwrap_err();
1217 assert!(err.to_string().contains("git repository"), "got: {err}");
1218 }
1219
1220 #[tokio::test]
1221 async fn list_hides_expired_by_default_but_can_include_them() {
1222 let (_guard, root) = git_repo();
1223 let claim = CodeClaimTool::new(None);
1224 let list = CodeListTool::new();
1225 let mut ctx = Context::default();
1226
1227 claim
1228 .call(
1229 &mut ctx,
1230 json!({
1231 "scope": "src/old.rs",
1232 "intent": "long gone",
1233 "owner_session": "session-old",
1234 "root": root_str(&root),
1235 }),
1236 )
1237 .await
1238 .unwrap();
1239
1240 let path = LeaseLedger::discover(&root).unwrap().path().to_path_buf();
1241 let raw = std::fs::read_to_string(&path).unwrap();
1242 let mut file: LeaseFile = serde_json::from_str(&raw).unwrap();
1243 file.leases[0].expires_at = "2020-01-01T00:00:00Z".into();
1244 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
1245
1246 let with_expired = list
1249 .call(
1250 &mut ctx,
1251 json!({"root": root_str(&root), "include_expired": true}),
1252 )
1253 .await
1254 .unwrap();
1255 assert_eq!(with_expired["count"], 1);
1256 assert_eq!(with_expired["leases"][0]["expired"], true);
1257
1258 let default_list = list
1259 .call(&mut ctx, json!({"root": root_str(&root)}))
1260 .await
1261 .unwrap();
1262 assert_eq!(default_list["count"], 0, "expired leases hidden by default");
1263 }
1264}