Skip to main content

mbx_cache_core/
agent.rs

1use crate::uploads::{ConnectionUploads, UploadQueue, UploadSink};
2use crate::{
3    ActionPrediction, ActionPromiseCompletion, ActionPromiseState, BlobPackLimits, CacheDigest,
4    CacheDirectory, LocalActionCache, LocalCas, MAX_STAGED_BLOB_PACK_BYTES,
5    MAX_STAGED_BLOB_PACK_ITEMS, ManifestPutOutcome, RemoteActionResult, RemoteCacheClient,
6    RemoteCacheMode, RustcMetadata, TaskActionManifest, blob_pack_chunk, canonical_json,
7};
8use eyre::{Context, Result, bail};
9use futures_util::{FutureExt, StreamExt, future::BoxFuture, stream};
10use log::warn;
11use std::collections::{BTreeMap, BTreeSet};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, Weak};
16use std::time::{Duration, Instant, SystemTime};
17
18/// Fleet claims are leases rather than correctness locks. This only bounds how
19/// long one shim waits through repeated pending responses before degrading to
20/// an ordinary compilation; the server independently expires abandoned claims.
21const ACTION_PROMISE_WAIT: Duration = Duration::from_secs(60 * 60);
22use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
23
24mod file_digest;
25mod manifest;
26mod prefetch;
27mod stats;
28mod wire;
29
30#[cfg(test)]
31pub(crate) use prefetch::select_prefetch_actions;
32
33pub use file_digest::{
34    FileDigestCache, FileDigestScope, FileIdentity, NoFileDigestCache, RecordedFileDigest,
35};
36pub use manifest::{is_task_identity, task_manifest_actions};
37use manifest::{
38    merge_remote_task_manifest, merge_task_manifests, task_manifest_dir, validate_task_identity,
39    validate_task_manifest,
40};
41pub use stats::{AgentStats, CompilerStats};
42use wire::MAX_REQUEST_BYTES;
43pub use wire::{
44    AGENT_PROTOCOL_VERSION, ActionDiagnostic, AgentEvent, AgentEventObserver, AgentRequest,
45    AgentResponse, RestoreStats,
46};
47
48const MAX_EXECUTABLE_IDENTITIES: usize = 64;
49const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
50const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
51const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
52const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
53/// Longest shim diagnostic the agent accepts, in bytes.
54const MAX_WARNING_BYTES: usize = 4 * 1024;
55/// Most distinct shim diagnostics one session surfaces before going quiet.
56///
57/// A failure mode that repeats across a build tends to repeat with the same
58/// message, which deduplication already collapses; the cap only guards
59/// against a message that embeds something unique per compilation.
60const MAX_WARNINGS: usize = 128;
61const ACTION_DIAGNOSTIC_PREFIX: &str = "@mbx-action-diagnostic\t";
62/// Most file identities one digest lookup or record may carry.
63const MAX_FILE_DIGEST_BATCH: usize = 16 * 1024;
64/// Most recorded file digests one session retains across every scope.
65///
66/// Roughly two orders of magnitude above the largest workspace measured; the
67/// cap exists so a pathological build bounds the agent instead of growing it.
68const MAX_FILE_DIGEST_ENTRIES: usize = 1024 * 1024;
69const MAX_REMOTE_TRANSFERS: usize = 64;
70const MAX_PREFETCH_TRANSFERS: usize = 48;
71/// Most predicted actions whose complete output closures are downloaded
72/// speculatively for one task.
73///
74/// Manifests deliberately retain predictions across compatible builds, so a
75/// large workspace can describe far more actions than the next invocation
76/// will request. Keep the most expensive recorded actions warm and let
77/// foreground lookups fetch only unusually large tails on demand. The remote
78/// download-byte budget remains the primary bound on speculative transfer. A
79/// 1,024-action ceiling covers the measured large-workspace manifests while
80/// retaining a guard against pathological manifests and stale prediction sets.
81const MAX_PREFETCH_ACTIONS: usize = 1024;
82// Resolve speculative actions progressively so the most valuable predictions
83// become usable first and cancellation at the end of a build abandons a small
84// tail instead of one workspace-sized transfer wave. Action-result lookup is
85// still batched independently; this only bounds each output-closure download.
86const MAX_PREFETCH_ACTION_WAVE: usize = 32;
87/// Batched action lookups issued at once.
88///
89/// One request already asks about hundreds of actions. Serial batches keep a
90/// fleet of concurrent CI jobs from multiplying metadata pressure while blob
91/// transfers from earlier answers are also underway.
92const MAX_PREFETCH_BATCH_LOOKUPS: usize = 1;
93const PREFETCH_ACTION_BATCH_DELAY: Duration = Duration::from_millis(5);
94const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
95const MAX_PREFETCH_OBJECTS_PER_WAVE: usize = 100_000;
96const DEFAULT_MAX_REMOTE_DOWNLOAD_BYTES: u64 = 5 * 1024 * 1024 * 1024;
97
98/// Remote action-cache access owned by one task session.
99pub struct AgentRemoteCache {
100    /// Remote protocol client used by the agent.
101    pub client: RemoteCacheClient,
102    /// Permitted remote read/write operations.
103    pub mode: RemoteCacheMode,
104    /// Directory used for verified downloads before CAS ingestion.
105    pub staging_dir: PathBuf,
106}
107
108#[derive(Default)]
109struct AtomicAgentStats {
110    lookups: AtomicU64,
111    unconsulted: AtomicU64,
112    hits: AtomicU64,
113    stores: AtomicU64,
114    stored_bytes: AtomicU64,
115    verifications: AtomicU64,
116    divergences: AtomicU64,
117    downloaded_bytes: AtomicU64,
118    uploaded_bytes: AtomicU64,
119    background_uploads: AtomicU64,
120    background_upload_failures: AtomicU64,
121    remote_blob_pack_uploads: AtomicU64,
122    remote_blob_pack_upload_blobs: AtomicU64,
123    upload_drain_duration_ns: AtomicU64,
124    prefetched_actions: AtomicU64,
125    predictions_loaded: AtomicU64,
126    remote_failures: AtomicU64,
127    remote_manifest_lookups: AtomicU64,
128    remote_manifest_lookup_duration_ns: AtomicU64,
129    remote_action_lookups: AtomicU64,
130    remote_action_lookup_duration_ns: AtomicU64,
131    remote_blob_requests: AtomicU64,
132    remote_blob_pack_requests: AtomicU64,
133    remote_blob_pack_blobs: AtomicU64,
134    remote_blob_transfer_duration_ns: AtomicU64,
135    local_cas_write_duration_ns: AtomicU64,
136    prefetch_runs: AtomicU64,
137    prefetch_duration_ns: AtomicU64,
138    materialization_duration_ns: AtomicU64,
139    bypasses: Mutex<BTreeMap<String, u64>>,
140    avoided_compiler_duration_ns: AtomicU64,
141    compiler: Mutex<BTreeMap<String, CompilerStats>>,
142    slow_compilations: Mutex<BTreeMap<String, u64>>,
143    restored_output_files: AtomicU64,
144    restored_output_bytes: AtomicU64,
145    reflinked_output_files: AtomicU64,
146    reflinked_output_bytes: AtomicU64,
147    copied_output_files: AtomicU64,
148    copied_output_bytes: AtomicU64,
149    reused_output_files: AtomicU64,
150    reused_output_bytes: AtomicU64,
151}
152
153struct AtomicDurationTimer<'a> {
154    started: Instant,
155    target: &'a AtomicU64,
156}
157
158impl<'a> AtomicDurationTimer<'a> {
159    fn start(target: &'a AtomicU64) -> Self {
160        Self {
161            started: Instant::now(),
162            target,
163        }
164    }
165}
166
167impl Drop for AtomicDurationTimer<'_> {
168    fn drop(&mut self) {
169        atomic_saturating_add(self.target, duration_ns(self.started));
170    }
171}
172
173fn duration_ns(started: Instant) -> u64 {
174    started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX)
175}
176
177fn validate_crate_name(crate_name: Option<&str>) -> Result<()> {
178    if let Some(crate_name) = crate_name
179        && (crate_name.len() > 256 || crate_name.contains(['\0', '\n', '\r']))
180    {
181        bail!("invalid compiler crate name");
182    }
183    Ok(())
184}
185
186#[derive(serde::Deserialize)]
187struct ActionDiagnosticEnvelope {
188    outcome: String,
189    crate_name: Option<String>,
190    diagnostic: ActionDiagnostic,
191}
192
193fn parse_action_diagnostic(
194    message: &str,
195) -> Option<Result<(String, Option<String>, ActionDiagnostic)>> {
196    let payload = message.strip_prefix(ACTION_DIAGNOSTIC_PREFIX)?;
197    Some((|| {
198        let envelope: ActionDiagnosticEnvelope = serde_json::from_str(payload)?;
199        if !matches!(envelope.outcome.as_str(), "hit" | "miss") {
200            bail!("invalid action diagnostic outcome");
201        }
202        validate_crate_name(envelope.crate_name.as_deref())?;
203        Ok((envelope.outcome, envelope.crate_name, envelope.diagnostic))
204    })())
205}
206
207fn atomic_saturating_add(target: &AtomicU64, value: u64) {
208    let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
209        Some(current.saturating_add(value))
210    });
211}
212
213fn queue_prefetch_digest(
214    verified: &BTreeMap<CacheDigest, PathBuf>,
215    pending: &mut BTreeMap<CacheDigest, ()>,
216    digest: CacheDigest,
217) {
218    if verified.contains_key(&digest) || pending.contains_key(&digest) {
219        return;
220    }
221    pending.insert(digest, ());
222}
223
224fn queue_prefetch_directory(
225    seen: &BTreeMap<CacheDigest, ()>,
226    pending: &mut BTreeMap<CacheDigest, ()>,
227    digest: CacheDigest,
228    limit: usize,
229) -> bool {
230    if seen.contains_key(&digest) || pending.contains_key(&digest) {
231        return true;
232    }
233    if seen.len().saturating_add(pending.len()) >= limit {
234        return false;
235    }
236    pending.insert(digest, ());
237    true
238}
239
240/// Shared state for an agent hosted by the process that owns a build session.
241///
242/// Transport listeners deliberately live in the embedder so the session
243/// lifecycle owns them. This type only contains ecosystem-independent CAS and
244/// protocol logic.
245#[derive(Clone)]
246pub struct CacheAgent {
247    cas: LocalCas,
248    actions: LocalActionCache,
249    verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, VerifiedBlob>>>,
250    version: Arc<str>,
251    write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
252    action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
253    stats: Arc<AtomicAgentStats>,
254    observer: Option<Arc<dyn AgentEventObserver>>,
255    observer_emission: Arc<Mutex<()>>,
256    executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
257    manifest_dir: Arc<PathBuf>,
258    task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
259    next_task_run: Arc<AtomicU64>,
260    manifest_write_lock: Arc<Mutex<()>>,
261    remote: Option<Arc<RemoteCacheClient>>,
262    remote_mode: RemoteCacheMode,
263    remote_staging_dir: Arc<PathBuf>,
264    remote_download_limit: u64,
265    remote_download_bytes: Arc<AtomicU64>,
266    pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
267    remote_transfers: Arc<tokio::sync::Semaphore>,
268    prefetch_transfers: Arc<tokio::sync::Semaphore>,
269    prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
270    /// Distinct shim diagnostics already surfaced, so a warning that fires
271    /// once per compilation is printed once per session.
272    warnings: Arc<Mutex<BTreeSet<String>>>,
273    /// Digests of files shims hashed or wrote this session, keyed by scope and
274    /// path, each entry standing while its recorded identity matches the disk.
275    file_digests: Arc<Mutex<BTreeMap<(FileDigestScope, PathBuf), RecordedFileDigest>>>,
276    /// Deferred remote publication, present only when the session may write.
277    uploads: Option<UploadQueue>,
278}
279
280/// What every file-digest record must satisfy before the ledger will answer
281/// with it, whether a shim sent it or an earlier session left it behind.
282fn validate_file_digest_record(entry: &RecordedFileDigest) -> Result<()> {
283    if !entry.file.path.is_absolute() {
284        bail!("file-digest records need absolute paths");
285    }
286    entry.digest.validate()?;
287    if entry.file.len != entry.digest.size {
288        bail!("file-digest record length does not match its digest");
289    }
290    Ok(())
291}
292
293/// Records background upload activity against a session's statistics.
294struct AgentUploadSink {
295    stats: Arc<AtomicAgentStats>,
296}
297
298impl UploadSink for AgentUploadSink {
299    fn record_blob_uploaded(&self, bytes: u64) {
300        self.stats
301            .background_uploads
302            .fetch_add(1, Ordering::Relaxed);
303        self.stats
304            .uploaded_bytes
305            .fetch_add(bytes, Ordering::Relaxed);
306    }
307
308    fn record_action_uploaded(&self) {
309        self.stats
310            .background_uploads
311            .fetch_add(1, Ordering::Relaxed);
312    }
313
314    fn record_blob_pack_uploaded(&self, blobs: u64) {
315        self.stats
316            .remote_blob_pack_uploads
317            .fetch_add(1, Ordering::Relaxed);
318        self.stats
319            .remote_blob_pack_upload_blobs
320            .fetch_add(blobs, Ordering::Relaxed);
321    }
322
323    fn record_upload_failure(&self) {
324        self.stats
325            .background_upload_failures
326            .fetch_add(1, Ordering::Relaxed);
327        self.stats.remote_failures.fetch_add(1, Ordering::Relaxed);
328    }
329}
330
331/// A CAS blob whose contents this session hashed, and the file identity that
332/// says the hash still describes what is on disk.
333///
334/// Length alone would miss an overwrite that keeps the size; the modification
335/// time is what makes a rewrite visible without reading the bytes again.
336#[derive(Debug, Clone)]
337struct VerifiedBlob {
338    path: PathBuf,
339    len: u64,
340    modified: SystemTime,
341}
342
343impl VerifiedBlob {
344    /// Describe a file that was just verified, or nothing when the filesystem
345    /// will not report a modification time to compare against later.
346    fn describe(path: &Path) -> Option<Self> {
347        let metadata = std::fs::metadata(path).ok()?;
348        Some(Self {
349            path: path.to_path_buf(),
350            len: metadata.len(),
351            modified: metadata.modified().ok()?,
352        })
353    }
354
355    /// Whether the file still has the identity it had when it was verified.
356    fn is_unchanged(&self) -> bool {
357        let Ok(metadata) = std::fs::metadata(&self.path) else {
358            return false;
359        };
360        metadata.len() == self.len && metadata.modified().is_ok_and(|now| now == self.modified)
361    }
362}
363
364#[derive(Debug, Clone, Default)]
365struct TaskActionState {
366    manifest: String,
367    baseline_loaded: bool,
368    predictions: BTreeMap<CacheDigest, ActionPrediction>,
369    pending_predictions: BTreeMap<CacheDigest, ActionPrediction>,
370    remote_etag: Option<String>,
371}
372
373struct PrefetchedAction {
374    adapter: String,
375    result: RemoteActionResult,
376}
377
378struct RemoteDownloadReservation {
379    counter: Arc<AtomicU64>,
380    reserved: u64,
381    committed: bool,
382}
383
384impl RemoteDownloadReservation {
385    fn bytes(&self) -> u64 {
386        self.reserved
387    }
388
389    fn commit(mut self, bytes: u64) {
390        debug_assert!(bytes <= self.reserved);
391        self.counter
392            .fetch_sub(self.reserved.saturating_sub(bytes), Ordering::AcqRel);
393        self.committed = true;
394    }
395}
396
397impl Drop for RemoteDownloadReservation {
398    fn drop(&mut self) {
399        if !self.committed {
400            self.counter.fetch_sub(self.reserved, Ordering::AcqRel);
401        }
402    }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
406struct ExecutableIdentityKey {
407    executable: PathBuf,
408    environment: BTreeMap<String, Option<String>>,
409}
410
411impl CacheAgent {
412    /// Create an agent backed by the cache rooted at `cache_dir`.
413    pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
414        Self::build(cache_dir.into(), version.into(), None, 0)
415    }
416
417    /// Create an agent with local-first access to a remote action cache.
418    pub fn new_remote(
419        cache_dir: impl Into<PathBuf>,
420        version: impl Into<Arc<str>>,
421        remote: AgentRemoteCache,
422    ) -> Self {
423        Self::build(
424            cache_dir.into(),
425            version.into(),
426            Some(remote),
427            DEFAULT_MAX_REMOTE_DOWNLOAD_BYTES,
428        )
429    }
430
431    /// Create a remote agent with a cumulative download budget for the session.
432    pub fn new_remote_with_download_limit(
433        cache_dir: impl Into<PathBuf>,
434        version: impl Into<Arc<str>>,
435        remote: AgentRemoteCache,
436        max_remote_download_bytes: u64,
437    ) -> Self {
438        Self::build(
439            cache_dir.into(),
440            version.into(),
441            Some(remote),
442            max_remote_download_bytes,
443        )
444    }
445
446    fn build(
447        cache_dir: PathBuf,
448        version: Arc<str>,
449        remote: Option<AgentRemoteCache>,
450        remote_download_limit: u64,
451    ) -> Self {
452        let remote_mode = remote
453            .as_ref()
454            .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
455        let remote_staging_dir = remote.as_ref().map_or_else(
456            || cache_dir.join("remote"),
457            |remote| remote.staging_dir.clone(),
458        );
459        let remote = remote.map(|remote| Arc::new(remote.client));
460        let stats = Arc::new(AtomicAgentStats::default());
461        let remote_transfers = Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS));
462        // A session that cannot publish never queues an upload, so it does not
463        // need somewhere to queue one.
464        let uploads = remote
465            .clone()
466            .filter(|_| remote_mode.writes())
467            .map(|client| {
468                UploadQueue::new(
469                    client,
470                    Arc::new(AgentUploadSink {
471                        stats: stats.clone(),
472                    }),
473                    remote_transfers.clone(),
474                )
475            });
476        Self {
477            cas: LocalCas::new(cache_dir.clone()),
478            actions: LocalActionCache::new(cache_dir.clone()),
479            verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
480            version,
481            write_locks: Arc::new(Mutex::new(BTreeMap::new())),
482            action_locks: Arc::new(Mutex::new(BTreeMap::new())),
483            stats,
484            observer: None,
485            observer_emission: Arc::new(Mutex::new(())),
486            executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
487            manifest_dir: Arc::new(task_manifest_dir(&cache_dir)),
488            task_actions: Arc::new(Mutex::new(BTreeMap::new())),
489            next_task_run: Arc::new(AtomicU64::new(0)),
490            manifest_write_lock: Arc::new(Mutex::new(())),
491            remote,
492            remote_mode,
493            remote_staging_dir: Arc::new(remote_staging_dir),
494            remote_download_limit,
495            remote_download_bytes: Arc::new(AtomicU64::new(0)),
496            pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
497            remote_transfers,
498            prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
499            prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
500            warnings: Arc::new(Mutex::new(BTreeSet::new())),
501            file_digests: Arc::new(Mutex::new(BTreeMap::new())),
502            uploads,
503        }
504    }
505
506    /// Report each accounted cache decision to `observer` as it happens.
507    #[must_use]
508    pub fn with_observer(mut self, observer: Arc<dyn AgentEventObserver>) -> Self {
509        self.observer = Some(observer);
510        self
511    }
512
513    fn emit(&self, event: impl FnOnce() -> AgentEvent) {
514        if let Some(observer) = &self.observer {
515            let _emission = self.observer_emission.lock().unwrap();
516            observer.event(event());
517        }
518    }
519
520    fn emit_action(&self, diagnostic: Option<AgentEvent>, action: AgentEvent) {
521        if let Some(observer) = &self.observer {
522            let _emission = self.observer_emission.lock().unwrap();
523            if let Some(diagnostic) = diagnostic {
524                observer.event(diagnostic);
525            }
526            observer.event(action);
527        }
528    }
529
530    fn reserve_remote_download(&self, bytes: u64) -> Result<RemoteDownloadReservation> {
531        let mut current = self.remote_download_bytes.load(Ordering::Acquire);
532        loop {
533            let next = current
534                .checked_add(bytes)
535                .ok_or_else(|| eyre::eyre!("remote cache download budget overflowed"))?;
536            if next > self.remote_download_limit {
537                bail!(
538                    "remote cache download budget exceeded: {} bytes requested with {} of {} bytes already used",
539                    bytes,
540                    current,
541                    self.remote_download_limit
542                );
543            }
544            match self.remote_download_bytes.compare_exchange_weak(
545                current,
546                next,
547                Ordering::AcqRel,
548                Ordering::Acquire,
549            ) {
550                Ok(_) => {
551                    return Ok(RemoteDownloadReservation {
552                        counter: self.remote_download_bytes.clone(),
553                        reserved: bytes,
554                        committed: false,
555                    });
556                }
557                Err(observed) => current = observed,
558            }
559        }
560    }
561
562    fn reserve_remote_download_up_to(&self, requested: u64) -> Result<RemoteDownloadReservation> {
563        let mut current = self.remote_download_bytes.load(Ordering::Acquire);
564        loop {
565            let reserved = requested.min(self.remote_download_limit.saturating_sub(current));
566            let next = current + reserved;
567            match self.remote_download_bytes.compare_exchange_weak(
568                current,
569                next,
570                Ordering::AcqRel,
571                Ordering::Acquire,
572            ) {
573                Ok(_) => {
574                    return Ok(RemoteDownloadReservation {
575                        counter: self.remote_download_bytes.clone(),
576                        reserved,
577                        committed: false,
578                    });
579                }
580                Err(observed) => current = observed,
581            }
582        }
583    }
584
585    /// Load the last committed action manifest for a task into this session.
586    pub async fn begin_task(&self, task: &str) -> Result<String> {
587        self.begin_task_with_remote_errors(task, false, None).await
588    }
589
590    /// Load a task using its identity as the run key.
591    ///
592    /// A task-scoped process has only one run for an identity, so it can defer
593    /// this work until its first client connects while putting the identity in
594    /// the client's environment ahead of time.
595    pub async fn begin_session_task(&self, task: &str) -> Result<()> {
596        self.begin_task_with_remote_errors(task, false, Some(task.to_string()))
597            .await?;
598        Ok(())
599    }
600
601    /// Load a task and finish its prefetch, surfacing remote lookup failures.
602    pub async fn prefetch_task(&self, task: &str) -> Result<String> {
603        let run = self.begin_task_with_remote_errors(task, true, None).await?;
604        self.wait_for_prefetches().await;
605        Ok(run)
606    }
607
608    async fn begin_task_with_remote_errors(
609        &self,
610        task: &str,
611        strict: bool,
612        run: Option<String>,
613    ) -> Result<String> {
614        validate_task_identity(task)?;
615        // The local manifest is already enough to start useful work. Do not
616        // leave those predictions idle while a high-latency remote answers the
617        // manifest lookup; the authoritative snapshot is loaded again below
618        // before it is merged, so another process can still update it while
619        // this request is in flight.
620        let early_predictions = {
621            let _write_guard = self.manifest_write_lock.lock().unwrap();
622            let _file_guard = self.lock_task_manifest(task)?;
623            self.load_task_manifest(task)?
624                .map(|manifest| manifest.predictions)
625                .unwrap_or_default()
626        };
627        let early_actions: BTreeSet<_> = early_predictions
628            .iter()
629            .map(|prediction| prediction.action.clone())
630            .collect();
631        self.spawn_prefetch_predictions(early_predictions);
632        let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
633            match self.get_remote_task_manifest(task).await {
634                Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
635                Ok(None) => (None, None),
636                Err(error) => {
637                    if strict {
638                        return Err(error).wrap_err_with(|| {
639                            format!("remote task action manifest lookup failed for {task}")
640                        });
641                    }
642                    self.note_remote_failure();
643                    warn!("remote task action manifest lookup failed for {task}: {error}");
644                    (None, None)
645                }
646            }
647        } else {
648            (None, None)
649        };
650        let manifest = {
651            let _write_guard = self.manifest_write_lock.lock().unwrap();
652            let _file_guard = self.lock_task_manifest(task)?;
653            let local_manifest = self.load_task_manifest(task)?;
654            let manifest = match (remote_manifest, local_manifest) {
655                (Some(remote), Some(local)) => {
656                    let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
657                    if !merged {
658                        remote_etag = None;
659                    }
660                    Some(manifest)
661                }
662                (Some(remote), None) => Some(remote),
663                (None, local) => local,
664            };
665            if let Some(manifest) = &manifest {
666                self.persist_task_manifest(manifest)?;
667            }
668            manifest
669        };
670        let state = if let Some(manifest) = manifest {
671            TaskActionState {
672                manifest: task.to_string(),
673                baseline_loaded: true,
674                predictions: manifest
675                    .predictions
676                    .into_iter()
677                    .map(|prediction| (prediction.invocation.clone(), prediction))
678                    .collect(),
679                pending_predictions: BTreeMap::new(),
680                remote_etag,
681            }
682        } else {
683            TaskActionState {
684                manifest: task.to_string(),
685                baseline_loaded: true,
686                remote_etag,
687                ..TaskActionState::default()
688            }
689        };
690        let run = run.unwrap_or_else(|| {
691            let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
692            CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
693                .hash
694        });
695        // The largest baseline rather than a sum: beginning the same task again
696        // in one session reloads the same manifest, and counting it twice would
697        // overstate what there was to match.
698        self.stats.predictions_loaded.fetch_max(
699            state.predictions.len().try_into().unwrap_or(u64::MAX),
700            Ordering::Relaxed,
701        );
702        // The early local wave already owns these actions. Only launch a
703        // second wave for predictions learned from the remote or from a local
704        // writer that committed while its lookup was in flight.
705        let predictions = state
706            .predictions
707            .values()
708            .filter(|prediction| !early_actions.contains(&prediction.action))
709            .cloned()
710            .collect();
711        self.task_actions.lock().unwrap().insert(run.clone(), state);
712        self.spawn_prefetch_predictions(predictions);
713        Ok(run)
714    }
715
716    /// Cancel speculative downloads before the owning session exits.
717    pub async fn cancel_prefetches(&self) {
718        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
719        for task in &tasks {
720            task.abort();
721        }
722        for task in tasks {
723            if let Err(error) = task.await
724                && !error.is_cancelled()
725            {
726                warn!("remote action prefetch task failed: {error}");
727            }
728        }
729    }
730
731    /// Publish everything a build queued, before the session stops.
732    ///
733    /// Store requests return once an object is durable locally, so at this point
734    /// the remote cache may still be behind the local one. Uploads run on the
735    /// session's runtime and are abandoned if it goes away, so a session that
736    /// wants them published has to wait here.
737    pub async fn wait_for_uploads(&self) {
738        let Some(uploads) = &self.uploads else {
739            return;
740        };
741        let _timer = AtomicDurationTimer::start(&self.stats.upload_drain_duration_ns);
742        uploads.drain().await;
743    }
744
745    async fn wait_for_prefetches(&self) {
746        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
747        for task in tasks {
748            if let Err(error) = task.await {
749                warn!("remote action prefetch task failed: {error}");
750            }
751        }
752    }
753
754    /// Atomically publish the completed actions collected by a task run.
755    pub async fn commit_task(&self, run: &str) -> Result<()> {
756        self.commit_task_actions(run).await.map(|_| ())
757    }
758
759    /// Publish a task run and return exactly the predictions completed by it.
760    ///
761    /// The persisted task manifest also carries predictions inherited from
762    /// earlier runs. Callers that need a receipt for this one run must not
763    /// mistake that cumulative manifest for the work the run completed.
764    pub async fn commit_task_actions(&self, run: &str) -> Result<Vec<ActionPrediction>> {
765        validate_task_identity(run)?;
766        let state = self
767            .task_actions
768            .lock()
769            .unwrap()
770            .get(run)
771            .cloned()
772            .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
773        if !state.baseline_loaded {
774            bail!("task action manifest baseline was not loaded");
775        }
776        let task = state.manifest;
777        validate_task_identity(&task)?;
778        let completed = state
779            .pending_predictions
780            .values()
781            .cloned()
782            .collect::<Vec<_>>();
783        let (manifest, introduced) = {
784            let _write_guard = self.manifest_write_lock.lock().unwrap();
785            let _file_guard = self.lock_task_manifest(&task)?;
786            let mut predictions = self
787                .load_task_manifest(&task)?
788                .map(|manifest| {
789                    manifest
790                        .predictions
791                        .into_iter()
792                        .map(|prediction| (prediction.invocation.clone(), prediction))
793                        .collect::<BTreeMap<_, _>>()
794                })
795                .unwrap_or_default();
796            // Which predictions this run adds, as opposed to inherits. Only a
797            // new one may be withheld for a failed upload: retracting an
798            // inherited one would un-advertise a result that is plausibly still
799            // on the server from whichever session put it there.
800            let introduced: BTreeSet<CacheDigest> = state
801                .pending_predictions
802                .keys()
803                .filter(|invocation| !predictions.contains_key(*invocation))
804                .cloned()
805                .collect();
806            // Only publish predictions recorded by this run. `predictions`
807            // also contains the baseline loaded by `begin_task`; extending
808            // with that snapshot would overwrite newer entries committed by
809            // another agent process after this run began.
810            predictions.extend(state.pending_predictions);
811            let manifest = TaskActionManifest {
812                version: TASK_ACTION_MANIFEST_VERSION,
813                task: task.clone(),
814                predictions: predictions.into_values().collect(),
815            };
816            validate_task_manifest(&manifest, &task)?;
817            self.persist_task_manifest(&manifest)?;
818            (manifest, introduced)
819        };
820        self.task_actions.lock().unwrap().remove(run);
821        if self.remote_mode.writes() {
822            // A manifest advertises the actions it predicts, so it must not
823            // reach the remote cache before the results a reader would then go
824            // looking for -- nor name a result that never got there at all.
825            let mut manifest = manifest;
826            if let Some(uploads) = &self.uploads {
827                let actions: Vec<CacheDigest> = manifest
828                    .predictions
829                    .iter()
830                    .map(|prediction| prediction.action.clone())
831                    .collect();
832                let unpublished = uploads.wait_for_actions(&actions).await;
833                let withheld = manifest
834                    .predictions
835                    .iter()
836                    .filter(|prediction| {
837                        introduced.contains(&prediction.invocation)
838                            && unpublished.contains(&prediction.action)
839                    })
840                    .count();
841                if withheld > 0 {
842                    // The local manifest keeps them: this checkout can still use
843                    // what it built, and a later session can publish it.
844                    warn!(
845                        "{withheld} of {} predicted actions were not published, so the remote task action manifest omits them",
846                        manifest.predictions.len()
847                    );
848                    manifest.predictions.retain(|prediction| {
849                        !(introduced.contains(&prediction.invocation)
850                            && unpublished.contains(&prediction.action))
851                    });
852                }
853            }
854            match self
855                .put_remote_task_manifest(&task, manifest, state.remote_etag)
856                .await
857            {
858                Ok(remote_manifest) => {
859                    let _write_guard = self.manifest_write_lock.lock().unwrap();
860                    let reconciliation = (|| {
861                        let _file_guard = self.lock_task_manifest(&task)?;
862                        let manifest = match self.load_task_manifest(&task)? {
863                            Some(local) => {
864                                merge_remote_task_manifest(&task, remote_manifest, local).0
865                            }
866                            None => remote_manifest,
867                        };
868                        self.persist_task_manifest(&manifest)
869                    })();
870                    if let Err(error) = reconciliation {
871                        warn!(
872                            "remote task action manifest reconciliation failed for {task}: {error}"
873                        );
874                    }
875                }
876                Err(error) => {
877                    self.note_remote_failure();
878                    warn!("remote task action manifest upload failed for {task}: {error}");
879                }
880            }
881        }
882        Ok(completed)
883    }
884
885    fn task_manifest_path(&self, task: &str) -> PathBuf {
886        self.manifest_dir.join(format!("{task}.json"))
887    }
888
889    fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
890        self.manifest_dir.join("locks").join(format!("{task}.lock"))
891    }
892
893    fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
894        let path = self.task_manifest_lock_path(task);
895        fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
896        let mut lock = fslock::LockFile::open(&path)?;
897        lock.lock()?;
898        Ok(lock)
899    }
900
901    fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
902        match fs::read(self.task_manifest_path(task)) {
903            Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
904            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
905            Err(error) => Err(error.into()),
906        }
907    }
908
909    fn parse_task_manifest(
910        &self,
911        task: &str,
912        contents: &[u8],
913        require_canonical: bool,
914    ) -> Result<TaskActionManifest> {
915        let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
916        validate_task_manifest(&manifest, task)?;
917        if require_canonical && canonical_json(&manifest)? != contents {
918            bail!("task action manifest is not canonical JSON");
919        }
920        Ok(manifest)
921    }
922
923    fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
924        TaskActionManifest::selector(task)
925    }
926
927    fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
928        let bytes = canonical_json(manifest)?;
929        fs::create_dir_all(self.manifest_dir.as_path())?;
930        let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
931        std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
932        temporary.as_file_mut().sync_all()?;
933        temporary
934            .persist(self.task_manifest_path(&manifest.task))
935            .map_err(|error| error.error)?;
936        Ok(())
937    }
938
939    async fn get_remote_task_manifest(
940        &self,
941        task: &str,
942    ) -> Result<Option<(TaskActionManifest, String)>> {
943        let Some(remote) = &self.remote else {
944            return Ok(None);
945        };
946        let (_, selector) = Self::task_manifest_selector(task)?;
947        let _permit = self.remote_transfers.acquire().await?;
948        self.stats
949            .remote_manifest_lookups
950            .fetch_add(1, Ordering::Relaxed);
951        let _timer = AtomicDurationTimer::start(&self.stats.remote_manifest_lookup_duration_ns);
952        let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
953            return Ok(None);
954        };
955        let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
956        Ok(Some((manifest, remote_manifest.etag)))
957    }
958
959    async fn put_remote_task_manifest(
960        &self,
961        task: &str,
962        mut manifest: TaskActionManifest,
963        mut expected_etag: Option<String>,
964    ) -> Result<TaskActionManifest> {
965        let Some(remote) = &self.remote else {
966            return Ok(manifest);
967        };
968        let (_, selector) = Self::task_manifest_selector(task)?;
969        for _ in 0..4 {
970            let bytes = canonical_json(&manifest)?;
971            let outcome = {
972                let _permit = self.remote_transfers.acquire().await?;
973                remote
974                    .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
975                    .await?
976            };
977            match outcome {
978                ManifestPutOutcome::Stored => return Ok(manifest),
979                ManifestPutOutcome::PreconditionFailed => {
980                    let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
981                    else {
982                        expected_etag = None;
983                        continue;
984                    };
985                    manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
986                    expected_etag = Some(etag);
987                }
988            }
989        }
990        bail!("remote task action manifest changed too frequently")
991    }
992
993    /// Return a snapshot of this session's cache activity.
994    pub fn stats(&self) -> AgentStats {
995        AgentStats {
996            session_duration_ns: 0,
997            lookups: self.stats.lookups.load(Ordering::Relaxed),
998            unconsulted: self.stats.unconsulted.load(Ordering::Relaxed),
999            hits: self.stats.hits.load(Ordering::Relaxed),
1000            stores: self.stats.stores.load(Ordering::Relaxed),
1001            stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
1002            verifications: self.stats.verifications.load(Ordering::Relaxed),
1003            divergences: self.stats.divergences.load(Ordering::Relaxed),
1004            downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
1005            uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
1006            background_uploads: self.stats.background_uploads.load(Ordering::Relaxed),
1007            background_upload_failures: self
1008                .stats
1009                .background_upload_failures
1010                .load(Ordering::Relaxed),
1011            remote_blob_pack_uploads: self.stats.remote_blob_pack_uploads.load(Ordering::Relaxed),
1012            remote_blob_pack_upload_blobs: self
1013                .stats
1014                .remote_blob_pack_upload_blobs
1015                .load(Ordering::Relaxed),
1016            upload_drain_duration_ns: self.stats.upload_drain_duration_ns.load(Ordering::Relaxed),
1017            prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
1018            predictions_loaded: self.stats.predictions_loaded.load(Ordering::Relaxed),
1019            bypasses: self.stats.bypasses.lock().unwrap().clone(),
1020            avoided_compiler_duration_ns: self
1021                .stats
1022                .avoided_compiler_duration_ns
1023                .load(Ordering::Relaxed),
1024            compiler: self.stats.compiler.lock().unwrap().clone(),
1025            slow_compilations: self.stats.slow_compilations.lock().unwrap().clone(),
1026            remote_failures: self.stats.remote_failures.load(Ordering::Relaxed),
1027            remote_manifest_lookups: self.stats.remote_manifest_lookups.load(Ordering::Relaxed),
1028            remote_manifest_lookup_duration_ns: self
1029                .stats
1030                .remote_manifest_lookup_duration_ns
1031                .load(Ordering::Relaxed),
1032            remote_action_lookups: self.stats.remote_action_lookups.load(Ordering::Relaxed),
1033            remote_action_lookup_duration_ns: self
1034                .stats
1035                .remote_action_lookup_duration_ns
1036                .load(Ordering::Relaxed),
1037            remote_blob_requests: self.stats.remote_blob_requests.load(Ordering::Relaxed),
1038            remote_blob_pack_requests: self.stats.remote_blob_pack_requests.load(Ordering::Relaxed),
1039            remote_blob_pack_blobs: self.stats.remote_blob_pack_blobs.load(Ordering::Relaxed),
1040            remote_blob_transfer_duration_ns: self
1041                .stats
1042                .remote_blob_transfer_duration_ns
1043                .load(Ordering::Relaxed),
1044            local_cas_write_duration_ns: self
1045                .stats
1046                .local_cas_write_duration_ns
1047                .load(Ordering::Relaxed),
1048            prefetch_runs: self.stats.prefetch_runs.load(Ordering::Relaxed),
1049            prefetch_duration_ns: self.stats.prefetch_duration_ns.load(Ordering::Relaxed),
1050            materialization_duration_ns: self
1051                .stats
1052                .materialization_duration_ns
1053                .load(Ordering::Relaxed),
1054            restored_output_files: self.stats.restored_output_files.load(Ordering::Relaxed),
1055            restored_output_bytes: self.stats.restored_output_bytes.load(Ordering::Relaxed),
1056            reflinked_output_files: self.stats.reflinked_output_files.load(Ordering::Relaxed),
1057            reflinked_output_bytes: self.stats.reflinked_output_bytes.load(Ordering::Relaxed),
1058            copied_output_files: self.stats.copied_output_files.load(Ordering::Relaxed),
1059            copied_output_bytes: self.stats.copied_output_bytes.load(Ordering::Relaxed),
1060            reused_output_files: self.stats.reused_output_files.load(Ordering::Relaxed),
1061            reused_output_bytes: self.stats.reused_output_bytes.load(Ordering::Relaxed),
1062        }
1063    }
1064
1065    /// Handle requests without a transport connection.
1066    ///
1067    /// Persistent wrappers use this entry point when Cargo invokes mbx outside
1068    /// an orchestrated session. It intentionally has the same response
1069    /// semantics as [`Self::handle_connection`], while leaving framing and the
1070    /// version handshake to callers that actually cross a process boundary.
1071    pub async fn handle_requests(
1072        &self,
1073        requests: impl IntoIterator<Item = AgentRequest>,
1074    ) -> Vec<AgentResponse> {
1075        let mut connection = ConnectionUploads::default();
1076        let mut responses = Vec::new();
1077        for request in requests {
1078            responses.push(self.respond_on(request, &mut connection).await);
1079        }
1080        responses
1081    }
1082
1083    fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
1084        Self::digest_lock(&self.write_locks, digest)
1085    }
1086
1087    fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
1088        Self::digest_lock(&self.action_locks, digest)
1089    }
1090
1091    fn digest_lock(
1092        locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
1093        digest: &CacheDigest,
1094    ) -> Arc<tokio::sync::Mutex<()>> {
1095        let mut locks = locks.lock().unwrap();
1096        locks.retain(|_, lock| lock.strong_count() > 0);
1097        if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
1098            return lock;
1099        }
1100        let lock = Arc::new(tokio::sync::Mutex::new(()));
1101        locks.insert(digest.clone(), Arc::downgrade(&lock));
1102        lock
1103    }
1104
1105    /// Answer one request outside any connection.
1106    ///
1107    /// An upload queued this way has no sibling requests to order against, so
1108    /// the queue falls back to treating every blob it holds as a prerequisite.
1109    #[cfg(test)]
1110    async fn respond(&self, request: AgentRequest) -> AgentResponse {
1111        self.respond_on(request, &mut ConnectionUploads::default())
1112            .await
1113    }
1114
1115    async fn respond_on(
1116        &self,
1117        request: AgentRequest,
1118        connection: &mut ConnectionUploads,
1119    ) -> AgentResponse {
1120        let result = match request {
1121            AgentRequest::BeginTask { task } => self
1122                .begin_task(&task)
1123                .await
1124                .map(|run| AgentResponse::TaskBegun { run }),
1125            AgentRequest::CommitTask { run } => self
1126                .commit_task(&run)
1127                .await
1128                .map(|()| AgentResponse::TaskCommitted),
1129            AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
1130            AgentRequest::FindBlobs { digests } => self.find_blobs(digests).await,
1131            AgentRequest::StoreBlob { digest, source } => {
1132                self.store_blob(&digest, &source, connection).await
1133            }
1134            AgentRequest::FindActionResult { action } => {
1135                self.stats.lookups.fetch_add(1, Ordering::Relaxed);
1136                self.find_action_result(&action).await
1137            }
1138            AgentRequest::RecordActionHit {
1139                action,
1140                restore,
1141                crate_name,
1142            } => {
1143                let diagnostic = connection
1144                    .take_action_diagnostic("hit", crate_name.as_deref())
1145                    .map(|diagnostic| AgentEvent::ActionDiagnostic {
1146                        outcome: "hit".into(),
1147                        crate_name: crate_name.clone(),
1148                        diagnostic,
1149                    });
1150                self.record_action_hit(&action, restore, crate_name, diagnostic)
1151            }
1152            AgentRequest::RecordBypass { kind } => {
1153                *self
1154                    .stats
1155                    .bypasses
1156                    .lock()
1157                    .unwrap()
1158                    .entry(kind.clone())
1159                    .or_insert(0) += 1;
1160                self.emit(|| AgentEvent::Bypass { kind });
1161                Ok(AgentResponse::BypassRecorded)
1162            }
1163            AgentRequest::RecordUnconsulted => {
1164                self.stats.unconsulted.fetch_add(1, Ordering::Relaxed);
1165                self.emit(|| AgentEvent::Unconsulted);
1166                Ok(AgentResponse::UnconsultedRecorded)
1167            }
1168            AgentRequest::RecordWarning { message } => match parse_action_diagnostic(&message) {
1169                Some(Ok((outcome, crate_name, diagnostic))) => {
1170                    connection.record_action_diagnostic(outcome, crate_name, diagnostic);
1171                    Ok(AgentResponse::WarningRecorded)
1172                }
1173                Some(Err(error)) => Err(error),
1174                None => self.record_warning(message),
1175            },
1176            AgentRequest::FindFileDigests { scope, files } => self.find_file_digests(scope, files),
1177            AgentRequest::JoinActionPromise {
1178                adapter,
1179                invocation,
1180            } => self.join_action_promise(&adapter, &invocation).await,
1181            AgentRequest::CompleteActionPromise { claim, prediction } => {
1182                self.complete_action_promise(&claim, &prediction).await
1183            }
1184            AgentRequest::RecordFileDigests { scope, entries } => {
1185                self.record_file_digests(scope, entries)
1186            }
1187            AgentRequest::RecordCompilerInvocation {
1188                outcome,
1189                crate_name,
1190                duration_ns,
1191            } => {
1192                let diagnostic = connection
1193                    .take_action_diagnostic(&outcome, crate_name.as_deref())
1194                    .map(|diagnostic| AgentEvent::ActionDiagnostic {
1195                        outcome: outcome.clone(),
1196                        crate_name: crate_name.clone(),
1197                        diagnostic,
1198                    });
1199                self.record_compiler_invocation(
1200                    &outcome,
1201                    crate_name.as_deref(),
1202                    duration_ns,
1203                    diagnostic,
1204                )
1205            }
1206            AgentRequest::RecordActionVerification { matched, restore } => {
1207                self.record_materialization(restore);
1208                self.stats.verifications.fetch_add(1, Ordering::Relaxed);
1209                if !matched {
1210                    self.stats.divergences.fetch_add(1, Ordering::Relaxed);
1211                }
1212                self.emit(|| AgentEvent::Verification { matched, restore });
1213                Ok(AgentResponse::ActionVerificationRecorded)
1214            }
1215            AgentRequest::StoreActionResult { result } => {
1216                self.store_action_result(&result, connection).await
1217            }
1218            AgentRequest::FindActionPrediction { task, invocation } => {
1219                self.find_action_prediction(&task, &invocation)
1220            }
1221            AgentRequest::RecordActionPrediction { task, prediction } => {
1222                self.record_action_prediction(&task, prediction)
1223            }
1224            AgentRequest::FindExecutableIdentity {
1225                executable,
1226                environment,
1227            } => self.find_executable_identity(executable, environment),
1228            AgentRequest::StoreExecutableIdentity {
1229                executable,
1230                environment,
1231                stdout,
1232            } => self.store_executable_identity(executable, environment, stdout),
1233            AgentRequest::Hello { .. } => {
1234                Err(eyre::eyre!("hello is only valid as the first request"))
1235            }
1236        };
1237        result.unwrap_or_else(|error| AgentResponse::Error {
1238            message: error.to_string(),
1239        })
1240    }
1241
1242    async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
1243        if let Some(path) = self.find_verified_blob(digest)? {
1244            return Ok(AgentResponse::Blob { path: Some(path) });
1245        }
1246        if !self.remote_mode.reads() {
1247            return Ok(AgentResponse::Blob { path: None });
1248        }
1249        let Some(remote) = &self.remote else {
1250            return Ok(AgentResponse::Blob { path: None });
1251        };
1252        match self.fetch_remote_blob(remote, digest).await {
1253            Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
1254            Err(error) => {
1255                warn!(
1256                    "remote cache blob lookup failed for {}: {error}",
1257                    digest.hash
1258                );
1259                Ok(AgentResponse::Blob { path: None })
1260            }
1261        }
1262    }
1263
1264    async fn find_blobs(&self, digests: Vec<CacheDigest>) -> Result<AgentResponse> {
1265        let mut paths = BTreeMap::new();
1266        let mut missing = Vec::new();
1267        for digest in &digests {
1268            match self.find_verified_blob(digest)? {
1269                Some(path) => {
1270                    paths.insert(digest.clone(), path);
1271                }
1272                None => {
1273                    missing.push(digest.clone());
1274                }
1275            }
1276        }
1277
1278        if !missing.is_empty()
1279            && self.remote_mode.reads()
1280            && let Some(remote) = &self.remote
1281        {
1282            paths.extend(self.fetch_remote_blobs(remote, missing, None).await);
1283        }
1284
1285        Ok(AgentResponse::Blobs {
1286            paths: digests
1287                .into_iter()
1288                .map(|digest| paths.get(&digest).cloned())
1289                .collect(),
1290        })
1291    }
1292
1293    async fn store_blob(
1294        &self,
1295        digest: &CacheDigest,
1296        source: &Path,
1297        connection: &mut ConnectionUploads,
1298    ) -> Result<AgentResponse> {
1299        let path = {
1300            let lock = self.write_lock(digest);
1301            let _guard = lock.lock().await;
1302            if let Some(path) = self.find_verified_blob(digest)? {
1303                path
1304            } else {
1305                let path = self.cas.store_file(digest, source)?;
1306                self.remember_verified_blob(digest, &path);
1307                self.stats.stores.fetch_add(1, Ordering::Relaxed);
1308                self.stats
1309                    .stored_bytes
1310                    .fetch_add(digest.size, Ordering::Relaxed);
1311                path
1312            }
1313        };
1314        // The object is durable locally, so the build has what it needs and the
1315        // remote publication can happen after this request returns.
1316        if let Some(uploads) = &self.uploads {
1317            uploads.queue_blob(digest, path.clone(), connection);
1318        }
1319        Ok(AgentResponse::Stored { path })
1320    }
1321
1322    fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
1323        let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
1324        if let Some(remembered) = remembered {
1325            // The contents behind this digest were hashed in full when the
1326            // session first reached for them. Hashing again on every later
1327            // lookup would re-read each dependency's rlib once per crate that
1328            // links it, so what is rechecked here is that the file has not
1329            // been written since: an overwrite moves the modification time,
1330            // and truncation or eviction changes the length or removes it.
1331            // Only a replacement that reproduces both -- which no writer in
1332            // the store does, since blobs are published by rename under a
1333            // content-derived name -- would go unnoticed until `mbx cache
1334            // verify` reads the store back in full.
1335            if remembered.is_unchanged() {
1336                return Ok(Some(remembered.path));
1337            }
1338            self.verified_blobs.lock().unwrap().remove(digest);
1339        }
1340        let path = self.cas.find(digest)?;
1341        if let Some(path) = &path {
1342            self.remember_verified_blob(digest, path);
1343        }
1344        Ok(path)
1345    }
1346
1347    fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
1348        let Some(verified) = VerifiedBlob::describe(path) else {
1349            // Without an identity to compare against there is nothing to
1350            // shortcut safely, so the digest stays unremembered and every
1351            // lookup re-reads it.
1352            return;
1353        };
1354        self.verified_blobs
1355            .lock()
1356            .unwrap()
1357            .insert(digest.clone(), verified);
1358    }
1359
1360    async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
1361        if let Some(result) = self.actions.find(action)? {
1362            return Ok(AgentResponse::ActionResult {
1363                result: Some(result),
1364            });
1365        }
1366        if !self.remote_mode.reads() {
1367            return Ok(AgentResponse::ActionResult { result: None });
1368        }
1369        let Some(remote) = &self.remote else {
1370            return Ok(AgentResponse::ActionResult { result: None });
1371        };
1372        let lock = self.action_lock(action);
1373        let _guard = lock.lock().await;
1374        if let Some(result) = self.actions.find(action)? {
1375            return Ok(AgentResponse::ActionResult {
1376                result: Some(result),
1377            });
1378        }
1379        if let Some(result) = self
1380            .pending_remote_actions
1381            .lock()
1382            .unwrap()
1383            .get(action)
1384            .cloned()
1385        {
1386            return Ok(AgentResponse::ActionResult {
1387                result: Some(result),
1388            });
1389        }
1390        let _permit = self.remote_transfers.acquire().await?;
1391        match self.get_remote_action_result(remote, action).await {
1392            Ok(Some(result)) => {
1393                self.pending_remote_actions
1394                    .lock()
1395                    .unwrap()
1396                    .insert(action.clone(), result.clone());
1397                Ok(AgentResponse::ActionResult {
1398                    result: Some(result),
1399                })
1400            }
1401            Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
1402            Err(error) => {
1403                self.note_remote_failure();
1404                warn!(
1405                    "remote cache action lookup failed for {}: {error}",
1406                    action.hash
1407                );
1408                Ok(AgentResponse::ActionResult { result: None })
1409            }
1410        }
1411    }
1412
1413    async fn store_action_result(
1414        &self,
1415        result: &RemoteActionResult,
1416        connection: &ConnectionUploads,
1417    ) -> Result<AgentResponse> {
1418        let path = self.actions.store(result)?;
1419        if let Some(uploads) = &self.uploads {
1420            uploads.queue_action_result(result, connection);
1421        }
1422        Ok(AgentResponse::ActionStored { path })
1423    }
1424
1425    async fn join_action_promise(
1426        &self,
1427        adapter: &str,
1428        invocation: &CacheDigest,
1429    ) -> Result<AgentResponse> {
1430        if !self.remote_mode.reads() || !self.remote_mode.writes() {
1431            return Ok(AgentResponse::ActionPromise {
1432                claim: None,
1433                prediction: None,
1434            });
1435        }
1436        let Some(remote) = &self.remote else {
1437            return Ok(AgentResponse::ActionPromise {
1438                claim: None,
1439                prediction: None,
1440            });
1441        };
1442        let deadline = Instant::now() + ACTION_PROMISE_WAIT;
1443        loop {
1444            let _permit = self.remote_transfers.acquire().await?;
1445            let state = remote.join_action_promise(invocation, adapter).await;
1446            drop(_permit);
1447            match state {
1448                Ok(Some(ActionPromiseState::Claimed { claim })) => {
1449                    return Ok(AgentResponse::ActionPromise {
1450                        claim: Some(claim),
1451                        prediction: None,
1452                    });
1453                }
1454                Ok(Some(ActionPromiseState::Complete { prediction })) => {
1455                    return Ok(AgentResponse::ActionPromise {
1456                        claim: None,
1457                        prediction: Some(prediction),
1458                    });
1459                }
1460                Ok(Some(ActionPromiseState::Pending { retry_after_ms }))
1461                    if Instant::now() < deadline =>
1462                {
1463                    tokio::time::sleep(Duration::from_millis(retry_after_ms.clamp(10, 5_000)))
1464                        .await;
1465                }
1466                Ok(Some(ActionPromiseState::Pending { .. }) | None) => {
1467                    return Ok(AgentResponse::ActionPromise {
1468                        claim: None,
1469                        prediction: None,
1470                    });
1471                }
1472                Ok(Some(_)) => {
1473                    return Ok(AgentResponse::ActionPromise {
1474                        claim: None,
1475                        prediction: None,
1476                    });
1477                }
1478                Err(error) => {
1479                    self.note_remote_failure();
1480                    warn!(
1481                        "remote cache action promise failed for {}: {error}",
1482                        invocation.hash
1483                    );
1484                    return Ok(AgentResponse::ActionPromise {
1485                        claim: None,
1486                        prediction: None,
1487                    });
1488                }
1489            }
1490        }
1491    }
1492
1493    async fn complete_action_promise(
1494        &self,
1495        claim: &str,
1496        prediction: &ActionPrediction,
1497    ) -> Result<AgentResponse> {
1498        prediction.validate()?;
1499        let Some(remote) = &self.remote else {
1500            return Ok(AgentResponse::ActionPromiseCompleted);
1501        };
1502        let Some(uploads) = &self.uploads else {
1503            return Ok(AgentResponse::ActionPromiseCompleted);
1504        };
1505        if uploads
1506            .wait_for_actions(std::slice::from_ref(&prediction.action))
1507            .await
1508            .contains(&prediction.action)
1509        {
1510            // Never promise an action result the server does not hold. The
1511            // server lease expires and another runner gets to repair it.
1512            return Ok(AgentResponse::ActionPromiseCompleted);
1513        }
1514        let completion = ActionPromiseCompletion {
1515            claim: claim.to_string(),
1516            prediction: prediction.clone(),
1517        };
1518        let _permit = self.remote_transfers.acquire().await?;
1519        if let Err(error) = remote
1520            .complete_action_promise(&prediction.invocation, &completion)
1521            .await
1522        {
1523            self.note_remote_failure();
1524            warn!(
1525                "remote cache action promise completion failed for {}: {error}",
1526                prediction.invocation.hash
1527            );
1528        }
1529        Ok(AgentResponse::ActionPromiseCompleted)
1530    }
1531
1532    /// Record that a remote operation failed and the build carried on without it.
1533    fn note_remote_failure(&self) {
1534        self.stats.remote_failures.fetch_add(1, Ordering::Relaxed);
1535    }
1536
1537    async fn get_remote_action_result(
1538        &self,
1539        remote: &RemoteCacheClient,
1540        action: &CacheDigest,
1541    ) -> Result<Option<RemoteActionResult>> {
1542        self.stats
1543            .remote_action_lookups
1544            .fetch_add(1, Ordering::Relaxed);
1545        let _timer = AtomicDurationTimer::start(&self.stats.remote_action_lookup_duration_ns);
1546        remote.get_action_result(action).await
1547    }
1548
1549    fn record_action_hit(
1550        &self,
1551        action: &CacheDigest,
1552        restore: RestoreStats,
1553        crate_name: Option<String>,
1554        diagnostic: Option<AgentEvent>,
1555    ) -> Result<AgentResponse> {
1556        validate_crate_name(crate_name.as_deref())?;
1557        if self.actions.find(action)?.is_none() {
1558            let pending = self.pending_remote_actions.lock().unwrap().remove(action);
1559            if let Some(result) = pending {
1560                self.actions.store(&result)?;
1561            } else {
1562                bail!("cannot record a hit for a missing action result");
1563            }
1564        }
1565        self.record_restore(restore);
1566        self.stats.hits.fetch_add(1, Ordering::Relaxed);
1567        self.emit_action(
1568            diagnostic,
1569            AgentEvent::ActionHit {
1570                crate_name,
1571                restore,
1572            },
1573        );
1574        Ok(AgentResponse::ActionHitRecorded)
1575    }
1576
1577    fn record_restore(&self, restore: RestoreStats) {
1578        self.record_materialization(restore);
1579        atomic_saturating_add(
1580            &self.stats.avoided_compiler_duration_ns,
1581            restore.avoided_compiler_duration_ns,
1582        );
1583        atomic_saturating_add(&self.stats.restored_output_files, restore.output_files);
1584        atomic_saturating_add(&self.stats.restored_output_bytes, restore.output_bytes);
1585        atomic_saturating_add(
1586            &self.stats.reflinked_output_files,
1587            restore.reflinked_output_files,
1588        );
1589        atomic_saturating_add(
1590            &self.stats.reflinked_output_bytes,
1591            restore.reflinked_output_bytes,
1592        );
1593        atomic_saturating_add(&self.stats.copied_output_files, restore.copied_output_files);
1594        atomic_saturating_add(&self.stats.copied_output_bytes, restore.copied_output_bytes);
1595        atomic_saturating_add(&self.stats.reused_output_files, restore.reused_output_files);
1596        atomic_saturating_add(&self.stats.reused_output_bytes, restore.reused_output_bytes);
1597    }
1598
1599    fn record_compiler_invocation(
1600        &self,
1601        outcome: &str,
1602        crate_name: Option<&str>,
1603        duration_ns: u64,
1604        diagnostic: Option<AgentEvent>,
1605    ) -> Result<AgentResponse> {
1606        if !matches!(
1607            outcome,
1608            "miss" | "unconsulted" | "bypass" | "verification" | "incremental"
1609        ) {
1610            bail!("invalid compiler invocation outcome");
1611        }
1612        validate_crate_name(crate_name)?;
1613        let mut compiler = self.stats.compiler.lock().unwrap();
1614        let stats = compiler.entry(outcome.to_string()).or_default();
1615        stats.invocations = stats.invocations.saturating_add(1);
1616        stats.duration_ns = stats.duration_ns.saturating_add(duration_ns);
1617        drop(compiler);
1618        if outcome != "verification"
1619            && let Some(crate_name) = crate_name.filter(|name| !name.is_empty())
1620        {
1621            let mut slow = self.stats.slow_compilations.lock().unwrap();
1622            let duration = slow.entry(crate_name.to_string()).or_default();
1623            *duration = duration.saturating_add(duration_ns);
1624        }
1625        self.emit_action(
1626            diagnostic,
1627            AgentEvent::CompilerInvocation {
1628                outcome: outcome.to_string(),
1629                crate_name: crate_name.map(str::to_string),
1630                duration_ns,
1631            },
1632        );
1633        Ok(AgentResponse::CompilerInvocationRecorded)
1634    }
1635
1636    fn record_materialization(&self, restore: RestoreStats) {
1637        atomic_saturating_add(&self.stats.materialization_duration_ns, restore.duration_ns);
1638    }
1639
1640    /// Surface a shim diagnostic on this process's stderr.
1641    ///
1642    /// The agent lives in the process that owns the session, so printing here
1643    /// reaches the terminal running the build rather than the stderr of the
1644    /// compilation the shim stands in for -- which build scripts read as part
1645    /// of the compiler's answer. Deduplicated because one cause tends to fire
1646    /// once per compilation, and capped so a message unique per compilation
1647    /// cannot scroll the build away.
1648    fn record_warning(&self, message: String) -> Result<AgentResponse> {
1649        if message.is_empty()
1650            || message.len() > MAX_WARNING_BYTES
1651            || message.contains(['\n', '\r', '\0'])
1652        {
1653            bail!("invalid shim warning");
1654        }
1655        let mut warnings = self.warnings.lock().unwrap();
1656        if !warnings.contains(&message) && warnings.len() < MAX_WARNINGS {
1657            eprintln!("mbx[warning]: {message}");
1658            self.emit(|| AgentEvent::Warning {
1659                message: message.clone(),
1660            });
1661            warnings.insert(message);
1662        }
1663        Ok(AgentResponse::WarningRecorded)
1664    }
1665
1666    /// Answer file-digest lookups from the session ledger.
1667    ///
1668    /// A recorded digest is returned only when the requested identity matches
1669    /// the recorded one exactly. The caller statted the file to build the
1670    /// identity, so this is the [`VerifiedBlob`] freshness check with the
1671    /// stat moved to the side that was already making it.
1672    fn find_file_digests(
1673        &self,
1674        scope: FileDigestScope,
1675        files: Vec<FileIdentity>,
1676    ) -> Result<AgentResponse> {
1677        if files.len() > MAX_FILE_DIGEST_BATCH {
1678            bail!("too many file-digest lookups in one request");
1679        }
1680        let ledger = self.file_digests.lock().unwrap();
1681        let digests = files
1682            .into_iter()
1683            .map(|file| {
1684                let recorded = ledger.get(&(scope, file.path.clone()))?;
1685                (recorded.file == file).then(|| recorded.digest.clone())
1686            })
1687            .collect();
1688        Ok(AgentResponse::FileDigests { digests })
1689    }
1690
1691    /// Record digests of files a shim read in full, for later reuse.
1692    ///
1693    /// Capped rather than evicted: a session that outgrows the cap loses the
1694    /// shortcut for the overflow and nothing else, and no workload observed so
1695    /// far comes near it.
1696    fn record_file_digests(
1697        &self,
1698        scope: FileDigestScope,
1699        entries: Vec<RecordedFileDigest>,
1700    ) -> Result<AgentResponse> {
1701        if entries.len() > MAX_FILE_DIGEST_BATCH {
1702            bail!("too many file-digest records in one request");
1703        }
1704        for entry in &entries {
1705            validate_file_digest_record(entry)?;
1706        }
1707        let mut ledger = self.file_digests.lock().unwrap();
1708        for entry in entries {
1709            if ledger.len() >= MAX_FILE_DIGEST_ENTRIES
1710                && !ledger.contains_key(&(scope, entry.file.path.clone()))
1711            {
1712                break;
1713            }
1714            ledger.insert((scope, entry.file.path.clone()), entry);
1715        }
1716        Ok(AgentResponse::FileDigestsRecorded)
1717    }
1718
1719    /// Start the file-digest ledger from entries an earlier session left
1720    /// behind, so a file nothing has touched since is not read again by the
1721    /// first compilation of this session that names it.
1722    ///
1723    /// Each entry stands only while its recorded identity still matches the
1724    /// disk, the same rule a lookup applies to what this session recorded, so
1725    /// a stale seed costs a hash and nothing else. Entries this session has
1726    /// already recorded are kept over seeded ones. Returns how many were taken.
1727    pub fn seed_file_digests(&self, entries: Vec<(FileDigestScope, RecordedFileDigest)>) -> usize {
1728        let mut ledger = self.file_digests.lock().unwrap();
1729        let mut seeded = 0;
1730        for (scope, entry) in entries {
1731            if ledger.len() >= MAX_FILE_DIGEST_ENTRIES {
1732                break;
1733            }
1734            if validate_file_digest_record(&entry).is_err() {
1735                continue;
1736            }
1737            if let std::collections::btree_map::Entry::Vacant(slot) =
1738                ledger.entry((scope, entry.file.path.clone()))
1739            {
1740                slot.insert(entry);
1741                seeded += 1;
1742            }
1743        }
1744        seeded
1745    }
1746
1747    /// Everything the file-digest ledger holds, for a later session to start
1748    /// from.
1749    pub fn file_digests(&self) -> Vec<(FileDigestScope, RecordedFileDigest)> {
1750        self.file_digests
1751            .lock()
1752            .unwrap()
1753            .iter()
1754            .map(|((scope, _), entry)| (*scope, entry.clone()))
1755            .collect()
1756    }
1757
1758    fn find_action_prediction(
1759        &self,
1760        task: &str,
1761        invocation: &CacheDigest,
1762    ) -> Result<AgentResponse> {
1763        validate_task_identity(task)?;
1764        invocation.validate()?;
1765        let prediction = self
1766            .task_actions
1767            .lock()
1768            .unwrap()
1769            .get(task)
1770            .and_then(|state| state.predictions.get(invocation))
1771            .cloned();
1772        Ok(AgentResponse::ActionPrediction { prediction })
1773    }
1774
1775    fn record_action_prediction(
1776        &self,
1777        task: &str,
1778        prediction: ActionPrediction,
1779    ) -> Result<AgentResponse> {
1780        validate_task_identity(task)?;
1781        prediction.validate()?;
1782        let mut tasks = self.task_actions.lock().unwrap();
1783        let state = tasks.entry(task.to_string()).or_default();
1784        if !state.predictions.contains_key(&prediction.invocation)
1785            && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
1786        {
1787            bail!("task action manifest contains too many predictions");
1788        }
1789        state
1790            .predictions
1791            .insert(prediction.invocation.clone(), prediction.clone());
1792        state
1793            .pending_predictions
1794            .insert(prediction.invocation.clone(), prediction);
1795        Ok(AgentResponse::ActionPredictionRecorded)
1796    }
1797
1798    fn executable_identity_key(
1799        &self,
1800        executable: PathBuf,
1801        environment: BTreeMap<String, Option<String>>,
1802    ) -> Result<ExecutableIdentityKey> {
1803        // Restricted to the variables that actually select what an identity
1804        // probe reports: the toolchain rustup resolves, the SDK a linker
1805        // driver builds against, and a `-fuse-ld` linker selection. Anything
1806        // else would let one key stand for two different compilers.
1807        if !environment.keys().all(|name| {
1808            matches!(
1809                name.as_str(),
1810                "MBX_FUSE_LD"
1811                    | "RUSTUP_HOME"
1812                    | "RUSTUP_TOOLCHAIN"
1813                    | "SDKROOT"
1814                    | "MACOSX_DEPLOYMENT_TARGET"
1815                    | "LIB"
1816                    | "UCRTVersion"
1817                    | "UniversalCRTSdkDir"
1818                    | "VCToolsInstallDir"
1819                    | "VCToolsVersion"
1820                    | "WindowsSdkDir"
1821                    | "WindowsSDKVersion"
1822            )
1823        }) {
1824            bail!("executable identity contains an unsupported environment variable");
1825        }
1826        Ok(ExecutableIdentityKey {
1827            executable,
1828            environment,
1829        })
1830    }
1831
1832    fn find_executable_identity(
1833        &self,
1834        executable: PathBuf,
1835        environment: BTreeMap<String, Option<String>>,
1836    ) -> Result<AgentResponse> {
1837        let key = self.executable_identity_key(executable, environment)?;
1838        let stdout = self
1839            .executable_identities
1840            .lock()
1841            .unwrap()
1842            .get(&key)
1843            .cloned();
1844        Ok(AgentResponse::ExecutableIdentity { stdout })
1845    }
1846
1847    fn store_executable_identity(
1848        &self,
1849        executable: PathBuf,
1850        environment: BTreeMap<String, Option<String>>,
1851        stdout: Vec<u8>,
1852    ) -> Result<AgentResponse> {
1853        if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1854            bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1855        }
1856        let key = self.executable_identity_key(executable, environment)?;
1857        let mut identities = self.executable_identities.lock().unwrap();
1858        let is_new = !identities.contains_key(&key);
1859        let previous_size = identities.get(&key).map_or(0, Vec::len);
1860        if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1861            bail!("executable identity cache contains too many entries");
1862        }
1863        let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1864        if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1865            bail!("executable identity cache contains too many bytes");
1866        }
1867        identities.insert(key, stdout.clone());
1868        Ok(AgentResponse::ExecutableIdentity {
1869            stdout: Some(stdout),
1870        })
1871    }
1872
1873    /// Serve newline-delimited protocol requests on an authenticated session stream.
1874    pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1875    where
1876        S: AsyncRead + AsyncWrite + Unpin,
1877    {
1878        let (reader, mut writer) = tokio::io::split(stream);
1879        let mut reader = BufReader::new(reader);
1880        let hello = read_request(&mut reader)
1881            .await?
1882            .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1883        let request: AgentRequest = serde_json::from_str(&hello)?;
1884        match request {
1885            AgentRequest::Hello {
1886                protocol,
1887                client_version,
1888            } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1889            AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1890                send_response(
1891                    &mut writer,
1892                    &AgentResponse::Error {
1893                        message: format!(
1894                            "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1895                        ),
1896                    },
1897                )
1898                .await?;
1899                return Ok(());
1900            }
1901            AgentRequest::Hello { client_version, .. } => {
1902                send_response(
1903                    &mut writer,
1904                    &AgentResponse::Error {
1905                        message: format!(
1906                            "cache client {client_version} does not match agent {}",
1907                            self.version
1908                        ),
1909                    },
1910                )
1911                .await?;
1912                return Ok(());
1913            }
1914            _ => bail!("the first agent request must be hello"),
1915        }
1916        send_response(
1917            &mut writer,
1918            &AgentResponse::Hello {
1919                protocol: AGENT_PROTOCOL_VERSION,
1920                agent_version: self.version.to_string(),
1921            },
1922        )
1923        .await?;
1924
1925        // Tickets accumulate for the life of the connection because a shim
1926        // publishes a compilation's blobs and the action result naming them over
1927        // one connection, in that order.
1928        let mut connection = ConnectionUploads::default();
1929        while let Some(line) = read_request(&mut reader).await? {
1930            let response = match serde_json::from_str(&line) {
1931                Ok(request) => self.respond_on(request, &mut connection).await,
1932                Err(error) => AgentResponse::Error {
1933                    message: format!("invalid agent request: {error}"),
1934                },
1935            };
1936            send_response(&mut writer, &response).await?;
1937        }
1938        Ok(())
1939    }
1940}
1941
1942/// Read one newline-delimited request, refusing one that grows past the cap.
1943///
1944/// Any process running as this user can open the session socket, so a request
1945/// that never terminates its line must not be able to grow the agent's memory
1946/// without bound.
1947async fn read_request<R>(reader: &mut R) -> Result<Option<String>>
1948where
1949    R: AsyncBufRead + Unpin,
1950{
1951    let mut line = Vec::new();
1952    loop {
1953        let available = reader.fill_buf().await?;
1954        if available.is_empty() {
1955            break;
1956        }
1957        let (consumed, complete) = match available.iter().position(|byte| *byte == b'\n') {
1958            Some(index) => (index, true),
1959            None => (available.len(), false),
1960        };
1961        if line.len() + consumed > MAX_REQUEST_BYTES {
1962            bail!("agent request exceeded {MAX_REQUEST_BYTES} bytes");
1963        }
1964        line.extend_from_slice(&available[..consumed]);
1965        // The newline itself is consumed but never kept.
1966        reader.consume(consumed + usize::from(complete));
1967        if complete {
1968            return Ok(Some(String::from_utf8(line)?));
1969        }
1970    }
1971    if line.is_empty() {
1972        Ok(None)
1973    } else {
1974        Ok(Some(String::from_utf8(line)?))
1975    }
1976}
1977
1978async fn send_response(
1979    writer: &mut (impl AsyncWrite + Unpin),
1980    response: &AgentResponse,
1981) -> Result<()> {
1982    let mut encoded = serde_json::to_vec(response)?;
1983    encoded.push(b'\n');
1984    writer.write_all(&encoded).await?;
1985    writer.flush().await?;
1986    Ok(())
1987}
1988
1989#[cfg(test)]
1990#[path = "agent_tests.rs"]
1991mod tests;