Skip to main content

harn_vm/stdlib/host/
turn_cache.rs

1//! Per-turn memoization of turn-stable host capability reads.
2//!
3//! Context assembly repeatedly reads the same host facts per agent-loop
4//! iteration. harn#5190 measured ~20 identical `runtime.pipeline_input`
5//! round-trips per turn; Burin H-063 later measured roughly 164 repeated
6//! `project.metadata_get` calls in an ordinary context build. This module
7//! front-runs the thread-local `HOST_CALL_BRIDGE` with a per-turn memo so those
8//! reads collapse to one host round-trip per stable fact. Project metadata is
9//! fetched once as a typed directory snapshot and namespace reads project from
10//! it locally. The allowlist ([`is_turn_stable`]) is deliberately
11//! narrow, and metadata mutations invalidate the whole memo before and after
12//! dispatch so inherited read-after-write values cannot be stale.
13//!
14//! The memoized value is stable only *within* a turn: the host re-projects
15//! `runtime.pipeline_input` each turn (e.g. so a mid-session model switch is
16//! observed on the next prompt). The memo is therefore cleared at each
17//! agent-loop iteration boundary (`iteration_start`, wired in
18//! `__host_agent_emit_event`) and at run/embedder boundaries via [`reset`].
19
20use std::cell::RefCell;
21use std::collections::HashMap;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24use crate::value::{DictMap, VmError, VmValue};
25
26mod metadata_snapshot;
27
28/// Monotonic turn counter, bumped by [`reset`] at every turn boundary.
29///
30/// Deliberately process-global rather than per-session. A turn boundary in one
31/// session therefore also invalidates a concurrently-running session's memo,
32/// which costs that session one extra round-trip per crossed boundary. That is
33/// the conservative direction: the failure it forecloses is serving *stale*
34/// turn-stable state, and the worst case degrades toward the uncached behaviour
35/// this module replaced rather than toward incorrectness. Keying by session
36/// would recover those hits, but `reset` is driven from an agent-loop event that
37/// carries no session identity here, so it would be inferred rather than known.
38///
39/// The memo below is thread-local, but turn boundaries are not guaranteed to be
40/// observed on the same thread that populated it — `reset` runs where the
41/// agent-loop event is emitted, while a `host_call` may be served from
42/// elsewhere. Storing the epoch alongside each entry makes a stale entry
43/// *unreadable* rather than merely unlikely, so correctness no longer depends on
44/// a reset reaching any particular thread; thread-locality is then a pure
45/// performance choice. Without this, a missed reset would silently serve last
46/// turn's `runtime.pipeline_input` — which is exactly the mid-session `/model`
47/// switch that hosts re-project it per turn to observe.
48static TURN_EPOCH: AtomicU64 = AtomicU64::new(0);
49
50thread_local! {
51    /// Turn-scoped memo keyed by [`cache_key`], each entry tagged with the
52    /// [`TURN_EPOCH`] it was written in. Non-authoritative and always
53    /// resettable, so it is safe to keep thread-private.
54    static TURN_STABLE_HOST_CACHE: RefCell<HashMap<String, (u64, VmValue)>> =
55        RefCell::new(HashMap::new());
56}
57
58fn current_epoch() -> u64 {
59    TURN_EPOCH.load(Ordering::Acquire)
60}
61
62/// Cache semantics for a canonical host operation.
63///
64/// This is the single owner of both admission and invalidation: adding a
65/// stable read without naming its mutators (or adding a mutator in a separate
66/// string table) is therefore visible in one exhaustive match.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68enum TurnCacheDisposition {
69    StableRead,
70    Invalidates,
71    Live,
72}
73
74fn disposition(capability: &str, operation: &str) -> TurnCacheDisposition {
75    match (capability, operation) {
76        ("runtime", "pipeline_input") | ("project", "metadata_get") => {
77            TurnCacheDisposition::StableRead
78        }
79        ("project", "metadata_set" | "metadata_save" | "metadata_refresh_hashes") => {
80            TurnCacheDisposition::Invalidates
81        }
82        _ => TurnCacheDisposition::Live,
83    }
84}
85
86/// True for host capabilities whose result is stable for the duration of a
87/// single agent-loop iteration: pure, side-effect-free reads that project the
88/// current turn's host input.
89///
90/// Membership is an explicit allowlist, and the bar for adding an entry is a
91/// producer-side citation that no host mutates the value *within* a turn — not
92/// merely that it "looks stable." The default is NOT to cache, so a write, an
93/// interactive prompt, or any value a host can change mid-turn is never served
94/// stale.
95///
96/// The qualifying reads are:
97/// - `runtime.pipeline_input`: every Burin host recomputes it per turn from
98///   per-turn-stable inputs (model selection, task, dry-run), so a mid-turn
99///   re-read never diverges.
100/// - `project.metadata_get`: directory metadata is coherent for a turn unless
101///   the canonical metadata mutation operations change it. Those writes
102///   invalidate the global epoch on both sides of dispatch. Directory keys stay
103///   distinct while sibling namespaces share one validated bulk snapshot.
104///
105/// Deliberately excluded after auditing the producers:
106/// - `session.active_roots` — the IDE host serves it live from the mutable
107///   workspace root set (also used live for path validation), so a user adding
108///   a root mid-turn would be served stale within the turn.
109/// - `project.metadata_inspect` / `metadata_stale` — their freshness fields can
110///   change when workspace files change, independently of metadata mutations.
111/// - `runtime.task` / `runtime.dry_run` / `runtime.approved_plan` — not served
112///   as standalone host ops by the Burin hosts at all; their values ride inside
113///   `pipeline_input` (already cached here), so caching the standalone op buys
114///   nothing.
115fn is_turn_stable(capability: &str, operation: &str) -> bool {
116    disposition(capability, operation) == TurnCacheDisposition::StableRead
117}
118
119/// True when a host operation can change a memoized fact.
120///
121/// Metadata resolution is hierarchical: writing an ancestor can change a
122/// descendant read, and saving can make host-managed metadata visible through
123/// a different backend. Exact-key eviction would therefore be unsound. The
124/// caller opens a fresh global epoch both before and after these operations,
125/// which also makes concurrent cross-thread refills conservative rather than
126/// stale. Writes are rare, so invalidating `runtime.pipeline_input` alongside
127/// metadata is a cheaper and safer seam than a second metadata-only epoch.
128fn invalidates_turn_stable_reads(capability: &str, operation: &str) -> bool {
129    disposition(capability, operation) == TurnCacheDisposition::Invalidates
130}
131
132/// Invalidates turn-stable reads around a host mutation, including early
133/// returns and errors from the canonical dispatcher.
134pub(crate) struct InvalidationScope {
135    invalidates: bool,
136}
137
138impl Drop for InvalidationScope {
139    fn drop(&mut self) {
140        if self.invalidates {
141            reset();
142        }
143    }
144}
145
146pub(crate) fn invalidation_scope(capability: &str, operation: &str) -> InvalidationScope {
147    let invalidates = invalidates_turn_stable_reads(capability, operation);
148    if invalidates {
149        reset();
150    }
151    InvalidationScope { invalidates }
152}
153
154/// Cache key for a turn-stable host call. Keyed on capability, operation, and a
155/// canonical fingerprint of the params so `project.metadata_get` caches per
156/// distinct argument set while no-arg reads retain the cheap fast path.
157/// serde_json's `Map` is key-sorted here (no `preserve_order` feature), so the
158/// fingerprint is stable.
159fn cache_key(capability: &str, operation: &str, params: &DictMap) -> String {
160    if params.is_empty() {
161        return format!("{capability}.{operation}");
162    }
163    let json = crate::llm::helpers::vm_value_to_json(&VmValue::dict(params.clone()));
164    format!(
165        "{capability}.{operation}#{}",
166        serde_json::to_string(&json).unwrap_or_default()
167    )
168}
169
170/// Serve `(capability, operation, params)` from the per-turn memo when it is a
171/// turn-stable read, otherwise run `dispatch` verbatim. A successful
172/// `Ok(Some(value))` from a turn-stable read is memoized for the rest of the
173/// turn; `Ok(None)` (the bridge declined) and errors are never cached.
174pub(crate) async fn cached_or<F, Fut>(
175    capability: &str,
176    operation: &str,
177    params: &DictMap,
178    dispatch: F,
179) -> Result<Option<VmValue>, VmError>
180where
181    F: FnOnce() -> Fut,
182    Fut: std::future::Future<Output = Result<Option<VmValue>, VmError>>,
183{
184    if !is_turn_stable(capability, operation) {
185        return dispatch().await;
186    }
187    if let Some(cached) = lookup(capability, operation, params) {
188        return Ok(Some(cached));
189    }
190    let dispatch_epoch = current_epoch();
191    let result = dispatch().await?;
192    if let Some(value) = &result {
193        store_at_epoch(capability, operation, params, value, dispatch_epoch);
194    }
195    Ok(result)
196}
197
198/// Metadata-specific deep cache interface: validate one directory snapshot,
199/// project namespaces locally, and coalesce concurrent cold reads. The
200/// dispatcher receives owned canonical params because a namespace request is
201/// deliberately replaced with one namespace-free bulk request.
202pub(crate) async fn cached_metadata_or<F, Fut>(
203    params: &DictMap,
204    dispatch: F,
205) -> Result<Option<VmValue>, VmError>
206where
207    F: FnOnce(DictMap) -> Fut,
208    Fut: std::future::Future<Output = Result<Option<VmValue>, VmError>>,
209{
210    metadata_snapshot::cached_or(params, current_epoch(), dispatch).await
211}
212
213/// Read a turn-stable host fact from the current turn's memo.
214///
215/// Returns `None` for anything not on the [`is_turn_stable`] allowlist, for a
216/// cold memo, and for an entry written in an earlier turn.
217///
218/// Read API for the turn memo. Prefer going through canonical
219/// [`super::dispatch_host_operation`]; this exists for tests that seed or
220/// inspect the memo directly (harn#5190 / harn#5523).
221pub fn lookup(capability: &str, operation: &str, params: &DictMap) -> Option<VmValue> {
222    if !is_turn_stable(capability, operation) {
223        return None;
224    }
225    let epoch = current_epoch();
226    if capability == "project" && operation == "metadata_get" {
227        return metadata_snapshot::lookup(params, epoch);
228    }
229    let key = cache_key(capability, operation, params);
230    TURN_STABLE_HOST_CACHE.with(|cache| {
231        cache
232            .borrow()
233            .get(&key)
234            .filter(|(written, _)| *written == epoch)
235            .map(|(_, value)| value.clone())
236    })
237}
238
239/// Memoize a turn-stable host fact for the remainder of the current turn.
240/// Non-allowlisted `(capability, operation)` pairs are ignored, so a caller
241/// cannot widen the allowlist by calling this directly. See [`lookup`].
242pub fn store(capability: &str, operation: &str, params: &DictMap, value: &VmValue) {
243    store_at_epoch(capability, operation, params, value, current_epoch());
244}
245
246/// Store a value only in the epoch in which its host dispatch began.
247///
248/// A read can be in flight while a metadata mutation opens a new epoch. It may
249/// still return its result to that original caller, but tagging the memo entry
250/// with the captured epoch makes the stale refill unreadable. Loading the
251/// current epoch and then storing with it would let a slow pre-write read poison
252/// the post-write cache after the mutator's trailing reset.
253fn store_at_epoch(
254    capability: &str,
255    operation: &str,
256    params: &DictMap,
257    value: &VmValue,
258    dispatch_epoch: u64,
259) {
260    if !is_turn_stable(capability, operation) {
261        return;
262    }
263    if current_epoch() != dispatch_epoch {
264        return;
265    }
266    if capability == "project" && operation == "metadata_get" {
267        metadata_snapshot::store(params, value, dispatch_epoch);
268        return;
269    }
270    let key = cache_key(capability, operation, params);
271    TURN_STABLE_HOST_CACHE.with(|cache| {
272        cache
273            .borrow_mut()
274            .insert(key, (dispatch_epoch, value.clone()));
275    });
276}
277
278/// Split a dotted `capability.operation` host-call name and [`lookup`] it.
279/// Convenience for embedder `host_call` implementations, which receive the
280/// dotted wire name rather than a split pair.
281pub fn lookup_by_name(name: &str, params: &DictMap) -> Option<VmValue> {
282    let (capability, operation) = name.split_once('.')?;
283    lookup(capability, operation, params)
284}
285
286/// Dotted-name counterpart to [`store`]. See [`lookup_by_name`].
287pub fn store_by_name(name: &str, params: &DictMap, value: &VmValue) {
288    if let Some((capability, operation)) = name.split_once('.') {
289        store(capability, operation, params, value);
290    }
291}
292
293/// Open a new turn: every entry written before this call becomes unreadable,
294/// on this thread and every other.
295///
296/// Called at each agent-loop iteration boundary so a turn re-reads turn-stable
297/// host facts exactly once, and at bridge install/teardown so a memo can never
298/// leak across embedders on a reused thread. Bumping a global epoch rather than
299/// clearing the thread-local map means a turn boundary observed on one thread
300/// invalidates entries cached on every thread — see [`TURN_EPOCH`].
301pub(crate) fn reset() {
302    TURN_EPOCH.fetch_add(1, Ordering::AcqRel);
303    reset_local();
304}
305
306/// Drop this thread's entries without opening a new turn.
307///
308/// For `reset_host_state`, reached from `reset_stdlib_state` and in turn from
309/// [`crate::reset_thread_local_state`] — whose contract is to reset *this
310/// thread*, and which runs between VM runs on a reused thread rather than at any
311/// turn boundary. [`TURN_EPOCH`] is process-global, so bumping it from there
312/// reaches past that contract: every VM run that ended anywhere would invalidate
313/// the live memo of every concurrently-running session, costing each an extra
314/// round-trip. A thread-local reset should clear thread-local state only.
315///
316/// This is exactly the pre-epoch behaviour of [`reset`], so the call sites moved
317/// here keep the semantics they already had; only genuine turn boundaries and
318/// bridge swaps gained cross-thread reach.
319pub(crate) fn reset_local() {
320    TURN_STABLE_HOST_CACHE.with(|cache| cache.borrow_mut().clear());
321    metadata_snapshot::reset_local();
322}
323
324/// Serializes tests that bump [`TURN_EPOCH`] against tests that rely on a memo
325/// entry surviving between a `store` and a `lookup`.
326///
327/// The epoch is process-global, so without this a bridge swap in one test
328/// invalidates another test's entry mid-assertion. Mirrors the
329/// `LONG_RUNNING_TEST_LOCK` convention in `stdlib::fs::tests` for the same
330/// reason: process-global state needs process-global test exclusion.
331#[cfg(test)]
332pub(crate) fn epoch_test_lock() -> &'static std::sync::Mutex<()> {
333    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
334    LOCK.get_or_init(|| std::sync::Mutex::new(()))
335}
336
337#[cfg(test)]
338mod tests {
339    use std::sync::{Arc, Mutex};
340
341    use super::super::{
342        clear_host_call_bridge, dispatch_host_operation, reset_host_state, set_host_call_bridge,
343        HostCallBridge,
344    };
345    use super::reset;
346    use crate::value::{DictMap, VmValue};
347
348    /// [`TURN_EPOCH`] is process-global, so these tests mutate shared state: a
349    /// `reset` in one invalidates entries another is mid-assertion about. Cargo
350    /// runs them on separate threads by default, which made that a real
351    /// cross-talk failure rather than a theoretical one. Serialize them.
352    /// Bridge that counts dispatches per `(capability, operation)` and answers
353    /// every op, so a test can assert how many times the host was actually hit.
354    struct CountingRuntimeBridge {
355        counts: Arc<Mutex<std::collections::HashMap<(String, String), usize>>>,
356    }
357
358    struct VersionedMetadataBridge {
359        generation: Arc<std::sync::atomic::AtomicUsize>,
360        counts: Arc<Mutex<std::collections::HashMap<(String, String), usize>>>,
361    }
362
363    impl HostCallBridge for VersionedMetadataBridge {
364        fn dispatch<'a>(
365            &'a self,
366            capability: &'a str,
367            operation: &'a str,
368            params: &'a DictMap,
369        ) -> super::super::HostCallDispatchFuture<'a> {
370            *self
371                .counts
372                .lock()
373                .unwrap()
374                .entry((capability.to_string(), operation.to_string()))
375                .or_insert(0) += 1;
376            if capability == "project" && operation == "metadata_set" {
377                self.generation
378                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
379                return super::super::host_call_ready(Ok(Some(VmValue::Nil)));
380            }
381            let generation = self.generation.load(std::sync::atomic::Ordering::SeqCst);
382            assert!(
383                params.get("namespace").is_none(),
384                "metadata snapshots must be fetched without a namespace projection"
385            );
386            let namespace = |name: &str| {
387                (
388                    crate::value::intern_key(name),
389                    VmValue::dict(DictMap::from_iter([(
390                        crate::value::intern_key("generation"),
391                        VmValue::Int(generation as i64),
392                    )])),
393                )
394            };
395            super::super::host_call_ready(Ok(Some(VmValue::dict(DictMap::from_iter([
396                namespace("facts"),
397                namespace("test"),
398            ])))))
399        }
400    }
401
402    impl HostCallBridge for CountingRuntimeBridge {
403        fn dispatch<'a>(
404            &'a self,
405            capability: &'a str,
406            operation: &'a str,
407            _params: &'a DictMap,
408        ) -> super::super::HostCallDispatchFuture<'a> {
409            *self
410                .counts
411                .lock()
412                .unwrap()
413                .entry((capability.to_string(), operation.to_string()))
414                .or_insert(0) += 1;
415            super::super::host_call_ready(Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
416                "{capability}.{operation}"
417            ))))))
418        }
419    }
420
421    fn run_async<F, Fut>(test: F)
422    where
423        F: FnOnce() -> Fut,
424        Fut: std::future::Future<Output = ()>,
425    {
426        let rt = tokio::runtime::Builder::new_current_thread()
427            .enable_all()
428            .build()
429            .expect("runtime");
430        rt.block_on(async {
431            let local = tokio::task::LocalSet::new();
432            local.run_until(test()).await;
433        });
434    }
435
436    #[test]
437    fn turn_stable_host_capability_is_fetched_once_per_turn() {
438        let _guard = super::epoch_test_lock()
439            .lock()
440            .unwrap_or_else(|e| e.into_inner());
441        run_async(|| async {
442            reset_host_state();
443            let counts = Arc::new(Mutex::new(std::collections::HashMap::new()));
444            set_host_call_bridge(Arc::new(CountingRuntimeBridge {
445                counts: counts.clone(),
446            }));
447
448            let count = |cap: &str, op: &str| -> usize {
449                counts
450                    .lock()
451                    .unwrap()
452                    .get(&(cap.to_string(), op.to_string()))
453                    .copied()
454                    .unwrap_or(0)
455            };
456
457            // Many reads within one turn collapse to a single host round-trip.
458            for _ in 0..20 {
459                dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
460                    .await
461                    .expect("pipeline_input");
462            }
463            assert_eq!(
464                count("runtime", "pipeline_input"),
465                1,
466                "20 same-turn reads must hit the host exactly once"
467            );
468
469            // A non-allowlisted op is never memoized: every call reaches the host.
470            for _ in 0..3 {
471                dispatch_host_operation("runtime", "record_run", &DictMap::new())
472                    .await
473                    .expect("record_run");
474            }
475            assert_eq!(
476                count("runtime", "record_run"),
477                3,
478                "writes/non-stable ops must never be served from the turn memo"
479            );
480
481            // The next turn boundary re-reads the turn-stable fact exactly once,
482            // so a mid-session change (e.g. a model switch) is observed.
483            reset();
484            for _ in 0..20 {
485                dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
486                    .await
487                    .expect("pipeline_input");
488            }
489            assert_eq!(
490                count("runtime", "pipeline_input"),
491                2,
492                "a new turn must re-fetch once, not serve the prior turn's value"
493            );
494
495            clear_host_call_bridge();
496        });
497    }
498
499    #[test]
500    fn metadata_namespaces_share_a_snapshot_and_writes_invalidate_inherited_values() {
501        let _guard = super::epoch_test_lock()
502            .lock()
503            .unwrap_or_else(|e| e.into_inner());
504        run_async(|| async {
505            reset_host_state();
506            let counts = Arc::new(Mutex::new(std::collections::HashMap::new()));
507            let generation = Arc::new(std::sync::atomic::AtomicUsize::new(0));
508            set_host_call_bridge(Arc::new(VersionedMetadataBridge {
509                generation,
510                counts: counts.clone(),
511            }));
512
513            let count = |op: &str| -> usize {
514                counts
515                    .lock()
516                    .unwrap()
517                    .get(&("project".to_string(), op.to_string()))
518                    .copied()
519                    .unwrap_or(0)
520            };
521            let descendant_facts = DictMap::from_iter([
522                (
523                    crate::value::intern_key("dir"),
524                    VmValue::String(arcstr::ArcStr::from("src/nested")),
525                ),
526                (
527                    crate::value::intern_key("namespace"),
528                    VmValue::String(arcstr::ArcStr::from("facts")),
529                ),
530            ]);
531            let descendant_test = DictMap::from_iter([
532                (
533                    crate::value::intern_key("dir"),
534                    VmValue::String(arcstr::ArcStr::from("src/nested")),
535                ),
536                (
537                    crate::value::intern_key("namespace"),
538                    VmValue::String(arcstr::ArcStr::from("test")),
539                ),
540            ]);
541
542            for _ in 0..100 {
543                let value = dispatch_host_operation("project", "metadata_get", &descendant_facts)
544                    .await
545                    .expect("metadata_get");
546                assert!(matches!(
547                    value.as_dict().and_then(|fields| fields.get("generation")),
548                    Some(VmValue::Int(0))
549                ));
550            }
551            assert_eq!(
552                count("metadata_get"),
553                1,
554                "100 exact reads must dispatch once"
555            );
556
557            dispatch_host_operation("project", "metadata_get", &descendant_test)
558                .await
559                .expect("parameter-distinct metadata_get");
560            assert_eq!(
561                count("metadata_get"),
562                1,
563                "sibling namespaces must project from one directory snapshot"
564            );
565
566            let ancestor_write = DictMap::from_iter([
567                (
568                    crate::value::intern_key("dir"),
569                    VmValue::String(arcstr::ArcStr::from("src")),
570                ),
571                (
572                    crate::value::intern_key("namespace"),
573                    VmValue::String(arcstr::ArcStr::from("facts")),
574                ),
575                (
576                    crate::value::intern_key("value"),
577                    VmValue::dict(DictMap::new()),
578                ),
579            ]);
580            dispatch_host_operation("project", "metadata_set", &ancestor_write)
581                .await
582                .expect("metadata_set");
583            assert_eq!(count("metadata_set"), 1);
584
585            let refreshed = dispatch_host_operation("project", "metadata_get", &descendant_facts)
586                .await
587                .expect("read after ancestor write");
588            assert!(
589                matches!(
590                    refreshed
591                        .as_dict()
592                        .and_then(|fields| fields.get("generation")),
593                    Some(VmValue::Int(1))
594                ),
595                "an ancestor write must invalidate a cached descendant read"
596            );
597            assert_eq!(count("metadata_get"), 2);
598
599            reset();
600            dispatch_host_operation("project", "metadata_get", &descendant_facts)
601                .await
602                .expect("next-turn metadata_get");
603            assert_eq!(count("metadata_get"), 3, "the next turn must re-read once");
604
605            clear_host_call_bridge();
606        });
607    }
608
609    #[test]
610    fn every_canonical_metadata_mutator_invalidates_turn_stable_reads() {
611        for operation in ["metadata_set", "metadata_save", "metadata_refresh_hashes"] {
612            assert!(
613                super::invalidates_turn_stable_reads("project", operation),
614                "project.{operation} must invalidate the metadata read memo"
615            );
616        }
617        for operation in ["metadata_get", "metadata_inspect", "metadata_stale"] {
618            assert!(
619                !super::invalidates_turn_stable_reads("project", operation),
620                "read-only project.{operation} must not open a new epoch"
621            );
622        }
623    }
624
625    #[test]
626    fn mutation_scope_invalidates_before_and_after_every_return_path() {
627        let _guard = super::epoch_test_lock()
628            .lock()
629            .unwrap_or_else(|e| e.into_inner());
630        let before = super::current_epoch();
631        {
632            let _scope = super::invalidation_scope("project", "metadata_set");
633            assert!(
634                super::current_epoch() > before,
635                "mutation must invalidate before dispatch"
636            );
637        }
638        let after_mutation = super::current_epoch();
639        assert!(
640            after_mutation > before + 1,
641            "scope drop must invalidate after dispatch"
642        );
643
644        {
645            let _scope = super::invalidation_scope("project", "metadata_get");
646        }
647        assert_eq!(
648            super::current_epoch(),
649            after_mutation,
650            "read-only dispatch must not invalidate the memo"
651        );
652    }
653
654    /// A turn boundary observed on a *different* thread must still invalidate
655    /// entries cached here.
656    ///
657    /// This is the property that makes it safe for an embedder to front its own
658    /// `host_call` with [`super::lookup`] / [`super::store`]: `reset` runs where
659    /// the agent-loop event is emitted, which is not guaranteed to be the thread
660    /// that populated the memo. Before epoch tagging, a reset that landed
661    /// elsewhere left this thread serving the previous turn's
662    /// `runtime.pipeline_input` — silently defeating the per-turn re-projection
663    /// hosts rely on to observe a mid-session `/model` switch.
664    #[test]
665    fn turn_boundary_on_another_thread_invalidates_this_thread() {
666        let _guard = super::epoch_test_lock()
667            .lock()
668            .unwrap_or_else(|e| e.into_inner());
669        let params = DictMap::new();
670        let cached = VmValue::String(arcstr::ArcStr::from("turn-1"));
671        super::store("runtime", "pipeline_input", &params, &cached);
672        assert!(
673            super::lookup("runtime", "pipeline_input", &params).is_some(),
674            "same-turn read must hit"
675        );
676
677        std::thread::spawn(reset).join().expect("reset thread");
678
679        assert!(
680            super::lookup("runtime", "pipeline_input", &params).is_none(),
681            "a turn boundary observed on another thread must invalidate this thread's entry"
682        );
683    }
684
685    /// A host read that began before a mutation may complete after the
686    /// mutator's trailing reset. The result is valid for its original caller,
687    /// but it must not refill the new epoch's memo.
688    #[test]
689    fn pre_mutation_read_cannot_poison_the_post_mutation_epoch() {
690        let _guard = super::epoch_test_lock()
691            .lock()
692            .unwrap_or_else(|e| e.into_inner());
693        reset();
694        let params = DictMap::from_iter([(
695            crate::value::intern_key("dir"),
696            VmValue::String(arcstr::ArcStr::from("src")),
697        )]);
698        let dispatch_epoch = super::current_epoch();
699
700        // Models a metadata write completing while this read is in flight.
701        reset();
702        super::store_at_epoch(
703            "project",
704            "metadata_get",
705            &params,
706            &VmValue::dict(DictMap::from_iter([(
707                crate::value::intern_key("facts"),
708                VmValue::dict(DictMap::new()),
709            )])),
710            dispatch_epoch,
711        );
712
713        assert!(
714            super::lookup("project", "metadata_get", &params).is_none(),
715            "an old dispatch result must not become the new epoch's cached value"
716        );
717    }
718
719    /// `store` cannot be used to widen the allowlist: a non-turn-stable op is
720    /// dropped rather than memoized, so an embedder wiring these in cannot
721    /// accidentally cache a write or a live read.
722    #[test]
723    fn store_ignores_non_turn_stable_operations() {
724        let _guard = super::epoch_test_lock()
725            .lock()
726            .unwrap_or_else(|e| e.into_inner());
727        let params = DictMap::new();
728        let value = VmValue::String(arcstr::ArcStr::from("live"));
729        super::store("session", "active_roots", &params, &value);
730        assert!(
731            super::lookup("session", "active_roots", &params).is_none(),
732            "non-allowlisted reads must never be served from the memo"
733        );
734    }
735
736    /// The dotted-name helpers an embedder uses must resolve to the same entry
737    /// as the split-pair API, or the two `host_call` routes would keep separate
738    /// memos and the ACP path would still pay every round-trip.
739    #[test]
740    fn dotted_name_helpers_share_the_split_pair_entry() {
741        let _guard = super::epoch_test_lock()
742            .lock()
743            .unwrap_or_else(|e| e.into_inner());
744        reset();
745        let params = DictMap::new();
746        let value = VmValue::String(arcstr::ArcStr::from("shared"));
747        super::store_by_name("runtime.pipeline_input", &params, &value);
748        assert_eq!(
749            super::lookup("runtime", "pipeline_input", &params).map(|v| v.display()),
750            Some("shared".to_string()),
751            "store_by_name must populate the entry lookup() reads"
752        );
753        assert_eq!(
754            super::lookup_by_name("runtime.pipeline_input", &params).map(|v| v.display()),
755            Some("shared".to_string()),
756            "lookup_by_name must read it back"
757        );
758        assert!(
759            super::lookup_by_name("no-separator", &params).is_none(),
760            "a name without a capability separator must not panic or match"
761        );
762    }
763}