Skip to main content

mbx_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::{FutureExt, StreamExt, future::BoxFuture, 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 std::time::{Duration, Instant};
16use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
17
18const MAX_EXECUTABLE_IDENTITIES: usize = 64;
19const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
20const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
21const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
22const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
23const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
24const MAX_REMOTE_TRANSFERS: usize = 64;
25const MAX_PREFETCH_TRANSFERS: usize = 48;
26const MAX_PREFETCH_ACTION_BATCH: usize = 256;
27const PREFETCH_ACTION_BATCH_DELAY: Duration = Duration::from_millis(5);
28const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
29const MAX_PREFETCH_OBJECTS_PER_WAVE: usize = 100_000;
30
31/// Remote action-cache access owned by one task session.
32pub struct AgentRemoteCache {
33    pub client: RemoteCacheClient,
34    pub mode: RemoteCacheMode,
35    pub staging_dir: PathBuf,
36}
37
38/// Wire protocol version used between an in-process cache agent and its shims.
39pub const AGENT_PROTOCOL_VERSION: u8 = 1;
40/// Largest single protocol request the agent will read.
41///
42/// Requests are small JSON objects; the largest legitimate ones carry an output
43/// tree or a batch of digests, which stay far below this.
44const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024;
45
46/// A request accepted by the task-scoped cache agent.
47#[derive(Debug, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum AgentRequest {
50    Hello {
51        protocol: u8,
52        client_version: String,
53    },
54    /// Resolve a blob to a session-verified local CAS path.
55    FindBlob {
56        digest: CacheDigest,
57    },
58    /// Resolve blobs to session-verified local CAS paths.
59    FindBlobs {
60        digests: Vec<CacheDigest>,
61    },
62    StoreBlob {
63        digest: CacheDigest,
64        source: PathBuf,
65    },
66    FindActionResult {
67        action: CacheDigest,
68    },
69    RecordActionHit {
70        action: CacheDigest,
71        restore: RestoreStats,
72    },
73    /// A compilation the adapter declined to cache, grouped by reason.
74    RecordBypass {
75        kind: String,
76    },
77    /// A compilation the adapter could not look up, having no key to look up
78    /// with. Distinct from a bypass: these are cached once compiled.
79    RecordUnconsulted,
80    RecordActionVerification {
81        matched: bool,
82        restore: RestoreStats,
83    },
84    StoreActionResult {
85        result: RemoteActionResult,
86    },
87    FindActionPrediction {
88        task: String,
89        invocation: CacheDigest,
90    },
91    RecordActionPrediction {
92        task: String,
93        prediction: ActionPrediction,
94    },
95    FindExecutableIdentity {
96        executable: PathBuf,
97        environment: BTreeMap<String, Option<String>>,
98    },
99    StoreExecutableIdentity {
100        executable: PathBuf,
101        environment: BTreeMap<String, Option<String>>,
102        stdout: Vec<u8>,
103    },
104}
105
106/// Local output restoration work performed by one action-cache adapter hit.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct RestoreStats {
110    /// Cumulative time spent materializing and validating output files.
111    pub duration_ns: u64,
112    /// Number of compiler output files restored.
113    pub output_files: u64,
114    /// Declared size of compiler output files restored.
115    pub output_bytes: u64,
116}
117
118/// A response returned by the task-scoped cache agent.
119#[derive(Debug, Serialize, Deserialize)]
120#[serde(tag = "type", rename_all = "snake_case")]
121pub enum AgentResponse {
122    Hello {
123        protocol: u8,
124        agent_version: String,
125    },
126    /// A local CAS path already verified against the requested digest.
127    Blob {
128        path: Option<PathBuf>,
129    },
130    /// Local CAS paths already verified against the requested digests.
131    Blobs {
132        paths: Vec<Option<PathBuf>>,
133    },
134    Stored {
135        path: PathBuf,
136    },
137    ActionResult {
138        result: Option<RemoteActionResult>,
139    },
140    ActionHitRecorded,
141    ActionVerificationRecorded,
142    BypassRecorded,
143    UnconsultedRecorded,
144    ActionStored {
145        path: PathBuf,
146    },
147    ActionPrediction {
148        prediction: Option<ActionPrediction>,
149    },
150    ActionPredictionRecorded,
151    ExecutableIdentity {
152        stdout: Option<Vec<u8>>,
153    },
154    Error {
155        message: String,
156    },
157}
158
159/// Aggregate cache activity for one task session.
160#[derive(Debug, Clone, Default, PartialEq, Eq)]
161pub struct AgentStats {
162    /// End-to-end lifetime of the task-scoped cache session.
163    pub session_duration_ns: u64,
164    /// Number of action-result lookups.
165    pub lookups: u64,
166    /// Compilations no action-result lookup was possible for, because no usable
167    /// action key was available.
168    ///
169    /// Counted separately from a miss, which is a lookup that found nothing.
170    /// Both compile, but only a miss says a lookup happened.
171    pub unconsulted: u64,
172    /// Number of lookups that found a valid local action result.
173    pub hits: u64,
174    /// Number of newly stored content-addressed objects.
175    pub stores: u64,
176    /// Total size of newly stored objects.
177    pub stored_bytes: u64,
178    /// Number of cache hits compiled again for qualification.
179    pub verifications: u64,
180    /// Number of qualification builds that diverged from the cached result.
181    pub divergences: u64,
182    /// CAS payload bytes downloaded from the remote cache.
183    pub downloaded_bytes: u64,
184    /// CAS payload bytes uploaded to the remote cache.
185    pub uploaded_bytes: u64,
186    /// Complete actions staged before an adapter requested them.
187    pub prefetched_actions: u64,
188    /// Compilations that were not cacheable, counted by reason.
189    pub bypasses: BTreeMap<String, u64>,
190    /// Number of task manifest requests made to the remote cache.
191    pub remote_manifest_lookups: u64,
192    /// Cumulative time spent requesting remote task manifests.
193    pub remote_manifest_lookup_duration_ns: u64,
194    /// Number of action-result requests made to the remote cache.
195    pub remote_action_lookups: u64,
196    /// Cumulative time spent requesting remote action results.
197    pub remote_action_lookup_duration_ns: u64,
198    /// Number of blob requests made to the remote cache.
199    pub remote_blob_requests: u64,
200    /// Number of packed blob requests made to the remote cache.
201    pub remote_blob_pack_requests: u64,
202    /// Number of verified blobs received through packed responses.
203    pub remote_blob_pack_blobs: u64,
204    /// Cumulative time spent downloading and verifying remote blobs.
205    pub remote_blob_transfer_duration_ns: u64,
206    /// Cumulative time spent ingesting downloaded blobs into the local CAS.
207    pub local_cas_write_duration_ns: u64,
208    /// Number of speculative prefetch runs started for task manifests.
209    pub prefetch_runs: u64,
210    /// Cumulative wall time of speculative task-manifest prefetch runs.
211    pub prefetch_duration_ns: u64,
212    /// Cumulative time spent staging or materializing and validating cached outputs.
213    pub materialization_duration_ns: u64,
214    /// Number of compiler output files restored from action hits.
215    pub restored_output_files: u64,
216    /// Declared size of compiler output files restored from action hits.
217    pub restored_output_bytes: u64,
218}
219
220/// Adapter-owned data needed to reconstruct an action before fresh dependency
221/// discovery is available.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct ActionPrediction {
225    pub invocation: CacheDigest,
226    pub action: CacheDigest,
227    pub adapter: String,
228    pub payload: String,
229}
230
231#[derive(Default)]
232struct AtomicAgentStats {
233    lookups: AtomicU64,
234    unconsulted: AtomicU64,
235    hits: AtomicU64,
236    stores: AtomicU64,
237    stored_bytes: AtomicU64,
238    verifications: AtomicU64,
239    divergences: AtomicU64,
240    downloaded_bytes: AtomicU64,
241    uploaded_bytes: AtomicU64,
242    prefetched_actions: AtomicU64,
243    remote_manifest_lookups: AtomicU64,
244    remote_manifest_lookup_duration_ns: AtomicU64,
245    remote_action_lookups: AtomicU64,
246    remote_action_lookup_duration_ns: AtomicU64,
247    remote_blob_requests: AtomicU64,
248    remote_blob_pack_requests: AtomicU64,
249    remote_blob_pack_blobs: AtomicU64,
250    remote_blob_transfer_duration_ns: AtomicU64,
251    local_cas_write_duration_ns: AtomicU64,
252    prefetch_runs: AtomicU64,
253    prefetch_duration_ns: AtomicU64,
254    materialization_duration_ns: AtomicU64,
255    bypasses: Mutex<BTreeMap<String, u64>>,
256    restored_output_files: AtomicU64,
257    restored_output_bytes: AtomicU64,
258}
259
260struct AtomicDurationTimer<'a> {
261    started: Instant,
262    target: &'a AtomicU64,
263}
264
265impl<'a> AtomicDurationTimer<'a> {
266    fn start(target: &'a AtomicU64) -> Self {
267        Self {
268            started: Instant::now(),
269            target,
270        }
271    }
272}
273
274impl Drop for AtomicDurationTimer<'_> {
275    fn drop(&mut self) {
276        atomic_saturating_add(self.target, duration_ns(self.started));
277    }
278}
279
280fn duration_ns(started: Instant) -> u64 {
281    started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX)
282}
283
284fn atomic_saturating_add(target: &AtomicU64, value: u64) {
285    let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
286        Some(current.saturating_add(value))
287    });
288}
289
290fn queue_prefetch_digest(
291    verified: &BTreeMap<CacheDigest, PathBuf>,
292    pending: &mut BTreeMap<CacheDigest, ()>,
293    digest: CacheDigest,
294) {
295    if verified.contains_key(&digest) || pending.contains_key(&digest) {
296        return;
297    }
298    pending.insert(digest, ());
299}
300
301fn queue_prefetch_directory(
302    seen: &BTreeMap<CacheDigest, ()>,
303    pending: &mut BTreeMap<CacheDigest, ()>,
304    digest: CacheDigest,
305    limit: usize,
306) -> bool {
307    if seen.contains_key(&digest) || pending.contains_key(&digest) {
308        return true;
309    }
310    if seen.len().saturating_add(pending.len()) >= limit {
311        return false;
312    }
313    pending.insert(digest, ());
314    true
315}
316
317/// Shared state for an agent hosted by the process that owns a build session.
318///
319/// Transport listeners deliberately live in the embedder so the session
320/// lifecycle owns them. This type only contains ecosystem-independent CAS and
321/// protocol logic.
322#[derive(Clone)]
323pub struct CacheAgent {
324    cas: LocalCas,
325    actions: LocalActionCache,
326    verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, PathBuf>>>,
327    version: Arc<str>,
328    write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
329    action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
330    stats: Arc<AtomicAgentStats>,
331    executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
332    manifest_dir: Arc<PathBuf>,
333    task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
334    next_task_run: Arc<AtomicU64>,
335    manifest_write_lock: Arc<Mutex<()>>,
336    remote: Option<Arc<RemoteCacheClient>>,
337    remote_mode: RemoteCacheMode,
338    remote_staging_dir: Arc<PathBuf>,
339    pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
340    remote_transfers: Arc<tokio::sync::Semaphore>,
341    prefetch_transfers: Arc<tokio::sync::Semaphore>,
342    prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
346#[serde(deny_unknown_fields)]
347struct TaskActionManifest {
348    version: u8,
349    task: String,
350    predictions: Vec<ActionPrediction>,
351}
352
353#[derive(Serialize)]
354struct TaskActionManifestSelector<'a> {
355    version: u8,
356    kind: &'static str,
357    task: &'a str,
358}
359
360#[derive(Debug, Clone, Default)]
361struct TaskActionState {
362    manifest: String,
363    baseline_loaded: bool,
364    predictions: BTreeMap<CacheDigest, ActionPrediction>,
365    remote_etag: Option<String>,
366}
367
368struct PrefetchedAction {
369    adapter: String,
370    result: RemoteActionResult,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
374struct ExecutableIdentityKey {
375    executable: PathBuf,
376    environment: BTreeMap<String, Option<String>>,
377}
378
379impl CacheAgent {
380    /// Create an agent backed by the cache rooted at `cache_dir`.
381    pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
382        Self::build(cache_dir.into(), version.into(), None)
383    }
384
385    /// Create an agent with local-first access to a remote action cache.
386    pub fn new_remote(
387        cache_dir: impl Into<PathBuf>,
388        version: impl Into<Arc<str>>,
389        remote: AgentRemoteCache,
390    ) -> Self {
391        Self::build(cache_dir.into(), version.into(), Some(remote))
392    }
393
394    fn build(cache_dir: PathBuf, version: Arc<str>, remote: Option<AgentRemoteCache>) -> Self {
395        let remote_mode = remote
396            .as_ref()
397            .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
398        let remote_staging_dir = remote.as_ref().map_or_else(
399            || cache_dir.join("remote"),
400            |remote| remote.staging_dir.clone(),
401        );
402        let remote = remote.map(|remote| Arc::new(remote.client));
403        Self {
404            cas: LocalCas::new(cache_dir.clone()),
405            actions: LocalActionCache::new(cache_dir.clone()),
406            verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
407            version,
408            write_locks: Arc::new(Mutex::new(BTreeMap::new())),
409            action_locks: Arc::new(Mutex::new(BTreeMap::new())),
410            stats: Arc::new(AtomicAgentStats::default()),
411            executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
412            manifest_dir: Arc::new(cache_dir.join("task-manifests").join("v1")),
413            task_actions: Arc::new(Mutex::new(BTreeMap::new())),
414            next_task_run: Arc::new(AtomicU64::new(0)),
415            manifest_write_lock: Arc::new(Mutex::new(())),
416            remote,
417            remote_mode,
418            remote_staging_dir: Arc::new(remote_staging_dir),
419            pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
420            remote_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS)),
421            prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
422            prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
423        }
424    }
425
426    /// Load the last successful action manifest for a task into this session.
427    pub async fn begin_task(&self, task: &str) -> Result<String> {
428        validate_task_identity(task)?;
429        let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
430            match self.get_remote_task_manifest(task).await {
431                Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
432                Ok(None) => (None, None),
433                Err(error) => {
434                    warn!("remote task action manifest lookup failed for {task}: {error}");
435                    (None, None)
436                }
437            }
438        } else {
439            (None, None)
440        };
441        let manifest = {
442            let _write_guard = self.manifest_write_lock.lock().unwrap();
443            let _file_guard = self.lock_task_manifest(task)?;
444            let local_manifest = self.load_task_manifest(task)?;
445            let manifest = match (remote_manifest, local_manifest) {
446                (Some(remote), Some(local)) => {
447                    let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
448                    if !merged {
449                        remote_etag = None;
450                    }
451                    Some(manifest)
452                }
453                (Some(remote), None) => Some(remote),
454                (None, local) => local,
455            };
456            if let Some(manifest) = &manifest {
457                self.persist_task_manifest(manifest)?;
458            }
459            manifest
460        };
461        let state = if let Some(manifest) = manifest {
462            TaskActionState {
463                manifest: task.to_string(),
464                baseline_loaded: true,
465                predictions: manifest
466                    .predictions
467                    .into_iter()
468                    .map(|prediction| (prediction.invocation.clone(), prediction))
469                    .collect(),
470                remote_etag,
471            }
472        } else {
473            TaskActionState {
474                manifest: task.to_string(),
475                baseline_loaded: true,
476                remote_etag,
477                ..TaskActionState::default()
478            }
479        };
480        let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
481        let run =
482            CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
483                .hash;
484        let predictions = state.predictions.values().cloned().collect();
485        self.task_actions.lock().unwrap().insert(run.clone(), state);
486        self.spawn_prefetch_predictions(predictions);
487        Ok(run)
488    }
489
490    /// Cancel speculative downloads before the owning session exits.
491    pub async fn cancel_prefetches(&self) {
492        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
493        for task in &tasks {
494            task.abort();
495        }
496        for task in tasks {
497            if let Err(error) = task.await
498                && !error.is_cancelled()
499            {
500                warn!("remote action prefetch task failed: {error}");
501            }
502        }
503    }
504
505    #[cfg(test)]
506    async fn wait_for_prefetches(&self) {
507        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
508        for task in tasks {
509            if let Err(error) = task.await {
510                warn!("remote action prefetch task failed: {error}");
511            }
512        }
513    }
514
515    /// Atomically publish the candidate manifest collected by a successful task.
516    pub async fn commit_task(&self, run: &str) -> Result<()> {
517        validate_task_identity(run)?;
518        let state = self
519            .task_actions
520            .lock()
521            .unwrap()
522            .get(run)
523            .cloned()
524            .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
525        if !state.baseline_loaded {
526            bail!("task action manifest baseline was not loaded");
527        }
528        let task = state.manifest;
529        validate_task_identity(&task)?;
530        let manifest = {
531            let _write_guard = self.manifest_write_lock.lock().unwrap();
532            let _file_guard = self.lock_task_manifest(&task)?;
533            let mut predictions = self
534                .load_task_manifest(&task)?
535                .map(|manifest| {
536                    manifest
537                        .predictions
538                        .into_iter()
539                        .map(|prediction| (prediction.invocation.clone(), prediction))
540                        .collect::<BTreeMap<_, _>>()
541                })
542                .unwrap_or_default();
543            predictions.extend(state.predictions);
544            let manifest = TaskActionManifest {
545                version: TASK_ACTION_MANIFEST_VERSION,
546                task: task.clone(),
547                predictions: predictions.into_values().collect(),
548            };
549            validate_task_manifest(&manifest, &task)?;
550            self.persist_task_manifest(&manifest)?;
551            manifest
552        };
553        self.task_actions.lock().unwrap().remove(run);
554        if self.remote_mode.writes() {
555            match self
556                .put_remote_task_manifest(&task, manifest, state.remote_etag)
557                .await
558            {
559                Ok(remote_manifest) => {
560                    let _write_guard = self.manifest_write_lock.lock().unwrap();
561                    let reconciliation = (|| {
562                        let _file_guard = self.lock_task_manifest(&task)?;
563                        let manifest = match self.load_task_manifest(&task)? {
564                            Some(local) => {
565                                merge_remote_task_manifest(&task, remote_manifest, local).0
566                            }
567                            None => remote_manifest,
568                        };
569                        self.persist_task_manifest(&manifest)
570                    })();
571                    if let Err(error) = reconciliation {
572                        warn!(
573                            "remote task action manifest reconciliation failed for {task}: {error}"
574                        );
575                    }
576                }
577                Err(error) => {
578                    warn!("remote task action manifest upload failed for {task}: {error}");
579                }
580            }
581        }
582        Ok(())
583    }
584
585    fn task_manifest_path(&self, task: &str) -> PathBuf {
586        self.manifest_dir.join(format!("{task}.json"))
587    }
588
589    fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
590        self.manifest_dir.join("locks").join(format!("{task}.lock"))
591    }
592
593    fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
594        let path = self.task_manifest_lock_path(task);
595        fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
596        let mut lock = fslock::LockFile::open(&path)?;
597        lock.lock()?;
598        Ok(lock)
599    }
600
601    fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
602        match fs::read(self.task_manifest_path(task)) {
603            Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
604            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
605            Err(error) => Err(error.into()),
606        }
607    }
608
609    fn parse_task_manifest(
610        &self,
611        task: &str,
612        contents: &[u8],
613        require_canonical: bool,
614    ) -> Result<TaskActionManifest> {
615        let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
616        validate_task_manifest(&manifest, task)?;
617        if require_canonical && canonical_json(&manifest)? != contents {
618            bail!("task action manifest is not canonical JSON");
619        }
620        Ok(manifest)
621    }
622
623    fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
624        let bytes = canonical_json(&TaskActionManifestSelector {
625            version: 1,
626            kind: "task_action_manifest",
627            task,
628        })?;
629        let digest = CacheDigest::blake3(&bytes);
630        Ok((bytes, digest))
631    }
632
633    fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
634        let bytes = canonical_json(manifest)?;
635        fs::create_dir_all(self.manifest_dir.as_path())?;
636        let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
637        std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
638        temporary.as_file_mut().sync_all()?;
639        temporary
640            .persist(self.task_manifest_path(&manifest.task))
641            .map_err(|error| error.error)?;
642        Ok(())
643    }
644
645    async fn get_remote_task_manifest(
646        &self,
647        task: &str,
648    ) -> Result<Option<(TaskActionManifest, String)>> {
649        let Some(remote) = &self.remote else {
650            return Ok(None);
651        };
652        let (_, selector) = Self::task_manifest_selector(task)?;
653        let _permit = self.remote_transfers.acquire().await?;
654        self.stats
655            .remote_manifest_lookups
656            .fetch_add(1, Ordering::Relaxed);
657        let _timer = AtomicDurationTimer::start(&self.stats.remote_manifest_lookup_duration_ns);
658        let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
659            return Ok(None);
660        };
661        let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
662        Ok(Some((manifest, remote_manifest.etag)))
663    }
664
665    async fn put_remote_task_manifest(
666        &self,
667        task: &str,
668        mut manifest: TaskActionManifest,
669        mut expected_etag: Option<String>,
670    ) -> Result<TaskActionManifest> {
671        let Some(remote) = &self.remote else {
672            return Ok(manifest);
673        };
674        let (_, selector) = Self::task_manifest_selector(task)?;
675        for _ in 0..4 {
676            let bytes = canonical_json(&manifest)?;
677            let outcome = {
678                let _permit = self.remote_transfers.acquire().await?;
679                remote
680                    .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
681                    .await?
682            };
683            match outcome {
684                ManifestPutOutcome::Stored => return Ok(manifest),
685                ManifestPutOutcome::PreconditionFailed => {
686                    let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
687                    else {
688                        expected_etag = None;
689                        continue;
690                    };
691                    manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
692                    expected_etag = Some(etag);
693                }
694            }
695        }
696        bail!("remote task action manifest changed too frequently")
697    }
698
699    /// Return a snapshot of this session's cache activity.
700    pub fn stats(&self) -> AgentStats {
701        AgentStats {
702            session_duration_ns: 0,
703            lookups: self.stats.lookups.load(Ordering::Relaxed),
704            unconsulted: self.stats.unconsulted.load(Ordering::Relaxed),
705            hits: self.stats.hits.load(Ordering::Relaxed),
706            stores: self.stats.stores.load(Ordering::Relaxed),
707            stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
708            verifications: self.stats.verifications.load(Ordering::Relaxed),
709            divergences: self.stats.divergences.load(Ordering::Relaxed),
710            downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
711            uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
712            prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
713            bypasses: self.stats.bypasses.lock().unwrap().clone(),
714            remote_manifest_lookups: self.stats.remote_manifest_lookups.load(Ordering::Relaxed),
715            remote_manifest_lookup_duration_ns: self
716                .stats
717                .remote_manifest_lookup_duration_ns
718                .load(Ordering::Relaxed),
719            remote_action_lookups: self.stats.remote_action_lookups.load(Ordering::Relaxed),
720            remote_action_lookup_duration_ns: self
721                .stats
722                .remote_action_lookup_duration_ns
723                .load(Ordering::Relaxed),
724            remote_blob_requests: self.stats.remote_blob_requests.load(Ordering::Relaxed),
725            remote_blob_pack_requests: self.stats.remote_blob_pack_requests.load(Ordering::Relaxed),
726            remote_blob_pack_blobs: self.stats.remote_blob_pack_blobs.load(Ordering::Relaxed),
727            remote_blob_transfer_duration_ns: self
728                .stats
729                .remote_blob_transfer_duration_ns
730                .load(Ordering::Relaxed),
731            local_cas_write_duration_ns: self
732                .stats
733                .local_cas_write_duration_ns
734                .load(Ordering::Relaxed),
735            prefetch_runs: self.stats.prefetch_runs.load(Ordering::Relaxed),
736            prefetch_duration_ns: self.stats.prefetch_duration_ns.load(Ordering::Relaxed),
737            materialization_duration_ns: self
738                .stats
739                .materialization_duration_ns
740                .load(Ordering::Relaxed),
741            restored_output_files: self.stats.restored_output_files.load(Ordering::Relaxed),
742            restored_output_bytes: self.stats.restored_output_bytes.load(Ordering::Relaxed),
743        }
744    }
745
746    fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
747        Self::digest_lock(&self.write_locks, digest)
748    }
749
750    fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
751        Self::digest_lock(&self.action_locks, digest)
752    }
753
754    fn digest_lock(
755        locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
756        digest: &CacheDigest,
757    ) -> Arc<tokio::sync::Mutex<()>> {
758        let mut locks = locks.lock().unwrap();
759        locks.retain(|_, lock| lock.strong_count() > 0);
760        if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
761            return lock;
762        }
763        let lock = Arc::new(tokio::sync::Mutex::new(()));
764        locks.insert(digest.clone(), Arc::downgrade(&lock));
765        lock
766    }
767
768    fn spawn_prefetch_predictions(&self, predictions: Vec<ActionPrediction>) {
769        if predictions.is_empty() || !self.remote_mode.reads() || self.remote.is_none() {
770            return;
771        }
772        let agent = self.clone();
773        let task = tokio::spawn(async move {
774            agent.prefetch_predictions(predictions.iter()).await;
775        });
776        self.prefetch_tasks.lock().unwrap().push(task);
777    }
778
779    async fn prefetch_predictions<'a>(
780        &self,
781        predictions: impl Iterator<Item = &'a ActionPrediction>,
782    ) {
783        if !self.remote_mode.reads() || self.remote.is_none() {
784            return;
785        }
786        self.stats.prefetch_runs.fetch_add(1, Ordering::Relaxed);
787        let _timer = AtomicDurationTimer::start(&self.stats.prefetch_duration_ns);
788        let mut actions = BTreeMap::new();
789        for prediction in predictions {
790            actions
791                .entry(prediction.action.clone())
792                .or_insert_with(|| prediction.adapter.clone());
793        }
794        let mut actions = actions.into_iter();
795        let mut tasks = tokio::task::JoinSet::new();
796        for _ in 0..MAX_PREFETCH_TRANSFERS {
797            let Some((action, adapter)) = actions.next() else {
798                break;
799            };
800            let agent = self.clone();
801            tasks.spawn(async move { agent.resolve_prefetch_action(action, adapter).await });
802        }
803        let mut resolved = Vec::new();
804        while !tasks.is_empty() {
805            let result = if resolved.is_empty() {
806                tasks.join_next().await
807            } else {
808                match tokio::time::timeout(PREFETCH_ACTION_BATCH_DELAY, tasks.join_next()).await {
809                    Ok(result) => result,
810                    Err(_) => {
811                        self.prefetch_resolved_actions(std::mem::take(&mut resolved))
812                            .await;
813                        continue;
814                    }
815                }
816            };
817            let Some(result) = result else {
818                break;
819            };
820            match result {
821                Ok(Ok(Some(action))) => resolved.push(action),
822                Ok(Ok(None)) => {}
823                Ok(Err(error)) => warn!("remote action prefetch failed: {error}"),
824                Err(error) => warn!("remote action prefetch task failed: {error}"),
825            }
826            if let Some((action, adapter)) = actions.next() {
827                let agent = self.clone();
828                tasks.spawn(async move { agent.resolve_prefetch_action(action, adapter).await });
829            }
830            if resolved.len() == MAX_PREFETCH_ACTION_BATCH {
831                self.prefetch_resolved_actions(std::mem::take(&mut resolved))
832                    .await;
833            }
834        }
835        if !resolved.is_empty() {
836            self.prefetch_resolved_actions(resolved).await;
837        }
838    }
839
840    #[cfg(test)]
841    async fn prefetch_action(&self, action: CacheDigest, adapter: String) -> Result<()> {
842        if let Some(action) = self.resolve_prefetch_action(action, adapter).await? {
843            self.prefetch_resolved_actions(vec![action]).await;
844        }
845        Ok(())
846    }
847
848    async fn resolve_prefetch_action(
849        &self,
850        action: CacheDigest,
851        adapter: String,
852    ) -> Result<Option<PrefetchedAction>> {
853        let remote = self
854            .remote
855            .as_ref()
856            .ok_or_else(|| eyre::eyre!("remote cache is not configured"))?;
857        let result = {
858            let lock = self.action_lock(&action);
859            let _guard = lock.lock().await;
860            if self.actions.find(&action)?.is_some() {
861                return Ok(None);
862            }
863            if let Some(result) = self
864                .pending_remote_actions
865                .lock()
866                .unwrap()
867                .get(&action)
868                .cloned()
869            {
870                result
871            } else {
872                let _prefetch_permit = self.prefetch_transfers.acquire().await?;
873                let result = {
874                    let _permit = self.remote_transfers.acquire().await?;
875                    self.get_remote_action_result(remote, &action).await?
876                };
877                let Some(result) = result else {
878                    return Ok(None);
879                };
880                self.pending_remote_actions
881                    .lock()
882                    .unwrap()
883                    .insert(action.clone(), result.clone());
884                result
885            }
886        };
887        Ok(Some(PrefetchedAction { adapter, result }))
888    }
889
890    fn prefetch_resolved_actions(&self, actions: Vec<PrefetchedAction>) -> BoxFuture<'_, ()> {
891        self.prefetch_resolved_actions_inner(actions).boxed()
892    }
893
894    async fn prefetch_resolved_actions_inner(&self, actions: Vec<PrefetchedAction>) {
895        let Some(remote) = self.remote.as_deref() else {
896            return;
897        };
898        if actions.is_empty() {
899            return;
900        }
901
902        let mut top_level = BTreeMap::new();
903        for action in &actions {
904            for digest in [
905                Some(&action.result.action),
906                action.result.metadata.as_ref(),
907                action.result.output_root.as_ref(),
908            ]
909            .into_iter()
910            .flatten()
911            {
912                top_level.insert(digest.clone(), ());
913            }
914        }
915        let mut verified = self
916            .fetch_remote_blobs(
917                remote,
918                top_level.into_keys().collect(),
919                Some(&self.prefetch_transfers),
920            )
921            .await;
922
923        let mut next = BTreeMap::new();
924        let mut pending_directories = BTreeMap::new();
925        let mut parsed_directories = BTreeMap::new();
926        let mut rustc_metadata = BTreeMap::new();
927        for action in &actions {
928            if action.adapter == "rustc"
929                && let Some(metadata_digest) = &action.result.metadata
930            {
931                match verified
932                    .get(metadata_digest)
933                    .ok_or_else(|| eyre::eyre!("remote rustc action metadata is missing"))
934                    .and_then(|path| Self::parse_rustc_metadata(path))
935                {
936                    Ok(metadata) => {
937                        queue_prefetch_digest(&verified, &mut next, metadata.stdout.clone());
938                        queue_prefetch_digest(&verified, &mut next, metadata.stderr.clone());
939                        rustc_metadata.insert(metadata_digest.clone(), metadata);
940                    }
941                    Err(error) => warn!(
942                        "remote rustc action metadata prefetch failed for {}: {error}",
943                        action.result.action.hash
944                    ),
945                }
946            }
947            if let Some(output_root) = &action.result.output_root {
948                pending_directories.insert(output_root.clone(), ());
949            }
950        }
951
952        let mut seen_directories = BTreeMap::new();
953        loop {
954            let mut following = BTreeMap::new();
955            let mut directory_limit_exceeded = false;
956            for digest in pending_directories.into_keys() {
957                following.remove(&digest);
958                if seen_directories.insert(digest.clone(), ()).is_some() {
959                    continue;
960                }
961                if seen_directories.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
962                    warn!("remote action output tree is too large to prefetch");
963                    following.clear();
964                    break;
965                }
966                match verified
967                    .get(&digest)
968                    .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))
969                    .and_then(|path| Self::parse_cache_directory(path))
970                {
971                    Ok(directory) => {
972                        for file in &directory.files {
973                            queue_prefetch_digest(&verified, &mut next, file.digest.clone());
974                            if next.len() >= MAX_PREFETCH_OBJECTS_PER_WAVE {
975                                self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
976                                    .await;
977                            }
978                        }
979                        for child in &directory.directories {
980                            if !queue_prefetch_directory(
981                                &seen_directories,
982                                &mut following,
983                                child.digest.clone(),
984                                MAX_PREFETCH_DIRECTORY_OBJECTS,
985                            ) {
986                                warn!("remote action output tree is too large to prefetch");
987                                directory_limit_exceeded = true;
988                                break;
989                            }
990                            queue_prefetch_digest(&verified, &mut next, child.digest.clone());
991                            if next.len() >= MAX_PREFETCH_OBJECTS_PER_WAVE {
992                                self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
993                                    .await;
994                            }
995                        }
996                        parsed_directories.insert(digest, directory);
997                    }
998                    Err(error) => warn!(
999                        "remote action output directory prefetch failed for {}: {error}",
1000                        digest.hash
1001                    ),
1002                }
1003                if directory_limit_exceeded {
1004                    following.clear();
1005                    break;
1006                }
1007            }
1008            self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
1009                .await;
1010            if following.is_empty() {
1011                break;
1012            }
1013            pending_directories = following;
1014        }
1015
1016        for action in actions {
1017            match Self::validate_prefetched_action(
1018                &action,
1019                &verified,
1020                &rustc_metadata,
1021                &parsed_directories,
1022            ) {
1023                Ok(()) => {
1024                    if let Err(error) = self.actions.store(&action.result) {
1025                        warn!(
1026                            "remote action prefetch could not publish {}: {error}",
1027                            action.result.action.hash
1028                        );
1029                        continue;
1030                    }
1031                    self.pending_remote_actions
1032                        .lock()
1033                        .unwrap()
1034                        .remove(&action.result.action);
1035                    self.stats
1036                        .prefetched_actions
1037                        .fetch_add(1, Ordering::Relaxed);
1038                }
1039                Err(error) => warn!(
1040                    "remote action prefetch was incomplete for {}: {error}",
1041                    action.result.action.hash
1042                ),
1043            }
1044        }
1045    }
1046
1047    async fn flush_prefetch_digest_batch(
1048        &self,
1049        remote: &RemoteCacheClient,
1050        verified: &mut BTreeMap<CacheDigest, PathBuf>,
1051        pending: &mut BTreeMap<CacheDigest, ()>,
1052    ) {
1053        if pending.is_empty() {
1054            return;
1055        }
1056        let digests = std::mem::take(pending).into_keys().collect();
1057        verified.extend(
1058            self.fetch_remote_blobs(remote, digests, Some(&self.prefetch_transfers))
1059                .await,
1060        );
1061    }
1062
1063    async fn fetch_remote_blobs(
1064        &self,
1065        remote: &RemoteCacheClient,
1066        digests: Vec<CacheDigest>,
1067        prefetch_limit: Option<&tokio::sync::Semaphore>,
1068    ) -> BTreeMap<CacheDigest, PathBuf> {
1069        let mut verified = BTreeMap::new();
1070        let mut missing = BTreeMap::new();
1071        for digest in digests {
1072            match self.find_verified_blob(&digest) {
1073                Ok(Some(path)) => {
1074                    verified.insert(digest, path);
1075                }
1076                Ok(None) => {
1077                    missing.insert(digest, ());
1078                }
1079                Err(error) => warn!(
1080                    "local cache blob lookup failed for {}: {error}",
1081                    digest.hash
1082                ),
1083            }
1084        }
1085        if missing.is_empty() {
1086            return verified;
1087        }
1088
1089        let mut pack_candidates = missing.clone();
1090        while !pack_candidates.is_empty() {
1091            let requested = pack_candidates.keys().cloned().collect::<Vec<_>>();
1092            let (pack, transfer_duration_ns) = {
1093                let _prefetch_permit = match prefetch_limit {
1094                    Some(limit) => match limit.acquire().await {
1095                        Ok(permit) => Some(permit),
1096                        Err(error) => {
1097                            warn!(
1098                                "remote cache blob pack could not acquire prefetch limit: {error}"
1099                            );
1100                            break;
1101                        }
1102                    },
1103                    None => None,
1104                };
1105                let _transfer_permit = match self.remote_transfers.acquire().await {
1106                    Ok(permit) => permit,
1107                    Err(error) => {
1108                        warn!("remote cache blob pack could not acquire transfer limit: {error}");
1109                        break;
1110                    }
1111                };
1112                let transfer_started = Instant::now();
1113                let pack = remote
1114                    .get_blob_pack(&requested, self.remote_staging_dir.as_path())
1115                    .await;
1116                (pack, duration_ns(transfer_started))
1117            };
1118            let pack = match pack {
1119                Ok(Some(pack)) => pack,
1120                Ok(None) => break,
1121                Err(error) => {
1122                    atomic_saturating_add(
1123                        &self.stats.remote_blob_transfer_duration_ns,
1124                        transfer_duration_ns,
1125                    );
1126                    warn!(
1127                        "remote cache blob pack failed; falling back to individual blobs: {error}"
1128                    );
1129                    break;
1130                }
1131            };
1132            atomic_saturating_add(
1133                &self.stats.remote_blob_transfer_duration_ns,
1134                transfer_duration_ns,
1135            );
1136            atomic_saturating_add(&self.stats.remote_blob_pack_requests, pack.requests);
1137            atomic_saturating_add(&self.stats.remote_blob_pack_blobs, pack.blob_count);
1138            atomic_saturating_add(&self.stats.downloaded_bytes, pack.payload_bytes);
1139            if pack.requested.is_empty() {
1140                break;
1141            }
1142            for digest in &pack.requested {
1143                pack_candidates.remove(digest);
1144            }
1145            let mut ingests = stream::iter(pack.blobs.into_iter().map(|(digest, source)| {
1146                let digest_for_result = digest.clone();
1147                async move {
1148                    (
1149                        digest_for_result,
1150                        self.ingest_packed_blob(digest, source).await,
1151                    )
1152                }
1153            }))
1154            .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1155            while let Some((digest, result)) = ingests.next().await {
1156                match result {
1157                    Ok(path) => {
1158                        missing.remove(&digest);
1159                        verified.insert(digest, path);
1160                    }
1161                    Err(error) => warn!(
1162                        "remote cache packed blob ingest failed for {}: {error}",
1163                        digest.hash
1164                    ),
1165                }
1166            }
1167        }
1168
1169        let mut transfers = stream::iter(missing.into_keys().map(|digest| {
1170            let digest_for_result = digest.clone();
1171            async move {
1172                (
1173                    digest_for_result,
1174                    self.fetch_remote_blob_with_limit(remote, &digest, prefetch_limit)
1175                        .await,
1176                )
1177            }
1178        }))
1179        .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1180        while let Some((digest, result)) = transfers.next().await {
1181            match result {
1182                Ok(path) => {
1183                    verified.insert(digest, path);
1184                }
1185                Err(error) => warn!(
1186                    "remote cache blob prefetch failed for {}: {error}",
1187                    digest.hash
1188                ),
1189            }
1190        }
1191        verified
1192    }
1193
1194    async fn ingest_packed_blob(&self, digest: CacheDigest, source: PathBuf) -> Result<PathBuf> {
1195        let digest_size = digest.size;
1196        let lock = self.write_lock(&digest);
1197        let _guard = lock.lock().await;
1198        let agent = self.clone();
1199        let (path, stored, cas_duration_ns) = tokio::task::spawn_blocking(move || {
1200            if let Some(path) = agent.find_verified_blob(&digest)? {
1201                return Ok::<_, eyre::Report>((path, false, 0));
1202            }
1203            let cas_started = Instant::now();
1204            let path = agent.cas.store_verified_file(&digest, &source)?;
1205            let cas_duration_ns = duration_ns(cas_started);
1206            agent.remember_verified_blob(&digest, &path);
1207            Ok((path, true, cas_duration_ns))
1208        })
1209        .await??;
1210        atomic_saturating_add(&self.stats.local_cas_write_duration_ns, cas_duration_ns);
1211        if stored {
1212            self.stats.stores.fetch_add(1, Ordering::Relaxed);
1213            atomic_saturating_add(&self.stats.stored_bytes, digest_size);
1214        }
1215        Ok(path)
1216    }
1217
1218    fn parse_rustc_metadata(path: &Path) -> Result<RustcMetadata> {
1219        let bytes = fs::read(path)?;
1220        let metadata: RustcMetadata = serde_json::from_slice(&bytes)?;
1221        if metadata.version != 1 || metadata.kind != "rustc" || canonical_json(&metadata)? != bytes
1222        {
1223            bail!("remote rustc action metadata is invalid");
1224        }
1225        Ok(metadata)
1226    }
1227
1228    fn parse_cache_directory(path: &Path) -> Result<CacheDirectory> {
1229        let bytes = fs::read(path)?;
1230        let directory: CacheDirectory = serde_json::from_slice(&bytes)?;
1231        if directory.version != 1 || canonical_json(&directory)? != bytes {
1232            bail!("remote action output directory is invalid");
1233        }
1234        Ok(directory)
1235    }
1236
1237    #[cfg(test)]
1238    fn load_cache_directory(&self, digest: &CacheDigest) -> Result<CacheDirectory> {
1239        let path = self
1240            .find_verified_blob(digest)?
1241            .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))?;
1242        Self::parse_cache_directory(&path)
1243    }
1244
1245    fn validate_prefetched_action(
1246        action: &PrefetchedAction,
1247        verified: &BTreeMap<CacheDigest, PathBuf>,
1248        rustc_metadata: &BTreeMap<CacheDigest, RustcMetadata>,
1249        directories: &BTreeMap<CacheDigest, CacheDirectory>,
1250    ) -> Result<()> {
1251        if !verified.contains_key(&action.result.action) {
1252            bail!("remote action descriptor is missing");
1253        }
1254        if let Some(metadata) = &action.result.metadata {
1255            if action.adapter == "rustc" {
1256                let metadata = rustc_metadata
1257                    .get(metadata)
1258                    .ok_or_else(|| eyre::eyre!("remote rustc action metadata is missing"))?;
1259                for digest in [&metadata.stdout, &metadata.stderr] {
1260                    if !verified.contains_key(digest) {
1261                        bail!("remote rustc action diagnostic blob is missing");
1262                    }
1263                }
1264            } else if !verified.contains_key(metadata) {
1265                bail!("remote action metadata is missing");
1266            }
1267        }
1268        let mut pending = action
1269            .result
1270            .output_root
1271            .iter()
1272            .cloned()
1273            .collect::<Vec<_>>();
1274        let mut seen = BTreeMap::new();
1275        while let Some(digest) = pending.pop() {
1276            if seen.insert(digest.clone(), ()).is_some() {
1277                continue;
1278            }
1279            if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
1280                bail!("remote action output tree is too large");
1281            }
1282            let directory = directories
1283                .get(&digest)
1284                .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))?;
1285            for file in &directory.files {
1286                if !verified.contains_key(&file.digest) {
1287                    bail!("remote action output file is missing");
1288                }
1289            }
1290            pending.extend(
1291                directory
1292                    .directories
1293                    .iter()
1294                    .map(|directory| directory.digest.clone()),
1295            );
1296        }
1297        Ok(())
1298    }
1299
1300    #[cfg(test)]
1301    async fn prefetch_output_tree(
1302        &self,
1303        remote: &RemoteCacheClient,
1304        output_root: &CacheDigest,
1305    ) -> Result<()> {
1306        let mut pending = vec![output_root.clone()];
1307        let mut seen = BTreeMap::new();
1308        while let Some(digest) = pending.pop() {
1309            if seen.insert(digest.clone(), ()).is_some() {
1310                continue;
1311            }
1312            if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
1313                bail!("remote action output tree is too large");
1314            }
1315            self.fetch_remote_blob_with_limit(remote, &digest, Some(&self.prefetch_transfers))
1316                .await?;
1317            let directory = self.load_cache_directory(&digest)?;
1318            let mut transfers = stream::iter(directory.files.into_iter().map(|file| async move {
1319                self.fetch_remote_blob_with_limit(
1320                    remote,
1321                    &file.digest,
1322                    Some(&self.prefetch_transfers),
1323                )
1324                .await
1325                .map(|_| ())
1326            }))
1327            .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1328            while let Some(result) = transfers.next().await {
1329                result?;
1330            }
1331            pending.extend(
1332                directory
1333                    .directories
1334                    .into_iter()
1335                    .map(|directory| directory.digest),
1336            );
1337        }
1338        Ok(())
1339    }
1340
1341    async fn fetch_remote_blob(
1342        &self,
1343        remote: &RemoteCacheClient,
1344        digest: &CacheDigest,
1345    ) -> Result<PathBuf> {
1346        self.fetch_remote_blob_with_limit(remote, digest, None)
1347            .await
1348    }
1349
1350    async fn fetch_remote_blob_with_limit(
1351        &self,
1352        remote: &RemoteCacheClient,
1353        digest: &CacheDigest,
1354        prefetch_limit: Option<&tokio::sync::Semaphore>,
1355    ) -> Result<PathBuf> {
1356        let lock = self.write_lock(digest);
1357        let _guard = lock.lock().await;
1358        if let Some(path) = self.find_verified_blob(digest)? {
1359            return Ok(path);
1360        }
1361        let _prefetch_permit = match prefetch_limit {
1362            Some(limit) => Some(limit.acquire().await?),
1363            None => None,
1364        };
1365        let _permit = self.remote_transfers.acquire().await?;
1366        self.stats
1367            .remote_blob_requests
1368            .fetch_add(1, Ordering::Relaxed);
1369        let transfer_timer =
1370            AtomicDurationTimer::start(&self.stats.remote_blob_transfer_duration_ns);
1371        let temporary = remote
1372            .get_blob_file(digest, self.remote_staging_dir.as_path())
1373            .await?;
1374        drop(transfer_timer);
1375        let _cas_timer = AtomicDurationTimer::start(&self.stats.local_cas_write_duration_ns);
1376        let path = self.cas.store_verified_file(digest, temporary.path())?;
1377        self.remember_verified_blob(digest, &path);
1378        self.stats.stores.fetch_add(1, Ordering::Relaxed);
1379        self.stats
1380            .stored_bytes
1381            .fetch_add(digest.size, Ordering::Relaxed);
1382        self.stats
1383            .downloaded_bytes
1384            .fetch_add(digest.size, Ordering::Relaxed);
1385        Ok(path)
1386    }
1387
1388    async fn respond(&self, request: AgentRequest) -> AgentResponse {
1389        let result = match request {
1390            AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
1391            AgentRequest::FindBlobs { digests } => self.find_blobs(digests).await,
1392            AgentRequest::StoreBlob { digest, source } => self.store_blob(&digest, &source).await,
1393            AgentRequest::FindActionResult { action } => {
1394                self.stats.lookups.fetch_add(1, Ordering::Relaxed);
1395                self.find_action_result(&action).await
1396            }
1397            AgentRequest::RecordActionHit { action, restore } => {
1398                self.record_action_hit(&action, restore)
1399            }
1400            AgentRequest::RecordBypass { kind } => {
1401                *self.stats.bypasses.lock().unwrap().entry(kind).or_insert(0) += 1;
1402                Ok(AgentResponse::BypassRecorded)
1403            }
1404            AgentRequest::RecordUnconsulted => {
1405                self.stats.unconsulted.fetch_add(1, Ordering::Relaxed);
1406                Ok(AgentResponse::UnconsultedRecorded)
1407            }
1408            AgentRequest::RecordActionVerification { matched, restore } => {
1409                self.record_materialization(restore);
1410                self.stats.verifications.fetch_add(1, Ordering::Relaxed);
1411                if !matched {
1412                    self.stats.divergences.fetch_add(1, Ordering::Relaxed);
1413                }
1414                Ok(AgentResponse::ActionVerificationRecorded)
1415            }
1416            AgentRequest::StoreActionResult { result } => self.store_action_result(&result).await,
1417            AgentRequest::FindActionPrediction { task, invocation } => {
1418                self.find_action_prediction(&task, &invocation)
1419            }
1420            AgentRequest::RecordActionPrediction { task, prediction } => {
1421                self.record_action_prediction(&task, prediction)
1422            }
1423            AgentRequest::FindExecutableIdentity {
1424                executable,
1425                environment,
1426            } => self.find_executable_identity(executable, environment),
1427            AgentRequest::StoreExecutableIdentity {
1428                executable,
1429                environment,
1430                stdout,
1431            } => self.store_executable_identity(executable, environment, stdout),
1432            AgentRequest::Hello { .. } => {
1433                Err(eyre::eyre!("hello is only valid as the first request"))
1434            }
1435        };
1436        result.unwrap_or_else(|error| AgentResponse::Error {
1437            message: error.to_string(),
1438        })
1439    }
1440
1441    async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
1442        if let Some(path) = self.find_verified_blob(digest)? {
1443            return Ok(AgentResponse::Blob { path: Some(path) });
1444        }
1445        if !self.remote_mode.reads() {
1446            return Ok(AgentResponse::Blob { path: None });
1447        }
1448        let Some(remote) = &self.remote else {
1449            return Ok(AgentResponse::Blob { path: None });
1450        };
1451        match self.fetch_remote_blob(remote, digest).await {
1452            Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
1453            Err(error) => {
1454                warn!(
1455                    "remote cache blob lookup failed for {}: {error}",
1456                    digest.hash
1457                );
1458                Ok(AgentResponse::Blob { path: None })
1459            }
1460        }
1461    }
1462
1463    async fn find_blobs(&self, digests: Vec<CacheDigest>) -> Result<AgentResponse> {
1464        let mut paths = BTreeMap::new();
1465        let mut missing = Vec::new();
1466        for digest in &digests {
1467            match self.find_verified_blob(digest)? {
1468                Some(path) => {
1469                    paths.insert(digest.clone(), path);
1470                }
1471                None => {
1472                    missing.push(digest.clone());
1473                }
1474            }
1475        }
1476
1477        if !missing.is_empty()
1478            && self.remote_mode.reads()
1479            && let Some(remote) = &self.remote
1480        {
1481            paths.extend(self.fetch_remote_blobs(remote, missing, None).await);
1482        }
1483
1484        Ok(AgentResponse::Blobs {
1485            paths: digests
1486                .into_iter()
1487                .map(|digest| paths.get(&digest).cloned())
1488                .collect(),
1489        })
1490    }
1491
1492    async fn store_blob(&self, digest: &CacheDigest, source: &Path) -> Result<AgentResponse> {
1493        let remote = if self.remote_mode.writes() {
1494            self.remote.as_deref()
1495        } else {
1496            None
1497        };
1498        let path = {
1499            let lock = self.write_lock(digest);
1500            let _guard = lock.lock().await;
1501            if let Some(path) = self.find_verified_blob(digest)? {
1502                path
1503            } else {
1504                let path = self.cas.store_file(digest, source)?;
1505                self.remember_verified_blob(digest, &path);
1506                self.stats.stores.fetch_add(1, Ordering::Relaxed);
1507                self.stats
1508                    .stored_bytes
1509                    .fetch_add(digest.size, Ordering::Relaxed);
1510                path
1511            }
1512        };
1513        if let Some(remote) = remote {
1514            let _permit = self.remote_transfers.acquire().await?;
1515            if let Err(error) = remote
1516                .put_blob(&BlobUpload {
1517                    digest: digest.clone(),
1518                    source: BlobSource::Path(path.clone()),
1519                })
1520                .await
1521            {
1522                warn!(
1523                    "remote cache blob upload failed for {}: {error}",
1524                    digest.hash
1525                );
1526            } else {
1527                self.stats
1528                    .uploaded_bytes
1529                    .fetch_add(digest.size, Ordering::Relaxed);
1530            }
1531        }
1532        Ok(AgentResponse::Stored { path })
1533    }
1534
1535    fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
1536        let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
1537        if let Some(path) = remembered {
1538            if digest.matches_file(&path).unwrap_or(false) {
1539                return Ok(Some(path));
1540            }
1541            self.verified_blobs.lock().unwrap().remove(digest);
1542        }
1543        let path = self.cas.find(digest)?;
1544        if let Some(path) = &path {
1545            self.remember_verified_blob(digest, path);
1546        }
1547        Ok(path)
1548    }
1549
1550    fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
1551        self.verified_blobs
1552            .lock()
1553            .unwrap()
1554            .insert(digest.clone(), path.to_path_buf());
1555    }
1556
1557    async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
1558        if let Some(result) = self.actions.find(action)? {
1559            return Ok(AgentResponse::ActionResult {
1560                result: Some(result),
1561            });
1562        }
1563        if !self.remote_mode.reads() {
1564            return Ok(AgentResponse::ActionResult { result: None });
1565        }
1566        let Some(remote) = &self.remote else {
1567            return Ok(AgentResponse::ActionResult { result: None });
1568        };
1569        let lock = self.action_lock(action);
1570        let _guard = lock.lock().await;
1571        if let Some(result) = self.actions.find(action)? {
1572            return Ok(AgentResponse::ActionResult {
1573                result: Some(result),
1574            });
1575        }
1576        if let Some(result) = self
1577            .pending_remote_actions
1578            .lock()
1579            .unwrap()
1580            .get(action)
1581            .cloned()
1582        {
1583            return Ok(AgentResponse::ActionResult {
1584                result: Some(result),
1585            });
1586        }
1587        let _permit = self.remote_transfers.acquire().await?;
1588        match self.get_remote_action_result(remote, action).await {
1589            Ok(Some(result)) => {
1590                self.pending_remote_actions
1591                    .lock()
1592                    .unwrap()
1593                    .insert(action.clone(), result.clone());
1594                Ok(AgentResponse::ActionResult {
1595                    result: Some(result),
1596                })
1597            }
1598            Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
1599            Err(error) => {
1600                warn!(
1601                    "remote cache action lookup failed for {}: {error}",
1602                    action.hash
1603                );
1604                Ok(AgentResponse::ActionResult { result: None })
1605            }
1606        }
1607    }
1608
1609    async fn store_action_result(&self, result: &RemoteActionResult) -> Result<AgentResponse> {
1610        let path = self.actions.store(result)?;
1611        if self.remote_mode.writes()
1612            && let Some(remote) = &self.remote
1613        {
1614            let _permit = self.remote_transfers.acquire().await?;
1615            if let Err(error) = remote.put_action_result(result).await {
1616                warn!(
1617                    "remote cache action upload failed for {}: {error}",
1618                    result.action.hash
1619                );
1620            }
1621        }
1622        Ok(AgentResponse::ActionStored { path })
1623    }
1624
1625    async fn get_remote_action_result(
1626        &self,
1627        remote: &RemoteCacheClient,
1628        action: &CacheDigest,
1629    ) -> Result<Option<RemoteActionResult>> {
1630        self.stats
1631            .remote_action_lookups
1632            .fetch_add(1, Ordering::Relaxed);
1633        let _timer = AtomicDurationTimer::start(&self.stats.remote_action_lookup_duration_ns);
1634        remote.get_action_result(action).await
1635    }
1636
1637    fn record_action_hit(
1638        &self,
1639        action: &CacheDigest,
1640        restore: RestoreStats,
1641    ) -> Result<AgentResponse> {
1642        if self.actions.find(action)?.is_none() {
1643            let pending = self.pending_remote_actions.lock().unwrap().remove(action);
1644            if let Some(result) = pending {
1645                self.actions.store(&result)?;
1646            } else {
1647                bail!("cannot record a hit for a missing action result");
1648            }
1649        }
1650        self.record_restore(restore);
1651        self.stats.hits.fetch_add(1, Ordering::Relaxed);
1652        Ok(AgentResponse::ActionHitRecorded)
1653    }
1654
1655    fn record_restore(&self, restore: RestoreStats) {
1656        self.record_materialization(restore);
1657        atomic_saturating_add(&self.stats.restored_output_files, restore.output_files);
1658        atomic_saturating_add(&self.stats.restored_output_bytes, restore.output_bytes);
1659    }
1660
1661    fn record_materialization(&self, restore: RestoreStats) {
1662        atomic_saturating_add(&self.stats.materialization_duration_ns, restore.duration_ns);
1663    }
1664
1665    fn find_action_prediction(
1666        &self,
1667        task: &str,
1668        invocation: &CacheDigest,
1669    ) -> Result<AgentResponse> {
1670        validate_task_identity(task)?;
1671        invocation.validate()?;
1672        let prediction = self
1673            .task_actions
1674            .lock()
1675            .unwrap()
1676            .get(task)
1677            .and_then(|state| state.predictions.get(invocation))
1678            .cloned();
1679        Ok(AgentResponse::ActionPrediction { prediction })
1680    }
1681
1682    fn record_action_prediction(
1683        &self,
1684        task: &str,
1685        prediction: ActionPrediction,
1686    ) -> Result<AgentResponse> {
1687        validate_task_identity(task)?;
1688        validate_action_prediction(&prediction)?;
1689        let mut tasks = self.task_actions.lock().unwrap();
1690        let state = tasks.entry(task.to_string()).or_default();
1691        if !state.predictions.contains_key(&prediction.invocation)
1692            && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
1693        {
1694            bail!("task action manifest contains too many predictions");
1695        }
1696        state
1697            .predictions
1698            .insert(prediction.invocation.clone(), prediction);
1699        Ok(AgentResponse::ActionPredictionRecorded)
1700    }
1701
1702    fn executable_identity_key(
1703        &self,
1704        executable: PathBuf,
1705        environment: BTreeMap<String, Option<String>>,
1706    ) -> Result<ExecutableIdentityKey> {
1707        if !environment
1708            .keys()
1709            .all(|name| matches!(name.as_str(), "RUSTUP_HOME" | "RUSTUP_TOOLCHAIN"))
1710        {
1711            bail!("executable identity contains an unsupported environment variable");
1712        }
1713        Ok(ExecutableIdentityKey {
1714            executable,
1715            environment,
1716        })
1717    }
1718
1719    fn find_executable_identity(
1720        &self,
1721        executable: PathBuf,
1722        environment: BTreeMap<String, Option<String>>,
1723    ) -> Result<AgentResponse> {
1724        let key = self.executable_identity_key(executable, environment)?;
1725        let stdout = self
1726            .executable_identities
1727            .lock()
1728            .unwrap()
1729            .get(&key)
1730            .cloned();
1731        Ok(AgentResponse::ExecutableIdentity { stdout })
1732    }
1733
1734    fn store_executable_identity(
1735        &self,
1736        executable: PathBuf,
1737        environment: BTreeMap<String, Option<String>>,
1738        stdout: Vec<u8>,
1739    ) -> Result<AgentResponse> {
1740        if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1741            bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1742        }
1743        let key = self.executable_identity_key(executable, environment)?;
1744        let mut identities = self.executable_identities.lock().unwrap();
1745        let is_new = !identities.contains_key(&key);
1746        let previous_size = identities.get(&key).map_or(0, Vec::len);
1747        if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1748            bail!("executable identity cache contains too many entries");
1749        }
1750        let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1751        if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1752            bail!("executable identity cache contains too many bytes");
1753        }
1754        identities.insert(key, stdout.clone());
1755        Ok(AgentResponse::ExecutableIdentity {
1756            stdout: Some(stdout),
1757        })
1758    }
1759
1760    /// Serve newline-delimited protocol requests on an authenticated session stream.
1761    pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1762    where
1763        S: AsyncRead + AsyncWrite + Unpin,
1764    {
1765        let (reader, mut writer) = tokio::io::split(stream);
1766        let mut reader = BufReader::new(reader);
1767        let hello = read_request(&mut reader)
1768            .await?
1769            .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1770        let request: AgentRequest = serde_json::from_str(&hello)?;
1771        match request {
1772            AgentRequest::Hello {
1773                protocol,
1774                client_version,
1775            } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1776            AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1777                send_response(
1778                    &mut writer,
1779                    &AgentResponse::Error {
1780                        message: format!(
1781                            "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1782                        ),
1783                    },
1784                )
1785                .await?;
1786                return Ok(());
1787            }
1788            AgentRequest::Hello { client_version, .. } => {
1789                send_response(
1790                    &mut writer,
1791                    &AgentResponse::Error {
1792                        message: format!(
1793                            "cache client {client_version} does not match agent {}",
1794                            self.version
1795                        ),
1796                    },
1797                )
1798                .await?;
1799                return Ok(());
1800            }
1801            _ => bail!("the first agent request must be hello"),
1802        }
1803        send_response(
1804            &mut writer,
1805            &AgentResponse::Hello {
1806                protocol: AGENT_PROTOCOL_VERSION,
1807                agent_version: self.version.to_string(),
1808            },
1809        )
1810        .await?;
1811
1812        while let Some(line) = read_request(&mut reader).await? {
1813            let response = match serde_json::from_str(&line) {
1814                Ok(request) => self.respond(request).await,
1815                Err(error) => AgentResponse::Error {
1816                    message: format!("invalid agent request: {error}"),
1817                },
1818            };
1819            send_response(&mut writer, &response).await?;
1820        }
1821        Ok(())
1822    }
1823}
1824
1825/// Read one newline-delimited request, refusing one that grows past the cap.
1826///
1827/// Any process running as this user can open the session socket, so a request
1828/// that never terminates its line must not be able to grow the agent's memory
1829/// without bound.
1830async fn read_request<R>(reader: &mut R) -> Result<Option<String>>
1831where
1832    R: AsyncBufRead + Unpin,
1833{
1834    let mut line = Vec::new();
1835    loop {
1836        let available = reader.fill_buf().await?;
1837        if available.is_empty() {
1838            break;
1839        }
1840        let (consumed, complete) = match available.iter().position(|byte| *byte == b'\n') {
1841            Some(index) => (index, true),
1842            None => (available.len(), false),
1843        };
1844        if line.len() + consumed > MAX_REQUEST_BYTES {
1845            bail!("agent request exceeded {MAX_REQUEST_BYTES} bytes");
1846        }
1847        line.extend_from_slice(&available[..consumed]);
1848        // The newline itself is consumed but never kept.
1849        reader.consume(consumed + usize::from(complete));
1850        if complete {
1851            return Ok(Some(String::from_utf8(line)?));
1852        }
1853    }
1854    if line.is_empty() {
1855        Ok(None)
1856    } else {
1857        Ok(Some(String::from_utf8(line)?))
1858    }
1859}
1860
1861fn validate_task_identity(task: &str) -> Result<()> {
1862    if task.len() != 64
1863        || !task
1864            .bytes()
1865            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1866    {
1867        bail!("invalid task action identity");
1868    }
1869    Ok(())
1870}
1871
1872fn validate_action_prediction(prediction: &ActionPrediction) -> Result<()> {
1873    prediction.invocation.validate()?;
1874    prediction.action.validate()?;
1875    if prediction.adapter.is_empty()
1876        || !prediction
1877            .adapter
1878            .bytes()
1879            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1880    {
1881        bail!("invalid action prediction adapter");
1882    }
1883    if prediction.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
1884        bail!("action prediction payload is too large");
1885    }
1886    serde_json::from_str::<serde_json::Value>(&prediction.payload)?;
1887    Ok(())
1888}
1889
1890fn validate_task_manifest(manifest: &TaskActionManifest, task: &str) -> Result<()> {
1891    if manifest.version != TASK_ACTION_MANIFEST_VERSION || manifest.task != task {
1892        bail!("task action manifest has an invalid identity");
1893    }
1894    if manifest.predictions.len() > MAX_TASK_ACTION_PREDICTIONS {
1895        bail!("task action manifest contains too many predictions");
1896    }
1897    let mut invocations = BTreeMap::new();
1898    for prediction in &manifest.predictions {
1899        validate_action_prediction(prediction)?;
1900        if invocations.insert(&prediction.invocation, ()).is_some() {
1901            bail!("task action manifest contains duplicate predictions");
1902        }
1903    }
1904    Ok(())
1905}
1906
1907fn merge_task_manifests(
1908    task: &str,
1909    base: Option<TaskActionManifest>,
1910    update: TaskActionManifest,
1911) -> Result<TaskActionManifest> {
1912    validate_task_manifest(&update, task)?;
1913    let mut predictions = BTreeMap::new();
1914    if let Some(base) = base {
1915        validate_task_manifest(&base, task)?;
1916        predictions.extend(
1917            base.predictions
1918                .into_iter()
1919                .map(|prediction| (prediction.invocation.clone(), prediction)),
1920        );
1921    }
1922    predictions.extend(
1923        update
1924            .predictions
1925            .into_iter()
1926            .map(|prediction| (prediction.invocation.clone(), prediction)),
1927    );
1928    let manifest = TaskActionManifest {
1929        version: TASK_ACTION_MANIFEST_VERSION,
1930        task: task.to_owned(),
1931        predictions: predictions.into_values().collect(),
1932    };
1933    validate_task_manifest(&manifest, task)?;
1934    Ok(manifest)
1935}
1936
1937fn merge_remote_task_manifest(
1938    task: &str,
1939    remote: TaskActionManifest,
1940    local: TaskActionManifest,
1941) -> (TaskActionManifest, bool) {
1942    match merge_task_manifests(task, Some(remote), local.clone()) {
1943        Ok(manifest) => (manifest, true),
1944        Err(error) => {
1945            warn!("remote task action manifest merge failed for {task}: {error}");
1946            (local, false)
1947        }
1948    }
1949}
1950
1951async fn send_response(
1952    writer: &mut (impl AsyncWrite + Unpin),
1953    response: &AgentResponse,
1954) -> Result<()> {
1955    let mut encoded = serde_json::to_vec(response)?;
1956    encoded.push(b'\n');
1957    writer.write_all(&encoded).await?;
1958    writer.flush().await?;
1959    Ok(())
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964    use super::*;
1965    use crate::ACTION_RESULT_MEDIA_TYPE;
1966    use std::time::Duration;
1967
1968    #[test]
1969    fn directory_queue_counts_only_unique_unseen_nodes() {
1970        let shared = CacheDigest::blake3(b"shared");
1971        let first = CacheDigest::blake3(b"first");
1972        let second = CacheDigest::blake3(b"second");
1973        let overflow = CacheDigest::blake3(b"overflow");
1974        let seen = BTreeMap::from([(shared.clone(), ())]);
1975        let mut pending = BTreeMap::new();
1976
1977        assert!(queue_prefetch_directory(&seen, &mut pending, shared, 3));
1978        assert!(pending.is_empty());
1979        assert!(queue_prefetch_directory(
1980            &seen,
1981            &mut pending,
1982            first.clone(),
1983            3
1984        ));
1985        assert!(queue_prefetch_directory(&seen, &mut pending, first, 3));
1986        assert!(queue_prefetch_directory(&seen, &mut pending, second, 3));
1987        assert!(!queue_prefetch_directory(&seen, &mut pending, overflow, 3));
1988        assert_eq!(pending.len(), 2);
1989    }
1990
1991    async fn handshake(stream: &mut (impl AsyncRead + AsyncWrite + Unpin), version: &str) {
1992        let request = AgentRequest::Hello {
1993            protocol: AGENT_PROTOCOL_VERSION,
1994            client_version: version.to_string(),
1995        };
1996        let mut encoded = serde_json::to_vec(&request).unwrap();
1997        encoded.push(b'\n');
1998        stream.write_all(&encoded).await.unwrap();
1999        stream.flush().await.unwrap();
2000        let mut response = String::new();
2001        BufReader::new(stream)
2002            .read_line(&mut response)
2003            .await
2004            .unwrap();
2005        assert!(matches!(
2006            serde_json::from_str(&response).unwrap(),
2007            AgentResponse::Hello { .. }
2008        ));
2009    }
2010
2011    #[tokio::test]
2012    async fn rejects_a_request_that_never_ends_its_line() {
2013        let directory = tempfile::tempdir().unwrap();
2014        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2015        let (mut client, server) = tokio::io::duplex(64 * 1024);
2016        let task = tokio::spawn(async move { agent.handle_connection(server).await });
2017
2018        handshake(&mut client, "test-version").await;
2019        // Never send a newline: the agent must give up rather than buffer
2020        // whatever a peer is willing to write.
2021        let filler = vec![b'x'; 64 * 1024];
2022        let mut written = 0usize;
2023        while written <= MAX_REQUEST_BYTES {
2024            if client.write_all(&filler).await.is_err() {
2025                break;
2026            }
2027            written += filler.len();
2028        }
2029
2030        let error = task.await.unwrap().err().unwrap();
2031        assert!(
2032            error.to_string().contains("exceeded"),
2033            "unexpected error: {error}"
2034        );
2035    }
2036
2037    #[tokio::test]
2038    async fn counts_compilations_it_could_not_look_up() {
2039        let directory = tempfile::tempdir().unwrap();
2040        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2041
2042        for _ in 0..3 {
2043            agent.respond(AgentRequest::RecordUnconsulted).await;
2044        }
2045
2046        let stats = agent.stats();
2047        assert_eq!(stats.unconsulted, 3);
2048        // Not a miss: nothing was looked up, and a hit rate computed over these
2049        // would be a rate over lookups that never happened.
2050        assert_eq!(stats.lookups, 0);
2051        assert_eq!(stats.hits, 0);
2052    }
2053
2054    #[tokio::test]
2055    async fn counts_bypasses_by_reason() {
2056        let directory = tempfile::tempdir().unwrap();
2057        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2058
2059        for kind in [
2060            "unsupported-crate-type",
2061            "unsupported-crate-type",
2062            "incremental",
2063        ] {
2064            agent
2065                .respond(AgentRequest::RecordBypass { kind: kind.into() })
2066                .await;
2067        }
2068
2069        let stats = agent.stats();
2070        assert_eq!(stats.bypasses.get("unsupported-crate-type"), Some(&2));
2071        assert_eq!(stats.bypasses.get("incremental"), Some(&1));
2072    }
2073
2074    #[tokio::test]
2075    async fn handshake_and_blob_round_trip() {
2076        let directory = tempfile::tempdir().unwrap();
2077        let source = directory.path().join("source");
2078        std::fs::write(&source, b"cached object").unwrap();
2079        let digest = CacheDigest::blake3(b"cached object");
2080        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2081        let (mut client, server) = tokio::io::duplex(16 * 1024);
2082        let server_agent = agent.clone();
2083        let task = tokio::spawn(async move { server_agent.handle_connection(server).await });
2084
2085        handshake(&mut client, "test-version").await;
2086        let request = AgentRequest::StoreBlob {
2087            digest: digest.clone(),
2088            source,
2089        };
2090        let mut encoded = serde_json::to_vec(&request).unwrap();
2091        encoded.push(b'\n');
2092        client.write_all(&encoded).await.unwrap();
2093        let mut response = String::new();
2094        BufReader::new(&mut client)
2095            .read_line(&mut response)
2096            .await
2097            .unwrap();
2098        assert!(matches!(
2099            serde_json::from_str(&response).unwrap(),
2100            AgentResponse::Stored { .. }
2101        ));
2102        drop(client);
2103        task.await.unwrap().unwrap();
2104        assert_eq!(
2105            agent.stats(),
2106            AgentStats {
2107                stores: 1,
2108                stored_bytes: digest.size,
2109                ..AgentStats::default()
2110            }
2111        );
2112    }
2113
2114    #[test]
2115    fn remembered_blobs_reject_same_size_corruption() {
2116        let directory = tempfile::tempdir().unwrap();
2117        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2118        let digest = CacheDigest::blake3(b"cached object");
2119        let path = agent.cas.store_bytes(&digest, b"cached object").unwrap();
2120        assert_eq!(
2121            agent.find_verified_blob(&digest).unwrap(),
2122            Some(path.clone())
2123        );
2124
2125        std::fs::write(&path, b"broken object").unwrap();
2126
2127        assert!(agent.find_verified_blob(&digest).is_err());
2128        assert!(!agent.verified_blobs.lock().unwrap().contains_key(&digest));
2129    }
2130
2131    #[tokio::test]
2132    async fn publishes_a_complete_action_result() {
2133        let directory = tempfile::tempdir().unwrap();
2134        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2135        let action = CacheDigest::blake3(b"action");
2136        let metadata = CacheDigest::blake3(b"metadata");
2137        let output_root = CacheDigest::blake3(b"directory");
2138        for (digest, contents) in [
2139            (&action, b"action".as_slice()),
2140            (&metadata, b"metadata".as_slice()),
2141            (&output_root, b"directory".as_slice()),
2142        ] {
2143            agent.cas.store_bytes(digest, contents).unwrap();
2144        }
2145        let response = agent
2146            .respond(AgentRequest::StoreActionResult {
2147                result: RemoteActionResult {
2148                    action: action.clone(),
2149                    metadata: Some(metadata),
2150                    output_root: Some(output_root),
2151                    version: 1,
2152                },
2153            })
2154            .await;
2155        assert!(matches!(response, AgentResponse::ActionStored { .. }));
2156        let response = agent
2157            .respond(AgentRequest::FindActionResult {
2158                action: action.clone(),
2159            })
2160            .await;
2161        assert!(matches!(
2162            response,
2163            AgentResponse::ActionResult {
2164                result: Some(result)
2165            } if result.action == action
2166        ));
2167        assert!(matches!(
2168            agent
2169                .respond(AgentRequest::RecordActionHit {
2170                    action: action.clone(),
2171                    restore: RestoreStats {
2172                        duration_ns: 7,
2173                        output_files: 2,
2174                        output_bytes: 11,
2175                    },
2176                })
2177                .await,
2178            AgentResponse::ActionHitRecorded
2179        ));
2180        assert_eq!(
2181            agent.stats(),
2182            AgentStats {
2183                lookups: 1,
2184                hits: 1,
2185                materialization_duration_ns: 7,
2186                restored_output_files: 2,
2187                restored_output_bytes: 11,
2188                ..AgentStats::default()
2189            }
2190        );
2191    }
2192
2193    #[tokio::test]
2194    async fn missing_action_result_is_a_cache_miss() {
2195        let directory = tempfile::tempdir().unwrap();
2196        let agent = CacheAgent::new(directory.path(), "test-version");
2197        let action = CacheDigest::blake3(b"missing action");
2198        let response = agent
2199            .respond(AgentRequest::FindActionResult {
2200                action: action.clone(),
2201            })
2202            .await;
2203
2204        assert!(matches!(
2205            response,
2206            AgentResponse::ActionResult { result: None }
2207        ));
2208        assert!(matches!(
2209            agent
2210                .respond(AgentRequest::RecordActionHit {
2211                    action,
2212                    restore: RestoreStats::default(),
2213                })
2214                .await,
2215            AgentResponse::Error { .. }
2216        ));
2217        assert_eq!(
2218            agent.stats(),
2219            AgentStats {
2220                lookups: 1,
2221                ..AgentStats::default()
2222            }
2223        );
2224
2225        assert!(matches!(
2226            agent
2227                .respond(AgentRequest::RecordActionVerification {
2228                    matched: false,
2229                    restore: RestoreStats {
2230                        duration_ns: 7,
2231                        output_files: 2,
2232                        output_bytes: 11,
2233                    },
2234                })
2235                .await,
2236            AgentResponse::ActionVerificationRecorded
2237        ));
2238        assert_eq!(agent.stats().verifications, 1);
2239        assert_eq!(agent.stats().divergences, 1);
2240        assert_eq!(agent.stats().materialization_duration_ns, 7);
2241        assert_eq!(agent.stats().restored_output_files, 0);
2242        assert_eq!(agent.stats().restored_output_bytes, 0);
2243    }
2244
2245    #[tokio::test]
2246    async fn coalesces_repeated_remote_action_lookups() {
2247        let directory = tempfile::tempdir().unwrap();
2248        let mut server = mockito::Server::new_async().await;
2249        let action = CacheDigest::blake3(b"remote action");
2250        let result = RemoteActionResult {
2251            action: action.clone(),
2252            metadata: None,
2253            output_root: None,
2254            version: 1,
2255        };
2256        let remote = server
2257            .mock("GET", action_path(&action).as_str())
2258            .with_status(200)
2259            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2260            .with_body(serde_json::to_vec(&result).unwrap())
2261            .expect(1)
2262            .create_async()
2263            .await;
2264        let agent = remote_agent(
2265            &server,
2266            directory.path().join("reader"),
2267            RemoteCacheMode::ReadOnly,
2268        );
2269
2270        for _ in 0..2 {
2271            assert!(matches!(
2272                agent
2273                    .respond(AgentRequest::FindActionResult {
2274                        action: action.clone(),
2275                    })
2276                    .await,
2277                AgentResponse::ActionResult {
2278                    result: Some(found)
2279                } if found == result
2280            ));
2281        }
2282        remote.assert_async().await;
2283    }
2284
2285    #[tokio::test]
2286    async fn publishes_only_successfully_committed_task_action_manifests() {
2287        let directory = tempfile::tempdir().unwrap();
2288        let cache = directory.path().join("cache");
2289        let task = "a".repeat(64);
2290        let first_invocation = CacheDigest::blake3(b"first invocation");
2291        let first = ActionPrediction {
2292            invocation: first_invocation.clone(),
2293            action: CacheDigest::blake3(b"first action"),
2294            adapter: "rustc".into(),
2295            payload: "{}".into(),
2296        };
2297
2298        let agent = CacheAgent::new(&cache, "test-version");
2299        let first_run = agent.begin_task(&task).await.unwrap();
2300        assert!(matches!(
2301            agent
2302                .respond(AgentRequest::RecordActionPrediction {
2303                    task: first_run.clone(),
2304                    prediction: first.clone(),
2305                })
2306                .await,
2307            AgentResponse::ActionPredictionRecorded
2308        ));
2309        agent.commit_task(&first_run).await.unwrap();
2310
2311        let uncommitted = CacheAgent::new(&cache, "test-version");
2312        let uncommitted_run = uncommitted.begin_task(&task).await.unwrap();
2313        let second_invocation = CacheDigest::blake3(b"second invocation");
2314        assert!(matches!(
2315            uncommitted
2316                .respond(AgentRequest::RecordActionPrediction {
2317                    task: uncommitted_run,
2318                    prediction: ActionPrediction {
2319                        invocation: second_invocation.clone(),
2320                        action: CacheDigest::blake3(b"second action"),
2321                        adapter: "rustc".into(),
2322                        payload: "{}".into(),
2323                    },
2324                })
2325                .await,
2326            AgentResponse::ActionPredictionRecorded
2327        ));
2328
2329        let next_session = CacheAgent::new(&cache, "test-version");
2330        let next_run = next_session.begin_task(&task).await.unwrap();
2331        assert!(matches!(
2332            next_session
2333                .respond(AgentRequest::FindActionPrediction {
2334                    task: next_run.clone(),
2335                    invocation: first_invocation,
2336                })
2337                .await,
2338            AgentResponse::ActionPrediction {
2339                prediction: Some(prediction)
2340            } if prediction == first
2341        ));
2342        assert!(matches!(
2343            next_session
2344                .respond(AgentRequest::FindActionPrediction {
2345                    task: next_run,
2346                    invocation: second_invocation,
2347                })
2348                .await,
2349            AgentResponse::ActionPrediction { prediction: None }
2350        ));
2351
2352        let corrupt_task = "b".repeat(64);
2353        fs::create_dir_all(next_session.manifest_dir.as_path()).unwrap();
2354        fs::write(next_session.task_manifest_path(&corrupt_task), b"not json").unwrap();
2355        assert!(next_session.begin_task(&corrupt_task).await.is_err());
2356        let corrupt_run = "c".repeat(64);
2357        next_session.task_actions.lock().unwrap().insert(
2358            corrupt_run.clone(),
2359            TaskActionState {
2360                manifest: corrupt_task.clone(),
2361                ..TaskActionState::default()
2362            },
2363        );
2364        assert!(matches!(
2365            next_session
2366                .respond(AgentRequest::RecordActionPrediction {
2367                    task: corrupt_run.clone(),
2368                    prediction: first,
2369                })
2370                .await,
2371            AgentResponse::ActionPredictionRecorded
2372        ));
2373        assert!(next_session.commit_task(&corrupt_run).await.is_err());
2374        assert_eq!(
2375            fs::read(next_session.task_manifest_path(&corrupt_task)).unwrap(),
2376            b"not json"
2377        );
2378    }
2379
2380    #[tokio::test]
2381    async fn round_trips_task_actions_between_fresh_local_caches() {
2382        let directory = tempfile::tempdir().unwrap();
2383        let mut server = mockito::Server::new_async().await;
2384        let task = "e".repeat(64);
2385        let invocation = CacheDigest::blake3(b"remote invocation");
2386        let action_bytes = canonical_json(&serde_json::json!({"kind":"rustc"})).unwrap();
2387        let stdout_bytes = b"cached stdout".to_vec();
2388        let stderr_bytes = b"cached stderr".to_vec();
2389        let artifact_bytes = b"cached artifact".to_vec();
2390        let stdout = CacheDigest::blake3(&stdout_bytes);
2391        let stderr = CacheDigest::blake3(&stderr_bytes);
2392        let artifact = CacheDigest::blake3(&artifact_bytes);
2393        let metadata_bytes = canonical_json(&RustcMetadata {
2394            version: 1,
2395            kind: "rustc".into(),
2396            stdout: stdout.clone(),
2397            stderr: stderr.clone(),
2398        })
2399        .unwrap();
2400        let directory_bytes = canonical_json(&serde_json::json!({
2401            "directories":[],
2402            "files":[{"digest":artifact,"executable":false,"mode":420,"name":"artifact"}],
2403            "symlinks":[],
2404            "version":1
2405        }))
2406        .unwrap();
2407        let action = CacheDigest::blake3(&action_bytes);
2408        let metadata = CacheDigest::blake3(&metadata_bytes);
2409        let output_root = CacheDigest::blake3(&directory_bytes);
2410        let result = RemoteActionResult {
2411            action: action.clone(),
2412            metadata: Some(metadata.clone()),
2413            output_root: Some(output_root.clone()),
2414            version: 1,
2415        };
2416        let prediction = ActionPrediction {
2417            invocation: invocation.clone(),
2418            action: action.clone(),
2419            adapter: "rustc".into(),
2420            payload: "{}".into(),
2421        };
2422        let manifest_bytes = canonical_json(&TaskActionManifest {
2423            version: TASK_ACTION_MANIFEST_VERSION,
2424            task: task.clone(),
2425            predictions: vec![prediction.clone()],
2426        })
2427        .unwrap();
2428        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
2429        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2430
2431        let mut mocks = Vec::new();
2432        for (digest, bytes) in [
2433            (&action, action_bytes.as_slice()),
2434            (&metadata, metadata_bytes.as_slice()),
2435            (&output_root, directory_bytes.as_slice()),
2436            (&stdout, stdout_bytes.as_slice()),
2437            (&stderr, stderr_bytes.as_slice()),
2438            (&artifact, artifact_bytes.as_slice()),
2439        ] {
2440            mocks.push(
2441                server
2442                    .mock("PUT", blob_path(digest).as_str())
2443                    .match_header("mbx-cache-namespace", "test")
2444                    .match_body(bytes.to_vec())
2445                    .with_status(200)
2446                    .expect(1)
2447                    .create_async()
2448                    .await,
2449            );
2450        }
2451        mocks.push(
2452            server
2453                .mock("PUT", action_path(&result.action).as_str())
2454                .match_header("mbx-cache-namespace", "test")
2455                .with_status(200)
2456                .expect(1)
2457                .create_async()
2458                .await,
2459        );
2460        mocks.push(
2461            server
2462                .mock("PUT", action_manifest_path(&selector).as_str())
2463                .match_header("mbx-cache-namespace", "test")
2464                .match_header("if-none-match", "*")
2465                .match_body(manifest_bytes.clone())
2466                .with_status(201)
2467                .expect(1)
2468                .create_async()
2469                .await,
2470        );
2471        mocks.push(
2472            server
2473                .mock("GET", action_manifest_path(&selector).as_str())
2474                .with_status(200)
2475                .with_header("etag", &format!("\"{manifest_etag}\""))
2476                .with_body(manifest_bytes.clone())
2477                .expect(1)
2478                .create_async()
2479                .await,
2480        );
2481        mocks.push(
2482            server
2483                .mock("GET", action_path(&action).as_str())
2484                .with_status(200)
2485                .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2486                .with_body(serde_json::to_vec(&result).unwrap())
2487                .expect(1)
2488                .create_async()
2489                .await,
2490        );
2491        for (digest, bytes) in [
2492            (&action, action_bytes.as_slice()),
2493            (&metadata, metadata_bytes.as_slice()),
2494            (&output_root, directory_bytes.as_slice()),
2495            (&stdout, stdout_bytes.as_slice()),
2496            (&stderr, stderr_bytes.as_slice()),
2497            (&artifact, artifact_bytes.as_slice()),
2498        ] {
2499            mocks.push(
2500                server
2501                    .mock("GET", blob_path(digest).as_str())
2502                    .with_status(200)
2503                    .with_body(bytes)
2504                    .expect(1)
2505                    .create_async()
2506                    .await,
2507            );
2508        }
2509
2510        let writer = remote_agent(
2511            &server,
2512            directory.path().join("writer"),
2513            RemoteCacheMode::WriteOnly,
2514        );
2515        for (index, (digest, bytes)) in [
2516            (&action, action_bytes.as_slice()),
2517            (&metadata, metadata_bytes.as_slice()),
2518            (&output_root, directory_bytes.as_slice()),
2519            (&stdout, stdout_bytes.as_slice()),
2520            (&stderr, stderr_bytes.as_slice()),
2521            (&artifact, artifact_bytes.as_slice()),
2522        ]
2523        .into_iter()
2524        .enumerate()
2525        {
2526            let source = directory.path().join(format!("source-{index}"));
2527            fs::write(&source, bytes).unwrap();
2528            assert!(matches!(
2529                writer
2530                    .respond(AgentRequest::StoreBlob {
2531                        digest: digest.clone(),
2532                        source,
2533                    })
2534                    .await,
2535                AgentResponse::Stored { .. }
2536            ));
2537        }
2538        assert!(matches!(
2539            writer
2540                .respond(AgentRequest::StoreActionResult {
2541                    result: result.clone(),
2542                })
2543                .await,
2544            AgentResponse::ActionStored { .. }
2545        ));
2546        let run = writer.begin_task(&task).await.unwrap();
2547        assert!(matches!(
2548            writer
2549                .respond(AgentRequest::RecordActionPrediction {
2550                    task: run.clone(),
2551                    prediction: prediction.clone(),
2552                })
2553                .await,
2554            AgentResponse::ActionPredictionRecorded
2555        ));
2556        writer.commit_task(&run).await.unwrap();
2557
2558        let reader = remote_agent(
2559            &server,
2560            directory.path().join("reader"),
2561            RemoteCacheMode::ReadOnly,
2562        );
2563        let run = reader.begin_task(&task).await.unwrap();
2564        reader.wait_for_prefetches().await;
2565        assert!(matches!(
2566            reader
2567                .respond(AgentRequest::FindActionPrediction {
2568                    task: run,
2569                    invocation,
2570                })
2571                .await,
2572            AgentResponse::ActionPrediction {
2573                prediction: Some(found)
2574            } if found == prediction
2575        ));
2576        assert!(matches!(
2577            reader
2578                .respond(AgentRequest::FindActionResult {
2579                    action: action.clone(),
2580                })
2581                .await,
2582            AgentResponse::ActionResult {
2583                result: Some(found)
2584            } if found == result
2585        ));
2586        for digest in [&action, &metadata, &output_root] {
2587            assert!(matches!(
2588                reader
2589                    .respond(AgentRequest::FindBlob {
2590                        digest: digest.clone(),
2591                    })
2592                    .await,
2593                AgentResponse::Blob { path: Some(_) }
2594            ));
2595        }
2596        assert!(matches!(
2597            reader
2598                .respond(AgentRequest::RecordActionHit {
2599                    action,
2600                    restore: RestoreStats::default(),
2601                })
2602                .await,
2603            AgentResponse::ActionHitRecorded
2604        ));
2605        for mock in mocks {
2606            mock.assert_async().await;
2607        }
2608        let stats = reader.stats();
2609        assert_eq!(stats.prefetch_runs, 1);
2610        assert_eq!(stats.prefetched_actions, 1);
2611        assert!(stats.remote_manifest_lookups > 0);
2612        assert!(stats.remote_action_lookups > 0);
2613        assert!(stats.remote_blob_requests > 0);
2614        assert!(stats.remote_manifest_lookup_duration_ns > 0);
2615        assert!(stats.remote_action_lookup_duration_ns > 0);
2616        assert!(stats.remote_blob_transfer_duration_ns > 0);
2617        assert!(stats.local_cas_write_duration_ns > 0);
2618        assert!(stats.prefetch_duration_ns > 0);
2619    }
2620
2621    #[tokio::test]
2622    async fn keeps_newer_local_predictions_when_remote_manifest_is_stale() {
2623        let directory = tempfile::tempdir().unwrap();
2624        let mut server = mockito::Server::new_async().await;
2625        let task = "f".repeat(64);
2626        let invocation = CacheDigest::blake3(b"shared invocation");
2627        let local_prediction = ActionPrediction {
2628            invocation: invocation.clone(),
2629            action: CacheDigest::blake3(b"new local action"),
2630            adapter: "rustc".into(),
2631            payload: "{}".into(),
2632        };
2633        let remote_prediction = ActionPrediction {
2634            invocation: invocation.clone(),
2635            action: CacheDigest::blake3(b"stale remote action"),
2636            adapter: "rustc".into(),
2637            payload: "{}".into(),
2638        };
2639        let remote_manifest = TaskActionManifest {
2640            version: TASK_ACTION_MANIFEST_VERSION,
2641            task: task.clone(),
2642            predictions: vec![remote_prediction],
2643        };
2644        let remote_bytes = canonical_json(&remote_manifest).unwrap();
2645        let remote_etag = blake3::hash(&remote_bytes).to_hex().to_string();
2646        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2647        let remote = server
2648            .mock("GET", action_manifest_path(&selector).as_str())
2649            .with_status(200)
2650            .with_header("etag", &format!("\"{remote_etag}\""))
2651            .with_body(remote_bytes)
2652            .expect(1)
2653            .create_async()
2654            .await;
2655
2656        let agent = remote_agent(
2657            &server,
2658            directory.path().join("reader"),
2659            RemoteCacheMode::ReadOnly,
2660        );
2661        agent
2662            .persist_task_manifest(&TaskActionManifest {
2663                version: TASK_ACTION_MANIFEST_VERSION,
2664                task: task.clone(),
2665                predictions: vec![local_prediction.clone()],
2666            })
2667            .unwrap();
2668
2669        let run = agent.begin_task(&task).await.unwrap();
2670        assert!(matches!(
2671            agent
2672                .respond(AgentRequest::FindActionPrediction {
2673                    task: run,
2674                    invocation,
2675                })
2676                .await,
2677            AgentResponse::ActionPrediction {
2678                prediction: Some(found)
2679            } if found == local_prediction
2680        ));
2681        let persisted = agent.load_task_manifest(&task).unwrap().unwrap();
2682        assert_eq!(persisted.predictions, vec![local_prediction]);
2683        remote.assert_async().await;
2684    }
2685
2686    #[tokio::test]
2687    async fn prefetch_does_not_block_task_initialization() {
2688        let directory = tempfile::tempdir().unwrap();
2689        let mut server = mockito::Server::new_async().await;
2690        let task = "9".repeat(64);
2691        let invocation = CacheDigest::blake3(b"prefetched invocation");
2692        let action_bytes = b"prefetched action";
2693        let action = CacheDigest::blake3(action_bytes);
2694        let result = RemoteActionResult {
2695            action: action.clone(),
2696            metadata: None,
2697            output_root: None,
2698            version: 1,
2699        };
2700        let manifest_bytes = canonical_json(&TaskActionManifest {
2701            version: TASK_ACTION_MANIFEST_VERSION,
2702            task: task.clone(),
2703            predictions: vec![ActionPrediction {
2704                invocation,
2705                action: action.clone(),
2706                adapter: "rustc".into(),
2707                payload: "{}".into(),
2708            }],
2709        })
2710        .unwrap();
2711        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
2712        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2713        let manifest = server
2714            .mock("GET", action_manifest_path(&selector).as_str())
2715            .with_status(200)
2716            .with_header("etag", &format!("\"{manifest_etag}\""))
2717            .with_body(manifest_bytes)
2718            .expect(1)
2719            .create_async()
2720            .await;
2721        let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
2722        let response_release = release.clone();
2723        let result_bytes = serde_json::to_vec(&result).unwrap();
2724        let action_result = server
2725            .mock("GET", action_path(&action).as_str())
2726            .with_status(200)
2727            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2728            .with_chunked_body(move |writer| {
2729                let (released, condition) = &*response_release;
2730                let mut released = released.lock().unwrap();
2731                while !*released {
2732                    released = condition.wait(released).unwrap();
2733                }
2734                std::io::Write::write_all(writer, &result_bytes)
2735            })
2736            .expect(1)
2737            .create_async()
2738            .await;
2739        let action_blob = server
2740            .mock("GET", blob_path(&action).as_str())
2741            .with_status(200)
2742            .with_body(action_bytes)
2743            .expect(1)
2744            .create_async()
2745            .await;
2746        let agent = remote_agent(
2747            &server,
2748            directory.path().join("reader"),
2749            RemoteCacheMode::ReadOnly,
2750        );
2751
2752        let begin = tokio::time::timeout(Duration::from_secs(2), agent.begin_task(&task)).await;
2753        let (released, condition) = &*release;
2754        *released.lock().unwrap() = true;
2755        condition.notify_all();
2756        let run = begin
2757            .expect("task initialization waited for prefetch")
2758            .unwrap();
2759        assert_eq!(
2760            agent
2761                .task_actions
2762                .lock()
2763                .unwrap()
2764                .get(&run)
2765                .unwrap()
2766                .predictions
2767                .len(),
2768            1
2769        );
2770        agent.wait_for_prefetches().await;
2771        manifest.assert_async().await;
2772        action_result.assert_async().await;
2773        action_blob.assert_async().await;
2774        assert!(agent.actions.find(&action).unwrap().is_some());
2775    }
2776
2777    #[tokio::test]
2778    async fn prefetches_complete_actions_in_directory_wave_blob_packs() {
2779        let directory = tempfile::tempdir().unwrap();
2780        let mut server = mockito::Server::new_async().await;
2781        let action_bytes = b"packed action descriptor";
2782        let stdout_bytes = b"packed stdout";
2783        let stderr_bytes = b"packed stderr";
2784        let artifact_bytes = b"packed artifact";
2785        let action = CacheDigest::blake3(action_bytes);
2786        let stdout = CacheDigest::blake3(stdout_bytes);
2787        let stderr = CacheDigest::blake3(stderr_bytes);
2788        let artifact = CacheDigest::blake3(artifact_bytes);
2789        let metadata_bytes = canonical_json(&RustcMetadata {
2790            version: 1,
2791            kind: "rustc".into(),
2792            stdout: stdout.clone(),
2793            stderr: stderr.clone(),
2794        })
2795        .unwrap();
2796        let metadata = CacheDigest::blake3(&metadata_bytes);
2797        let directory_bytes = canonical_json(&serde_json::json!({
2798            "directories": [],
2799            "files": [{
2800                "digest": artifact,
2801                "executable": false,
2802                "mode": 420,
2803                "name": "artifact",
2804            }],
2805            "symlinks": [],
2806            "version": 1,
2807        }))
2808        .unwrap();
2809        let output_root = CacheDigest::blake3(&directory_bytes);
2810        let result = RemoteActionResult {
2811            action: action.clone(),
2812            metadata: Some(metadata.clone()),
2813            output_root: Some(output_root.clone()),
2814            version: 1,
2815        };
2816        let action_result = server
2817            .mock("GET", action_path(&action).as_str())
2818            .with_status(200)
2819            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2820            .with_body(serde_json::to_vec(&result).unwrap())
2821            .expect(1)
2822            .create_async()
2823            .await;
2824        let capabilities = server
2825            .mock("GET", "/v1/capabilities")
2826            .with_status(200)
2827            .with_header("content-type", "application/json")
2828            .with_body(
2829                serde_json::json!({
2830                    "protocol":{"major":1},
2831                    "features":{"blob_packs":true},
2832                    "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
2833                })
2834                .to_string(),
2835            )
2836            .expect(1)
2837            .create_async()
2838            .await;
2839        let mut top = vec![
2840            (action.clone(), action_bytes.as_slice()),
2841            (metadata.clone(), metadata_bytes.as_slice()),
2842            (output_root.clone(), directory_bytes.as_slice()),
2843        ];
2844        top.sort_by(|left, right| left.0.cmp(&right.0));
2845        let first_pack = server
2846            .mock("POST", "/v1/blobs:pack")
2847            .match_body(mockito::Matcher::Json(serde_json::json!({
2848                "digests": top.iter().map(|(digest, _)| digest).collect::<Vec<_>>()
2849            })))
2850            .with_status(200)
2851            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2852            .with_body(blob_pack_body(&top))
2853            .expect(1)
2854            .create_async()
2855            .await;
2856        let mut leaves = vec![
2857            (stdout.clone(), stdout_bytes.as_slice()),
2858            (stderr.clone(), stderr_bytes.as_slice()),
2859            (artifact.clone(), artifact_bytes.as_slice()),
2860        ];
2861        leaves.sort_by(|left, right| left.0.cmp(&right.0));
2862        let second_pack = server
2863            .mock("POST", "/v1/blobs:pack")
2864            .match_body(mockito::Matcher::Json(serde_json::json!({
2865                "digests": leaves.iter().map(|(digest, _)| digest).collect::<Vec<_>>()
2866            })))
2867            .with_status(200)
2868            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2869            .with_body(blob_pack_body(&leaves))
2870            .expect(1)
2871            .create_async()
2872            .await;
2873        let agent = remote_agent(
2874            &server,
2875            directory.path().join("reader"),
2876            RemoteCacheMode::ReadOnly,
2877        );
2878
2879        agent
2880            .prefetch_action(action.clone(), "rustc".into())
2881            .await
2882            .unwrap();
2883
2884        assert_eq!(agent.actions.find(&action).unwrap(), Some(result));
2885        let stats = agent.stats();
2886        assert_eq!(stats.prefetched_actions, 1);
2887        assert_eq!(stats.remote_blob_requests, 0);
2888        assert_eq!(stats.remote_blob_pack_requests, 2);
2889        assert_eq!(stats.remote_blob_pack_blobs, 6);
2890        action_result.assert_async().await;
2891        capabilities.assert_async().await;
2892        first_pack.assert_async().await;
2893        second_pack.assert_async().await;
2894    }
2895
2896    #[tokio::test]
2897    async fn foreground_blob_batches_use_blob_packs() {
2898        let directory = tempfile::tempdir().unwrap();
2899        let mut server = mockito::Server::new_async().await;
2900        let mut entries = [
2901            (CacheDigest::blake3(b"first"), b"first".as_slice()),
2902            (CacheDigest::blake3(b"second"), b"second".as_slice()),
2903        ];
2904        entries.sort_by(|left, right| left.0.cmp(&right.0));
2905        let requested = entries
2906            .iter()
2907            .map(|(digest, _)| digest.clone())
2908            .collect::<Vec<_>>();
2909        let response_requested = vec![
2910            entries[0].0.clone(),
2911            entries[1].0.clone(),
2912            entries[0].0.clone(),
2913        ];
2914        let capabilities = server
2915            .mock("GET", "/v1/capabilities")
2916            .with_status(200)
2917            .with_header("content-type", "application/json")
2918            .with_body(
2919                serde_json::json!({
2920                    "protocol":{"major":1},
2921                    "features":{"blob_packs":true},
2922                    "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
2923                })
2924                .to_string(),
2925            )
2926            .expect(1)
2927            .create_async()
2928            .await;
2929        let pack = server
2930            .mock("POST", "/v1/blobs:pack")
2931            .match_body(mockito::Matcher::Json(serde_json::json!({
2932                "digests": requested.clone()
2933            })))
2934            .with_status(200)
2935            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2936            .with_body(blob_pack_body(&entries))
2937            .expect(1)
2938            .create_async()
2939            .await;
2940        let agent = remote_agent(
2941            &server,
2942            directory.path().join("reader"),
2943            RemoteCacheMode::ReadOnly,
2944        );
2945        let response = agent
2946            .respond(AgentRequest::FindBlobs {
2947                digests: response_requested,
2948            })
2949            .await;
2950
2951        let AgentResponse::Blobs { paths } = response else {
2952            panic!("unexpected blob lookup response");
2953        };
2954        assert_eq!(paths.len(), 3);
2955        for (expected, path) in [entries[0].1, entries[1].1, entries[0].1]
2956            .into_iter()
2957            .zip(paths)
2958        {
2959            assert_eq!(fs::read(path.unwrap()).unwrap(), expected);
2960        }
2961        let stats = agent.stats();
2962        assert_eq!(stats.remote_blob_requests, 0);
2963        assert_eq!(stats.remote_blob_pack_requests, 1);
2964        assert_eq!(stats.remote_blob_pack_blobs, 2);
2965        capabilities.assert_async().await;
2966        pack.assert_async().await;
2967    }
2968
2969    #[tokio::test]
2970    async fn preserves_successful_pack_metrics_when_a_later_chunk_falls_back() {
2971        let directory = tempfile::tempdir().unwrap();
2972        let mut server = mockito::Server::new_async().await;
2973        let mut entries = [
2974            (CacheDigest::blake3(b"first"), b"first".as_slice()),
2975            (CacheDigest::blake3(b"second"), b"second".as_slice()),
2976        ];
2977        entries.sort_by(|left, right| left.0.cmp(&right.0));
2978        let (first_digest, first_bytes) = entries[0].clone();
2979        let (second_digest, second_bytes) = entries[1].clone();
2980        let capabilities = server
2981            .mock("GET", "/v1/capabilities")
2982            .with_status(200)
2983            .with_header("content-type", "application/json")
2984            .with_body(
2985                serde_json::json!({
2986                    "protocol":{"major":1},
2987                    "features":{"blob_packs":true},
2988                    "limits":{"max_batch_items":1,"max_pack_bytes":1048576}
2989                })
2990                .to_string(),
2991            )
2992            .expect(1)
2993            .create_async()
2994            .await;
2995        let first_pack = server
2996            .mock("POST", "/v1/blobs:pack")
2997            .match_body(mockito::Matcher::Json(serde_json::json!({
2998                "digests": [&first_digest]
2999            })))
3000            .with_status(200)
3001            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
3002            .with_body(blob_pack_body(&[(first_digest.clone(), first_bytes)]))
3003            .expect(1)
3004            .create_async()
3005            .await;
3006        let failed_pack = server
3007            .mock("POST", "/v1/blobs:pack")
3008            .match_body(mockito::Matcher::Json(serde_json::json!({
3009                "digests": [&second_digest]
3010            })))
3011            .with_status(500)
3012            .expect(1)
3013            .create_async()
3014            .await;
3015        let fallback = server
3016            .mock("GET", blob_path(&second_digest).as_str())
3017            .with_status(200)
3018            .with_body(second_bytes)
3019            .expect(1)
3020            .create_async()
3021            .await;
3022        let agent = remote_agent(
3023            &server,
3024            directory.path().join("reader"),
3025            RemoteCacheMode::ReadOnly,
3026        );
3027        let remote = agent.remote.as_deref().unwrap();
3028
3029        let verified = agent
3030            .fetch_remote_blobs(
3031                remote,
3032                vec![first_digest.clone(), second_digest.clone()],
3033                Some(&agent.prefetch_transfers),
3034            )
3035            .await;
3036
3037        assert_eq!(verified.len(), 2);
3038        assert_eq!(fs::read(&verified[&first_digest]).unwrap(), first_bytes);
3039        assert_eq!(fs::read(&verified[&second_digest]).unwrap(), second_bytes);
3040        let stats = agent.stats();
3041        assert_eq!(stats.remote_blob_pack_requests, 1);
3042        assert_eq!(stats.remote_blob_pack_blobs, 1);
3043        assert_eq!(stats.remote_blob_requests, 1);
3044        capabilities.assert_async().await;
3045        first_pack.assert_async().await;
3046        failed_pack.assert_async().await;
3047        fallback.assert_async().await;
3048    }
3049
3050    #[tokio::test]
3051    async fn malformed_blob_pack_metadata_falls_back_to_individual_blobs() {
3052        let directory = tempfile::tempdir().unwrap();
3053        let mut server = mockito::Server::new_async().await;
3054        let bytes = b"fallback blob";
3055        let digest = CacheDigest::blake3(bytes);
3056        server
3057            .mock("GET", "/v1/capabilities")
3058            .with_status(200)
3059            .with_header("content-type", "application/json")
3060            .with_body(
3061                serde_json::json!({
3062                    "protocol":{"major":1},
3063                    "features":{"blob_packs":true},
3064                    "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
3065                })
3066                .to_string(),
3067            )
3068            .expect(1)
3069            .create_async()
3070            .await;
3071        let pack = server
3072            .mock("POST", "/v1/blobs:pack")
3073            .with_status(200)
3074            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
3075            .with_header(crate::BLOB_PACK_BYTES_HEADER, "not-a-number")
3076            .with_body(blob_pack_body(&[(digest.clone(), bytes.as_slice())]))
3077            .expect(1)
3078            .create_async()
3079            .await;
3080        let fallback = server
3081            .mock("GET", blob_path(&digest).as_str())
3082            .with_status(200)
3083            .with_body(bytes)
3084            .expect(1)
3085            .create_async()
3086            .await;
3087        let agent = remote_agent(
3088            &server,
3089            directory.path().join("reader"),
3090            RemoteCacheMode::ReadOnly,
3091        );
3092        let remote = agent.remote.as_deref().unwrap();
3093
3094        let verified = agent
3095            .fetch_remote_blobs(remote, vec![digest.clone()], None)
3096            .await;
3097
3098        assert_eq!(fs::read(&verified[&digest]).unwrap(), bytes);
3099        let stats = agent.stats();
3100        assert_eq!(stats.remote_blob_pack_requests, 0);
3101        assert_eq!(stats.remote_blob_pack_blobs, 0);
3102        assert_eq!(stats.remote_blob_requests, 1);
3103        pack.assert_async().await;
3104        fallback.assert_async().await;
3105    }
3106
3107    #[tokio::test]
3108    async fn foreground_action_lookup_does_not_wait_for_prefetch_output() {
3109        let directory = tempfile::tempdir().unwrap();
3110        let mut server = mockito::Server::new_async().await;
3111        let action_bytes = b"prefetched action";
3112        let artifact_bytes = b"prefetched artifact";
3113        let action = CacheDigest::blake3(action_bytes);
3114        let artifact = CacheDigest::blake3(artifact_bytes);
3115        let directory_bytes = canonical_json(&serde_json::json!({
3116            "directories": [],
3117            "files": [{
3118                "digest": artifact,
3119                "executable": false,
3120                "mode": 420,
3121                "name": "artifact",
3122            }],
3123            "symlinks": [],
3124            "version": 1,
3125        }))
3126        .unwrap();
3127        let output_root = CacheDigest::blake3(&directory_bytes);
3128        let result = RemoteActionResult {
3129            action: action.clone(),
3130            metadata: None,
3131            output_root: Some(output_root.clone()),
3132            version: 1,
3133        };
3134        let action_result = server
3135            .mock("GET", action_path(&action).as_str())
3136            .with_status(200)
3137            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
3138            .with_body(serde_json::to_vec(&result).unwrap())
3139            .expect(1)
3140            .create_async()
3141            .await;
3142        let action_blob = server
3143            .mock("GET", blob_path(&action).as_str())
3144            .with_status(200)
3145            .with_body(action_bytes)
3146            .expect(1)
3147            .create_async()
3148            .await;
3149        let output_directory = server
3150            .mock("GET", blob_path(&output_root).as_str())
3151            .with_status(200)
3152            .with_body(directory_bytes)
3153            .expect(1)
3154            .create_async()
3155            .await;
3156        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
3157        let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
3158        let response_started = started.clone();
3159        let response_release = release.clone();
3160        let artifact_blob = server
3161            .mock("GET", blob_path(&artifact).as_str())
3162            .with_status(200)
3163            .with_chunked_body(move |writer| {
3164                response_started.store(true, Ordering::Release);
3165                while !response_release.load(Ordering::Acquire) {
3166                    std::thread::sleep(Duration::from_millis(10));
3167                }
3168                std::io::Write::write_all(writer, artifact_bytes)
3169            })
3170            .expect(1)
3171            .create_async()
3172            .await;
3173        let agent = remote_agent(
3174            &server,
3175            directory.path().join("reader"),
3176            RemoteCacheMode::ReadOnly,
3177        );
3178        let prefetch_agent = agent.clone();
3179        let prefetch_action = action.clone();
3180        let prefetch = tokio::spawn(async move {
3181            prefetch_agent
3182                .prefetch_action(prefetch_action, "rustc".into())
3183                .await
3184        });
3185        tokio::time::timeout(Duration::from_secs(1), async {
3186            while !started.load(Ordering::Acquire) {
3187                tokio::time::sleep(Duration::from_millis(10)).await;
3188            }
3189        })
3190        .await
3191        .expect("prefetch did not request the output blob");
3192
3193        let foreground = tokio::time::timeout(
3194            Duration::from_millis(250),
3195            agent.find_action_result(&action),
3196        )
3197        .await;
3198        release.store(true, Ordering::Release);
3199        prefetch.await.unwrap().unwrap();
3200        let foreground = foreground.expect("foreground action lookup waited for output prefetch");
3201
3202        assert!(matches!(
3203            foreground.unwrap(),
3204            AgentResponse::ActionResult {
3205                result: Some(found)
3206            } if found == result
3207        ));
3208        action_result.assert_async().await;
3209        action_blob.assert_async().await;
3210        output_directory.assert_async().await;
3211        artifact_blob.assert_async().await;
3212    }
3213
3214    #[tokio::test]
3215    async fn session_completion_cancels_outstanding_prefetches() {
3216        let directory = tempfile::tempdir().unwrap();
3217        let agent = CacheAgent::new(directory.path(), "test-version");
3218        let task = tokio::spawn(std::future::pending::<()>());
3219        agent.prefetch_tasks.lock().unwrap().push(task);
3220
3221        tokio::time::timeout(Duration::from_secs(1), agent.cancel_prefetches())
3222            .await
3223            .expect("prefetch cancellation blocked session completion");
3224        assert!(agent.prefetch_tasks.lock().unwrap().is_empty());
3225    }
3226
3227    #[tokio::test]
3228    async fn prefetch_reserves_capacity_for_foreground_transfers() {
3229        let transfers = tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS);
3230        let _prefetch = transfers
3231            .acquire_many(MAX_PREFETCH_TRANSFERS as u32)
3232            .await
3233            .unwrap();
3234        assert!(transfers.available_permits() > 0);
3235    }
3236
3237    #[tokio::test]
3238    async fn prefetches_output_files_concurrently() {
3239        let directory = tempfile::tempdir().unwrap();
3240        let (responses, output_root) = output_tree_responses(8);
3241        let (base_url, maximum_in_flight, server) =
3242            delayed_blob_server(responses, Duration::from_millis(50)).await;
3243        let agent = remote_agent_url(
3244            base_url,
3245            directory.path().join("reader"),
3246            RemoteCacheMode::ReadOnly,
3247        );
3248
3249        agent
3250            .prefetch_output_tree(agent.remote.as_deref().unwrap(), &output_root)
3251            .await
3252            .unwrap();
3253        server.await.unwrap();
3254
3255        assert!(maximum_in_flight.load(Ordering::Relaxed) > 1);
3256    }
3257
3258    #[tokio::test]
3259    #[ignore = "local remote-cache throughput benchmark"]
3260    async fn benchmark_prefetch_output_tree_latency() {
3261        let files = std::env::var("MBX_BENCH_FILES")
3262            .ok()
3263            .and_then(|value| value.parse().ok())
3264            .unwrap_or(96);
3265        let latency = Duration::from_millis(
3266            std::env::var("MBX_BENCH_LATENCY_MS")
3267                .ok()
3268                .and_then(|value| value.parse().ok())
3269                .unwrap_or(100),
3270        );
3271
3272        let directory = tempfile::tempdir().unwrap();
3273        let (responses, output_root) = output_tree_responses(files);
3274        let (base_url, maximum_in_flight, server) = delayed_blob_server(responses, latency).await;
3275        let agent = remote_agent_url(
3276            base_url,
3277            directory.path().join("reader"),
3278            RemoteCacheMode::ReadOnly,
3279        );
3280        let remote = agent.remote.as_deref().unwrap();
3281
3282        let started = std::time::Instant::now();
3283        agent
3284            .prefetch_output_tree(remote, &output_root)
3285            .await
3286            .unwrap();
3287        let elapsed = started.elapsed();
3288
3289        eprintln!(
3290            "prefetched {files} blobs with {} ms latency in {elapsed:?}",
3291            latency.as_millis()
3292        );
3293        server.await.unwrap();
3294        eprintln!(
3295            "maximum concurrent requests: {}",
3296            maximum_in_flight.load(Ordering::Relaxed)
3297        );
3298    }
3299
3300    fn output_tree_responses(files: usize) -> (BTreeMap<String, Vec<u8>>, CacheDigest) {
3301        let mut entries = Vec::with_capacity(files);
3302        let mut responses = BTreeMap::new();
3303        for index in 0..files {
3304            let body = format!("cached artifact {index}").into_bytes();
3305            let digest = CacheDigest::blake3(&body);
3306            entries.push(serde_json::json!({
3307                "digest": digest,
3308                "executable": false,
3309                "mode": 420,
3310                "name": format!("artifact-{index}"),
3311            }));
3312            responses.insert(blob_path(&digest), body);
3313        }
3314        let directory = canonical_json(&serde_json::json!({
3315            "directories": [],
3316            "files": entries,
3317            "symlinks": [],
3318            "version": 1,
3319        }))
3320        .unwrap();
3321        let output_root = CacheDigest::blake3(&directory);
3322        responses.insert(blob_path(&output_root), directory);
3323        (responses, output_root)
3324    }
3325
3326    fn blob_pack_body(entries: &[(CacheDigest, &[u8])]) -> Vec<u8> {
3327        let mut pack = crate::BLOB_PACK_MAGIC.to_vec();
3328        for (digest, bytes) in entries {
3329            pack.push(match digest.algorithm.as_str() {
3330                "blake3" => 1,
3331                "sha256" => 2,
3332                algorithm => panic!("unexpected test digest algorithm {algorithm}"),
3333            });
3334            pack.extend(hex::decode(&digest.hash).unwrap());
3335            pack.extend(digest.size.to_be_bytes());
3336            pack.extend_from_slice(bytes);
3337        }
3338        pack
3339    }
3340
3341    async fn delayed_blob_server(
3342        responses: BTreeMap<String, Vec<u8>>,
3343        latency: Duration,
3344    ) -> (
3345        url::Url,
3346        Arc<std::sync::atomic::AtomicUsize>,
3347        tokio::task::JoinHandle<()>,
3348    ) {
3349        use std::sync::atomic::AtomicUsize;
3350        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3351
3352        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3353        let address = listener.local_addr().unwrap();
3354        let responses = Arc::new(responses);
3355        let request_count = responses.len();
3356        let in_flight = Arc::new(AtomicUsize::new(0));
3357        let maximum_in_flight = Arc::new(AtomicUsize::new(0));
3358        let observed_maximum = maximum_in_flight.clone();
3359        let server = tokio::spawn(async move {
3360            let mut requests = tokio::task::JoinSet::new();
3361            for _ in 0..request_count {
3362                let (mut socket, _) = listener.accept().await.unwrap();
3363                let responses = responses.clone();
3364                let in_flight = in_flight.clone();
3365                let maximum_in_flight = maximum_in_flight.clone();
3366                requests.spawn(async move {
3367                    let mut request = Vec::new();
3368                    loop {
3369                        let mut chunk = [0; 1024];
3370                        let size = socket.read(&mut chunk).await.unwrap();
3371                        assert!(size > 0, "client closed before sending request headers");
3372                        request.extend_from_slice(&chunk[..size]);
3373                        if request.windows(4).any(|window| window == b"\r\n\r\n") {
3374                            break;
3375                        }
3376                    }
3377                    let request = String::from_utf8_lossy(&request);
3378                    let path = request
3379                        .lines()
3380                        .next()
3381                        .and_then(|line| line.split_whitespace().nth(1))
3382                        .unwrap();
3383                    let body = responses.get(path).unwrap();
3384                    let active = in_flight.fetch_add(1, Ordering::Relaxed) + 1;
3385                    maximum_in_flight.fetch_max(active, Ordering::Relaxed);
3386                    tokio::time::sleep(latency).await;
3387                    socket
3388                        .write_all(
3389                            format!(
3390                                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
3391                                body.len()
3392                            )
3393                            .as_bytes(),
3394                        )
3395                        .await
3396                        .unwrap();
3397                    socket.write_all(body).await.unwrap();
3398                    in_flight.fetch_sub(1, Ordering::Relaxed);
3399                });
3400            }
3401            while requests.join_next().await.is_some() {}
3402        });
3403        (
3404            format!("http://{address}").parse().unwrap(),
3405            observed_maximum,
3406            server,
3407        )
3408    }
3409
3410    fn remote_agent(
3411        server: &mockito::ServerGuard,
3412        cache_dir: PathBuf,
3413        mode: RemoteCacheMode,
3414    ) -> CacheAgent {
3415        remote_agent_url(server.url().parse().unwrap(), cache_dir, mode)
3416    }
3417
3418    fn remote_agent_url(
3419        base_url: url::Url,
3420        cache_dir: PathBuf,
3421        mode: RemoteCacheMode,
3422    ) -> CacheAgent {
3423        let client = RemoteCacheClient::new(crate::RemoteCacheConfig {
3424            base_url,
3425            namespace: "test".into(),
3426            token: None,
3427            token_file: None,
3428            oidc_audience: None,
3429            connect_timeout: Duration::from_secs(1),
3430            read_timeout: Duration::from_secs(1),
3431            download_timeout: Duration::from_secs(1),
3432            retries: 0,
3433        })
3434        .unwrap();
3435        CacheAgent::new_remote(
3436            &cache_dir,
3437            "test-version",
3438            AgentRemoteCache {
3439                client,
3440                mode,
3441                staging_dir: cache_dir.join("remote"),
3442            },
3443        )
3444    }
3445
3446    fn blob_path(digest: &CacheDigest) -> String {
3447        format!(
3448            "/v1/blobs/{}/{}/{}",
3449            digest.algorithm, digest.hash, digest.size
3450        )
3451    }
3452
3453    fn action_path(digest: &CacheDigest) -> String {
3454        format!(
3455            "/v1/action-results/{}/{}/{}",
3456            digest.algorithm, digest.hash, digest.size
3457        )
3458    }
3459
3460    fn action_manifest_path(digest: &CacheDigest) -> String {
3461        format!(
3462            "/v1/action-manifests/{}/{}/{}",
3463            digest.algorithm, digest.hash, digest.size
3464        )
3465    }
3466
3467    #[tokio::test]
3468    async fn merges_overlapping_runs_into_one_task_manifest() {
3469        let directory = tempfile::tempdir().unwrap();
3470        let cache = directory.path().join("cache");
3471        let task = "d".repeat(64);
3472        let agent = CacheAgent::new(&cache, "test-version");
3473        let first_run = agent.begin_task(&task).await.unwrap();
3474        let second_run = agent.begin_task(&task).await.unwrap();
3475        assert_ne!(first_run, second_run);
3476        let first_invocation = CacheDigest::blake3(b"overlap one");
3477        let second_invocation = CacheDigest::blake3(b"overlap two");
3478        for (run, invocation) in [
3479            (&first_run, &first_invocation),
3480            (&second_run, &second_invocation),
3481        ] {
3482            assert!(matches!(
3483                agent
3484                    .respond(AgentRequest::RecordActionPrediction {
3485                        task: run.clone(),
3486                        prediction: ActionPrediction {
3487                            invocation: invocation.clone(),
3488                            action: CacheDigest::blake3(invocation.hash.as_bytes()),
3489                            adapter: "rustc".into(),
3490                            payload: "{}".into(),
3491                        },
3492                    })
3493                    .await,
3494                AgentResponse::ActionPredictionRecorded
3495            ));
3496        }
3497        agent.commit_task(&first_run).await.unwrap();
3498        agent.commit_task(&second_run).await.unwrap();
3499
3500        let next = CacheAgent::new(cache, "test-version");
3501        let run = next.begin_task(&task).await.unwrap();
3502        for invocation in [first_invocation, second_invocation] {
3503            assert!(matches!(
3504                next.respond(AgentRequest::FindActionPrediction {
3505                    task: run.clone(),
3506                    invocation,
3507                })
3508                .await,
3509                AgentResponse::ActionPrediction {
3510                    prediction: Some(_)
3511                }
3512            ));
3513        }
3514    }
3515
3516    #[test]
3517    fn keeps_local_manifest_when_remote_merge_exceeds_prediction_limit() {
3518        let task = "7".repeat(64);
3519        let prediction = |index: usize| {
3520            let digest = CacheDigest::blake3(&index.to_le_bytes());
3521            ActionPrediction {
3522                invocation: digest.clone(),
3523                action: digest,
3524                adapter: "rustc".into(),
3525                payload: "{}".into(),
3526            }
3527        };
3528        let local = TaskActionManifest {
3529            version: TASK_ACTION_MANIFEST_VERSION,
3530            task: task.clone(),
3531            predictions: (0..MAX_TASK_ACTION_PREDICTIONS).map(prediction).collect(),
3532        };
3533        let expected_first = local.predictions[0].clone();
3534        let remote = TaskActionManifest {
3535            version: TASK_ACTION_MANIFEST_VERSION,
3536            task: task.clone(),
3537            predictions: vec![prediction(MAX_TASK_ACTION_PREDICTIONS)],
3538        };
3539
3540        let (manifest, merged) = merge_remote_task_manifest(&task, remote, local);
3541        assert!(!merged);
3542        assert_eq!(manifest.predictions.len(), MAX_TASK_ACTION_PREDICTIONS);
3543        assert_eq!(manifest.predictions[0], expected_first);
3544    }
3545
3546    #[test]
3547    fn task_manifest_lock_is_shared_across_agents() {
3548        let directory = tempfile::tempdir().unwrap();
3549        let cache = directory.path().join("cache");
3550        let first = CacheAgent::new(&cache, "test-version");
3551        let second = CacheAgent::new(&cache, "test-version");
3552        let task = "8".repeat(64);
3553
3554        let first_lock = first.lock_task_manifest(&task).unwrap();
3555        let mut contender = fslock::LockFile::open(&second.task_manifest_lock_path(&task)).unwrap();
3556        assert!(!contender.try_lock().unwrap());
3557        drop(first_lock);
3558        assert!(contender.try_lock().unwrap());
3559    }
3560
3561    #[tokio::test]
3562    async fn memoizes_client_observed_executable_identities() {
3563        let directory = tempfile::tempdir().unwrap();
3564        let agent = CacheAgent::new(directory.path(), "test-version");
3565        let executable = directory.path().join("rustc");
3566        let environment = BTreeMap::from([("RUSTUP_TOOLCHAIN".into(), Some("stable".into()))]);
3567
3568        let response = agent
3569            .respond(AgentRequest::FindExecutableIdentity {
3570                executable: executable.clone(),
3571                environment: environment.clone(),
3572            })
3573            .await;
3574        assert!(matches!(
3575            response,
3576            AgentResponse::ExecutableIdentity { stdout: None }
3577        ));
3578
3579        let response = agent
3580            .respond(AgentRequest::StoreExecutableIdentity {
3581                executable: executable.clone(),
3582                environment: environment.clone(),
3583                stdout: b"rustc identity".to_vec(),
3584            })
3585            .await;
3586        assert!(matches!(
3587            response,
3588            AgentResponse::ExecutableIdentity {
3589                stdout: Some(stdout)
3590            } if stdout == b"rustc identity"
3591        ));
3592
3593        let response = agent
3594            .respond(AgentRequest::FindExecutableIdentity {
3595                executable,
3596                environment,
3597            })
3598            .await;
3599        assert!(matches!(
3600            response,
3601            AgentResponse::ExecutableIdentity {
3602                stdout: Some(stdout)
3603            } if stdout == b"rustc identity"
3604        ));
3605    }
3606
3607    #[test]
3608    fn bounds_executable_identity_entry_count() {
3609        let directory = tempfile::tempdir().unwrap();
3610        let agent = CacheAgent::new(directory.path(), "test-version");
3611        for index in 0..MAX_EXECUTABLE_IDENTITIES {
3612            agent
3613                .store_executable_identity(
3614                    directory.path().join(format!("rustc-{index}")),
3615                    BTreeMap::new(),
3616                    vec![b'x'],
3617                )
3618                .unwrap();
3619        }
3620
3621        assert!(
3622            agent
3623                .store_executable_identity(
3624                    directory.path().join("one-too-many"),
3625                    BTreeMap::new(),
3626                    vec![b'x'],
3627                )
3628                .is_err()
3629        );
3630    }
3631
3632    #[test]
3633    fn bounds_executable_identity_retained_bytes() {
3634        let directory = tempfile::tempdir().unwrap();
3635        let agent = CacheAgent::new(directory.path(), "test-version");
3636        for index in 0..MAX_EXECUTABLE_IDENTITY_BYTES / MAX_EXECUTABLE_IDENTITY_SIZE {
3637            agent
3638                .store_executable_identity(
3639                    directory.path().join(format!("rustc-{index}")),
3640                    BTreeMap::new(),
3641                    vec![b'x'; MAX_EXECUTABLE_IDENTITY_SIZE],
3642                )
3643                .unwrap();
3644        }
3645
3646        assert!(
3647            agent
3648                .store_executable_identity(
3649                    directory.path().join("one-byte-too-many"),
3650                    BTreeMap::new(),
3651                    vec![b'x'],
3652                )
3653                .is_err()
3654        );
3655    }
3656
3657    #[tokio::test]
3658    async fn version_skew_is_a_handshake_miss() {
3659        let directory = tempfile::tempdir().unwrap();
3660        let agent = CacheAgent::new(directory.path(), "agent-version");
3661        let (mut client, server) = tokio::io::duplex(1024);
3662        let task = tokio::spawn(async move { agent.handle_connection(server).await });
3663        let request = AgentRequest::Hello {
3664            protocol: AGENT_PROTOCOL_VERSION,
3665            client_version: "other-version".into(),
3666        };
3667        let mut encoded = serde_json::to_vec(&request).unwrap();
3668        encoded.push(b'\n');
3669        client.write_all(&encoded).await.unwrap();
3670        let mut response = String::new();
3671        BufReader::new(&mut client)
3672            .read_line(&mut response)
3673            .await
3674            .unwrap();
3675
3676        assert!(matches!(
3677            serde_json::from_str(&response).unwrap(),
3678            AgentResponse::Error { .. }
3679        ));
3680        task.await.unwrap().unwrap();
3681    }
3682}