Skip to main content

harn_vm/orchestration/
agent_inbox.rs

1//! Unified per-session inbox for asynchronous nudges.
2//!
3//! Any code path that needs to deliver a payload to a running agent loop
4//! from *outside* the loop's current iteration funnels through this
5//! module. That includes:
6//!
7//!   * Long-running tool completions (`stdlib::long_running`,
8//!     `harn-hostlib::tools::run_command`'s background worker).
9//!   * Command-policy post-hooks that produce feedback for the next turn
10//!     (`orchestration::command_policy`).
11//!   * MCP server `notifications/progress` messages relayed from the
12//!     client transport.
13//!   * Trigger/connector handlers that want to nudge a session that's
14//!     already running (e.g. "the GitHub PR you've been waiting on just
15//!     merged").
16//!   * File-edited notifications queued by sync builtins like
17//!     `write_file`.
18//!
19//! ## Lifecycle and ordering
20//!
21//! Each producer calls [`push`] with the target `session_id`, a `kind`
22//! string (used as the synthetic message marker downstream), the
23//! payload `content`, and a `source` label for telemetry. Entries land
24//! in a per-session FIFO with monotonically increasing sequence
25//! numbers. Consumers (the agent loop, `run_command`'s short-wait
26//! helper, tests) call [`drain`] at well-defined boundaries:
27//!
28//!   1. Turn start, before `agent_autocompact_if_needed`. The summary
29//!      then reflects events that piled up between turns.
30//!   2. Turn start, *after* `agent_autocompact_if_needed`. Any push
31//!      that landed *during* the LLM summarization call gets injected
32//!      verbatim into the post-compaction transcript so it's visible
33//!      in this turn's prompt.
34//!
35//! The "second drain after compaction" is the load-bearing piece — it
36//! eliminates the race where async events arriving during a multi-
37//! second compaction LLM call would otherwise wait an entire extra
38//! turn before the agent sees them.
39//!
40//! ## Sync vs async waiters
41//!
42//! [`wait_sync`] blocks the calling thread on a Condvar paired with
43//! the push side. It exists because a handful of sync builtins
44//! (notably `run_command`'s "wait briefly for initial output" helper)
45//! need to park before returning to the VM. The Condvar wait uses
46//! wall-clock time — it is only appropriate inside an already-blocking
47//! sync builtin.
48//!
49//! [`wait_async`] is the canonical async waiter. It composes a
50//! `tokio::sync::Notify` with [`harn_clock::Clock`] so tests can
51//! advance virtual time. New consumers should always reach for the
52//! async variant.
53
54use std::collections::{HashMap, VecDeque};
55use std::sync::{Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
56use std::time::Duration;
57
58use harn_clock::{Clock, RealClock};
59use serde::{Deserialize, Serialize};
60use tokio::sync::Notify;
61
62/// A single inbox entry. Producers fill in `kind`, `content`, and
63/// `source`; the inbox stamps `sequence` and `ts_ms`.
64#[derive(Clone, Debug, Serialize, Deserialize)]
65pub struct InboxEntry {
66    /// Monotonically increasing per-session sequence number, starting
67    /// at 1. Wraps at `u64::MAX` (unreachable in practice).
68    pub sequence: u64,
69    pub session_id: String,
70    pub kind: String,
71    pub content: String,
72    pub source: String,
73    pub ts_ms: i64,
74}
75
76#[derive(Default)]
77struct InboxState {
78    entries: VecDeque<InboxEntry>,
79    seq: u64,
80    notify: std::sync::Arc<Notify>,
81}
82
83struct InboxRegistry {
84    inboxes: Mutex<HashMap<String, InboxState>>,
85    sync_cv: Condvar,
86}
87
88impl InboxRegistry {
89    fn new() -> Self {
90        Self {
91            inboxes: Mutex::new(HashMap::new()),
92            sync_cv: Condvar::new(),
93        }
94    }
95}
96
97fn registry() -> &'static InboxRegistry {
98    static REGISTRY: OnceLock<InboxRegistry> = OnceLock::new();
99    REGISTRY.get_or_init(InboxRegistry::new)
100}
101
102#[cfg(test)]
103fn reset_gate() -> &'static Mutex<()> {
104    static RESET_GATE: OnceLock<Mutex<()>> = OnceLock::new();
105    RESET_GATE.get_or_init(|| Mutex::new(()))
106}
107
108#[cfg(test)]
109pub(crate) fn lock_reset_for_test() -> MutexGuard<'static, ()> {
110    reset_gate().lock().unwrap_or_else(PoisonError::into_inner)
111}
112
113fn lock_map(reg: &InboxRegistry) -> MutexGuard<'_, HashMap<String, InboxState>> {
114    reg.inboxes.lock().unwrap_or_else(PoisonError::into_inner)
115}
116
117/// Default clock used for inbox timestamps. Tests can install a paused
118/// clock with [`install_clock`].
119fn clock_arc() -> std::sync::Arc<dyn Clock> {
120    static CLOCK: OnceLock<std::sync::Arc<dyn Clock>> = OnceLock::new();
121    CLOCK
122        .get_or_init(|| std::sync::Arc::new(RealClock::new()) as std::sync::Arc<dyn Clock>)
123        .clone()
124}
125
126/// Install a clock implementation. Idempotent: only the first caller
127/// wins (subsequent calls are silently dropped) which matches the
128/// pattern used elsewhere in the runtime.
129pub fn install_clock(clock: std::sync::Arc<dyn Clock>) {
130    static SLOT: OnceLock<std::sync::Arc<dyn Clock>> = OnceLock::new();
131    let _ = SLOT.set(clock);
132}
133
134/// Push an entry into `session_id`'s inbox and wake any waiters.
135///
136/// Safe to call from any thread. The session id may be empty — in that
137/// case the entry lands in an unnamed bucket that legacy callers
138/// (background fs/glob workers without a session context) can drain
139/// with `drain("")`. New producers should always supply a session id.
140pub fn push(session_id: &str, kind: &str, content: &str, source: &str) {
141    let reg = registry();
142    let notify = {
143        let mut map = lock_map(reg);
144        let state = map.entry(session_id.to_string()).or_default();
145        state.seq = state.seq.wrapping_add(1).max(1);
146        let entry = InboxEntry {
147            sequence: state.seq,
148            session_id: session_id.to_string(),
149            kind: kind.to_string(),
150            content: content.to_string(),
151            source: source.to_string(),
152            ts_ms: harn_clock::now_wall_ms(&*clock_arc()),
153        };
154        state.entries.push_back(entry);
155        state.notify.clone()
156    };
157    reg.sync_cv.notify_all();
158    notify.notify_waiters();
159}
160
161/// Drain every entry currently queued for `session_id`, in FIFO order.
162pub fn drain(session_id: &str) -> Vec<InboxEntry> {
163    let reg = registry();
164    let mut map = lock_map(reg);
165    map.get_mut(session_id)
166        .map(|state| state.entries.drain(..).collect())
167        .unwrap_or_default()
168}
169
170/// Drain entries from `session_id` whose `kind` matches `predicate`.
171/// Non-matching entries are kept in the inbox in their original order.
172pub fn drain_where<F>(session_id: &str, mut predicate: F) -> Vec<InboxEntry>
173where
174    F: FnMut(&InboxEntry) -> bool,
175{
176    let reg = registry();
177    let mut map = lock_map(reg);
178    let Some(state) = map.get_mut(session_id) else {
179        return Vec::new();
180    };
181    let mut taken = Vec::new();
182    let mut kept = VecDeque::with_capacity(state.entries.len());
183    for entry in state.entries.drain(..) {
184        if predicate(&entry) {
185            taken.push(entry);
186        } else {
187            kept.push_back(entry);
188        }
189    }
190    state.entries = kept;
191    taken
192}
193
194/// Re-insert an entry at the front of `session_id`'s inbox. Used when a
195/// consumer drains optimistically, peeks, and discovers the entry is
196/// not relevant to its caller (e.g. `run_command` waiting for one
197/// specific `handle_id`).
198pub fn requeue_front(entry: InboxEntry) {
199    let reg = registry();
200    let mut map = lock_map(reg);
201    let state = map.entry(entry.session_id.clone()).or_default();
202    state.entries.push_front(entry);
203}
204
205/// Number of entries currently queued for `session_id`.
206pub fn pending_count(session_id: &str) -> usize {
207    let reg = registry();
208    let map = lock_map(reg);
209    map.get(session_id)
210        .map(|state| state.entries.len())
211        .unwrap_or(0)
212}
213
214/// Drop everything queued for `session_id`. Called when a session ends.
215pub fn clear_session(session_id: &str) {
216    let reg = registry();
217    let mut map = lock_map(reg);
218    map.remove(session_id);
219}
220
221/// Wipe every inbox. Steady-state callers should prefer
222/// [`clear_session`] when a single conversation ends; this drains the
223/// entire registry and is wired into the per-test reset path so a
224/// reused worker thread does not accumulate one [`InboxState`] per
225/// session id across the suite.
226pub fn reset() {
227    #[cfg(test)]
228    let _reset_guard = lock_reset_for_test();
229    let reg = registry();
230    let mut map = lock_map(reg);
231    map.clear();
232}
233
234/// Number of sessions with a live inbox. Test-only.
235#[cfg(test)]
236pub fn session_count() -> usize {
237    let reg = registry();
238    let map = lock_map(reg);
239    map.len()
240}
241
242/// Sync wait. Parks the calling thread on a Condvar until `session_id`
243/// has at least one queued entry, or `timeout` elapses. Returns `true`
244/// if an entry is present at return time.
245///
246/// **When to use:** only inside sync builtins that are already blocking
247/// the runtime thread (e.g. `run_command`'s "wait_ms" helper). Every
248/// other consumer must use [`wait_async`].
249pub fn wait_sync(session_id: &str, timeout: Duration) -> bool {
250    let reg = registry();
251    let mut map = match reg.inboxes.lock() {
252        Ok(g) => g,
253        Err(p) => p.into_inner(),
254    };
255    if has_pending(&map, session_id) {
256        return true;
257    }
258    let start = std::time::Instant::now();
259    loop {
260        let remaining = match timeout.checked_sub(start.elapsed()) {
261            Some(remaining) if !remaining.is_zero() => remaining,
262            _ => return has_pending(&map, session_id),
263        };
264        let (next_guard, wait_result) = match reg.sync_cv.wait_timeout(map, remaining) {
265            Ok(pair) => pair,
266            Err(poison) => {
267                let pair = poison.into_inner();
268                (pair.0, pair.1)
269            }
270        };
271        map = next_guard;
272        if has_pending(&map, session_id) {
273            return true;
274        }
275        if wait_result.timed_out() {
276            return false;
277        }
278    }
279}
280
281fn has_pending(map: &HashMap<String, InboxState>, session_id: &str) -> bool {
282    map.get(session_id)
283        .map(|s| !s.entries.is_empty())
284        .unwrap_or(false)
285}
286
287/// Async wait. Returns when `session_id` has at least one queued entry
288/// or `clock.sleep(timeout)` resolves. Uses `tokio::sync::Notify` so it
289/// composes correctly with `tokio::time::pause()` and the
290/// [`harn_clock::PausedClock`] used by deterministic tests.
291///
292/// Cross-thread safety: a producer running on a different thread may
293/// finish its entire `push` (entry append + `notify_waiters`) between
294/// our `pending_count` check and our `Notified` snapshot. To close that
295/// window we *create* the `Notified` first (which captures the
296/// `notify_waiters` call counter), then re-check `pending_count`. Any
297/// push completed before the snapshot is visible via `pending_count`;
298/// any push completed after the snapshot triggers the `Notified`.
299pub async fn wait_async(session_id: &str, timeout: Duration, clock: &dyn Clock) -> bool {
300    if pending_count(session_id) > 0 {
301        return true;
302    }
303    let notify = {
304        let reg = registry();
305        let mut map = lock_map(reg);
306        map.entry(session_id.to_string())
307            .or_default()
308            .notify
309            .clone()
310    };
311    let sleep = clock.sleep(timeout);
312    tokio::pin!(sleep);
313    loop {
314        // Snapshot the notify counter BEFORE re-checking pending_count.
315        // See doc comment above for the race this closes.
316        let notified = notify.notified();
317        tokio::pin!(notified);
318        if pending_count(session_id) > 0 {
319            return true;
320        }
321        tokio::select! {
322            biased;
323            _ = &mut notified => {
324                if pending_count(session_id) > 0 {
325                    return true;
326                }
327            }
328            () = &mut sleep => {
329                return pending_count(session_id) > 0;
330            }
331        }
332    }
333}
334
335/// Snapshot a session's queue without draining. Test/observability
336/// helper.
337#[cfg(any(test, feature = "vm-bench-internals"))]
338pub fn snapshot(session_id: &str) -> Vec<InboxEntry> {
339    let reg = registry();
340    let map = lock_map(reg);
341    map.get(session_id)
342        .map(|state| state.entries.iter().cloned().collect())
343        .unwrap_or_default()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use harn_clock::PausedClock;
350    use time::OffsetDateTime;
351
352    fn fresh_session_id() -> String {
353        // Each test owns its own session id, so the global registry
354        // doesn't need per-test wipes; that also keeps concurrent
355        // cargo-nextest runs isolated from each other.
356        format!("test-{}", uuid::Uuid::now_v7())
357    }
358
359    #[test]
360    fn push_then_drain_preserves_fifo_order() {
361        let sid = fresh_session_id();
362        push(&sid, "tool_result", "first", "test");
363        push(&sid, "tool_result", "second", "test");
364        push(&sid, "file_edited", "third", "test");
365        let entries = drain(&sid);
366        assert_eq!(entries.len(), 3);
367        assert_eq!(entries[0].content, "first");
368        assert_eq!(entries[1].content, "second");
369        assert_eq!(entries[2].content, "third");
370        assert!(entries[0].sequence < entries[1].sequence);
371        assert!(entries[1].sequence < entries[2].sequence);
372    }
373
374    #[test]
375    fn drain_where_partitions_by_kind() {
376        let sid = fresh_session_id();
377        push(&sid, "tool_result", "a", "test");
378        push(&sid, "file_edited", "b", "test");
379        push(&sid, "tool_result", "c", "test");
380        let taken = drain_where(&sid, |e| e.kind == "file_edited");
381        assert_eq!(taken.len(), 1);
382        assert_eq!(taken[0].content, "b");
383        let remaining = drain(&sid);
384        assert_eq!(remaining.len(), 2);
385        assert_eq!(remaining[0].content, "a");
386        assert_eq!(remaining[1].content, "c");
387    }
388
389    #[test]
390    fn requeue_front_keeps_unwanted_entry_at_head() {
391        let sid = fresh_session_id();
392        push(&sid, "tool_result", "first", "test");
393        let mut entries = drain(&sid);
394        assert_eq!(entries.len(), 1);
395        let entry = entries.remove(0);
396        requeue_front(entry);
397        let again = drain(&sid);
398        assert_eq!(again[0].content, "first");
399    }
400
401    // `current_thread` keeps everything on a single OS thread so the
402    // waiter and producer can't race across threads — the test
403    // task and any spawned tasks share the same scheduler and are
404    // serialized by tokio. The cross-thread race is exercised by the
405    // `inbox_survives_concurrent_pushes_during_awaited_future` end-to-
406    // end test in `crates/harn-vm/tests/agent_inbox_e2e.rs`, and the
407    // `wait_async` cross-thread safety is documented inline.
408    #[tokio::test]
409    async fn wait_async_returns_when_push_happens() {
410        let sid = fresh_session_id();
411        let clock = PausedClock::new(OffsetDateTime::UNIX_EPOCH);
412        let waiter_sid = sid.clone();
413        let waiter_clock = clock.clone();
414        let waiter = tokio::spawn(async move {
415            wait_async(&waiter_sid, Duration::from_mins(1), &*waiter_clock).await
416        });
417        // Yield once so the waiter installs its notify watch before we push.
418        tokio::task::yield_now().await;
419        push(&sid, "tool_result", "hello", "test");
420        assert!(waiter.await.expect("join"));
421        // Push side wrote one entry — verify it's still drainable.
422        let entries = drain(&sid);
423        assert_eq!(entries.len(), 1);
424    }
425
426    #[tokio::test]
427    async fn wait_async_times_out_when_silent() {
428        let sid = fresh_session_id();
429        let clock = PausedClock::new(OffsetDateTime::UNIX_EPOCH);
430        // Drive the timeout to completion by advancing logical time.
431        // On `current_thread`, the test task yields when wait_async
432        // parks on the clock notify; the spawned advancer then runs
433        // synchronously after wait_async has registered its waiter, so
434        // the deadline computation and the advance can't race.
435        let clock_advance = clock.clone();
436        let advancer = tokio::spawn(async move {
437            tokio::task::yield_now().await;
438            clock_advance.advance(Duration::from_millis(50));
439        });
440        let result = wait_async(&sid, Duration::from_millis(50), &*clock).await;
441        advancer.await.ok();
442        assert!(!result);
443    }
444
445    #[test]
446    fn pending_count_tracks_pushes_and_drains() {
447        let sid = fresh_session_id();
448        assert_eq!(pending_count(&sid), 0);
449        push(&sid, "tool_result", "x", "test");
450        push(&sid, "tool_result", "y", "test");
451        assert_eq!(pending_count(&sid), 2);
452        let _ = drain(&sid);
453        assert_eq!(pending_count(&sid), 0);
454    }
455
456    #[test]
457    fn clear_session_drops_pending_entries() {
458        let sid = fresh_session_id();
459        push(&sid, "tool_result", "x", "test");
460        clear_session(&sid);
461        assert_eq!(pending_count(&sid), 0);
462    }
463}