1use serde::{Deserialize, Serialize};
23use std::path::PathBuf;
24
25const GC_THRESHOLD: f64 = 0.05;
27const INTENSITY_CAP: f64 = 3.0;
29pub const CLAIM_ACTIVE_THRESHOLD: f64 = 0.3;
31const SYNC_TOP_K: usize = 15;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ScentKind {
37 Claimed,
39 Done,
41 Stuck,
43 Hot,
46 Avoid,
48}
49
50impl ScentKind {
51 fn half_life_secs(self) -> f64 {
53 match self {
54 ScentKind::Claimed | ScentKind::Hot => 600.0,
55 ScentKind::Stuck => 1800.0,
56 ScentKind::Done | ScentKind::Avoid => 3600.0,
57 }
58 }
59
60 pub fn as_str(self) -> &'static str {
61 match self {
62 ScentKind::Claimed => "CLAIMED",
63 ScentKind::Done => "DONE",
64 ScentKind::Stuck => "STUCK",
65 ScentKind::Hot => "HOT",
66 ScentKind::Avoid => "AVOID",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Scent {
73 pub agent_id: String,
74 pub kind: ScentKind,
75 pub target: String,
77 pub intensity: f64,
79 pub deposited_at: u64,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub project: Option<String>,
84}
85
86impl Scent {
87 fn in_project(&self, project: &str) -> bool {
88 self.project.as_deref() == Some(project)
89 }
90}
91
92impl Scent {
93 pub fn effective_intensity(&self, now: u64) -> f64 {
94 let dt = now.saturating_sub(self.deposited_at) as f64;
95 self.intensity * (-(std::f64::consts::LN_2) * dt / self.kind.half_life_secs()).exp()
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct ScentField {
101 pub scents: Vec<Scent>,
102 pub schema_version: u32,
103 #[serde(default)]
106 pub claims_rejected: u64,
107}
108
109fn field_path() -> Result<PathBuf, String> {
110 let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("agents");
111 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
112 Ok(dir.join("scent_field.json"))
113}
114
115fn now_secs() -> u64 {
116 std::time::SystemTime::now()
117 .duration_since(std::time::UNIX_EPOCH)
118 .map_or(0, |d| d.as_secs())
119}
120
121#[must_use]
128pub fn scent_agent_id() -> &'static str {
129 static CACHE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
130 CACHE.get_or_init(|| {
131 let base = crate::core::agent_identity::current_agent_id();
132 if base == "local" {
133 format!("local-{}", std::process::id())
134 } else {
135 base.to_string()
136 }
137 })
138}
139
140impl ScentField {
141 fn load_unlocked(path: &PathBuf) -> Self {
142 if let Ok(content) = std::fs::read_to_string(path)
143 && let Ok(mut f) = serde_json::from_str::<ScentField>(&content)
144 {
145 f.scents.retain(|s| s.kind != ScentKind::Hot);
146 return f;
147 }
148 ScentField {
149 schema_version: 1,
150 ..Default::default()
151 }
152 }
153
154 fn save_unlocked(&self, path: &PathBuf) -> Result<(), String> {
155 let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
156 std::fs::write(path, json).map_err(|e| e.to_string())
157 }
158
159 pub fn gc(&mut self, now: u64) {
161 self.scents
162 .retain(|s| s.effective_intensity(now) >= GC_THRESHOLD);
163 }
164
165 pub fn deposit(
168 &mut self,
169 project: &str,
170 agent_id: &str,
171 kind: ScentKind,
172 target: &str,
173 intensity: f64,
174 now: u64,
175 ) {
176 self.gc(now);
177 let target = target.trim();
178 if target.is_empty() || agent_id.is_empty() {
179 return;
180 }
181 if let Some(existing) = self.scents.iter_mut().find(|s| {
182 s.in_project(project) && s.agent_id == agent_id && s.kind == kind && s.target == target
183 }) {
184 let carried = existing.effective_intensity(now);
185 existing.intensity = (carried + intensity).min(INTENSITY_CAP);
186 existing.deposited_at = now;
187 } else {
188 self.scents.push(Scent {
189 agent_id: agent_id.to_string(),
190 kind,
191 target: target.to_string(),
192 intensity: intensity.min(INTENSITY_CAP),
193 deposited_at: now,
194 project: Some(project.to_string()),
195 });
196 }
197 }
198
199 pub fn foreign_claim(
202 &self,
203 project: &str,
204 target: &str,
205 self_agent: &str,
206 now: u64,
207 ) -> Option<(String, u64)> {
208 self.scents
209 .iter()
210 .filter(|s| {
211 s.in_project(project)
212 && s.kind == ScentKind::Claimed
213 && s.target == target
214 && s.agent_id != self_agent
215 && s.effective_intensity(now) >= CLAIM_ACTIVE_THRESHOLD
216 })
217 .max_by(|a, b| {
218 a.effective_intensity(now)
219 .partial_cmp(&b.effective_intensity(now))
220 .unwrap_or(std::cmp::Ordering::Equal)
221 })
222 .map(|s| (s.agent_id.clone(), now.saturating_sub(s.deposited_at)))
223 }
224
225 pub fn render_sync(&self, project: &str, now: u64) -> String {
228 use std::collections::HashMap;
229 type SyncKey<'a> = (ScentKind, &'a str);
231 type SyncAgg<'a> = (f64, Vec<&'a str>);
232 let mut groups: HashMap<SyncKey<'_>, SyncAgg<'_>> = HashMap::new();
233 for s in self.scents.iter().filter(|s| s.in_project(project)) {
234 let eff = s.effective_intensity(now);
235 if eff < GC_THRESHOLD {
236 continue;
237 }
238 let entry = groups.entry((s.kind, s.target.as_str())).or_default();
239 entry.0 += eff;
240 if !entry.1.contains(&s.agent_id.as_str()) {
241 entry.1.push(s.agent_id.as_str());
242 }
243 }
244 if groups.is_empty() {
245 return String::new();
246 }
247 let mut rows: Vec<(SyncKey<'_>, SyncAgg<'_>)> = groups.into_iter().collect();
248 rows.sort_by(|a, b| {
249 b.1.0
250 .partial_cmp(&a.1.0)
251 .unwrap_or(std::cmp::Ordering::Equal)
252 .then_with(|| a.0.1.cmp(b.0.1))
253 });
254
255 let mut out = String::from("Scent field (decaying, zero-token coordination):\n");
256 for ((kind, target), (total, agents)) in rows.iter().take(SYNC_TOP_K) {
257 let who = if agents.len() == 1 {
258 agents[0].to_string()
259 } else {
260 format!("{} agents", agents.len())
261 };
262 out.push_str(&format!(
263 " {} {} ({:.1}) by {}\n",
264 kind.as_str(),
265 target,
266 total,
267 who
268 ));
269 }
270 let extra = rows.len().saturating_sub(SYNC_TOP_K);
271 if extra > 0 {
272 out.push_str(&format!(" … {extra} weaker scent(s) below cutoff\n"));
273 }
274 out
275 }
276}
277
278fn with_field<R>(f: impl FnOnce(&mut ScentField, u64) -> R) -> Result<R, String> {
280 let path = field_path()?;
281 let lock_path = path.with_extension("json.lock");
282 let _lock = crate::core::agents::FileLock::acquire(&lock_path)?;
283 let mut field = ScentField::load_unlocked(&path);
284 let now = now_secs();
285 let result = f(&mut field, now);
286 field.gc(now);
287 field.save_unlocked(&path)?;
288 Ok(result)
289}
290
291fn partition(project_root: &str) -> String {
293 crate::core::project_hash::hash_project_root(project_root)
294}
295
296#[must_use]
300pub fn current_project() -> Option<String> {
301 crate::core::policy::diagnostics::request_project()
302 .map(|root| root.to_string_lossy().into_owned())
303}
304
305pub fn deposit(agent_id: &str, kind: ScentKind, target: &str, intensity: f64) {
309 if let Some(project_root) = current_project() {
310 deposit_for(&project_root, agent_id, kind, target, intensity);
311 }
312}
313
314pub fn deposit_for(
316 project_root: &str,
317 agent_id: &str,
318 kind: ScentKind,
319 target: &str,
320 intensity: f64,
321) {
322 let project = partition(project_root);
323 let _ =
324 with_field(|field, now| field.deposit(&project, agent_id, kind, target, intensity, now));
325}
326
327pub fn claim(project_root: &str, agent_id: &str, target: &str) -> Result<(), String> {
331 let project = partition(project_root);
332 with_field(|field, now| {
333 if let Some((holder, age)) = field.foreign_claim(&project, target, agent_id, now) {
334 field.claims_rejected += 1;
335 return Err(format!(
336 "already claimed by {holder} ({}m ago, still active)",
337 age / 60
338 ));
339 }
340 field.deposit(&project, agent_id, ScentKind::Claimed, target, 2.0, now);
341 Ok(())
342 })?
343}
344
345pub fn claims_rejected_total() -> u64 {
347 field_path().map_or(0, |p| ScentField::load_unlocked(&p).claims_rejected)
348}
349
350pub fn active_scents() -> Vec<(Scent, f64)> {
354 let Ok(path) = field_path() else {
355 return Vec::new();
356 };
357 let field = ScentField::load_unlocked(&path);
358 let now = now_secs();
359 let mut v: Vec<(Scent, f64)> = field
360 .scents
361 .into_iter()
362 .filter_map(|s| {
363 let eff = s.effective_intensity(now);
364 (eff >= GC_THRESHOLD).then_some((s, eff))
365 })
366 .collect();
367 v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
368 v
369}
370
371pub fn release(project_root: &str, agent_id: &str, target: &str) {
373 let project = partition(project_root);
374 let _ = with_field(|field, now| {
375 field.scents.retain(|s| {
376 !(s.in_project(&project)
377 && s.agent_id == agent_id
378 && s.target == target
379 && s.kind == ScentKind::Claimed)
380 });
381 field.gc(now);
382 });
383}
384
385pub fn sync_block(project_root: &str) -> String {
387 let Ok(path) = field_path() else {
388 return String::new();
389 };
390 let field = ScentField::load_unlocked(&path);
391 field.render_sync(&partition(project_root), now_secs())
392}
393
394#[cfg(test)]
395pub mod tests {
396 use super::*;
397
398 const NOW: u64 = 1_780_000_000;
399 const P: &str = "project-a";
400
401 #[test]
402 fn decay_halves_at_half_life() {
403 let s = Scent {
404 agent_id: "a1".into(),
405 kind: ScentKind::Claimed,
406 target: "src/x.rs".into(),
407 intensity: 1.0,
408 deposited_at: NOW,
409 project: Some(P.into()),
410 };
411 let eff = s.effective_intensity(NOW + 600);
412 assert!((eff - 0.5).abs() < 0.01, "half-life decay, got {eff}");
413 assert!(s.effective_intensity(NOW + 1200) < 0.3);
415 }
416
417 #[test]
418 fn superposition_caps_intensity() {
419 let mut f = ScentField::default();
420 for _ in 0..20 {
421 f.deposit(P, "a1", ScentKind::Claimed, "src/x.rs", 0.3, NOW);
422 }
423 assert_eq!(f.scents.len(), 1);
424 assert!(f.scents[0].intensity <= INTENSITY_CAP + f64::EPSILON);
425 }
426
427 #[test]
428 fn gc_drops_dead_scents() {
429 let mut f = ScentField::default();
430 f.deposit(P, "a1", ScentKind::Claimed, "src/x.rs", 0.3, NOW);
431 f.gc(NOW + 6 * 600); assert!(f.scents.is_empty());
433 }
434
435 #[test]
438 fn foreign_claim_detected_and_own_ignored() {
439 let mut f = ScentField::default();
440 f.deposit(P, "a1", ScentKind::Claimed, "src/x.rs", 2.0, NOW);
441 f.deposit(P, "a1", ScentKind::Done, "handoff secret-plan", 1.0, NOW);
442 assert!(f.foreign_claim(P, "src/x.rs", "a2", NOW + 60).is_some());
443 assert!(
444 f.foreign_claim("project-b", "src/x.rs", "a2", NOW + 60)
445 .is_none()
446 );
447 assert!(f.render_sync("project-b", NOW).is_empty());
448 assert!(f.foreign_claim(P, "src/x.rs", "a1", NOW + 60).is_none());
449 assert!(
451 f.foreign_claim(P, "src/x.rs", "a2", NOW + 3 * 600)
452 .is_none()
453 );
454 }
455
456 #[test]
457 fn sync_view_caps_lines_and_superposes() {
458 let mut f = ScentField::default();
459 for i in 0..50 {
460 f.deposit(P, "a1", ScentKind::Stuck, &format!("src/f{i}.rs"), 0.5, NOW);
461 }
462 f.deposit(P, "a2", ScentKind::Stuck, "src/f0.rs", 0.5, NOW);
463 let view = f.render_sync(P, NOW);
464 let lines: Vec<&str> = view.lines().collect();
465 assert!(
466 lines.len() <= SYNC_TOP_K + 2,
467 "header + topk + overflow, got {}",
468 lines.len()
469 );
470 assert!(view.contains("2 agents"), "superposed line: {view}");
471 assert!(view.contains("weaker scent"));
472 }
473
474 #[test]
475 fn empty_field_renders_empty() {
476 let f = ScentField::default();
477 {
478 let found = &f.render_sync(P, NOW);
479 assert!(found.is_empty(), "{found:?}");
480 }
481 }
482
483 #[test]
487 fn loading_an_older_field_drops_hot_scents() {
488 let dir = tempfile::tempdir().unwrap();
489 let path = dir.path().join("scent_field.json");
490 let mut old = ScentField {
491 schema_version: 1,
492 claims_rejected: 2,
493 ..Default::default()
494 };
495 old.deposit(P, "a1", ScentKind::Hot, "src/read.rs", 0.3, NOW);
496 old.deposit(P, "a1", ScentKind::Claimed, "src/x.rs", 2.0, NOW);
497 old.save_unlocked(&path).unwrap();
498
499 let loaded = ScentField::load_unlocked(&path);
500 assert_eq!(loaded.claims_rejected, 2);
501 assert_eq!(loaded.scents.len(), 1);
502 assert_eq!(loaded.scents[0].kind, ScentKind::Claimed);
503 let view = loaded.render_sync(P, NOW);
504 assert!(
505 view.contains("CLAIMED src/x.rs") && !view.contains("HOT"),
506 "{view}"
507 );
508 }
509
510 #[test]
511 fn scent_identity_disambiguates_unconfigured_processes() {
512 let id = scent_agent_id();
513 let base = crate::core::agent_identity::current_agent_id();
514 if base == "local" {
515 assert_eq!(id, format!("local-{}", std::process::id()));
517 } else {
518 assert_eq!(id, base);
520 }
521 let mut f = ScentField::default();
523 f.deposit(P, "local-1111", ScentKind::Claimed, "src/x.rs", 2.0, NOW);
524 assert!(
525 f.foreign_claim(P, "src/x.rs", "local-2222", NOW + 30)
526 .is_some()
527 );
528 }
529}