harn-hostlib 0.7.50

Opt-in code-intelligence and deterministic-tool host builtins for the Harn VM
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Per-workspace agent registry plus advisory per-file locks.
//!
//! Tracks live agents (IDE, background eval, agentic loops, etc.) and the
//! TTL-based advisory locks they hold over files. Agents call `heartbeat`
//! on their own cadence; the registry reaps anyone who has gone silent
//! beyond `agent_timeout_ms` and releases their locks.
//!
//! All bookkeeping lives behind a `Mutex` inside [`IndexState`] so the
//! capability stays single-threaded from the Harn VM's perspective. The
//! registry itself is `Send + Sync`-friendly: callers wrap it in
//! `Arc<Mutex<_>>`.
//!
//! ## Recovery
//!
//! [`AgentRegistry::reap`] is the single recovery primitive: walking
//! every recorded agent and downgrading anyone whose `last_seen` is older
//! than the timeout. Lock holders that have been reaped lose their locks
//! at the same time. Embedders call this at startup (after restoring
//! state from a snapshot, if any) to clear out agents that crashed
//! between runs.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Stable identifier for an agent in the registry.
pub type AgentId = u64;

/// Lifecycle state of one tracked agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentState {
    /// Recently heartbeated and considered live.
    Active,
    /// Missed too many heartbeats. Locks have been released; the record
    /// is kept around so historical version-log entries can resolve the
    /// human-readable name.
    Crashed,
    /// Explicitly unregistered. Equivalent to "deleted" but exposed so
    /// listings stay debuggable.
    Gone,
}

/// One row in the registry. Public so embedders that want to surface a
/// `status` panel can read the lifecycle state without going through the
/// host builtins.
#[derive(Debug, Clone)]
pub struct AgentInfo {
    /// Stable identifier.
    pub id: AgentId,
    /// Human-readable label (`"editor"`, `"copilot"`, etc.).
    pub name: String,
    /// Lifecycle state.
    pub state: AgentState,
    /// Wall-clock ms since the Unix epoch of the last heartbeat (or
    /// register/lock activity).
    pub last_seen_ms: i64,
    /// Cumulative number of edits attributed to this agent.
    pub edit_count: u64,
    /// Workspace-relative path → expiry timestamp (ms). Empty when the
    /// agent holds no locks.
    pub locked_paths: HashMap<String, i64>,
}

/// Registry config for agent liveness and lock expiry.
#[derive(Debug, Clone, Copy)]
pub struct RegistryConfig {
    /// Default lock TTL when callers don't supply one.
    pub default_lock_ttl_ms: i64,
    /// How long a registered agent can stay silent before the next reap
    /// downgrades it to `Crashed` and releases its locks. 45 seconds.
    pub agent_timeout_ms: i64,
}

impl Default for RegistryConfig {
    fn default() -> Self {
        Self {
            default_lock_ttl_ms: 30_000,
            agent_timeout_ms: 45_000,
        }
    }
}

/// Per-workspace agent registry plus advisory per-file lock table.
#[derive(Debug, Default, Clone)]
pub struct AgentRegistry {
    config: RegistryConfig,
    next_id: AgentId,
    agents: HashMap<AgentId, AgentInfo>,
}

impl AgentRegistry {
    /// Construct an empty registry with default TTL/timeout values.
    pub fn new() -> Self {
        Self::with_config(RegistryConfig::default())
    }

    /// Construct an empty registry with explicit thresholds.
    pub fn with_config(config: RegistryConfig) -> Self {
        Self {
            config,
            next_id: 1,
            agents: HashMap::new(),
        }
    }

    /// Borrow the active config (lock TTL + timeout).
    pub fn config(&self) -> RegistryConfig {
        self.config
    }

    /// Register a new agent under an auto-assigned id. The display name is
    /// stored verbatim so embedders can surface it in `status`.
    pub fn register(&mut self, name: impl Into<String>, now_ms: i64) -> AgentId {
        let id = self.next_id;
        self.next_id = self
            .next_id
            .checked_add(1)
            .expect("AgentId overflow — registry has been alive an absurd amount of time");
        self.register_with_id(id, name, now_ms);
        id
    }

    /// Register or refresh an agent under an explicit id. Used by callers
    /// (and the version log) that need to thread an externally-assigned id
    /// through the registry. Returns the same id back.
    pub fn register_with_id(
        &mut self,
        id: AgentId,
        name: impl Into<String>,
        now_ms: i64,
    ) -> AgentId {
        self.next_id = self.next_id.max(id.saturating_add(1));
        self.agents.insert(
            id,
            AgentInfo {
                id,
                name: name.into(),
                state: AgentState::Active,
                last_seen_ms: now_ms,
                edit_count: 0,
                locked_paths: HashMap::new(),
            },
        );
        id
    }

