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