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", ¶ms, &cached);
672 assert!(
673 super::lookup("runtime", "pipeline_input", ¶ms).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", ¶ms).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 ¶ms,
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", ¶ms).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", ¶ms, &value);
730 assert!(
731 super::lookup("session", "active_roots", ¶ms).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", ¶ms, &value);
748 assert_eq!(
749 super::lookup("runtime", "pipeline_input", ¶ms).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", ¶ms).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", ¶ms).is_none(),
760 "a name without a capability separator must not panic or match"
761 );
762 }
763}