    /// Refresh an agent's `last_seen_ms`. If the agent isn't registered we
    /// transparently register it with a placeholder name (`agent-<id>`).
    pub fn heartbeat(&mut self, id: AgentId, now_ms: i64) {
        match self.agents.get_mut(&id) {
            Some(info) => {
                info.last_seen_ms = now_ms;
                if info.state == AgentState::Crashed {
                    info.state = AgentState::Active;
                }
            }
            None => {
                self.register_with_id(id, format!("agent-{id}"), now_ms);
            }
        }
    }

    /// Drop an agent record. No-op if the id isn't registered.
    pub fn unregister(&mut self, id: AgentId) {
        self.agents.remove(&id);
    }

    /// Iterate over the live agent records — useful for `status` payloads.
    pub fn agents(&self) -> impl Iterator<Item = &AgentInfo> {
        self.agents.values()
    }

    /// Look up one agent record by id.
    pub fn get(&self, id: AgentId) -> Option<&AgentInfo> {
        self.agents.get(&id)
    }

    /// Bump the `edit_count` for `id`. Used by `version_record` so the
    /// status surface can quickly answer "is this agent still busy?".
    pub fn note_edit(&mut self, id: AgentId, now_ms: i64) {
        if let Some(info) = self.agents.get_mut(&id) {
            info.edit_count = info.edit_count.saturating_add(1);
            info.last_seen_ms = now_ms;
        }
    }

    /// Try to acquire an exclusive lock on `path` for `agent_id`. Reaps
    /// expired records first so a stale holder doesn't block forever.
    /// Returns `true` if the lock was granted.
    pub fn try_lock(
        &mut self,
        agent_id: AgentId,
        path: &str,
        ttl_ms: Option<i64>,
        now_ms: i64,
    ) -> bool {
        self.reap(now_ms);
        let ttl = ttl_ms.unwrap_or(self.config.default_lock_ttl_ms);
        for (other_id, other) in &self.agents {
            if *other_id == agent_id {
                continue;
            }
            if let Some(expiry) = other.locked_paths.get(path) {
                if *expiry > now_ms {
                    return false;
                }
            }
        }
        if !self.agents.contains_key(&agent_id) {
            self.register_with_id(agent_id, format!("agent-{agent_id}"), now_ms);
        }
        let info = self
            .agents
            .get_mut(&agent_id)
            .expect("just registered above");
        info.locked_paths.insert(path.to_string(), now_ms + ttl);
        info.last_seen_ms = now_ms;
        true
    }

    /// Release a lock previously held by `agent_id` on `path`. No-op when
    /// the agent or the lock are gone.
    pub fn release_lock(&mut self, agent_id: AgentId, path: &str) {
        if let Some(info) = self.agents.get_mut(&agent_id) {
            info.locked_paths.remove(path);
        }
    }

    /// Return the id of the agent currently holding `path`, or `None`.
    /// Expired holders are reaped lazily so the answer reflects state
    /// without requiring a separate pass.
    pub fn lock_holder(&mut self, path: &str, now_ms: i64) -> Option<AgentId> {
        self.reap(now_ms);
        for (id, info) in &self.agents {
            if let Some(expiry) = info.locked_paths.get(path) {
                if *expiry > now_ms {
                    return Some(*id);
                }
            }
        }
        None
    }

    /// Walk every agent and downgrade ones whose `last_seen_ms` is older
    /// than `agent_timeout_ms`. Crashed agents drop their locks as a
    /// side effect. Idempotent — embedders call this at startup to recover
    /// state inherited from a previous run.
    pub fn reap(&mut self, now_ms: i64) {
        let timeout = self.config.agent_timeout_ms;
        for info in self.agents.values_mut() {
            if info.state == AgentState::Active && now_ms - info.last_seen_ms > timeout {
                info.state = AgentState::Crashed;
                info.locked_paths.clear();
            }
        }
    }

    /// Persist the registry into a serialisable form.
    pub fn snapshot(&self) -> SerializedRegistry {
        SerializedRegistry {
            next_id: self.next_id,
            agents: self.agents.values().map(SerializedAgent::from).collect(),
        }
    }

