Skip to main content

whycodes_core/
file_claims.rs

1//! File ownership claims for concurrent swarm agents.
2//!
3//! When several agents edit one checkout, each mutator (`write` / `edit` /
4//! `apply_patch`) must claim the target path first. A second agent that tries
5//! the same path gets a structured conflict instead of a silent overwrite.
6//!
7//! Claims are process-local (in-memory). They do not replace git worktrees for
8//! hard isolation; they are the lightweight conflict-notify layer.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex};
13
14/// Who holds a path while a swarm (or multi-writer) session is active.
15#[derive(Debug, Clone)]
16pub struct FileClaim {
17    /// Stable id for the agent (e.g. `worker-0`).
18    pub owner_id: String,
19    /// Human-readable label for toasts / tool errors.
20    pub owner_label: String,
21}
22
23/// Result of attempting to claim a file for writing.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum ClaimResult {
26    /// Path was free; now owned by the claimant.
27    Acquired,
28    /// Same agent already owns the path.
29    Held,
30    /// Another agent owns the path.
31    Conflict {
32        owner_id: String,
33        owner_label: String,
34    },
35}
36
37/// Emitted when a claim fails because another agent already owns the path.
38#[derive(Debug, Clone)]
39pub struct FileConflictEvent {
40    pub path: String,
41    pub claimant_id: String,
42    pub claimant_label: String,
43    pub owner_id: String,
44    pub owner_label: String,
45}
46
47/// Optional listener for conflict notifications (TUI toast, logs, …).
48pub type ConflictListener = Arc<dyn Fn(FileConflictEvent) + Send + Sync>;
49
50/// Shared registry of file claims for one swarm (or multi-writer) run.
51#[derive(Clone, Default)]
52pub struct FileClaimRegistry {
53    inner: Arc<FileClaimRegistryInner>,
54}
55
56/// Last writer of a path (survives `release_agent` so later reads can go stale).
57#[derive(Debug, Clone)]
58struct WriteRecord {
59    owner_id: String,
60    owner_label: String,
61    generation: u64,
62}
63
64/// A reader opened a file another agent wrote since their last read.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct FileStaleEvent {
67    pub path: String,
68    pub reader_id: String,
69    pub writer_id: String,
70    pub writer_label: String,
71}
72
73/// Optional listener for stale-read notifications.
74pub type StaleListener = Arc<dyn Fn(FileStaleEvent) + Send + Sync>;
75
76struct FileClaimRegistryInner {
77    /// Canonical absolute path string → claim.
78    claims: Mutex<HashMap<String, FileClaim>>,
79    listener: Mutex<Option<ConflictListener>>,
80    last_write: Mutex<HashMap<String, WriteRecord>>,
81    /// `(agent_id, path)` → last write gen this agent has seen.
82    last_seen: Mutex<HashMap<(String, String), u64>>,
83    stale_listener: Mutex<Option<StaleListener>>,
84}
85
86impl Default for FileClaimRegistryInner {
87    fn default() -> Self {
88        Self {
89            claims: Mutex::new(HashMap::new()),
90            listener: Mutex::new(None),
91            last_write: Mutex::new(HashMap::new()),
92            last_seen: Mutex::new(HashMap::new()),
93            stale_listener: Mutex::new(None),
94        }
95    }
96}
97
98impl std::fmt::Debug for FileClaimRegistry {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        let n = recover_lock(self.inner.claims.lock()).len();
101        f.debug_struct("FileClaimRegistry")
102            .field("claims", &n)
103            .finish()
104    }
105}
106
107impl FileClaimRegistry {
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Install (or replace) the conflict listener.
113    pub fn set_listener(&self, listener: Option<ConflictListener>) {
114        *recover_lock(self.inner.listener.lock()) = listener;
115    }
116
117    /// Normalize a path for claim keys: absolute, no trailing slash noise.
118    pub fn claim_key(path: &Path) -> String {
119        let abs = if path.is_absolute() {
120            path.to_path_buf()
121        } else {
122            path_or_dot(std::env::current_dir()).join(path)
123        };
124        // Prefer canonicalize when the path exists; otherwise use cleaned abs.
125        let normalized = abs.canonicalize().unwrap_or_else(|_| {
126            // Clean `..` / `.` components without requiring existence.
127            let mut out = PathBuf::new();
128            for comp in abs.components() {
129                apply_component(&mut out, comp);
130            }
131            out
132        });
133        normalized.to_string_lossy().into_owned()
134    }
135
136    /// Try to claim `path` for `agent_id`. Same agent re-claim is a no-op success.
137    pub fn try_claim(&self, agent_id: &str, agent_label: &str, path: &Path) -> ClaimResult {
138        let key = Self::claim_key(path);
139        let mut map = recover_lock(self.inner.claims.lock());
140        match map.get(&key) {
141            Some(c) if c.owner_id == agent_id => {
142                self.record_write(&key, agent_id, agent_label);
143                ClaimResult::Held
144            }
145            Some(c) => {
146                let ev = FileConflictEvent {
147                    path: key,
148                    claimant_id: agent_id.to_string(),
149                    claimant_label: agent_label.to_string(),
150                    owner_id: c.owner_id.clone(),
151                    owner_label: c.owner_label.clone(),
152                };
153                self.emit_conflict(ev.clone());
154                ClaimResult::Conflict {
155                    owner_id: ev.owner_id,
156                    owner_label: ev.owner_label,
157                }
158            }
159            None => {
160                map.insert(
161                    key.clone(),
162                    FileClaim {
163                        owner_id: agent_id.to_string(),
164                        owner_label: agent_label.to_string(),
165                    },
166                );
167                self.record_write(&key, agent_id, agent_label);
168                ClaimResult::Acquired
169            }
170        }
171    }
172
173    fn record_write(&self, key: &str, agent_id: &str, agent_label: &str) {
174        let mut writes = recover_lock(self.inner.last_write.lock());
175        let generation = writes
176            .get(key)
177            .map(|w| w.generation)
178            .unwrap_or(0)
179            .saturating_add(1);
180        writes.insert(
181            key.to_string(),
182            WriteRecord {
183                owner_id: agent_id.to_string(),
184                owner_label: agent_label.to_string(),
185                generation,
186            },
187        );
188        recover_lock(self.inner.last_seen.lock())
189            .insert((agent_id.to_string(), key.to_string()), generation);
190    }
191
192    pub fn set_stale_listener(&self, listener: Option<StaleListener>) {
193        *recover_lock(self.inner.stale_listener.lock()) = listener;
194    }
195
196    /// Record a read. If another agent wrote since this reader last saw the
197    /// path, return a stale event (and notify the listener).
198    pub fn note_read(&self, agent_id: &str, path: &Path) -> Option<FileStaleEvent> {
199        let key = Self::claim_key(path);
200        let writes = recover_lock(self.inner.last_write.lock());
201        let write = writes.get(&key)?;
202        if write.owner_id == agent_id {
203            return None;
204        }
205        let generation = write.generation;
206        let writer_id = write.owner_id.clone();
207        let writer_label = write.owner_label.clone();
208        drop(writes);
209
210        let mut seen = recover_lock(self.inner.last_seen.lock());
211        let last = seen.get(&(agent_id.to_string(), key.clone())).copied();
212        seen.insert((agent_id.to_string(), key.clone()), generation);
213        if last == Some(generation) {
214            return None;
215        }
216        let ev = FileStaleEvent {
217            path: key,
218            reader_id: agent_id.to_string(),
219            writer_id,
220            writer_label,
221        };
222        if let Some(ref f) = *recover_lock(self.inner.stale_listener.lock()) {
223            f(ev.clone());
224        }
225        Some(ev)
226    }
227
228    fn emit_conflict(&self, ev: FileConflictEvent) {
229        if let Some(ref f) = *recover_lock(self.inner.listener.lock()) {
230            f(ev);
231        }
232    }
233
234    /// Drop every claim held by `agent_id` (worker finished).
235    pub fn release_agent(&self, agent_id: &str) {
236        let mut map = recover_lock(self.inner.claims.lock());
237        map.retain(|_, c| c.owner_id != agent_id);
238    }
239
240    /// Clear all claims (swarm session end).
241    pub fn clear(&self) {
242        recover_lock(self.inner.claims.lock()).clear();
243    }
244
245    /// Snapshot of current claims (path → owner label), sorted by path.
246    pub fn snapshot(&self) -> Vec<(String, String)> {
247        let map = recover_lock(self.inner.claims.lock());
248        let mut rows: Vec<_> = map
249            .iter()
250            .map(|(p, c)| (p.clone(), c.owner_label.clone()))
251            .collect();
252        rows.sort_by(|a, b| a.0.cmp(&b.0));
253        rows
254    }
255
256    pub fn len(&self) -> usize {
257        recover_lock(self.inner.claims.lock()).len()
258    }
259
260    pub fn is_empty(&self) -> bool {
261        self.len() == 0
262    }
263}
264
265pub(crate) fn apply_component(out: &mut PathBuf, comp: std::path::Component<'_>) {
266    match comp {
267        std::path::Component::ParentDir => {
268            out.pop();
269        }
270        std::path::Component::CurDir => {}
271        other => out.push(other.as_os_str()),
272    }
273}
274
275pub(crate) fn path_or_dot(res: std::io::Result<PathBuf>) -> PathBuf {
276    res.unwrap_or_else(|_| PathBuf::from("."))
277}
278
279pub(crate) fn recover_lock<T>(res: std::sync::LockResult<T>) -> T {
280    match res {
281        Ok(g) => g,
282        Err(p) => p.into_inner(),
283    }
284}
285
286#[cfg(test)]
287impl FileClaimRegistry {
288    pub(crate) fn poison_claims(&self) {
289        poison_mutex(&self.inner.claims);
290    }
291    pub(crate) fn poison_last_write(&self) {
292        poison_mutex(&self.inner.last_write);
293    }
294    pub(crate) fn poison_last_seen(&self) {
295        poison_mutex(&self.inner.last_seen);
296    }
297    pub(crate) fn poison_listener(&self) {
298        poison_mutex(&self.inner.listener);
299    }
300    pub(crate) fn poison_stale_listener(&self) {
301        poison_mutex(&self.inner.stale_listener);
302    }
303}
304
305#[cfg(test)]
306fn poison_mutex<T>(m: &Mutex<T>) {
307    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
308        let _g = m.lock().unwrap();
309        panic!("poison");
310    }));
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use std::path::Path;
317    use std::sync::{Arc, Mutex};
318
319    #[test]
320    fn claim_hold_conflict_stale_and_snapshot() {
321        let reg = FileClaimRegistry::new();
322        assert!(reg.is_empty());
323        assert_eq!(reg.len(), 0);
324        let a = Path::new("src/a.rs");
325        assert_eq!(reg.try_claim("w0", "worker-0", a), ClaimResult::Acquired);
326        assert_eq!(reg.try_claim("w0", "worker-0", a), ClaimResult::Held);
327        assert_eq!(
328            reg.try_claim("w1", "worker-1", a),
329            ClaimResult::Conflict {
330                owner_id: "w0".into(),
331                owner_label: "worker-0".into(),
332            }
333        );
334        let seen = Arc::new(Mutex::new(Vec::new()));
335        let seen2 = Arc::clone(&seen);
336        reg.set_listener(Some(Arc::new(move |ev: FileConflictEvent| {
337            seen2.lock().unwrap().push(ev.owner_id);
338        })));
339        let _ = reg.try_claim("w1", "worker-1", a);
340        assert!(!seen.lock().unwrap().is_empty());
341
342        let stale = Arc::new(Mutex::new(0usize));
343        let stale2 = Arc::clone(&stale);
344        reg.set_stale_listener(Some(Arc::new(move |_ev: FileStaleEvent| {
345            *stale2.lock().unwrap() += 1;
346        })));
347        assert!(reg.note_read("w0", a).is_none());
348        assert!(reg.note_read("w1", a).is_some());
349        assert_eq!(*stale.lock().unwrap(), 1);
350        assert!(reg.note_read("w1", a).is_none());
351        assert_eq!(*stale.lock().unwrap(), 1);
352        assert!(format!("{reg:?}").contains("FileClaimRegistry"));
353        assert!(!reg.snapshot().is_empty());
354        assert!(!FileClaimRegistry::claim_key(a).is_empty());
355        reg.release_agent("w0");
356        reg.clear();
357        assert!(reg.is_empty());
358    }
359}