Skip to main content

mbx_cache_core/
agent.rs

1use crate::uploads::{ConnectionUploads, UploadQueue, UploadSink};
2use crate::{
3    ActionPrediction, ActionPromiseCompletion, ActionPromiseState, CacheDigest, CacheDirectory,
4    LocalActionCache, LocalCas, MAX_STAGED_BLOB_PACK_BYTES, ManifestPutOutcome, RemoteActionResult,
5    RemoteCacheClient, RemoteCacheMode, RustcMetadata, TaskActionManifest, blob_pack_chunk,
6    canonical_json,
7};
8use eyre::{Context, Result, bail};
9use futures_util::{FutureExt, StreamExt, future::BoxFuture, stream};
10use log::{debug, warn};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::{Arc, Mutex, Weak};
17use std::time::{Duration, Instant, SystemTime};
18
19/// Fleet claims are leases rather than correctness locks. This only bounds how
20/// long one shim waits through repeated pending responses before degrading to
21/// an ordinary compilation; the server independently expires abandoned claims.
22const ACTION_PROMISE_WAIT: Duration = Duration::from_secs(60 * 60);
23use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
24
25mod file_digest;
26mod manifest;
27mod prefetch;
28mod stats;
29mod wire;
30
31#[cfg(test)]
32pub(crate) use prefetch::select_prefetch_actions;
33
34pub use file_digest::{
35    FileDigestCache, FileDigestResolution, FileDigestScope, FileIdentity, FileObjectIdentity,
36    FileSnapshot, NoFileDigestCache, RecordedFileDigest, ValidatedFileDigest, digest_file,
37    digest_file_validated,
38};
39pub use manifest::{
40    is_task_identity, task_manifest_actions,
41    update_task_predictions_in_order as merge_task_action_predictions,
42};
43use manifest::{
44    merge_task_manifests, task_manifest_dir, update_task_predictions, validate_task_identity,
45    validate_task_manifest,
46};
47pub use stats::{AgentStats, CompilerStats};
48use wire::MAX_REQUEST_BYTES;
49pub use wire::{
50    AGENT_PROTOCOL_VERSION, ActionDiagnostic, AgentEvent, AgentEventObserver, AgentRequest,
51    AgentResponse, PinnedFile, PinnedState, RestoreStats, WrapperSpan, WrapperTiming,
52};
53
54const MAX_EXECUTABLE_IDENTITIES: usize = 64;
55const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
56const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
57const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
58const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
59const TASK_ACTION_LRU_REFRESH_MARGIN: usize = MAX_TASK_ACTION_PREDICTIONS / 16;
60/// Longest shim diagnostic the agent accepts, in bytes.
61const MAX_DIAGNOSTIC_BYTES: usize = 4 * 1024;
62/// Most distinct shim diagnostics one session surfaces before going quiet.
63///
64/// A failure mode that repeats across a build tends to repeat with the same
65/// message, which deduplication already collapses; the cap only guards
66/// against a message that embeds something unique per compilation.
67const MAX_DIAGNOSTICS: usize = 128;
68const ACTION_DIAGNOSTIC_PREFIX: &str = "@mbx-action-diagnostic\t";
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
71enum DiagnosticLevel {
72    Warning,
73    Error,
74}
75
76impl DiagnosticLevel {
77    fn label(self) -> &'static str {
78        match self {
79            Self::Warning => "warning",
80            Self::Error => "error",
81        }
82    }
83}
84
85/// Most file identities one digest lookup or record may carry.
86const MAX_FILE_DIGEST_BATCH: usize = 16 * 1024;
87/// Most recorded file digests one session retains across every scope.
88///
89/// Roughly two orders of magnitude above the largest workspace measured; the
90/// cap exists so a pathological build bounds the agent instead of growing it.
91const MAX_FILE_DIGEST_ENTRIES: usize = 1024 * 1024;
92/// Distinct cold file reads allowed at once. Identical reads coalesce before
93/// this limit, while the limit prevents a wide cold build from flooding NFS.
94const MAX_CONCURRENT_FILE_DIGESTS: usize = 8;
95const MAX_REMOTE_TRANSFERS: usize = 64;
96const MAX_PREFETCH_TRANSFERS: usize = 48;
97/// Most blob packs downloaded at once.
98///
99/// Packs are individually bounded to keep their staging footprint predictable.
100/// Allowing a few independent streams prevents one large Azure object stream
101/// from serializing an entire workspace restore.
102const MAX_CONCURRENT_BLOB_PACKS: usize = 8;
103/// Most predicted actions whose complete output closures are downloaded
104/// speculatively for one task.
105///
106/// Manifests deliberately retain predictions across compatible builds, so a
107/// large workspace can describe far more actions than the next invocation
108/// will request. Keep the most expensive recorded actions warm and let
109/// foreground lookups fetch only unusually large tails on demand. The remote
110/// download-byte budget remains the primary bound on speculative transfer. A
111/// 1,024-action ceiling covers the measured large-workspace manifests while
112/// retaining a guard against pathological manifests and stale prediction sets.
113const MAX_PREFETCH_ACTIONS: usize = 1024;
114// Resolve speculative actions progressively so the most valuable predictions
115// become usable first and cancellation at the end of a build abandons a small
116// tail instead of one workspace-sized transfer wave. Action-result lookup is
117// still batched independently; this only bounds each output-closure download.
118const MAX_PREFETCH_ACTION_WAVE: usize = 32;
119/// Batched action lookups issued at once.
120///
121/// One request already asks about hundreds of actions. Serial batches keep a
122/// fleet of concurrent CI jobs from multiplying metadata pressure while blob
123/// transfers from earlier answers are also underway.
124const MAX_PREFETCH_BATCH_LOOKUPS: usize = 1;
125const PREFETCH_ACTION_BATCH_DELAY: Duration = Duration::from_millis(5);
126const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
127const MAX_PREFETCH_OBJECTS_PER_WAVE: usize = 100_000;
128const DEFAULT_MAX_REMOTE_DOWNLOAD_BYTES: u64 = 5 * 1024 * 1024 * 1024;
129
130/// Names the identities a task may inherit predictions from, consulted only
131/// once nothing has been recorded under its own.
132pub type TaskFallbacks = Arc<dyn Fn() -> Vec<String> + Send + Sync>;
133type FileDigestFlightKey = (FileDigestScope, FileIdentity);
134type FileDigestFlights = BTreeMap<FileDigestFlightKey, Weak<FileDigestFlight>>;
135
136struct FileDigestFlight {
137    lock: tokio::sync::Mutex<()>,
138    resolution: Mutex<Option<FileDigestResolution>>,
139}
140
141/// How many of the store's most recently written manifests a task with
142/// fallbacks tries after the named identities yield nothing.
143///
144/// A runner that restored a cache bundle holds the manifests of the builds
145/// that produced it and no version-control history to name them by, so what
146/// was written last is the best remaining guess at the lockfile before this
147/// one. Each candidate costs one parse, and a manifest from an unrelated
148/// workspace only fails to match. One is skipped when it fills more than half
149/// the prediction limit: an inherited manifest is kept under the new identity,
150/// and a foreign one that large would leave this workspace's own recordings
151/// no room.
152const NEWEST_MANIFEST_CANDIDATES: usize = 8;
153
154/// Remote action-cache access owned by one task session.
155pub struct AgentRemoteCache {
156    /// Remote protocol client used by the agent.
157    pub client: RemoteCacheClient,
158    /// Permitted remote read/write operations.
159    pub mode: RemoteCacheMode,
160    /// Directory used for verified downloads before CAS ingestion.
161    pub staging_dir: PathBuf,
162}
163
164#[derive(Default)]
165struct AtomicAgentStats {
166    wrapper_phases_ns: Mutex<BTreeMap<String, u64>>,
167    lookups: AtomicU64,
168    unconsulted: AtomicU64,
169    incremental_compilations: AtomicU64,
170    hits: AtomicU64,
171    stores: AtomicU64,
172    stored_bytes: AtomicU64,
173    verifications: AtomicU64,
174    divergences: AtomicU64,
175    downloaded_bytes: AtomicU64,
176    uploaded_bytes: AtomicU64,
177    background_uploads: AtomicU64,
178    background_upload_failures: AtomicU64,
179    remote_blob_pack_uploads: AtomicU64,
180    remote_blob_pack_upload_blobs: AtomicU64,
181    upload_drain_duration_ns: AtomicU64,
182    prefetched_actions: AtomicU64,
183    predictions_loaded: AtomicU64,
184    remote_failures: AtomicU64,
185    remote_manifest_lookups: AtomicU64,
186    remote_manifest_lookup_duration_ns: AtomicU64,
187    remote_action_lookups: AtomicU64,
188    remote_action_lookup_duration_ns: AtomicU64,
189    remote_blob_requests: AtomicU64,
190    remote_blob_pack_requests: AtomicU64,
191    remote_blob_pack_blobs: AtomicU64,
192    remote_blob_transfer_duration_ns: AtomicU64,
193    local_cas_write_duration_ns: AtomicU64,
194    prefetch_runs: AtomicU64,
195    prefetch_duration_ns: AtomicU64,
196    materialization_duration_ns: AtomicU64,
197    bypasses: Mutex<BTreeMap<String, u64>>,
198    avoided_compiler_duration_ns: AtomicU64,
199    compiler: Mutex<BTreeMap<String, CompilerStats>>,
200    unit_outcomes: Mutex<BTreeMap<String, BTreeSet<String>>>,
201    slow_compilations: Mutex<BTreeMap<String, u64>>,
202    restored_output_files: AtomicU64,
203    restored_output_bytes: AtomicU64,
204    reflinked_output_files: AtomicU64,
205    reflinked_output_bytes: AtomicU64,
206    copied_output_files: AtomicU64,
207    copied_output_bytes: AtomicU64,
208    reused_output_files: AtomicU64,
209    reused_output_bytes: AtomicU64,
210}
211
212struct AtomicDurationTimer<'a> {
213    started: Instant,
214    target: &'a AtomicU64,
215}
216
217impl<'a> AtomicDurationTimer<'a> {
218    fn start(target: &'a AtomicU64) -> Self {
219        Self {
220            started: Instant::now(),
221            target,
222        }
223    }
224}
225
226impl Drop for AtomicDurationTimer<'_> {
227    fn drop(&mut self) {
228        atomic_saturating_add(self.target, duration_ns(self.started));
229    }
230}
231
232fn duration_ns(started: Instant) -> u64 {
233    started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX)
234}
235
236fn validate_crate_name(crate_name: Option<&str>) -> Result<()> {
237    if let Some(crate_name) = crate_name
238        && (crate_name.len() > 256 || crate_name.contains(['\0', '\n', '\r']))
239    {
240        bail!("invalid compiler crate name");
241    }
242    Ok(())
243}
244
245#[derive(serde::Deserialize)]
246struct ActionDiagnosticEnvelope {
247    outcome: String,
248    crate_name: Option<String>,
249    diagnostic: ActionDiagnostic,
250}
251
252/// Split a reported outcome into what the lookup did and whether the result was
253/// withheld.
254///
255/// Every consumer normalizes through this. The diagnostic envelope and the
256/// invocation it belongs to are matched by outcome, so a name normalized in one
257/// place and not the other leaves the ledger row without its key details.
258pub(crate) fn lookup_outcome(outcome: &str) -> (&str, bool) {
259    match outcome {
260        "incremental-miss" => ("miss", true),
261        "incremental-unconsulted" => ("unconsulted", true),
262        other => (other, false),
263    }
264}
265
266fn parse_action_diagnostic(
267    message: &str,
268) -> Option<Result<(String, Option<String>, ActionDiagnostic)>> {
269    let payload = message.strip_prefix(ACTION_DIAGNOSTIC_PREFIX)?;
270    Some((|| {
271        let envelope: ActionDiagnosticEnvelope = serde_json::from_str(payload)?;
272        // Unconsulted belongs here with hit and miss: it is the outcome a cold
273        // store gives every compilation it publishes, so refusing it threw away
274        // the only record of the key another checkout would later look up.
275        let (outcome, _) = lookup_outcome(&envelope.outcome);
276        if !matches!(outcome, "hit" | "miss" | "unconsulted") {
277            bail!("invalid action diagnostic outcome");
278        }
279        validate_crate_name(envelope.crate_name.as_deref())?;
280        Ok((
281            outcome.to_string(),
282            envelope.crate_name,
283            envelope.diagnostic,
284        ))
285    })())
286}
287
288fn atomic_saturating_add(target: &AtomicU64, value: u64) {
289    let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
290        Some(current.saturating_add(value))
291    });
292}
293
294fn queue_prefetch_digest(
295    verified: &BTreeMap<CacheDigest, PathBuf>,
296    pending: &mut BTreeMap<CacheDigest, ()>,
297    digest: CacheDigest,
298) {
299    if verified.contains_key(&digest) || pending.contains_key(&digest) {
300        return;
301    }
302    pending.insert(digest, ());
303}
304
305fn queue_prefetch_directory(
306    seen: &BTreeMap<CacheDigest, ()>,
307    pending: &mut BTreeMap<CacheDigest, ()>,
308    digest: CacheDigest,
309    limit: usize,
310) -> bool {
311    if seen.contains_key(&digest) || pending.contains_key(&digest) {
312        return true;
313    }
314    if seen.len().saturating_add(pending.len()) >= limit {
315        return false;
316    }
317    pending.insert(digest, ());
318    true
319}
320
321/// See [`CacheAgent::with_task_loader`].
322pub type TaskLoader = dyn for<'a> Fn(&'a CacheAgent, &'a str) -> BoxFuture<'a, ()> + Send + Sync;
323
324/// Shared state for an agent hosted by the process that owns a build session.
325///
326/// Transport listeners deliberately live in the embedder so the session
327/// lifecycle owns them. This type only contains ecosystem-independent CAS and
328/// protocol logic.
329#[derive(Clone)]
330pub struct CacheAgent {
331    cas: LocalCas,
332    actions: LocalActionCache,
333    verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, VerifiedBlob>>>,
334    version: Arc<str>,
335    write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
336    action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
337    stats: Arc<AtomicAgentStats>,
338    observer: Option<Arc<dyn AgentEventObserver>>,
339    observer_emission: Arc<Mutex<()>>,
340    /// Loads a task the first time a request names it, so a connection that
341    /// only reports a bypass never waits for a manifest it will not read.
342    task_loader: Option<Arc<TaskLoader>>,
343    executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
344    /// Where identities pinned to their executables outlive the session.
345    identity_dir: Arc<PathBuf>,
346    manifest_dir: Arc<PathBuf>,
347    task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
348    /// Where a task may inherit predictions from, by task identity.
349    task_fallbacks: Arc<Mutex<BTreeMap<String, TaskFallbacks>>>,
350    next_task_run: Arc<AtomicU64>,
351    manifest_write_lock: Arc<Mutex<()>>,
352    remote: Option<Arc<RemoteCacheClient>>,
353    remote_mode: RemoteCacheMode,
354    remote_staging_dir: Arc<PathBuf>,
355    remote_download_limit: u64,
356    remote_download_bytes: Arc<AtomicU64>,
357    pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
358    remote_transfers: Arc<tokio::sync::Semaphore>,
359    prefetch_transfers: Arc<tokio::sync::Semaphore>,
360    prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
361    /// Distinct shim diagnostics already surfaced, so a diagnostic that fires
362    /// once per compilation is printed once per session. The severity is part
363    /// of each key: a fatal reason must still be surfaced after the same text
364    /// appeared as a warning.
365    diagnostics: Arc<Mutex<BTreeSet<(DiagnosticLevel, String)>>>,
366    /// Digests of files shims hashed or wrote this session, keyed by scope and
367    /// path, each entry standing while its recorded identity matches the disk.
368    file_digests: Arc<Mutex<BTreeMap<(FileDigestScope, PathBuf), RecordedFileDigest>>>,
369    /// Per-identity flights that make concurrent cold lookups share one read.
370    file_digest_locks: Arc<Mutex<FileDigestFlights>>,
371    file_digest_permits: Arc<tokio::sync::Semaphore>,
372    #[cfg(test)]
373    file_digest_reads: Arc<AtomicU64>,
374    /// Deferred remote publication, present only when the session may write.
375    uploads: Option<UploadQueue>,
376}
377
378/// What every file-digest record must satisfy before the ledger will answer
379/// with it, whether a shim sent it or an earlier session left it behind.
380fn validate_file_digest_record(entry: &RecordedFileDigest) -> Result<()> {
381    if !entry.file.path.is_absolute() {
382        bail!("file-digest records need absolute paths");
383    }
384    entry.digest.validate()?;
385    if entry.file.len != entry.digest.size {
386        bail!("file-digest record length does not match its digest");
387    }
388    Ok(())
389}
390
391/// Records background upload activity against a session's statistics.
392struct AgentUploadSink {
393    stats: Arc<AtomicAgentStats>,
394}
395
396impl UploadSink for AgentUploadSink {
397    fn record_blob_uploaded(&self, bytes: u64) {
398        self.stats
399            .background_uploads
400            .fetch_add(1, Ordering::Relaxed);
401        self.stats
402            .uploaded_bytes
403            .fetch_add(bytes, Ordering::Relaxed);
404    }
405
406    fn record_action_uploaded(&self) {
407        self.stats
408            .background_uploads
409            .fetch_add(1, Ordering::Relaxed);
410    }
411
412    fn record_blob_pack_uploaded(&self, blobs: u64) {
413        self.stats
414            .remote_blob_pack_uploads
415            .fetch_add(1, Ordering::Relaxed);
416        self.stats
417            .remote_blob_pack_upload_blobs
418            .fetch_add(blobs, Ordering::Relaxed);
419    }
420
421    fn record_upload_failure(&self) {
422        self.stats
423            .background_upload_failures
424            .fetch_add(1, Ordering::Relaxed);
425        self.stats.remote_failures.fetch_add(1, Ordering::Relaxed);
426    }
427}
428
429/// A CAS blob whose contents this session hashed, and the file identity that
430/// says the hash still describes what is on disk.
431///
432/// Length alone would miss an overwrite that keeps the size; the modification
433/// time is what makes a rewrite visible without reading the bytes again.
434#[derive(Debug, Clone)]
435struct VerifiedBlob {
436    path: PathBuf,
437    len: u64,
438    modified: SystemTime,
439}
440
441impl VerifiedBlob {
442    /// Describe a file that was just verified, or nothing when the filesystem
443    /// will not report a modification time to compare against later.
444    fn describe(path: &Path) -> Option<Self> {
445        let metadata = std::fs::metadata(path).ok()?;
446        Some(Self {
447            path: path.to_path_buf(),
448            len: metadata.len(),
449            modified: metadata.modified().ok()?,
450        })
451    }
452
453    /// Whether the file still has the identity it had when it was verified.
454    fn is_unchanged(&self) -> bool {
455        let Ok(metadata) = std::fs::metadata(&self.path) else {
456            return false;
457        };
458        metadata.len() == self.len && metadata.modified().is_ok_and(|now| now == self.modified)
459    }
460}
461
462#[derive(Debug, Clone, Default)]
463struct TaskActionState {
464    manifest: String,
465    baseline_loaded: bool,
466    predictions: BTreeMap<CacheDigest, ActionPrediction>,
467    pending_predictions: BTreeMap<CacheDigest, ActionPrediction>,
468    /// The pending predictions that differ from what the manifest already
469    /// held when this run loaded it, each with what it held: nothing, or
470    /// the baseline's own prediction. A hit re-records its prediction
471    /// byte-for-byte so the receipt can name it, and that is no reason to
472    /// rewrite the manifest; nor is a prediction that moved and then moved
473    /// back, which is why the baseline value is kept rather than a flag.
474    changed_predictions: BTreeMap<CacheDigest, Option<ActionPrediction>>,
475    /// Values observed in the remote manifest when this run began. `None`
476    /// means provenance is unknown because the lookup failed or the baseline
477    /// was inherited locally; it must not make every inherited value look
478    /// unpublished.
479    remote_predictions: Option<BTreeMap<CacheDigest, ActionPrediction>>,
480    /// The ordered remote snapshot associated with `remote_etag`. A
481    /// read-write publisher merges this back immediately before its PUT so a
482    /// bounded begin-time view cannot retract remote-only history.
483    remote_manifest: Option<TaskActionManifest>,
484    /// The task identity has a useful local baseline but no known remote copy.
485    /// Even an otherwise hit-only run must publish an inherited or missing
486    /// remote manifest once.
487    remote_publication_needed: bool,
488    prefetched_adapters: BTreeSet<String>,
489    remote_etag: Option<String>,
490}
491
492/// Match an invocation and, on an adapter's first match, select its prefetch wave.
493fn activate_prediction_adapter(
494    state: &mut TaskActionState,
495    invocation: &CacheDigest,
496) -> (Option<ActionPrediction>, Option<Vec<ActionPrediction>>) {
497    let prediction = state.predictions.get(invocation).cloned();
498    let prefetch = prediction.as_ref().and_then(|prediction| {
499        if !state.prefetched_adapters.insert(prediction.adapter.clone()) {
500            return None;
501        }
502        Some(
503            state
504                .predictions
505                .values()
506                .filter(|candidate| candidate.adapter == prediction.adapter)
507                .cloned()
508                .collect(),
509        )
510    });
511    (prediction, prefetch)
512}
513
514/// Protect this run's complete working set and then as many of the newest
515/// concurrent changes as the bounded manifest can carry.
516fn required_local_predictions(
517    predictions: &[ActionPrediction],
518    updates: &BTreeMap<CacheDigest, ActionPrediction>,
519    concurrent_updates: &BTreeMap<CacheDigest, ActionPrediction>,
520) -> BTreeSet<CacheDigest> {
521    let mut required: BTreeSet<_> = updates.keys().cloned().collect();
522    for prediction in predictions.iter().rev() {
523        if required.len() == MAX_TASK_ACTION_PREDICTIONS {
524            break;
525        }
526        if concurrent_updates.contains_key(&prediction.invocation) {
527            required.insert(prediction.invocation.clone());
528        }
529    }
530    required
531}
532
533/// Find disk values written after this run began, using the saved pre-recording
534/// value for invocations this run also changed.
535fn concurrent_task_predictions(
536    predictions: &[ActionPrediction],
537    state: &TaskActionState,
538) -> BTreeMap<CacheDigest, ActionPrediction> {
539    predictions
540        .iter()
541        .filter(|prediction| {
542            let baseline = match state.changed_predictions.get(&prediction.invocation) {
543                Some(baseline) => baseline.as_ref(),
544                None => state.predictions.get(&prediction.invocation),
545            };
546            baseline != Some(*prediction)
547        })
548        .map(|prediction| (prediction.invocation.clone(), prediction.clone()))
549        .collect()
550}
551
552/// Turn hit re-recordings into LRU refreshes of the value current at commit
553/// time. A run that actually introduced or changed a prediction remains
554/// authoritative; a hit on its unchanged baseline must not clobber a newer
555/// concurrent value for the same invocation.
556fn effective_task_updates(
557    predictions: &[ActionPrediction],
558    state: &TaskActionState,
559) -> BTreeMap<CacheDigest, ActionPrediction> {
560    let current: BTreeMap<_, _> = predictions
561        .iter()
562        .map(|prediction| (prediction.invocation.clone(), prediction))
563        .collect();
564    state
565        .pending_predictions
566        .iter()
567        .map(|(invocation, update)| {
568            let unchanged_hit = !state.changed_predictions.contains_key(invocation);
569            let effective = if unchanged_hit {
570                current.get(invocation).copied().unwrap_or(update).clone()
571            } else {
572                update.clone()
573            };
574            (invocation.clone(), effective)
575        })
576        .collect()
577}
578
579fn should_refresh_task_prediction_order(prediction_count: usize, has_recorded: bool) -> bool {
580    has_recorded && prediction_count >= MAX_TASK_ACTION_PREDICTIONS - TASK_ACTION_LRU_REFRESH_MARGIN
581}
582
583/// Choose the last remotely observed value for every prediction this run
584/// replaces. A local-only value is not proof that its action was published.
585fn replaced_task_predictions(
586    remote: &BTreeMap<CacheDigest, ActionPrediction>,
587    candidates: &BTreeSet<CacheDigest>,
588) -> Vec<ActionPrediction> {
589    candidates
590        .iter()
591        .filter_map(|invocation| remote.get(invocation).cloned())
592        .collect()
593}
594
595/// Decide which local values may outrank a remote manifest during publication.
596///
597/// Values changed by this run and concurrent local writers are authoritative
598/// once their actions are publishable. An unchanged hit is only an LRU hint:
599/// it must not replace a newer value learned remotely after this run began.
600fn remote_publication_precedence(
601    state: &TaskActionState,
602    completed: &[ActionPrediction],
603    concurrent_updates: &BTreeMap<CacheDigest, ActionPrediction>,
604    proven_uploads: &BTreeSet<CacheDigest>,
605    published: &BTreeMap<CacheDigest, &ActionPrediction>,
606    withheld: &BTreeSet<CacheDigest>,
607) -> (BTreeSet<CacheDigest>, BTreeSet<CacheDigest>) {
608    let publication_base_invocations: BTreeSet<_> = state
609        .remote_manifest
610        .iter()
611        .flat_map(|manifest| manifest.predictions.iter())
612        .map(|prediction| prediction.invocation.clone())
613        .collect();
614    let unchanged_hits: BTreeSet<_> = completed
615        .iter()
616        .filter(|prediction| {
617            !state
618                .changed_predictions
619                .contains_key(&prediction.invocation)
620        })
621        .map(|prediction| prediction.invocation.clone())
622        .collect();
623    let required = completed
624        .iter()
625        .filter(|prediction| !unchanged_hits.contains(&prediction.invocation))
626        .chain(concurrent_updates.values().filter(|prediction| {
627            state.remote_predictions.is_some() || proven_uploads.contains(&prediction.action)
628        }))
629        .filter(|prediction| {
630            !withheld.contains(&prediction.invocation)
631                && published.get(&prediction.invocation).copied() == Some(*prediction)
632        })
633        .map(|prediction| prediction.invocation.clone())
634        .collect();
635    let fallbacks = withheld
636        .iter()
637        .filter(|invocation| published.contains_key(*invocation))
638        .cloned()
639        .chain(
640            unchanged_hits
641                .into_iter()
642                .filter(|invocation| published.contains_key(invocation)),
643        )
644        .chain(
645            concurrent_updates
646                .iter()
647                .filter(|(invocation, prediction)| {
648                    state.remote_predictions.is_none()
649                        && !proven_uploads.contains(&prediction.action)
650                        && published.contains_key(*invocation)
651                })
652                .map(|(invocation, _)| invocation.clone()),
653        )
654        .chain(
655            published
656                .keys()
657                .filter(|invocation| {
658                    state.remote_predictions.is_none()
659                        && state.remote_manifest.is_some()
660                        && !publication_base_invocations.contains(*invocation)
661                })
662                .cloned(),
663        )
664        .collect();
665    (required, fallbacks)
666}
667
668/// Combine baseline provenance with this queue's observed upload outcomes.
669/// A held action is proven only when it is absent from the failed set.
670fn unpublished_task_actions(
671    mut unproven: BTreeSet<CacheDigest>,
672    held: &BTreeSet<CacheDigest>,
673    failed: BTreeSet<CacheDigest>,
674) -> BTreeSet<CacheDigest> {
675    unproven.retain(|action| !held.contains(action));
676    unproven.extend(failed);
677    unproven
678}
679
680/// Find final values the task's remote baseline does not prove were published.
681fn unproven_task_predictions<'a>(
682    predictions: impl IntoIterator<Item = &'a ActionPrediction>,
683    remote: &BTreeMap<CacheDigest, ActionPrediction>,
684) -> BTreeMap<CacheDigest, CacheDigest> {
685    predictions
686        .into_iter()
687        .filter(|prediction| remote.get(&prediction.invocation) != Some(*prediction))
688        .map(|prediction| (prediction.invocation.clone(), prediction.action.clone()))
689        .collect()
690}
691
692/// Remove new or replacement predictions whose actions did not publish,
693/// restoring only the last value observed in the remote manifest.
694fn withhold_unpublished_updates(
695    manifest: &mut TaskActionManifest,
696    candidates: &BTreeSet<CacheDigest>,
697    replaced: &[ActionPrediction],
698    unpublished: &BTreeSet<CacheDigest>,
699) -> BTreeSet<CacheDigest> {
700    let withheld: BTreeSet<_> = manifest
701        .predictions
702        .iter()
703        .filter(|prediction| {
704            candidates.contains(&prediction.invocation) && unpublished.contains(&prediction.action)
705        })
706        .map(|prediction| prediction.invocation.clone())
707        .collect();
708    manifest
709        .predictions
710        .retain(|prediction| !withheld.contains(&prediction.invocation));
711    let mut retained: BTreeSet<_> = manifest
712        .predictions
713        .iter()
714        .map(|prediction| prediction.invocation.clone())
715        .collect();
716    for previous in replaced.iter().rev() {
717        if withheld.contains(&previous.invocation) && retained.insert(previous.invocation.clone()) {
718            // This invocation was used by the current run. Keep its last
719            // published value at the newest end of the durable LRU even though
720            // the replacement could not be advertised.
721            manifest.predictions.push(previous.clone());
722        }
723    }
724    withheld
725}
726
727struct PrefetchedAction {
728    adapter: String,
729    result: RemoteActionResult,
730}
731
732struct RemoteDownloadReservation {
733    counter: Arc<AtomicU64>,
734    reserved: u64,
735    committed: bool,
736}
737
738impl RemoteDownloadReservation {
739    fn bytes(&self) -> u64 {
740        self.reserved
741    }
742
743    fn commit(mut self, bytes: u64) {
744        debug_assert!(bytes <= self.reserved);
745        self.counter
746            .fetch_sub(self.reserved.saturating_sub(bytes), Ordering::AcqRel);
747        self.committed = true;
748    }
749}
750
751impl Drop for RemoteDownloadReservation {
752    fn drop(&mut self) {
753        if !self.committed {
754            self.counter.fetch_sub(self.reserved, Ordering::AcqRel);
755        }
756    }
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
760struct ExecutableIdentityKey {
761    executable: PathBuf,
762    environment: BTreeMap<String, Option<String>>,
763}
764
765// 2: pins carry the state the shim saw, and cover the search directories.
766const PERSISTED_EXECUTABLE_IDENTITY_VERSION: u8 = 2;
767
768/// An executable identity kept across sessions.
769///
770/// It stands while every pinned file is as the probing shim saw it. The
771/// probe reads those files and nothing else, so an unchanged set of them
772/// would print the same answer again.
773#[derive(Debug, Serialize, Deserialize)]
774struct PersistedExecutableIdentity {
775    version: u8,
776    executable: PathBuf,
777    environment: BTreeMap<String, Option<String>>,
778    pins: Vec<PinnedFile>,
779    stdout: String,
780}
781
782impl CacheAgent {
783    /// Create an agent backed by the cache rooted at `cache_dir`.
784    pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
785        Self::build(cache_dir.into(), version.into(), None, 0)
786    }
787
788    /// Create an agent with local-first access to a remote action cache.
789    pub fn new_remote(
790        cache_dir: impl Into<PathBuf>,
791        version: impl Into<Arc<str>>,
792        remote: AgentRemoteCache,
793    ) -> Self {
794        Self::build(
795            cache_dir.into(),
796            version.into(),
797            Some(remote),
798            DEFAULT_MAX_REMOTE_DOWNLOAD_BYTES,
799        )
800    }
801
802    /// Create a remote agent with a cumulative download budget for the session.
803    pub fn new_remote_with_download_limit(
804        cache_dir: impl Into<PathBuf>,
805        version: impl Into<Arc<str>>,
806        remote: AgentRemoteCache,
807        max_remote_download_bytes: u64,
808    ) -> Self {
809        Self::build(
810            cache_dir.into(),
811            version.into(),
812            Some(remote),
813            max_remote_download_bytes,
814        )
815    }
816
817    fn build(
818        cache_dir: PathBuf,
819        version: Arc<str>,
820        remote: Option<AgentRemoteCache>,
821        remote_download_limit: u64,
822    ) -> Self {
823        let remote_mode = remote
824            .as_ref()
825            .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
826        let remote_staging_dir = remote.as_ref().map_or_else(
827            || cache_dir.join("remote"),
828            |remote| remote.staging_dir.clone(),
829        );
830        let remote = remote.map(|remote| Arc::new(remote.client));
831        let stats = Arc::new(AtomicAgentStats::default());
832        let remote_transfers = Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS));
833        // A session that cannot publish never queues an upload, so it does not
834        // need somewhere to queue one.
835        let uploads = remote
836            .clone()
837            .filter(|_| remote_mode.writes())
838            .map(|client| {
839                UploadQueue::new(
840                    client,
841                    Arc::new(AgentUploadSink {
842                        stats: stats.clone(),
843                    }),
844                    remote_transfers.clone(),
845                )
846            });
847        Self {
848            cas: LocalCas::new(cache_dir.clone()),
849            actions: LocalActionCache::new(cache_dir.clone()),
850            verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
851            version,
852            write_locks: Arc::new(Mutex::new(BTreeMap::new())),
853            action_locks: Arc::new(Mutex::new(BTreeMap::new())),
854            stats,
855            observer: None,
856            observer_emission: Arc::new(Mutex::new(())),
857            task_loader: None,
858            executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
859            identity_dir: Arc::new(cache_dir.join("executable-identities").join("v1")),
860            manifest_dir: Arc::new(task_manifest_dir(&cache_dir)),
861            task_actions: Arc::new(Mutex::new(BTreeMap::new())),
862            task_fallbacks: Arc::new(Mutex::new(BTreeMap::new())),
863            next_task_run: Arc::new(AtomicU64::new(0)),
864            manifest_write_lock: Arc::new(Mutex::new(())),
865            remote,
866            remote_mode,
867            remote_staging_dir: Arc::new(remote_staging_dir),
868            remote_download_limit,
869            remote_download_bytes: Arc::new(AtomicU64::new(0)),
870            pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
871            remote_transfers,
872            prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
873            prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
874            diagnostics: Arc::new(Mutex::new(BTreeSet::new())),
875            file_digests: Arc::new(Mutex::new(BTreeMap::new())),
876            file_digest_locks: Arc::new(Mutex::new(BTreeMap::new())),
877            file_digest_permits: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_DIGESTS)),
878            #[cfg(test)]
879            file_digest_reads: Arc::new(AtomicU64::new(0)),
880            uploads,
881        }
882    }
883
884    /// Report each accounted cache decision to `observer` as it happens.
885    #[must_use]
886    pub fn with_observer(mut self, observer: Arc<dyn AgentEventObserver>) -> Self {
887        self.observer = Some(observer);
888        self
889    }
890
891    /// Load tasks on demand.
892    ///
893    /// The loader is called with the agent and the task's identity before the
894    /// first request that names a task this agent has not begun, and is
895    /// expected to begin it; a second call for the same task must be a no-op.
896    /// Requests that name no task never wait on it.
897    pub fn with_task_loader(mut self, loader: Arc<TaskLoader>) -> Self {
898        self.task_loader = Some(loader);
899        self
900    }
901
902    /// Make sure `task` has been begun before a request reads or records
903    /// under it.
904    async fn ensure_task_loaded(&self, task: &str) {
905        let Some(loader) = &self.task_loader else {
906            return;
907        };
908        if self.task_actions.lock().unwrap().contains_key(task) {
909            return;
910        }
911        loader(self, task).await;
912    }
913
914    fn emit(&self, event: impl FnOnce() -> AgentEvent) {
915        if let Some(observer) = &self.observer {
916            let _emission = self.observer_emission.lock().unwrap();
917            observer.event(event());
918        }
919    }
920
921    fn emit_action(&self, diagnostic: Option<AgentEvent>, action: AgentEvent) {
922        if let Some(observer) = &self.observer {
923            let _emission = self.observer_emission.lock().unwrap();
924            if let Some(diagnostic) = diagnostic {
925                observer.event(diagnostic);
926            }
927            observer.event(action);
928        }
929    }
930
931    fn reserve_remote_download(&self, bytes: u64) -> Result<RemoteDownloadReservation> {
932        let mut current = self.remote_download_bytes.load(Ordering::Acquire);
933        loop {
934            let next = current
935                .checked_add(bytes)
936                .ok_or_else(|| eyre::eyre!("remote cache download budget overflowed"))?;
937            if next > self.remote_download_limit {
938                bail!(
939                    "remote cache download budget exceeded: {} bytes requested with {} of {} bytes already used",
940                    bytes,
941                    current,
942                    self.remote_download_limit
943                );
944            }
945            match self.remote_download_bytes.compare_exchange_weak(
946                current,
947                next,
948                Ordering::AcqRel,
949                Ordering::Acquire,
950            ) {
951                Ok(_) => {
952                    return Ok(RemoteDownloadReservation {
953                        counter: self.remote_download_bytes.clone(),
954                        reserved: bytes,
955                        committed: false,
956                    });
957                }
958                Err(observed) => current = observed,
959            }
960        }
961    }
962
963    fn reserve_remote_download_up_to(&self, requested: u64) -> Result<RemoteDownloadReservation> {
964        let mut current = self.remote_download_bytes.load(Ordering::Acquire);
965        loop {
966            let reserved = requested.min(self.remote_download_limit.saturating_sub(current));
967            let next = current + reserved;
968            match self.remote_download_bytes.compare_exchange_weak(
969                current,
970                next,
971                Ordering::AcqRel,
972                Ordering::Acquire,
973            ) {
974                Ok(_) => {
975                    return Ok(RemoteDownloadReservation {
976                        counter: self.remote_download_bytes.clone(),
977                        reserved,
978                        committed: false,
979                    });
980                }
981                Err(observed) => current = observed,
982            }
983        }
984    }
985
986    /// Load the last committed action manifest for a task into this session.
987    pub async fn begin_task(&self, task: &str) -> Result<String> {
988        self.begin_task_with_remote_errors(task, false, None, true)
989            .await
990    }
991
992    /// Load a task but defer each adapter's speculative downloads until its
993    /// first prediction matches the current build.
994    pub async fn begin_task_on_prediction(&self, task: &str) -> Result<String> {
995        self.begin_task_with_remote_errors(task, false, None, false)
996            .await
997    }
998
999    /// Load a task using its identity as the run key.
1000    ///
1001    /// A task-scoped process has only one run for an identity, so it can defer
1002    /// this work until its first client connects while putting the identity in
1003    /// the client's environment ahead of time.
1004    pub async fn begin_session_task(&self, task: &str) -> Result<()> {
1005        self.begin_task_with_remote_errors(task, false, Some(task.to_string()), false)
1006            .await?;
1007        Ok(())
1008    }
1009
1010    /// Name where a task's predictions may come from when nothing has been
1011    /// recorded under its own identity.
1012    ///
1013    /// The identities are produced on demand rather than taken up front,
1014    /// because finding them can cost a few version-control commands and most
1015    /// builds never need them. The first manifest found, locally and then on
1016    /// the remote, is adopted under the task's own identity, so the commands
1017    /// that follow, tests and lints included, start from it too, and a trusted
1018    /// build publishes it there. When none of the named identities has one,
1019    /// the store's newest manifests are tried.
1020    pub fn register_task_fallbacks(
1021        &self,
1022        task: &str,
1023        fallbacks: impl Fn() -> Vec<String> + Send + Sync + 'static,
1024    ) -> Result<()> {
1025        validate_task_identity(task)?;
1026        self.task_fallbacks
1027            .lock()
1028            .unwrap()
1029            .insert(task.to_string(), Arc::new(fallbacks));
1030        Ok(())
1031    }
1032
1033    /// Load a task and finish its prefetch, surfacing remote lookup failures.
1034    pub async fn prefetch_task(&self, task: &str) -> Result<String> {
1035        let run = self
1036            .begin_task_with_remote_errors(task, true, None, true)
1037            .await?;
1038        self.wait_for_prefetches().await;
1039        Ok(run)
1040    }
1041
1042    async fn begin_task_with_remote_errors(
1043        &self,
1044        task: &str,
1045        strict: bool,
1046        run: Option<String>,
1047        eager_prefetch: bool,
1048    ) -> Result<String> {
1049        validate_task_identity(task)?;
1050        // The local manifest is already enough to start useful work. Do not
1051        // leave those predictions idle while a high-latency remote answers the
1052        // manifest lookup; the authoritative snapshot is loaded again below
1053        // before it is merged, so another process can still update it while
1054        // this request is in flight.
1055        let early_manifest = {
1056            let _write_guard = self.manifest_write_lock.lock().unwrap();
1057            let _file_guard = self.lock_task_manifest(task)?;
1058            self.load_task_manifest(task)?
1059        };
1060        let early_actions: BTreeSet<_> = early_manifest
1061            .iter()
1062            .flat_map(|manifest| manifest.predictions.iter())
1063            .map(|prediction| prediction.action.clone())
1064            .collect();
1065        if eager_prefetch {
1066            self.spawn_prefetch_predictions(
1067                early_manifest
1068                    .as_ref()
1069                    .map(|manifest| manifest.predictions.clone())
1070                    .unwrap_or_default(),
1071            );
1072        }
1073        let (remote_manifest, mut remote_etag, mut remote_predictions, remote_missing) =
1074            if self.remote.is_some() && self.remote_mode.reads() {
1075                match self.get_remote_task_manifest(task).await {
1076                    Ok(Some((manifest, etag))) => {
1077                        let predictions = manifest
1078                            .predictions
1079                            .iter()
1080                            .map(|prediction| (prediction.invocation.clone(), prediction.clone()))
1081                            .collect();
1082                        (Some(manifest), Some(etag), Some(predictions), false)
1083                    }
1084                    Ok(None) => (None, None, Some(BTreeMap::new()), true),
1085                    Err(error) => {
1086                        if strict {
1087                            return Err(error).wrap_err_with(|| {
1088                                format!("remote task action manifest lookup failed for {task}")
1089                            });
1090                        }
1091                        self.note_remote_failure();
1092                        warn!("remote task action manifest lookup failed for {task}: {error}");
1093                        (None, None, None, false)
1094                    }
1095                }
1096            } else {
1097                (None, None, None, false)
1098            };
1099        let remote_publication_base = remote_manifest.clone();
1100        // Without a remote there is nothing to reconcile: the manifest just
1101        // read is the whole truth, and writing it back byte for byte would
1102        // serialize every prediction for nothing.
1103        let manifest = if self.remote.is_some() && self.remote_mode.reads() {
1104            let _write_guard = self.manifest_write_lock.lock().unwrap();
1105            let _file_guard = self.lock_task_manifest(task)?;
1106            let local_manifest = self.load_task_manifest(task)?;
1107            let local_baseline = local_manifest.clone();
1108            let manifest = match (remote_manifest, local_manifest) {
1109                (Some(remote), Some(local)) => {
1110                    let required = local
1111                        .predictions
1112                        .iter()
1113                        .map(|prediction| prediction.invocation.clone())
1114                        .collect();
1115                    match merge_task_manifests(
1116                        task,
1117                        remote,
1118                        local.clone(),
1119                        &required,
1120                        &BTreeSet::new(),
1121                    ) {
1122                        Ok(manifest) => Some(manifest),
1123                        Err(error) => {
1124                            warn!("remote task action manifest merge failed for {task}: {error}");
1125                            remote_etag = None;
1126                            Some(local)
1127                        }
1128                    }
1129                }
1130                (Some(remote), None) => Some(remote),
1131                (None, local) => local,
1132            };
1133            if manifest != local_baseline
1134                && let Some(manifest) = &manifest
1135            {
1136                self.persist_task_manifest(manifest)?;
1137            }
1138            manifest
1139        } else {
1140            early_manifest
1141        };
1142        let (manifest, remote_etag, inherited) = match manifest {
1143            Some(manifest) => (Some(manifest), remote_etag, false),
1144            // Inherited from another identity, so the remote's copy of that
1145            // one is no precondition for publishing under this one.
1146            None => {
1147                let (manifest, inherited_provenance, inherited) =
1148                    self.inherit_task_manifest(task, strict).await?;
1149                if inherited {
1150                    remote_predictions = inherited_provenance;
1151                }
1152                (manifest, None, inherited)
1153            }
1154        };
1155        let mut state = if let Some(manifest) = manifest {
1156            let write_only_baseline = self.remote_mode.writes()
1157                && !self.remote_mode.reads()
1158                && !manifest.predictions.is_empty();
1159            TaskActionState {
1160                manifest: task.to_string(),
1161                baseline_loaded: true,
1162                predictions: manifest
1163                    .predictions
1164                    .into_iter()
1165                    .map(|prediction| (prediction.invocation.clone(), prediction))
1166                    .collect(),
1167                pending_predictions: BTreeMap::new(),
1168                changed_predictions: BTreeMap::new(),
1169                remote_predictions,
1170                remote_manifest: remote_publication_base,
1171                remote_publication_needed: self.remote.is_some()
1172                    && self.remote_mode.writes()
1173                    && (remote_missing || inherited || write_only_baseline),
1174                prefetched_adapters: BTreeSet::new(),
1175                remote_etag,
1176            }
1177        } else {
1178            TaskActionState {
1179                manifest: task.to_string(),
1180                baseline_loaded: true,
1181                remote_predictions,
1182                remote_manifest: remote_publication_base,
1183                remote_etag,
1184                ..TaskActionState::default()
1185            }
1186        };
1187        let run = run.unwrap_or_else(|| {
1188            let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
1189            CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
1190                .hash
1191        });
1192        // The largest baseline rather than a sum: beginning the same task again
1193        // in one session reloads the same manifest, and counting it twice would
1194        // overstate what there was to match.
1195        self.stats.predictions_loaded.fetch_max(
1196            state.predictions.len().try_into().unwrap_or(u64::MAX),
1197            Ordering::Relaxed,
1198        );
1199        if eager_prefetch {
1200            state.prefetched_adapters.extend(
1201                state
1202                    .predictions
1203                    .values()
1204                    .map(|prediction| prediction.adapter.clone()),
1205            );
1206        }
1207        // The early local wave already owns these actions. Only launch a
1208        // second wave for predictions learned from the remote or from a local
1209        // writer that committed while its lookup was in flight.
1210        let predictions = state
1211            .predictions
1212            .values()
1213            .filter(|prediction| !early_actions.contains(&prediction.action))
1214            .cloned()
1215            .collect();
1216        self.task_actions.lock().unwrap().insert(run.clone(), state);
1217        if eager_prefetch {
1218            self.spawn_prefetch_predictions(predictions);
1219        }
1220        Ok(run)
1221    }
1222
1223    /// Adopt the manifest of a fallback identity for a task that has none.
1224    ///
1225    /// The named identities are tried in the order given, each in the local
1226    /// store and then on the remote, and then the store's newest manifests,
1227    /// locally only. The first one found is persisted under `task` so the
1228    /// next command sees a complete baseline rather than only what this one
1229    /// recorded, and a later session finds it without asking again.
1230    async fn inherit_task_manifest(
1231        &self,
1232        task: &str,
1233        strict: bool,
1234    ) -> Result<(
1235        Option<TaskActionManifest>,
1236        Option<BTreeMap<CacheDigest, ActionPrediction>>,
1237        bool,
1238    )> {
1239        let fallbacks = self.task_fallbacks.lock().unwrap().get(task).cloned();
1240        let Some(fallbacks) = fallbacks else {
1241            return Ok((None, None, false));
1242        };
1243        let named = tokio::task::spawn_blocking(move || fallbacks()).await?;
1244        let mut tried = BTreeSet::from([task.to_string()]);
1245        let candidates = named.into_iter().map(|identity| (identity, true)).chain(
1246            self.newest_task_identities()
1247                .into_iter()
1248                .map(|identity| (identity, false)),
1249        );
1250        for (identity, named) in candidates {
1251            if !tried.insert(identity.clone()) || validate_task_identity(&identity).is_err() {
1252                continue;
1253            }
1254            let mut found = self.load_task_manifest(&identity)?;
1255            let mut provenance = None;
1256            if self.remote_mode.reads() && (found.is_some() || named) {
1257                match self.get_remote_task_manifest(&identity).await {
1258                    Ok(Some((remote, _))) => {
1259                        provenance = Some(
1260                            remote
1261                                .predictions
1262                                .iter()
1263                                .map(|prediction| {
1264                                    (prediction.invocation.clone(), prediction.clone())
1265                                })
1266                                .collect(),
1267                        );
1268                        if found.is_none() {
1269                            found = Some(remote);
1270                        }
1271                    }
1272                    Ok(None) => provenance = Some(BTreeMap::new()),
1273                    Err(error) => {
1274                        // A local fallback is already useful without remote
1275                        // provenance. Keep that baseline with unknown
1276                        // provenance rather than making strict prefetch fail
1277                        // because its optional proof lookup was unavailable.
1278                        if strict && found.is_none() {
1279                            return Err(error).wrap_err_with(|| {
1280                                format!("remote task action manifest lookup failed for {identity}")
1281                            });
1282                        }
1283                        self.note_remote_failure();
1284                        warn!("remote task action manifest lookup failed for {identity}: {error}");
1285                    }
1286                }
1287            }
1288            let Some(mut manifest) = found else {
1289                continue;
1290            };
1291            if manifest.predictions.is_empty()
1292                || (!named && manifest.predictions.len() > MAX_TASK_ACTION_PREDICTIONS / 2)
1293            {
1294                continue;
1295            }
1296            debug!(
1297                "no predictions were recorded for {task}; inheriting {} from {identity}",
1298                manifest.predictions.len()
1299            );
1300            manifest.task = task.to_string();
1301            validate_task_manifest(&manifest, task)?;
1302            let _write_guard = self.manifest_write_lock.lock().unwrap();
1303            let _file_guard = self.lock_task_manifest(task)?;
1304            // Another process may have recorded this identity while the
1305            // fallbacks were being found. What it wrote describes this
1306            // lockfile and wins over an inheritance.
1307            if let Some(recorded) = self.load_task_manifest(task)? {
1308                return Ok((Some(recorded), None, false));
1309            }
1310            self.persist_task_manifest(&manifest)?;
1311            return Ok((Some(manifest), provenance, true));
1312        }
1313        Ok((None, None, false))
1314    }
1315
1316    /// The identities of the store's most recently written manifests.
1317    fn newest_task_identities(&self) -> Vec<String> {
1318        let Ok(entries) = fs::read_dir(self.manifest_dir.as_path()) else {
1319            return Vec::new();
1320        };
1321        let mut manifests: Vec<(std::time::SystemTime, String)> = entries
1322            .filter_map(|entry| {
1323                let entry = entry.ok()?;
1324                let name = entry.file_name();
1325                let identity = name.to_str()?.strip_suffix(".json")?.to_string();
1326                validate_task_identity(&identity).ok()?;
1327                let modified = entry.metadata().ok()?.modified().ok()?;
1328                Some((modified, identity))
1329            })
1330            .collect();
1331        manifests.sort_by(|left, right| right.cmp(left));
1332        manifests
1333            .into_iter()
1334            .take(NEWEST_MANIFEST_CANDIDATES)
1335            .map(|(_, identity)| identity)
1336            .collect()
1337    }
1338
1339    /// Cancel speculative downloads before the owning session exits.
1340    pub async fn cancel_prefetches(&self) {
1341        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
1342        for task in &tasks {
1343            task.abort();
1344        }
1345        for task in tasks {
1346            if let Err(error) = task.await
1347                && !error.is_cancelled()
1348            {
1349                warn!("remote action prefetch task failed: {error}");
1350            }
1351        }
1352    }
1353
1354    /// Publish everything a build queued, before the session stops.
1355    ///
1356    /// Store requests return once an object is durable locally, so at this point
1357    /// the remote cache may still be behind the local one. Uploads run on the
1358    /// session's runtime and are abandoned if it goes away, so a session that
1359    /// wants them published has to wait here.
1360    pub async fn wait_for_uploads(&self) {
1361        let Some(uploads) = &self.uploads else {
1362            return;
1363        };
1364        let _timer = AtomicDurationTimer::start(&self.stats.upload_drain_duration_ns);
1365        uploads.drain().await;
1366    }
1367
1368    async fn wait_for_prefetches(&self) {
1369        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
1370        for task in tasks {
1371            if let Err(error) = task.await {
1372                warn!("remote action prefetch task failed: {error}");
1373            }
1374        }
1375    }
1376
1377    /// Atomically publish the completed actions collected by a task run.
1378    pub async fn commit_task(&self, run: &str) -> Result<()> {
1379        self.commit_task_actions(run).await.map(|_| ())
1380    }
1381
1382    /// Publish a task run and return exactly the predictions completed by it.
1383    ///
1384    /// The persisted task manifest also carries predictions inherited from
1385    /// earlier runs. Callers that need a receipt for this one run must not
1386    /// mistake that cumulative manifest for the work the run completed.
1387    pub async fn commit_task_actions(&self, run: &str) -> Result<Vec<ActionPrediction>> {
1388        validate_task_identity(run)?;
1389        let mut state = {
1390            let mut runs = self.task_actions.lock().unwrap();
1391            let state = runs
1392                .get(run)
1393                .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
1394            if !state.baseline_loaded {
1395                bail!("task action manifest baseline was not loaded");
1396            }
1397            // A hit-only run leaves a comfortably bounded manifest as it found
1398            // it. Refresh durable LRU order only near the cap, where it can
1399            // affect the next pruning decision, or when this task identity
1400            // still needs its first remote publication.
1401            debug!(
1402                "committing {} changed of {} recorded predictions for {}",
1403                state.changed_predictions.len(),
1404                state.pending_predictions.len(),
1405                state.manifest
1406            );
1407            let refresh_hit_order = should_refresh_task_prediction_order(
1408                state.predictions.len(),
1409                !state.pending_predictions.is_empty(),
1410            );
1411            if state.changed_predictions.is_empty()
1412                && !refresh_hit_order
1413                && !state.remote_publication_needed
1414            {
1415                let completed = state.pending_predictions.values().cloned().collect();
1416                runs.remove(run);
1417                return Ok(completed);
1418            }
1419            state.clone()
1420        };
1421        let task = state.manifest.clone();
1422        validate_task_identity(&task)?;
1423        let write_only = self.remote_mode.writes() && !self.remote_mode.reads();
1424        // Write-only mode does not use remote predictions for cache hits, but
1425        // it still needs the current manifest as a publication precondition
1426        // and provenance baseline. The conditional update path may read the
1427        // same metadata after a conflict, so doing it up front does not weaken
1428        // the mode's promise not to satisfy build lookups from the remote.
1429        let write_only_remote = if write_only {
1430            match self.get_remote_task_manifest(&task).await {
1431                Ok(Some((manifest, etag))) => {
1432                    let remote_predictions: BTreeMap<_, _> = manifest
1433                        .predictions
1434                        .iter()
1435                        .map(|prediction| (prediction.invocation.clone(), prediction.clone()))
1436                        .collect();
1437                    let has_missing_fallback = state
1438                        .predictions
1439                        .keys()
1440                        .any(|invocation| !remote_predictions.contains_key(invocation));
1441                    state.remote_predictions = if has_missing_fallback {
1442                        None
1443                    } else {
1444                        Some(remote_predictions)
1445                    };
1446                    state.remote_etag = Some(etag);
1447                    state.remote_manifest = Some(manifest.clone());
1448                    // Same-valued or conflicting remote entries already cover
1449                    // this unknown-provenance baseline. Missing invocations do
1450                    // not: publish those as fallbacks without letting a stale
1451                    // local value overwrite a value the remote already knows.
1452                    state.remote_publication_needed = has_missing_fallback;
1453                    Some(manifest)
1454                }
1455                Ok(None) => {
1456                    state.remote_predictions = if state.predictions.is_empty() {
1457                        Some(BTreeMap::new())
1458                    } else {
1459                        None
1460                    };
1461                    state.remote_publication_needed = !state.predictions.is_empty();
1462                    None
1463                }
1464                Err(error) => {
1465                    state.remote_predictions = None;
1466                    state.remote_publication_needed = false;
1467                    self.note_remote_failure();
1468                    warn!("remote task action manifest lookup failed for {task}: {error}");
1469                    None
1470                }
1471            }
1472        } else {
1473            None
1474        };
1475        let completed = state
1476            .pending_predictions
1477            .values()
1478            .cloned()
1479            .collect::<Vec<_>>();
1480        if write_only
1481            && state.changed_predictions.is_empty()
1482            && !should_refresh_task_prediction_order(
1483                state.predictions.len(),
1484                !state.pending_predictions.is_empty(),
1485            )
1486            && !state.remote_publication_needed
1487        {
1488            self.task_actions.lock().unwrap().remove(run);
1489            return Ok(completed);
1490        }
1491        let (manifest, concurrent_updates) = {
1492            let _write_guard = self.manifest_write_lock.lock().unwrap();
1493            let _file_guard = self.lock_task_manifest(&task)?;
1494            let current_manifest = self.load_task_manifest(&task)?;
1495            let predictions = current_manifest
1496                .as_ref()
1497                .map(|manifest| manifest.predictions.clone())
1498                .unwrap_or_default();
1499            let concurrent_updates = concurrent_task_predictions(&predictions, &state);
1500            let effective_updates = effective_task_updates(&predictions, &state);
1501            let required =
1502                required_local_predictions(&predictions, &effective_updates, &concurrent_updates);
1503            let predictions = update_task_predictions(predictions, &effective_updates, &required)?;
1504            let manifest = TaskActionManifest {
1505                version: TASK_ACTION_MANIFEST_VERSION,
1506                task: task.clone(),
1507                predictions,
1508            };
1509            validate_task_manifest(&manifest, &task)?;
1510            if current_manifest.as_ref() != Some(&manifest) {
1511                self.persist_task_manifest(&manifest)?;
1512            }
1513            (manifest, concurrent_updates)
1514        };
1515        self.task_actions.lock().unwrap().remove(run);
1516        if self.remote_mode.writes() {
1517            // A manifest advertises the actions it predicts, so it must not
1518            // reach the remote cache before the results a reader would then go
1519            // looking for -- nor name a result that never got there at all.
1520            let mut manifest = manifest;
1521            let (held, failed) = if let Some(uploads) = &self.uploads {
1522                let actions: Vec<CacheDigest> = manifest
1523                    .predictions
1524                    .iter()
1525                    .map(|prediction| prediction.action.clone())
1526                    .collect();
1527                uploads.wait_for_action_outcomes(&actions).await
1528            } else {
1529                (BTreeSet::new(), BTreeSet::new())
1530            };
1531            let proven_uploads = held.difference(&failed).cloned().collect();
1532            let (withhold_candidates, replaced, unpublished) =
1533                if let Some(remote_predictions) = &state.remote_predictions {
1534                    let unproven_predictions =
1535                        unproven_task_predictions(&manifest.predictions, remote_predictions);
1536                    let unproven = unproven_predictions.values().cloned().collect();
1537                    let candidates = unproven_predictions.keys().cloned().collect();
1538                    let replaced = replaced_task_predictions(remote_predictions, &candidates);
1539                    // A successful upload by this queue proves an otherwise
1540                    // local-only value is now safe to advertise.
1541                    let unpublished = unpublished_task_actions(unproven, &held, failed);
1542                    (candidates, replaced, unpublished)
1543                } else {
1544                    // Unknown provenance must not turn an inherited baseline
1545                    // into thousands of false negatives. Still withhold values
1546                    // this run actually changed unless its own upload proves
1547                    // them publishable.
1548                    let candidates: BTreeSet<_> = state
1549                        .changed_predictions
1550                        .keys()
1551                        .filter(|invocation| {
1552                            manifest
1553                                .predictions
1554                                .iter()
1555                                .any(|prediction| &prediction.invocation == *invocation)
1556                        })
1557                        .cloned()
1558                        .collect();
1559                    let unproven = manifest
1560                        .predictions
1561                        .iter()
1562                        .filter(|prediction| candidates.contains(&prediction.invocation))
1563                        .map(|prediction| prediction.action.clone())
1564                        .collect();
1565                    let unpublished = unpublished_task_actions(unproven, &held, failed);
1566                    (candidates, Vec::new(), unpublished)
1567                };
1568            let prediction_count = manifest.predictions.len();
1569            let withheld = withhold_unpublished_updates(
1570                &mut manifest,
1571                &withhold_candidates,
1572                &replaced,
1573                &unpublished,
1574            );
1575            if !withheld.is_empty() {
1576                // The local manifest keeps them: this checkout can still use
1577                // what it built, and a later session can publish it.
1578                warn!(
1579                    "{} of {} predicted actions were not published, so the remote task action manifest omits them",
1580                    withheld.len(),
1581                    prediction_count
1582                );
1583            }
1584            let published: BTreeMap<_, _> = manifest
1585                .predictions
1586                .iter()
1587                .map(|prediction| (prediction.invocation.clone(), prediction))
1588                .collect();
1589            let (required, fallbacks) = remote_publication_precedence(
1590                &state,
1591                &completed,
1592                &concurrent_updates,
1593                &proven_uploads,
1594                &published,
1595                &withheld,
1596            );
1597            let publication_base = write_only_remote.or(state.remote_manifest);
1598            if let Some(remote) = publication_base {
1599                manifest = merge_task_manifests(&task, remote, manifest, &required, &fallbacks)?;
1600            }
1601            match self
1602                .put_remote_task_manifest(&task, manifest, state.remote_etag, &required, &fallbacks)
1603                .await
1604            {
1605                Ok(remote_manifest) => {
1606                    let _write_guard = self.manifest_write_lock.lock().unwrap();
1607                    let reconciliation = (|| {
1608                        let _file_guard = self.lock_task_manifest(&task)?;
1609                        let manifest = match self.load_task_manifest(&task)? {
1610                            Some(local) => {
1611                                let required = local
1612                                    .predictions
1613                                    .iter()
1614                                    .map(|prediction| prediction.invocation.clone())
1615                                    .collect();
1616                                match merge_task_manifests(
1617                                    &task,
1618                                    remote_manifest,
1619                                    local.clone(),
1620                                    &required,
1621                                    &BTreeSet::new(),
1622                                ) {
1623                                    Ok(manifest) => manifest,
1624                                    Err(error) => {
1625                                        warn!(
1626                                            "remote task action manifest merge failed for {task}: {error}"
1627                                        );
1628                                        local
1629                                    }
1630                                }
1631                            }
1632                            None => remote_manifest,
1633                        };
1634                        self.persist_task_manifest(&manifest)
1635                    })();
1636                    if let Err(error) = reconciliation {
1637                        warn!(
1638                            "remote task action manifest reconciliation failed for {task}: {error}"
1639                        );
1640                    }
1641                }
1642                Err(error) => {
1643                    self.note_remote_failure();
1644                    warn!("remote task action manifest upload failed for {task}: {error}");
1645                }
1646            }
1647        }
1648        Ok(completed)
1649    }
1650
1651    fn task_manifest_path(&self, task: &str) -> PathBuf {
1652        self.manifest_dir.join(format!("{task}.json"))
1653    }
1654
1655    fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
1656        self.manifest_dir.join("locks").join(format!("{task}.lock"))
1657    }
1658
1659    fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
1660        let path = self.task_manifest_lock_path(task);
1661        fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
1662        let mut lock = fslock::LockFile::open(&path)?;
1663        lock.lock()?;
1664        Ok(lock)
1665    }
1666
1667    fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
1668        match fs::read(self.task_manifest_path(task)) {
1669            Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
1670            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1671            Err(error) => Err(error.into()),
1672        }
1673    }
1674
1675    fn parse_task_manifest(
1676        &self,
1677        task: &str,
1678        contents: &[u8],
1679        require_canonical: bool,
1680    ) -> Result<TaskActionManifest> {
1681        let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
1682        validate_task_manifest(&manifest, task)?;
1683        if require_canonical && canonical_json(&manifest)? != contents {
1684            bail!("task action manifest is not canonical JSON");
1685        }
1686        Ok(manifest)
1687    }
1688
1689    fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
1690        TaskActionManifest::selector(task)
1691    }
1692
1693    fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
1694        let bytes = canonical_json(manifest)?;
1695        fs::create_dir_all(self.manifest_dir.as_path())?;
1696        let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
1697        std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
1698        temporary.as_file_mut().sync_all()?;
1699        temporary
1700            .persist(self.task_manifest_path(&manifest.task))
1701            .map_err(|error| error.error)?;
1702        Ok(())
1703    }
1704
1705    async fn get_remote_task_manifest(
1706        &self,
1707        task: &str,
1708    ) -> Result<Option<(TaskActionManifest, String)>> {
1709        let Some(remote) = &self.remote else {
1710            return Ok(None);
1711        };
1712        let (_, selector) = Self::task_manifest_selector(task)?;
1713        let _permit = self.remote_transfers.acquire().await?;
1714        self.stats
1715            .remote_manifest_lookups
1716            .fetch_add(1, Ordering::Relaxed);
1717        let _timer = AtomicDurationTimer::start(&self.stats.remote_manifest_lookup_duration_ns);
1718        let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
1719            return Ok(None);
1720        };
1721        let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
1722        Ok(Some((manifest, remote_manifest.etag)))
1723    }
1724
1725    async fn put_remote_task_manifest(
1726        &self,
1727        task: &str,
1728        mut manifest: TaskActionManifest,
1729        mut expected_etag: Option<String>,
1730        required: &BTreeSet<CacheDigest>,
1731        fallbacks: &BTreeSet<CacheDigest>,
1732    ) -> Result<TaskActionManifest> {
1733        let Some(remote) = &self.remote else {
1734            return Ok(manifest);
1735        };
1736        let (_, selector) = Self::task_manifest_selector(task)?;
1737        for _ in 0..4 {
1738            let bytes = canonical_json(&manifest)?;
1739            let outcome = {
1740                let _permit = self.remote_transfers.acquire().await?;
1741                remote
1742                    .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
1743                    .await?
1744            };
1745            match outcome {
1746                ManifestPutOutcome::Stored => return Ok(manifest),
1747                ManifestPutOutcome::PreconditionFailed => {
1748                    let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
1749                    else {
1750                        expected_etag = None;
1751                        continue;
1752                    };
1753                    manifest =
1754                        merge_task_manifests(task, remote_manifest, manifest, required, fallbacks)?;
1755                    expected_etag = Some(etag);
1756                }
1757            }
1758        }
1759        bail!("remote task action manifest changed too frequently")
1760    }
1761
1762    /// Return a snapshot of this session's cache activity.
1763    pub fn stats(&self) -> AgentStats {
1764        AgentStats {
1765            session_duration_ns: 0,
1766            lookups: self.stats.lookups.load(Ordering::Relaxed),
1767            unconsulted: self.stats.unconsulted.load(Ordering::Relaxed),
1768            incremental_compilations: self.stats.incremental_compilations.load(Ordering::Relaxed),
1769            hits: self.stats.hits.load(Ordering::Relaxed),
1770            stores: self.stats.stores.load(Ordering::Relaxed),
1771            stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
1772            verifications: self.stats.verifications.load(Ordering::Relaxed),
1773            divergences: self.stats.divergences.load(Ordering::Relaxed),
1774            downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
1775            uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
1776            background_uploads: self.stats.background_uploads.load(Ordering::Relaxed),
1777            background_upload_failures: self
1778                .stats
1779                .background_upload_failures
1780                .load(Ordering::Relaxed),
1781            remote_blob_pack_uploads: self.stats.remote_blob_pack_uploads.load(Ordering::Relaxed),
1782            remote_blob_pack_upload_blobs: self
1783                .stats
1784                .remote_blob_pack_upload_blobs
1785                .load(Ordering::Relaxed),
1786            upload_drain_duration_ns: self.stats.upload_drain_duration_ns.load(Ordering::Relaxed),
1787            prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
1788            predictions_loaded: self.stats.predictions_loaded.load(Ordering::Relaxed),
1789            bypasses: self.stats.bypasses.lock().unwrap().clone(),
1790            avoided_compiler_duration_ns: self
1791                .stats
1792                .avoided_compiler_duration_ns
1793                .load(Ordering::Relaxed),
1794            wrapper_phases_ns: self.stats.wrapper_phases_ns.lock().unwrap().clone(),
1795            compiler: self.stats.compiler.lock().unwrap().clone(),
1796            slow_compilations: self.stats.slow_compilations.lock().unwrap().clone(),
1797            unit_outcomes: self.stats.unit_outcomes.lock().unwrap().clone(),
1798            remote_failures: self.stats.remote_failures.load(Ordering::Relaxed),
1799            remote_manifest_lookups: self.stats.remote_manifest_lookups.load(Ordering::Relaxed),
1800            remote_manifest_lookup_duration_ns: self
1801                .stats
1802                .remote_manifest_lookup_duration_ns
1803                .load(Ordering::Relaxed),
1804            remote_action_lookups: self.stats.remote_action_lookups.load(Ordering::Relaxed),
1805            remote_action_lookup_duration_ns: self
1806                .stats
1807                .remote_action_lookup_duration_ns
1808                .load(Ordering::Relaxed),
1809            remote_blob_requests: self.stats.remote_blob_requests.load(Ordering::Relaxed),
1810            remote_blob_pack_requests: self.stats.remote_blob_pack_requests.load(Ordering::Relaxed),
1811            remote_blob_pack_blobs: self.stats.remote_blob_pack_blobs.load(Ordering::Relaxed),
1812            remote_blob_transfer_duration_ns: self
1813                .stats
1814                .remote_blob_transfer_duration_ns
1815                .load(Ordering::Relaxed),
1816            local_cas_write_duration_ns: self
1817                .stats
1818                .local_cas_write_duration_ns
1819                .load(Ordering::Relaxed),
1820            prefetch_runs: self.stats.prefetch_runs.load(Ordering::Relaxed),
1821            prefetch_duration_ns: self.stats.prefetch_duration_ns.load(Ordering::Relaxed),
1822            materialization_duration_ns: self
1823                .stats
1824                .materialization_duration_ns
1825                .load(Ordering::Relaxed),
1826            restored_output_files: self.stats.restored_output_files.load(Ordering::Relaxed),
1827            restored_output_bytes: self.stats.restored_output_bytes.load(Ordering::Relaxed),
1828            reflinked_output_files: self.stats.reflinked_output_files.load(Ordering::Relaxed),
1829            reflinked_output_bytes: self.stats.reflinked_output_bytes.load(Ordering::Relaxed),
1830            copied_output_files: self.stats.copied_output_files.load(Ordering::Relaxed),
1831            copied_output_bytes: self.stats.copied_output_bytes.load(Ordering::Relaxed),
1832            reused_output_files: self.stats.reused_output_files.load(Ordering::Relaxed),
1833            reused_output_bytes: self.stats.reused_output_bytes.load(Ordering::Relaxed),
1834        }
1835    }
1836
1837    /// Handle requests without a transport connection.
1838    ///
1839    /// Persistent wrappers use this entry point when Cargo invokes mbx outside
1840    /// an orchestrated session. It intentionally has the same response
1841    /// semantics as [`Self::handle_connection`], while leaving framing and the
1842    /// version handshake to callers that actually cross a process boundary.
1843    pub async fn handle_requests(
1844        &self,
1845        requests: impl IntoIterator<Item = AgentRequest>,
1846    ) -> Vec<AgentResponse> {
1847        let mut connection = ConnectionUploads::default();
1848        let mut responses = Vec::new();
1849        for request in requests {
1850            responses.push(self.respond_on(request, &mut connection).await);
1851        }
1852        responses
1853    }
1854
1855    fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
1856        Self::digest_lock(&self.write_locks, digest)
1857    }
1858
1859    fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
1860        Self::digest_lock(&self.action_locks, digest)
1861    }
1862
1863    fn digest_lock(
1864        locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
1865        digest: &CacheDigest,
1866    ) -> Arc<tokio::sync::Mutex<()>> {
1867        let mut locks = locks.lock().unwrap();
1868        locks.retain(|_, lock| lock.strong_count() > 0);
1869        if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
1870            return lock;
1871        }
1872        let lock = Arc::new(tokio::sync::Mutex::new(()));
1873        locks.insert(digest.clone(), Arc::downgrade(&lock));
1874        lock
1875    }
1876
1877    /// Answer one request outside any connection.
1878    ///
1879    /// An upload queued this way has no sibling requests to order against, so
1880    /// the queue falls back to treating every blob it holds as a prerequisite.
1881    #[cfg(test)]
1882    async fn respond(&self, request: AgentRequest) -> AgentResponse {
1883        self.respond_on(request, &mut ConnectionUploads::default())
1884            .await
1885    }
1886
1887    async fn respond_on(
1888        &self,
1889        request: AgentRequest,
1890        connection: &mut ConnectionUploads,
1891    ) -> AgentResponse {
1892        match &request {
1893            AgentRequest::FindActionPrediction { task, .. }
1894            | AgentRequest::RecordActionPrediction { task, .. } => {
1895                self.ensure_task_loaded(task).await;
1896            }
1897            _ => {}
1898        }
1899        let result = match request {
1900            AgentRequest::BeginTask { task } => self
1901                .begin_task(&task)
1902                .await
1903                .map(|run| AgentResponse::TaskBegun { run }),
1904            AgentRequest::CommitTask { run } => self
1905                .commit_task(&run)
1906                .await
1907                .map(|()| AgentResponse::TaskCommitted),
1908            AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
1909            AgentRequest::FindBlobs { digests } => self.find_blobs(digests).await,
1910            AgentRequest::StoreBlob { digest, source } => {
1911                self.store_blob(&digest, &source, connection).await
1912            }
1913            AgentRequest::FindActionResult { action } => {
1914                self.stats.lookups.fetch_add(1, Ordering::Relaxed);
1915                self.find_action_result(&action).await
1916            }
1917            AgentRequest::RecordActionHit {
1918                action,
1919                restore,
1920                crate_name,
1921            } => {
1922                let diagnostic = connection
1923                    .take_action_diagnostic("hit", crate_name.as_deref())
1924                    .map(|diagnostic| AgentEvent::ActionDiagnostic {
1925                        outcome: "hit".into(),
1926                        crate_name: crate_name.clone(),
1927                        diagnostic,
1928                    });
1929                self.record_action_hit(&action, restore, crate_name, diagnostic)
1930            }
1931            AgentRequest::RecordBypass { kind } => {
1932                *self
1933                    .stats
1934                    .bypasses
1935                    .lock()
1936                    .unwrap()
1937                    .entry(kind.clone())
1938                    .or_insert(0) += 1;
1939                self.emit(|| AgentEvent::Bypass { kind });
1940                Ok(AgentResponse::BypassRecorded)
1941            }
1942            AgentRequest::RecordUnconsulted => {
1943                self.stats.unconsulted.fetch_add(1, Ordering::Relaxed);
1944                self.emit(|| AgentEvent::Unconsulted);
1945                Ok(AgentResponse::UnconsultedRecorded)
1946            }
1947            AgentRequest::RecordWarning { message } => match parse_action_diagnostic(&message) {
1948                Some(Ok((outcome, crate_name, diagnostic))) => {
1949                    connection.record_action_diagnostic(outcome, crate_name, diagnostic);
1950                    Ok(AgentResponse::WarningRecorded)
1951                }
1952                Some(Err(error)) => Err(error),
1953                None => self.record_diagnostic(DiagnosticLevel::Warning, message),
1954            },
1955            AgentRequest::RecordDebug { target, message } => (|| {
1956                if target.is_empty()
1957                    || target.len() > 256
1958                    || target.contains(['\n', '\r', '\0'])
1959                    || message.is_empty()
1960                    || message.len() > MAX_DIAGNOSTIC_BYTES
1961                    || message.contains(['\n', '\r', '\0'])
1962                {
1963                    bail!("invalid shim debug record");
1964                }
1965                if target == "mbx::unit-outcome" {
1966                    let (unit, outcome): (String, String) = serde_json::from_str(&message)?;
1967                    // Recorded as what the cache did, which is the column the
1968                    // TUI shows. Whether a compilation kept incremental state
1969                    // is reported by the summary, not per unit.
1970                    let (outcome, _) = lookup_outcome(&outcome);
1971                    if unit.len() > 256
1972                        || !unit.contains(':')
1973                        || !matches!(
1974                            outcome,
1975                            "hit" | "miss" | "bypass" | "unconsulted" | "verification"
1976                        )
1977                    {
1978                        bail!("invalid unit outcome");
1979                    }
1980                    self.stats
1981                        .unit_outcomes
1982                        .lock()
1983                        .unwrap()
1984                        .entry(unit)
1985                        .or_default()
1986                        .insert(outcome.to_string());
1987                } else {
1988                    debug!(target: &target, "{message}");
1989                }
1990                Ok(AgentResponse::DebugRecorded)
1991            })(),
1992            AgentRequest::RecordError { message } => {
1993                self.record_diagnostic(DiagnosticLevel::Error, message)
1994            }
1995            AgentRequest::FindFileDigests { scope, files } => self.find_file_digests(scope, files),
1996            AgentRequest::JoinActionPromise {
1997                adapter,
1998                invocation,
1999            } => self.join_action_promise(&adapter, &invocation).await,
2000            AgentRequest::CompleteActionPromise { claim, prediction } => {
2001                self.complete_action_promise(&claim, &prediction).await
2002            }
2003            AgentRequest::ResolveFileDigests { scope, files } => {
2004                self.resolve_file_digests(scope, files).await
2005            }
2006            AgentRequest::RecordFileDigests { scope, entries } => {
2007                self.record_file_digests(scope, entries)
2008            }
2009            AgentRequest::RecordWrapperTiming { timing } => (|| {
2010                if timing.spans.len() > 512
2011                    || timing.phases_ns.len() > 32
2012                    || timing.adapter.len() > 64
2013                    || timing.unit.as_ref().is_some_and(|s| s.len() > 1024)
2014                    || timing.phases_ns.keys().any(|s| s.len() > 64)
2015                    || timing.spans.iter().any(|s| s.name.len() > 64)
2016                {
2017                    bail!("wrapper timing exceeds telemetry limits");
2018                }
2019                {
2020                    let mut phases = self.stats.wrapper_phases_ns.lock().unwrap();
2021                    for (phase, duration) in &timing.phases_ns {
2022                        let total = phases.entry(phase.clone()).or_default();
2023                        *total = total.saturating_add(*duration);
2024                    }
2025                }
2026                self.emit(|| AgentEvent::WrapperTiming { timing });
2027                Ok(AgentResponse::WrapperTimingRecorded)
2028            })(),
2029            AgentRequest::RecordCompilerInvocation {
2030                outcome,
2031                crate_name,
2032                duration_ns,
2033            } => {
2034                // Normalized before the diagnostic is claimed: it was stored
2035                // under the lookup outcome, and an unnormalized name here would
2036                // never find it.
2037                let (outcome, incremental) = lookup_outcome(&outcome);
2038                let outcome = outcome.to_string();
2039                let diagnostic = connection
2040                    .take_action_diagnostic(&outcome, crate_name.as_deref())
2041                    .map(|diagnostic| AgentEvent::ActionDiagnostic {
2042                        outcome: outcome.clone(),
2043                        crate_name: crate_name.clone(),
2044                        diagnostic,
2045                    });
2046                self.record_compiler_invocation(
2047                    &outcome,
2048                    incremental,
2049                    crate_name.as_deref(),
2050                    duration_ns,
2051                    diagnostic,
2052                )
2053            }
2054            AgentRequest::RecordActionVerification { matched, restore } => {
2055                self.record_materialization(restore);
2056                self.stats.verifications.fetch_add(1, Ordering::Relaxed);
2057                if !matched {
2058                    self.stats.divergences.fetch_add(1, Ordering::Relaxed);
2059                }
2060                self.emit(|| AgentEvent::Verification { matched, restore });
2061                Ok(AgentResponse::ActionVerificationRecorded)
2062            }
2063            AgentRequest::StoreActionResult { result } => {
2064                self.store_action_result(&result, connection).await
2065            }
2066            AgentRequest::FindActionPrediction { task, invocation } => {
2067                self.find_action_prediction(&task, &invocation)
2068            }
2069            AgentRequest::RecordActionPrediction { task, prediction } => {
2070                self.record_action_prediction(&task, prediction)
2071            }
2072            AgentRequest::FindExecutableIdentity {
2073                executable,
2074                environment,
2075            } => self.find_executable_identity(executable, environment),
2076            AgentRequest::StoreExecutableIdentity {
2077                executable,
2078                environment,
2079                stdout,
2080                pins,
2081            } => self.store_executable_identity(executable, environment, stdout, pins),
2082            AgentRequest::Hello { .. } => {
2083                Err(eyre::eyre!("hello is only valid as the first request"))
2084            }
2085        };
2086        result.unwrap_or_else(|error| AgentResponse::Error {
2087            message: error.to_string(),
2088        })
2089    }
2090
2091    async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
2092        if let Some(path) = self.find_verified_blob(digest)? {
2093            return Ok(AgentResponse::Blob { path: Some(path) });
2094        }
2095        if !self.remote_mode.reads() {
2096            return Ok(AgentResponse::Blob { path: None });
2097        }
2098        let Some(remote) = &self.remote else {
2099            return Ok(AgentResponse::Blob { path: None });
2100        };
2101        match self.fetch_remote_blob(remote, digest).await {
2102            Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
2103            Err(error) => {
2104                warn!(
2105                    "remote cache blob lookup failed for {}: {error}",
2106                    digest.hash
2107                );
2108                Ok(AgentResponse::Blob { path: None })
2109            }
2110        }
2111    }
2112
2113    async fn find_blobs(&self, digests: Vec<CacheDigest>) -> Result<AgentResponse> {
2114        let mut paths = BTreeMap::new();
2115        let mut missing = Vec::new();
2116        for digest in &digests {
2117            match self.find_verified_blob(digest)? {
2118                Some(path) => {
2119                    paths.insert(digest.clone(), path);
2120                }
2121                None => {
2122                    missing.push(digest.clone());
2123                }
2124            }
2125        }
2126
2127        if !missing.is_empty()
2128            && self.remote_mode.reads()
2129            && let Some(remote) = &self.remote
2130        {
2131            paths.extend(self.fetch_remote_blobs(remote, missing, None).await);
2132        }
2133
2134        Ok(AgentResponse::Blobs {
2135            paths: digests
2136                .into_iter()
2137                .map(|digest| paths.get(&digest).cloned())
2138                .collect(),
2139        })
2140    }
2141
2142    async fn store_blob(
2143        &self,
2144        digest: &CacheDigest,
2145        source: &Path,
2146        connection: &mut ConnectionUploads,
2147    ) -> Result<AgentResponse> {
2148        let path = {
2149            let lock = self.write_lock(digest);
2150            let _guard = lock.lock().await;
2151            if let Some(path) = self.find_verified_blob(digest)? {
2152                path
2153            } else {
2154                let path = self.cas.store_file(digest, source)?;
2155                self.remember_verified_blob(digest, &path);
2156                self.stats.stores.fetch_add(1, Ordering::Relaxed);
2157                self.stats
2158                    .stored_bytes
2159                    .fetch_add(digest.size, Ordering::Relaxed);
2160                path
2161            }
2162        };
2163        // The object is durable locally, so the build has what it needs and the
2164        // remote publication can happen after this request returns.
2165        if let Some(uploads) = &self.uploads {
2166            uploads.queue_blob(digest, path.clone(), connection);
2167        }
2168        Ok(AgentResponse::Stored { path })
2169    }
2170
2171    fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
2172        let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
2173        if let Some(remembered) = remembered {
2174            // The contents behind this digest were hashed in full when the
2175            // session first reached for them. Hashing again on every later
2176            // lookup would re-read each dependency's rlib once per crate that
2177            // links it, so what is rechecked here is that the file has not
2178            // been written since: an overwrite moves the modification time,
2179            // and truncation or eviction changes the length or removes it.
2180            // Only a replacement that reproduces both -- which no writer in
2181            // the store does, since blobs are published by rename under a
2182            // content-derived name -- would go unnoticed until `mbx cache
2183            // verify` reads the store back in full.
2184            if remembered.is_unchanged() {
2185                return Ok(Some(remembered.path));
2186            }
2187            self.verified_blobs.lock().unwrap().remove(digest);
2188        }
2189        let path = self.cas.find(digest)?;
2190        if let Some(path) = &path {
2191            self.remember_verified_blob(digest, path);
2192        }
2193        Ok(path)
2194    }
2195
2196    fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
2197        let Some(verified) = VerifiedBlob::describe(path) else {
2198            // Without an identity to compare against there is nothing to
2199            // shortcut safely, so the digest stays unremembered and every
2200            // lookup re-reads it.
2201            return;
2202        };
2203        self.verified_blobs
2204            .lock()
2205            .unwrap()
2206            .insert(digest.clone(), verified);
2207    }
2208
2209    async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
2210        if let Some(result) = self.actions.find(action)? {
2211            return Ok(AgentResponse::ActionResult {
2212                result: Some(result),
2213            });
2214        }
2215        if !self.remote_mode.reads() {
2216            return Ok(AgentResponse::ActionResult { result: None });
2217        }
2218        let Some(remote) = &self.remote else {
2219            return Ok(AgentResponse::ActionResult { result: None });
2220        };
2221        let lock = self.action_lock(action);
2222        let _guard = lock.lock().await;
2223        if let Some(result) = self.actions.find(action)? {
2224            return Ok(AgentResponse::ActionResult {
2225                result: Some(result),
2226            });
2227        }
2228        if let Some(result) = self
2229            .pending_remote_actions
2230            .lock()
2231            .unwrap()
2232            .get(action)
2233            .cloned()
2234        {
2235            return Ok(AgentResponse::ActionResult {
2236                result: Some(result),
2237            });
2238        }
2239        let _permit = self.remote_transfers.acquire().await?;
2240        match self.get_remote_action_result(remote, action).await {
2241            Ok(Some(result)) => {
2242                self.pending_remote_actions
2243                    .lock()
2244                    .unwrap()
2245                    .insert(action.clone(), result.clone());
2246                Ok(AgentResponse::ActionResult {
2247                    result: Some(result),
2248                })
2249            }
2250            Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
2251            Err(error) => {
2252                self.note_remote_failure();
2253                warn!(
2254                    "remote cache action lookup failed for {}: {error}",
2255                    action.hash
2256                );
2257                Ok(AgentResponse::ActionResult { result: None })
2258            }
2259        }
2260    }
2261
2262    async fn store_action_result(
2263        &self,
2264        result: &RemoteActionResult,
2265        connection: &ConnectionUploads,
2266    ) -> Result<AgentResponse> {
2267        let path = self.actions.store(result)?;
2268        if let Some(uploads) = &self.uploads {
2269            uploads.queue_action_result(result, connection);
2270        }
2271        Ok(AgentResponse::ActionStored { path })
2272    }
2273
2274    async fn join_action_promise(
2275        &self,
2276        adapter: &str,
2277        invocation: &CacheDigest,
2278    ) -> Result<AgentResponse> {
2279        if !self.remote_mode.reads() || !self.remote_mode.writes() {
2280            return Ok(AgentResponse::ActionPromise {
2281                claim: None,
2282                prediction: None,
2283            });
2284        }
2285        let Some(remote) = &self.remote else {
2286            return Ok(AgentResponse::ActionPromise {
2287                claim: None,
2288                prediction: None,
2289            });
2290        };
2291        let deadline = Instant::now() + ACTION_PROMISE_WAIT;
2292        loop {
2293            let _permit = self.remote_transfers.acquire().await?;
2294            let state = remote.join_action_promise(invocation, adapter).await;
2295            drop(_permit);
2296            match state {
2297                Ok(Some(ActionPromiseState::Claimed { claim })) => {
2298                    return Ok(AgentResponse::ActionPromise {
2299                        claim: Some(claim),
2300                        prediction: None,
2301                    });
2302                }
2303                Ok(Some(ActionPromiseState::Complete { prediction })) => {
2304                    return Ok(AgentResponse::ActionPromise {
2305                        claim: None,
2306                        prediction: Some(prediction),
2307                    });
2308                }
2309                Ok(Some(ActionPromiseState::Pending { retry_after_ms }))
2310                    if Instant::now() < deadline =>
2311                {
2312                    tokio::time::sleep(Duration::from_millis(retry_after_ms.clamp(10, 5_000)))
2313                        .await;
2314                }
2315                Ok(Some(ActionPromiseState::Pending { .. }) | None) => {
2316                    return Ok(AgentResponse::ActionPromise {
2317                        claim: None,
2318                        prediction: None,
2319                    });
2320                }
2321                Ok(Some(_)) => {
2322                    return Ok(AgentResponse::ActionPromise {
2323                        claim: None,
2324                        prediction: None,
2325                    });
2326                }
2327                Err(error) => {
2328                    self.note_remote_failure();
2329                    warn!(
2330                        "remote cache action promise failed for {}: {error}",
2331                        invocation.hash
2332                    );
2333                    return Ok(AgentResponse::ActionPromise {
2334                        claim: None,
2335                        prediction: None,
2336                    });
2337                }
2338            }
2339        }
2340    }
2341
2342    async fn complete_action_promise(
2343        &self,
2344        claim: &str,
2345        prediction: &ActionPrediction,
2346    ) -> Result<AgentResponse> {
2347        prediction.validate()?;
2348        let Some(remote) = &self.remote else {
2349            return Ok(AgentResponse::ActionPromiseCompleted);
2350        };
2351        let Some(uploads) = &self.uploads else {
2352            return Ok(AgentResponse::ActionPromiseCompleted);
2353        };
2354        if uploads
2355            .wait_for_actions(std::slice::from_ref(&prediction.action))
2356            .await
2357            .contains(&prediction.action)
2358        {
2359            // Never promise an action result the server does not hold. The
2360            // server lease expires and another runner gets to repair it.
2361            return Ok(AgentResponse::ActionPromiseCompleted);
2362        }
2363        let completion = ActionPromiseCompletion {
2364            claim: claim.to_string(),
2365            prediction: prediction.clone(),
2366        };
2367        let _permit = self.remote_transfers.acquire().await?;
2368        if let Err(error) = remote
2369            .complete_action_promise(&prediction.invocation, &completion)
2370            .await
2371        {
2372            self.note_remote_failure();
2373            warn!(
2374                "remote cache action promise completion failed for {}: {error}",
2375                prediction.invocation.hash
2376            );
2377        }
2378        Ok(AgentResponse::ActionPromiseCompleted)
2379    }
2380
2381    /// Record that a remote operation failed and the build carried on without it.
2382    fn note_remote_failure(&self) {
2383        self.stats.remote_failures.fetch_add(1, Ordering::Relaxed);
2384    }
2385
2386    async fn get_remote_action_result(
2387        &self,
2388        remote: &RemoteCacheClient,
2389        action: &CacheDigest,
2390    ) -> Result<Option<RemoteActionResult>> {
2391        self.stats
2392            .remote_action_lookups
2393            .fetch_add(1, Ordering::Relaxed);
2394        let _timer = AtomicDurationTimer::start(&self.stats.remote_action_lookup_duration_ns);
2395        remote.get_action_result(action).await
2396    }
2397
2398    fn record_action_hit(
2399        &self,
2400        action: &CacheDigest,
2401        restore: RestoreStats,
2402        crate_name: Option<String>,
2403        diagnostic: Option<AgentEvent>,
2404    ) -> Result<AgentResponse> {
2405        validate_crate_name(crate_name.as_deref())?;
2406        if self.actions.find(action)?.is_none() {
2407            let pending = self.pending_remote_actions.lock().unwrap().remove(action);
2408            if let Some(result) = pending {
2409                self.actions.store(&result)?;
2410            } else {
2411                bail!("cannot record a hit for a missing action result");
2412            }
2413        }
2414        self.record_restore(restore);
2415        self.stats.hits.fetch_add(1, Ordering::Relaxed);
2416        self.emit_action(
2417            diagnostic,
2418            AgentEvent::ActionHit {
2419                crate_name,
2420                restore,
2421            },
2422        );
2423        Ok(AgentResponse::ActionHitRecorded)
2424    }
2425
2426    fn record_restore(&self, restore: RestoreStats) {
2427        self.record_materialization(restore);
2428        atomic_saturating_add(
2429            &self.stats.avoided_compiler_duration_ns,
2430            restore.avoided_compiler_duration_ns,
2431        );
2432        atomic_saturating_add(&self.stats.restored_output_files, restore.output_files);
2433        atomic_saturating_add(&self.stats.restored_output_bytes, restore.output_bytes);
2434        atomic_saturating_add(
2435            &self.stats.reflinked_output_files,
2436            restore.reflinked_output_files,
2437        );
2438        atomic_saturating_add(
2439            &self.stats.reflinked_output_bytes,
2440            restore.reflinked_output_bytes,
2441        );
2442        atomic_saturating_add(&self.stats.copied_output_files, restore.copied_output_files);
2443        atomic_saturating_add(&self.stats.copied_output_bytes, restore.copied_output_bytes);
2444        atomic_saturating_add(&self.stats.reused_output_files, restore.reused_output_files);
2445        atomic_saturating_add(&self.stats.reused_output_bytes, restore.reused_output_bytes);
2446    }
2447
2448    fn record_compiler_invocation(
2449        &self,
2450        outcome: &str,
2451        incremental: bool,
2452        crate_name: Option<&str>,
2453        duration_ns: u64,
2454        diagnostic: Option<AgentEvent>,
2455    ) -> Result<AgentResponse> {
2456        if !matches!(outcome, "miss" | "unconsulted" | "bypass" | "verification") {
2457            bail!("invalid compiler invocation outcome");
2458        }
2459        validate_crate_name(crate_name)?;
2460        if incremental {
2461            self.stats
2462                .incremental_compilations
2463                .fetch_add(1, Ordering::Relaxed);
2464        }
2465        let mut compiler = self.stats.compiler.lock().unwrap();
2466        let stats = compiler.entry(outcome.to_string()).or_default();
2467        stats.invocations = stats.invocations.saturating_add(1);
2468        stats.duration_ns = stats.duration_ns.saturating_add(duration_ns);
2469        drop(compiler);
2470        if outcome != "verification"
2471            && let Some(crate_name) = crate_name.filter(|name| !name.is_empty())
2472        {
2473            let mut slow = self.stats.slow_compilations.lock().unwrap();
2474            let duration = slow.entry(crate_name.to_string()).or_default();
2475            *duration = duration.saturating_add(duration_ns);
2476        }
2477        self.emit_action(
2478            diagnostic,
2479            AgentEvent::CompilerInvocation {
2480                outcome: outcome.to_string(),
2481                crate_name: crate_name.map(str::to_string),
2482                duration_ns,
2483            },
2484        );
2485        Ok(AgentResponse::CompilerInvocationRecorded)
2486    }
2487
2488    fn record_materialization(&self, restore: RestoreStats) {
2489        atomic_saturating_add(&self.stats.materialization_duration_ns, restore.duration_ns);
2490    }
2491
2492    /// Surface a shim diagnostic on this process's stderr.
2493    ///
2494    /// The agent lives in the process that owns the session, so printing here
2495    /// reaches the terminal running the build rather than the stderr of the
2496    /// compilation the shim stands in for -- which build scripts read as part
2497    /// of the compiler's answer. Deduplicated because one cause tends to fire
2498    /// once per compilation, and capped so a message unique per compilation
2499    /// cannot scroll the build away. The level is part of the deduplication
2500    /// key, so a fatal reason is never hidden by an earlier warning with the
2501    /// same text.
2502    fn record_diagnostic(&self, level: DiagnosticLevel, message: String) -> Result<AgentResponse> {
2503        if message.is_empty()
2504            || message.len() > MAX_DIAGNOSTIC_BYTES
2505            || message.contains(['\n', '\r', '\0'])
2506        {
2507            bail!("invalid shim {}", level.label());
2508        }
2509        let mut diagnostics = self.diagnostics.lock().unwrap();
2510        let key = (level, message.clone());
2511        if !diagnostics.contains(&key) {
2512            // An acknowledgement promises the diagnostic was surfaced. A
2513            // fatal shim error must be able to fall back locally when this
2514            // channel can no longer display it.
2515            if diagnostics.len() >= MAX_DIAGNOSTICS {
2516                bail!("shim diagnostic limit reached");
2517            }
2518            eprintln!("mbx[{}]: {message}", level.label());
2519            self.emit(|| match level {
2520                DiagnosticLevel::Warning => AgentEvent::Warning {
2521                    message: message.clone(),
2522                },
2523                DiagnosticLevel::Error => AgentEvent::Error {
2524                    message: message.clone(),
2525                },
2526            });
2527            diagnostics.insert(key);
2528        }
2529        Ok(match level {
2530            DiagnosticLevel::Warning => AgentResponse::WarningRecorded,
2531            DiagnosticLevel::Error => AgentResponse::ErrorRecorded,
2532        })
2533    }
2534
2535    /// Answer file-digest lookups from the session ledger.
2536    ///
2537    /// A recorded digest is returned only when the requested identity matches
2538    /// the recorded one exactly. The caller statted the file to build the
2539    /// identity, so this is the [`VerifiedBlob`] freshness check with the
2540    /// stat moved to the side that was already making it.
2541    fn find_file_digests(
2542        &self,
2543        scope: FileDigestScope,
2544        files: Vec<FileIdentity>,
2545    ) -> Result<AgentResponse> {
2546        if files.len() > MAX_FILE_DIGEST_BATCH {
2547            bail!("too many file-digest lookups in one request");
2548        }
2549        let ledger = self.file_digests.lock().unwrap();
2550        let digests = files
2551            .into_iter()
2552            .map(|file| {
2553                let recorded = ledger.get(&(scope, file.path.clone()))?;
2554                (recorded.file == file).then(|| recorded.digest.clone())
2555            })
2556            .collect();
2557        Ok(AgentResponse::FileDigests { digests })
2558    }
2559
2560    /// Resolve ledger misses inside the agent so concurrent shims that name the
2561    /// same NFS object wait for and reuse one read instead of stampeding it.
2562    async fn resolve_file_digests(
2563        &self,
2564        scope: FileDigestScope,
2565        files: Vec<FileIdentity>,
2566    ) -> Result<AgentResponse> {
2567        if files.len() > MAX_FILE_DIGEST_BATCH {
2568            bail!("too many file-digest resolutions in one request");
2569        }
2570        for file in &files {
2571            if !file.path.is_absolute() {
2572                bail!("file-digest resolutions need absolute paths");
2573            }
2574        }
2575        let resolutions = stream::iter(
2576            files
2577                .into_iter()
2578                .map(|file| async move { self.resolve_file_digest(scope, file).await }),
2579        )
2580        .buffered(MAX_CONCURRENT_FILE_DIGESTS)
2581        .collect()
2582        .await;
2583        Ok(AgentResponse::FileDigestsResolved { resolutions })
2584    }
2585
2586    async fn resolve_file_digest(
2587        &self,
2588        scope: FileDigestScope,
2589        file: FileIdentity,
2590    ) -> FileDigestResolution {
2591        let lock = {
2592            let key = (scope, file.clone());
2593            let mut locks = self.file_digest_locks.lock().unwrap();
2594            locks.retain(|_, lock| lock.strong_count() > 0);
2595            if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
2596                lock
2597            } else {
2598                let lock = Arc::new(FileDigestFlight {
2599                    lock: tokio::sync::Mutex::new(()),
2600                    resolution: Mutex::new(None),
2601                });
2602                locks.insert(key, Arc::downgrade(&lock));
2603                lock
2604            }
2605        };
2606        let _flight = lock.lock.lock().await;
2607        if let Some(resolution) = lock.resolution.lock().unwrap().clone() {
2608            return resolution;
2609        }
2610        if let Some(digest) = self
2611            .file_digests
2612            .lock()
2613            .unwrap()
2614            .get(&(scope, file.path.clone()))
2615            .filter(|recorded| recorded.file == file)
2616            .map(|recorded| recorded.digest.clone())
2617            .filter(|digest| digest.size == file.len && file.still_describes().unwrap_or(false))
2618        {
2619            return FileDigestResolution::Digest(digest);
2620        }
2621        let Ok(_permit) = self.file_digest_permits.acquire().await else {
2622            return FileDigestResolution::Unresolved;
2623        };
2624        #[cfg(test)]
2625        self.file_digest_reads.fetch_add(1, Ordering::Relaxed);
2626        let path = file.path.clone();
2627        let resolved =
2628            tokio::task::spawn_blocking(move || digest_file_validated(scope, &path)).await;
2629        let Ok(Ok(Some(observed))) = resolved else {
2630            return FileDigestResolution::Unresolved;
2631        };
2632        if observed.cache_identity.as_ref() != Some(&file) {
2633            return FileDigestResolution::Unresolved;
2634        }
2635        let resolution = match observed.resolution {
2636            FileDigestResolution::Digest(digest) if digest.size == file.len => {
2637                let _ = self.record_file_digests(
2638                    scope,
2639                    vec![RecordedFileDigest {
2640                        file: file.clone(),
2641                        digest: digest.clone(),
2642                    }],
2643                );
2644                FileDigestResolution::Digest(digest)
2645            }
2646            FileDigestResolution::EmbeddedTimestampMacro => {
2647                FileDigestResolution::EmbeddedTimestampMacro
2648            }
2649            FileDigestResolution::Digest(_) | FileDigestResolution::Unresolved => {
2650                FileDigestResolution::Unresolved
2651            }
2652        };
2653        *lock.resolution.lock().unwrap() = Some(resolution.clone());
2654        resolution
2655    }
2656
2657    /// Record digests of files a shim read in full, for later reuse.
2658    ///
2659    /// Capped rather than evicted: a session that outgrows the cap loses the
2660    /// shortcut for the overflow and nothing else, and no workload observed so
2661    /// far comes near it.
2662    fn record_file_digests(
2663        &self,
2664        scope: FileDigestScope,
2665        entries: Vec<RecordedFileDigest>,
2666    ) -> Result<AgentResponse> {
2667        if entries.len() > MAX_FILE_DIGEST_BATCH {
2668            bail!("too many file-digest records in one request");
2669        }
2670        for entry in &entries {
2671            validate_file_digest_record(entry)?;
2672        }
2673        let mut ledger = self.file_digests.lock().unwrap();
2674        for entry in entries {
2675            if ledger.len() >= MAX_FILE_DIGEST_ENTRIES
2676                && !ledger.contains_key(&(scope, entry.file.path.clone()))
2677            {
2678                break;
2679            }
2680            ledger.insert((scope, entry.file.path.clone()), entry);
2681        }
2682        Ok(AgentResponse::FileDigestsRecorded)
2683    }
2684
2685    /// Start the file-digest ledger from entries an earlier session left
2686    /// behind, so a file nothing has touched since is not read again by the
2687    /// first compilation of this session that names it.
2688    ///
2689    /// Each entry stands only while its recorded identity still matches the
2690    /// disk, the same rule a lookup applies to what this session recorded, so
2691    /// a stale seed costs a hash and nothing else. Entries this session has
2692    /// already recorded are kept over seeded ones. Returns how many were taken.
2693    pub fn seed_file_digests(&self, entries: Vec<(FileDigestScope, RecordedFileDigest)>) -> usize {
2694        self.seed_file_digests_with(|| entries)
2695    }
2696
2697    /// Seed the ledger from entries produced under its lock.
2698    ///
2699    /// A lookup that arrives while `load` is still reading waits for it rather
2700    /// than missing, so a caller can start the read in the background and let
2701    /// whatever else the build is doing overlap it.
2702    pub fn seed_file_digests_with(
2703        &self,
2704        load: impl FnOnce() -> Vec<(FileDigestScope, RecordedFileDigest)>,
2705    ) -> usize {
2706        let mut ledger = self.file_digests.lock().unwrap();
2707        let entries = load();
2708        let mut seeded = 0;
2709        for (scope, entry) in entries {
2710            if ledger.len() >= MAX_FILE_DIGEST_ENTRIES {
2711                break;
2712            }
2713            if validate_file_digest_record(&entry).is_err() {
2714                continue;
2715            }
2716            if let std::collections::btree_map::Entry::Vacant(slot) =
2717                ledger.entry((scope, entry.file.path.clone()))
2718            {
2719                slot.insert(entry);
2720                seeded += 1;
2721            }
2722        }
2723        seeded
2724    }
2725
2726    /// Everything the file-digest ledger holds, for a later session to start
2727    /// from.
2728    pub fn file_digests(&self) -> Vec<(FileDigestScope, RecordedFileDigest)> {
2729        self.file_digests
2730            .lock()
2731            .unwrap()
2732            .iter()
2733            .map(|((scope, _), entry)| (*scope, entry.clone()))
2734            .collect()
2735    }
2736
2737    fn find_action_prediction(
2738        &self,
2739        task: &str,
2740        invocation: &CacheDigest,
2741    ) -> Result<AgentResponse> {
2742        validate_task_identity(task)?;
2743        invocation.validate()?;
2744        let (prediction, prefetch) = {
2745            let mut tasks = self.task_actions.lock().unwrap();
2746            let Some(state) = tasks.get_mut(task) else {
2747                return Ok(AgentResponse::ActionPrediction { prediction: None });
2748            };
2749            activate_prediction_adapter(state, invocation)
2750        };
2751        if let Some(prefetch) = prefetch {
2752            self.spawn_prefetch_predictions(prefetch);
2753        }
2754        Ok(AgentResponse::ActionPrediction { prediction })
2755    }
2756
2757    fn record_action_prediction(
2758        &self,
2759        task: &str,
2760        prediction: ActionPrediction,
2761    ) -> Result<AgentResponse> {
2762        validate_task_identity(task)?;
2763        prediction.validate()?;
2764        let mut tasks = self.task_actions.lock().unwrap();
2765        let state = tasks.entry(task.to_string()).or_default();
2766        if !state
2767            .pending_predictions
2768            .contains_key(&prediction.invocation)
2769            && state.pending_predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
2770        {
2771            bail!("this task run contains too many action predictions");
2772        }
2773        let current = state.predictions.get(&prediction.invocation);
2774        if current != Some(&prediction) {
2775            match state.changed_predictions.get(&prediction.invocation) {
2776                // Back to what the manifest held: no longer a change.
2777                Some(baseline) if baseline.as_ref() == Some(&prediction) => {
2778                    state.changed_predictions.remove(&prediction.invocation);
2779                }
2780                // Changed again; the baseline it left is already recorded.
2781                Some(_) => {}
2782                None => {
2783                    state
2784                        .changed_predictions
2785                        .insert(prediction.invocation.clone(), current.cloned());
2786                }
2787            }
2788        }
2789        state
2790            .predictions
2791            .insert(prediction.invocation.clone(), prediction.clone());
2792        state
2793            .pending_predictions
2794            .insert(prediction.invocation.clone(), prediction);
2795        Ok(AgentResponse::ActionPredictionRecorded)
2796    }
2797
2798    fn executable_identity_key(
2799        &self,
2800        executable: PathBuf,
2801        environment: BTreeMap<String, Option<String>>,
2802    ) -> Result<ExecutableIdentityKey> {
2803        // Restricted to the variables that actually select what an identity
2804        // probe reports: the toolchain rustup resolves, the SDK a linker
2805        // driver builds against, a `-fuse-ld` linker selection, and the
2806        // search path a driver finds its linker on. Anything else would let
2807        // one key stand for two different compilers.
2808        if !environment.keys().all(|name| {
2809            matches!(
2810                name.as_str(),
2811                "COMPILER_PATH"
2812                    | "GCC_EXEC_PREFIX"
2813                    | "LIBRARY_PATH"
2814                    | "MBX_FUSE_LD"
2815                    | "PATH"
2816                    | "RUSTUP_HOME"
2817                    | "RUSTUP_TOOLCHAIN"
2818                    | "SDKROOT"
2819                    | "MACOSX_DEPLOYMENT_TARGET"
2820                    | "LIB"
2821                    | "UCRTVersion"
2822                    | "UniversalCRTSdkDir"
2823                    | "VCToolsInstallDir"
2824                    | "VCToolsVersion"
2825                    | "WindowsSdkDir"
2826                    | "WindowsSDKVersion"
2827            )
2828        }) {
2829            bail!("executable identity contains an unsupported environment variable");
2830        }
2831        Ok(ExecutableIdentityKey {
2832            executable,
2833            environment,
2834        })
2835    }
2836
2837    fn find_executable_identity(
2838        &self,
2839        executable: PathBuf,
2840        environment: BTreeMap<String, Option<String>>,
2841    ) -> Result<AgentResponse> {
2842        let key = self.executable_identity_key(executable, environment)?;
2843        let remembered = self
2844            .executable_identities
2845            .lock()
2846            .unwrap()
2847            .get(&key)
2848            .cloned();
2849        let stdout = match remembered {
2850            Some(stdout) => Some(stdout),
2851            None => {
2852                let persisted = self.load_persisted_identity(&key);
2853                if let Some(stdout) = &persisted {
2854                    // Memoized like a fresh probe: the pins were checked once
2855                    // for this session, which is as often as a probe runs.
2856                    let _ = self.remember_executable_identity(&key, stdout.clone());
2857                }
2858                persisted
2859            }
2860        };
2861        Ok(AgentResponse::ExecutableIdentity { stdout })
2862    }
2863
2864    fn store_executable_identity(
2865        &self,
2866        executable: PathBuf,
2867        environment: BTreeMap<String, Option<String>>,
2868        stdout: Vec<u8>,
2869        pins: Vec<PinnedFile>,
2870    ) -> Result<AgentResponse> {
2871        if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
2872            bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
2873        }
2874        let key = self.executable_identity_key(executable, environment)?;
2875        self.remember_executable_identity(&key, stdout.clone())?;
2876        // Best-effort, like the ledger: a session that cannot write it probes
2877        // again next time, which is what it would have done anyway. A pin
2878        // that no longer holds means a file moved under the probe, and what
2879        // it printed describes neither the old file nor the new one.
2880        if pins.is_empty() {
2881        } else if !pins.iter().all(PinnedFile::holds) {
2882            debug!("executable identity was not persisted: a pinned file changed during the probe");
2883        } else if let Err(error) = self.persist_executable_identity(&key, pins, &stdout) {
2884            debug!("executable identity was not persisted: {error:#}");
2885        }
2886        Ok(AgentResponse::ExecutableIdentity {
2887            stdout: Some(stdout),
2888        })
2889    }
2890
2891    fn remember_executable_identity(
2892        &self,
2893        key: &ExecutableIdentityKey,
2894        stdout: Vec<u8>,
2895    ) -> Result<()> {
2896        let mut identities = self.executable_identities.lock().unwrap();
2897        let is_new = !identities.contains_key(key);
2898        let previous_size = identities.get(key).map_or(0, Vec::len);
2899        if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
2900            bail!("executable identity cache contains too many entries");
2901        }
2902        let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
2903        if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
2904            bail!("executable identity cache contains too many bytes");
2905        }
2906        identities.insert(key.clone(), stdout);
2907        Ok(())
2908    }
2909
2910    fn persisted_identity_path(&self, key: &ExecutableIdentityKey) -> Result<PathBuf> {
2911        let selector = serde_json::to_vec(&(&key.executable, &key.environment))?;
2912        let digest = CacheDigest::blake3(&selector);
2913        Ok(self.identity_dir.join(format!("{}.json", digest.hash)))
2914    }
2915
2916    /// What an earlier session recorded for `key`, if every file it was
2917    /// pinned to is still as that session saw it.
2918    fn load_persisted_identity(&self, key: &ExecutableIdentityKey) -> Option<Vec<u8>> {
2919        let path = self.persisted_identity_path(key).ok()?;
2920        let bytes = fs::read(path).ok()?;
2921        let persisted: PersistedExecutableIdentity = serde_json::from_slice(&bytes).ok()?;
2922        if persisted.version != PERSISTED_EXECUTABLE_IDENTITY_VERSION
2923            || persisted.executable != key.executable
2924            || persisted.environment != key.environment
2925            || persisted.pins.is_empty()
2926            || persisted.stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE
2927        {
2928            return None;
2929        }
2930        persisted
2931            .pins
2932            .iter()
2933            .all(PinnedFile::holds)
2934            .then(|| persisted.stdout.into_bytes())
2935    }
2936
2937    fn persist_executable_identity(
2938        &self,
2939        key: &ExecutableIdentityKey,
2940        pins: Vec<PinnedFile>,
2941        stdout: &[u8],
2942    ) -> Result<()> {
2943        // An identity that is not text is kept for this session only.
2944        let Ok(stdout) = std::str::from_utf8(stdout) else {
2945            return Ok(());
2946        };
2947        let persisted = PersistedExecutableIdentity {
2948            version: PERSISTED_EXECUTABLE_IDENTITY_VERSION,
2949            executable: key.executable.clone(),
2950            environment: key.environment.clone(),
2951            pins,
2952            stdout: stdout.to_owned(),
2953        };
2954        let path = self.persisted_identity_path(key)?;
2955        fs::create_dir_all(self.identity_dir.as_path())?;
2956        // Not synced: a torn record reads as no record, and costs one probe.
2957        let mut temporary = tempfile::NamedTempFile::new_in(self.identity_dir.as_path())?;
2958        std::io::Write::write_all(temporary.as_file_mut(), &serde_json::to_vec(&persisted)?)?;
2959        temporary.persist(path).map_err(|error| error.error)?;
2960        Ok(())
2961    }
2962
2963    /// Serve newline-delimited protocol requests on an authenticated session stream.
2964    pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
2965    where
2966        S: AsyncRead + AsyncWrite + Unpin,
2967    {
2968        let (reader, mut writer) = tokio::io::split(stream);
2969        let mut reader = BufReader::new(reader);
2970        let hello = read_request(&mut reader)
2971            .await?
2972            .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
2973        let request: AgentRequest = serde_json::from_str(&hello)?;
2974        match request {
2975            AgentRequest::Hello {
2976                protocol,
2977                client_version,
2978            } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
2979            AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
2980                send_response(
2981                    &mut writer,
2982                    &AgentResponse::Error {
2983                        message: format!(
2984                            "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
2985                        ),
2986                    },
2987                )
2988                .await?;
2989                return Ok(());
2990            }
2991            AgentRequest::Hello { client_version, .. } => {
2992                send_response(
2993                    &mut writer,
2994                    &AgentResponse::Error {
2995                        message: format!(
2996                            "cache client {client_version} does not match agent {}",
2997                            self.version
2998                        ),
2999                    },
3000                )
3001                .await?;
3002                return Ok(());
3003            }
3004            _ => bail!("the first agent request must be hello"),
3005        }
3006        send_response(
3007            &mut writer,
3008            &AgentResponse::Hello {
3009                protocol: AGENT_PROTOCOL_VERSION,
3010                agent_version: self.version.to_string(),
3011            },
3012        )
3013        .await?;
3014
3015        // Tickets accumulate for the life of the connection because a shim
3016        // publishes a compilation's blobs and the action result naming them over
3017        // one connection, in that order.
3018        let mut connection = ConnectionUploads::default();
3019        while let Some(line) = read_request(&mut reader).await? {
3020            let response = match serde_json::from_str(&line) {
3021                Ok(request) => self.respond_on(request, &mut connection).await,
3022                Err(error) => AgentResponse::Error {
3023                    message: format!("invalid agent request: {error}"),
3024                },
3025            };
3026            send_response(&mut writer, &response).await?;
3027        }
3028        Ok(())
3029    }
3030}
3031
3032/// Read one newline-delimited request, refusing one that grows past the cap.
3033///
3034/// Any process running as this user can open the session socket, so a request
3035/// that never terminates its line must not be able to grow the agent's memory
3036/// without bound.
3037async fn read_request<R>(reader: &mut R) -> Result<Option<String>>
3038where
3039    R: AsyncBufRead + Unpin,
3040{
3041    let mut line = Vec::new();
3042    loop {
3043        let available = reader.fill_buf().await?;
3044        if available.is_empty() {
3045            break;
3046        }
3047        let (consumed, complete) = match available.iter().position(|byte| *byte == b'\n') {
3048            Some(index) => (index, true),
3049            None => (available.len(), false),
3050        };
3051        if line.len() + consumed > MAX_REQUEST_BYTES {
3052            bail!("agent request exceeded {MAX_REQUEST_BYTES} bytes");
3053        }
3054        line.extend_from_slice(&available[..consumed]);
3055        // The newline itself is consumed but never kept.
3056        reader.consume(consumed + usize::from(complete));
3057        if complete {
3058            return Ok(Some(String::from_utf8(line)?));
3059        }
3060    }
3061    if line.is_empty() {
3062        Ok(None)
3063    } else {
3064        Ok(Some(String::from_utf8(line)?))
3065    }
3066}
3067
3068async fn send_response(
3069    writer: &mut (impl AsyncWrite + Unpin),
3070    response: &AgentResponse,
3071) -> Result<()> {
3072    let mut encoded = serde_json::to_vec(response)?;
3073    encoded.push(b'\n');
3074    writer.write_all(&encoded).await?;
3075    writer.flush().await?;
3076    Ok(())
3077}
3078
3079#[cfg(test)]
3080#[path = "agent_tests.rs"]
3081mod tests;