    /// Restore a registry from a previously persisted snapshot.
    pub fn from_snapshot(config: RegistryConfig, snap: SerializedRegistry) -> Self {
        let mut agents = HashMap::with_capacity(snap.agents.len());
        for entry in snap.agents {
            agents.insert(
                entry.id,
                AgentInfo {
                    id: entry.id,
                    name: entry.name,
                    state: entry.state,
                    last_seen_ms: entry.last_seen_ms,
                    edit_count: entry.edit_count,
                    locked_paths: entry.locked_paths,
                },
            );
        }
        Self {
            config,
            next_id: snap.next_id.max(1),
            agents,
        }
    }
}

/// On-disk layout for an agent record. Public so the snapshot module can
/// embed it. Field shapes are kept stable so existing snapshots remain
/// readable across hostlib versions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedAgent {
    /// Stable identifier.
    pub id: AgentId,
    /// Human-readable label.
    pub name: String,
    /// Lifecycle state at snapshot time.
    pub state: AgentState,
    /// Wall-clock ms of the last heartbeat.
    pub last_seen_ms: i64,
    /// Number of edits attributed at snapshot time.
    pub edit_count: u64,
    /// Locks held at snapshot time (path → expiry ms).
    pub locked_paths: HashMap<String, i64>,
}

impl From<&AgentInfo> for SerializedAgent {
    fn from(info: &AgentInfo) -> Self {
        Self {
            id: info.id,
            name: info.name.clone(),
            state: info.state,
            last_seen_ms: info.last_seen_ms,
            edit_count: info.edit_count,
            locked_paths: info.locked_paths.clone(),
        }
    }
}

/// On-disk layout for the full registry.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SerializedRegistry {
    /// Next id to hand out — preserved across restarts so reused ids don't
    /// collide with historical records in the version log.
    #[serde(default)]
    pub next_id: AgentId,
    /// All known agent records.
    #[serde(default)]
    pub agents: Vec<SerializedAgent>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn register_then_heartbeat_keeps_agent_active() {
        let mut reg = AgentRegistry::new();
        let id = reg.register("editor", 1_000);
        assert!(matches!(reg.get(id).unwrap().state, AgentState::Active));
        reg.heartbeat(id, 5_000);
        let info = reg.get(id).unwrap();
        assert_eq!(info.last_seen_ms, 5_000);
        assert_eq!(info.state, AgentState::Active);
    }

    #[test]
    fn reap_marks_silent_agents_crashed_and_drops_locks() {
        let mut reg = AgentRegistry::new();
        let id = reg.register("editor", 0);
        assert!(reg.try_lock(id, "src/main.rs", None, 0));
        // 60s later the agent has missed the 45s timeout.
        reg.reap(60_000);
        let info = reg.get(id).unwrap();
        assert_eq!(info.state, AgentState::Crashed);
        assert!(info.locked_paths.is_empty());
        assert_eq!(reg.lock_holder("src/main.rs", 60_000), None);
    }

    #[test]
    fn try_lock_blocks_other_agents_until_expiry() {
        let mut reg = AgentRegistry::new();
        let a = reg.register("a", 0);
        let b = reg.register("b", 0);
        assert!(reg.try_lock(a, "f.rs", Some(1_000), 0));
        // While A holds it, B can't grab it.
        assert!(!reg.try_lock(b, "f.rs", Some(1_000), 100));
        // Past expiry, B can take over.
        assert!(reg.try_lock(b, "f.rs", Some(1_000), 5_000));
        assert_eq!(reg.lock_holder("f.rs", 5_000), Some(b));
    }

    #[test]
    fn release_lock_lets_others_acquire_immediately() {
        let mut reg = AgentRegistry::new();
        let a = reg.register("a", 0);
        let b = reg.register("b", 0);
        reg.try_lock(a, "x", None, 0);
        reg.release_lock(a, "x");
        assert!(reg.try_lock(b, "x", None, 100));
    }

    #[test]
    fn heartbeat_resurrects_a_crashed_agent() {
        let mut reg = AgentRegistry::new();
        let id = reg.register("a", 0);
        reg.reap(60_000);
        assert_eq!(reg.get(id).unwrap().state, AgentState::Crashed);
        reg.heartbeat(id, 70_000);
        assert_eq!(reg.get(id).unwrap().state, AgentState::Active);
    }

    #[test]
    fn snapshot_round_trips_through_serialized_form() {
        let mut reg = AgentRegistry::new();
        let id = reg.register("editor", 100);
        reg.try_lock(id, "src/main.rs", Some(1_000), 100);
        let snap = reg.snapshot();
        let restored = AgentRegistry::from_snapshot(reg.config(), snap);
        assert_eq!(restored.get(id).unwrap().name, "editor");
        assert_eq!(
            restored
                .get(id)
                .unwrap()
                .locked_paths
                .get("src/main.rs")
                .copied(),
            Some(1_100)
        );
    }
}