Skip to main content

mise_cache_core/
agent.rs

1use crate::{
2    BlobSource, BlobUpload, CacheDigest, CacheDirectory, LocalActionCache, LocalCas,
3    ManifestPutOutcome, RemoteActionResult, RemoteCacheClient, RemoteCacheMode, RustcMetadata,
4    canonical_json,
5};
6use eyre::{Result, bail};
7use futures_util::{StreamExt, stream};
8use log::warn;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex, Weak};
15use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
16
17const MAX_EXECUTABLE_IDENTITIES: usize = 64;
18const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
19const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
20const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
21const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
22const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
23const MAX_REMOTE_TRANSFERS: usize = 64;
24const MAX_PREFETCH_TRANSFERS: usize = 48;
25const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
26
27/// Remote action-cache access owned by one task session.
28pub struct AgentRemoteCache {
29    pub client: RemoteCacheClient,
30    pub mode: RemoteCacheMode,
31    pub staging_dir: PathBuf,
32}
33
34/// Wire protocol version used between an in-process cache agent and its shims.
35pub const AGENT_PROTOCOL_VERSION: u8 = 1;
36
37/// A request accepted by the task-scoped cache agent.
38#[derive(Debug, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum AgentRequest {
41    Hello {
42        protocol: u8,
43        client_version: String,
44    },
45    /// Resolve a blob to a session-verified local CAS path.
46    FindBlob {
47        digest: CacheDigest,
48    },
49    StoreBlob {
50        digest: CacheDigest,
51        source: PathBuf,
52    },
53    FindActionResult {
54        action: CacheDigest,
55    },
56    RecordActionHit {
57        action: CacheDigest,
58    },
59    RecordActionVerification {
60        matched: bool,
61    },
62    StoreActionResult {
63        result: RemoteActionResult,
64    },
65    FindActionPrediction {
66        task: String,
67        invocation: CacheDigest,
68    },
69    RecordActionPrediction {
70        task: String,
71        prediction: ActionPrediction,
72    },
73    FindExecutableIdentity {
74        executable: PathBuf,
75        environment: BTreeMap<String, Option<String>>,
76    },
77    StoreExecutableIdentity {
78        executable: PathBuf,
79        environment: BTreeMap<String, Option<String>>,
80        stdout: Vec<u8>,
81    },
82}
83
84/// A response returned by the task-scoped cache agent.
85#[derive(Debug, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum AgentResponse {
88    Hello {
89        protocol: u8,
90        agent_version: String,
91    },
92    /// A local CAS path already verified against the requested digest.
93    Blob {
94        path: Option<PathBuf>,
95    },
96    Stored {
97        path: PathBuf,
98    },
99    ActionResult {
100        result: Option<RemoteActionResult>,
101    },
102    ActionHitRecorded,
103    ActionVerificationRecorded,
104    ActionStored {
105        path: PathBuf,
106    },
107    ActionPrediction {
108        prediction: Option<ActionPrediction>,
109    },
110    ActionPredictionRecorded,
111    ExecutableIdentity {
112        stdout: Option<Vec<u8>>,
113    },
114    Error {
115        message: String,
116    },
117}
118
119/// Aggregate cache activity for one task session.
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
121pub struct AgentStats {
122    /// Number of action-result lookups.
123    pub lookups: u64,
124    /// Number of lookups that found a valid local action result.
125    pub hits: u64,
126    /// Number of newly stored content-addressed objects.
127    pub stores: u64,
128    /// Total size of newly stored objects.
129    pub stored_bytes: u64,
130    /// Number of cache hits compiled again for qualification.
131    pub verifications: u64,
132    /// Number of qualification builds that diverged from the cached result.
133    pub divergences: u64,
134    /// CAS payload bytes downloaded from the remote cache.
135    pub downloaded_bytes: u64,
136    /// CAS payload bytes uploaded to the remote cache.
137    pub uploaded_bytes: u64,
138    /// Complete actions staged before an adapter requested them.
139    pub prefetched_actions: u64,
140}
141
142/// Adapter-owned data needed to reconstruct an action before fresh dependency
143/// discovery is available.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct ActionPrediction {
147    pub invocation: CacheDigest,
148    pub action: CacheDigest,
149    pub adapter: String,
150    pub payload: String,
151}
152
153#[derive(Default)]
154struct AtomicAgentStats {
155    lookups: AtomicU64,
156    hits: AtomicU64,
157    stores: AtomicU64,
158    stored_bytes: AtomicU64,
159    verifications: AtomicU64,
160    divergences: AtomicU64,
161    downloaded_bytes: AtomicU64,
162    uploaded_bytes: AtomicU64,
163    prefetched_actions: AtomicU64,
164}
165
166/// Shared state for an agent hosted by the top-level `mise run` process.
167///
168/// Transport listeners deliberately live in mise so the task-run lifecycle owns
169/// them. This type only contains ecosystem-independent CAS and protocol logic.
170#[derive(Clone)]
171pub struct CacheAgent {
172    cas: LocalCas,
173    actions: LocalActionCache,
174    verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, PathBuf>>>,
175    version: Arc<str>,
176    write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
177    action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
178    stats: Arc<AtomicAgentStats>,
179    executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
180    manifest_dir: Arc<PathBuf>,
181    task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
182    next_task_run: Arc<AtomicU64>,
183    manifest_write_lock: Arc<Mutex<()>>,
184    remote: Option<Arc<RemoteCacheClient>>,
185    remote_mode: RemoteCacheMode,
186    remote_staging_dir: Arc<PathBuf>,
187    pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
188    remote_transfers: Arc<tokio::sync::Semaphore>,
189    prefetch_transfers: Arc<tokio::sync::Semaphore>,
190    prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195struct TaskActionManifest {
196    version: u8,
197    task: String,
198    predictions: Vec<ActionPrediction>,
199}
200
201#[derive(Serialize)]
202struct TaskActionManifestSelector<'a> {
203    version: u8,
204    kind: &'static str,
205    task: &'a str,
206}
207
208#[derive(Debug, Clone, Default)]
209struct TaskActionState {
210    manifest: String,
211    baseline_loaded: bool,
212    predictions: BTreeMap<CacheDigest, ActionPrediction>,
213    remote_etag: Option<String>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
217struct ExecutableIdentityKey {
218    executable: PathBuf,
219    environment: BTreeMap<String, Option<String>>,
220}
221
222impl CacheAgent {
223    /// Create an agent backed by the cache rooted at `cache_dir`.
224    pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
225        Self::build(cache_dir.into(), version.into(), None)
226    }
227
228    /// Create an agent with local-first access to a remote action cache.
229    pub fn new_remote(
230        cache_dir: impl Into<PathBuf>,
231        version: impl Into<Arc<str>>,
232        remote: AgentRemoteCache,
233    ) -> Self {
234        Self::build(cache_dir.into(), version.into(), Some(remote))
235    }
236
237    fn build(cache_dir: PathBuf, version: Arc<str>, remote: Option<AgentRemoteCache>) -> Self {
238        let remote_mode = remote
239            .as_ref()
240            .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
241        let remote_staging_dir = remote.as_ref().map_or_else(
242            || cache_dir.join("remote"),
243            |remote| remote.staging_dir.clone(),
244        );
245        let remote = remote.map(|remote| Arc::new(remote.client));
246        Self {
247            cas: LocalCas::new(cache_dir.clone()),
248            actions: LocalActionCache::new(cache_dir.clone()),
249            verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
250            version,
251            write_locks: Arc::new(Mutex::new(BTreeMap::new())),
252            action_locks: Arc::new(Mutex::new(BTreeMap::new())),
253            stats: Arc::new(AtomicAgentStats::default()),
254            executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
255            manifest_dir: Arc::new(cache_dir.join("task-manifests").join("v1")),
256            task_actions: Arc::new(Mutex::new(BTreeMap::new())),
257            next_task_run: Arc::new(AtomicU64::new(0)),
258            manifest_write_lock: Arc::new(Mutex::new(())),
259            remote,
260            remote_mode,
261            remote_staging_dir: Arc::new(remote_staging_dir),
262            pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
263            remote_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS)),
264            prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
265            prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
266        }
267    }
268
269    /// Load the last successful action manifest for a task into this session.
270    pub async fn begin_task(&self, task: &str) -> Result<String> {
271        validate_task_identity(task)?;
272        let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
273            match self.get_remote_task_manifest(task).await {
274                Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
275                Ok(None) => (None, None),
276                Err(error) => {
277                    warn!("remote task action manifest lookup failed for {task}: {error}");
278                    (None, None)
279                }
280            }
281        } else {
282            (None, None)
283        };
284        let manifest = {
285            let _write_guard = self.manifest_write_lock.lock().unwrap();
286            let _file_guard = self.lock_task_manifest(task)?;
287            let local_manifest = self.load_task_manifest(task)?;
288            let manifest = match (remote_manifest, local_manifest) {
289                (Some(remote), Some(local)) => {
290                    let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
291                    if !merged {
292                        remote_etag = None;
293                    }
294                    Some(manifest)
295                }
296                (Some(remote), None) => Some(remote),
297                (None, local) => local,
298            };
299            if let Some(manifest) = &manifest {
300                self.persist_task_manifest(manifest)?;
301            }
302            manifest
303        };
304        let state = if let Some(manifest) = manifest {
305            TaskActionState {
306                manifest: task.to_string(),
307                baseline_loaded: true,
308                predictions: manifest
309                    .predictions
310                    .into_iter()
311                    .map(|prediction| (prediction.invocation.clone(), prediction))
312                    .collect(),
313                remote_etag,
314            }
315        } else {
316            TaskActionState {
317                manifest: task.to_string(),
318                baseline_loaded: true,
319                remote_etag,
320                ..TaskActionState::default()
321            }
322        };
323        let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
324        let run =
325            CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
326                .hash;
327        let predictions = state.predictions.values().cloned().collect();
328        self.task_actions.lock().unwrap().insert(run.clone(), state);
329        self.spawn_prefetch_predictions(predictions);
330        Ok(run)
331    }
332
333    /// Cancel speculative downloads before the owning session exits.
334    pub async fn cancel_prefetches(&self) {
335        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
336        for task in &tasks {
337            task.abort();
338        }
339        for task in tasks {
340            if let Err(error) = task.await
341                && !error.is_cancelled()
342            {
343                warn!("remote action prefetch task failed: {error}");
344            }
345        }
346    }
347
348    #[cfg(test)]
349    async fn wait_for_prefetches(&self) {
350        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
351        for task in tasks {
352            if let Err(error) = task.await {
353                warn!("remote action prefetch task failed: {error}");
354            }
355        }
356    }
357
358    /// Atomically publish the candidate manifest collected by a successful task.
359    pub async fn commit_task(&self, run: &str) -> Result<()> {
360        validate_task_identity(run)?;
361        let state = self
362            .task_actions
363            .lock()
364            .unwrap()
365            .get(run)
366            .cloned()
367            .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
368        if !state.baseline_loaded {
369            bail!("task action manifest baseline was not loaded");
370        }
371        let task = state.manifest;
372        validate_task_identity(&task)?;
373        let manifest = {
374            let _write_guard = self.manifest_write_lock.lock().unwrap();
375            let _file_guard = self.lock_task_manifest(&task)?;
376            let mut predictions = self
377                .load_task_manifest(&task)?
378                .map(|manifest| {
379                    manifest
380                        .predictions
381                        .into_iter()
382                        .map(|prediction| (prediction.invocation.clone(), prediction))
383                        .collect::<BTreeMap<_, _>>()
384                })
385                .unwrap_or_default();
386            predictions.extend(state.predictions);
387            let manifest = TaskActionManifest {
388                version: TASK_ACTION_MANIFEST_VERSION,
389                task: task.clone(),
390                predictions: predictions.into_values().collect(),
391            };
392            validate_task_manifest(&manifest, &task)?;
393            self.persist_task_manifest(&manifest)?;
394            manifest
395        };
396        self.task_actions.lock().unwrap().remove(run);
397        if self.remote_mode.writes() {
398            match self
399                .put_remote_task_manifest(&task, manifest, state.remote_etag)
400                .await
401            {
402                Ok(remote_manifest) => {
403                    let _write_guard = self.manifest_write_lock.lock().unwrap();
404                    let reconciliation = (|| {
405                        let _file_guard = self.lock_task_manifest(&task)?;
406                        let manifest = match self.load_task_manifest(&task)? {
407                            Some(local) => {
408                                merge_remote_task_manifest(&task, remote_manifest, local).0
409                            }
410                            None => remote_manifest,
411                        };
412                        self.persist_task_manifest(&manifest)
413                    })();
414                    if let Err(error) = reconciliation {
415                        warn!(
416                            "remote task action manifest reconciliation failed for {task}: {error}"
417                        );
418                    }
419                }
420                Err(error) => {
421                    warn!("remote task action manifest upload failed for {task}: {error}");
422                }
423            }
424        }
425        Ok(())
426    }
427
428    fn task_manifest_path(&self, task: &str) -> PathBuf {
429        self.manifest_dir.join(format!("{task}.json"))
430    }
431
432    fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
433        self.manifest_dir.join("locks").join(format!("{task}.lock"))
434    }
435
436    fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
437        let path = self.task_manifest_lock_path(task);
438        fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
439        let mut lock = fslock::LockFile::open(&path)?;
440        lock.lock()?;
441        Ok(lock)
442    }
443
444    fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
445        match fs::read(self.task_manifest_path(task)) {
446            Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
447            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
448            Err(error) => Err(error.into()),
449        }
450    }
451
452    fn parse_task_manifest(
453        &self,
454        task: &str,
455        contents: &[u8],
456        require_canonical: bool,
457    ) -> Result<TaskActionManifest> {
458        let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
459        validate_task_manifest(&manifest, task)?;
460        if require_canonical && canonical_json(&manifest)? != contents {
461            bail!("task action manifest is not canonical JSON");
462        }
463        Ok(manifest)
464    }
465
466    fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
467        let bytes = canonical_json(&TaskActionManifestSelector {
468            version: 1,
469            kind: "task_action_manifest",
470            task,
471        })?;
472        let digest = CacheDigest::blake3(&bytes);
473        Ok((bytes, digest))
474    }
475
476    fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
477        let bytes = canonical_json(manifest)?;
478        fs::create_dir_all(self.manifest_dir.as_path())?;
479        let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
480        std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
481        temporary.as_file_mut().sync_all()?;
482        temporary
483            .persist(self.task_manifest_path(&manifest.task))
484            .map_err(|error| error.error)?;
485        Ok(())
486    }
487
488    async fn get_remote_task_manifest(
489        &self,
490        task: &str,
491    ) -> Result<Option<(TaskActionManifest, String)>> {
492        let Some(remote) = &self.remote else {
493            return Ok(None);
494        };
495        let (_, selector) = Self::task_manifest_selector(task)?;
496        let _permit = self.remote_transfers.acquire().await?;
497        let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
498            return Ok(None);
499        };
500        let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
501        Ok(Some((manifest, remote_manifest.etag)))
502    }
503
504    async fn put_remote_task_manifest(
505        &self,
506        task: &str,
507        mut manifest: TaskActionManifest,
508        mut expected_etag: Option<String>,
509    ) -> Result<TaskActionManifest> {
510        let Some(remote) = &self.remote else {
511            return Ok(manifest);
512        };
513        let (_, selector) = Self::task_manifest_selector(task)?;
514        for _ in 0..4 {
515            let bytes = canonical_json(&manifest)?;
516            let outcome = {
517                let _permit = self.remote_transfers.acquire().await?;
518                remote
519                    .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
520                    .await?
521            };
522            match outcome {
523                ManifestPutOutcome::Stored => return Ok(manifest),
524                ManifestPutOutcome::PreconditionFailed => {
525                    let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
526                    else {
527                        expected_etag = None;
528                        continue;
529                    };
530                    manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
531                    expected_etag = Some(etag);
532                }
533            }
534        }
535        bail!("remote task action manifest changed too frequently")
536    }
537
538    /// Return a snapshot of this session's cache activity.
539    pub fn stats(&self) -> AgentStats {
540        AgentStats {
541            lookups: self.stats.lookups.load(Ordering::Relaxed),
542            hits: self.stats.hits.load(Ordering::Relaxed),
543            stores: self.stats.stores.load(Ordering::Relaxed),
544            stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
545            verifications: self.stats.verifications.load(Ordering::Relaxed),
546            divergences: self.stats.divergences.load(Ordering::Relaxed),
547            downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
548            uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
549            prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
550        }
551    }
552
553    fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
554        Self::digest_lock(&self.write_locks, digest)
555    }
556
557    fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
558        Self::digest_lock(&self.action_locks, digest)
559    }
560
561    fn digest_lock(
562        locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
563        digest: &CacheDigest,
564    ) -> Arc<tokio::sync::Mutex<()>> {
565        let mut locks = locks.lock().unwrap();
566        locks.retain(|_, lock| lock.strong_count() > 0);
567        if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
568            return lock;
569        }
570        let lock = Arc::new(tokio::sync::Mutex::new(()));
571        locks.insert(digest.clone(), Arc::downgrade(&lock));
572        lock
573    }
574
575    fn spawn_prefetch_predictions(&self, predictions: Vec<ActionPrediction>) {
576        if predictions.is_empty() || !self.remote_mode.reads() || self.remote.is_none() {
577            return;
578        }
579        let agent = self.clone();
580        let task = tokio::spawn(async move {
581            agent.prefetch_predictions(predictions.iter()).await;
582        });
583        self.prefetch_tasks.lock().unwrap().push(task);
584    }
585
586    async fn prefetch_predictions<'a>(
587        &self,
588        predictions: impl Iterator<Item = &'a ActionPrediction>,
589    ) {
590        if !self.remote_mode.reads() || self.remote.is_none() {
591            return;
592        }
593        let mut actions = BTreeMap::new();
594        for prediction in predictions {
595            actions
596                .entry(prediction.action.clone())
597                .or_insert_with(|| prediction.adapter.clone());
598        }
599        let mut actions = actions.into_iter();
600        let mut tasks = tokio::task::JoinSet::new();
601        for _ in 0..MAX_PREFETCH_TRANSFERS {
602            let Some((action, adapter)) = actions.next() else {
603                break;
604            };
605            let agent = self.clone();
606            tasks.spawn(async move { agent.prefetch_action(action, adapter).await });
607        }
608        while let Some(result) = tasks.join_next().await {
609            match result {
610                Ok(Ok(())) => {}
611                Ok(Err(error)) => warn!("remote action prefetch failed: {error}"),
612                Err(error) => warn!("remote action prefetch task failed: {error}"),
613            }
614            if let Some((action, adapter)) = actions.next() {
615                let agent = self.clone();
616                tasks.spawn(async move { agent.prefetch_action(action, adapter).await });
617            }
618        }
619    }
620
621    async fn prefetch_action(&self, action: CacheDigest, adapter: String) -> Result<()> {
622        let remote = self
623            .remote
624            .as_ref()
625            .ok_or_else(|| eyre::eyre!("remote cache is not configured"))?;
626        let result = {
627            let lock = self.action_lock(&action);
628            let _guard = lock.lock().await;
629            if self.actions.find(&action)?.is_some() {
630                return Ok(());
631            }
632            if let Some(result) = self
633                .pending_remote_actions
634                .lock()
635                .unwrap()
636                .get(&action)
637                .cloned()
638            {
639                result
640            } else {
641                let _prefetch_permit = self.prefetch_transfers.acquire().await?;
642                let result = {
643                    let _permit = self.remote_transfers.acquire().await?;
644                    remote.get_action_result(&action).await?
645                };
646                let Some(result) = result else { return Ok(()) };
647                self.pending_remote_actions
648                    .lock()
649                    .unwrap()
650                    .insert(action.clone(), result.clone());
651                result
652            }
653        };
654        self.fetch_remote_blob_prefetch(remote, &result.action)
655            .await?;
656        if let Some(metadata) = &result.metadata {
657            let path = self.fetch_remote_blob_prefetch(remote, metadata).await?;
658            if adapter == "rustc" {
659                let bytes = fs::read(path)?;
660                let metadata: RustcMetadata = serde_json::from_slice(&bytes)?;
661                if metadata.version != 1
662                    || metadata.kind != "rustc"
663                    || canonical_json(&metadata)? != bytes
664                {
665                    bail!("remote rustc action metadata is invalid");
666                }
667                self.fetch_remote_blob_prefetch(remote, &metadata.stdout)
668                    .await?;
669                self.fetch_remote_blob_prefetch(remote, &metadata.stderr)
670                    .await?;
671            }
672        }
673        if let Some(output_root) = &result.output_root {
674            self.prefetch_output_tree(remote, output_root).await?;
675        }
676        self.actions.store(&result)?;
677        self.pending_remote_actions.lock().unwrap().remove(&action);
678        self.stats
679            .prefetched_actions
680            .fetch_add(1, Ordering::Relaxed);
681        Ok(())
682    }
683
684    async fn prefetch_output_tree(
685        &self,
686        remote: &RemoteCacheClient,
687        output_root: &CacheDigest,
688    ) -> Result<()> {
689        let mut pending = vec![output_root.clone()];
690        let mut seen = BTreeMap::new();
691        while let Some(digest) = pending.pop() {
692            if seen.insert(digest.clone(), ()).is_some() {
693                continue;
694            }
695            if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
696                bail!("remote action output tree is too large");
697            }
698            let path = self.fetch_remote_blob_prefetch(remote, &digest).await?;
699            let bytes = fs::read(path)?;
700            let directory: CacheDirectory = serde_json::from_slice(&bytes)?;
701            if directory.version != 1 || canonical_json(&directory)? != bytes {
702                bail!("remote action output directory is invalid");
703            }
704            let mut transfers = stream::iter(directory.files.into_iter().map(|file| async move {
705                self.fetch_remote_blob_prefetch(remote, &file.digest)
706                    .await
707                    .map(|_| ())
708            }))
709            .buffer_unordered(MAX_PREFETCH_TRANSFERS);
710            while let Some(result) = transfers.next().await {
711                result?;
712            }
713            pending.extend(
714                directory
715                    .directories
716                    .into_iter()
717                    .map(|directory| directory.digest),
718            );
719        }
720        Ok(())
721    }
722
723    async fn fetch_remote_blob_prefetch(
724        &self,
725        remote: &RemoteCacheClient,
726        digest: &CacheDigest,
727    ) -> Result<PathBuf> {
728        self.fetch_remote_blob_with_limit(remote, digest, Some(&self.prefetch_transfers))
729            .await
730    }
731
732    async fn fetch_remote_blob(
733        &self,
734        remote: &RemoteCacheClient,
735        digest: &CacheDigest,
736    ) -> Result<PathBuf> {
737        self.fetch_remote_blob_with_limit(remote, digest, None)
738            .await
739    }
740
741    async fn fetch_remote_blob_with_limit(
742        &self,
743        remote: &RemoteCacheClient,
744        digest: &CacheDigest,
745        prefetch_limit: Option<&tokio::sync::Semaphore>,
746    ) -> Result<PathBuf> {
747        let lock = self.write_lock(digest);
748        let _guard = lock.lock().await;
749        if let Some(path) = self.find_verified_blob(digest)? {
750            return Ok(path);
751        }
752        let _prefetch_permit = match prefetch_limit {
753            Some(limit) => Some(limit.acquire().await?),
754            None => None,
755        };
756        let _permit = self.remote_transfers.acquire().await?;
757        let temporary = remote
758            .get_blob_file(digest, self.remote_staging_dir.as_path())
759            .await?;
760        let path = self.cas.store_verified_file(digest, temporary.path())?;
761        self.remember_verified_blob(digest, &path);
762        self.stats.stores.fetch_add(1, Ordering::Relaxed);
763        self.stats
764            .stored_bytes
765            .fetch_add(digest.size, Ordering::Relaxed);
766        self.stats
767            .downloaded_bytes
768            .fetch_add(digest.size, Ordering::Relaxed);
769        Ok(path)
770    }
771
772    async fn respond(&self, request: AgentRequest) -> AgentResponse {
773        let result = match request {
774            AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
775            AgentRequest::StoreBlob { digest, source } => self.store_blob(&digest, &source).await,
776            AgentRequest::FindActionResult { action } => {
777                self.stats.lookups.fetch_add(1, Ordering::Relaxed);
778                self.find_action_result(&action).await
779            }
780            AgentRequest::RecordActionHit { action } => self.record_action_hit(&action),
781            AgentRequest::RecordActionVerification { matched } => {
782                self.stats.verifications.fetch_add(1, Ordering::Relaxed);
783                if !matched {
784                    self.stats.divergences.fetch_add(1, Ordering::Relaxed);
785                }
786                Ok(AgentResponse::ActionVerificationRecorded)
787            }
788            AgentRequest::StoreActionResult { result } => self.store_action_result(&result).await,
789            AgentRequest::FindActionPrediction { task, invocation } => {
790                self.find_action_prediction(&task, &invocation)
791            }
792            AgentRequest::RecordActionPrediction { task, prediction } => {
793                self.record_action_prediction(&task, prediction)
794            }
795            AgentRequest::FindExecutableIdentity {
796                executable,
797                environment,
798            } => self.find_executable_identity(executable, environment),
799            AgentRequest::StoreExecutableIdentity {
800                executable,
801                environment,
802                stdout,
803            } => self.store_executable_identity(executable, environment, stdout),
804            AgentRequest::Hello { .. } => {
805                Err(eyre::eyre!("hello is only valid as the first request"))
806            }
807        };
808        result.unwrap_or_else(|error| AgentResponse::Error {
809            message: error.to_string(),
810        })
811    }
812
813    async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
814        if let Some(path) = self.find_verified_blob(digest)? {
815            return Ok(AgentResponse::Blob { path: Some(path) });
816        }
817        if !self.remote_mode.reads() {
818            return Ok(AgentResponse::Blob { path: None });
819        }
820        let Some(remote) = &self.remote else {
821            return Ok(AgentResponse::Blob { path: None });
822        };
823        match self.fetch_remote_blob(remote, digest).await {
824            Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
825            Err(error) => {
826                warn!(
827                    "remote cache blob lookup failed for {}: {error}",
828                    digest.hash
829                );
830                Ok(AgentResponse::Blob { path: None })
831            }
832        }
833    }
834
835    async fn store_blob(&self, digest: &CacheDigest, source: &Path) -> Result<AgentResponse> {
836        let remote = if self.remote_mode.writes() {
837            self.remote.as_deref()
838        } else {
839            None
840        };
841        let path = {
842            let lock = self.write_lock(digest);
843            let _guard = lock.lock().await;
844            if let Some(path) = self.find_verified_blob(digest)? {
845                path
846            } else {
847                let path = self.cas.store_file(digest, source)?;
848                self.remember_verified_blob(digest, &path);
849                self.stats.stores.fetch_add(1, Ordering::Relaxed);
850                self.stats
851                    .stored_bytes
852                    .fetch_add(digest.size, Ordering::Relaxed);
853                path
854            }
855        };
856        if let Some(remote) = remote {
857            let _permit = self.remote_transfers.acquire().await?;
858            if let Err(error) = remote
859                .put_blob(&BlobUpload {
860                    digest: digest.clone(),
861                    source: BlobSource::Path(path.clone()),
862                })
863                .await
864            {
865                warn!(
866                    "remote cache blob upload failed for {}: {error}",
867                    digest.hash
868                );
869            } else {
870                self.stats
871                    .uploaded_bytes
872                    .fetch_add(digest.size, Ordering::Relaxed);
873            }
874        }
875        Ok(AgentResponse::Stored { path })
876    }
877
878    fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
879        let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
880        if let Some(path) = remembered {
881            if digest.matches_file(&path).unwrap_or(false) {
882                return Ok(Some(path));
883            }
884            self.verified_blobs.lock().unwrap().remove(digest);
885        }
886        let path = self.cas.find(digest)?;
887        if let Some(path) = &path {
888            self.remember_verified_blob(digest, path);
889        }
890        Ok(path)
891    }
892
893    fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
894        self.verified_blobs
895            .lock()
896            .unwrap()
897            .insert(digest.clone(), path.to_path_buf());
898    }
899
900    async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
901        if let Some(result) = self.actions.find(action)? {
902            return Ok(AgentResponse::ActionResult {
903                result: Some(result),
904            });
905        }
906        if !self.remote_mode.reads() {
907            return Ok(AgentResponse::ActionResult { result: None });
908        }
909        let Some(remote) = &self.remote else {
910            return Ok(AgentResponse::ActionResult { result: None });
911        };
912        let lock = self.action_lock(action);
913        let _guard = lock.lock().await;
914        if let Some(result) = self.actions.find(action)? {
915            return Ok(AgentResponse::ActionResult {
916                result: Some(result),
917            });
918        }
919        if let Some(result) = self
920            .pending_remote_actions
921            .lock()
922            .unwrap()
923            .get(action)
924            .cloned()
925        {
926            return Ok(AgentResponse::ActionResult {
927                result: Some(result),
928            });
929        }
930        let _permit = self.remote_transfers.acquire().await?;
931        match remote.get_action_result(action).await {
932            Ok(Some(result)) => {
933                self.pending_remote_actions
934                    .lock()
935                    .unwrap()
936                    .insert(action.clone(), result.clone());
937                Ok(AgentResponse::ActionResult {
938                    result: Some(result),
939                })
940            }
941            Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
942            Err(error) => {
943                warn!(
944                    "remote cache action lookup failed for {}: {error}",
945                    action.hash
946                );
947                Ok(AgentResponse::ActionResult { result: None })
948            }
949        }
950    }
951
952    async fn store_action_result(&self, result: &RemoteActionResult) -> Result<AgentResponse> {
953        let path = self.actions.store(result)?;
954        if self.remote_mode.writes()
955            && let Some(remote) = &self.remote
956        {
957            let _permit = self.remote_transfers.acquire().await?;
958            if let Err(error) = remote.put_action_result(result).await {
959                warn!(
960                    "remote cache action upload failed for {}: {error}",
961                    result.action.hash
962                );
963            }
964        }
965        Ok(AgentResponse::ActionStored { path })
966    }
967
968    fn record_action_hit(&self, action: &CacheDigest) -> Result<AgentResponse> {
969        if self.actions.find(action)?.is_none() {
970            let pending = self.pending_remote_actions.lock().unwrap().remove(action);
971            if let Some(result) = pending {
972                self.actions.store(&result)?;
973            } else {
974                bail!("cannot record a hit for a missing action result");
975            }
976        }
977        self.stats.hits.fetch_add(1, Ordering::Relaxed);
978        Ok(AgentResponse::ActionHitRecorded)
979    }
980
981    fn find_action_prediction(
982        &self,
983        task: &str,
984        invocation: &CacheDigest,
985    ) -> Result<AgentResponse> {
986        validate_task_identity(task)?;
987        invocation.validate()?;
988        let prediction = self
989            .task_actions
990            .lock()
991            .unwrap()
992            .get(task)
993            .and_then(|state| state.predictions.get(invocation))
994            .cloned();
995        Ok(AgentResponse::ActionPrediction { prediction })
996    }
997
998    fn record_action_prediction(
999        &self,
1000        task: &str,
1001        prediction: ActionPrediction,
1002    ) -> Result<AgentResponse> {
1003        validate_task_identity(task)?;
1004        validate_action_prediction(&prediction)?;
1005        let mut tasks = self.task_actions.lock().unwrap();
1006        let state = tasks.entry(task.to_string()).or_default();
1007        if !state.predictions.contains_key(&prediction.invocation)
1008            && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
1009        {
1010            bail!("task action manifest contains too many predictions");
1011        }
1012        state
1013            .predictions
1014            .insert(prediction.invocation.clone(), prediction);
1015        Ok(AgentResponse::ActionPredictionRecorded)
1016    }
1017
1018    fn executable_identity_key(
1019        &self,
1020        executable: PathBuf,
1021        environment: BTreeMap<String, Option<String>>,
1022    ) -> Result<ExecutableIdentityKey> {
1023        if !environment
1024            .keys()
1025            .all(|name| matches!(name.as_str(), "RUSTUP_HOME" | "RUSTUP_TOOLCHAIN"))
1026        {
1027            bail!("executable identity contains an unsupported environment variable");
1028        }
1029        Ok(ExecutableIdentityKey {
1030            executable,
1031            environment,
1032        })
1033    }
1034
1035    fn find_executable_identity(
1036        &self,
1037        executable: PathBuf,
1038        environment: BTreeMap<String, Option<String>>,
1039    ) -> Result<AgentResponse> {
1040        let key = self.executable_identity_key(executable, environment)?;
1041        let stdout = self
1042            .executable_identities
1043            .lock()
1044            .unwrap()
1045            .get(&key)
1046            .cloned();
1047        Ok(AgentResponse::ExecutableIdentity { stdout })
1048    }
1049
1050    fn store_executable_identity(
1051        &self,
1052        executable: PathBuf,
1053        environment: BTreeMap<String, Option<String>>,
1054        stdout: Vec<u8>,
1055    ) -> Result<AgentResponse> {
1056        if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1057            bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1058        }
1059        let key = self.executable_identity_key(executable, environment)?;
1060        let mut identities = self.executable_identities.lock().unwrap();
1061        let is_new = !identities.contains_key(&key);
1062        let previous_size = identities.get(&key).map_or(0, Vec::len);
1063        if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1064            bail!("executable identity cache contains too many entries");
1065        }
1066        let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1067        if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1068            bail!("executable identity cache contains too many bytes");
1069        }
1070        identities.insert(key, stdout.clone());
1071        Ok(AgentResponse::ExecutableIdentity {
1072            stdout: Some(stdout),
1073        })
1074    }
1075
1076    /// Serve newline-delimited protocol requests on an authenticated session stream.
1077    pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1078    where
1079        S: AsyncRead + AsyncWrite + Unpin,
1080    {
1081        let (reader, mut writer) = tokio::io::split(stream);
1082        let mut lines = BufReader::new(reader).lines();
1083        let hello = lines
1084            .next_line()
1085            .await?
1086            .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1087        let request: AgentRequest = serde_json::from_str(&hello)?;
1088        match request {
1089            AgentRequest::Hello {
1090                protocol,
1091                client_version,
1092            } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1093            AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1094                send_response(
1095                    &mut writer,
1096                    &AgentResponse::Error {
1097                        message: format!(
1098                            "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1099                        ),
1100                    },
1101                )
1102                .await?;
1103                return Ok(());
1104            }
1105            AgentRequest::Hello { client_version, .. } => {
1106                send_response(
1107                    &mut writer,
1108                    &AgentResponse::Error {
1109                        message: format!(
1110                            "cache client {client_version} does not match agent {}",
1111                            self.version
1112                        ),
1113                    },
1114                )
1115                .await?;
1116                return Ok(());
1117            }
1118            _ => bail!("the first agent request must be hello"),
1119        }
1120        send_response(
1121            &mut writer,
1122            &AgentResponse::Hello {
1123                protocol: AGENT_PROTOCOL_VERSION,
1124                agent_version: self.version.to_string(),
1125            },
1126        )
1127        .await?;
1128
1129        while let Some(line) = lines.next_line().await? {
1130            let response = match serde_json::from_str(&line) {
1131                Ok(request) => self.respond(request).await,
1132                Err(error) => AgentResponse::Error {
1133                    message: format!("invalid agent request: {error}"),
1134                },
1135            };
1136            send_response(&mut writer, &response).await?;
1137        }
1138        Ok(())
1139    }
1140}
1141
1142fn validate_task_identity(task: &str) -> Result<()> {
1143    if task.len() != 64
1144        || !task
1145            .bytes()
1146            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1147    {
1148        bail!("invalid task action identity");
1149    }
1150    Ok(())
1151}
1152
1153fn validate_action_prediction(prediction: &ActionPrediction) -> Result<()> {
1154    prediction.invocation.validate()?;
1155    prediction.action.validate()?;
1156    if prediction.adapter.is_empty()
1157        || !prediction
1158            .adapter
1159            .bytes()
1160            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1161    {
1162        bail!("invalid action prediction adapter");
1163    }
1164    if prediction.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
1165        bail!("action prediction payload is too large");
1166    }
1167    serde_json::from_str::<serde_json::Value>(&prediction.payload)?;
1168    Ok(())
1169}
1170
1171fn validate_task_manifest(manifest: &TaskActionManifest, task: &str) -> Result<()> {
1172    if manifest.version != TASK_ACTION_MANIFEST_VERSION || manifest.task != task {
1173        bail!("task action manifest has an invalid identity");
1174    }
1175    if manifest.predictions.len() > MAX_TASK_ACTION_PREDICTIONS {
1176        bail!("task action manifest contains too many predictions");
1177    }
1178    let mut invocations = BTreeMap::new();
1179    for prediction in &manifest.predictions {
1180        validate_action_prediction(prediction)?;
1181        if invocations.insert(&prediction.invocation, ()).is_some() {
1182            bail!("task action manifest contains duplicate predictions");
1183        }
1184    }
1185    Ok(())
1186}
1187
1188fn merge_task_manifests(
1189    task: &str,
1190    base: Option<TaskActionManifest>,
1191    update: TaskActionManifest,
1192) -> Result<TaskActionManifest> {
1193    validate_task_manifest(&update, task)?;
1194    let mut predictions = BTreeMap::new();
1195    if let Some(base) = base {
1196        validate_task_manifest(&base, task)?;
1197        predictions.extend(
1198            base.predictions
1199                .into_iter()
1200                .map(|prediction| (prediction.invocation.clone(), prediction)),
1201        );
1202    }
1203    predictions.extend(
1204        update
1205            .predictions
1206            .into_iter()
1207            .map(|prediction| (prediction.invocation.clone(), prediction)),
1208    );
1209    let manifest = TaskActionManifest {
1210        version: TASK_ACTION_MANIFEST_VERSION,
1211        task: task.to_owned(),
1212        predictions: predictions.into_values().collect(),
1213    };
1214    validate_task_manifest(&manifest, task)?;
1215    Ok(manifest)
1216}
1217
1218fn merge_remote_task_manifest(
1219    task: &str,
1220    remote: TaskActionManifest,
1221    local: TaskActionManifest,
1222) -> (TaskActionManifest, bool) {
1223    match merge_task_manifests(task, Some(remote), local.clone()) {
1224        Ok(manifest) => (manifest, true),
1225        Err(error) => {
1226            warn!("remote task action manifest merge failed for {task}: {error}");
1227            (local, false)
1228        }
1229    }
1230}
1231
1232async fn send_response(
1233    writer: &mut (impl AsyncWrite + Unpin),
1234    response: &AgentResponse,
1235) -> Result<()> {
1236    let mut encoded = serde_json::to_vec(response)?;
1237    encoded.push(b'\n');
1238    writer.write_all(&encoded).await?;
1239    writer.flush().await?;
1240    Ok(())
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246    use crate::ACTION_RESULT_MEDIA_TYPE;
1247    use std::time::Duration;
1248
1249    async fn handshake(stream: &mut (impl AsyncRead + AsyncWrite + Unpin), version: &str) {
1250        let request = AgentRequest::Hello {
1251            protocol: AGENT_PROTOCOL_VERSION,
1252            client_version: version.to_string(),
1253        };
1254        let mut encoded = serde_json::to_vec(&request).unwrap();
1255        encoded.push(b'\n');
1256        stream.write_all(&encoded).await.unwrap();
1257        stream.flush().await.unwrap();
1258        let mut response = String::new();
1259        BufReader::new(stream)
1260            .read_line(&mut response)
1261            .await
1262            .unwrap();
1263        assert!(matches!(
1264            serde_json::from_str(&response).unwrap(),
1265            AgentResponse::Hello { .. }
1266        ));
1267    }
1268
1269    #[tokio::test]
1270    async fn handshake_and_blob_round_trip() {
1271        let directory = tempfile::tempdir().unwrap();
1272        let source = directory.path().join("source");
1273        std::fs::write(&source, b"cached object").unwrap();
1274        let digest = CacheDigest::blake3(b"cached object");
1275        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1276        let (mut client, server) = tokio::io::duplex(16 * 1024);
1277        let server_agent = agent.clone();
1278        let task = tokio::spawn(async move { server_agent.handle_connection(server).await });
1279
1280        handshake(&mut client, "test-version").await;
1281        let request = AgentRequest::StoreBlob {
1282            digest: digest.clone(),
1283            source,
1284        };
1285        let mut encoded = serde_json::to_vec(&request).unwrap();
1286        encoded.push(b'\n');
1287        client.write_all(&encoded).await.unwrap();
1288        let mut response = String::new();
1289        BufReader::new(&mut client)
1290            .read_line(&mut response)
1291            .await
1292            .unwrap();
1293        assert!(matches!(
1294            serde_json::from_str(&response).unwrap(),
1295            AgentResponse::Stored { .. }
1296        ));
1297        drop(client);
1298        task.await.unwrap().unwrap();
1299        assert_eq!(
1300            agent.stats(),
1301            AgentStats {
1302                stores: 1,
1303                stored_bytes: digest.size,
1304                ..AgentStats::default()
1305            }
1306        );
1307    }
1308
1309    #[test]
1310    fn remembered_blobs_reject_same_size_corruption() {
1311        let directory = tempfile::tempdir().unwrap();
1312        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1313        let digest = CacheDigest::blake3(b"cached object");
1314        let path = agent.cas.store_bytes(&digest, b"cached object").unwrap();
1315        assert_eq!(
1316            agent.find_verified_blob(&digest).unwrap(),
1317            Some(path.clone())
1318        );
1319
1320        std::fs::write(&path, b"broken object").unwrap();
1321
1322        assert!(agent.find_verified_blob(&digest).is_err());
1323        assert!(!agent.verified_blobs.lock().unwrap().contains_key(&digest));
1324    }
1325
1326    #[tokio::test]
1327    async fn publishes_a_complete_action_result() {
1328        let directory = tempfile::tempdir().unwrap();
1329        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1330        let action = CacheDigest::blake3(b"action");
1331        let metadata = CacheDigest::blake3(b"metadata");
1332        let output_root = CacheDigest::blake3(b"directory");
1333        for (digest, contents) in [
1334            (&action, b"action".as_slice()),
1335            (&metadata, b"metadata".as_slice()),
1336            (&output_root, b"directory".as_slice()),
1337        ] {
1338            agent.cas.store_bytes(digest, contents).unwrap();
1339        }
1340        let response = agent
1341            .respond(AgentRequest::StoreActionResult {
1342                result: RemoteActionResult {
1343                    action: action.clone(),
1344                    metadata: Some(metadata),
1345                    output_root: Some(output_root),
1346                    version: 1,
1347                },
1348            })
1349            .await;
1350        assert!(matches!(response, AgentResponse::ActionStored { .. }));
1351        let response = agent
1352            .respond(AgentRequest::FindActionResult {
1353                action: action.clone(),
1354            })
1355            .await;
1356        assert!(matches!(
1357            response,
1358            AgentResponse::ActionResult {
1359                result: Some(result)
1360            } if result.action == action
1361        ));
1362        assert!(matches!(
1363            agent
1364                .respond(AgentRequest::RecordActionHit {
1365                    action: action.clone()
1366                })
1367                .await,
1368            AgentResponse::ActionHitRecorded
1369        ));
1370        assert_eq!(
1371            agent.stats(),
1372            AgentStats {
1373                lookups: 1,
1374                hits: 1,
1375                ..AgentStats::default()
1376            }
1377        );
1378    }
1379
1380    #[tokio::test]
1381    async fn missing_action_result_is_a_cache_miss() {
1382        let directory = tempfile::tempdir().unwrap();
1383        let agent = CacheAgent::new(directory.path(), "test-version");
1384        let action = CacheDigest::blake3(b"missing action");
1385        let response = agent
1386            .respond(AgentRequest::FindActionResult {
1387                action: action.clone(),
1388            })
1389            .await;
1390
1391        assert!(matches!(
1392            response,
1393            AgentResponse::ActionResult { result: None }
1394        ));
1395        assert!(matches!(
1396            agent
1397                .respond(AgentRequest::RecordActionHit { action })
1398                .await,
1399            AgentResponse::Error { .. }
1400        ));
1401        assert_eq!(
1402            agent.stats(),
1403            AgentStats {
1404                lookups: 1,
1405                ..AgentStats::default()
1406            }
1407        );
1408
1409        assert!(matches!(
1410            agent
1411                .respond(AgentRequest::RecordActionVerification { matched: false })
1412                .await,
1413            AgentResponse::ActionVerificationRecorded
1414        ));
1415        assert_eq!(agent.stats().verifications, 1);
1416        assert_eq!(agent.stats().divergences, 1);
1417    }
1418
1419    #[tokio::test]
1420    async fn coalesces_repeated_remote_action_lookups() {
1421        let directory = tempfile::tempdir().unwrap();
1422        let mut server = mockito::Server::new_async().await;
1423        let action = CacheDigest::blake3(b"remote action");
1424        let result = RemoteActionResult {
1425            action: action.clone(),
1426            metadata: None,
1427            output_root: None,
1428            version: 1,
1429        };
1430        let remote = server
1431            .mock("GET", action_path(&action).as_str())
1432            .with_status(200)
1433            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1434            .with_body(serde_json::to_vec(&result).unwrap())
1435            .expect(1)
1436            .create_async()
1437            .await;
1438        let agent = remote_agent(
1439            &server,
1440            directory.path().join("reader"),
1441            RemoteCacheMode::ReadOnly,
1442        );
1443
1444        for _ in 0..2 {
1445            assert!(matches!(
1446                agent
1447                    .respond(AgentRequest::FindActionResult {
1448                        action: action.clone(),
1449                    })
1450                    .await,
1451                AgentResponse::ActionResult {
1452                    result: Some(found)
1453                } if found == result
1454            ));
1455        }
1456        remote.assert_async().await;
1457    }
1458
1459    #[tokio::test]
1460    async fn publishes_only_successfully_committed_task_action_manifests() {
1461        let directory = tempfile::tempdir().unwrap();
1462        let cache = directory.path().join("cache");
1463        let task = "a".repeat(64);
1464        let first_invocation = CacheDigest::blake3(b"first invocation");
1465        let first = ActionPrediction {
1466            invocation: first_invocation.clone(),
1467            action: CacheDigest::blake3(b"first action"),
1468            adapter: "rustc".into(),
1469            payload: "{}".into(),
1470        };
1471
1472        let agent = CacheAgent::new(&cache, "test-version");
1473        let first_run = agent.begin_task(&task).await.unwrap();
1474        assert!(matches!(
1475            agent
1476                .respond(AgentRequest::RecordActionPrediction {
1477                    task: first_run.clone(),
1478                    prediction: first.clone(),
1479                })
1480                .await,
1481            AgentResponse::ActionPredictionRecorded
1482        ));
1483        agent.commit_task(&first_run).await.unwrap();
1484
1485        let uncommitted = CacheAgent::new(&cache, "test-version");
1486        let uncommitted_run = uncommitted.begin_task(&task).await.unwrap();
1487        let second_invocation = CacheDigest::blake3(b"second invocation");
1488        assert!(matches!(
1489            uncommitted
1490                .respond(AgentRequest::RecordActionPrediction {
1491                    task: uncommitted_run,
1492                    prediction: ActionPrediction {
1493                        invocation: second_invocation.clone(),
1494                        action: CacheDigest::blake3(b"second action"),
1495                        adapter: "rustc".into(),
1496                        payload: "{}".into(),
1497                    },
1498                })
1499                .await,
1500            AgentResponse::ActionPredictionRecorded
1501        ));
1502
1503        let next_session = CacheAgent::new(&cache, "test-version");
1504        let next_run = next_session.begin_task(&task).await.unwrap();
1505        assert!(matches!(
1506            next_session
1507                .respond(AgentRequest::FindActionPrediction {
1508                    task: next_run.clone(),
1509                    invocation: first_invocation,
1510                })
1511                .await,
1512            AgentResponse::ActionPrediction {
1513                prediction: Some(prediction)
1514            } if prediction == first
1515        ));
1516        assert!(matches!(
1517            next_session
1518                .respond(AgentRequest::FindActionPrediction {
1519                    task: next_run,
1520                    invocation: second_invocation,
1521                })
1522                .await,
1523            AgentResponse::ActionPrediction { prediction: None }
1524        ));
1525
1526        let corrupt_task = "b".repeat(64);
1527        fs::create_dir_all(next_session.manifest_dir.as_path()).unwrap();
1528        fs::write(next_session.task_manifest_path(&corrupt_task), b"not json").unwrap();
1529        assert!(next_session.begin_task(&corrupt_task).await.is_err());
1530        let corrupt_run = "c".repeat(64);
1531        next_session.task_actions.lock().unwrap().insert(
1532            corrupt_run.clone(),
1533            TaskActionState {
1534                manifest: corrupt_task.clone(),
1535                ..TaskActionState::default()
1536            },
1537        );
1538        assert!(matches!(
1539            next_session
1540                .respond(AgentRequest::RecordActionPrediction {
1541                    task: corrupt_run.clone(),
1542                    prediction: first,
1543                })
1544                .await,
1545            AgentResponse::ActionPredictionRecorded
1546        ));
1547        assert!(next_session.commit_task(&corrupt_run).await.is_err());
1548        assert_eq!(
1549            fs::read(next_session.task_manifest_path(&corrupt_task)).unwrap(),
1550            b"not json"
1551        );
1552    }
1553
1554    #[tokio::test]
1555    async fn round_trips_task_actions_between_fresh_local_caches() {
1556        let directory = tempfile::tempdir().unwrap();
1557        let mut server = mockito::Server::new_async().await;
1558        let task = "e".repeat(64);
1559        let invocation = CacheDigest::blake3(b"remote invocation");
1560        let action_bytes = canonical_json(&serde_json::json!({"kind":"rustc"})).unwrap();
1561        let stdout_bytes = b"cached stdout".to_vec();
1562        let stderr_bytes = b"cached stderr".to_vec();
1563        let artifact_bytes = b"cached artifact".to_vec();
1564        let stdout = CacheDigest::blake3(&stdout_bytes);
1565        let stderr = CacheDigest::blake3(&stderr_bytes);
1566        let artifact = CacheDigest::blake3(&artifact_bytes);
1567        let metadata_bytes = canonical_json(&RustcMetadata {
1568            version: 1,
1569            kind: "rustc".into(),
1570            stdout: stdout.clone(),
1571            stderr: stderr.clone(),
1572        })
1573        .unwrap();
1574        let directory_bytes = canonical_json(&serde_json::json!({
1575            "directories":[],
1576            "files":[{"digest":artifact,"executable":false,"mode":420,"name":"artifact"}],
1577            "symlinks":[],
1578            "version":1
1579        }))
1580        .unwrap();
1581        let action = CacheDigest::blake3(&action_bytes);
1582        let metadata = CacheDigest::blake3(&metadata_bytes);
1583        let output_root = CacheDigest::blake3(&directory_bytes);
1584        let result = RemoteActionResult {
1585            action: action.clone(),
1586            metadata: Some(metadata.clone()),
1587            output_root: Some(output_root.clone()),
1588            version: 1,
1589        };
1590        let prediction = ActionPrediction {
1591            invocation: invocation.clone(),
1592            action: action.clone(),
1593            adapter: "rustc".into(),
1594            payload: "{}".into(),
1595        };
1596        let manifest_bytes = canonical_json(&TaskActionManifest {
1597            version: TASK_ACTION_MANIFEST_VERSION,
1598            task: task.clone(),
1599            predictions: vec![prediction.clone()],
1600        })
1601        .unwrap();
1602        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
1603        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1604
1605        let mut mocks = Vec::new();
1606        for (digest, bytes) in [
1607            (&action, action_bytes.as_slice()),
1608            (&metadata, metadata_bytes.as_slice()),
1609            (&output_root, directory_bytes.as_slice()),
1610            (&stdout, stdout_bytes.as_slice()),
1611            (&stderr, stderr_bytes.as_slice()),
1612            (&artifact, artifact_bytes.as_slice()),
1613        ] {
1614            mocks.push(
1615                server
1616                    .mock("PUT", blob_path(digest).as_str())
1617                    .match_header("mise-cache-namespace", "test")
1618                    .match_body(bytes.to_vec())
1619                    .with_status(200)
1620                    .expect(1)
1621                    .create_async()
1622                    .await,
1623            );
1624        }
1625        mocks.push(
1626            server
1627                .mock("PUT", action_path(&result.action).as_str())
1628                .match_header("mise-cache-namespace", "test")
1629                .with_status(200)
1630                .expect(1)
1631                .create_async()
1632                .await,
1633        );
1634        mocks.push(
1635            server
1636                .mock("PUT", action_manifest_path(&selector).as_str())
1637                .match_header("mise-cache-namespace", "test")
1638                .match_header("if-none-match", "*")
1639                .match_body(manifest_bytes.clone())
1640                .with_status(201)
1641                .expect(1)
1642                .create_async()
1643                .await,
1644        );
1645        mocks.push(
1646            server
1647                .mock("GET", action_manifest_path(&selector).as_str())
1648                .with_status(200)
1649                .with_header("etag", &format!("\"{manifest_etag}\""))
1650                .with_body(manifest_bytes.clone())
1651                .expect(1)
1652                .create_async()
1653                .await,
1654        );
1655        mocks.push(
1656            server
1657                .mock("GET", action_path(&action).as_str())
1658                .with_status(200)
1659                .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1660                .with_body(serde_json::to_vec(&result).unwrap())
1661                .expect(1)
1662                .create_async()
1663                .await,
1664        );
1665        for (digest, bytes) in [
1666            (&action, action_bytes.as_slice()),
1667            (&metadata, metadata_bytes.as_slice()),
1668            (&output_root, directory_bytes.as_slice()),
1669            (&stdout, stdout_bytes.as_slice()),
1670            (&stderr, stderr_bytes.as_slice()),
1671            (&artifact, artifact_bytes.as_slice()),
1672        ] {
1673            mocks.push(
1674                server
1675                    .mock("GET", blob_path(digest).as_str())
1676                    .with_status(200)
1677                    .with_body(bytes)
1678                    .expect(1)
1679                    .create_async()
1680                    .await,
1681            );
1682        }
1683
1684        let writer = remote_agent(
1685            &server,
1686            directory.path().join("writer"),
1687            RemoteCacheMode::WriteOnly,
1688        );
1689        for (index, (digest, bytes)) in [
1690            (&action, action_bytes.as_slice()),
1691            (&metadata, metadata_bytes.as_slice()),
1692            (&output_root, directory_bytes.as_slice()),
1693            (&stdout, stdout_bytes.as_slice()),
1694            (&stderr, stderr_bytes.as_slice()),
1695            (&artifact, artifact_bytes.as_slice()),
1696        ]
1697        .into_iter()
1698        .enumerate()
1699        {
1700            let source = directory.path().join(format!("source-{index}"));
1701            fs::write(&source, bytes).unwrap();
1702            assert!(matches!(
1703                writer
1704                    .respond(AgentRequest::StoreBlob {
1705                        digest: digest.clone(),
1706                        source,
1707                    })
1708                    .await,
1709                AgentResponse::Stored { .. }
1710            ));
1711        }
1712        assert!(matches!(
1713            writer
1714                .respond(AgentRequest::StoreActionResult {
1715                    result: result.clone(),
1716                })
1717                .await,
1718            AgentResponse::ActionStored { .. }
1719        ));
1720        let run = writer.begin_task(&task).await.unwrap();
1721        assert!(matches!(
1722            writer
1723                .respond(AgentRequest::RecordActionPrediction {
1724                    task: run.clone(),
1725                    prediction: prediction.clone(),
1726                })
1727                .await,
1728            AgentResponse::ActionPredictionRecorded
1729        ));
1730        writer.commit_task(&run).await.unwrap();
1731
1732        let reader = remote_agent(
1733            &server,
1734            directory.path().join("reader"),
1735            RemoteCacheMode::ReadOnly,
1736        );
1737        let run = reader.begin_task(&task).await.unwrap();
1738        reader.wait_for_prefetches().await;
1739        assert!(matches!(
1740            reader
1741                .respond(AgentRequest::FindActionPrediction {
1742                    task: run,
1743                    invocation,
1744                })
1745                .await,
1746            AgentResponse::ActionPrediction {
1747                prediction: Some(found)
1748            } if found == prediction
1749        ));
1750        assert!(matches!(
1751            reader
1752                .respond(AgentRequest::FindActionResult {
1753                    action: action.clone(),
1754                })
1755                .await,
1756            AgentResponse::ActionResult {
1757                result: Some(found)
1758            } if found == result
1759        ));
1760        for digest in [&action, &metadata, &output_root] {
1761            assert!(matches!(
1762                reader
1763                    .respond(AgentRequest::FindBlob {
1764                        digest: digest.clone(),
1765                    })
1766                    .await,
1767                AgentResponse::Blob { path: Some(_) }
1768            ));
1769        }
1770        assert!(matches!(
1771            reader
1772                .respond(AgentRequest::RecordActionHit { action })
1773                .await,
1774            AgentResponse::ActionHitRecorded
1775        ));
1776        for mock in mocks {
1777            mock.assert_async().await;
1778        }
1779    }
1780
1781    #[tokio::test]
1782    async fn keeps_newer_local_predictions_when_remote_manifest_is_stale() {
1783        let directory = tempfile::tempdir().unwrap();
1784        let mut server = mockito::Server::new_async().await;
1785        let task = "f".repeat(64);
1786        let invocation = CacheDigest::blake3(b"shared invocation");
1787        let local_prediction = ActionPrediction {
1788            invocation: invocation.clone(),
1789            action: CacheDigest::blake3(b"new local action"),
1790            adapter: "rustc".into(),
1791            payload: "{}".into(),
1792        };
1793        let remote_prediction = ActionPrediction {
1794            invocation: invocation.clone(),
1795            action: CacheDigest::blake3(b"stale remote action"),
1796            adapter: "rustc".into(),
1797            payload: "{}".into(),
1798        };
1799        let remote_manifest = TaskActionManifest {
1800            version: TASK_ACTION_MANIFEST_VERSION,
1801            task: task.clone(),
1802            predictions: vec![remote_prediction],
1803        };
1804        let remote_bytes = canonical_json(&remote_manifest).unwrap();
1805        let remote_etag = blake3::hash(&remote_bytes).to_hex().to_string();
1806        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1807        let remote = server
1808            .mock("GET", action_manifest_path(&selector).as_str())
1809            .with_status(200)
1810            .with_header("etag", &format!("\"{remote_etag}\""))
1811            .with_body(remote_bytes)
1812            .expect(1)
1813            .create_async()
1814            .await;
1815
1816        let agent = remote_agent(
1817            &server,
1818            directory.path().join("reader"),
1819            RemoteCacheMode::ReadOnly,
1820        );
1821        agent
1822            .persist_task_manifest(&TaskActionManifest {
1823                version: TASK_ACTION_MANIFEST_VERSION,
1824                task: task.clone(),
1825                predictions: vec![local_prediction.clone()],
1826            })
1827            .unwrap();
1828
1829        let run = agent.begin_task(&task).await.unwrap();
1830        assert!(matches!(
1831            agent
1832                .respond(AgentRequest::FindActionPrediction {
1833                    task: run,
1834                    invocation,
1835                })
1836                .await,
1837            AgentResponse::ActionPrediction {
1838                prediction: Some(found)
1839            } if found == local_prediction
1840        ));
1841        let persisted = agent.load_task_manifest(&task).unwrap().unwrap();
1842        assert_eq!(persisted.predictions, vec![local_prediction]);
1843        remote.assert_async().await;
1844    }
1845
1846    #[tokio::test]
1847    async fn prefetch_does_not_block_task_initialization() {
1848        let directory = tempfile::tempdir().unwrap();
1849        let mut server = mockito::Server::new_async().await;
1850        let task = "9".repeat(64);
1851        let invocation = CacheDigest::blake3(b"prefetched invocation");
1852        let action_bytes = b"prefetched action";
1853        let action = CacheDigest::blake3(action_bytes);
1854        let result = RemoteActionResult {
1855            action: action.clone(),
1856            metadata: None,
1857            output_root: None,
1858            version: 1,
1859        };
1860        let manifest_bytes = canonical_json(&TaskActionManifest {
1861            version: TASK_ACTION_MANIFEST_VERSION,
1862            task: task.clone(),
1863            predictions: vec![ActionPrediction {
1864                invocation,
1865                action: action.clone(),
1866                adapter: "rustc".into(),
1867                payload: "{}".into(),
1868            }],
1869        })
1870        .unwrap();
1871        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
1872        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1873        let manifest = server
1874            .mock("GET", action_manifest_path(&selector).as_str())
1875            .with_status(200)
1876            .with_header("etag", &format!("\"{manifest_etag}\""))
1877            .with_body(manifest_bytes)
1878            .expect(1)
1879            .create_async()
1880            .await;
1881        let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
1882        let response_release = release.clone();
1883        let result_bytes = serde_json::to_vec(&result).unwrap();
1884        let action_result = server
1885            .mock("GET", action_path(&action).as_str())
1886            .with_status(200)
1887            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1888            .with_chunked_body(move |writer| {
1889                let (released, condition) = &*response_release;
1890                let mut released = released.lock().unwrap();
1891                while !*released {
1892                    released = condition.wait(released).unwrap();
1893                }
1894                std::io::Write::write_all(writer, &result_bytes)
1895            })
1896            .expect(1)
1897            .create_async()
1898            .await;
1899        let action_blob = server
1900            .mock("GET", blob_path(&action).as_str())
1901            .with_status(200)
1902            .with_body(action_bytes)
1903            .expect(1)
1904            .create_async()
1905            .await;
1906        let agent = remote_agent(
1907            &server,
1908            directory.path().join("reader"),
1909            RemoteCacheMode::ReadOnly,
1910        );
1911
1912        let begin = tokio::time::timeout(Duration::from_secs(2), agent.begin_task(&task)).await;
1913        let (released, condition) = &*release;
1914        *released.lock().unwrap() = true;
1915        condition.notify_all();
1916        let run = begin
1917            .expect("task initialization waited for prefetch")
1918            .unwrap();
1919        assert_eq!(
1920            agent
1921                .task_actions
1922                .lock()
1923                .unwrap()
1924                .get(&run)
1925                .unwrap()
1926                .predictions
1927                .len(),
1928            1
1929        );
1930        agent.wait_for_prefetches().await;
1931        manifest.assert_async().await;
1932        action_result.assert_async().await;
1933        action_blob.assert_async().await;
1934        assert!(agent.actions.find(&action).unwrap().is_some());
1935    }
1936
1937    #[tokio::test]
1938    async fn foreground_action_lookup_does_not_wait_for_prefetch_output() {
1939        let directory = tempfile::tempdir().unwrap();
1940        let mut server = mockito::Server::new_async().await;
1941        let action_bytes = b"prefetched action";
1942        let artifact_bytes = b"prefetched artifact";
1943        let action = CacheDigest::blake3(action_bytes);
1944        let artifact = CacheDigest::blake3(artifact_bytes);
1945        let directory_bytes = canonical_json(&serde_json::json!({
1946            "directories": [],
1947            "files": [{
1948                "digest": artifact,
1949                "executable": false,
1950                "mode": 420,
1951                "name": "artifact",
1952            }],
1953            "symlinks": [],
1954            "version": 1,
1955        }))
1956        .unwrap();
1957        let output_root = CacheDigest::blake3(&directory_bytes);
1958        let result = RemoteActionResult {
1959            action: action.clone(),
1960            metadata: None,
1961            output_root: Some(output_root.clone()),
1962            version: 1,
1963        };
1964        let action_result = server
1965            .mock("GET", action_path(&action).as_str())
1966            .with_status(200)
1967            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1968            .with_body(serde_json::to_vec(&result).unwrap())
1969            .expect(1)
1970            .create_async()
1971            .await;
1972        let action_blob = server
1973            .mock("GET", blob_path(&action).as_str())
1974            .with_status(200)
1975            .with_body(action_bytes)
1976            .expect(1)
1977            .create_async()
1978            .await;
1979        let output_directory = server
1980            .mock("GET", blob_path(&output_root).as_str())
1981            .with_status(200)
1982            .with_body(directory_bytes)
1983            .expect(1)
1984            .create_async()
1985            .await;
1986        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
1987        let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
1988        let response_started = started.clone();
1989        let response_release = release.clone();
1990        let artifact_blob = server
1991            .mock("GET", blob_path(&artifact).as_str())
1992            .with_status(200)
1993            .with_chunked_body(move |writer| {
1994                response_started.store(true, Ordering::Release);
1995                while !response_release.load(Ordering::Acquire) {
1996                    std::thread::sleep(Duration::from_millis(10));
1997                }
1998                std::io::Write::write_all(writer, artifact_bytes)
1999            })
2000            .expect(1)
2001            .create_async()
2002            .await;
2003        let agent = remote_agent(
2004            &server,
2005            directory.path().join("reader"),
2006            RemoteCacheMode::ReadOnly,
2007        );
2008        let prefetch_agent = agent.clone();
2009        let prefetch_action = action.clone();
2010        let prefetch = tokio::spawn(async move {
2011            prefetch_agent
2012                .prefetch_action(prefetch_action, "rustc".into())
2013                .await
2014        });
2015        tokio::time::timeout(Duration::from_secs(1), async {
2016            while !started.load(Ordering::Acquire) {
2017                tokio::time::sleep(Duration::from_millis(10)).await;
2018            }
2019        })
2020        .await
2021        .expect("prefetch did not request the output blob");
2022
2023        let foreground = tokio::time::timeout(
2024            Duration::from_millis(250),
2025            agent.find_action_result(&action),
2026        )
2027        .await;
2028        release.store(true, Ordering::Release);
2029        prefetch.await.unwrap().unwrap();
2030        let foreground = foreground.expect("foreground action lookup waited for output prefetch");
2031
2032        assert!(matches!(
2033            foreground.unwrap(),
2034            AgentResponse::ActionResult {
2035                result: Some(found)
2036            } if found == result
2037        ));
2038        action_result.assert_async().await;
2039        action_blob.assert_async().await;
2040        output_directory.assert_async().await;
2041        artifact_blob.assert_async().await;
2042    }
2043
2044    #[tokio::test]
2045    async fn session_completion_cancels_outstanding_prefetches() {
2046        let directory = tempfile::tempdir().unwrap();
2047        let agent = CacheAgent::new(directory.path(), "test-version");
2048        let task = tokio::spawn(std::future::pending::<()>());
2049        agent.prefetch_tasks.lock().unwrap().push(task);
2050
2051        tokio::time::timeout(Duration::from_secs(1), agent.cancel_prefetches())
2052            .await
2053            .expect("prefetch cancellation blocked session completion");
2054        assert!(agent.prefetch_tasks.lock().unwrap().is_empty());
2055    }
2056
2057    #[tokio::test]
2058    async fn prefetch_reserves_capacity_for_foreground_transfers() {
2059        let transfers = tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS);
2060        let _prefetch = transfers
2061            .acquire_many(MAX_PREFETCH_TRANSFERS as u32)
2062            .await
2063            .unwrap();
2064        assert!(transfers.available_permits() > 0);
2065    }
2066
2067    #[tokio::test]
2068    async fn prefetches_output_files_concurrently() {
2069        let directory = tempfile::tempdir().unwrap();
2070        let (responses, output_root) = output_tree_responses(8);
2071        let (base_url, maximum_in_flight, server) =
2072            delayed_blob_server(responses, Duration::from_millis(50)).await;
2073        let agent = remote_agent_url(
2074            base_url,
2075            directory.path().join("reader"),
2076            RemoteCacheMode::ReadOnly,
2077        );
2078
2079        agent
2080            .prefetch_output_tree(agent.remote.as_deref().unwrap(), &output_root)
2081            .await
2082            .unwrap();
2083        server.await.unwrap();
2084
2085        assert!(maximum_in_flight.load(Ordering::Relaxed) > 1);
2086    }
2087
2088    #[tokio::test]
2089    #[ignore = "local remote-cache throughput benchmark"]
2090    async fn benchmark_prefetch_output_tree_latency() {
2091        let files = std::env::var("MISE_CACHE_BENCH_FILES")
2092            .ok()
2093            .and_then(|value| value.parse().ok())
2094            .unwrap_or(96);
2095        let latency = Duration::from_millis(
2096            std::env::var("MISE_CACHE_BENCH_LATENCY_MS")
2097                .ok()
2098                .and_then(|value| value.parse().ok())
2099                .unwrap_or(100),
2100        );
2101
2102        let directory = tempfile::tempdir().unwrap();
2103        let (responses, output_root) = output_tree_responses(files);
2104        let (base_url, maximum_in_flight, server) = delayed_blob_server(responses, latency).await;
2105        let agent = remote_agent_url(
2106            base_url,
2107            directory.path().join("reader"),
2108            RemoteCacheMode::ReadOnly,
2109        );
2110        let remote = agent.remote.as_deref().unwrap();
2111
2112        let started = std::time::Instant::now();
2113        agent
2114            .prefetch_output_tree(remote, &output_root)
2115            .await
2116            .unwrap();
2117        let elapsed = started.elapsed();
2118
2119        eprintln!(
2120            "prefetched {files} blobs with {} ms latency in {elapsed:?}",
2121            latency.as_millis()
2122        );
2123        server.await.unwrap();
2124        eprintln!(
2125            "maximum concurrent requests: {}",
2126            maximum_in_flight.load(Ordering::Relaxed)
2127        );
2128    }
2129
2130    fn output_tree_responses(files: usize) -> (BTreeMap<String, Vec<u8>>, CacheDigest) {
2131        let mut entries = Vec::with_capacity(files);
2132        let mut responses = BTreeMap::new();
2133        for index in 0..files {
2134            let body = format!("cached artifact {index}").into_bytes();
2135            let digest = CacheDigest::blake3(&body);
2136            entries.push(serde_json::json!({
2137                "digest": digest,
2138                "executable": false,
2139                "mode": 420,
2140                "name": format!("artifact-{index}"),
2141            }));
2142            responses.insert(blob_path(&digest), body);
2143        }
2144        let directory = canonical_json(&serde_json::json!({
2145            "directories": [],
2146            "files": entries,
2147            "symlinks": [],
2148            "version": 1,
2149        }))
2150        .unwrap();
2151        let output_root = CacheDigest::blake3(&directory);
2152        responses.insert(blob_path(&output_root), directory);
2153        (responses, output_root)
2154    }
2155
2156    async fn delayed_blob_server(
2157        responses: BTreeMap<String, Vec<u8>>,
2158        latency: Duration,
2159    ) -> (
2160        url::Url,
2161        Arc<std::sync::atomic::AtomicUsize>,
2162        tokio::task::JoinHandle<()>,
2163    ) {
2164        use std::sync::atomic::AtomicUsize;
2165        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2166
2167        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2168        let address = listener.local_addr().unwrap();
2169        let responses = Arc::new(responses);
2170        let request_count = responses.len();
2171        let in_flight = Arc::new(AtomicUsize::new(0));
2172        let maximum_in_flight = Arc::new(AtomicUsize::new(0));
2173        let observed_maximum = maximum_in_flight.clone();
2174        let server = tokio::spawn(async move {
2175            let mut requests = tokio::task::JoinSet::new();
2176            for _ in 0..request_count {
2177                let (mut socket, _) = listener.accept().await.unwrap();
2178                let responses = responses.clone();
2179                let in_flight = in_flight.clone();
2180                let maximum_in_flight = maximum_in_flight.clone();
2181                requests.spawn(async move {
2182                    let mut request = Vec::new();
2183                    loop {
2184                        let mut chunk = [0; 1024];
2185                        let size = socket.read(&mut chunk).await.unwrap();
2186                        assert!(size > 0, "client closed before sending request headers");
2187                        request.extend_from_slice(&chunk[..size]);
2188                        if request.windows(4).any(|window| window == b"\r\n\r\n") {
2189                            break;
2190                        }
2191                    }
2192                    let request = String::from_utf8_lossy(&request);
2193                    let path = request
2194                        .lines()
2195                        .next()
2196                        .and_then(|line| line.split_whitespace().nth(1))
2197                        .unwrap();
2198                    let body = responses.get(path).unwrap();
2199                    let active = in_flight.fetch_add(1, Ordering::Relaxed) + 1;
2200                    maximum_in_flight.fetch_max(active, Ordering::Relaxed);
2201                    tokio::time::sleep(latency).await;
2202                    socket
2203                        .write_all(
2204                            format!(
2205                                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
2206                                body.len()
2207                            )
2208                            .as_bytes(),
2209                        )
2210                        .await
2211                        .unwrap();
2212                    socket.write_all(body).await.unwrap();
2213                    in_flight.fetch_sub(1, Ordering::Relaxed);
2214                });
2215            }
2216            while requests.join_next().await.is_some() {}
2217        });
2218        (
2219            format!("http://{address}").parse().unwrap(),
2220            observed_maximum,
2221            server,
2222        )
2223    }
2224
2225    fn remote_agent(
2226        server: &mockito::ServerGuard,
2227        cache_dir: PathBuf,
2228        mode: RemoteCacheMode,
2229    ) -> CacheAgent {
2230        remote_agent_url(server.url().parse().unwrap(), cache_dir, mode)
2231    }
2232
2233    fn remote_agent_url(
2234        base_url: url::Url,
2235        cache_dir: PathBuf,
2236        mode: RemoteCacheMode,
2237    ) -> CacheAgent {
2238        let client = RemoteCacheClient::new(crate::RemoteCacheConfig {
2239            base_url,
2240            namespace: "test".into(),
2241            token: None,
2242            token_file: None,
2243            oidc_audience: None,
2244            connect_timeout: Duration::from_secs(1),
2245            read_timeout: Duration::from_secs(1),
2246            download_timeout: Duration::from_secs(1),
2247            retries: 0,
2248        })
2249        .unwrap();
2250        CacheAgent::new_remote(
2251            &cache_dir,
2252            "test-version",
2253            AgentRemoteCache {
2254                client,
2255                mode,
2256                staging_dir: cache_dir.join("remote"),
2257            },
2258        )
2259    }
2260
2261    fn blob_path(digest: &CacheDigest) -> String {
2262        format!(
2263            "/v1/blobs/{}/{}/{}",
2264            digest.algorithm, digest.hash, digest.size
2265        )
2266    }
2267
2268    fn action_path(digest: &CacheDigest) -> String {
2269        format!(
2270            "/v1/action-results/{}/{}/{}",
2271            digest.algorithm, digest.hash, digest.size
2272        )
2273    }
2274
2275    fn action_manifest_path(digest: &CacheDigest) -> String {
2276        format!(
2277            "/v1/action-manifests/{}/{}/{}",
2278            digest.algorithm, digest.hash, digest.size
2279        )
2280    }
2281
2282    #[tokio::test]
2283    async fn merges_overlapping_runs_into_one_task_manifest() {
2284        let directory = tempfile::tempdir().unwrap();
2285        let cache = directory.path().join("cache");
2286        let task = "d".repeat(64);
2287        let agent = CacheAgent::new(&cache, "test-version");
2288        let first_run = agent.begin_task(&task).await.unwrap();
2289        let second_run = agent.begin_task(&task).await.unwrap();
2290        assert_ne!(first_run, second_run);
2291        let first_invocation = CacheDigest::blake3(b"overlap one");
2292        let second_invocation = CacheDigest::blake3(b"overlap two");
2293        for (run, invocation) in [
2294            (&first_run, &first_invocation),
2295            (&second_run, &second_invocation),
2296        ] {
2297            assert!(matches!(
2298                agent
2299                    .respond(AgentRequest::RecordActionPrediction {
2300                        task: run.clone(),
2301                        prediction: ActionPrediction {
2302                            invocation: invocation.clone(),
2303                            action: CacheDigest::blake3(invocation.hash.as_bytes()),
2304                            adapter: "rustc".into(),
2305                            payload: "{}".into(),
2306                        },
2307                    })
2308                    .await,
2309                AgentResponse::ActionPredictionRecorded
2310            ));
2311        }
2312        agent.commit_task(&first_run).await.unwrap();
2313        agent.commit_task(&second_run).await.unwrap();
2314
2315        let next = CacheAgent::new(cache, "test-version");
2316        let run = next.begin_task(&task).await.unwrap();
2317        for invocation in [first_invocation, second_invocation] {
2318            assert!(matches!(
2319                next.respond(AgentRequest::FindActionPrediction {
2320                    task: run.clone(),
2321                    invocation,
2322                })
2323                .await,
2324                AgentResponse::ActionPrediction {
2325                    prediction: Some(_)
2326                }
2327            ));
2328        }
2329    }
2330
2331    #[test]
2332    fn keeps_local_manifest_when_remote_merge_exceeds_prediction_limit() {
2333        let task = "7".repeat(64);
2334        let prediction = |index: usize| {
2335            let digest = CacheDigest::blake3(&index.to_le_bytes());
2336            ActionPrediction {
2337                invocation: digest.clone(),
2338                action: digest,
2339                adapter: "rustc".into(),
2340                payload: "{}".into(),
2341            }
2342        };
2343        let local = TaskActionManifest {
2344            version: TASK_ACTION_MANIFEST_VERSION,
2345            task: task.clone(),
2346            predictions: (0..MAX_TASK_ACTION_PREDICTIONS).map(prediction).collect(),
2347        };
2348        let expected_first = local.predictions[0].clone();
2349        let remote = TaskActionManifest {
2350            version: TASK_ACTION_MANIFEST_VERSION,
2351            task: task.clone(),
2352            predictions: vec![prediction(MAX_TASK_ACTION_PREDICTIONS)],
2353        };
2354
2355        let (manifest, merged) = merge_remote_task_manifest(&task, remote, local);
2356        assert!(!merged);
2357        assert_eq!(manifest.predictions.len(), MAX_TASK_ACTION_PREDICTIONS);
2358        assert_eq!(manifest.predictions[0], expected_first);
2359    }
2360
2361    #[test]
2362    fn task_manifest_lock_is_shared_across_agents() {
2363        let directory = tempfile::tempdir().unwrap();
2364        let cache = directory.path().join("cache");
2365        let first = CacheAgent::new(&cache, "test-version");
2366        let second = CacheAgent::new(&cache, "test-version");
2367        let task = "8".repeat(64);
2368
2369        let first_lock = first.lock_task_manifest(&task).unwrap();
2370        let mut contender = fslock::LockFile::open(&second.task_manifest_lock_path(&task)).unwrap();
2371        assert!(!contender.try_lock().unwrap());
2372        drop(first_lock);
2373        assert!(contender.try_lock().unwrap());
2374    }
2375
2376    #[tokio::test]
2377    async fn memoizes_client_observed_executable_identities() {
2378        let directory = tempfile::tempdir().unwrap();
2379        let agent = CacheAgent::new(directory.path(), "test-version");
2380        let executable = directory.path().join("rustc");
2381        let environment = BTreeMap::from([("RUSTUP_TOOLCHAIN".into(), Some("stable".into()))]);
2382
2383        let response = agent
2384            .respond(AgentRequest::FindExecutableIdentity {
2385                executable: executable.clone(),
2386                environment: environment.clone(),
2387            })
2388            .await;
2389        assert!(matches!(
2390            response,
2391            AgentResponse::ExecutableIdentity { stdout: None }
2392        ));
2393
2394        let response = agent
2395            .respond(AgentRequest::StoreExecutableIdentity {
2396                executable: executable.clone(),
2397                environment: environment.clone(),
2398                stdout: b"rustc identity".to_vec(),
2399            })
2400            .await;
2401        assert!(matches!(
2402            response,
2403            AgentResponse::ExecutableIdentity {
2404                stdout: Some(stdout)
2405            } if stdout == b"rustc identity"
2406        ));
2407
2408        let response = agent
2409            .respond(AgentRequest::FindExecutableIdentity {
2410                executable,
2411                environment,
2412            })
2413            .await;
2414        assert!(matches!(
2415            response,
2416            AgentResponse::ExecutableIdentity {
2417                stdout: Some(stdout)
2418            } if stdout == b"rustc identity"
2419        ));
2420    }
2421
2422    #[test]
2423    fn bounds_executable_identity_entry_count() {
2424        let directory = tempfile::tempdir().unwrap();
2425        let agent = CacheAgent::new(directory.path(), "test-version");
2426        for index in 0..MAX_EXECUTABLE_IDENTITIES {
2427            agent
2428                .store_executable_identity(
2429                    directory.path().join(format!("rustc-{index}")),
2430                    BTreeMap::new(),
2431                    vec![b'x'],
2432                )
2433                .unwrap();
2434        }
2435
2436        assert!(
2437            agent
2438                .store_executable_identity(
2439                    directory.path().join("one-too-many"),
2440                    BTreeMap::new(),
2441                    vec![b'x'],
2442                )
2443                .is_err()
2444        );
2445    }
2446
2447    #[test]
2448    fn bounds_executable_identity_retained_bytes() {
2449        let directory = tempfile::tempdir().unwrap();
2450        let agent = CacheAgent::new(directory.path(), "test-version");
2451        for index in 0..MAX_EXECUTABLE_IDENTITY_BYTES / MAX_EXECUTABLE_IDENTITY_SIZE {
2452            agent
2453                .store_executable_identity(
2454                    directory.path().join(format!("rustc-{index}")),
2455                    BTreeMap::new(),
2456                    vec![b'x'; MAX_EXECUTABLE_IDENTITY_SIZE],
2457                )
2458                .unwrap();
2459        }
2460
2461        assert!(
2462            agent
2463                .store_executable_identity(
2464                    directory.path().join("one-byte-too-many"),
2465                    BTreeMap::new(),
2466                    vec![b'x'],
2467                )
2468                .is_err()
2469        );
2470    }
2471
2472    #[tokio::test]
2473    async fn version_skew_is_a_handshake_miss() {
2474        let directory = tempfile::tempdir().unwrap();
2475        let agent = CacheAgent::new(directory.path(), "agent-version");
2476        let (mut client, server) = tokio::io::duplex(1024);
2477        let task = tokio::spawn(async move { agent.handle_connection(server).await });
2478        let request = AgentRequest::Hello {
2479            protocol: AGENT_PROTOCOL_VERSION,
2480            client_version: "other-version".into(),
2481        };
2482        let mut encoded = serde_json::to_vec(&request).unwrap();
2483        encoded.push(b'\n');
2484        client.write_all(&encoded).await.unwrap();
2485        let mut response = String::new();
2486        BufReader::new(&mut client)
2487            .read_line(&mut response)
2488            .await
2489            .unwrap();
2490
2491        assert!(matches!(
2492            serde_json::from_str(&response).unwrap(),
2493            AgentResponse::Error { .. }
2494        ));
2495        task.await.unwrap().unwrap();
2496    }
2497}