Skip to main content

dk_engine/conflict/
claim_tracker.rs

1use dashmap::DashMap;
2use std::time::Instant;
3use uuid::Uuid;
4
5use dk_core::SymbolKind;
6
7/// A claim that a particular session has touched a symbol.
8#[derive(Debug, Clone)]
9pub struct SymbolClaim {
10    pub session_id: Uuid,
11    pub agent_name: String,
12    pub qualified_name: String,
13    pub kind: SymbolKind,
14    pub first_touched_at: Instant,
15}
16
17/// Information about a detected conflict: another session already claims
18/// ownership of a symbol that the current session wants to modify.
19#[derive(Debug, Clone)]
20pub struct ConflictInfo {
21    pub qualified_name: String,
22    pub kind: SymbolKind,
23    pub conflicting_session: Uuid,
24    pub conflicting_agent: String,
25    pub first_touched_at: Instant,
26}
27
28/// Thread-safe, lock-free tracker for symbol-level claims across sessions.
29///
30/// Key insight: two sessions modifying DIFFERENT symbols in the same file is
31/// NOT a conflict. Only same-symbol modifications across sessions are TRUE
32/// conflicts. This is dkod's core differentiator over line-based VCS.
33///
34/// The tracker is keyed by `(repo_id, file_path)` and stores a `Vec<SymbolClaim>`
35/// for each file. DashMap provides fine-grained per-shard locking so reads are
36/// effectively lock-free when not contending on the same shard.
37pub struct SymbolClaimTracker {
38    /// Map from (repo_id, file_path) to the list of claims on that file.
39    claims: DashMap<(Uuid, String), Vec<SymbolClaim>>,
40}
41
42impl SymbolClaimTracker {
43    /// Create a new, empty tracker.
44    pub fn new() -> Self {
45        Self {
46            claims: DashMap::new(),
47        }
48    }
49
50    /// Record a symbol claim. If the same session already claims the same
51    /// `qualified_name` in the same file, the existing claim is updated
52    /// (not duplicated).
53    pub fn record_claim(&self, repo_id: Uuid, file_path: &str, claim: SymbolClaim) {
54        let key = (repo_id, file_path.to_string());
55        let mut entry = self.claims.entry(key).or_default();
56        let claims = entry.value_mut();
57
58        // Deduplicate: same session + same qualified_name → update in place
59        if let Some(existing) = claims.iter_mut().find(|c| {
60            c.session_id == claim.session_id && c.qualified_name == claim.qualified_name
61        }) {
62            existing.kind = claim.kind;
63            existing.agent_name = claim.agent_name;
64            // Keep the original first_touched_at
65        } else {
66            claims.push(claim);
67        }
68    }
69
70    /// Check whether any of the given `qualified_names` are already claimed by
71    /// a session other than `session_id`. Returns a `ConflictInfo` for each
72    /// conflicting symbol.
73    pub fn check_conflicts(
74        &self,
75        repo_id: Uuid,
76        file_path: &str,
77        session_id: Uuid,
78        qualified_names: &[String],
79    ) -> Vec<ConflictInfo> {
80        let key = (repo_id, file_path.to_string());
81        let Some(entry) = self.claims.get(&key) else {
82            return Vec::new();
83        };
84
85        let mut conflicts = Vec::new();
86        for name in qualified_names {
87            for claim in entry.value() {
88                if claim.qualified_name == *name && claim.session_id != session_id {
89                    conflicts.push(ConflictInfo {
90                        qualified_name: name.clone(),
91                        kind: claim.kind.clone(),
92                        conflicting_session: claim.session_id,
93                        conflicting_agent: claim.agent_name.clone(),
94                        first_touched_at: claim.first_touched_at,
95                    });
96                    // Only report the first conflicting session per symbol
97                    break;
98                }
99            }
100        }
101        conflicts
102    }
103
104    /// Return all conflicts for a given session across ALL file paths.
105    ///
106    /// This checks every tracked file to find symbols where `session_id` has
107    /// a claim AND another session also claims the same symbol.
108    pub fn get_all_conflicts_for_session(
109        &self,
110        repo_id: Uuid,
111        session_id: Uuid,
112    ) -> Vec<(String, ConflictInfo)> {
113        let mut results = Vec::new();
114        for entry in self.claims.iter() {
115            let (entry_repo_id, file_path) = entry.key();
116            if *entry_repo_id != repo_id {
117                continue;
118            }
119            let claims = entry.value();
120
121            // Find symbols claimed by this session
122            let my_symbols: Vec<&SymbolClaim> = claims
123                .iter()
124                .filter(|c| c.session_id == session_id)
125                .collect();
126
127            for my_claim in &my_symbols {
128                // Check if any OTHER session also claims this symbol
129                for other_claim in claims {
130                    if other_claim.session_id != session_id
131                        && other_claim.qualified_name == my_claim.qualified_name
132                    {
133                        results.push((
134                            file_path.clone(),
135                            ConflictInfo {
136                                qualified_name: my_claim.qualified_name.clone(),
137                                kind: my_claim.kind.clone(),
138                                conflicting_session: other_claim.session_id,
139                                conflicting_agent: other_claim.agent_name.clone(),
140                                first_touched_at: other_claim.first_touched_at,
141                            },
142                        ));
143                        // Only report the first conflicting session per symbol
144                        break;
145                    }
146                }
147            }
148        }
149        results
150    }
151
152    /// Remove all claims belonging to a session (e.g. on disconnect or GC).
153    pub fn clear_session(&self, session_id: Uuid) {
154        // Iterate all entries and remove claims for the given session.
155        // Empty entries are cleaned up to avoid unbounded memory growth.
156        let mut empty_keys = Vec::new();
157        for mut entry in self.claims.iter_mut() {
158            entry.value_mut().retain(|c| c.session_id != session_id);
159            if entry.value().is_empty() {
160                empty_keys.push(entry.key().clone());
161            }
162        }
163        for key in empty_keys {
164            // Re-check under write lock to avoid race
165            self.claims.remove_if(&key, |_, v| v.is_empty());
166        }
167    }
168}
169
170impl Default for SymbolClaimTracker {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn make_claim(session_id: Uuid, agent: &str, name: &str, kind: SymbolKind) -> SymbolClaim {
181        SymbolClaim {
182            session_id,
183            agent_name: agent.to_string(),
184            qualified_name: name.to_string(),
185            kind,
186            first_touched_at: Instant::now(),
187        }
188    }
189
190    #[test]
191    fn no_conflict_different_symbols_same_file() {
192        let tracker = SymbolClaimTracker::new();
193        let repo = Uuid::new_v4();
194        let session_a = Uuid::new_v4();
195        let session_b = Uuid::new_v4();
196
197        tracker.record_claim(
198            repo,
199            "src/lib.rs",
200            make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
201        );
202
203        let conflicts = tracker.check_conflicts(
204            repo,
205            "src/lib.rs",
206            session_b,
207            &["fn_b".to_string()],
208        );
209        assert!(conflicts.is_empty(), "different symbols should not conflict");
210    }
211
212    #[test]
213    fn conflict_same_symbol() {
214        let tracker = SymbolClaimTracker::new();
215        let repo = Uuid::new_v4();
216        let session_a = Uuid::new_v4();
217        let session_b = Uuid::new_v4();
218
219        tracker.record_claim(
220            repo,
221            "src/lib.rs",
222            make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
223        );
224
225        let conflicts = tracker.check_conflicts(
226            repo,
227            "src/lib.rs",
228            session_b,
229            &["fn_a".to_string()],
230        );
231        assert_eq!(conflicts.len(), 1);
232        assert_eq!(conflicts[0].qualified_name, "fn_a");
233        assert_eq!(conflicts[0].conflicting_session, session_a);
234        assert_eq!(conflicts[0].conflicting_agent, "agent-1");
235    }
236
237    #[test]
238    fn claims_cleared_on_session_destroy() {
239        let tracker = SymbolClaimTracker::new();
240        let repo = Uuid::new_v4();
241        let session_a = Uuid::new_v4();
242        let session_b = Uuid::new_v4();
243
244        tracker.record_claim(
245            repo,
246            "src/lib.rs",
247            make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
248        );
249
250        tracker.clear_session(session_a);
251
252        let conflicts = tracker.check_conflicts(
253            repo,
254            "src/lib.rs",
255            session_b,
256            &["fn_a".to_string()],
257        );
258        assert!(conflicts.is_empty(), "cleared session should not cause conflicts");
259    }
260
261    #[test]
262    fn same_session_no_self_conflict() {
263        let tracker = SymbolClaimTracker::new();
264        let repo = Uuid::new_v4();
265        let session_a = Uuid::new_v4();
266
267        tracker.record_claim(
268            repo,
269            "src/lib.rs",
270            make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
271        );
272        // Re-write same symbol from same session
273        tracker.record_claim(
274            repo,
275            "src/lib.rs",
276            make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
277        );
278
279        let conflicts = tracker.check_conflicts(
280            repo,
281            "src/lib.rs",
282            session_a,
283            &["fn_a".to_string()],
284        );
285        assert!(conflicts.is_empty(), "same session should not conflict with itself");
286    }
287
288    #[test]
289    fn multiple_conflicts() {
290        let tracker = SymbolClaimTracker::new();
291        let repo = Uuid::new_v4();
292        let session_a = Uuid::new_v4();
293        let session_b = Uuid::new_v4();
294
295        tracker.record_claim(
296            repo,
297            "src/lib.rs",
298            make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
299        );
300        tracker.record_claim(
301            repo,
302            "src/lib.rs",
303            make_claim(session_a, "agent-1", "fn_b", SymbolKind::Function),
304        );
305
306        let conflicts = tracker.check_conflicts(
307            repo,
308            "src/lib.rs",
309            session_b,
310            &["fn_a".to_string(), "fn_b".to_string()],
311        );
312        assert_eq!(conflicts.len(), 2);
313
314        let names: Vec<&str> = conflicts.iter().map(|c| c.qualified_name.as_str()).collect();
315        assert!(names.contains(&"fn_a"));
316        assert!(names.contains(&"fn_b"));
317    }
318}