Skip to main content

lean_ctx/core/
scent_field.rs

1//! Stigmergic scent field for zero-token multi-agent coordination (#540, EFF-3).
2//!
3//! Agents coordinate indirectly through a shared, time-decaying field of
4//! "scent" deposits instead of reading each other's scratchpad messages
5//! (Many Tems: 3.4x fewer coordination tokens; Pressure Fields 2601.08129:
6//! temporal decay prevents premature convergence). Deposits happen as side
7//! effects of normal work — bounces, edit failures, claims, handoffs — and the `sync`
8//! view is pure arithmetic over the field: no LLM calls, no message reads.
9//!
10//! Storage: one JSON file under `data_dir/agents/scent_field.json`, guarded by
11//! the same create-new file lock the agent registry uses. Decayed entries are
12//! garbage-collected lazily on every locked operation — no daemon, no timer.
13//!
14//! Every scent names the project partition (`project_hash::hash_project_root`)
15//! it was deposited in; claims, releases and the sync view see only the
16//! scents of their own project. Scents of releases before 3.11.5 name none,
17//! are never shown or honoured, and decay away within hours.
18//! Scents are ephemeral by design (half-lives of 10 to 60 minutes): when a
19//! project's identity (remote, package name) changes its partition, its live
20//! scents are not carried over; they decay instead of being adopted.
21
22use serde::{Deserialize, Serialize};
23use std::path::PathBuf;
24
25/// Scents below this effective intensity are dead and get collected.
26const GC_THRESHOLD: f64 = 0.05;
27/// Superposed intensity per (agent, kind, target) is capped here.
28const INTENSITY_CAP: f64 = 3.0;
29/// A foreign claim is considered active at or above this effective intensity.
30pub const CLAIM_ACTIVE_THRESHOLD: f64 = 0.3;
31/// Max rendered lines in the sync view.
32const 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    /// Agent is actively working on this target (file, task, deploy unit).
38    Claimed,
39    /// Agent finished something related to the target.
40    Done,
41    /// Agent hit a wall here (edit failures, bounces).
42    Stuck,
43    /// Retired: deposited on every read until 3.13.0. Kept so a field file
44    /// that still holds such scents parses; loading the field drops them.
45    Hot,
46    /// Target should not be touched (e.g. broken generated file).
47    Avoid,
48}
49
50impl ScentKind {
51    /// Exponential-decay half-life per kind, in seconds.
52    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    /// Normalized target: relative file path, task label, or deploy unit.
76    pub target: String,
77    /// Deposited (pre-decay) intensity.
78    pub intensity: f64,
79    /// Unix seconds at deposit time.
80    pub deposited_at: u64,
81    /// Project partition the scent was deposited in.
82    #[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    /// Lifetime count of rejected claims (#549): every rejection is a piece
104    /// of duplicate work the field prevented — the efficacy currency of #540.
105    #[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/// Scent-field identity (#547). `agent_identity::current_agent_id` falls back
122/// to a shared `"local"` for every unconfigured process, which would make
123/// claims between two parallel MCP servers on the same machine invisible to
124/// each other (`foreign_claim` filters on `agent_id != self`). For scents we
125/// disambiguate with the PID; ledger/heatmap attribution keeps using the
126/// stable shared identity and is intentionally NOT changed.
127#[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    /// Drop scents whose effective intensity fell below the GC threshold.
160    pub fn gc(&mut self, now: u64) {
161        self.scents
162            .retain(|s| s.effective_intensity(now) >= GC_THRESHOLD);
163    }
164
165    /// Superpose a deposit: same (agent, kind, target) folds into one scent
166    /// with summed effective intensity (capped), fresh timestamp.
167    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    /// Active foreign claim on `target` in `project`, if any: returns
200    /// (agent_id, age_secs).
201    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    /// Arithmetic sync view: targets grouped, intensities superposed across
226    /// agents, sorted by total intensity, capped at SYNC_TOP_K lines.
227    pub fn render_sync(&self, project: &str, now: u64) -> String {
228        use std::collections::HashMap;
229        // (kind, target) -> (total intensity, agents)
230        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
278/// Locked load-modify-save against the shared field file.
279fn 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
291/// The partition of `project_root`.
292fn partition(project_root: &str) -> String {
293    crate::core::project_hash::hash_project_root(project_root)
294}
295
296/// The project of the current request; `None` without one (the process
297/// directory is never a project). A caller that hands the work to another
298/// thread resolves it first, because the request scope does not travel.
299#[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
305/// Deposit a scent as a side effect of normal work in the current request's
306/// project; nothing without a bound request project. Errors are swallowed — coordination hints must never break the
307/// primary operation.
308pub 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
314/// [`deposit`] into `project_root`'s scents.
315pub 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
327/// Atomic claim: fails with the holder's id if another agent's claim in the
328/// same project is still active, otherwise deposits a strong Claimed scent
329/// for `agent_id`.
330pub 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
345/// Lifetime rejected-claim counter (#549): duplicate work prevented.
346pub fn claims_rejected_total() -> u64 {
347    field_path().map_or(0, |p| ScentField::load_unlocked(&p).claims_rejected)
348}
349
350/// Read-only view of currently effective scents for the dashboard (#548):
351/// `(scent, effective_intensity_now)`, strongest first. Lock-free read —
352/// a slightly stale view is fine for display.
353pub 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
371/// Release a claim on `target` held by `agent_id` in `project_root`.
372pub 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
385/// Arithmetic sync block of `project_root` for ctx_agent sync.
386pub 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        // After two half-lives < 0.3 (ticket acceptance).
414        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); // six half-lives: 0.3 -> ~0.0047
432        assert!(f.scents.is_empty());
433    }
434
435    /// AP05-G6: the field was one installation-wide set: a claim blocked the
436    /// same-named file in every other project and `sync` showed their paths.
437    #[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        // Expired claim no longer blocks.
450        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    /// Owner decision 2026-10-31: reads no longer deposit Hot scents. A field
484    /// file written before still parses, keeps its claims and counter, and
485    /// shows no Hot trail.
486    #[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            // #547: two parallel unconfigured processes must not collide.
516            assert_eq!(id, format!("local-{}", std::process::id()));
517        } else {
518            // Explicitly configured identity is kept verbatim.
519            assert_eq!(id, base);
520        }
521        // Claims between distinct PIDs are mutually foreign.
522        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}