Skip to main content

runner_manager_agent/
lifecycle.rs

1// owner: e3-jit-lifecycle-recovery
2
3//! One ephemeral runner, from an allocation decision to a scrubbed runtime.
4//!
5//! The ordering in this module is intentional.  An attempt is written before
6//! the package or GitHub is touched, the JIT value exists only in a restrictive
7//! handoff, and a registration-timeout termination is journalled before the
8//! process is signalled.  Recovery uses the same code as ordinary supervision;
9//! startup merely supplies the first observation.
10
11#[cfg(test)]
12use std::collections::VecDeque;
13use std::collections::{BTreeMap, BTreeSet, HashMap};
14use std::ffi::OsStr;
15use std::fmt;
16use std::fs;
17use std::io::Write;
18use std::num::NonZeroU16;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use async_trait::async_trait;
24use runner_manager_domain::attempt::{
25    AttemptOutcome, AttemptState, FailureReason, GithubRunnerObservation, IsolationProviderFailure,
26    RecoveryDecision, RecoveryObservation, RecoveryTimeouts, RunnerAttempt, authorize,
27    recovery_decision,
28};
29use runner_manager_domain::execution::{AttemptExecution, Backend, ImageReference};
30use runner_manager_domain::model::{AttemptId, Clock, HostId, PolicyId, ScaleTarget};
31use runner_manager_domain::path::LocalAbsolutePath;
32use runner_manager_domain::policy::ScalePolicy;
33use runner_manager_domain::store::{Store, StoreError};
34use runner_manager_domain::workspace::{AttemptWorkspace, WorkspacePolicy};
35use runner_manager_github::jit::{
36    DEFAULT_WORK_FOLDER, EncodedJitConfig, JitError, JitGateway, JitRegistration, JitRunnerRequest,
37};
38use runner_manager_github::rest::{CancelToken, InventoryGateway};
39use runner_manager_platform::process::{
40    Adoption, ChildProcess, ProcessIdentity, RestrictiveHandoff, SpawnSpec, Termination,
41};
42use runner_manager_platform::runner_env::{self, RunnerEnv, RunnerPlatform};
43use runner_manager_platform::runner_root::{
44    self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
45};
46use secrecy::SecretString;
47
48use crate::dependency_caches::DependencyCaches;
49use crate::oci::OciProcesses;
50use crate::package::{InstalledPackage, LastCheck, PackageCache, PackageError, RunnerVersion};
51use crate::reconcile::{
52    AllocationGuard, CleanBackoff, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent,
53    OutcomeKind, ReplacementIntent, RunnerLauncher, failure_reason_kind,
54};
55
56mod windows_hyperv;
57pub use windows_hyperv::{WindowsHyperVContainers, WindowsHyperVHostState};
58
59const IDENTITY_FILE: &str = ".runner-process.json";
60const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
61const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
62const RUNNER_ID_FILE: &str = ".github-runner-id";
63const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
64const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
65
66/// Slot-root names a cleaned persistent attempt must not have left behind.
67///
68/// This is not the rule — the rule is that *nothing* but a real `_work`
69/// survives, and [`verify_slot_scrubbed`] enforces that by counting. This list
70/// is the second, independent question asked of the same directory: each name
71/// is stat-ed directly, so a scrub that skipped one is caught even if the
72/// enumeration that was supposed to find it under-reported. Every entry is one
73/// of the things `04-security-recovery.md` requires to be proven absent before a
74/// slot is released; the encoded JIT handoff is the one exception, matched by
75/// its published prefix in [`verify_slot_scrubbed`] because the rest of its name
76/// is a UUID. Being compile-time constants, these are also the only entry names
77/// a refusal message is allowed to print.
78const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
79    // Runner binaries and the launchers beside them.
80    "bin",
81    "externals",
82    "run.sh",
83    "run.cmd",
84    "config.sh",
85    "config.cmd",
86    // The registration identity GitHub's runner writes for itself, and the
87    // per-run environment it reads back.
88    ".runner",
89    ".credentials",
90    ".credentials_rsaparams",
91    ".env",
92    ".path",
93    "_diag",
94    // This agent's own process-identity and lifecycle sidecars.
95    IDENTITY_FILE,
96    FALLBACK_IDENTITY_FILE,
97    UNRESOLVED_PROCESS_FILE,
98    RUNNER_ID_FILE,
99    TERMINATE_INTENT_FILE,
100];
101#[cfg(test)]
102const TEST_LISTENER_READY: &str = ".test-listener-ready";
103
104/// GitHub Runner v2.336.0 accepts JIT configuration for `run` through its
105/// secret `ACTIONS_RUNNER_INPUT_JITCONFIG` input. The platform spawn boundary
106/// supplies that input from the restrictive handoff; the listener command line
107/// must contain only the supported `run` command.
108///
109/// The environment is the daemon's plus [`runner_env::platform_defaults`], then
110/// the dependency caches (`caches`, from [`DependencyCaches::for_launch`]), then
111/// the host's `runner.env`, which wins over either of the others.
112/// The per-attempt temporary directory goes last and `runner.env` cannot name
113/// it. On macOS a runner that still starts throttled is reported
114/// ([`SpawnSpec::expect_normal_scheduling`]).
115fn runner_listener_spec(
116    program: PathBuf,
117    runtime: &Path,
118    caches: Vec<(&'static str, std::ffi::OsString)>,
119    host_env: &RunnerEnv,
120) -> SpawnSpec {
121    let tmp = runtime.join("tmp");
122    let _ = std::fs::create_dir_all(&tmp);
123    let defaults = runner_env::platform_defaults(
124        RunnerPlatform::current(),
125        runtime,
126        &runner_env::Inherited::current(),
127        Path::exists,
128    );
129    // The Windows profile the defaults point at. Created even when
130    // `runner.env` overrides part of it: it is inside the attempt, so it costs
131    // nothing and goes with the attempt.
132    for (name, dir) in &defaults {
133        if runner_env::DIRECTORY_VARIABLES.contains(name) {
134            let _ = std::fs::create_dir_all(dir);
135        }
136    }
137    let mut spec = SpawnSpec::new(program)
138        .arg("run")
139        .working_dir(runtime)
140        .expect_normal_scheduling();
141    for (name, value) in runner_env::runner_environment(defaults, caches, host_env) {
142        spec = spec.env(name, value);
143    }
144    spec.env("TMPDIR", &tmp).env("TEMP", &tmp).env("TMP", &tmp)
145}
146
147/// Retry bounds for failures that can resolve without operator action.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RetryPolicy {
150    pub max_attempts: u32,
151    pub initial: Duration,
152    pub maximum: Duration,
153}
154
155impl RetryPolicy {
156    #[must_use]
157    pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
158        Self {
159            max_attempts,
160            initial,
161            maximum,
162        }
163    }
164
165    pub(crate) fn delay(self, failure_index: u32) -> Duration {
166        let shift = failure_index.saturating_sub(1).min(31);
167        self.initial
168            .saturating_mul(1_u32 << shift)
169            .min(self.maximum)
170    }
171}
172
173/// Non-secret lifecycle evidence.  Payloads are identifiers and closed enums;
174/// neither the encoded configuration nor child output can enter this type.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum AttemptEvent {
177    State {
178        attempt: AttemptId,
179        state: AttemptState,
180    },
181    Retry {
182        attempt: AttemptId,
183        operation: &'static str,
184        delay: Duration,
185    },
186    /// How long each step of a launch took, whether or not it started a
187    /// runner, so a slow or failed start can be attributed to the wait for the
188    /// allocation lock, the package copy, the JIT request, or the process start.
189    Launched {
190        attempt: AttemptId,
191        started: bool,
192        timings: LaunchTimings,
193    },
194    Adopted {
195        attempt: AttemptId,
196    },
197    RemoteIdentityRecovered {
198        attempt: AttemptId,
199        runner_id: u64,
200    },
201    TerminateIntent {
202        attempt: AttemptId,
203    },
204    Terminated {
205        attempt: AttemptId,
206    },
207    /// The attempt's GitHub registration was removed by this agent. Carries the
208    /// runner id because that is the identifier an operator sees in the
209    /// target's runner settings, and the attempt id is not shown there.
210    Deregistered {
211        attempt: AttemptId,
212        runner_id: u64,
213    },
214    Concluded {
215        attempt: AttemptId,
216        outcome: OutcomeKind,
217    },
218    Cleaned {
219        attempt: AttemptId,
220        outcome: OutcomeKind,
221    },
222    QuarantinedOwnedResource {
223        attempt: AttemptId,
224    },
225}
226
227pub trait AttemptEventSink: fmt::Debug + Send + Sync {
228    fn emit(&self, event: AttemptEvent);
229}
230
231#[derive(Debug, Default)]
232pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
233
234impl AttemptEventLog {
235    #[must_use]
236    pub fn events(&self) -> Vec<AttemptEvent> {
237        self.0
238            .lock()
239            .map(|events| events.clone())
240            .unwrap_or_default()
241    }
242}
243
244impl AttemptEventSink for AttemptEventLog {
245    fn emit(&self, event: AttemptEvent) {
246        if let Ok(mut events) = self.0.lock() {
247            events.push(event);
248        }
249    }
250}
251
252#[derive(Debug, Clone, Copy, Default)]
253pub struct NoAttemptEvents;
254
255impl AttemptEventSink for NoAttemptEvents {
256    fn emit(&self, _event: AttemptEvent) {}
257}
258
259/// Where one launch spent its time. A step the launch never reached is zero.
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
261pub struct LaunchTimings {
262    /// Waiting for the host allocation lock, which another launch may hold, and
263    /// for the rest of its pass to be reserved after it.
264    pub lock_wait: Duration,
265    /// Waiting for a turn to prepare: see [`preparation_turns`].
266    pub turn_wait: Duration,
267    pub materialize: Duration,
268    /// Where `materialize` went.
269    pub package: PackageTimings,
270    /// The execution provider's preparation: its environment, and the checks
271    /// of the host's runner environment and dependency caches.
272    pub prepare: Duration,
273    pub register: Duration,
274    pub spawn: Duration,
275}
276
277impl LaunchTimings {
278    #[must_use]
279    pub fn total(&self) -> Duration {
280        self.lock_wait
281            + self.turn_wait
282            + self.materialize
283            + self.prepare
284            + self.register
285            + self.spawn
286    }
287}
288
289/// Where a launch's package step went, across its retries.
290#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
291pub struct PackageTimings {
292    /// Waiting for another launch's install, or for copies an install must
293    /// not run under.
294    pub wait: Duration,
295    /// Checking the catalog, and downloading and extracting a package.
296    pub install: Duration,
297    /// Fingerprinting the trees a copy may trust.
298    pub verify: Duration,
299    /// Copying or cloning the tree into the runner's runtime.
300    pub copy: Duration,
301    /// A package was downloaded for this launch.
302    pub downloaded: bool,
303}
304
305#[derive(Debug, Clone, Copy)]
306enum LaunchStep {
307    Turn,
308    Materialize,
309    Prepare,
310    Register,
311    Spawn,
312}
313
314/// Times a launch's steps; beginning one ends the one before, so an early
315/// return still leaves the step it failed in measured once [`Self::finish`]
316/// runs.
317#[derive(Debug)]
318struct LaunchStopwatch {
319    timings: LaunchTimings,
320    running: Option<(LaunchStep, std::time::Instant)>,
321}
322
323impl LaunchStopwatch {
324    fn new(lock_wait: Duration) -> Self {
325        Self {
326            timings: LaunchTimings {
327                lock_wait,
328                ..LaunchTimings::default()
329            },
330            running: None,
331        }
332    }
333
334    fn begin(&mut self, step: LaunchStep) {
335        self.finish();
336        self.running = Some((step, std::time::Instant::now()));
337    }
338
339    fn finish(&mut self) {
340        if let Some((step, since)) = self.running.take() {
341            let slot = match step {
342                LaunchStep::Turn => &mut self.timings.turn_wait,
343                LaunchStep::Materialize => &mut self.timings.materialize,
344                LaunchStep::Prepare => &mut self.timings.prepare,
345                LaunchStep::Register => &mut self.timings.register,
346                LaunchStep::Spawn => &mut self.timings.spawn,
347            };
348            *slot = since.elapsed();
349        }
350    }
351}
352
353/// A launch slower than this is logged as a warning, with its steps.
354///
355/// A healthy launch is a few seconds; a queued job waits for all of it, and
356/// the daemon's default filter drops the `info` line that times every launch.
357const SLOW_LAUNCH: Duration = Duration::from_secs(60);
358
359/// The daemon's sink for retries and launch timings; the other attempt events
360/// already reach the log through the reconcile sink.
361///
362/// Every field name is on `d1`'s allow-list, or the redacting layer would log
363/// `[redacted]` in its place; `tests::every_field_name_the_attempt_sink_emits_is_allowed`
364/// keeps that true.
365#[derive(Debug, Clone, Copy, Default)]
366pub struct TracingAttemptEvents;
367
368impl AttemptEventSink for TracingAttemptEvents {
369    fn emit(&self, event: AttemptEvent) {
370        match event {
371            AttemptEvent::Retry {
372                attempt,
373                operation,
374                delay,
375            } => tracing::warn!(
376                event = "attempt_retry",
377                attempt_id = %attempt,
378                reason = operation,
379                retry_in_ms = millis(delay),
380            ),
381            AttemptEvent::Launched {
382                attempt,
383                started,
384                timings,
385            } => {
386                macro_rules! launched {
387                    ($level:ident, $name:literal) => {
388                        tracing::$level!(
389                            event = $name,
390                            attempt_id = %attempt,
391                            duration_ms = millis(timings.total()),
392                            lock_wait_ms = millis(timings.lock_wait),
393                            turn_wait_ms = millis(timings.turn_wait),
394                            materialize_ms = millis(timings.materialize),
395                            package_wait_ms = millis(timings.package.wait),
396                            package_install_ms = millis(timings.package.install),
397                            package_verify_ms = millis(timings.package.verify),
398                            package_copy_ms = millis(timings.package.copy),
399                            package_downloaded = timings.package.downloaded,
400                            prepare_ms = millis(timings.prepare),
401                            register_ms = millis(timings.register),
402                            spawn_ms = millis(timings.spawn),
403                        )
404                    };
405                }
406                if !started {
407                    launched!(warn, "attempt_launch_failed");
408                } else if timings.total() >= SLOW_LAUNCH {
409                    launched!(warn, "attempt_launch_slow");
410                } else {
411                    launched!(info, "attempt_launched");
412                }
413            }
414            _ => {}
415        }
416    }
417}
418
419fn millis(duration: Duration) -> u64 {
420    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
421}
422
423/// Whether the demand that justified a retry still exists.
424#[async_trait]
425pub trait DemandPersistence: fmt::Debug + Send + Sync {
426    async fn persists(&self, policy: PolicyId) -> bool;
427}
428
429#[derive(Debug, Clone, Copy, Default)]
430pub struct PersistentDemand;
431
432#[async_trait]
433impl DemandPersistence for PersistentDemand {
434    async fn persists(&self, _policy: PolicyId) -> bool {
435        true
436    }
437}
438
439#[async_trait]
440pub trait RetryDelay: fmt::Debug + Send + Sync {
441    async fn wait(&self, duration: Duration);
442}
443
444#[derive(Debug, Clone, Copy, Default)]
445pub struct TokioRetryDelay;
446
447#[async_trait]
448impl RetryDelay for TokioRetryDelay {
449    async fn wait(&self, duration: Duration) {
450        tokio::time::sleep(duration).await;
451    }
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct JitRequestFailure {
456    pub terminal: bool,
457    pub reason: FailureReason,
458    pub retry_after: Option<Duration>,
459}
460
461/// GitHub's authoritative runner state plus the identity returned by inventory.
462/// The id is carried independently of the local sidecar so recovery can close
463/// the crash boundary immediately after a successful remote registration.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub struct LifecycleGithubObservation {
466    pub status: GithubRunnerObservation,
467    pub runner_id: Option<u64>,
468}
469
470impl LifecycleGithubObservation {
471    #[must_use]
472    pub const fn unreachable() -> Self {
473        Self {
474            status: GithubRunnerObservation::Unreachable,
475            runner_id: None,
476        }
477    }
478
479    #[must_use]
480    pub const fn not_registered() -> Self {
481        Self {
482            status: GithubRunnerObservation::NotRegistered,
483            runner_id: None,
484        }
485    }
486
487    #[must_use]
488    pub const fn registered(runner_id: u64, busy: bool) -> Self {
489        Self {
490            status: GithubRunnerObservation::Registered { busy },
491            runner_id: Some(runner_id),
492        }
493    }
494}
495
496/// The two GitHub views the lifecycle needs, combined so one fake can drive
497/// registration and authoritative runner telemetry.
498#[async_trait]
499pub trait LifecycleGithub: fmt::Debug + Send + Sync {
500    async fn register(
501        &self,
502        target: &ScaleTarget,
503        request: &JitRunnerRequest,
504        cancel: &CancelToken,
505    ) -> Result<JitRegistration, JitRequestFailure>;
506
507    async fn observe(
508        &self,
509        target: &ScaleTarget,
510        attempt: AttemptId,
511        cancel: &CancelToken,
512    ) -> LifecycleGithubObservation;
513
514    /// Remove one runner registration this agent created.
515    ///
516    /// Answers whether the registration is gone, and is deliberately not
517    /// fallible in the `Result` sense: no caller may abandon a conclusion
518    /// because GitHub was unreachable. See
519    /// [`LifecycleLauncher::deregister_runner`].
520    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
521}
522
523#[async_trait]
524impl<T> LifecycleGithub for T
525where
526    T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
527{
528    async fn register(
529        &self,
530        target: &ScaleTarget,
531        request: &JitRunnerRequest,
532        cancel: &CancelToken,
533    ) -> Result<JitRegistration, JitRequestFailure> {
534        self.generate_jit_config(target, request, cancel)
535            .await
536            .map_err(|error| {
537                let reason = if matches!(&error, JitError::Forbidden { .. }) {
538                    FailureReason::Other(
539                        "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
540                            .into(),
541                    )
542                } else {
543                    FailureReason::JitRequestFailed
544                };
545                JitRequestFailure {
546                    terminal: error.is_terminal(),
547                    reason,
548                    retry_after: error
549                        .rate_limited()
550                        .map(|limit| limit.delay_from(self.now())),
551                }
552            })
553    }
554
555    async fn observe(
556        &self,
557        target: &ScaleTarget,
558        attempt: AttemptId,
559        cancel: &CancelToken,
560    ) -> LifecycleGithubObservation {
561        let expected_name = runner_name(attempt);
562        match self.list_runners(target, cancel).await {
563            Ok(inventory) => inventory
564                .runners()
565                .iter()
566                .find(|runner| runner.name == expected_name)
567                .map_or(LifecycleGithubObservation::not_registered(), |runner| {
568                    LifecycleGithubObservation::registered(runner.id, runner.busy)
569                }),
570            Err(_) => LifecycleGithubObservation::unreachable(),
571        }
572    }
573
574    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
575        self.remove_runner(target, runner_id, cancel).await.is_ok()
576    }
577}
578
579/// Package/cache operations used by one attempt.
580#[async_trait]
581pub trait RuntimePackages: fmt::Debug + Send + Sync {
582    /// Copy the package into `attempt`'s runtime, adding where the time went
583    /// to `timings`.
584    async fn materialize(
585        &self,
586        attempt: &RunnerAttempt,
587        timings: &mut PackageTimings,
588    ) -> Result<RunnerVersion, FailureReason>;
589    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
590    fn prune_obsolete_guarded(
591        &self,
592        authority: PruneAuthority<'_>,
593        attempts: &[RunnerAttempt],
594    ) -> Result<(), FailureReason>;
595}
596
597/// Unforgeable evidence that pruning was reached through e1's launch request.
598/// The type is public only because it appears in the public adapter trait; its
599/// private field and constructor prevent callers from substituting a guard
600/// acquired from an unrelated lock.
601pub struct PruneAuthority<'a> {
602    _guard: &'a AllocationGuard,
603}
604
605impl fmt::Debug for PruneAuthority<'_> {
606    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
607        f.write_str("PruneAuthority")
608    }
609}
610
611impl<'a> PruneAuthority<'a> {
612    fn from_launch_request(guard: &'a AllocationGuard) -> Self {
613        Self { _guard: guard }
614    }
615
616    #[cfg(test)]
617    pub(crate) const fn for_test(guard: &'a AllocationGuard) -> Self {
618        Self { _guard: guard }
619    }
620}
621
622/// What every target of one daemon shares about the one package directory they
623/// all use: which trees this process vouches for, and who may touch them.
624///
625/// Per target, one target's reinstall replaces the tree another target
626/// recorded, whose next launch finds it changed and reinstalls in turn: on a
627/// host serving two targets, every switch between them was a full download.
628#[derive(Debug, Clone, Default)]
629pub struct SharedPackages {
630    /// Held shared to copy from a tree, exclusive to change one. A cache entry
631    /// or seed is not replaced under a copy.
632    trees: Arc<tokio::sync::RwLock<()>>,
633    trusted: Arc<TrustedTrees>,
634    last_check: LastCheck,
635}
636
637impl SharedPackages {
638    /// For each target's [`PackageCache`]: the published version is one fact,
639    /// so one target's check spares the others theirs, and each check holds
640    /// every copy on this host back.
641    #[must_use]
642    pub fn last_check(&self) -> LastCheck {
643        self.last_check.clone()
644    }
645}
646
647#[cfg(test)]
648impl SharedPackages {
649    pub(crate) async fn copying(&self) -> tokio::sync::RwLockReadGuard<'_, ()> {
650        self.trees.read().await
651    }
652}
653
654/// Production package adapter.  It takes an e2 lease before returning, so a
655/// cache entry can never look unused while its runtime is starting.
656#[derive(Debug)]
657pub struct CachedRuntimePackages {
658    cache: Arc<PackageCache>,
659    shared: SharedPackages,
660}
661
662/// Add how long `work` took to `slot`.
663async fn timed<T>(slot: &mut Duration, work: impl std::future::Future<Output = T>) -> T {
664    let began = std::time::Instant::now();
665    let result = work.await;
666    *slot += began.elapsed();
667    result
668}
669
670impl CachedRuntimePackages {
671    /// Run `work` against a package tree on the blocking pool: a fingerprint
672    /// walks ~9,000 entries and a copy can take minutes.
673    async fn blocking<T: Send + 'static>(
674        &self,
675        root: &Path,
676        work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
677    ) -> Result<T, FailureReason> {
678        let trusted = Arc::clone(&self.shared.trusted);
679        let root = root.to_path_buf();
680        tokio::task::spawn_blocking(move || work(&trusted, &root))
681            .await
682            .map_err(|_| FailureReason::ProcessStartFailed)
683    }
684
685    /// One target's view of the package directory the targets of `shared` use.
686    #[must_use]
687    pub const fn new(cache: Arc<PackageCache>, shared: SharedPackages) -> Self {
688        Self { cache, shared }
689    }
690
691    /// The tree a runtime can be copied from right now, under nothing more than
692    /// the shared hold: nothing to check for or install, and a tree still as
693    /// this process left it.
694    async fn ready(
695        &self,
696        destination: &Path,
697        timings: &mut PackageTimings,
698    ) -> Result<Option<(InstalledPackage, PathBuf)>, FailureReason> {
699        let Some(installed) = self.cache.reusable().map_err(package_failure)? else {
700            return Ok(None);
701        };
702        let destination = destination.to_path_buf();
703        let verifying = self.blocking(installed.root(), move |trusted, source| {
704            if trusted.holds(source) {
705                copy_origin_ready(source, &destination, trusted)
706            } else {
707                None
708            }
709        });
710        let origin = timed(&mut timings.verify, verifying).await?;
711        Ok(origin.map(|origin| (installed, origin)))
712    }
713
714    /// Under the exclusive hold: a package installed and trusted, and the tree
715    /// to copy it from.
716    async fn install_trusted(
717        &self,
718        destination: &Path,
719        timings: &mut PackageTimings,
720    ) -> Result<(InstalledPackage, PathBuf), FailureReason> {
721        let (mut installed, downloaded) = timed(
722            &mut timings.install,
723            self.cache.ensure_installed_reporting(),
724        )
725        .await
726        .map_err(package_failure)?;
727        timings.downloaded |= downloaded;
728        // A cache entry this process did not install, or one that has changed
729        // since, may hold a job's edits; replace it with one downloaded and
730        // verified now. One this call just downloaded is trusted as it lands.
731        // See `TrustedTrees`.
732        let fresh = downloaded
733            || !timed(
734                &mut timings.verify,
735                self.blocking(installed.root(), TrustedTrees::holds),
736            )
737            .await?;
738        if fresh && !downloaded {
739            installed = timed(&mut timings.install, self.cache.reinstall())
740                .await
741                .map_err(package_failure)?;
742            timings.downloaded = true;
743        }
744        let destination = destination.to_path_buf();
745        let origin = timed(
746            &mut timings.verify,
747            self.blocking(installed.root(), move |trusted, source| {
748                if fresh {
749                    trusted.record(source)?;
750                }
751                Ok::<_, std::io::Error>(copy_origin(source, &destination, trusted))
752            }),
753        )
754        .await?
755        .map_err(|_| FailureReason::ProcessStartFailed)?;
756        Ok((installed, origin))
757    }
758}
759
760#[async_trait]
761impl RuntimePackages for CachedRuntimePackages {
762    async fn materialize(
763        &self,
764        attempt: &RunnerAttempt,
765        timings: &mut PackageTimings,
766    ) -> Result<RunnerVersion, FailureReason> {
767        let destination = attempt.runtime_path().to_path_buf();
768        let trees = &self.shared.trees;
769        // Only a launch that has something to change goes exclusive, and it lets
770        // go before it copies, so the launches queued behind it do not wait for
771        // its copy too. Once more if the tree still is not ready: a job edited
772        // it meanwhile, or a seed cannot be made here. Then it copies under what
773        // it just made, which no other launch can change.
774        let mut installs = 0;
775        let (hold, (installed, origin)) = loop {
776            let hold = timed(&mut timings.wait, trees.read()).await;
777            if let Some(ready) = self.ready(&destination, timings).await? {
778                break (hold, ready);
779            }
780            drop(hold);
781            let exclusive = timed(&mut timings.wait, trees.write()).await;
782            let ready = self.install_trusted(&destination, timings).await?;
783            installs += 1;
784            if installs == 2 {
785                break (exclusive.downgrade(), ready);
786            }
787        };
788        // Off the async thread: the daemon runs a current-thread runtime, and a
789        // copy can take minutes on a busy disk.
790        let copied = timed(
791            &mut timings.copy,
792            self.blocking(installed.root(), move |trusted, source| {
793                copy_package_from(source, &origin, &destination, trusted)?;
794                // Checked again after the copy: an edit a job made while it ran
795                // moved the entry's print, and the runtime may hold it.
796                if trusted.holds(source) {
797                    Ok(())
798                } else {
799                    Err(std::io::Error::other(
800                        "the runner package changed while copied",
801                    ))
802                }
803            }),
804        )
805        .await?;
806        if copied.is_err() {
807            // Nothing a failed or tainted copy left may reach a retry's copy,
808            // which lays out over the same runtime.
809            let _ = remove_materialized_package(attempt);
810            return Err(FailureReason::ProcessStartFailed);
811        }
812        if let Err(error) = self.cache.lease(attempt, installed.version()) {
813            // Undoing the copy must not undo the *job* workspace: a persistent
814            // slot's `_work` is retained across attempts, and this rollback
815            // runs before the attempt that would have owned it ever started.
816            let _ = remove_materialized_package(attempt);
817            return Err(package_failure(error));
818        }
819        // Leased while still holding the trees, so a prune that gets the
820        // exclusive hold finds this runtime's lease.
821        drop(hold);
822        Ok(installed.version().clone())
823    }
824
825    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
826        self.cache.release(attempt).map_err(package_failure)
827    }
828
829    /// Everything but the newest version, when nothing is using the trees: a
830    /// copy from an older version, or an install in progress, makes this a
831    /// no-op, and a later launch prunes what it left, which costs only disk.
832    fn prune_obsolete_guarded(
833        &self,
834        _authority: PruneAuthority<'_>,
835        attempts: &[RunnerAttempt],
836    ) -> Result<(), FailureReason> {
837        let Ok(_exclusive) = self.shared.trees.try_write() else {
838            return Ok(());
839        };
840        let Some(current) = self.cache.newest_installed().map_err(package_failure)? else {
841            return Ok(());
842        };
843        for installed in self.cache.installed().map_err(package_failure)? {
844            if installed.version() != current.version() {
845                match self.cache.prune(installed.version(), attempts) {
846                    Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
847                    Err(error) => return Err(package_failure(error)),
848                }
849            }
850        }
851        Ok(())
852    }
853}
854
855fn package_failure(error: PackageError) -> FailureReason {
856    error.failure_reason().unwrap_or(FailureReason::Other(
857        "runner package cache operation failed".into(),
858    ))
859}
860
861fn package_failure_is_terminal(reason: &FailureReason) -> bool {
862    matches!(
863        reason,
864        FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
865    )
866}
867
868/// How many hex characters of the attempt id name its workspace.
869///
870/// # Why this is not the whole identifier, and why the policy is not in the path
871///
872/// Windows refuses a path over `MAX_PATH`, and the runner writes deep inside
873/// this directory: `_work/<repo>/<repo>/.git/objects/pack/pack-<40 hex>.keep`
874/// is 100 characters on its own before the repository is named twice. The
875/// layout used to add two full identifiers -- the policy's and the attempt's,
876/// 74 characters between them -- and that was enough to put a real checkout
877/// over the line. Measured, not guessed: this repository's own CI failed here
878/// three times in a row at 264 characters against a limit of 260, with
879/// `fatal: cannot write keep file ...: Filename too long`. A repository whose
880/// name is ten characters longer would have missed by fourteen.
881///
882/// The policy identifier is simply redundant -- an attempt identifier is
883/// unique on its own, and nothing reads the directory tree to find a policy's
884/// attempts, because [`crate::lifecycle::LifecycleLauncher`] asks the journal.
885/// Twelve hex characters of the attempt is 48 bits, which for the handful of
886/// directories one host holds at once is not a collision anybody will see, and
887/// the journal keeps the full identifier either way.
888///
889/// Together that is 61 characters returned to the repository name.
890const WORKSPACE_NAME_LEN: usize = 12;
891
892/// The directory name for one attempt's workspace.
893fn workspace_name(id: AttemptId) -> String {
894    let full = id.to_string();
895    full.chars()
896        .filter(|c| *c != '-')
897        .take(WORKSPACE_NAME_LEN)
898        .collect()
899}
900
901/// Where one attempt's files go, and which cleanup algorithm they are owed.
902///
903/// The pair travels together because journalling them apart is exactly the bug
904/// `AttemptWorkspace` exists to prevent: a `runtime_path` under a persistent
905/// root recorded as ephemeral would be removed whole, taking the retained job
906/// workspace with it.
907#[derive(Debug, Clone)]
908struct Placement {
909    runtime: PathBuf,
910    workspace: AttemptWorkspace,
911}
912
913/// A runner-root refusal, rendered for the operator who has to fix it.
914///
915/// `RunnerRootError`'s `Display` already names the path, the relation and the
916/// remediation command, and none of its variants can carry a credential — they
917/// are paths, and `03-migration-rollout.md` requires the remediation command to
918/// reach the operator verbatim.
919fn root_failure(error: RunnerRootError) -> LifecycleError {
920    // Logged here because here is the last place this is known at all. A launch
921    // refused by the runner root fails *before* `record_allocation`, so no
922    // attempt row is ever written: `b2` has nothing to carry the failure on and
923    // `g2` has nothing to show it from. The lifecycle event keeps only
924    // `reason=other`, by `failure_reason_kind`'s rule that no free text may
925    // reach an event -- which left the whole refusal reading
926    // `runner_start_failed reason=other`, once per poll, naming nothing.
927    //
928    // What travels is the *kind* and not the sentence, and that is forced rather
929    // than chosen: `crate::logging` redacts every field it does not allow-list
930    // and then scrubs anything path-shaped out of the ones it does, so a
931    // rendered `RunnerRootError` -- which is mostly paths -- reaches the log as
932    // `[redacted]`. `error_kind` is allow-listed and `RunnerRootError::kind` is
933    // a closed vocabulary that survives the scrub, so this names which of a
934    // dozen causes the operator has. The paths and the remediation reach them
935    // through the command line, which is not redacted.
936    tracing::warn!(
937        error_kind = error.kind(),
938        "the runner root refused this launch, so no attempt was created; the host will \
939         retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
940         with the same path re-runs this check and prints the directory and the \
941         remediation in full"
942    );
943    LifecycleError::Failed(FailureReason::Other(error.to_string()))
944}
945
946/// The lowest positive slot inside `ceiling` that no uncleaned attempt holds.
947///
948/// `leases` is the journal's answer to "which slots are leased"
949/// (`Store::slot_leases_for_policy`), which deliberately includes a terminal
950/// attempt whose cleanup has not finished: that attempt still owns its
951/// directory, so its slot is not free even though it no longer counts against
952/// host capacity. `None` means the ceiling is reached, which is a refusal and
953/// not a reason to allocate `s(ceiling + 1)`.
954fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
955    let held: BTreeSet<u16> = leases
956        .iter()
957        .filter_map(|attempt| attempt.workspace().slot_number())
958        .collect();
959    (1..=ceiling.get())
960        .find(|slot| !held.contains(slot))
961        .and_then(NonZeroU16::new)
962}
963
964/// Create `<root>/sN`, or prove that what is already there is a real directory.
965///
966/// A symlink, junction or reparse point standing where the slot should be is
967/// refused rather than followed: it is the one thing that could put an attempt's
968/// files outside the root the operator configured, and
969/// `04-security-recovery.md` requires that case to fail closed rather than to
970/// be repaired here.
971fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
972    match fs::symlink_metadata(slot) {
973        // [`is_link_like`] and not `is_symlink`, so that this is the same
974        // question cleanup asks in [`slot_is_present`]: a reparse tag the
975        // standard library has no name for is refused here rather than
976        // allocated into and then quarantined forever by a cleanup that will
977        // not scrub it.
978        Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
979            slot,
980            "is a symbolic link, junction or other reparse point, which could place runner \
981             files outside the configured root",
982        )),
983        Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
984        Ok(_) => Ok(()),
985        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
986            .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
987        Err(source) => Err(slot_refusal(
988            slot,
989            format!("could not be inspected: {source}"),
990        )),
991    }
992}
993
994/// Accept a slot for reuse only when it is empty or holds one real `_work`.
995///
996/// `02-target-architecture.md`: "Before materialization, a reusable slot must
997/// contain only a valid real `_work` directory or be empty." Everything else —
998/// a leftover `bin/`, a link-shaped `_work`, a stray file — is refused here
999/// rather than cleaned, because deciding whether those bytes are safe is
1000/// cleanup's and recovery's job (`c3`), and quietly reusing them would hand one
1001/// repository's retained state to the next attempt without anybody choosing to.
1002///
1003/// The inspection is one level deep and uses `symlink_metadata`, so nothing is
1004/// followed while it is being judged.
1005fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
1006    let unreadable =
1007        |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
1008    let entries = fs::read_dir(slot).map_err(unreadable)?;
1009    let mut refused: Vec<String> = Vec::new();
1010    for entry in entries {
1011        let entry = entry.map_err(unreadable)?;
1012        let name = entry.file_name();
1013        let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
1014            slot_refusal(
1015                slot,
1016                format!("entry {name:?} could not be inspected: {source}"),
1017            )
1018        })?;
1019        // The same predicate cleanup retains by, so a `_work` this accepts is
1020        // one [`scrub_slot_entries`] will keep rather than refuse: a link, a
1021        // junction or any other reparse point is not a job workspace to either
1022        // of them.
1023        if is_retainable_work_folder(&name, &metadata) {
1024            continue;
1025        }
1026        refused.push(name.to_string_lossy().into_owned());
1027    }
1028    if refused.is_empty() {
1029        return Ok(());
1030    }
1031    refused.sort();
1032    Err(slot_refusal(
1033        slot,
1034        format!(
1035            "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
1036             one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
1037             entries listed, or let cleanup and recovery resolve them",
1038            if refused.len() == 1 {
1039                "an entry"
1040            } else {
1041                "entries"
1042            },
1043            refused.join(", ")
1044        ),
1045    ))
1046}
1047
1048fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
1049    LifecycleError::Failed(FailureReason::Other(format!(
1050        "the persistent slot {} {detail}",
1051        slot.display()
1052    )))
1053}
1054
1055/// Whether a directory entry names the retained job workspace.
1056///
1057/// The comparison folds case on Windows because the filesystem does: there
1058/// `_Work` and `_work` are one directory, so a case-sensitive test would let
1059/// [`scrub_slot_entries`] delete the very directory it exists to keep, let
1060/// [`accept_reusable_slot`] refuse a slot that holds nothing but a valid job
1061/// workspace, and let a package's top-level `_Work` merge itself into the
1062/// previous attempt's `_work`. Elsewhere the two names really are two
1063/// directories and only the exact one is the job workspace.
1064fn is_work_folder(name: &OsStr) -> bool {
1065    if cfg!(windows) {
1066        name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
1067    } else {
1068        name == OsStr::new(DEFAULT_WORK_FOLDER)
1069    }
1070}
1071
1072/// Whether the operating system would follow this entry somewhere else.
1073///
1074/// `FileType::is_symlink` is the whole answer on Unix. On Windows it is not:
1075/// the standard library reports only the symlink and mount-point reparse tags,
1076/// and the substitution this has to refuse is *any* reparse point standing
1077/// where a real directory should be. So the attribute bit is the test there,
1078/// and a tag the standard library has no name for fails closed with the two it
1079/// does.
1080fn is_link_like(metadata: &fs::Metadata) -> bool {
1081    if metadata.file_type().is_symlink() {
1082        return true;
1083    }
1084    #[cfg(windows)]
1085    {
1086        use std::os::windows::fs::MetadataExt;
1087
1088        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1089        metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
1090    }
1091    #[cfg(not(windows))]
1092    false
1093}
1094
1095/// Whether an entry is the retained job workspace and is safe to retain.
1096///
1097/// The two halves are one question. A `_work` that is a file, a symlink, a
1098/// junction or any other reparse point is not a job workspace, and it is also
1099/// the exact substitution a hostile workflow makes to send cleanup somewhere
1100/// else (`04-security-recovery.md`, "A workflow replaces `_work` with a
1101/// junction or symlink to escape cleanup").
1102fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
1103    is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
1104}
1105
1106/// Undo one package materialization, dispatching on the journalled workspace.
1107///
1108/// The ephemeral half is what this always did: the directory is the attempt's
1109/// alone, so it goes whole. The persistent half removes the copy and nothing
1110/// else, because the slot's `_work` predates this attempt and outlives it
1111/// (`02-target-architecture.md`, "Persistent repository").
1112fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
1113    match attempt.workspace() {
1114        AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
1115        AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
1116            .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
1117    }
1118}
1119
1120/// Remove a disposable runner tree without opening workflow-created special files.
1121///
1122/// On Unix, `remove_dir_all` 1.0 can open a FIFO while walking a directory and
1123/// wait forever for its peer. The .NET runner routinely leaves diagnostic FIFOs
1124/// in its private `tmp`, so use the standard library's fd-relative Unix remover,
1125/// which unlinks non-directories without opening them. Keep the external remover
1126/// on Windows for its existing read-only and long-path handling; when it is
1127/// refused, [`unlink_reparse_points`] takes every link out of its way and it
1128/// runs once more.
1129fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
1130    #[cfg(windows)]
1131    {
1132        match remove_dir_all::remove_dir_all(path) {
1133            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1134                unlink_reparse_points(path);
1135                remove_dir_all::remove_dir_all(path)
1136            }
1137            other => other,
1138        }
1139    }
1140    #[cfg(not(windows))]
1141    {
1142        fs::remove_dir_all(path)
1143    }
1144}
1145
1146/// Remove every symlink, junction and other reparse point under `dir` as a
1147/// link, without ever descending into one.
1148///
1149/// WinINet creates `…\INetCache\Content.IE5` as a junction whose own DACL denies
1150/// `Everyone` the right to list it, and a job that points `USERPROFILE` or
1151/// `LOCALAPPDATA` into its workspace gets one there. Both `remove_dir_all` 1.0
1152/// and `std::fs::remove_dir_all` then fail on the whole tree with
1153/// `ERROR_ACCESS_DENIED`, so the attempt could never be cleaned. Removing the
1154/// link itself is a different operation (`RemoveDirectoryW` on the reparse
1155/// point) that the deny entry does not cover, and once the links are gone the
1156/// tree is ordinary.
1157///
1158/// Only walked after a refusal, so an ordinary tree is traversed once. The
1159/// metadata comes from the directory listing itself, which on Windows
1160/// describes a reparse point without opening it.
1161///
1162/// Best effort: a failure here is not reported, because
1163/// [`remove_runtime_tree`]'s second `remove_dir_all` runs next and its result
1164/// is the authority on whether the tree went.
1165#[cfg(windows)]
1166fn unlink_reparse_points(dir: &Path) {
1167    let Ok(entries) = fs::read_dir(dir) else {
1168        return;
1169    };
1170    for entry in entries.flatten() {
1171        let path = entry.path();
1172        let Ok(metadata) = entry.metadata() else {
1173            continue;
1174        };
1175        if is_link_like(&metadata) {
1176            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
1177        } else if metadata.is_dir() {
1178            unlink_reparse_points(&path);
1179        }
1180    }
1181}
1182
1183// ---------------------------------------------------------------------------
1184// Persistent cleanup (`04-security-recovery.md`, "Safe path handling")
1185// ---------------------------------------------------------------------------
1186
1187/// Why a persistent slot could not be proven safe to scrub.
1188///
1189/// A closed set rather than a formatted string, for two reasons that point the
1190/// same way. [`LifecycleEvent::AttemptCleanFailed`] takes a `&'static str` for
1191/// exactly the reason [`crate::reconcile::failure_reason_kind`] documents —
1192/// free text is the one shape that can carry a credential past a field
1193/// allow-list. And the entries under a slot root are *workflow-controlled*: a
1194/// job that writes a file named after a secret would publish it through any
1195/// message that echoed a directory listing, which is why nothing here ever
1196/// renders an entry name that did not come from this module's own constants.
1197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1198enum SlotRefusal {
1199    /// The journalled runtime path is not `<root>/sN` for the journalled slot.
1200    NotTheJournalledSlot,
1201    /// A policy that still exists names a different root than the journal does.
1202    PolicyRootDisagrees,
1203    /// The slot is not strictly inside its root once components resolve.
1204    Containment,
1205    /// The slot itself is a file, a link, or could not be inspected.
1206    SlotNotADirectory,
1207    /// The slot's direct entries could not be listed.
1208    Enumeration,
1209    /// `_work` is a file, a symlink, a junction or another reparse point.
1210    WorkNotADirectory,
1211    /// An entry that had to go could not be removed.
1212    Deletion,
1213    /// Something other than the job workspace survived removal.
1214    Residue,
1215}
1216
1217impl SlotRefusal {
1218    /// The event field: a fixed vocabulary, never operator or workflow text.
1219    const fn class(self) -> &'static str {
1220        match self {
1221            Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1222            Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1223            Self::Containment => "slot_escapes_its_root",
1224            Self::SlotNotADirectory => "slot_is_not_a_directory",
1225            Self::Enumeration => "slot_could_not_be_enumerated",
1226            Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1227            Self::Deletion => "slot_entry_could_not_be_removed",
1228            Self::Residue => "slot_still_holds_runner_state",
1229        }
1230    }
1231
1232    /// What the operator has to do, in one sentence and with no path in it.
1233    const fn remediation(self) -> &'static str {
1234        match self {
1235            Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1236                "the attempt keeps its slot lease and nothing was removed; correct the \
1237                 repository's persistent workspace path, or remove the slot directory by hand \
1238                 once you have confirmed what is in it"
1239            }
1240            Self::SlotNotADirectory | Self::WorkNotADirectory => {
1241                "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1242                 slot or its `_work` with a link, so inspect it before deleting anything and \
1243                 treat the retained workspace as untrusted"
1244            }
1245            Self::Enumeration | Self::Deletion | Self::Residue => {
1246                "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1247                 release whatever is holding the files open, or remove the slot's contents by \
1248                 hand leaving only `_work`"
1249            }
1250        }
1251    }
1252}
1253
1254/// A refusal that leaves one persistent slot quarantined.
1255///
1256/// `detail` is redacted by construction: it may hold paths this product
1257/// configured, `std::io::ErrorKind` values, counts, and names drawn from
1258/// [`SENSITIVE_SLOT_ENTRIES`] — and nothing that came out of a directory
1259/// listing.
1260#[derive(Debug, Clone, PartialEq, Eq)]
1261struct SlotQuarantine {
1262    refusal: SlotRefusal,
1263    detail: String,
1264}
1265
1266impl SlotQuarantine {
1267    fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1268        Self {
1269            refusal,
1270            detail: detail.into(),
1271        }
1272    }
1273}
1274
1275impl fmt::Display for SlotQuarantine {
1276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1277        write!(f, "{}; {}", self.detail, self.refusal.remediation())
1278    }
1279}
1280
1281/// Steps 1 to 3: the journalled root, the journalled slot, and containment.
1282///
1283/// Everything comes from the two immutable allocation facts — the exact runtime
1284/// path and the slot number. Not from the filesystem: scanning a root to decide
1285/// which directories are "mine" is what invariant 6 forbids, and it is also
1286/// impossible for an attempt whose policy has since been deleted. `configured`
1287/// is therefore a *cross-check* and not a source. A policy that survives has to
1288/// agree; a policy that does not survive removes a check rather than the
1289/// ability to clean the directory the journal already names.
1290///
1291/// The name test comes before the parent is used, so `<root>/s1` for a journal
1292/// row that says `s2` — and anything at all that is not one `sN` component —
1293/// is refused before a root is derived from it.
1294fn verify_journalled_slot(
1295    runtime: &Path,
1296    slot: NonZeroU16,
1297    configured: Option<&LocalAbsolutePath>,
1298) -> Result<(), SlotQuarantine> {
1299    let mislaid = || {
1300        SlotQuarantine::new(
1301            SlotRefusal::NotTheJournalledSlot,
1302            format!(
1303                "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1304                runtime.display()
1305            ),
1306        )
1307    };
1308    let local = |path: &Path| {
1309        path.to_str()
1310            .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1311            .ok_or_else(mislaid)
1312    };
1313
1314    let runtime_path = local(runtime)?;
1315    let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1316    // Containment's lexical half, by construction: `derive_child` accepts one
1317    // component, so the equality below can only hold when the journalled path
1318    // really is this root's `sN` and nothing else.
1319    // The directory name comes from the domain that allocation named it with,
1320    // never from a second `s{n}` spelled out here: a convention with two
1321    // spellings would let cleanup refuse every slot the allocator created.
1322    let name = AttemptWorkspace::persistent_slot(slot)
1323        .slot_directory_name()
1324        .expect("a persistent workspace names its slot directory");
1325    let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1326    if derived != runtime_path {
1327        return Err(mislaid());
1328    }
1329    if let Some(configured) = configured
1330        && configured != &root
1331    {
1332        return Err(SlotQuarantine::new(
1333            SlotRefusal::PolicyRootDisagrees,
1334            format!(
1335                "the journalled slot {} is not under the repository's configured persistent root \
1336                 {}",
1337                runtime.display(),
1338                configured.as_str()
1339            ),
1340        ));
1341    }
1342    // And containment's canonical half, which is what a junction planted inside
1343    // the root between allocation and cleanup has to get past.
1344    runner_root::verify_containment(&root, &derived).map_err(|source| {
1345        SlotQuarantine::new(
1346            SlotRefusal::Containment,
1347            format!("the journalled slot is not inside the root it was allocated from: {source}"),
1348        )
1349    })
1350}
1351
1352/// Whether the slot is there to be scrubbed at all, before its entries are.
1353///
1354/// `Ok(false)` — the directory is gone — is not a refusal. There is nothing to
1355/// remove and nothing to prove absent, which is the same tolerance the
1356/// disposable arm has always had for a runtime that vanished under it.
1357///
1358/// A slot that is a file, a link, a junction or any other reparse point *is* a
1359/// refusal, and it is checked here rather than inside the enumeration so that
1360/// the reason an operator reads names the shape rather than reporting that a
1361/// directory could not be listed.
1362fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1363    match fs::symlink_metadata(slot) {
1364        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1365        Err(source) => Err(SlotQuarantine::new(
1366            SlotRefusal::SlotNotADirectory,
1367            format!(
1368                "the slot {} could not be inspected: {:?}",
1369                slot.display(),
1370                source.kind()
1371            ),
1372        )),
1373        Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1374            SlotRefusal::SlotNotADirectory,
1375            format!(
1376                "the slot {} is a link or a file rather than a real directory",
1377                slot.display()
1378            ),
1379        )),
1380        Ok(_) => Ok(true),
1381    }
1382}
1383
1384/// Steps 4 to 6: list the slot's direct entries, keep one real `_work`, remove
1385/// every other one.
1386///
1387/// Every call is a literal filesystem API against a path built from the slot
1388/// root and one entry name — no glob, no shell string, no repository-controlled
1389/// fragment. Nothing is followed: `symlink_metadata` says what each entry *is*,
1390/// and a link-shaped entry is unlinked rather than descended into, so a junction
1391/// planted where `bin` used to be cannot take the deletion outside the slot.
1392///
1393/// A `_work` that is not a real directory is the one entry this refuses to act
1394/// on at all. Removing it could destroy an operator's data if the link were
1395/// theirs; keeping it would hand the next attempt a workspace pointing
1396/// anywhere. `04-security-recovery.md` requires that case to quarantine the
1397/// slot, so the whole scrub stops there with nothing removed after it.
1398fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1399    let unreadable = |source: std::io::Error| {
1400        SlotQuarantine::new(
1401            SlotRefusal::Enumeration,
1402            format!(
1403                "the entries of {} could not be listed: {:?}",
1404                slot.display(),
1405                source.kind()
1406            ),
1407        )
1408    };
1409    for entry in fs::read_dir(slot).map_err(unreadable)? {
1410        let name = entry.map_err(unreadable)?.file_name();
1411        let path = slot.join(&name);
1412        // Nothing is assumed from an entry that vanished: `verify_slot_scrubbed`
1413        // asks the filesystem again afterwards and refuses if it is still there.
1414        let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1415            continue;
1416        };
1417        if is_work_folder(&name) {
1418            if is_retainable_work_folder(&name, &metadata) {
1419                continue;
1420            }
1421            return Err(SlotQuarantine::new(
1422                SlotRefusal::WorkNotADirectory,
1423                format!(
1424                    "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1425                     real directory",
1426                    slot.display()
1427                ),
1428            ));
1429        }
1430        remove_slot_entry(&path, &metadata).map_err(|source| {
1431            SlotQuarantine::new(
1432                SlotRefusal::Deletion,
1433                format!(
1434                    "an entry of {} could not be removed: {:?}",
1435                    slot.display(),
1436                    source.kind()
1437                ),
1438            )
1439        })?;
1440    }
1441    Ok(())
1442}
1443
1444/// What a listed entry *is*, or `None` when it is no longer there.
1445///
1446/// An entry named by a listing and gone by the time it is stat-ed is absent,
1447/// which is a fact both passes over a slot want rather than an enumeration that
1448/// failed. Nothing is followed: `symlink_metadata` reports a link as a link.
1449fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1450    match fs::symlink_metadata(path) {
1451        Ok(metadata) => Ok(Some(metadata)),
1452        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1453        Err(source) => Err(source),
1454    }
1455}
1456
1457/// Remove one slot entry without following it.
1458///
1459/// An entry that is already gone is removed: the post-condition this serves is
1460/// "not there", and racing with whatever removed it first is not a refusal.
1461fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1462    let removed = if is_link_like(metadata) {
1463        // A file symlink unlinks with `remove_file`; a directory symlink or a
1464        // Windows junction needs `remove_dir`. Neither follows the link.
1465        fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1466    } else if metadata.is_dir() {
1467        remove_runtime_tree(path)
1468    } else {
1469        fs::remove_file(path)
1470    };
1471    match removed {
1472        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1473        other => other,
1474    }
1475}
1476
1477/// Step 7: prove that nothing but the retained job workspace is left.
1478///
1479/// Two passes, deliberately independent, because the interesting failure is an
1480/// enumeration that under-reports. The first asks the directory what remains
1481/// and counts everything that is not a real `_work`. The second ignores the
1482/// listing entirely and stats the names an attempt is known to write — the
1483/// runner's binaries, the registration identity it stores beside them, the
1484/// process-identity sidecars and this agent's lifecycle marks — so a scrub that
1485/// silently skipped one is caught by a question that never consulted the
1486/// listing that skipped it.
1487///
1488/// Only the second pass names anything, and the encoded JIT handoff is reported
1489/// by its published prefix rather than by the UUID that follows it. A slot root
1490/// is workflow-writable, so a job that named a file after a secret would publish
1491/// it through any message that echoed the listing; the first pass therefore
1492/// reports a count.
1493fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1494    let unreadable = |source: std::io::Error| {
1495        SlotQuarantine::new(
1496            SlotRefusal::Enumeration,
1497            format!(
1498                "the entries of {} could not be listed to verify the scrub: {:?}",
1499                slot.display(),
1500                source.kind()
1501            ),
1502        )
1503    };
1504    let mut residue = 0_usize;
1505    let mut named: Vec<String> = Vec::new();
1506    for entry in fs::read_dir(slot).map_err(unreadable)? {
1507        let name = entry.map_err(unreadable)?.file_name();
1508        // Absent is what this pass is here to establish. The named half below
1509        // stats every sensitive entry again, so an entry that only *looks*
1510        // absent to this listing is still caught.
1511        let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1512            continue;
1513        };
1514        if is_retainable_work_folder(&name, &metadata) {
1515            continue;
1516        }
1517        residue = residue.saturating_add(1);
1518        if name
1519            .to_string_lossy()
1520            .starts_with(RestrictiveHandoff::NAME_PREFIX)
1521        {
1522            named.push("an encoded JIT handoff".to_owned());
1523        }
1524    }
1525    named.extend(
1526        SENSITIVE_SLOT_ENTRIES
1527            .iter()
1528            .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1529            .map(|entry| format!("`{entry}`")),
1530    );
1531    if residue == 0 && named.is_empty() {
1532        return Ok(());
1533    }
1534    named.sort_unstable();
1535    named.dedup();
1536    Err(SlotQuarantine::new(
1537        SlotRefusal::Residue,
1538        residue_detail(slot, residue, &named),
1539    ))
1540}
1541
1542/// Word a [`SlotRefusal::Residue`] refusal from the two facts that produced it.
1543///
1544/// Separate from [`verify_slot_scrubbed`] because the disagreement it has to
1545/// report — a listing that counted nothing and a filesystem that answered
1546/// otherwise — is a race no test can stage, and a message that contradicts
1547/// itself is exactly what an operator reads at three in the morning.
1548///
1549/// `named` is the sanitized half: entries this crate published the names of.
1550/// A name a workflow chose is only ever counted, never echoed.
1551fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1552    if residue == 0 {
1553        // The whole reason the second pass ignores the listing: the listing
1554        // reported a clean slot and the filesystem disagrees. Saying "0
1555        // entries survived" here would report the under-count as the fact.
1556        format!(
1557            "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1558                 cleanup",
1559            slot.display(),
1560            named.join(", ")
1561        )
1562    } else {
1563        format!(
1564            "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1565            if residue == 1 { "y" } else { "ies" },
1566            slot.display(),
1567            if named.is_empty() {
1568                String::new()
1569            } else {
1570                format!(", including {}", named.join(", "))
1571            }
1572        )
1573    }
1574}
1575
1576fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1577    match outcome {
1578        AttemptOutcome::Failed {
1579            reason: FailureReason::JitExpired,
1580        } => Some("jit_expired_replacement"),
1581        AttemptOutcome::Failed {
1582            reason: FailureReason::ProcessExitedUnexpectedly,
1583        } => Some("exit_before_acceptance_replacement"),
1584        _ => None,
1585    }
1586}
1587
1588/// Lay the verified runner package out *around* whatever the slot retains.
1589///
1590/// `02-target-architecture.md`: "The verified runner package is copied into the
1591/// slot for the attempt", beside a `_work` that survives every attempt. Two
1592/// properties make that safe, and both are structural rather than documented:
1593///
1594/// * the walk is of the **source** tree, so a retained `_work` in the
1595///   destination is never opened, never descended into, and cannot be followed
1596///   wherever it might point;
1597/// * a top-level source entry named `_work` is refused rather than copied, so a
1598///   package that ever grew one could not merge itself into, or replace, the
1599///   job workspace of the attempt before it. The refusal is top-level only,
1600///   because the retained directory is a direct child of the slot; a `_work`
1601///   nested inside the package's own tree is an ordinary name.
1602///
1603/// On Unix the copy is a copy-on-write clone where the filesystem offers one,
1604/// taken from a [`package_seed`] on the destination's own volume. A plain
1605/// byte copy of the ~9,000-file package across volumes took four minutes on a
1606/// host whose runner root shared a busy disk with a build, and a queued job
1607/// waits for every second of it.
1608/// The tree to copy `source` from, making a seed where one helps; a change to
1609/// a tree, so only under the exclusive hold of [`SharedPackages::trees`].
1610///
1611/// A seed that cannot be made is a slower launch, not a failed one. The seed
1612/// sits under the runner root, beside job runtimes, so the refusal is re-checked
1613/// on it; a seed that fails it is bypassed.
1614fn copy_origin(source: &Path, destination: &Path, seeds: &TrustedTrees) -> PathBuf {
1615    #[cfg(unix)]
1616    {
1617        package_seed(source, destination, seeds)
1618            .ok()
1619            .filter(|seed| refuse_work_folder(seed).is_ok())
1620            .unwrap_or_else(|| source.to_path_buf())
1621    }
1622    #[cfg(not(unix))]
1623    {
1624        let _ = (destination, seeds);
1625        source.to_path_buf()
1626    }
1627}
1628
1629/// The tree to copy `source` from if it needs no change: `None` is a seed to
1630/// rebuild first.
1631fn copy_origin_ready(source: &Path, destination: &Path, seeds: &TrustedTrees) -> Option<PathBuf> {
1632    #[cfg(unix)]
1633    {
1634        match seed_location(source, destination) {
1635            Ok(None) => Some(source.to_path_buf()),
1636            Ok(Some(seeds_root)) => held_seed(source, &seeds_root, seeds)
1637                .ok()
1638                .flatten()
1639                .filter(|seed| refuse_work_folder(seed).is_ok()),
1640            Err(_) => None,
1641        }
1642    }
1643    #[cfg(not(unix))]
1644    {
1645        let _ = (destination, seeds);
1646        Some(source.to_path_buf())
1647    }
1648}
1649
1650/// Copy the package at `source` into `destination`, from `origin`: itself or
1651/// its seed. Under the shared hold of [`SharedPackages::trees`].
1652fn copy_package_from(
1653    source: &Path,
1654    origin: &Path,
1655    destination: &Path,
1656    seeds: &TrustedTrees,
1657) -> std::io::Result<()> {
1658    refuse_work_folder(source)?;
1659
1660    #[cfg(unix)]
1661    {
1662        clone_tree(origin, destination)?;
1663        // A seed a job edited during the clone no longer holds; the caller
1664        // re-checks the cache entry itself.
1665        if origin != source && !seeds.holds(origin) {
1666            return Err(std::io::Error::other(
1667                "the package seed changed while cloned",
1668            ));
1669        }
1670        Ok(())
1671    }
1672    #[cfg(not(unix))]
1673    {
1674        let _ = (origin, seeds);
1675        copy_package_entries(source, destination, true)
1676    }
1677}
1678
1679fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1680    if source.join(DEFAULT_WORK_FOLDER).exists() {
1681        return Err(std::io::Error::new(
1682            std::io::ErrorKind::InvalidData,
1683            "a cached runner package contains a _work folder, which means it was used \
1684             to run a job before it was archived; the cache must only contain clean \
1685             extracts to prevent data leakage",
1686        ));
1687    }
1688    Ok(())
1689}
1690
1691/// Where runtime copies of the package are cloned from, beside the runtimes.
1692#[cfg(unix)]
1693const PACKAGE_SEED_DIR: &str = ".runner-package";
1694
1695/// A copy of the cache entry on the destination's volume, so that every
1696/// runtime after the first is a same-volume clone rather than a cross-volume
1697/// byte copy. Returns `source` itself when it already shares the volume.
1698///
1699/// The seed lives at `<runner root>/.runner-package/<version>` and is replaced
1700/// whenever its manifest stops matching the cache entry's, which covers both a
1701/// new version and a reinstalled one. Superseded versions and interrupted
1702/// stagings are removed on that same pass. A seed is only made under the
1703/// exclusive hold of [`SharedPackages::trees`], so no launch is cloning from it
1704/// while it is replaced.
1705///
1706/// The seed sits one `..` from every job's runtime and is owned by the account
1707/// jobs run as, so it is only reused while it is provably the tree this
1708/// process built: see [`TrustedTrees`].
1709///
1710/// Only on macOS, where the runner root is all but certainly APFS and a clone
1711/// is free. On a Linux filesystem without reflinks a seed would only move every
1712/// copy's reads onto the runner root's own, possibly busy, disk.
1713#[cfg(unix)]
1714fn package_seed(
1715    source: &Path,
1716    destination: &Path,
1717    seeds: &TrustedTrees,
1718) -> std::io::Result<PathBuf> {
1719    match seed_location(source, destination)? {
1720        Some(root) => refresh_package_seed(source, &root, seeds),
1721        None => Ok(source.to_path_buf()),
1722    }
1723}
1724
1725/// Where `destination`'s seeds live, if copies to it use one.
1726#[cfg(unix)]
1727fn seed_location(source: &Path, destination: &Path) -> std::io::Result<Option<PathBuf>> {
1728    use std::os::unix::fs::MetadataExt as _;
1729
1730    if !cfg!(target_os = "macos") {
1731        return Ok(None);
1732    }
1733    let root = destination
1734        .parent()
1735        .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1736    if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1737        return Ok(None);
1738    }
1739    Ok(Some(root.join(PACKAGE_SEED_DIR)))
1740}
1741
1742/// The package trees this process built or installed, and what each looked
1743/// like right after: the cache entry runtimes are copied from and the seeds
1744/// they are cloned from.
1745///
1746/// Both are owned by the account jobs run as, so a job can edit any file in
1747/// them, and nothing on disk can say what they should hold: the published
1748/// digest covers the archive, not the extracted tree, and a record kept on disk
1749/// could be rewritten by the same account. So a tree is trusted only while its
1750/// [`tree_fingerprint`] matches the one recorded here. Every edit, replacement
1751/// or rename moves the change time (`ctime`) of the entry or of its directory,
1752/// and only the superuser can set a change time. A tree without a record, as
1753/// every tree has after a restart, is rebuilt before it is used.
1754///
1755/// Unix only. On Windows every tree is trusted, as before.
1756#[derive(Debug, Default)]
1757pub(crate) struct TrustedTrees {
1758    #[cfg_attr(not(unix), allow(dead_code))]
1759    prints: Mutex<HashMap<PathBuf, [u8; 32]>>,
1760}
1761
1762impl TrustedTrees {
1763    /// Whether `root` is a tree recorded here and still looks as it did.
1764    fn holds(&self, root: &Path) -> bool {
1765        #[cfg(unix)]
1766        {
1767            let recorded = self
1768                .prints
1769                .lock()
1770                .unwrap_or_else(std::sync::PoisonError::into_inner)
1771                .get(root)
1772                .copied();
1773            recorded.is_some_and(|recorded| {
1774                tree_fingerprint(root).is_ok_and(|current| current == recorded)
1775            })
1776        }
1777        #[cfg(not(unix))]
1778        {
1779            let _ = root;
1780            true
1781        }
1782    }
1783
1784    /// Trust `root` as it is now; called right after this process made it.
1785    fn record(&self, root: &Path) -> std::io::Result<()> {
1786        #[cfg(unix)]
1787        {
1788            let print = tree_fingerprint(root)?;
1789            self.prints
1790                .lock()
1791                .unwrap_or_else(std::sync::PoisonError::into_inner)
1792                .insert(root.to_path_buf(), print);
1793        }
1794        #[cfg(not(unix))]
1795        let _ = root;
1796        Ok(())
1797    }
1798}
1799
1800/// A digest of every entry under `root`: its relative path, inode, size, mode,
1801/// owner and change time.
1802#[cfg(unix)]
1803fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
1804    use sha2::{Digest as _, Sha256};
1805    use std::os::unix::fs::MetadataExt as _;
1806
1807    fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
1808        let metadata = fs::symlink_metadata(path)?;
1809        let relative = path.strip_prefix(root).unwrap_or(path);
1810        hasher.update(relative.as_os_str().as_encoded_bytes());
1811        hasher.update([0]);
1812        for field in [
1813            metadata.ino(),
1814            metadata.size(),
1815            u64::from(metadata.mode()),
1816            u64::from(metadata.uid()),
1817            u64::from(metadata.gid()),
1818        ] {
1819            hasher.update(field.to_le_bytes());
1820        }
1821        hasher.update(metadata.ctime().to_le_bytes());
1822        hasher.update(metadata.ctime_nsec().to_le_bytes());
1823        if metadata.is_dir() {
1824            let mut children = fs::read_dir(path)?
1825                .map(|entry| entry.map(|entry| entry.path()))
1826                .collect::<std::io::Result<Vec<_>>>()?;
1827            children.sort();
1828            for child in children {
1829                walk(root, &child, hasher)?;
1830            }
1831        }
1832        Ok(())
1833    }
1834
1835    let mut hasher = Sha256::new();
1836    walk(root, root, &mut hasher)?;
1837    Ok(hasher.finalize().into())
1838}
1839
1840#[cfg(unix)]
1841fn seed_path(source: &Path, seeds: &Path) -> std::io::Result<PathBuf> {
1842    let version = source
1843        .file_name()
1844        .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
1845    Ok(seeds.join(version))
1846}
1847
1848/// The seed of `source` under `seeds`, if its manifest matches the cache
1849/// entry's and its fingerprint the one this process recorded.
1850#[cfg(unix)]
1851fn held_seed(
1852    source: &Path,
1853    seeds: &Path,
1854    trusted: &TrustedTrees,
1855) -> std::io::Result<Option<PathBuf>> {
1856    use crate::package::MANIFEST_FILE;
1857
1858    let seed = seed_path(source, seeds)?;
1859    let manifest = fs::read(source.join(MANIFEST_FILE))?;
1860    let held = fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest)
1861        && trusted.holds(&seed);
1862    Ok(held.then_some(seed))
1863}
1864
1865/// The seed of `source` under `seeds`, rebuilt unless [`held_seed`] finds it.
1866#[cfg(unix)]
1867fn refresh_package_seed(
1868    source: &Path,
1869    seeds: &Path,
1870    trusted: &TrustedTrees,
1871) -> std::io::Result<PathBuf> {
1872    if let Some(seed) = held_seed(source, seeds, trusted)? {
1873        return Ok(seed);
1874    }
1875    let seed = seed_path(source, seeds)?;
1876
1877    // Superseded versions, a stale seed and interrupted stagings all go.
1878    match remove_runtime_tree(seeds) {
1879        Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
1880        _ => {}
1881    }
1882    fs::create_dir_all(seeds)?;
1883    let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1884    fs::create_dir(&staging)?;
1885    // A half-made staging would hold the disk space the fallback copy needs.
1886    if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
1887        let _ = remove_runtime_tree(&staging);
1888        return Err(error);
1889    }
1890    // After the rename, which itself moves the seed directory's change time.
1891    trusted.record(&seed)?;
1892    Ok(seed)
1893}
1894
1895/// `cp -a`, asking for a copy-on-write clone where the platform's `cp` can.
1896///
1897/// macOS `cp -c` and GNU `--reflink=auto` both fall back to a byte copy across
1898/// volumes; a `cp` that does not know the flag at all gets a plain `cp -a`.
1899#[cfg(unix)]
1900fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1901    #[cfg(target_os = "macos")]
1902    const CLONE: &[&str] = &["-c"];
1903    #[cfg(target_os = "linux")]
1904    const CLONE: &[&str] = &["--reflink=auto"];
1905    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1906    const CLONE: &[&str] = &[];
1907
1908    let from = format!("{}/.", source.display());
1909    let cp = |extra: &[&str]| {
1910        std::process::Command::new("cp")
1911            .arg("-a")
1912            .args(extra)
1913            .arg(&from)
1914            .arg(destination)
1915            .status()
1916    };
1917    if cp(CLONE)?.success() || cp(&[])?.success() {
1918        Ok(())
1919    } else {
1920        Err(std::io::Error::other("cp failed"))
1921    }
1922}
1923
1924#[cfg(not(unix))]
1925fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1926    fs::create_dir_all(destination)?;
1927    for entry in fs::read_dir(source)? {
1928        let entry = entry?;
1929        if top_level && is_work_folder(&entry.file_name()) {
1930            return Err(std::io::Error::new(
1931                std::io::ErrorKind::InvalidData,
1932                format!(
1933                    "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1934                     it would overwrite the job workspace a persistent slot retains"
1935                ),
1936            ));
1937        }
1938        let target = destination.join(entry.file_name());
1939        if entry.file_type()?.is_dir() {
1940            copy_package_entries(&entry.path(), &target, false)?;
1941        } else {
1942            fs::copy(entry.path(), target)?;
1943        }
1944    }
1945    Ok(())
1946}
1947
1948/// Process operations are attempt-addressed so a recovered process and a child
1949/// started in this invocation are supervised through one port.
1950#[derive(Debug, Clone, PartialEq, Eq)]
1951pub struct ProcessStartFailure {
1952    pub reason: FailureReason,
1953    /// False once a child existed: the one-shot JIT value may have been
1954    /// consumed, so retrying it could start a duplicate.
1955    pub retryable: bool,
1956    /// Set only when cleanup could not prove the spawned process dead.  The
1957    /// caller must journal `starting` and retain capacity/supervision.
1958    pub live_pid: Option<u32>,
1959}
1960
1961impl ProcessStartFailure {
1962    pub(crate) fn before_spawn(reason: FailureReason) -> Self {
1963        Self {
1964            reason,
1965            retryable: true,
1966            live_pid: None,
1967        }
1968    }
1969
1970    fn after_spawn_stopped() -> Self {
1971        Self {
1972            reason: FailureReason::ProcessStartFailed,
1973            retryable: false,
1974            live_pid: None,
1975        }
1976    }
1977
1978    fn after_spawn_live(pid: u32) -> Self {
1979        Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1980    }
1981
1982    fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1983        Self {
1984            reason,
1985            retryable: false,
1986            live_pid: Some(pid),
1987        }
1988    }
1989}
1990
1991/// A credential-free capability report. An isolated policy never treats a
1992/// native-ready result as permission to launch.
1993#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1994pub enum ProviderCapability {
1995    Ready,
1996    Unsupported,
1997    NotInstalled,
1998    PermissionDenied,
1999    ImageUnavailableOrIncompatible,
2000    DiskQuotaUnavailable,
2001    Degraded,
2002}
2003
2004impl ProviderCapability {
2005    #[must_use]
2006    pub const fn display_name(self) -> &'static str {
2007        match self {
2008            Self::Ready => "ready",
2009            Self::Unsupported => "unsupported",
2010            Self::NotInstalled => "not installed",
2011            Self::PermissionDenied => "permission denied",
2012            Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
2013            Self::DiskQuotaUnavailable => "disk quota unavailable",
2014            Self::Degraded => "degraded",
2015        }
2016    }
2017
2018    pub(crate) fn refusal(self) -> FailureReason {
2019        FailureReason::IsolationProvider(match self {
2020            Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
2021            Self::Unsupported => IsolationProviderFailure::Unsupported,
2022            Self::NotInstalled => IsolationProviderFailure::NotInstalled,
2023            Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
2024            Self::ImageUnavailableOrIncompatible => {
2025                IsolationProviderFailure::ImageUnavailableOrIncompatible
2026            }
2027            Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
2028        })
2029    }
2030}
2031
2032/// Only the closed isolation category may cross from an adapter into the
2033/// attempt journal or operator error. Arbitrary adapter text could echo JIT.
2034fn safe_isolation_reason(
2035    reason: FailureReason,
2036    fallback: IsolationProviderFailure,
2037) -> FailureReason {
2038    match reason {
2039        FailureReason::IsolationProvider(_) => reason,
2040        _ => FailureReason::IsolationProvider(fallback),
2041    }
2042}
2043
2044#[derive(Debug, Clone, PartialEq, Eq)]
2045pub struct PreparedEnvironment {
2046    attempt: AttemptId,
2047    identity: Option<EnvironmentIdentity>,
2048    /// The target whose dependency-cache namespace a native runner uses:
2049    /// `prepare` sees the policy and `start` does not.
2050    cache_target: Option<runner_manager_domain::model::ScaleTarget>,
2051}
2052
2053impl PreparedEnvironment {
2054    /// An adapter must return the identity independently recorded on its resource.
2055    #[must_use]
2056    pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
2057        Self {
2058            attempt,
2059            identity: Some(identity),
2060            cache_target: None,
2061        }
2062    }
2063
2064    #[must_use]
2065    pub fn identity(&self) -> Option<&EnvironmentIdentity> {
2066        self.identity.as_ref()
2067    }
2068}
2069
2070#[derive(Debug, Clone, PartialEq, Eq)]
2071pub struct ResolvedEnvironment {
2072    pub provider_kind: Backend,
2073    pub image: ImageReference,
2074}
2075
2076/// Closed, non-secret provider diagnostics; adapters cannot attach arbitrary output.
2077#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2078pub enum ProviderDiagnostic {
2079    CapabilityUnavailable,
2080    ImageRejected,
2081    PrepareFailed,
2082    StartFailed,
2083    OwnershipMismatch,
2084    CleanupDeferred,
2085}
2086
2087#[derive(Debug, Clone, PartialEq, Eq)]
2088pub enum EnvironmentIdentity {
2089    NativeProcess(u32),
2090    Isolated {
2091        host: HostId,
2092        attempt: AttemptId,
2093        provider_kind: Backend,
2094        environment_id: String,
2095        resolved_image: ImageReference,
2096        generation: String,
2097    },
2098}
2099
2100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2101pub enum EnvironmentState {
2102    Starting,
2103    Running,
2104    Exited,
2105    Missing,
2106}
2107
2108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2109pub enum ProviderStop {
2110    Stopped,
2111    StillRunning,
2112}
2113
2114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2115pub enum ProviderDestroy {
2116    Destroyed,
2117    Deferred(&'static str),
2118}
2119
2120fn identity_matches_intent(
2121    host: HostId,
2122    attempt: &RunnerAttempt,
2123    identity: &EnvironmentIdentity,
2124) -> bool {
2125    identity_same_allocation(host, attempt, identity)
2126        && match (attempt.execution(), identity) {
2127            (
2128                AttemptExecution::Isolated { environment_id, .. },
2129                EnvironmentIdentity::Isolated {
2130                    environment_id: id, ..
2131                },
2132            ) => environment_id
2133                .as_ref()
2134                .is_none_or(|journal_id| journal_id == id),
2135            _ => false,
2136        }
2137}
2138
2139fn identity_same_allocation(
2140    host: HostId,
2141    attempt: &RunnerAttempt,
2142    identity: &EnvironmentIdentity,
2143) -> bool {
2144    match (attempt.execution(), identity) {
2145        (
2146            AttemptExecution::Isolated {
2147                provider_kind,
2148                environment_id: _,
2149                resolved_image,
2150                generation,
2151            },
2152            EnvironmentIdentity::Isolated {
2153                host: owner,
2154                attempt: owner_attempt,
2155                provider_kind: kind,
2156                environment_id: _,
2157                resolved_image: image,
2158                generation: resource_generation,
2159            },
2160        ) => {
2161            *owner == host
2162                && *owner_attempt == attempt.id
2163                && provider_kind == kind
2164                && resolved_image == image
2165                && generation == resource_generation
2166        }
2167        _ => false,
2168    }
2169}
2170
2171/// The JIT value can be passed into a provider start exactly once. It must never
2172/// become a provider identity, label, argument, persisted configuration or log field.
2173pub struct OneTimeJitHandoff<'a> {
2174    config: &'a EncodedJitConfig,
2175}
2176
2177impl fmt::Debug for OneTimeJitHandoff<'_> {
2178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2179        f.write_str("OneTimeJitHandoff([redacted])")
2180    }
2181}
2182
2183impl<'a> OneTimeJitHandoff<'a> {
2184    pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
2185        Self { config }
2186    }
2187
2188    /// Consuming the handoff prevents the same token being delivered twice by one call.
2189    pub fn consume(self) -> &'a EncodedJitConfig {
2190        self.config
2191    }
2192}
2193
2194/// Provider lifecycle and native process supervision. The default lifecycle
2195/// methods adapt the existing native spawn/recovery implementation, and refuse
2196/// every isolated allocation. Future platform adapters must implement their
2197/// own prepare/start/inspect/stop/destroy paths rather than fall back here.
2198pub trait ExecutionProvider: fmt::Debug + Send + Sync {
2199    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2200        if policy.execution_policy().is_native() {
2201            ProviderCapability::Ready
2202        } else {
2203            ProviderCapability::Unsupported
2204        }
2205    }
2206
2207    /// Resolve Auto and image identity before allocation or GitHub JIT. Native has no image.
2208    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2209        if policy.execution_policy().is_native() {
2210            Ok(None)
2211        } else {
2212            Err(FailureReason::Other(
2213                "execution provider unavailable".into(),
2214            ))
2215        }
2216    }
2217
2218    fn prepare(
2219        &self,
2220        attempt: &RunnerAttempt,
2221        policy: &ScalePolicy,
2222    ) -> Result<PreparedEnvironment, FailureReason> {
2223        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2224            return Err(FailureReason::Other(
2225                "execution provider unavailable".into(),
2226            ));
2227        }
2228        Ok(PreparedEnvironment {
2229            attempt: attempt.id,
2230            identity: None,
2231            cache_target: None,
2232        })
2233    }
2234
2235    fn start(
2236        &self,
2237        prepared: PreparedEnvironment,
2238        attempt: &RunnerAttempt,
2239        handoff: OneTimeJitHandoff<'_>,
2240    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2241        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2242            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2243                "execution provider identity mismatch".into(),
2244            )));
2245        }
2246        self.spawn(attempt, handoff.consume())
2247            .map(EnvironmentIdentity::NativeProcess)
2248    }
2249
2250    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2251        if !attempt.execution().is_native() {
2252            return Err(FailureReason::Other(
2253                "execution provider unavailable".into(),
2254            ));
2255        }
2256        self.is_alive(attempt).map(|alive| {
2257            if alive {
2258                EnvironmentState::Running
2259            } else {
2260                EnvironmentState::Missing
2261            }
2262        })
2263    }
2264
2265    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2266        if !attempt.execution().is_native() {
2267            return Err(FailureReason::Other(
2268                "execution provider unavailable".into(),
2269            ));
2270        }
2271        self.terminate(attempt).map(|()| ProviderStop::Stopped)
2272    }
2273
2274    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2275        if !attempt.execution().is_native() {
2276            return Err(FailureReason::Other(
2277                "execution provider unavailable".into(),
2278            ));
2279        }
2280        // Native termination is already settled by the existing recovery
2281        // decision before workspace cleanup. There is no separate sandbox
2282        // resource to destroy, and repeating an observation here changes the
2283        // established native cleanup behaviour.
2284        Ok(ProviderDestroy::Destroyed)
2285    }
2286
2287    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2288        self.inspect(attempt)
2289    }
2290
2291    fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2292        Vec::new() // Native processes have no independent provider resources.
2293    }
2294
2295    fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2296        Vec::new() // No job or JIT output enters provider metadata.
2297    }
2298
2299    /// Independent resource metadata must match the journal before destructive calls.
2300    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2301        Ok(attempt.execution().is_native())
2302    }
2303
2304    fn spawn(
2305        &self,
2306        attempt: &RunnerAttempt,
2307        config: &EncodedJitConfig,
2308    ) -> Result<u32, ProcessStartFailure>;
2309    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2310    /// Durable identity observed for a process that spawned before the
2311    /// `starting` journal write survived.
2312    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2313    /// True only for a child this invocation owned and reaped with a successful
2314    /// exit status.  A recovered process that is merely gone answers false.
2315    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2316    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2317    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2318    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2319}
2320
2321/// Compatibility name for existing native process tests and callers.
2322pub use ExecutionProvider as ProcessSupervisor;
2323
2324/// Native process supervision.  The start token is stored beside the runtime;
2325/// recovery never trusts a recycled PID merely because SQLite contains it.
2326#[derive(Debug, Default)]
2327pub struct NativeProcesses {
2328    children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2329    successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2330    /// The host's `runner.env`, read at every launch so that `host env set`
2331    /// reaches the next runner without a daemon restart. `None` applies none.
2332    runner_env_file: Option<PathBuf>,
2333    /// Persistent dependency caches. `None` gives runners none.
2334    dependency_caches: Option<DependencyCaches>,
2335    docker_cleanup: Option<crate::docker_cleanup::DockerCleanup>,
2336    #[cfg(test)]
2337    post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2338    #[cfg(test)]
2339    post_spawn_reaps: std::sync::atomic::AtomicUsize,
2340    #[cfg(test)]
2341    post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2342    #[cfg(test)]
2343    use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2344}
2345
2346/// Production provider set. Native and isolated allocations are routed by the
2347/// immutable execution kind in the policy/journal; an unavailable isolated
2348/// backend can never fall through to native process launch.
2349#[derive(Debug)]
2350pub struct PlatformExecutionProvider {
2351    native: NativeProcesses,
2352    oci: OciProcesses,
2353    windows_hyper_v: WindowsHyperVContainers,
2354}
2355
2356impl PlatformExecutionProvider {
2357    #[must_use]
2358    pub fn new(host_id: HostId) -> Self {
2359        Self {
2360            native: NativeProcesses::new(),
2361            oci: OciProcesses::new(host_id),
2362            windows_hyper_v: WindowsHyperVContainers::new(host_id),
2363        }
2364    }
2365
2366    /// Applies the host's `runner.env` at `path` to every native runner.
2367    /// Isolated runners do not get it: their environment is the image's.
2368    #[must_use]
2369    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2370        self.native = self.native.with_runner_env_file(path);
2371        self
2372    }
2373
2374    /// Gives every native runner persistent dependency caches. Isolated
2375    /// runners do not get them: their providers mount no host directory.
2376    #[must_use]
2377    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2378        self.native = self.native.with_dependency_caches(caches);
2379        self
2380    }
2381
2382    #[must_use]
2383    pub fn with_docker_cleanup(mut self, cleanup: crate::docker_cleanup::DockerCleanup) -> Self {
2384        self.native.docker_cleanup = Some(cleanup);
2385        self
2386    }
2387
2388    fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2389        match backend {
2390            Backend::Auto => {
2391                #[cfg(target_os = "windows")]
2392                {
2393                    Some(&self.windows_hyper_v)
2394                }
2395                #[cfg(not(target_os = "windows"))]
2396                {
2397                    Some(&self.oci)
2398                }
2399            }
2400            Backend::Oci => Some(&self.oci),
2401            Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2402            Backend::VirtualMachine => None,
2403        }
2404    }
2405
2406    fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2407        match attempt.execution() {
2408            AttemptExecution::Native { .. } => Some(&self.native),
2409            AttemptExecution::Isolated { provider_kind, .. } => {
2410                self.isolated_provider(*provider_kind)
2411            }
2412        }
2413    }
2414
2415    fn required_provider_for_attempt(
2416        &self,
2417        attempt: &RunnerAttempt,
2418    ) -> Result<&dyn ExecutionProvider, FailureReason> {
2419        self.provider_for_attempt(attempt)
2420            .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2421    }
2422}
2423
2424impl ExecutionProvider for PlatformExecutionProvider {
2425    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2426        match policy.execution_policy() {
2427            runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2428            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2429                .isolated_provider(*backend)
2430                .map_or(ProviderCapability::Unsupported, |provider| {
2431                    provider.probe(policy)
2432                }),
2433        }
2434    }
2435
2436    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2437        match policy.execution_policy() {
2438            runner_manager_domain::execution::ExecutionPolicy::Native => {
2439                self.native.resolve(policy)
2440            }
2441            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2442                .isolated_provider(*backend)
2443                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2444                .resolve(policy),
2445        }
2446    }
2447
2448    fn prepare(
2449        &self,
2450        attempt: &RunnerAttempt,
2451        policy: &ScalePolicy,
2452    ) -> Result<PreparedEnvironment, FailureReason> {
2453        match policy.execution_policy() {
2454            runner_manager_domain::execution::ExecutionPolicy::Native => {
2455                self.native.prepare(attempt, policy)
2456            }
2457            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2458                .isolated_provider(*backend)
2459                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2460                .prepare(attempt, policy),
2461        }
2462    }
2463
2464    fn start(
2465        &self,
2466        prepared: PreparedEnvironment,
2467        attempt: &RunnerAttempt,
2468        handoff: OneTimeJitHandoff<'_>,
2469    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2470        self.provider_for_attempt(attempt)
2471            .ok_or_else(|| {
2472                ProcessStartFailure::before_spawn(FailureReason::Other(
2473                    "execution provider unavailable".into(),
2474                ))
2475            })?
2476            .start(prepared, attempt, handoff)
2477    }
2478
2479    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2480        self.required_provider_for_attempt(attempt)?
2481            .inspect(attempt)
2482    }
2483
2484    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2485        self.required_provider_for_attempt(attempt)?.stop(attempt)
2486    }
2487
2488    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2489        self.required_provider_for_attempt(attempt)?
2490            .destroy(attempt)
2491    }
2492
2493    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2494        self.required_provider_for_attempt(attempt)?
2495            .recover(attempt)
2496    }
2497
2498    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2499        let mut owned = self.oci.enumerate_owned(host_id);
2500        owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2501        owned
2502    }
2503
2504    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2505        self.provider_for_attempt(attempt)
2506            .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2507    }
2508
2509    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2510        self.required_provider_for_attempt(attempt)?.owns(attempt)
2511    }
2512
2513    fn spawn(
2514        &self,
2515        attempt: &RunnerAttempt,
2516        config: &EncodedJitConfig,
2517    ) -> Result<u32, ProcessStartFailure> {
2518        if !attempt.execution().is_native() {
2519            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2520                "native process launch refused for an isolated allocation".into(),
2521            )));
2522        }
2523        self.native.spawn(attempt, config)
2524    }
2525
2526    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2527        self.required_provider_for_attempt(attempt)?
2528            .is_alive(attempt)
2529    }
2530
2531    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2532        self.required_provider_for_attempt(attempt)?
2533            .recovered_pid(attempt)
2534    }
2535
2536    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2537        self.provider_for_attempt(attempt)
2538            .is_some_and(|provider| provider.completed_successfully(attempt))
2539    }
2540
2541    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2542        self.required_provider_for_attempt(attempt)?
2543            .record_terminate_intent(attempt)
2544    }
2545
2546    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2547        self.provider_for_attempt(attempt)
2548            .is_some_and(|provider| provider.has_terminate_intent(attempt))
2549    }
2550
2551    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2552        self.required_provider_for_attempt(attempt)?
2553            .terminate(attempt)
2554    }
2555}
2556
2557#[cfg(test)]
2558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2559enum PostSpawnBoundary {
2560    HandoffDelete,
2561    IdentitySerialize,
2562    IdentityWrite,
2563    ChildMapInsert,
2564}
2565
2566impl NativeProcesses {
2567    #[must_use]
2568    pub fn new() -> Self {
2569        Self::default()
2570    }
2571
2572    /// Applies the host's `runner.env` at `path` to every runner launched.
2573    #[must_use]
2574    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2575        self.runner_env_file = Some(path);
2576        self
2577    }
2578
2579    /// Gives every runner launched persistent dependency caches.
2580    #[must_use]
2581    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2582        self.dependency_caches = Some(caches);
2583        self
2584    }
2585
2586    /// The cache variables for `attempt`, when `prepare` named its target.
2587    fn cache_env(
2588        &self,
2589        attempt: &RunnerAttempt,
2590        target: Option<&runner_manager_domain::model::ScaleTarget>,
2591        host_env: &RunnerEnv,
2592    ) -> Vec<(&'static str, std::ffi::OsString)> {
2593        match (&self.dependency_caches, target) {
2594            (Some(caches), Some(target)) => {
2595                caches.for_launch(target, attempt.runtime_path(), host_env)
2596            }
2597            _ => Vec::new(),
2598        }
2599    }
2600
2601    /// Starts `Runner.Listener` for `attempt`, with the dependency caches of
2602    /// `cache_target` when there is one.
2603    fn spawn_listener(
2604        &self,
2605        attempt: &RunnerAttempt,
2606        config: &EncodedJitConfig,
2607        cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2608    ) -> Result<u32, ProcessStartFailure> {
2609        let handoff = RestrictiveHandoff::create(
2610            attempt.runtime_path(),
2611            SecretString::from(config.expose().to_owned()),
2612        )
2613        .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2614        #[cfg(windows)]
2615        let program = attempt
2616            .runtime_path()
2617            .join("bin")
2618            .join("Runner.Listener.exe");
2619        #[cfg(not(windows))]
2620        let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2621        // Checked after the handoff exists on purpose: the error path below is
2622        // a real post-handoff launch failure, and unwinding must delete it.
2623        if !program.is_file() {
2624            return Err(ProcessStartFailure::before_spawn(
2625                FailureReason::ProcessStartFailed,
2626            ));
2627        }
2628        let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2629        let caches = self.cache_env(attempt, cache_target, &host_env);
2630        #[cfg(test)]
2631        let spec = if self
2632            .use_long_lived_test_listener
2633            .load(std::sync::atomic::Ordering::SeqCst)
2634        {
2635            SpawnSpec::new(program)
2636                .args([
2637                    "--ignored",
2638                    "--exact",
2639                    "lifecycle::tests::long_lived_native_listener_helper",
2640                    "--nocapture",
2641                ])
2642                .env(
2643                    "RUNNER_MANAGER_TEST_LISTENER_READY",
2644                    attempt.runtime_path().join(TEST_LISTENER_READY),
2645                )
2646                .working_dir(attempt.runtime_path())
2647        } else {
2648            runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2649        };
2650        #[cfg(not(test))]
2651        let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2652        let (spec, docker_proxy) = if let Some(cleanup) = &self.docker_cleanup {
2653            let repository = cache_target.map(ToString::to_string).unwrap_or_default();
2654            match cleanup.launch_environment(attempt.id, &repository, &host_env) {
2655                Ok(Some(endpoint)) => (
2656                    spec.env("DOCKER_HOST", endpoint),
2657                    Some(cleanup.root.join(attempt.id.to_string())),
2658                ),
2659                Ok(None) => (spec, None),
2660                Err(error) => {
2661                    tracing::warn!(attempt_id = %attempt.id, event = "docker_accounting_unavailable", reason = ?error.kind(), "native Docker accounting could not start");
2662                    if crate::docker_cleanup::Config::load(&cleanup.config).map_or(true, |config| {
2663                        config.mode == crate::docker_cleanup::Mode::Apply
2664                    }) {
2665                        return Err(ProcessStartFailure::before_spawn(
2666                            FailureReason::ProcessStartFailed,
2667                        ));
2668                    }
2669                    (spec, None)
2670                }
2671            }
2672        } else {
2673            (spec, None)
2674        };
2675        let child = spec
2676            .spawn_runner_with_handoff(&handoff)
2677            .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2678        // The payload is gone before any state saying "starting" is persisted.
2679        #[cfg(test)]
2680        if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2681            drop(handoff);
2682            return Err(self.abort_spawned_child(child, attempt, false));
2683        }
2684        if handoff.delete().is_err() {
2685            return Err(self.abort_spawned_child(child, attempt, false));
2686        }
2687        #[cfg(test)]
2688        if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2689            return Err(self.abort_spawned_child(child, attempt, false));
2690        }
2691        let identity = match serde_json::to_vec(child.identity()) {
2692            Ok(identity) => identity,
2693            Err(_) => {
2694                return Err(self.abort_spawned_child(child, attempt, false));
2695            }
2696        };
2697        if self.persist_identity(attempt, &identity).is_err() {
2698            return Err(self.abort_spawned_child(child, attempt, true));
2699        }
2700        if let Some(directory) = docker_proxy
2701            && crate::docker_cleanup::write_json(&directory.join("runner.json"), child.identity())
2702                .is_err()
2703        {
2704            return Err(self.abort_spawned_child(child, attempt, true));
2705        }
2706        let pid = child.pid();
2707        #[cfg(test)]
2708        if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2709            return Err(self.abort_spawned_child(child, attempt, true));
2710        }
2711        let mut children = self
2712            .children
2713            .lock()
2714            .unwrap_or_else(std::sync::PoisonError::into_inner);
2715        children.insert(attempt.id, child);
2716        Ok(pid)
2717    }
2718
2719    /// The host's `runner.env`, read now.
2720    ///
2721    /// A file that cannot be read or parsed fails the launch rather than
2722    /// starting a runner without the environment the operator configured.
2723    /// [`ExecutionProvider::prepare`] asks first, before a JIT runner is
2724    /// registered, so a broken file costs no registration. The reason names
2725    /// the file and the line or variable, never a value.
2726    fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2727        let Some(path) = &self.runner_env_file else {
2728            return Ok(RunnerEnv::default());
2729        };
2730        RunnerEnv::load(path).map_err(|error| {
2731            tracing::warn!(
2732                reason = "runner_env_file_invalid",
2733                "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2734            );
2735            FailureReason::Other(format!(
2736                "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2737            ))
2738        })
2739    }
2740
2741    #[cfg(test)]
2742    fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2743        self.post_spawn_faults.lock().unwrap().push_back(boundary);
2744    }
2745
2746    #[cfg(test)]
2747    fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2748        let mut faults = self.post_spawn_faults.lock().unwrap();
2749        if faults.front() == Some(&boundary) {
2750            faults.pop_front();
2751            true
2752        } else {
2753            false
2754        }
2755    }
2756
2757    #[cfg(test)]
2758    fn fail_post_spawn_stops(&self, count: usize) {
2759        self.post_spawn_stop_failures
2760            .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2761    }
2762
2763    #[cfg(test)]
2764    fn fail_next_post_spawn_stop(&self) {
2765        self.fail_post_spawn_stops(1);
2766    }
2767
2768    #[cfg(test)]
2769    fn use_long_lived_test_listener(&self) {
2770        self.use_long_lived_test_listener
2771            .store(true, std::sync::atomic::Ordering::SeqCst);
2772    }
2773
2774    fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2775        #[cfg(test)]
2776        if self
2777            .post_spawn_stop_failures
2778            .fetch_update(
2779                std::sync::atomic::Ordering::SeqCst,
2780                std::sync::atomic::Ordering::SeqCst,
2781                |left| if left > 0 { Some(left - 1) } else { None },
2782            )
2783            .is_ok()
2784        {
2785            return Err(FailureReason::Other("injected runner stop failure".into()));
2786        }
2787        child
2788            .stop(Duration::from_secs(1))
2789            .map(|_| ())
2790            .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2791    }
2792
2793    fn abort_spawned_child(
2794        &self,
2795        mut child: ChildProcess,
2796        attempt: &RunnerAttempt,
2797        remove_identity: bool,
2798    ) -> ProcessStartFailure {
2799        let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2800        if reaped {
2801            if remove_identity {
2802                Self::remove_identity_files(attempt);
2803            }
2804        } else {
2805            // A failed stop is not a failed attempt yet.  Persist enough truth
2806            // for crash recovery, and retain the owned child when possible.
2807            let identity_durable =
2808                serde_json::to_vec(child.identity())
2809                    .ok()
2810                    .is_some_and(|identity| {
2811                        self.persist_identity(attempt, &identity).is_ok()
2812                            || self.persist_fallback_identity(attempt, &identity).is_ok()
2813                    });
2814            if !identity_durable {
2815                // Returning a live PID as though recovery were complete would
2816                // make the next boot trust a recyclable PID. Reaping is bounded;
2817                // if it cannot finish, the durable `starting` journal entry is
2818                // deliberately unresolved on restart and blocks new launches.
2819                for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
2820                    if self.stop_spawned_child(&mut child).is_ok() {
2821                        reaped = true;
2822                        break;
2823                    }
2824                }
2825                if !reaped {
2826                    // The attempt journal will durably record `starting` and
2827                    // its PID. Recovery treats a missing full identity as
2828                    // unresolved and starts nothing, so bounded stop failure
2829                    // cannot turn into either a hang or a duplicate runner.
2830                    let pid = child.pid();
2831                    let marker = write_durable_file(
2832                        &Self::unresolved_process_path(attempt),
2833                        pid.to_string().as_bytes(),
2834                    );
2835                    self.children
2836                        .lock()
2837                        .unwrap_or_else(std::sync::PoisonError::into_inner)
2838                        .insert(attempt.id, child);
2839                    let reason = if marker.is_ok() {
2840                        FailureReason::Other(
2841                            "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
2842                                .into(),
2843                        )
2844                    } else {
2845                        FailureReason::Other(
2846                            "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
2847                                .into(),
2848                        )
2849                    };
2850                    return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
2851                }
2852            } else {
2853                let pid = child.pid();
2854                self.children
2855                    .lock()
2856                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2857                    .insert(attempt.id, child);
2858                return ProcessStartFailure::after_spawn_live(pid);
2859            }
2860        }
2861        #[cfg(test)]
2862        if reaped {
2863            self.post_spawn_reaps
2864                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2865        }
2866        #[cfg(not(test))]
2867        let _ = reaped;
2868        ProcessStartFailure::after_spawn_stopped()
2869    }
2870
2871    fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
2872        attempt.runtime_path().join(IDENTITY_FILE)
2873    }
2874
2875    fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
2876        attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
2877    }
2878
2879    fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
2880        attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
2881    }
2882
2883    fn remove_identity_files(attempt: &RunnerAttempt) {
2884        let _ = fs::remove_file(Self::identity_path(attempt));
2885        let _ = fs::remove_file(Self::fallback_identity_path(attempt));
2886        let _ = fs::remove_file(Self::unresolved_process_path(attempt));
2887    }
2888
2889    fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
2890        self.persist_identity_at(&Self::identity_path(attempt), bytes)
2891    }
2892
2893    fn persist_fallback_identity(
2894        &self,
2895        attempt: &RunnerAttempt,
2896        bytes: &[u8],
2897    ) -> std::io::Result<()> {
2898        self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
2899    }
2900
2901    fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2902        #[cfg(test)]
2903        if self.faults_at(PostSpawnBoundary::IdentityWrite) {
2904            return Err(std::io::Error::other("injected identity write failure"));
2905        }
2906        write_durable_file(path, bytes)
2907    }
2908
2909    fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
2910        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
2911    }
2912
2913    fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
2914        match Self::read_identity_at(&Self::identity_path(attempt))? {
2915            Some(identity) => Ok(Some(identity)),
2916            None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
2917        }
2918    }
2919
2920    fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
2921        match fs::read(path) {
2922            Ok(bytes) => serde_json::from_slice(&bytes)
2923                .map(Some)
2924                .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
2925            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2926            Err(_) => Err(FailureReason::Other(
2927                "process identity journal could not be read".into(),
2928            )),
2929        }
2930    }
2931}
2932
2933impl ProcessSupervisor for NativeProcesses {
2934    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2935        if !attempt.execution().is_native() {
2936            return Err(FailureReason::Other(
2937                "execution provider unavailable".into(),
2938            ));
2939        }
2940        // Existing native reconciliation has already settled process termination
2941        // before this boundary. Capture before ephemeral workspace deletion.
2942        if let Some(caches) = &self.dependency_caches {
2943            caches.capture_git(attempt.runtime_path());
2944        }
2945        Ok(ProviderDestroy::Destroyed)
2946    }
2947
2948    fn prepare(
2949        &self,
2950        attempt: &RunnerAttempt,
2951        policy: &ScalePolicy,
2952    ) -> Result<PreparedEnvironment, FailureReason> {
2953        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2954            return Err(FailureReason::Other(
2955                "execution provider unavailable".into(),
2956            ));
2957        }
2958        self.host_env()?;
2959        // Asked here, before the JIT registration, for the reason `host_env`
2960        // is: a broken file must cost no registration.
2961        let cache_target = match &self.dependency_caches {
2962            Some(caches) => {
2963                caches.config()?;
2964                Some(policy.target.clone())
2965            }
2966            None => None,
2967        };
2968        Ok(PreparedEnvironment {
2969            attempt: attempt.id,
2970            identity: None,
2971            cache_target,
2972        })
2973    }
2974
2975    fn start(
2976        &self,
2977        prepared: PreparedEnvironment,
2978        attempt: &RunnerAttempt,
2979        handoff: OneTimeJitHandoff<'_>,
2980    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2981        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2982            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2983                "execution provider identity mismatch".into(),
2984            )));
2985        }
2986        self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
2987            .map(EnvironmentIdentity::NativeProcess)
2988    }
2989
2990    fn spawn(
2991        &self,
2992        attempt: &RunnerAttempt,
2993        config: &EncodedJitConfig,
2994    ) -> Result<u32, ProcessStartFailure> {
2995        self.spawn_listener(attempt, config, None)
2996    }
2997
2998    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2999        let mut children = self
3000            .children
3001            .lock()
3002            .unwrap_or_else(std::sync::PoisonError::into_inner);
3003        if let Some(child) = children.get_mut(&attempt.id) {
3004            return match child
3005                .try_exit_status()
3006                .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
3007            {
3008                None => Ok(true),
3009                Some(status) => {
3010                    if let Ok(mut exits) = self.successful_exits.lock() {
3011                        exits.insert(attempt.id, status.success());
3012                    }
3013                    Ok(false)
3014                }
3015            };
3016        }
3017        let Some(identity) = Self::read_identity(attempt)? else {
3018            if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
3019                return Err(FailureReason::Other(
3020                    "runner process identity is missing; refusing recovery until the process is resolved"
3021                        .into(),
3022                ));
3023            }
3024            return Ok(false);
3025        };
3026        match identity.recheck() {
3027            Ok(Adoption::Live) => Ok(true),
3028            Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
3029            Err(_) => Ok(false),
3030        }
3031    }
3032
3033    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3034        Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
3035    }
3036
3037    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
3038        self.successful_exits
3039            .lock()
3040            .ok()
3041            .and_then(|exits| exits.get(&attempt.id).copied())
3042            .unwrap_or(false)
3043    }
3044
3045    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3046        let path = Self::intent_path(attempt);
3047        write_durable_file(&path, b"registration-timeout\n")
3048            .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
3049    }
3050
3051    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
3052        Self::intent_path(attempt).is_file()
3053    }
3054
3055    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3056        let mut children = self
3057            .children
3058            .lock()
3059            .unwrap_or_else(std::sync::PoisonError::into_inner);
3060        if let Some(child) = children.get_mut(&attempt.id) {
3061            child
3062                .stop(Duration::from_secs(10))
3063                .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
3064            return Ok(());
3065        }
3066        let Some(identity) = Self::read_identity(attempt)? else {
3067            return Ok(());
3068        };
3069        match identity
3070            .terminate(Duration::from_secs(10))
3071            .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
3072        {
3073            Termination::Terminated | Termination::AlreadyGone => Ok(()),
3074            Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
3075                "runner PID was recycled; refusing to signal it".into(),
3076            )),
3077        }
3078    }
3079}
3080
3081pub struct LifecyclePorts {
3082    pub store: Arc<dyn Store>,
3083    pub github: Arc<dyn LifecycleGithub>,
3084    pub packages: Arc<dyn RuntimePackages>,
3085    pub processes: Arc<dyn ExecutionProvider>,
3086    pub clock: Arc<dyn Clock>,
3087    pub demand: Arc<dyn DemandPersistence>,
3088    pub delay: Arc<dyn RetryDelay>,
3089    pub events: Arc<dyn AttemptEventSink>,
3090    pub reconcile_events: Arc<dyn EventSink>,
3091}
3092
3093impl fmt::Debug for LifecyclePorts {
3094    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3095        f.debug_struct("LifecyclePorts")
3096            .field("store", &self.store)
3097            .field("github", &self.github)
3098            .field("packages", &self.packages)
3099            .field("processes", &self.processes)
3100            .finish_non_exhaustive()
3101    }
3102}
3103
3104#[derive(Debug, thiserror::Error)]
3105pub enum LifecycleError {
3106    #[error("attempt journal operation failed")]
3107    Journal,
3108    #[error("attempt {0} is not in the journal")]
3109    Missing(AttemptId),
3110    #[error("attempt lifecycle transition was refused")]
3111    Transition,
3112    #[error("startup recovery has not completed")]
3113    RecoveryIncomplete,
3114    /// A persistent slot could not be proven safe to scrub, so the attempt keeps
3115    /// its uncleaned state and, with it, its slot lease.
3116    ///
3117    /// Separate from [`Self::Failed`] because the two demand opposite handling
3118    /// from the same call sites. A failure aborts the pass; a quarantine must
3119    /// not, or one stuck slot would stop the host launching anything at all —
3120    /// which is precisely what `04-security-recovery.md` rules out when it says
3121    /// such an attempt "does not count as active host capacity" and "recovery
3122    /// retries the same cleanup".
3123    #[error("the persistent slot was not cleaned: {detail}")]
3124    SlotQuarantined {
3125        /// The closed-vocabulary event field; never operator or workflow text.
3126        class: &'static str,
3127        detail: String,
3128    },
3129    /// An ephemeral runtime is still held by a late process. Its journal state
3130    /// remains unchanged and ordinary supervision retries it, but startup
3131    /// recovery must not take unrelated repositories offline because of it.
3132    #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
3133    WorkspaceCleanupDeferred,
3134    #[error("isolated environment cleanup is deferred")]
3135    EnvironmentCleanupDeferred,
3136    #[error("runner lifecycle failed: {0}")]
3137    Failed(FailureReason),
3138}
3139
3140impl LifecycleError {
3141    fn reason(&self) -> FailureReason {
3142        match self {
3143            Self::Failed(reason) => reason.clone(),
3144            Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
3145            Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
3146            Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
3147            Self::Transition => FailureReason::Other("attempt transition was refused".into()),
3148            // Rendered through `Display` rather than a second copy of the same
3149            // sentence, so the two cannot drift apart.
3150            Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
3151            Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
3152            Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
3153        }
3154    }
3155}
3156
3157/// Production implementation of e1's launcher port.
3158#[derive(Debug)]
3159pub struct LifecycleLauncher {
3160    host_id: HostId,
3161    app_paths: runner_manager_platform::paths::AppPaths,
3162    diagnostics_root: PathBuf,
3163    runner_group_id: u64,
3164    timeouts: RecoveryTimeouts,
3165    retry: RetryPolicy,
3166    cancel: CancelToken,
3167    ports: LifecyclePorts,
3168    recovery_complete: Mutex<bool>,
3169    versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
3170    pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
3171    /// Backoff for cleanups [`Self::supervise`] tolerates and retries, so a
3172    /// workspace that cannot be removed is not retried and reported on every
3173    /// pass.
3174    cleanup_retries: Mutex<CleanBackoff>,
3175    turns: Arc<tokio::sync::Semaphore>,
3176}
3177
3178/// How many runners of one host prepare at once, whatever their target: a
3179/// quarter of its cores, at least two. A runner's package is ~9,000 files, and
3180/// on a disk jobs are using, more at once only make all of them slower. Fair,
3181/// so the ones waiting go in the order they were reserved.
3182#[must_use]
3183pub fn preparation_turns() -> Arc<tokio::sync::Semaphore> {
3184    let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
3185    Arc::new(tokio::sync::Semaphore::new((cores / 4).max(2)))
3186}
3187
3188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3189enum ReconcileProgress {
3190    Reconciled,
3191    Deferred,
3192    Replacement {
3193        attempt: AttemptId,
3194        operation: &'static str,
3195    },
3196}
3197
3198impl LifecycleLauncher {
3199    #[must_use]
3200    pub fn new(
3201        host_id: HostId,
3202        app_paths: runner_manager_platform::paths::AppPaths,
3203        diagnostics_root: impl Into<PathBuf>,
3204        runner_group_id: u64,
3205        timeouts: RecoveryTimeouts,
3206        retry: RetryPolicy,
3207        ports: LifecyclePorts,
3208    ) -> Self {
3209        Self {
3210            host_id,
3211            app_paths,
3212            diagnostics_root: diagnostics_root.into(),
3213            runner_group_id,
3214            timeouts,
3215            retry,
3216            cancel: CancelToken::new(),
3217            ports,
3218            recovery_complete: Mutex::new(false),
3219            versions: Mutex::new(BTreeMap::new()),
3220            pending_replacements: Mutex::new(BTreeMap::new()),
3221            cleanup_retries: Mutex::new(CleanBackoff::default()),
3222            turns: preparation_turns(),
3223        }
3224    }
3225
3226    /// Takes turns to prepare with every launcher holding `turns`, rather than
3227    /// only with itself: the targets of one host.
3228    #[must_use]
3229    pub fn with_preparation_turns(mut self, turns: Arc<tokio::sync::Semaphore>) -> Self {
3230        self.turns = turns;
3231        self
3232    }
3233
3234    /// Reconcile the entire journal before allowing a launch.  Unknown-policy
3235    /// attempts are left untouched rather than being acted on without an
3236    /// ownership proof.
3237    pub async fn recover_startup(
3238        &self,
3239        policies: &[ScalePolicy],
3240    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3241        *self
3242            .recovery_complete
3243            .lock()
3244            .map_err(|_| LifecycleError::Journal)? = false;
3245        let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
3246        let attempts = self
3247            .ports
3248            .store
3249            .attempts()
3250            .map_err(|_| LifecycleError::Journal)?;
3251        let owned = self.ports.processes.enumerate_owned(self.host_id);
3252        for identity in &owned {
3253            if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
3254                && !attempts.iter().any(|attempt| {
3255                    attempt.state() != AttemptState::Cleaned
3256                        && identity_matches_intent(self.host_id, attempt, identity)
3257                })
3258            {
3259                // Enumeration is discovery, not authority to delete.
3260                self.ports
3261                    .events
3262                    .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
3263                tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
3264            }
3265        }
3266        let mut unresolved = false;
3267        for mut attempt in attempts {
3268            let Some(policy) = by_id.get(&attempt.policy_id) else {
3269                if attempt.counts_against_capacity() {
3270                    unresolved = true;
3271                }
3272                continue;
3273            };
3274            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3275            let allocation_resources = owned
3276                .iter()
3277                .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
3278                .collect::<Vec<_>>();
3279            if allocation_resources.len() > 1 {
3280                self.ports
3281                    .events
3282                    .emit(AttemptEvent::QuarantinedOwnedResource {
3283                        attempt: attempt.id,
3284                    });
3285                tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
3286            }
3287            if attempt.state() != AttemptState::Cleaned
3288                && matches!(
3289                    attempt.execution(),
3290                    AttemptExecution::Isolated {
3291                        environment_id: None,
3292                        ..
3293                    }
3294                )
3295                && allocation_resources.len() == 1
3296                && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
3297                    allocation_resources.first().copied()
3298            {
3299                attempt
3300                    .prepared_environment(environment_id.clone())
3301                    .map_err(|_| LifecycleError::Transition)?;
3302                if attempt.state() == AttemptState::Preparing {
3303                    attempt
3304                        .mark_prepared(self.ports.clock.now())
3305                        .map_err(|_| LifecycleError::Transition)?;
3306                }
3307                self.record(&attempt)?;
3308            }
3309            match self.reconcile_one(policy, attempt).await? {
3310                ReconcileProgress::Deferred => {
3311                    if policy.execution_policy().is_native() {
3312                        unresolved = true;
3313                    }
3314                }
3315                ReconcileProgress::Replacement { attempt, operation } => {
3316                    self.pending_replacements
3317                        .lock()
3318                        .map_err(|_| LifecycleError::Journal)?
3319                        .insert(
3320                            attempt,
3321                            ReplacementIntent {
3322                                policy: policy.id,
3323                                previous_attempt: attempt,
3324                                operation,
3325                            },
3326                        );
3327                }
3328                ReconcileProgress::Reconciled => {}
3329            }
3330        }
3331        if unresolved {
3332            return Err(LifecycleError::RecoveryIncomplete);
3333        }
3334        *self
3335            .recovery_complete
3336            .lock()
3337            .map_err(|_| LifecycleError::Journal)? = true;
3338        Ok(self
3339            .pending_replacements
3340            .lock()
3341            .map_err(|_| LifecycleError::Journal)?
3342            .values()
3343            .copied()
3344            .collect())
3345    }
3346
3347    /// Supervise all attempts of one policy during an ordinary poll.
3348    pub async fn supervise(
3349        &self,
3350        policy: &ScalePolicy,
3351    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3352        let mut replacements = Vec::new();
3353        self.pending_replacements
3354            .lock()
3355            .map_err(|_| LifecycleError::Journal)?
3356            .retain(|_, intent| {
3357                if intent.policy == policy.id {
3358                    replacements.push(*intent);
3359                    false
3360                } else {
3361                    true
3362                }
3363            });
3364        let attempts = self
3365            .ports
3366            .store
3367            .attempts_for_policy(policy.id)
3368            .map_err(|_| LifecycleError::Journal)?;
3369        for attempt in attempts {
3370            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3371            if let ReconcileProgress::Replacement { attempt, operation } =
3372                self.reconcile_one(policy, attempt).await?
3373            {
3374                replacements.push(ReplacementIntent {
3375                    policy: policy.id,
3376                    previous_attempt: attempt,
3377                    operation,
3378                });
3379            }
3380        }
3381        Ok(replacements)
3382    }
3383
3384    async fn reconcile_one(
3385        &self,
3386        policy: &ScalePolicy,
3387        mut attempt: RunnerAttempt,
3388    ) -> Result<ReconcileProgress, LifecycleError> {
3389        if !attempt.execution().is_native() {
3390            return self.reconcile_isolated_one(policy, attempt).await;
3391        }
3392        if attempt.state() == AttemptState::Cleaned {
3393            // A child of Runner.Worker can outlive the runner process briefly.
3394            // If it recreates `_work` after `clean_attempt` removed the tree but
3395            // before the journal's final write, the journal truthfully reaches
3396            // `cleaned` and the directory appears again afterwards. Cleaned
3397            // attempts used to return here forever, making that late residue
3398            // invisible. The journalled ephemeral mode is the ownership proof:
3399            // retry the whole-tree removal, while a persistent slot deliberately
3400            // remains on disk with its retained `_work`.
3401            if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3402                && attempt.runtime_path().exists()
3403            {
3404                // The attempt is already terminal, has released its package
3405                // lease, and consumes no capacity. A late child may still hold
3406                // one of its recreated files open on Windows, so failure here
3407                // is residue to retry, not a reason to abort startup recovery
3408                // and take every unrelated repository offline.
3409                // Retried with backoff; the event is the one report, which the
3410                // event sink logs as a warning.
3411                let now = self.ports.clock.now();
3412                if !self.cleanup_retries().is_due(attempt.id, now) {
3413                    return Ok(ReconcileProgress::Reconciled);
3414                }
3415                match self.scrub_workspace(&attempt) {
3416                    Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3417                    Err(LifecycleError::WorkspaceCleanupDeferred) => {
3418                        self.cleanup_retries().failed(attempt.id, now);
3419                        self.ports
3420                            .reconcile_events
3421                            .emit(LifecycleEvent::AttemptCleanFailed {
3422                                policy: attempt.policy_id,
3423                                attempt: attempt.id,
3424                                reason: "late_ephemeral_workspace_could_not_be_removed",
3425                            });
3426                    }
3427                    Err(error) => return Err(error),
3428                }
3429            }
3430            return Ok(ReconcileProgress::Reconciled);
3431        }
3432        if attempt.is_terminal() {
3433            self.clean_or_quarantine(&mut attempt)?;
3434            return Ok(ReconcileProgress::Reconciled);
3435        }
3436        let process_alive = self
3437            .ports
3438            .processes
3439            .inspect(&attempt)
3440            .map_err(LifecycleError::Failed)?
3441            == EnvironmentState::Running;
3442        let github = self
3443            .ports
3444            .github
3445            .observe(&policy.target, attempt.id, &self.cancel)
3446            .await;
3447
3448        // If the agent died after GitHub accepted the registration but before
3449        // the non-secret runner-id sidecar landed, inventory closes the gap.
3450        // Persist it before making any state decision so a second crash moves
3451        // the boundary forward rather than repeating it.
3452        if let Some(runner_id) = github.runner_id
3453            && read_runner_id(attempt.runtime_path()).is_none()
3454        {
3455            write_runner_id(attempt.runtime_path(), runner_id)?;
3456            self.ports
3457                .events
3458                .emit(AttemptEvent::RemoteIdentityRecovered {
3459                    attempt: attempt.id,
3460                    runner_id,
3461                });
3462        }
3463
3464        // The child identity is synced before `spawn` returns.  If the agent
3465        // crashed before the following `starting` journal write, recover that
3466        // exact PID and take the legal `jit_received -> starting` edge before
3467        // applying GitHub's authoritative idle/busy observation below.
3468        if attempt.state() == AttemptState::JitReceived
3469            && process_alive
3470            && let Some(pid) = self
3471                .ports
3472                .processes
3473                .recovered_pid(&attempt)
3474                .map_err(LifecycleError::Failed)?
3475        {
3476            attempt
3477                .started(pid, self.ports.clock.now())
3478                .map_err(|_| LifecycleError::Transition)?;
3479            self.record(&attempt)?;
3480        }
3481
3482        // What the runner's own diagnostics say about whether it ran a job.
3483        // Read before anything below can conclude the attempt, because cleanup
3484        // deletes `_diag` with the rest of the runtime directory.
3485        // A one-shot child owned by this invocation exited successfully. This
3486        // concludes the *runner attempt*, never the workflow outcome; GitHub
3487        // remains authoritative for that. GitHub's inventory is a sampled
3488        // witness: a job of a few seconds starts and ends between two passes,
3489        // and an assignment GitHub shows can be one the runner never took up.
3490        // The runner's own diagnostics decide, read before cleanup deletes
3491        // them, and GitHub's sample only decides when they say nothing. Not
3492        // while GitHub is unreachable: a registration still listed there could
3493        // not be removed, and nothing visits a concluded attempt again.
3494        if !process_alive
3495            && github.status != GithubRunnerObservation::Unreachable
3496            && matches!(
3497                attempt.state(),
3498                AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3499            )
3500            && self.ports.processes.completed_successfully(&attempt)
3501        {
3502            let state = attempt.state();
3503            let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3504                // Without a runner id there is no edge to `busy`; the recovery
3505                // path below concludes the attempt without one.
3506                (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3507                    known_runner_id(&attempt, github.runner_id)
3508                        .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3509                }
3510                (AttemptState::Busy, JobEvidence::RanAJob) => {
3511                    Some((None, AttemptOutcome::CompletedJob))
3512                }
3513                (AttemptState::Busy, JobEvidence::NoJob) => {
3514                    Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3515                }
3516                (AttemptState::Busy, JobEvidence::Unknown)
3517                    if github.status == GithubRunnerObservation::NotRegistered =>
3518                {
3519                    Some((None, AttemptOutcome::CompletedJob))
3520                }
3521                _ => None,
3522            };
3523            if let Some((walk_to_busy, outcome)) = outcome {
3524                // An ephemeral runner leaves GitHub's list a little after its
3525                // process exits; one still listed is removed here rather than
3526                // left for GitHub to time out. First, so the journal's last
3527                // intermediate state and the conclusion are written back to back:
3528                // an agent that dies between them recovers from the state it was
3529                // in before this pass.
3530                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3531                    self.deregister_runner(policy, &attempt).await;
3532                }
3533                let now = self.ports.clock.now();
3534                match (walk_to_busy, &outcome) {
3535                    (Some(runner_id), _) => {
3536                        self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3537                    }
3538                    (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3539                        self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3540                    }
3541                    (None, _) => {}
3542                }
3543                self.conclude(&mut attempt, outcome)?;
3544                self.clean_or_quarantine(&mut attempt)?;
3545                return Ok(ReconcileProgress::Reconciled);
3546            }
3547        }
3548
3549        // A live runner journalled `busy` that GitHub now shows waiting, and
3550        // whose diagnostics show no job: the assignment was never taken up.
3551        // Back to `idle`, where the idle timeout applies to it again; the
3552        // `busy` arm below would otherwise adopt it for as long as it runs.
3553        if process_alive
3554            && attempt.state() == AttemptState::Busy
3555            && github.status == (GithubRunnerObservation::Registered { busy: false })
3556            && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
3557        {
3558            let now = self.ports.clock.now();
3559            self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3560            return Ok(ReconcileProgress::Reconciled);
3561        }
3562
3563        // This durable mark is more authoritative than a later observation
3564        // which cannot distinguish an agent kill from a crash.
3565        if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3566            self.deregister_runner(policy, &attempt).await;
3567            self.conclude(
3568                &mut attempt,
3569                AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3570            )?;
3571            self.clean_or_quarantine(&mut attempt)?;
3572            return Ok(ReconcileProgress::Replacement {
3573                attempt: attempt.id,
3574                operation: "registration_timeout_replacement",
3575            });
3576        }
3577
3578        // A remote registration with no surviving process has lost its
3579        // one-shot JIT secret.  Walking it to `starting` would require inventing
3580        // a PID; retrying the same registration would require inventing the
3581        // secret.  Record the configuration as expired and let the bounded
3582        // replacement path request a fresh one only if demand remains.
3583        if matches!(
3584            attempt.state(),
3585            AttemptState::Allocated | AttemptState::JitReceived
3586        ) && !process_alive
3587            && matches!(github.status, GithubRunnerObservation::Registered { .. })
3588        {
3589            if attempt.state() == AttemptState::Allocated {
3590                attempt
3591                    .jit_received(self.ports.clock.now())
3592                    .map_err(|_| LifecycleError::Transition)?;
3593                self.record(&attempt)?;
3594            }
3595            self.deregister_runner(policy, &attempt).await;
3596            self.conclude(
3597                &mut attempt,
3598                AttemptOutcome::failed(FailureReason::JitExpired),
3599            )?;
3600            self.clean_or_quarantine(&mut attempt)?;
3601            return Ok(ReconcileProgress::Replacement {
3602                attempt: attempt.id,
3603                operation: "jit_expired_replacement",
3604            });
3605        }
3606
3607        match recovery_decision(
3608            &attempt,
3609            RecoveryObservation {
3610                process_alive,
3611                github: github.status,
3612            },
3613            self.timeouts,
3614            self.ports.clock.as_ref(),
3615        ) {
3616            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3617            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3618            RecoveryDecision::Adopt => {
3619                self.ports.events.emit(AttemptEvent::Adopted {
3620                    attempt: attempt.id,
3621                });
3622                Ok(ReconcileProgress::Reconciled)
3623            }
3624            RecoveryDecision::Clean => {
3625                self.clean_or_quarantine(&mut attempt)?;
3626                Ok(ReconcileProgress::Reconciled)
3627            }
3628            RecoveryDecision::Observe(state) => {
3629                let runner_id = known_runner_id(&attempt, github.runner_id)
3630                    .ok_or(LifecycleError::Transition)?;
3631                match state {
3632                    AttemptState::JitReceived => attempt
3633                        .jit_received(self.ports.clock.now())
3634                        .map_err(|_| LifecycleError::Transition)?,
3635                    AttemptState::Starting => {
3636                        let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3637                        attempt
3638                            .started(pid, self.ports.clock.now())
3639                            .map_err(|_| LifecycleError::Transition)?;
3640                    }
3641                    AttemptState::Idle => attempt
3642                        .registered_idle(runner_id, self.ports.clock.now())
3643                        .map_err(|_| LifecycleError::Transition)?,
3644                    AttemptState::Busy => attempt
3645                        .assigned_job(runner_id, self.ports.clock.now())
3646                        .map_err(|_| LifecycleError::Transition)?,
3647                    _ => return Err(LifecycleError::Transition),
3648                }
3649                self.record(&attempt)?;
3650                Ok(ReconcileProgress::Reconciled)
3651            }
3652            RecoveryDecision::Conclude(outcome) => {
3653                let replacement = replacement_operation(&outcome);
3654                // Only when GitHub still holds one. Every other conclusion here
3655                // was reached *because* the observation was `NotRegistered`, and
3656                // spending a DELETE to be told so again would put a request per
3657                // concluded attempt on a budget `rest.rs` prices to the request.
3658                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3659                    self.deregister_runner(policy, &attempt).await;
3660                }
3661                self.conclude(&mut attempt, outcome)?;
3662                self.clean_or_quarantine(&mut attempt)?;
3663                Ok(
3664                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3665                        ReconcileProgress::Replacement {
3666                            attempt: attempt.id,
3667                            operation,
3668                        }
3669                    }),
3670                )
3671            }
3672            RecoveryDecision::Terminate(payload) => {
3673                // The mark is synced first, and what proves the process died is
3674                // the `is_alive` re-read below -- not the outcome recorded after
3675                // it. Which outcome that is depends on why the termination was
3676                // ordered, and only the payload knows: a `starting` runner that
3677                // never registered is a failure this agent then stopped, while
3678                // an `idle` one past its timeout is flow 2.7's surplus exit and
3679                // no failure at all. Hardcoding the first reason here labelled
3680                // the second as a registration timeout and asked the allocator
3681                // for a replacement to boot.
3682                let idle_exit = payload.is_idle_exit();
3683                self.ports
3684                    .processes
3685                    .record_terminate_intent(&attempt)
3686                    .map_err(LifecycleError::Failed)?;
3687                self.ports.events.emit(AttemptEvent::TerminateIntent {
3688                    attempt: attempt.id,
3689                });
3690                if self
3691                    .ports
3692                    .processes
3693                    .stop(&attempt)
3694                    .map_err(LifecycleError::Failed)?
3695                    == ProviderStop::StillRunning
3696                {
3697                    return Ok(ReconcileProgress::Deferred);
3698                }
3699                if self
3700                    .ports
3701                    .processes
3702                    .inspect(&attempt)
3703                    .map_err(LifecycleError::Failed)?
3704                    == EnvironmentState::Running
3705                {
3706                    return Ok(ReconcileProgress::Deferred);
3707                }
3708                self.ports.events.emit(AttemptEvent::Terminated {
3709                    attempt: attempt.id,
3710                });
3711                // The registration-timeout path keeps deriving its own reason
3712                // rather than applying the payload: on the pass that reads the
3713                // journalled mark back the process is dead, and
3714                // `TerminatedAfterRegistrationTimeout` is the reason that stays
3715                // true of a dead process. See `RecoveryDecision::Terminate`.
3716                let outcome = if idle_exit {
3717                    AttemptOutcome::ExitedIdleWithoutWork
3718                } else {
3719                    AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3720                };
3721                self.deregister_runner(policy, &attempt).await;
3722                self.conclude(&mut attempt, outcome)?;
3723                self.clean_or_quarantine(&mut attempt)?;
3724                // A surplus runner is not replaced. It was stopped precisely
3725                // because the work it was started for went elsewhere; asking the
3726                // allocator for another one rebuilds it every idle timeout.
3727                if idle_exit {
3728                    Ok(ReconcileProgress::Reconciled)
3729                } else {
3730                    Ok(ReconcileProgress::Replacement {
3731                        attempt: attempt.id,
3732                        operation: "registration_timeout_replacement",
3733                    })
3734                }
3735            }
3736        }
3737    }
3738
3739    async fn reconcile_isolated_one(
3740        &self,
3741        policy: &ScalePolicy,
3742        mut attempt: RunnerAttempt,
3743    ) -> Result<ReconcileProgress, LifecycleError> {
3744        if attempt.state() == AttemptState::Cleaned {
3745            return Ok(ReconcileProgress::Reconciled);
3746        }
3747        if attempt.is_terminal() {
3748            self.clean_or_quarantine(&mut attempt)?;
3749            return Ok(ReconcileProgress::Reconciled);
3750        }
3751        if matches!(
3752            attempt.state(),
3753            AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3754        ) {
3755            // No JIT was requested yet. Restart can safely retire this attempt;
3756            // a matching owned resource is stopped by the durable cleanup path.
3757            self.conclude(
3758                &mut attempt,
3759                AttemptOutcome::failed(FailureReason::Other(
3760                    "isolated preparation interrupted".into(),
3761                )),
3762            )?;
3763            self.clean_or_quarantine(&mut attempt)?;
3764            return Ok(ReconcileProgress::Reconciled);
3765        }
3766        let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3767            return Ok(ReconcileProgress::Deferred);
3768        };
3769        if provider_state != EnvironmentState::Missing {
3770            let Ok(owned) = self.ports.processes.owns(&attempt) else {
3771                return Ok(ReconcileProgress::Deferred);
3772            };
3773            if !owned {
3774                self.ports
3775                    .events
3776                    .emit(AttemptEvent::QuarantinedOwnedResource {
3777                        attempt: attempt.id,
3778                    });
3779                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3780                return Ok(ReconcileProgress::Deferred);
3781            }
3782        }
3783        let live = matches!(
3784            provider_state,
3785            EnvironmentState::Starting | EnvironmentState::Running
3786        );
3787        if attempt.state() == AttemptState::JitReceived && live {
3788            attempt
3789                .started_isolated(self.ports.clock.now())
3790                .map_err(|_| LifecycleError::Transition)?;
3791            self.record(&attempt)?;
3792        }
3793        let github = self
3794            .ports
3795            .github
3796            .observe(&policy.target, attempt.id, &self.cancel)
3797            .await;
3798        if let Some(runner_id) = github.runner_id
3799            && read_runner_id(attempt.runtime_path()).is_none()
3800        {
3801            write_runner_id(attempt.runtime_path(), runner_id)?;
3802        }
3803        // A successfully reaped one-shot listener is stronger evidence than a
3804        // missing registration. Recovered guests have no attached child and
3805        // still follow the conservative orphan path below.
3806        if attempt.state() == AttemptState::Busy
3807            && !live
3808            && github.status == GithubRunnerObservation::NotRegistered
3809            && self.ports.processes.completed_successfully(&attempt)
3810        {
3811            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
3812            self.clean_or_quarantine(&mut attempt)?;
3813            return Ok(ReconcileProgress::Reconciled);
3814        }
3815        match recovery_decision(
3816            &attempt,
3817            RecoveryObservation {
3818                process_alive: live,
3819                github: github.status,
3820            },
3821            self.timeouts,
3822            self.ports.clock.as_ref(),
3823        ) {
3824            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3825            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3826            RecoveryDecision::Adopt => {
3827                self.ports.events.emit(AttemptEvent::Adopted {
3828                    attempt: attempt.id,
3829                });
3830                Ok(ReconcileProgress::Reconciled)
3831            }
3832            RecoveryDecision::Clean => {
3833                self.clean_or_quarantine(&mut attempt)?;
3834                Ok(ReconcileProgress::Reconciled)
3835            }
3836            RecoveryDecision::Observe(state) => {
3837                let runner_id = github
3838                    .runner_id
3839                    .or_else(|| read_runner_id(attempt.runtime_path()))
3840                    .ok_or(LifecycleError::Transition)?;
3841                match state {
3842                    AttemptState::Idle => attempt
3843                        .registered_idle(runner_id, self.ports.clock.now())
3844                        .map_err(|_| LifecycleError::Transition)?,
3845                    AttemptState::Busy => attempt
3846                        .assigned_job(runner_id, self.ports.clock.now())
3847                        .map_err(|_| LifecycleError::Transition)?,
3848                    _ => return Err(LifecycleError::Transition),
3849                }
3850                self.record(&attempt)?;
3851                Ok(ReconcileProgress::Reconciled)
3852            }
3853            RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
3854                let replacement = replacement_operation(&outcome);
3855                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3856                    self.deregister_runner(policy, &attempt).await;
3857                }
3858                self.conclude(&mut attempt, outcome)?;
3859                self.clean_or_quarantine(&mut attempt)?;
3860                Ok(
3861                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3862                        ReconcileProgress::Replacement {
3863                            attempt: attempt.id,
3864                            operation,
3865                        }
3866                    }),
3867                )
3868            }
3869        }
3870    }
3871
3872    /// Takes one domain edge and journals it.
3873    fn transition(
3874        &self,
3875        attempt: &mut RunnerAttempt,
3876        edge: impl FnOnce(
3877            &mut RunnerAttempt,
3878        ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
3879    ) -> Result<(), LifecycleError> {
3880        edge(attempt).map_err(|_| LifecycleError::Transition)?;
3881        self.record(attempt)
3882    }
3883
3884    fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3885        self.ports
3886            .store
3887            .record_attempt(attempt)
3888            .map_err(|_| LifecycleError::Journal)?;
3889        self.ports.events.emit(AttemptEvent::State {
3890            attempt: attempt.id,
3891            state: attempt.state(),
3892        });
3893        Ok(())
3894    }
3895
3896    /// Remove the GitHub registration an attempt is about to leave behind.
3897    ///
3898    /// # Why this is not fallible, and does not block the conclusion
3899    ///
3900    /// GitHub retires an ephemeral runner itself once that runner *completes a
3901    /// job*, and for the ordinary path that is the whole story. The paths that
3902    /// reach here are the ones where it does not: a runner stopped before it
3903    /// was ever assigned work, a registration whose process died still holding
3904    /// it, a JIT configuration that expired. Nothing else deletes those, and
3905    /// before this existed nothing did — they accumulated in the target's
3906    /// runner settings, one row per attempt, for the life of the repository.
3907    ///
3908    /// It returns `()` rather than a `Result` because the alternative is worse
3909    /// in both directions. The attempt is over: its process is gone and its
3910    /// slot has to come back, so a failed delete may not abort the conclusion
3911    /// or the host leaks capacity every time GitHub is unreachable. And a
3912    /// registration that outlives this call is not lost — it is exactly the
3913    /// `Registered` + dead-process observation that
3914    /// [`AttemptOutcome::Orphaned`] already names, which a later pass can still
3915    /// see. So a failure is logged and stepped over, deliberately.
3916    async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
3917        let Some(runner_id) = attempt
3918            .github_runner_id()
3919            .or_else(|| read_runner_id(attempt.runtime_path()))
3920        else {
3921            return;
3922        };
3923        if self
3924            .ports
3925            .github
3926            .deregister(&policy.target, runner_id, &self.cancel)
3927            .await
3928        {
3929            self.ports.events.emit(AttemptEvent::Deregistered {
3930                attempt: attempt.id,
3931                runner_id,
3932            });
3933        } else {
3934            tracing::warn!(
3935                attempt = %attempt.id,
3936                runner_id,
3937                "the runner registration could not be removed from GitHub; it will show in the \
3938                 target's runner settings until GitHub retires it or a later pass removes it"
3939            );
3940        }
3941    }
3942
3943    fn conclude(
3944        &self,
3945        attempt: &mut RunnerAttempt,
3946        outcome: AttemptOutcome,
3947    ) -> Result<(), LifecycleError> {
3948        attempt
3949            .conclude(outcome.clone(), self.ports.clock.now())
3950            .map_err(|_| LifecycleError::Transition)?;
3951        self.record(attempt)?;
3952        self.ports.events.emit(AttemptEvent::Concluded {
3953            attempt: attempt.id,
3954            outcome: OutcomeKind::of(&outcome),
3955        });
3956        Ok(())
3957    }
3958
3959    /// Clean a concluded attempt, tolerating a quarantined persistent slot.
3960    ///
3961    /// The quarantine is reported and stepped over rather than raised, because
3962    /// raising it aborts the whole pass: on the startup path that leaves
3963    /// `recovery_complete` false and stops the host launching anything, which is
3964    /// the opposite of `04-security-recovery.md`'s "it does not count as active
3965    /// host capacity" and "recovery retries the same cleanup". The attempt keeps
3966    /// its state, so it keeps its slot lease and its directory, and the next
3967    /// pass — [`crate::reconcile::Reconciler`]'s terminal sweep on every poll,
3968    /// or the next startup — attempts exactly the same cleanup again.
3969    ///
3970    /// A persistent-slot quarantine and an ephemeral directory still held by a
3971    /// late process are tolerated. A journal failure or a package lease that
3972    /// cannot be released still propagates: those are not one workspace's
3973    /// problem.
3974    ///
3975    /// A tolerated failure is retried with per-attempt backoff: until it is
3976    /// due again the attempt is skipped silently rather than re-tried and
3977    /// re-reported on every pass.
3978    fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3979        let now = self.ports.clock.now();
3980        if !self.cleanup_retries().is_due(attempt.id, now) {
3981            return Ok(());
3982        }
3983        let reason = match self.clean_attempt(attempt) {
3984            Err(LifecycleError::SlotQuarantined { class, .. }) => class,
3985            Err(LifecycleError::WorkspaceCleanupDeferred) => {
3986                failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
3987            }
3988            Err(LifecycleError::EnvironmentCleanupDeferred) => {
3989                "isolated_environment_cleanup_deferred"
3990            }
3991            other => {
3992                if other.is_ok() {
3993                    self.cleanup_retries().succeeded(attempt.id);
3994                }
3995                return other;
3996            }
3997        };
3998        self.cleanup_retries().failed(attempt.id, now);
3999        self.ports
4000            .reconcile_events
4001            .emit(LifecycleEvent::AttemptCleanFailed {
4002                policy: attempt.policy_id,
4003                attempt: attempt.id,
4004                reason,
4005            });
4006        Ok(())
4007    }
4008
4009    fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
4010        self.cleanup_retries
4011            .lock()
4012            .unwrap_or_else(std::sync::PoisonError::into_inner)
4013    }
4014
4015    fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4016        if !attempt.execution().is_native() {
4017            return self.clean_isolated_attempt(attempt);
4018        }
4019        match self
4020            .ports
4021            .processes
4022            .destroy(attempt)
4023            .map_err(LifecycleError::Failed)?
4024        {
4025            ProviderDestroy::Destroyed => {}
4026            ProviderDestroy::Deferred(reason) => {
4027                return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
4028            }
4029        }
4030        let outcome = attempt
4031            .outcome()
4032            .cloned()
4033            .ok_or(LifecycleError::Transition)?;
4034        self.preserve_diagnostics(attempt, &outcome)?;
4035        self.scrub_workspace(attempt)?;
4036        self.ports
4037            .packages
4038            .release(attempt.id)
4039            .map_err(LifecycleError::Failed)?;
4040        attempt
4041            .clean(self.ports.clock.now())
4042            .map_err(|_| LifecycleError::Transition)?;
4043        self.record(attempt)?;
4044        let kind = OutcomeKind::of(&outcome);
4045        self.ports.events.emit(AttemptEvent::Cleaned {
4046            attempt: attempt.id,
4047            outcome: kind,
4048        });
4049        self.ports
4050            .reconcile_events
4051            .emit(LifecycleEvent::AttemptCleaned {
4052                policy: attempt.policy_id,
4053                attempt: attempt.id,
4054                outcome: kind,
4055            });
4056        Ok(())
4057    }
4058
4059    fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4060        if attempt.state() != AttemptState::Destroying {
4061            attempt
4062                .begin_destroy(self.ports.clock.now())
4063                .map_err(|_| LifecycleError::Transition)?;
4064            self.record(attempt)?;
4065        }
4066        let provider = self.ports.processes.as_ref();
4067        let allocation_resources = provider.enumerate_owned(self.host_id);
4068        let matching_allocation = allocation_resources
4069            .iter()
4070            .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
4071            .collect::<Vec<_>>();
4072        if matching_allocation.len() > 1
4073            || matching_allocation
4074                .iter()
4075                .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
4076        {
4077            self.ports
4078                .events
4079                .emit(AttemptEvent::QuarantinedOwnedResource {
4080                    attempt: attempt.id,
4081                });
4082            tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
4083            return Err(self.defer_environment_cleanup(attempt)?);
4084        }
4085        let has_identity = matches!(
4086            attempt.execution(),
4087            AttemptExecution::Isolated {
4088                environment_id: Some(_),
4089                ..
4090            }
4091        );
4092        if !has_identity
4093            && allocation_resources
4094                .iter()
4095                .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
4096        {
4097            return Err(self.defer_environment_cleanup(attempt)?);
4098        }
4099        let current = if has_identity {
4100            provider.inspect(attempt)
4101        } else {
4102            // A prepare effect may have succeeded before its identity could be
4103            // journalled. Empty enumeration alone is not proof of absence.
4104            provider.recover(attempt)
4105        };
4106        let current = match current {
4107            Ok(state) => state,
4108            Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
4109        };
4110        if !has_identity && current != EnvironmentState::Missing {
4111            return Err(self.defer_environment_cleanup(attempt)?);
4112        }
4113        if current != EnvironmentState::Missing {
4114            if !matches!(provider.owns(attempt), Ok(true)) {
4115                self.ports
4116                    .events
4117                    .emit(AttemptEvent::QuarantinedOwnedResource {
4118                        attempt: attempt.id,
4119                    });
4120                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4121                return Err(self.defer_environment_cleanup(attempt)?);
4122            }
4123            if matches!(
4124                current,
4125                EnvironmentState::Starting | EnvironmentState::Running
4126            ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
4127            {
4128                return Err(self.defer_environment_cleanup(attempt)?);
4129            }
4130            if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
4131                return Err(self.defer_environment_cleanup(attempt)?);
4132            }
4133            if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
4134                return Err(self.defer_environment_cleanup(attempt)?);
4135            }
4136        }
4137        if provider
4138            .enumerate_owned(self.host_id)
4139            .iter()
4140            .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
4141        {
4142            return Err(self.defer_environment_cleanup(attempt)?);
4143        }
4144        let outcome = attempt
4145            .outcome()
4146            .cloned()
4147            .ok_or(LifecycleError::Transition)?;
4148        // Provider diagnostics are a closed vocabulary and bounded before they
4149        // can enter tracing, even if an adapter returns an unbounded vector.
4150        for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
4151            tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
4152        }
4153        self.preserve_diagnostics(attempt, &outcome)?;
4154        self.scrub_workspace(attempt)?;
4155        self.ports
4156            .packages
4157            .release(attempt.id)
4158            .map_err(LifecycleError::Failed)?;
4159        attempt
4160            .clean_isolated(self.ports.clock.now())
4161            .map_err(|_| LifecycleError::Transition)?;
4162        self.record(attempt)?;
4163        self.ports.events.emit(AttemptEvent::Cleaned {
4164            attempt: attempt.id,
4165            outcome: OutcomeKind::of(&outcome),
4166        });
4167        self.ports
4168            .reconcile_events
4169            .emit(LifecycleEvent::AttemptCleaned {
4170                policy: attempt.policy_id,
4171                attempt: attempt.id,
4172                outcome: OutcomeKind::of(&outcome),
4173            });
4174        Ok(())
4175    }
4176
4177    fn defer_environment_cleanup(
4178        &self,
4179        attempt: &mut RunnerAttempt,
4180    ) -> Result<LifecycleError, LifecycleError> {
4181        attempt
4182            .defer_cleanup(self.ports.clock.now())
4183            .map_err(|_| LifecycleError::Transition)?;
4184        self.record(attempt)?;
4185        Ok(LifecycleError::EnvironmentCleanupDeferred)
4186    }
4187
4188    /// Undo an attempt's placement by the algorithm its journalled workspace
4189    /// kind makes legal (`02-target-architecture.md`, "Cleanup and recovery").
4190    ///
4191    /// The dispatch is on the *journal*, never on what the directory looks like
4192    /// now. A slot whose `_work` was replaced by a junction is still scrubbed as
4193    /// a slot rather than removed whole, and a disposable directory that happens
4194    /// to contain a `_work` still goes whole rather than being spared: the
4195    /// workspace kind is immutable precisely so that the shape of a directory a
4196    /// workflow can write to cannot choose the algorithm applied to it.
4197    fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4198        #[cfg(test)]
4199        {
4200            // The two contamination mutants `f1` drives the security gates with.
4201            // They sit in front of the dispatch rather than inside one arm so
4202            // that a skipped cleanup is equally observable in both modes.
4203            if matches!(
4204                std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
4205                Ok("skip_workspace_cleanup" | "reuse_job_workspace")
4206            ) {
4207                return Ok(());
4208            }
4209        }
4210        match attempt.workspace() {
4211            AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
4212                Ok(()) => Ok(()),
4213                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4214                Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
4215            },
4216            AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
4217        }
4218    }
4219
4220    /// Retain exactly `_work` and prove everything else is gone.
4221    ///
4222    /// The seven ordered checks of `04-security-recovery.md`, "Safe path
4223    /// handling": the journalled root and slot, a surviving policy's agreement,
4224    /// lexical and canonical containment, a literal enumeration, the one-entry
4225    /// allowlist, and a verification pass before the caller releases the package
4226    /// lease and marks the attempt cleaned. Any of them may refuse, and a
4227    /// refusal removes nothing after it.
4228    ///
4229    /// A slot directory that is already gone is not a refusal. There is nothing
4230    /// to scrub and nothing to prove absent, which is the same tolerance the
4231    /// disposable arm has always had for a runtime that vanished under it.
4232    fn scrub_persistent_slot(
4233        &self,
4234        attempt: &RunnerAttempt,
4235        slot: NonZeroU16,
4236    ) -> Result<(), LifecycleError> {
4237        // The policy is read from the journal, never inferred from the
4238        // directory tree, and its absence is legal: `Reconciler` cleans every
4239        // concluded attempt whether or not its policy still exists, and this
4240        // one's root is already journalled on the attempt itself.
4241        let configured = self
4242            .ports
4243            .store
4244            .policy(attempt.policy_id)
4245            .map_err(|_| LifecycleError::Journal)?
4246            .and_then(|policy| match policy.workspace_policy() {
4247                WorkspacePolicy::Persistent { root } => Some(root.clone()),
4248                WorkspacePolicy::Ephemeral => None,
4249            });
4250        let runtime = attempt.runtime_path();
4251        self.quarantine_on_refusal(
4252            attempt,
4253            verify_journalled_slot(runtime, slot, configured.as_ref())
4254                .and_then(|()| slot_is_present(runtime))
4255                .and_then(|present| {
4256                    if present {
4257                        scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
4258                    } else {
4259                        Ok(())
4260                    }
4261                }),
4262        )
4263    }
4264
4265    /// Turn a slot refusal into the error that keeps the attempt uncleaned.
4266    ///
4267    /// The warning is emitted here, at the one place a quarantine is minted, so
4268    /// that both routes out of cleanup carry it: the reconciler's terminal sweep,
4269    /// which receives the error through the launcher port, and
4270    /// [`Self::clean_or_quarantine`], which swallows it to keep the pass alive.
4271    /// Everything logged is either a path this product configured or a constant
4272    /// from this module — see [`SlotQuarantine`] for why that matters.
4273    fn quarantine_on_refusal(
4274        &self,
4275        attempt: &RunnerAttempt,
4276        outcome: Result<(), SlotQuarantine>,
4277    ) -> Result<(), LifecycleError> {
4278        let Err(quarantine) = outcome else {
4279            return Ok(());
4280        };
4281        let detail = quarantine.to_string();
4282        tracing::warn!(
4283            attempt = %attempt.id,
4284            policy = %attempt.policy_id,
4285            slot = attempt.workspace().slot_number(),
4286            refusal = quarantine.refusal.class(),
4287            "{detail}"
4288        );
4289        Err(LifecycleError::SlotQuarantined {
4290            class: quarantine.refusal.class(),
4291            detail,
4292        })
4293    }
4294
4295    fn preserve_diagnostics(
4296        &self,
4297        attempt: &RunnerAttempt,
4298        outcome: &AttemptOutcome,
4299    ) -> Result<(), LifecycleError> {
4300        fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
4301            LifecycleError::Failed(FailureReason::Other(
4302                "diagnostics directory could not be created".into(),
4303            ))
4304        })?;
4305        // Intentionally constructed from typed local facts, not runner output.
4306        // Raw child output can contain workflow secrets and is never copied.
4307        let reason_kind = match outcome {
4308            AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4309            _ => "none",
4310        };
4311        let diagnostic = format!(
4312            "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4313            attempt.id,
4314            attempt.policy_id,
4315            OutcomeKind::of(outcome).as_str(),
4316            reason_kind,
4317        );
4318        fs::write(
4319            self.diagnostics_root.join(format!("{}.log", attempt.id)),
4320            diagnostic,
4321        )
4322        .map_err(|_| {
4323            LifecycleError::Failed(FailureReason::Other(
4324                "redacted diagnostics could not be preserved".into(),
4325            ))
4326        })
4327    }
4328
4329    async fn materialize_with_retry(
4330        &self,
4331        policy: &ScalePolicy,
4332        attempt: &RunnerAttempt,
4333        timings: &mut PackageTimings,
4334    ) -> Result<RunnerVersion, FailureReason> {
4335        let mut issued = 0_u32;
4336        loop {
4337            issued = issued.saturating_add(1);
4338            match self.ports.packages.materialize(attempt, timings).await {
4339                Ok(version) => return Ok(version),
4340                Err(reason)
4341                    if package_failure_is_terminal(&reason)
4342                        || issued >= self.retry.max_attempts.max(1) =>
4343                {
4344                    return Err(reason);
4345                }
4346                Err(reason) => {
4347                    if !self.ports.demand.persists(policy.id).await {
4348                        return Err(reason);
4349                    }
4350                    let delay = self.retry.delay(issued);
4351                    self.ports.events.emit(AttemptEvent::Retry {
4352                        attempt: attempt.id,
4353                        operation: "package_materialization",
4354                        delay,
4355                    });
4356                    self.ports.delay.wait(delay).await;
4357                    if !self.ports.demand.persists(policy.id).await {
4358                        return Err(reason);
4359                    }
4360                }
4361            }
4362        }
4363    }
4364
4365    async fn register_with_retry(
4366        &self,
4367        policy: &ScalePolicy,
4368        attempt: AttemptId,
4369        request: &JitRunnerRequest,
4370    ) -> Result<JitRegistration, LifecycleError> {
4371        let mut issued = 0_u32;
4372        loop {
4373            issued = issued.saturating_add(1);
4374            match self
4375                .ports
4376                .github
4377                .register(&policy.target, request, &self.cancel)
4378                .await
4379            {
4380                Ok(registration) => return Ok(registration),
4381                Err(error) if error.terminal => {
4382                    return Err(LifecycleError::Failed(error.reason));
4383                }
4384                Err(error) => {
4385                    if issued >= self.retry.max_attempts.max(1)
4386                        || !self.ports.demand.persists(policy.id).await
4387                    {
4388                        return Err(LifecycleError::Failed(error.reason));
4389                    }
4390                    let delay = error
4391                        .retry_after
4392                        .unwrap_or_else(|| self.retry.delay(issued));
4393                    self.ports.events.emit(AttemptEvent::Retry {
4394                        attempt,
4395                        operation: "jit_request",
4396                        delay,
4397                    });
4398                    self.ports.delay.wait(delay).await;
4399                    if !self.ports.demand.persists(policy.id).await {
4400                        return Err(LifecycleError::Failed(error.reason));
4401                    }
4402                }
4403            }
4404        }
4405    }
4406
4407    /// Where one attempt's files go, decided while the host allocation lock is
4408    /// held and before anything external happens.
4409    ///
4410    /// The branch is on the *repository's configured* workspace policy, so an
4411    /// organization policy and an ephemeral repository never reach slot
4412    /// selection at all: a persistent policy is unrepresentable for an
4413    /// organization target (D7, refused by `WorkspacePolicy::permitted_for` in
4414    /// both the constructor and the loader), and an ephemeral repository takes
4415    /// the disposable arm that existed before slots did.
4416    fn allocate_workspace(
4417        &self,
4418        policy: &ScalePolicy,
4419        id: AttemptId,
4420    ) -> Result<Placement, LifecycleError> {
4421        let placement = match policy.workspace_policy() {
4422            // Precedence (`02-target-architecture.md`): the repository's
4423            // persistent root is selected *before* the host root, which is why
4424            // this arm is first and why it never makes resolving the host
4425            // default a precondition of its own success.
4426            WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4427            WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4428        };
4429        // Here rather than inside the two arms, so that every path a root can
4430        // accept clears the record and none can be forgotten.
4431        if placement.is_ok() {
4432            self.root_accepted(policy.id);
4433        }
4434        placement
4435    }
4436
4437    /// `Host.runner_root_override`, read from the journal.
4438    ///
4439    /// Separated from [`Self::effective_host_root`] so that the two failures it
4440    /// folds together stay apart: an unreadable or missing host row is a journal
4441    /// problem and is always fatal, while an unresolvable *platform default* is
4442    /// only fatal to a placement that actually needs the host root.
4443    fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4444        let host = self
4445            .ports
4446            .store
4447            .host(self.host_id)
4448            .map_err(|_| LifecycleError::Journal)?
4449            .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4450        Ok(host.runner_root_override.clone())
4451    }
4452
4453    /// `Host.runner_root_override`, or the platform default when that resolves;
4454    /// `None` when neither does. Unlike [`Self::effective_host_root`] it records
4455    /// no refusal, for callers to whom an unresolvable root is not a failure.
4456    fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4457        Ok(self
4458            .configured_host_root()?
4459            .or_else(|| default_runner_root(&self.app_paths).ok()))
4460    }
4461
4462    /// `Host.runner_root_override`, or the platform default standing in for it.
4463    ///
4464    /// Takes the policy because an unresolvable default is a refusal like any
4465    /// other, and the record it leaves is that policy's.
4466    fn effective_host_root(
4467        &self,
4468        policy: &ScalePolicy,
4469    ) -> Result<LocalAbsolutePath, LifecycleError> {
4470        match self.configured_host_root()? {
4471            Some(configured) => Ok(configured),
4472            None => default_runner_root(&self.app_paths).map_err(|error| {
4473                // No root resolved, so there is no path to name but the one the
4474                // platform would have produced.
4475                self.root_refused(policy.id, "the platform default runner root", error)
4476            }),
4477        }
4478    }
4479
4480    /// Turns a runner-root refusal into a failure, and leaves the sentence
4481    /// somewhere an operator can read it.
4482    ///
4483    /// Per policy, because the policies on a host do not share a fate: one with
4484    /// its own persistent root places runners while another on a withheld
4485    /// volume places none, and a host-wide record would have the first clear
4486    /// the second's on the same pass.
4487    ///
4488    /// The recording is best-effort and its failure is deliberately swallowed:
4489    /// this runs on the path that is already failing, and a host that cannot
4490    /// write a diagnostic file must still report the refusal it came to report.
4491    /// `service status` says so itself when the file is unreadable.
4492    fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4493        let _ = runner_manager_platform::service::record_runner_root_refusal(
4494            &self.app_paths,
4495            &policy.to_string(),
4496            self.ports.clock.now(),
4497            error.kind(),
4498            root,
4499            &error.to_string(),
4500        );
4501        root_failure(error)
4502    }
4503
4504    /// Clears that policy's record, because its root accepted a placement.
4505    ///
4506    /// Called on every successful placement rather than only after a failure:
4507    /// the daemon that recovers is often not the process that failed -- a
4508    /// self-update restarts it -- so "clear it if we wrote it" would leave a
4509    /// stale note on `service status` for as long as the host ran.
4510    fn root_accepted(&self, policy: PolicyId) {
4511        let _ = runner_manager_platform::service::clear_runner_root_refusal(
4512            &self.app_paths,
4513            &policy.to_string(),
4514        );
4515    }
4516
4517    /// D3's disposable placement: a unique child of the effective host root,
4518    /// removed whole on cleanup. `c1`'s behaviour, moved behind the branch.
4519    fn allocate_disposable(
4520        &self,
4521        policy: &ScalePolicy,
4522        id: AttemptId,
4523    ) -> Result<Placement, LifecycleError> {
4524        let effective_root = self.effective_host_root(policy)?;
4525        RootPreflight::new(&self.app_paths)
4526            .check(&RootOwner::Host, &effective_root)
4527            .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4528        let runtime = effective_root.as_path().join({
4529            #[cfg(test)]
4530            {
4531                if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4532                    == Ok("reuse_job_workspace")
4533                {
4534                    "mutant-shared-workspace".to_owned()
4535                } else {
4536                    workspace_name(id)
4537                }
4538            }
4539            #[cfg(not(test))]
4540            {
4541                workspace_name(id)
4542            }
4543        });
4544        fs::create_dir_all(&runtime)
4545            .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4546        Ok(Placement {
4547            runtime,
4548            workspace: AttemptWorkspace::Ephemeral,
4549        })
4550    }
4551
4552    /// D4/D5's persistent placement: the lowest free `sN` under the repository's
4553    /// configured root.
4554    ///
4555    /// Steps 1 to 6 of `02-target-architecture.md`, "Slot allocation", in order;
4556    /// step 7 is the journal write [`Self::record_allocation`] owns. All of them
4557    /// run under the host allocation lock, because [`Self::reserve_attempt`] is
4558    /// reachable only through a `LaunchRequest` and that carries the guard.
4559    ///
4560    /// **The filesystem is never consulted to decide which slots are taken**
4561    /// (invariant 6). The leases come from the journal; the directory is
4562    /// inspected only to decide whether *this* slot is safe to reuse.
4563    fn allocate_persistent_slot(
4564        &self,
4565        policy: &ScalePolicy,
4566        root: &LocalAbsolutePath,
4567    ) -> Result<Placement, LifecycleError> {
4568        // 1-4. The lowest positive slot no uncleaned attempt holds, refused
4569        // above the policy ceiling.
4570        let ceiling = policy.max_capacity().ok_or_else(|| {
4571            LifecycleError::Failed(FailureReason::Other(
4572                "a persistent workspace needs the policy's max_capacity to bound its slots"
4573                    .to_string(),
4574            ))
4575        })?;
4576        let leases = self
4577            .ports
4578            .store
4579            .slot_leases_for_policy(policy.id)
4580            .map_err(|_| LifecycleError::Journal)?;
4581        let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4582            LifecycleError::Failed(FailureReason::Other(format!(
4583                "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4584                 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4585                 finish cleaning a concluded attempt",
4586                policy.target
4587            )))
4588        })?;
4589        let workspace = AttemptWorkspace::persistent_slot(slot);
4590        let name = workspace
4591            .slot_directory_name()
4592            .expect("a persistent allocation names its slot directory");
4593
4594        // The operational preflight, for the reasons the host root gets one: a
4595        // root that is remote, unwritable, or overlapping application data has
4596        // to fail before a directory is created rather than after. The host
4597        // root is registered only as something *not* to overlap; a host default
4598        // that cannot be resolved is a host-root problem and does not block a
4599        // repository that configured a root of its own.
4600        //
4601        // Only *that* failure is tolerated. An unreadable host row is a journal
4602        // failure and propagates, because silently continuing would drop the
4603        // overlap check entirely and accept a repository root that sits inside
4604        // the host root — the pair `RootPreflight` exists to refuse.
4605        let host_root = self.resolvable_host_root()?;
4606        let mut preflight = RootPreflight::new(&self.app_paths);
4607        if let Some(host_root) = host_root {
4608            preflight = preflight.against(RootOwner::Host, host_root);
4609        }
4610        let checked = preflight
4611            .check(&RootOwner::Repository(policy.target.to_string()), root)
4612            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4613        if let Some(leaf) = checked.leaf_to_create() {
4614            fs::create_dir(leaf).map_err(|source| {
4615                LifecycleError::Failed(FailureReason::Other(format!(
4616                    "the persistent workspace root {} could not be created: {source}",
4617                    leaf.display()
4618                )))
4619            })?;
4620        }
4621
4622        // 5-6. `<root>/sN`, contained lexically by construction, then created or
4623        // validated, then contained canonically now that it resolves, and only
4624        // then accepted for reuse.
4625        let slot_path = runner_root::derive_child(root, &name)
4626            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4627        create_or_validate_slot(slot_path.as_path())?;
4628        runner_root::verify_containment(root, &slot_path)
4629            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4630        accept_reusable_slot(slot_path.as_path())?;
4631        Ok(Placement {
4632            runtime: slot_path.as_path().to_path_buf(),
4633            workspace,
4634        })
4635    }
4636
4637    /// The first journal write of an attempt, where a duplicate slot lease is
4638    /// still possible and has to be reported as itself.
4639    ///
4640    /// [`Self::record`] flattens every store failure into
4641    /// [`LifecycleError::Journal`], which is right for a state transition and
4642    /// wrong here: the partial unique index
4643    /// `one_uncleaned_persistent_attempt_per_slot` is the final race fence
4644    /// (`04-security-recovery.md`, "two attempts use one slot concurrently"),
4645    /// and an operator who reaches it needs to read that rather than "attempt
4646    /// journal operation failed". Nothing was written, so the caller returns
4647    /// without concluding an attempt that is not in the journal.
4648    fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4649        match self.ports.store.record_attempt(attempt) {
4650            Ok(()) => {
4651                self.ports.events.emit(AttemptEvent::State {
4652                    attempt: attempt.id,
4653                    state: attempt.state(),
4654                });
4655                Ok(())
4656            }
4657            Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4658                FailureReason::Other(error.to_string()),
4659            )),
4660            Err(_) => Err(LifecycleError::Journal),
4661        }
4662    }
4663
4664    /// Under the allocation lock: what no other launch may decide at the same
4665    /// time. The rest is [`Self::finish_attempt`], outside it.
4666    fn reserve_attempt(
4667        &self,
4668        policy: &ScalePolicy,
4669        allocation_guard: &AllocationGuard,
4670    ) -> Result<RunnerAttempt, LifecycleError> {
4671        // What a prune leaves costs only disk, and a tree it cannot remove is one
4672        // the launch's install may yet repair: not a reason to refuse the launch.
4673        if let Err(error) = self.prune_under_allocation_lock(allocation_guard) {
4674            tracing::warn!(
4675                event = "package_prune_failed",
4676                reason = failure_reason_kind(&error.reason()),
4677            );
4678        }
4679        self.allocate_attempt(policy)
4680    }
4681
4682    async fn finish_attempt(
4683        &self,
4684        policy: &ScalePolicy,
4685        attempt: RunnerAttempt,
4686        lock_wait: Duration,
4687    ) -> Result<RunnerAttempt, LifecycleError> {
4688        let id = attempt.id;
4689        let mut stopwatch = LaunchStopwatch::new(lock_wait);
4690        let result = self.launch_steps(policy, attempt, &mut stopwatch).await;
4691        stopwatch.finish();
4692        self.ports.events.emit(AttemptEvent::Launched {
4693            attempt: id,
4694            started: result.is_ok(),
4695            timings: stopwatch.timings,
4696        });
4697        result
4698    }
4699
4700    /// Place and journal a new attempt: everything before its first external
4701    /// effect, and so before there is anything to time.
4702    fn allocate_attempt(&self, policy: &ScalePolicy) -> Result<RunnerAttempt, LifecycleError> {
4703        if !*self
4704            .recovery_complete
4705            .lock()
4706            .map_err(|_| LifecycleError::Journal)?
4707        {
4708            return Err(LifecycleError::RecoveryIncomplete);
4709        }
4710        let capability = self.ports.processes.probe(policy);
4711        if capability != ProviderCapability::Ready {
4712            return Err(LifecycleError::Failed(capability.refusal()));
4713        }
4714        let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4715            LifecycleError::Failed(if policy.execution_policy().is_native() {
4716                reason
4717            } else {
4718                safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4719            })
4720        })?;
4721        if policy.execution_policy().is_native() != resolved.is_none() {
4722            return Err(LifecycleError::Failed(FailureReason::Other(
4723                "provider resolution mismatch".into(),
4724            )));
4725        }
4726        if let Some(resolution) = &resolved {
4727            if resolution.provider_kind == Backend::Auto {
4728                return Err(LifecycleError::Failed(FailureReason::Other(
4729                    "provider resolution mismatch".into(),
4730                )));
4731            }
4732            AttemptExecution::Isolated {
4733                provider_kind: resolution.provider_kind,
4734                environment_id: None,
4735                resolved_image: resolution.image.clone(),
4736                generation: "generation-check".into(),
4737            }
4738            .validate()
4739            .map_err(|_| {
4740                LifecycleError::Failed(FailureReason::Other(
4741                    "resolved image is incompatible".into(),
4742                ))
4743            })?;
4744        }
4745        policy
4746            .routing_labels()
4747            .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4748        let id = AttemptId::new_random();
4749        let placement = self.allocate_workspace(policy, id)?;
4750        let mut attempt = RunnerAttempt::allocate_in(
4751            id,
4752            policy.id,
4753            placement.runtime,
4754            placement.workspace,
4755            self.ports.clock.now(),
4756        );
4757        if let Some(resolution) = &resolved {
4758            attempt
4759                .allocate_execution(AttemptExecution::Isolated {
4760                    provider_kind: resolution.provider_kind,
4761                    environment_id: None,
4762                    resolved_image: resolution.image.clone(),
4763                    generation: uuid::Uuid::new_v4().to_string(),
4764                })
4765                .map_err(|_| LifecycleError::Transition)?;
4766        }
4767        // This is deliberately the first effect after directory allocation, and
4768        // for a persistent attempt it is also what makes the slot lease durable
4769        // before any package or GitHub effect
4770        // (`02-target-architecture.md`, "Slot allocation", step 7).
4771        self.record_allocation(&attempt)?;
4772        Ok(attempt)
4773    }
4774
4775    async fn launch_steps(
4776        &self,
4777        policy: &ScalePolicy,
4778        mut attempt: RunnerAttempt,
4779        stopwatch: &mut LaunchStopwatch,
4780    ) -> Result<RunnerAttempt, LifecycleError> {
4781        let id = attempt.id;
4782        let isolated = !attempt.execution().is_native();
4783        let Some(labels) = policy.routing_labels() else {
4784            return self.fail_launch(&mut attempt, FailureReason::JitRequestFailed);
4785        };
4786        stopwatch.begin(LaunchStep::Turn);
4787        // Never closed; held through the preparation below.
4788        let turn = self.turns.acquire().await.ok();
4789        stopwatch.begin(LaunchStep::Materialize);
4790        let package_timings = &mut stopwatch.timings.package;
4791        let version = match self
4792            .materialize_with_retry(policy, &attempt, package_timings)
4793            .await
4794        {
4795            Ok(version) => version,
4796            Err(reason) => return self.fail_launch(&mut attempt, reason),
4797        };
4798        self.versions
4799            .lock()
4800            .map_err(|_| LifecycleError::Journal)?
4801            .insert(id, version);
4802
4803        if isolated {
4804            attempt
4805                .begin_prepare(self.ports.clock.now())
4806                .map_err(|_| LifecycleError::Transition)?;
4807            self.record(&attempt)?;
4808        }
4809        stopwatch.begin(LaunchStep::Prepare);
4810        // Off the async thread: a provider may pull an image, and every target
4811        // of this host shares that thread.
4812        let preparing = {
4813            let (processes, attempt, policy) = (
4814                Arc::clone(&self.ports.processes),
4815                attempt.clone(),
4816                policy.clone(),
4817            );
4818            tokio::task::spawn_blocking(move || processes.prepare(&attempt, &policy)).await
4819        };
4820        drop(turn);
4821        let prepared = match preparing.unwrap_or(Err(FailureReason::ProcessStartFailed)) {
4822            Ok(prepared) => prepared,
4823            Err(reason) => {
4824                let safe_reason = if isolated {
4825                    safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4826                } else {
4827                    reason
4828                };
4829                return self.fail_launch(&mut attempt, safe_reason);
4830            }
4831        };
4832        if isolated {
4833            let Some(identity) = prepared.identity() else {
4834                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4835                    IsolationProviderFailure::OwnershipMismatch,
4836                )));
4837            };
4838            if !identity_matches_intent(self.host_id, &attempt, identity) {
4839                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4840                    IsolationProviderFailure::OwnershipMismatch,
4841                )));
4842            }
4843            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
4844                unreachable!()
4845            };
4846            attempt
4847                .prepared_environment(environment_id.clone())
4848                .map_err(|_| LifecycleError::Transition)?;
4849            attempt
4850                .mark_prepared(self.ports.clock.now())
4851                .map_err(|_| LifecycleError::Transition)?;
4852            self.record(&attempt)?;
4853        }
4854
4855        stopwatch.begin(LaunchStep::Register);
4856        let jit_request =
4857            JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
4858        let registration = match self.register_with_retry(policy, id, &jit_request).await {
4859            Ok(registration) => registration,
4860            Err(error) => return self.fail_launch(&mut attempt, error.reason()),
4861        };
4862        stopwatch.begin(LaunchStep::Spawn);
4863        let runner_id = registration.runner().id;
4864        write_runner_id(attempt.runtime_path(), runner_id)?;
4865        attempt
4866            .jit_received(self.ports.clock.now())
4867            .map_err(|_| LifecycleError::Transition)?;
4868        self.record(&attempt)?;
4869        let config = registration.into_config();
4870        if isolated {
4871            let started = self
4872                .ports
4873                .processes
4874                .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
4875                .map_err(|failure| {
4876                    LifecycleError::Failed(safe_isolation_reason(
4877                        failure.reason,
4878                        IsolationProviderFailure::RuntimeOperationFailed,
4879                    ))
4880                })?;
4881            if !identity_matches_intent(self.host_id, &attempt, &started) {
4882                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4883                    IsolationProviderFailure::OwnershipMismatch,
4884                )));
4885            }
4886            attempt
4887                .started_isolated(self.ports.clock.now())
4888                .map_err(|_| LifecycleError::Transition)?;
4889            self.record(&attempt)?;
4890            return Ok(attempt);
4891        }
4892        let mut issued = 0_u32;
4893        let pid = loop {
4894            issued = issued.saturating_add(1);
4895            match self.ports.processes.start(
4896                prepared.clone(),
4897                &attempt,
4898                OneTimeJitHandoff::new(&config),
4899            ) {
4900                Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
4901                Ok(EnvironmentIdentity::Isolated { .. }) => {
4902                    // No isolated launch sequence is wired in this task. Keep
4903                    // the nonterminal JIT journal as a capacity fence rather
4904                    // than treating an unexpected resource as a native PID.
4905                    return Err(LifecycleError::Failed(FailureReason::Other(
4906                        "unexpected isolated environment identity".into(),
4907                    )));
4908                }
4909                Err(error) => {
4910                    if let Some(pid) = error.live_pid {
4911                        attempt
4912                            .started(pid, self.ports.clock.now())
4913                            .map_err(|_| LifecycleError::Transition)?;
4914                        self.record(&attempt)?;
4915                        return Err(LifecycleError::Failed(error.reason));
4916                    }
4917                    if !error.retryable
4918                        || issued >= self.retry.max_attempts.max(1)
4919                        || !self.ports.demand.persists(policy.id).await
4920                    {
4921                        return self.fail_launch(&mut attempt, error.reason);
4922                    }
4923                    let delay = self.retry.delay(issued);
4924                    self.ports.events.emit(AttemptEvent::Retry {
4925                        attempt: attempt.id,
4926                        operation: "process_start",
4927                        delay,
4928                    });
4929                    self.ports.delay.wait(delay).await;
4930                    if !self.ports.demand.persists(policy.id).await {
4931                        return self.fail_launch(&mut attempt, error.reason);
4932                    }
4933                }
4934            }
4935        };
4936        attempt
4937            .started(pid, self.ports.clock.now())
4938            .map_err(|_| LifecycleError::Transition)?;
4939        self.record(&attempt)?;
4940        Ok(attempt)
4941    }
4942
4943    fn fail_launch<T>(
4944        &self,
4945        attempt: &mut RunnerAttempt,
4946        reason: FailureReason,
4947    ) -> Result<T, LifecycleError> {
4948        self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
4949        Err(LifecycleError::Failed(reason))
4950    }
4951
4952    /// e2's prune guard is invoked only with e1's allocation guard borrowed.
4953    /// The otherwise-unused argument is a compile-time witness of the ordering.
4954    fn prune_under_allocation_lock(&self, guard: &AllocationGuard) -> Result<(), LifecycleError> {
4955        let attempts = self
4956            .ports
4957            .store
4958            .attempts()
4959            .map_err(|_| LifecycleError::Journal)?;
4960        self.ports
4961            .packages
4962            .prune_obsolete_guarded(PruneAuthority::from_launch_request(guard), &attempts)
4963            .map_err(LifecycleError::Failed)?;
4964        #[cfg(target_os = "macos")]
4965        self.prune_abandoned_seeds(&attempts);
4966        Ok(())
4967    }
4968
4969    /// Remove the package seed from every runner root this host once placed a
4970    /// runtime in and no longer does: a changed host root override, a removed
4971    /// persistent policy, or one that moved its workspace.
4972    ///
4973    /// The roots ever used are the parents of the journal's runtime paths; the
4974    /// roots in use are the effective host root and every persistent policy's
4975    /// root. When the effective host root cannot be resolved nothing is
4976    /// removed, because a seed still in use cannot then be told apart. No
4977    /// launch clones from a seed under a root no policy uses, and it is
4978    /// best-effort: a seed left behind costs disk, never a launch.
4979    #[cfg(target_os = "macos")]
4980    fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
4981        let Ok(policies) = self.ports.store.policies() else {
4982            return;
4983        };
4984        let Ok(Some(host_root)) = self.resolvable_host_root() else {
4985            return;
4986        };
4987        let in_use: BTreeSet<PathBuf> = policies
4988            .iter()
4989            .filter_map(|policy| match policy.workspace_policy() {
4990                WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
4991                WorkspacePolicy::Ephemeral => None,
4992            })
4993            .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
4994            .collect();
4995        // Distinct parents first: the journal keeps every attempt ever made.
4996        let used: BTreeSet<&Path> = attempts
4997            .iter()
4998            .filter_map(|attempt| attempt.runtime_path().parent())
4999            .collect();
5000        for root in used.into_iter().map(canonical_or_raw) {
5001            if !in_use.contains(&root) {
5002                let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
5003            }
5004        }
5005    }
5006}
5007
5008#[cfg(target_os = "macos")]
5009fn canonical_or_raw(path: &Path) -> PathBuf {
5010    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
5011}
5012
5013#[async_trait]
5014impl RunnerLauncher for LifecycleLauncher {
5015    async fn host_capacity(
5016        &self,
5017        host: &runner_manager_domain::model::Host,
5018    ) -> Result<std::num::NonZeroU16, crate::reconcile::HostCapacityError> {
5019        self.ports
5020            .store
5021            .host(host.id)
5022            .map_err(|_| crate::reconcile::HostCapacityError::JournalUnreadable)?
5023            .map(|current| current.host_capacity)
5024            .ok_or(crate::reconcile::HostCapacityError::MissingHost)
5025    }
5026
5027    async fn supervise(
5028        &self,
5029        policy: &ScalePolicy,
5030    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
5031        LifecycleLauncher::supervise(self, policy)
5032            .await
5033            .map_err(|error| LaunchFailure::new(error.reason()))
5034    }
5035
5036    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
5037        self.ports.store.attempts().map_err(|_| {
5038            LaunchFailure::new(FailureReason::Other(
5039                "attempt journal could not be read".into(),
5040            ))
5041        })
5042    }
5043
5044    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
5045        self.reserve_attempt(request.policy, request.allocation_guard)
5046            .map_err(|error| LaunchFailure::new(error.reason()))
5047    }
5048
5049    async fn finish_launch(
5050        &self,
5051        policy: &ScalePolicy,
5052        attempt: RunnerAttempt,
5053        lock_wait: Duration,
5054    ) -> Result<RunnerAttempt, LaunchFailure> {
5055        self.finish_attempt(policy, attempt, lock_wait)
5056            .await
5057            .map_err(|error| LaunchFailure::new(error.reason()))
5058    }
5059
5060    async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
5061        let mut attempt = self
5062            .ports
5063            .store
5064            .attempt(id)
5065            .map_err(|_| {
5066                LaunchFailure::new(FailureReason::Other(
5067                    "attempt journal could not be read".into(),
5068                ))
5069            })?
5070            .ok_or_else(|| {
5071                LaunchFailure::new(FailureReason::Other(
5072                    "attempt disappeared from the journal".into(),
5073                ))
5074            })?;
5075        self.clean_attempt(&mut attempt)
5076            .map_err(|error| LaunchFailure::new(error.reason()))?;
5077        self.cleanup_retries().succeeded(id);
5078        Ok(())
5079    }
5080}
5081
5082/// The name an attempt's runner registers with GitHub, which a
5083/// `workflow_job` webhook's `runner_name` repeats.
5084#[must_use]
5085pub fn runner_name(attempt: AttemptId) -> String {
5086    format!("runner-manager-{attempt}")
5087}
5088
5089/// How long reading a runner's diagnostics may take.
5090const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
5091
5092/// What a native runner's own diagnostics say about whether it ran a job.
5093///
5094/// `Runner.Listener` writes `_diag/Runner_*.log` when it starts, and starts
5095/// `Runner.Worker` -- which writes `_diag/Worker_*.log` -- only for a job it has
5096/// acquired. So a worker log is proof a job ran, and a listener log without one
5097/// is proof none did. With neither, the directory is not where the listener
5098/// wrote, and nothing is concluded from it. `_diag` is scrubbed with the rest of
5099/// the runtime after every attempt, persistent slots included, so a log found
5100/// here belongs to this attempt.
5101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5102enum JobEvidence {
5103    RanAJob,
5104    NoJob,
5105    Unknown,
5106}
5107
5108impl JobEvidence {
5109    fn of(runtime: &Path) -> Self {
5110        let diag = runtime.join("_diag");
5111        // Bounded: the daemon's loop runs on one thread, and a runtime on a
5112        // volume that does not answer (a hung disk, a pending macOS question)
5113        // must not stall it for longer than this.
5114        if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
5115            != runner_manager_platform::host_fitness::Responsiveness::Responds
5116        {
5117            return Self::Unknown;
5118        }
5119        let Ok(entries) = fs::read_dir(diag) else {
5120            return Self::Unknown;
5121        };
5122        let mut listener = false;
5123        for entry in entries.flatten() {
5124            let name = entry.file_name();
5125            let name = name.to_string_lossy();
5126            if !name.ends_with(".log") {
5127                continue;
5128            }
5129            if name.starts_with("Worker_") {
5130                return Self::RanAJob;
5131            }
5132            listener |= name.starts_with("Runner_");
5133        }
5134        if listener { Self::NoJob } else { Self::Unknown }
5135    }
5136}
5137
5138/// The attempt's GitHub runner id: journalled, observed, or from the sidecar
5139/// written at registration.
5140fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
5141    attempt
5142        .github_runner_id()
5143        .or(observed)
5144        .or_else(|| read_runner_id(attempt.runtime_path()))
5145}
5146
5147fn read_runner_id(runtime: &Path) -> Option<u64> {
5148    fs::read_to_string(runtime.join(RUNNER_ID_FILE))
5149        .ok()?
5150        .trim()
5151        .parse()
5152        .ok()
5153}
5154
5155fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
5156    let target = runtime.join(RUNNER_ID_FILE);
5157    if let Some(existing) = read_runner_id(runtime) {
5158        return (existing == runner_id)
5159            .then_some(())
5160            .ok_or(LifecycleError::Journal);
5161    }
5162    let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
5163    write_durable_file(&temporary, runner_id.to_string().as_bytes())
5164        .map_err(|_| LifecycleError::Journal)?;
5165    match fs::rename(&temporary, &target) {
5166        Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
5167        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
5168            let _ = fs::remove_file(&temporary);
5169            (read_runner_id(runtime) == Some(runner_id))
5170                .then_some(())
5171                .ok_or(LifecycleError::Journal)
5172        }
5173        Err(_) => {
5174            let _ = fs::remove_file(&temporary);
5175            Err(LifecycleError::Journal)
5176        }
5177    }
5178}
5179
5180fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
5181    let mut file = fs::OpenOptions::new()
5182        .create(true)
5183        .truncate(true)
5184        .write(true)
5185        .open(path)?;
5186    file.write_all(bytes)?;
5187    file.sync_all()?;
5188    let parent = path.parent().ok_or_else(|| {
5189        std::io::Error::new(
5190            std::io::ErrorKind::InvalidInput,
5191            "file has no parent directory",
5192        )
5193    })?;
5194    sync_directory(parent)
5195}
5196
5197#[cfg(unix)]
5198fn sync_directory(path: &Path) -> std::io::Result<()> {
5199    fs::File::open(path)?.sync_all()
5200}
5201
5202#[cfg(windows)]
5203fn sync_directory(path: &Path) -> std::io::Result<()> {
5204    use std::os::windows::fs::OpenOptionsExt;
5205
5206    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
5207    const FILE_SHARE_ALL: u32 = 0x0000_0007;
5208    const GENERIC_WRITE: u32 = 0x4000_0000;
5209    fs::OpenOptions::new()
5210        .access_mode(GENERIC_WRITE)
5211        .share_mode(FILE_SHARE_ALL)
5212        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
5213        .open(path)?
5214        .sync_all()
5215}
5216
5217#[cfg(test)]
5218mod tests {
5219    use super::*;
5220    use std::collections::BTreeSet;
5221    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5222
5223    use crate::reconcile::{AllocationLock, InProcessAllocationLock};
5224    use runner_manager_domain::model::{Elapsed, TargetScope};
5225    use runner_manager_domain::store::SqliteStore;
5226    use runner_manager_github::jit::JitRunner;
5227    use runner_manager_testkit::clock::FakeClock;
5228    use runner_manager_testkit::fixtures;
5229
5230    #[test]
5231    fn production_provider_set_routes_every_implemented_isolated_backend() {
5232        let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
5233        assert!(providers.isolated_provider(Backend::Auto).is_some());
5234        assert!(providers.isolated_provider(Backend::Oci).is_some());
5235        assert!(
5236            providers
5237                .isolated_provider(Backend::WindowsHyperVContainer)
5238                .is_some()
5239        );
5240        assert!(
5241            providers
5242                .isolated_provider(Backend::VirtualMachine)
5243                .is_none()
5244        );
5245    }
5246
5247    /// One event's fields, in the order they were recorded.
5248    type CapturedFields = Vec<(String, String)>;
5249
5250    /// Keeps the `(name, value)` fields of every `tracing` event emitted while
5251    /// it is installed.
5252    ///
5253    /// The names are kept and not just the rendered line, so a test can hold the
5254    /// event to `crate::logging`'s two rules -- the field allow-list and the
5255    /// value scrub -- instead of asserting that some string was passed to a
5256    /// macro.
5257    #[derive(Clone, Default)]
5258    struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
5259
5260    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
5261        fn on_event(
5262            &self,
5263            event: &tracing::Event<'_>,
5264            _context: tracing_subscriber::layer::Context<'_, S>,
5265        ) {
5266            struct Collect(Vec<(String, String)>);
5267            impl tracing::field::Visit for Collect {
5268                fn record_debug(
5269                    &mut self,
5270                    field: &tracing::field::Field,
5271                    value: &dyn std::fmt::Debug,
5272                ) {
5273                    // `{value:?}` on a `&str` field would keep the quotes, and
5274                    // the redaction rules are about the value, not its literal.
5275                    self.0.push((
5276                        field.name().to_owned(),
5277                        format!("{value:?}").trim_matches('"').to_owned(),
5278                    ));
5279                }
5280            }
5281            let mut collected = Collect(Vec::new());
5282            event.record(&mut collected);
5283            self.0
5284                .lock()
5285                .expect("the capture mutex is not poisoned")
5286                .push(collected.0);
5287        }
5288    }
5289
5290    /// The regression behind a three-hour outage that showed nothing an operator
5291    /// could act on.
5292    ///
5293    /// A root the daemon cannot use refuses the launch *before*
5294    /// `record_allocation`, so no attempt row is ever written: `b2` has nothing
5295    /// to carry the failure on and `g2` has nothing to show it from. The
5296    /// lifecycle event keeps only the variant -- `failure_reason_kind` allows no
5297    /// free text on an event -- so the whole failure read
5298    /// `runner_start_failed reason=other`, once per poll, for hours.
5299    ///
5300    /// The assertion is deliberately made against the **production redaction
5301    /// rules** and not merely against what was emitted. An earlier attempt at
5302    /// this fix logged the rendered error on a `detail` field and passed a test
5303    /// exactly like this one, while shipping `detail="[redacted]"`: the field
5304    /// was not allow-listed, and had it been, the value is mostly paths and
5305    /// would have been scrubbed to `[path]`. A test that does not ask
5306    /// `crate::logging` what survives is a test that proves nothing.
5307    #[test]
5308    fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
5309        use runner_manager_platform::logging;
5310        use tracing_subscriber::layer::SubscriberExt as _;
5311
5312        let captured = CapturedEvents::default();
5313        let error = RunnerRootError::DeniedByPrivacyPolicy {
5314            requested: PathBuf::from("/Volumes/NVME/runners"),
5315            refused: PathBuf::from("/Volumes/NVME"),
5316            remediation: RootOwner::Host.remediation(),
5317        };
5318        let kind = error.kind();
5319
5320        let failure = tracing::subscriber::with_default(
5321            tracing_subscriber::registry().with(captured.clone()),
5322            || root_failure(error),
5323        );
5324
5325        // The reason still carries the whole sentence. Nothing on *this* path
5326        // renders it -- there is no attempt row, and the event carries only the
5327        // variant -- so this is a guard against a refactor that drops the detail
5328        // before some future surface can show it, and not a claim that one does.
5329        assert!(
5330            matches!(
5331                &failure,
5332                LifecycleError::Failed(FailureReason::Other(detail))
5333                    if detail.contains("/Volumes/NVME/runners")
5334                        && detail.contains("Full Disk Access")
5335            ),
5336            "the reason must still carry the detail: {failure:?}"
5337        );
5338
5339        let events = captured
5340            .0
5341            .lock()
5342            .expect("the capture mutex is not poisoned")
5343            .clone();
5344        let event = events
5345            .iter()
5346            .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
5347            .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
5348
5349        // Held to the rules the real sink applies, by name and by value. An
5350        // earlier fix put the rendered error on an unlisted `detail` field and
5351        // shipped `[redacted]`; asserting only that something was emitted would
5352        // have passed then too.
5353        for (name, value) in event {
5354            assert!(
5355                logging::is_field_allowed(name),
5356                "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5357                logging::REDACTION
5358            );
5359            assert_eq!(
5360                &logging::redact(value),
5361                value,
5362                "`{name}` does not survive value-shape scrubbing: {event:?}"
5363            );
5364        }
5365    }
5366
5367    /// A slot number, for the tests that name one.
5368    fn nz(slot: u16) -> NonZeroU16 {
5369        NonZeroU16::new(slot).expect("a positive slot")
5370    }
5371
5372    const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5373
5374    #[derive(Debug, Default)]
5375    struct FakeGithubLifecycle {
5376        registration_failures: Mutex<VecDeque<bool>>,
5377        observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5378        registrations: AtomicUsize,
5379        remaining_runners: AtomicUsize,
5380        /// Every runner id `deregister` was asked to remove, in order. A count
5381        /// would not do: the assertions worth making are that the *right*
5382        /// registration was deleted and that it was deleted once.
5383        deregistrations: Mutex<Vec<u64>>,
5384        /// Set to make `deregister` answer `false`, standing for a GitHub that
5385        /// could not be reached at the moment the attempt concluded.
5386        deregistration_fails: AtomicBool,
5387        /// The journal to read *during* a registration, for the ordering
5388        /// assertion `02-target-architecture.md` makes: the slot lease is
5389        /// written "before package or GitHub effects". Reading it afterwards
5390        /// would pass even if the write happened second.
5391        journal: Mutex<Option<Arc<SqliteStore>>>,
5392        /// One entry per registration, in order.
5393        registration_facts: Mutex<Vec<RegistrationFact>>,
5394    }
5395
5396    /// What one JIT registration saw of the world at the moment it was issued.
5397    ///
5398    /// `runner_name` is what ties the other two fields to *one* attempt: with
5399    /// two allocators racing, "some slot was journalled" is a much weaker claim
5400    /// than "the slot this very request belongs to was journalled", and only the
5401    /// name distinguishes them.
5402    #[derive(Debug, Clone)]
5403    struct RegistrationFact {
5404        /// The persistent slots the journal already held.
5405        leased_slots: Vec<u16>,
5406        /// The `work_folder` the request carried.
5407        work_folder: String,
5408        /// The runner name the request carried, i.e. [`runner_name`] of the
5409        /// registering attempt.
5410        runner_name: String,
5411        journal_states: Vec<AttemptState>,
5412    }
5413
5414    impl FakeGithubLifecycle {
5415        fn fail(mut self, terminal: bool) -> Self {
5416            self.registration_failures
5417                .get_mut()
5418                .expect("unpoisoned")
5419                .push_back(terminal);
5420            self
5421        }
5422
5423        fn watch_journal(&self, store: Arc<SqliteStore>) {
5424            *self.journal.lock().unwrap() = Some(store);
5425        }
5426
5427        fn registration_facts(&self) -> Vec<RegistrationFact> {
5428            self.registration_facts.lock().unwrap().clone()
5429        }
5430
5431        fn observe(&self, observation: GithubRunnerObservation) {
5432            let observation = match observation {
5433                GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5434                GithubRunnerObservation::NotRegistered => {
5435                    LifecycleGithubObservation::not_registered()
5436                }
5437                GithubRunnerObservation::Registered { busy } => {
5438                    LifecycleGithubObservation::registered(73, busy)
5439                }
5440            };
5441            self.observations.lock().unwrap().push_back(observation);
5442        }
5443    }
5444
5445    #[async_trait]
5446    impl LifecycleGithub for FakeGithubLifecycle {
5447        async fn register(
5448            &self,
5449            _target: &ScaleTarget,
5450            request: &JitRunnerRequest,
5451            _cancel: &CancelToken,
5452        ) -> Result<JitRegistration, JitRequestFailure> {
5453            self.registrations.fetch_add(1, Ordering::SeqCst);
5454            if let Some(store) = self.journal.lock().unwrap().as_ref() {
5455                let attempts = store.attempts().expect("the journal is readable");
5456                let slots = attempts
5457                    .iter()
5458                    .filter_map(|attempt| attempt.workspace().slot_number())
5459                    .collect();
5460                self.registration_facts
5461                    .lock()
5462                    .unwrap()
5463                    .push(RegistrationFact {
5464                        leased_slots: slots,
5465                        work_folder: request.work_folder().to_string(),
5466                        runner_name: request.name().to_string(),
5467                        journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5468                    });
5469            }
5470            if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5471                return Err(JitRequestFailure {
5472                    terminal,
5473                    reason: if terminal {
5474                        FailureReason::Other("GitHub refused JIT registration with 403".into())
5475                    } else {
5476                        FailureReason::JitRequestFailed
5477                    },
5478                    retry_after: None,
5479                });
5480            }
5481            self.remaining_runners.store(1, Ordering::SeqCst);
5482            Ok(JitRegistration::new(
5483                EncodedJitConfig::new(JIT),
5484                JitRunner {
5485                    id: 73,
5486                    name: request.name().to_string(),
5487                    os: "windows".into(),
5488                    status: "offline".into(),
5489                    busy: false,
5490                    runner_group_id: Some(1),
5491                    labels: request.labels().to_vec(),
5492                },
5493            ))
5494        }
5495
5496        async fn observe(
5497            &self,
5498            _target: &ScaleTarget,
5499            _attempt: AttemptId,
5500            _cancel: &CancelToken,
5501        ) -> LifecycleGithubObservation {
5502            let observation = self
5503                .observations
5504                .lock()
5505                .unwrap()
5506                .pop_front()
5507                .unwrap_or(LifecycleGithubObservation::not_registered());
5508            if observation.status == GithubRunnerObservation::NotRegistered {
5509                self.remaining_runners.store(0, Ordering::SeqCst);
5510            }
5511            observation
5512        }
5513
5514        async fn deregister(
5515            &self,
5516            _target: &ScaleTarget,
5517            runner_id: u64,
5518            _cancel: &CancelToken,
5519        ) -> bool {
5520            self.deregistrations.lock().unwrap().push(runner_id);
5521            if self.deregistration_fails.load(Ordering::SeqCst) {
5522                return false;
5523            }
5524            self.remaining_runners.store(0, Ordering::SeqCst);
5525            true
5526        }
5527    }
5528
5529    #[derive(Debug)]
5530    struct FakePackages {
5531        version: RunnerVersion,
5532        leases: Mutex<BTreeSet<AttemptId>>,
5533        materializations: AtomicUsize,
5534        materializing: AtomicUsize,
5535        peak_materializing: AtomicUsize,
5536        materialization_failures: AtomicUsize,
5537        release_failures: AtomicUsize,
5538        releases: AtomicUsize,
5539        prunes: AtomicUsize,
5540    }
5541
5542    impl Default for FakePackages {
5543        fn default() -> Self {
5544            Self {
5545                version: RunnerVersion::parse("2.330.0").unwrap(),
5546                leases: Mutex::new(BTreeSet::new()),
5547                materializations: AtomicUsize::new(0),
5548                materializing: AtomicUsize::new(0),
5549                peak_materializing: AtomicUsize::new(0),
5550                materialization_failures: AtomicUsize::new(0),
5551                release_failures: AtomicUsize::new(0),
5552                releases: AtomicUsize::new(0),
5553                prunes: AtomicUsize::new(0),
5554            }
5555        }
5556    }
5557
5558    impl FakePackages {
5559        fn fail_materializations(&self, count: usize) {
5560            self.materialization_failures.store(count, Ordering::SeqCst);
5561        }
5562
5563        fn fail_releases(&self, count: usize) {
5564            self.release_failures.store(count, Ordering::SeqCst);
5565        }
5566    }
5567
5568    #[async_trait]
5569    impl RuntimePackages for FakePackages {
5570        async fn materialize(
5571            &self,
5572            attempt: &RunnerAttempt,
5573            _timings: &mut PackageTimings,
5574        ) -> Result<RunnerVersion, FailureReason> {
5575            self.materializations.fetch_add(1, Ordering::SeqCst);
5576            let now = self.materializing.fetch_add(1, Ordering::SeqCst) + 1;
5577            self.peak_materializing.fetch_max(now, Ordering::SeqCst);
5578            for _ in 0..5 {
5579                tokio::task::yield_now().await;
5580            }
5581            self.materializing.fetch_sub(1, Ordering::SeqCst);
5582            if self
5583                .materialization_failures
5584                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5585                    if left > 0 { Some(left - 1) } else { None }
5586                })
5587                .is_ok()
5588            {
5589                return Err(FailureReason::Other(
5590                    "runner package materialization failed transiently".into(),
5591                ));
5592            }
5593            fs::create_dir_all(attempt.runtime_path()).unwrap();
5594            fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5595            self.leases.lock().unwrap().insert(attempt.id);
5596            Ok(self.version.clone())
5597        }
5598
5599        fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5600            if self
5601                .release_failures
5602                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5603                    if left > 0 { Some(left - 1) } else { None }
5604                })
5605                .is_ok()
5606            {
5607                return Err(FailureReason::Other(
5608                    "runner package lease could not be released".into(),
5609                ));
5610            }
5611            self.leases.lock().unwrap().remove(&attempt);
5612            self.releases.fetch_add(1, Ordering::SeqCst);
5613            Ok(())
5614        }
5615
5616        fn prune_obsolete_guarded(
5617            &self,
5618            _authority: PruneAuthority<'_>,
5619            _attempts: &[RunnerAttempt],
5620        ) -> Result<(), FailureReason> {
5621            self.prunes.fetch_add(1, Ordering::SeqCst);
5622            Ok(())
5623        }
5624    }
5625
5626    #[derive(Debug, Default)]
5627    struct FakeProcesses {
5628        alive: AtomicBool,
5629        completed_successfully: AtomicBool,
5630        spawns: AtomicUsize,
5631        spawn_failures: AtomicUsize,
5632        live_spawn_failure: AtomicBool,
5633        terminations: AtomicUsize,
5634        intent: AtomicBool,
5635        intent_failure: AtomicBool,
5636        actions: Mutex<Vec<&'static str>>,
5637        saw_secret: AtomicBool,
5638    }
5639
5640    impl FakeProcesses {
5641        fn fail_spawns(&self, count: usize) {
5642            self.spawn_failures.store(count, Ordering::SeqCst);
5643        }
5644
5645        fn fail_spawn_with_live_child(&self) {
5646            self.live_spawn_failure.store(true, Ordering::SeqCst);
5647        }
5648
5649        fn set_alive(&self, alive: bool) {
5650            self.alive.store(alive, Ordering::SeqCst);
5651        }
5652
5653        fn finish_successfully(&self) {
5654            self.completed_successfully.store(true, Ordering::SeqCst);
5655            self.alive.store(false, Ordering::SeqCst);
5656        }
5657
5658        fn fail_intent(&self) {
5659            self.intent_failure.store(true, Ordering::SeqCst);
5660        }
5661    }
5662
5663    impl ProcessSupervisor for FakeProcesses {
5664        fn spawn(
5665            &self,
5666            attempt: &RunnerAttempt,
5667            config: &EncodedJitConfig,
5668        ) -> Result<u32, ProcessStartFailure> {
5669            self.spawns.fetch_add(1, Ordering::SeqCst);
5670            // Model the production handoff on both paths: the sensitive file is
5671            // scoped to this call and absent when it returns.
5672            let handoff = RestrictiveHandoff::create(
5673                attempt.runtime_path(),
5674                SecretString::from(config.expose().to_owned()),
5675            )
5676            .unwrap();
5677            self.saw_secret
5678                .store(config.expose() == JIT, Ordering::SeqCst);
5679            let handoff_path = handoff.path().to_path_buf();
5680            let failing = self
5681                .spawn_failures
5682                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5683                    if left > 0 { Some(left - 1) } else { None }
5684                })
5685                .is_ok();
5686            drop(handoff);
5687            assert!(!handoff_path.exists(), "handoff must be absent on return");
5688            if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5689                self.alive.store(true, Ordering::SeqCst);
5690                return Err(ProcessStartFailure::after_spawn_live(4242));
5691            }
5692            if failing {
5693                return Err(ProcessStartFailure::before_spawn(
5694                    FailureReason::ProcessStartFailed,
5695                ));
5696            }
5697            self.alive.store(true, Ordering::SeqCst);
5698            Ok(4242)
5699        }
5700
5701        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5702            self.actions.lock().unwrap().push("observe_process");
5703            Ok(self.alive.load(Ordering::SeqCst))
5704        }
5705
5706        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5707            Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5708        }
5709
5710        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5711            self.completed_successfully.load(Ordering::SeqCst)
5712        }
5713
5714        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5715            self.actions.lock().unwrap().push("terminate_intent");
5716            if self.intent_failure.load(Ordering::SeqCst) {
5717                return Err(FailureReason::Other(
5718                    "terminate intent directory sync failed".into(),
5719                ));
5720            }
5721            self.intent.store(true, Ordering::SeqCst);
5722            Ok(())
5723        }
5724
5725        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5726            self.intent.load(Ordering::SeqCst)
5727        }
5728
5729        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5730            assert!(
5731                self.intent.load(Ordering::SeqCst),
5732                "the durable intent must exist before signalling"
5733            );
5734            self.actions.lock().unwrap().push("terminate");
5735            self.terminations.fetch_add(1, Ordering::SeqCst);
5736            self.alive.store(false, Ordering::SeqCst);
5737            Ok(())
5738        }
5739    }
5740
5741    #[derive(Debug, Default)]
5742    struct FakeIsolatedProvider {
5743        resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5744        extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5745        unsupported_host: AtomicBool,
5746        permission_denied: AtomicBool,
5747        incompatible_image: AtomicBool,
5748        prepare_failure: AtomicBool,
5749        recovery_unavailable: AtomicBool,
5750        destroy_deferred: AtomicBool,
5751        handoffs: AtomicUsize,
5752        completed_successfully: AtomicBool,
5753        native_calls: AtomicUsize,
5754        actions: Mutex<Vec<&'static str>>,
5755    }
5756
5757    impl FakeIsolatedProvider {
5758        fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
5759            let AttemptExecution::Isolated {
5760                provider_kind,
5761                resolved_image,
5762                generation,
5763                ..
5764            } = attempt.execution()
5765            else {
5766                panic!("isolated intent required")
5767            };
5768            EnvironmentIdentity::Isolated {
5769                host,
5770                attempt: attempt.id,
5771                provider_kind: *provider_kind,
5772                environment_id: format!("environment-{}", attempt.id),
5773                resolved_image: resolved_image.clone(),
5774                generation: generation.clone(),
5775            }
5776        }
5777
5778        fn resource_count(&self) -> usize {
5779            self.resources.lock().unwrap().len()
5780        }
5781    }
5782
5783    impl ExecutionProvider for FakeIsolatedProvider {
5784        fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
5785            if self.unsupported_host.load(Ordering::SeqCst) {
5786                ProviderCapability::Unsupported
5787            } else if self.permission_denied.load(Ordering::SeqCst) {
5788                ProviderCapability::PermissionDenied
5789            } else if policy.execution_policy().is_native() {
5790                ProviderCapability::Unsupported
5791            } else {
5792                ProviderCapability::Ready
5793            }
5794        }
5795
5796        fn resolve(
5797            &self,
5798            policy: &ScalePolicy,
5799        ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
5800            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
5801                policy.execution_policy()
5802            else {
5803                return Err(FailureReason::Other("isolated policy required".into()));
5804            };
5805            Ok(Some(ResolvedEnvironment {
5806                provider_kind: Backend::Oci,
5807                image: if self.incompatible_image.load(Ordering::SeqCst) {
5808                    ImageReference::new("vm-version:wrong-provider")
5809                        .expect("valid but incompatible")
5810                } else {
5811                    image.clone()
5812                },
5813            }))
5814        }
5815
5816        fn prepare(
5817            &self,
5818            attempt: &RunnerAttempt,
5819            policy: &ScalePolicy,
5820        ) -> Result<PreparedEnvironment, FailureReason> {
5821            assert_eq!(
5822                attempt.state(),
5823                AttemptState::Preparing,
5824                "intent must be durable before provider effect"
5825            );
5826            self.actions.lock().unwrap().push("prepare");
5827            let identity = Self::identity(attempt, policy.host_id);
5828            self.resources
5829                .lock()
5830                .unwrap()
5831                .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
5832            if self.prepare_failure.swap(false, Ordering::SeqCst) {
5833                return Err(FailureReason::Other("provider-secret-needle".into()));
5834            }
5835            Ok(PreparedEnvironment::isolated(attempt.id, identity))
5836        }
5837
5838        fn start(
5839            &self,
5840            prepared: PreparedEnvironment,
5841            attempt: &RunnerAttempt,
5842            handoff: OneTimeJitHandoff<'_>,
5843        ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
5844            assert_eq!(prepared.attempt, attempt.id);
5845            assert_eq!(attempt.state(), AttemptState::JitReceived);
5846            assert_eq!(handoff.consume().expose(), JIT);
5847            self.handoffs.fetch_add(1, Ordering::SeqCst);
5848            self.actions.lock().unwrap().push("start");
5849            let mut resources = self.resources.lock().unwrap();
5850            let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
5851            *state = EnvironmentState::Running;
5852            Ok(identity.clone())
5853        }
5854
5855        fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5856            self.actions.lock().unwrap().push("inspect");
5857            Ok(self
5858                .resources
5859                .lock()
5860                .unwrap()
5861                .get(&attempt.id)
5862                .map_or(EnvironmentState::Missing, |(_, state)| *state))
5863        }
5864
5865        fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5866            if self.recovery_unavailable.load(Ordering::SeqCst) {
5867                return Err(FailureReason::Other(
5868                    "provider discovery unavailable".into(),
5869                ));
5870            }
5871            self.inspect(attempt)
5872        }
5873
5874        fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5875            Ok(self
5876                .resources
5877                .lock()
5878                .unwrap()
5879                .get(&attempt.id)
5880                .is_some_and(|(identity, _)| {
5881                    identity_matches_intent(attempt_host(identity), attempt, identity)
5882                }))
5883        }
5884
5885        fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
5886            assert!(self.owns(attempt)?);
5887            self.actions.lock().unwrap().push("stop");
5888            if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
5889                *state = EnvironmentState::Exited;
5890            }
5891            Ok(ProviderStop::Stopped)
5892        }
5893
5894        fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
5895            assert!(self.owns(attempt)?);
5896            self.actions.lock().unwrap().push("destroy");
5897            if self.destroy_deferred.load(Ordering::SeqCst) {
5898                return Ok(ProviderDestroy::Deferred("provider busy"));
5899            }
5900            self.resources.lock().unwrap().remove(&attempt.id);
5901            Ok(ProviderDestroy::Destroyed)
5902        }
5903
5904        fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
5905            let mut owned = self
5906                .resources
5907                .lock()
5908                .unwrap()
5909                .values()
5910                .filter(|(identity, _)| attempt_host(identity) == host)
5911                .map(|(identity, _)| identity.clone())
5912                .collect::<Vec<_>>();
5913            owned.extend(
5914                self.extra_resources
5915                    .lock()
5916                    .unwrap()
5917                    .iter()
5918                    .filter(|identity| attempt_host(identity) == host)
5919                    .cloned(),
5920            );
5921            owned
5922        }
5923
5924        fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
5925            vec![ProviderDiagnostic::CleanupDeferred; 40]
5926        }
5927
5928        fn spawn(
5929            &self,
5930            _attempt: &RunnerAttempt,
5931            _config: &EncodedJitConfig,
5932        ) -> Result<u32, ProcessStartFailure> {
5933            self.native_calls.fetch_add(1, Ordering::SeqCst);
5934            panic!("isolated provider must never spawn natively")
5935        }
5936        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5937            panic!("native fallback")
5938        }
5939        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5940            panic!("native fallback")
5941        }
5942        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5943            self.completed_successfully.load(Ordering::SeqCst)
5944        }
5945        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5946            panic!("native fallback")
5947        }
5948        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5949            false
5950        }
5951        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5952            panic!("native fallback")
5953        }
5954    }
5955
5956    fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
5957        let EnvironmentIdentity::Isolated { host, .. } = identity else {
5958            panic!("isolated identity")
5959        };
5960        *host
5961    }
5962
5963    #[derive(Debug, Default)]
5964    struct FakeDemand {
5965        answers: Mutex<VecDeque<bool>>,
5966    }
5967
5968    impl FakeDemand {
5969        fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
5970            Self {
5971                answers: Mutex::new(answers.into_iter().collect()),
5972            }
5973        }
5974    }
5975
5976    #[async_trait]
5977    impl DemandPersistence for FakeDemand {
5978        async fn persists(&self, _policy: PolicyId) -> bool {
5979            self.answers.lock().unwrap().pop_front().unwrap_or(true)
5980        }
5981    }
5982
5983    #[derive(Debug, Default)]
5984    struct FakeDelay(Mutex<Vec<Duration>>);
5985
5986    #[async_trait]
5987    impl RetryDelay for FakeDelay {
5988        async fn wait(&self, duration: Duration) {
5989            self.0.lock().unwrap().push(duration);
5990        }
5991    }
5992
5993    #[tokio::test]
5994    async fn live_capacity_reads_the_updated_sqlite_host_and_rejects_a_missing_host() {
5995        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5996        let path = harness._root.path().join("live-capacity.sqlite3");
5997        harness.store = Arc::new(SqliteStore::open(&path).unwrap());
5998        harness.store.put_host(&harness.host).unwrap();
5999        harness.launcher = harness.restart();
6000        assert_eq!(
6001            harness.launcher.host_capacity(&harness.host).await.unwrap(),
6002            harness.host.host_capacity
6003        );
6004        // A separate CLI/TUI connection updates the journal while the existing
6005        // launcher keeps its original store connection and startup host.
6006        let writer = SqliteStore::open(&path).unwrap();
6007        let mut updated = harness.host.clone();
6008        updated.host_capacity = std::num::NonZeroU16::new(12).unwrap();
6009        writer.put_host(&updated).unwrap();
6010        assert_eq!(
6011            harness
6012                .launcher
6013                .host_capacity(&harness.host)
6014                .await
6015                .unwrap()
6016                .get(),
6017            12
6018        );
6019
6020        updated.host_capacity = std::num::NonZeroU16::new(2).unwrap();
6021        writer.put_host(&updated).unwrap();
6022        assert_eq!(
6023            harness
6024                .launcher
6025                .host_capacity(&harness.host)
6026                .await
6027                .unwrap()
6028                .get(),
6029            2
6030        );
6031
6032        let mut missing = harness.host.clone();
6033        missing.id = HostId::from_u128(999);
6034        assert_eq!(
6035            harness.launcher.host_capacity(&missing).await,
6036            Err(crate::reconcile::HostCapacityError::MissingHost)
6037        );
6038    }
6039
6040    struct Harness {
6041        _root: tempfile::TempDir,
6042        app_paths: runner_manager_platform::paths::AppPaths,
6043        launcher: LifecycleLauncher,
6044        demand: Arc<dyn DemandPersistence>,
6045        store: Arc<SqliteStore>,
6046        github: Arc<FakeGithubLifecycle>,
6047        packages: Arc<FakePackages>,
6048        processes: Arc<FakeProcesses>,
6049        clock: Arc<FakeClock>,
6050        events: Arc<AttemptEventLog>,
6051        reconcile_events: Arc<crate::reconcile::EventLog>,
6052        delay: Arc<FakeDelay>,
6053        host: runner_manager_domain::model::Host,
6054        policy: ScalePolicy,
6055        allocation_lock: InProcessAllocationLock,
6056        /// The repository persistent root, once one is configured.
6057        workspace_root: Option<LocalAbsolutePath>,
6058    }
6059
6060    impl Harness {
6061        fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
6062            let root = tempfile::tempdir().unwrap();
6063            let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
6064            paths.create_all().unwrap();
6065            let policy = fixtures::policy()
6066                .repository("octo/repo")
6067                .autoscale("home", 2)
6068                .active()
6069                .build();
6070            // A unit harness must never inspect or write the workstation's
6071            // production runner root. On Windows that is C:\rman, which is
6072            // intentionally inaccessible to an ordinary account after a boot
6073            // service is installed. Using it made the same suite pass on a
6074            // clean CI image and fail on a correctly secured developer host.
6075            let host_root = root.path().join("host-root");
6076            fs::create_dir_all(&host_root).unwrap();
6077            let mut host = fixtures::host().build();
6078            host.runner_root_override = Some(
6079                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6080                    .expect("a local absolute host root"),
6081            );
6082            let store = Arc::new(SqliteStore::open_in_memory().unwrap());
6083            store.put_host(&host).unwrap();
6084            let github = Arc::new(github);
6085            let packages = Arc::new(FakePackages::default());
6086            let processes = Arc::new(FakeProcesses::default());
6087            let clock = Arc::new(FakeClock::default());
6088            let events = Arc::new(AttemptEventLog::default());
6089            let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
6090            let delay = Arc::new(FakeDelay::default());
6091            let ports = LifecyclePorts {
6092                store: Arc::clone(&store) as Arc<dyn Store>,
6093                github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
6094                packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
6095                processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
6096                clock: Arc::clone(&clock) as Arc<dyn Clock>,
6097                demand: Arc::clone(&demand),
6098                delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
6099                events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
6100                reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
6101            };
6102            let launcher = Self::launcher_over(policy.host_id, &paths, ports);
6103            Self {
6104                _root: root,
6105                app_paths: paths,
6106                launcher,
6107                demand,
6108                store,
6109                github,
6110                packages,
6111                processes,
6112                clock,
6113                events,
6114                reconcile_events,
6115                delay,
6116                host,
6117                policy,
6118                allocation_lock: InProcessAllocationLock::new(),
6119                workspace_root: None,
6120            }
6121        }
6122
6123        /// Put disposable attempts under a host root of this harness's own.
6124        ///
6125        /// Without it the launcher resolves the *platform* default, which on
6126        /// Windows is `%SystemDrive%\rman` — a real directory on the machine
6127        /// running the suite. Every test added by `c2` places its files inside
6128        /// its own temporary directory instead.
6129        fn with_host_runner_root(mut self) -> Self {
6130            let host_root = self.host_root();
6131            fs::create_dir_all(&host_root).unwrap();
6132            self.host.runner_root_override = Some(
6133                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6134                    .expect("a local absolute host root"),
6135            );
6136            self.store.put_host(&self.host).unwrap();
6137            self
6138        }
6139
6140        /// Opt this harness's repository into a persistent workspace (D4).
6141        fn with_persistent_workspace(mut self, capacity: u16) -> Self {
6142            self = self.with_host_runner_root();
6143            let root = self._root.path().join("persist");
6144            let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
6145                .expect("a local absolute workspace root");
6146            self.policy = fixtures::policy()
6147                .repository("octo/repo")
6148                .autoscale("home", capacity)
6149                .active()
6150                .build();
6151            self.policy
6152                .set_workspace_policy(
6153                    WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
6154                        .expect("a repository may be persistent"),
6155                )
6156                .expect("a repository may be persistent");
6157            self.workspace_root = Some(root);
6158            // The journal's copy, so that cleanup's cross-check against a
6159            // surviving policy is exercised rather than skipped.
6160            self.store.insert_policy(&self.policy).unwrap();
6161            self
6162        }
6163
6164        fn workspace_root(&self) -> &LocalAbsolutePath {
6165            self.workspace_root
6166                .as_ref()
6167                .expect("this harness configured a persistent workspace")
6168        }
6169
6170        fn slot_path(&self, slot: u16) -> PathBuf {
6171            self.workspace_root().as_path().join(format!("s{slot}"))
6172        }
6173
6174        fn host_root(&self) -> PathBuf {
6175            self._root.path().join("host-root")
6176        }
6177
6178        fn attempt(&self, id: AttemptId) -> RunnerAttempt {
6179            self.store
6180                .attempt(id)
6181                .unwrap()
6182                .expect("the attempt is journalled")
6183        }
6184
6185        /// Conclude an attempt and run the real cleanup over it.
6186        fn conclude(&self, id: AttemptId) -> RunnerAttempt {
6187            let mut attempt = self.attempt(id);
6188            attempt
6189                .conclude(
6190                    AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
6191                    self.clock.now(),
6192                )
6193                .unwrap();
6194            self.store.record_attempt(&attempt).unwrap();
6195            attempt
6196        }
6197
6198        async fn cleanup_retaining_work(&self, id: AttemptId) {
6199            self.conclude(id);
6200            self.launcher
6201                .clean(id)
6202                .await
6203                .expect("the slot is scrubbed and the lease released");
6204        }
6205
6206        /// The launcher configuration every launcher in this harness shares, so
6207        /// that the one a restart mints cannot drift from the original.
6208        fn launcher_over(
6209            host: HostId,
6210            paths: &runner_manager_platform::paths::AppPaths,
6211            ports: LifecyclePorts,
6212        ) -> LifecycleLauncher {
6213            LifecycleLauncher::new(
6214                host,
6215                paths.clone(),
6216                paths.logs_dir(),
6217                1,
6218                RecoveryTimeouts::new(
6219                    Elapsed::seconds(10),
6220                    Elapsed::seconds(10),
6221                    Elapsed::seconds(10),
6222                ),
6223                RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
6224                ports,
6225            )
6226        }
6227
6228        /// The same journal, the same directories, a launcher that remembers
6229        /// nothing — which is what a daemon restart is.
6230        fn restart(&self) -> LifecycleLauncher {
6231            Self::launcher_over(
6232                self.policy.host_id,
6233                &self.app_paths,
6234                LifecyclePorts {
6235                    store: Arc::clone(&self.store) as Arc<dyn Store>,
6236                    github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
6237                    packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
6238                    processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
6239                    clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
6240                    demand: Arc::clone(&self.demand),
6241                    delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
6242                    events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
6243                    reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
6244                },
6245            )
6246        }
6247
6248        async fn ready(&self) {
6249            self.launcher
6250                .recover_startup(std::slice::from_ref(&self.policy))
6251                .await
6252                .unwrap();
6253        }
6254
6255        async fn launch(&self) -> RunnerAttempt {
6256            self.launch_result().await.unwrap()
6257        }
6258
6259        async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
6260            self.launch_after_waiting(Duration::ZERO).await
6261        }
6262
6263        async fn launch_after_waiting(
6264            &self,
6265            lock_wait: Duration,
6266        ) -> Result<RunnerAttempt, LaunchFailure> {
6267            let reserved = self.reserve().await?;
6268            self.launcher
6269                .finish_launch(&self.policy, reserved, lock_wait)
6270                .await
6271        }
6272
6273        /// A whole launch through `launcher`, as a reconciliation pass makes it.
6274        async fn launch_through(
6275            &self,
6276            launcher: &LifecycleLauncher,
6277        ) -> Result<RunnerAttempt, LaunchFailure> {
6278            let reserved = self.reserve_through(launcher).await?;
6279            launcher
6280                .finish_launch(&self.policy, reserved, Duration::ZERO)
6281                .await
6282        }
6283
6284        async fn reserve(&self) -> Result<RunnerAttempt, LaunchFailure> {
6285            self.reserve_through(&self.launcher).await
6286        }
6287
6288        async fn reserve_through(
6289            &self,
6290            launcher: &LifecycleLauncher,
6291        ) -> Result<RunnerAttempt, LaunchFailure> {
6292            let guard = self.allocation_lock.acquire().await.unwrap();
6293            launcher
6294                .launch(LaunchRequest {
6295                    host: &self.host,
6296                    policy: &self.policy,
6297                    allocation_guard: &guard,
6298                })
6299                .await
6300        }
6301
6302        fn only_attempt(&self) -> RunnerAttempt {
6303            self.store.attempts().unwrap().into_iter().next().unwrap()
6304        }
6305    }
6306
6307    fn isolated_harness() -> Harness {
6308        use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
6309        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6310            .with_host_runner_root();
6311        let image =
6312            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6313                .expect("pinned image");
6314        harness
6315            .policy
6316            .set_execution_policy(ExecutionPolicy::Isolated {
6317                backend: Backend::Oci,
6318                image,
6319                resources: ResourceLimits {
6320                    cpu_millis: 1000,
6321                    memory_mib: 1024,
6322                    disk_mib: 4096,
6323                },
6324            })
6325            .expect("isolated policy");
6326        harness.github.watch_journal(Arc::clone(&harness.store));
6327        harness
6328    }
6329
6330    fn launcher_with_isolated(
6331        harness: &Harness,
6332        provider: Arc<FakeIsolatedProvider>,
6333    ) -> LifecycleLauncher {
6334        Harness::launcher_over(
6335            harness.policy.host_id,
6336            &harness.app_paths,
6337            LifecyclePorts {
6338                store: Arc::clone(&harness.store) as Arc<dyn Store>,
6339                github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
6340                packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
6341                processes: provider as Arc<dyn ExecutionProvider>,
6342                clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
6343                demand: Arc::clone(&harness.demand),
6344                delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
6345                events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
6346                reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
6347            },
6348        )
6349    }
6350
6351    #[tokio::test]
6352    async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
6353        use runner_manager_domain::execution::{
6354            AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6355        };
6356
6357        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6358            .with_host_runner_root();
6359        let image =
6360            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6361                .expect("pinned image");
6362        harness
6363            .policy
6364            .set_execution_policy(ExecutionPolicy::Isolated {
6365                backend: Backend::Oci,
6366                image: image.clone(),
6367                resources: ResourceLimits {
6368                    cpu_millis: 1000,
6369                    memory_mib: 1024,
6370                    disk_mib: 4096,
6371                },
6372            })
6373            .expect("valid isolated profile");
6374
6375        harness.ready().await;
6376        assert_eq!(
6377            harness.processes.probe(&harness.policy),
6378            ProviderCapability::Unsupported
6379        );
6380        let failure = harness
6381            .launch_result()
6382            .await
6383            .expect_err("provider is unavailable");
6384        assert_eq!(
6385            failure.reason,
6386            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6387        );
6388
6389        let runtime = harness.host_root().join("isolated-owned-environment");
6390        fs::create_dir_all(&runtime).expect("provider resource fixture");
6391        fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
6392        let mut attempt = RunnerAttempt::allocate(
6393            AttemptId::from_u128(0x123),
6394            harness.policy.id,
6395            runtime.clone(),
6396            harness.clock.now(),
6397        );
6398        attempt
6399            .allocate_execution(AttemptExecution::Isolated {
6400                provider_kind: Backend::Oci,
6401                environment_id: None,
6402                resolved_image: image,
6403                generation: "generation-123".into(),
6404            })
6405            .expect("isolated intent");
6406        attempt
6407            .prepared_environment("environment-123".into())
6408            .expect("prepared identity");
6409        harness
6410            .store
6411            .record_attempt(&attempt)
6412            .expect("durable isolated journal");
6413
6414        harness
6415            .launcher
6416            .recover_startup(std::slice::from_ref(&harness.policy))
6417            .await
6418            .expect("unsupported isolated cleanup is profile-local");
6419        let failure = harness
6420            .launch_result()
6421            .await
6422            .expect_err("provider unavailable for this policy");
6423        assert_eq!(
6424            failure.reason,
6425            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6426        );
6427        harness
6428            .launcher
6429            .supervise(&harness.policy)
6430            .await
6431            .expect("unavailable attempt is deferred");
6432        assert!(harness.launcher.clean(attempt.id).await.is_err());
6433        assert_eq!(
6434            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6435            AttemptState::CleanupDeferred
6436        );
6437        assert!(matches!(
6438            harness.launcher.recover_startup(&[]).await,
6439            Err(LifecycleError::RecoveryIncomplete)
6440        ));
6441        assert!(
6442            runtime.join("owned-marker").is_file(),
6443            "native cleanup touched the isolated environment"
6444        );
6445        assert!(
6446            harness.processes.actions.lock().unwrap().is_empty(),
6447            "native recovery inspected an isolated identity"
6448        );
6449        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6450        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6451        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6452    }
6453
6454    #[tokio::test]
6455    async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6456        let harness = isolated_harness();
6457        let provider = Arc::new(FakeIsolatedProvider::default());
6458        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6459        launcher
6460            .recover_startup(std::slice::from_ref(&harness.policy))
6461            .await
6462            .expect("ready");
6463        let attempt = harness
6464            .launch_through(&launcher)
6465            .await
6466            .expect("isolated launch");
6467        assert_eq!(attempt.state(), AttemptState::Starting);
6468        assert_eq!(provider.resource_count(), 1);
6469        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6470        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6471        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6472        let fact = harness
6473            .github
6474            .registration_facts()
6475            .pop()
6476            .expect("JIT registration");
6477        assert_eq!(
6478            fact.journal_states,
6479            vec![AttemptState::Prepared],
6480            "provider readiness preceded GitHub JIT"
6481        );
6482        assert_eq!(
6483            harness
6484                .events
6485                .events()
6486                .into_iter()
6487                .filter_map(|event| match event {
6488                    AttemptEvent::State { state, .. } => Some(state),
6489                    _ => None,
6490                })
6491                .collect::<Vec<_>>(),
6492            vec![
6493                AttemptState::Allocated,
6494                AttemptState::Preparing,
6495                AttemptState::Prepared,
6496                AttemptState::JitReceived,
6497                AttemptState::Starting
6498            ]
6499        );
6500        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6501        restarted
6502            .recover_startup(std::slice::from_ref(&harness.policy))
6503            .await
6504            .expect("adopt live sandbox");
6505        assert_eq!(provider.resource_count(), 1);
6506        assert_eq!(
6507            harness.github.registrations.load(Ordering::SeqCst),
6508            1,
6509            "restart cannot issue duplicate JIT"
6510        );
6511        harness
6512            .github
6513            .observe(GithubRunnerObservation::Registered { busy: true });
6514        restarted
6515            .supervise(&harness.policy)
6516            .await
6517            .expect("job observed");
6518        assert_eq!(
6519            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6520            AttemptState::Busy
6521        );
6522        provider
6523            .resources
6524            .lock()
6525            .unwrap()
6526            .get_mut(&attempt.id)
6527            .unwrap()
6528            .1 = EnvironmentState::Exited;
6529        harness
6530            .github
6531            .observe(GithubRunnerObservation::NotRegistered);
6532        restarted
6533            .supervise(&harness.policy)
6534            .await
6535            .expect("terminal cleanup");
6536        assert_eq!(provider.resource_count(), 0);
6537        assert_eq!(
6538            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6539            AttemptState::Cleaned
6540        );
6541    }
6542
6543    #[tokio::test]
6544    async fn attached_isolated_job_exit_is_recorded_as_completed() {
6545        let harness = isolated_harness();
6546        let provider = Arc::new(FakeIsolatedProvider::default());
6547        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6548        launcher
6549            .recover_startup(std::slice::from_ref(&harness.policy))
6550            .await
6551            .unwrap();
6552        let attempt = harness.launch_through(&launcher).await.unwrap();
6553        harness
6554            .github
6555            .observe(GithubRunnerObservation::Registered { busy: true });
6556        launcher.supervise(&harness.policy).await.unwrap();
6557        provider
6558            .resources
6559            .lock()
6560            .unwrap()
6561            .get_mut(&attempt.id)
6562            .unwrap()
6563            .1 = EnvironmentState::Exited;
6564        provider
6565            .completed_successfully
6566            .store(true, Ordering::SeqCst);
6567        harness
6568            .github
6569            .observe(GithubRunnerObservation::NotRegistered);
6570        launcher.supervise(&harness.policy).await.unwrap();
6571        let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
6572        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6573        assert_eq!(cleaned.state(), AttemptState::Cleaned);
6574    }
6575
6576    #[tokio::test]
6577    async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
6578        let harness = isolated_harness();
6579        let provider = Arc::new(FakeIsolatedProvider::default());
6580        provider.prepare_failure.store(true, Ordering::SeqCst);
6581        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6582        launcher
6583            .recover_startup(std::slice::from_ref(&harness.policy))
6584            .await
6585            .expect("ready");
6586        let failure = harness
6587            .launch_through(&launcher)
6588            .await
6589            .expect_err("prepare failed after creating resource");
6590        assert_eq!(
6591            failure.reason,
6592            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6593        );
6594        assert_eq!(provider.resource_count(), 1);
6595        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6596        assert!(
6597            !harness
6598                .store
6599                .dump_text()
6600                .unwrap()
6601                .contains("provider-secret-needle")
6602        );
6603        let id = harness.store.attempts().unwrap()[0].id;
6604        assert_eq!(
6605            harness.store.attempt(id).unwrap().unwrap().outcome(),
6606            Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
6607                IsolationProviderFailure::RuntimeOperationFailed
6608            )))
6609        );
6610        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6611        restarted
6612            .recover_startup(std::slice::from_ref(&harness.policy))
6613            .await
6614            .expect("profile-local recovery");
6615        assert_eq!(
6616            provider.resource_count(),
6617            0,
6618            "the exact owned resource was destroyed once"
6619        );
6620        assert_eq!(
6621            harness.store.attempt(id).unwrap().unwrap().state(),
6622            AttemptState::Cleaned
6623        );
6624        let diagnostic =
6625            fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6626        assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6627        assert!(!diagnostic.contains("provider-secret-needle"));
6628        assert_eq!(
6629            provider
6630                .actions
6631                .lock()
6632                .unwrap()
6633                .iter()
6634                .filter(|action| **action == "destroy")
6635                .count(),
6636            1
6637        );
6638        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6639    }
6640
6641    #[test]
6642    fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6643        let cases = [
6644            (
6645                ProviderCapability::NotInstalled,
6646                IsolationProviderFailure::NotInstalled,
6647            ),
6648            (
6649                ProviderCapability::PermissionDenied,
6650                IsolationProviderFailure::PermissionDenied,
6651            ),
6652            (
6653                ProviderCapability::ImageUnavailableOrIncompatible,
6654                IsolationProviderFailure::ImageUnavailableOrIncompatible,
6655            ),
6656            (
6657                ProviderCapability::DiskQuotaUnavailable,
6658                IsolationProviderFailure::DiskQuotaUnavailable,
6659            ),
6660            (
6661                ProviderCapability::Degraded,
6662                IsolationProviderFailure::Degraded,
6663            ),
6664        ];
6665        for (capability, category) in cases {
6666            assert_eq!(
6667                capability.refusal(),
6668                FailureReason::IsolationProvider(category)
6669            );
6670        }
6671        let raw = FailureReason::Other("ghp_secret_from_provider".into());
6672        let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6673        assert_eq!(
6674            safe,
6675            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6676        );
6677        assert!(!safe.to_string().contains("ghp_secret"));
6678        assert_eq!(
6679            safe_isolation_reason(
6680                FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6681                IsolationProviderFailure::RuntimeOperationFailed,
6682            ),
6683            FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6684        );
6685    }
6686
6687    #[tokio::test]
6688    async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6689        for image_failure in [false, true] {
6690            let harness = isolated_harness();
6691            let provider = Arc::new(FakeIsolatedProvider::default());
6692            provider
6693                .permission_denied
6694                .store(!image_failure, Ordering::SeqCst);
6695            provider
6696                .incompatible_image
6697                .store(image_failure, Ordering::SeqCst);
6698            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6699            launcher
6700                .recover_startup(std::slice::from_ref(&harness.policy))
6701                .await
6702                .unwrap();
6703            let error = harness
6704                .launch_through(&launcher)
6705                .await
6706                .expect_err("pre-JIT refusal");
6707            let expected = if image_failure {
6708                "resolved image is incompatible"
6709            } else {
6710                "permission denied"
6711            };
6712            assert!(error.reason.to_string().contains(expected));
6713            assert!(
6714                harness.store.attempts().unwrap().is_empty(),
6715                "no allocation was journalled"
6716            );
6717            assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6718            assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6719            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6720        }
6721    }
6722
6723    #[tokio::test]
6724    async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6725        let harness = isolated_harness();
6726        let provider = Arc::new(FakeIsolatedProvider::default());
6727        provider.unsupported_host.store(true, Ordering::SeqCst);
6728        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6729        launcher
6730            .recover_startup(std::slice::from_ref(&harness.policy))
6731            .await
6732            .unwrap();
6733        let error = harness
6734            .launch_through(&launcher)
6735            .await
6736            .expect_err("unsupported host must be refused before JIT");
6737
6738        assert_eq!(
6739            error.reason,
6740            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6741        );
6742        assert!(harness.store.attempts().unwrap().is_empty());
6743        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6744        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6745        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6746        assert!(provider.actions.lock().unwrap().is_empty());
6747    }
6748
6749    #[tokio::test]
6750    async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6751        let harness = isolated_harness();
6752        let provider = Arc::new(FakeIsolatedProvider::default());
6753        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6754        let runtime = harness.host_root().join("prepare-without-identity");
6755        fs::create_dir_all(&runtime).unwrap();
6756        let mut attempt = RunnerAttempt::allocate(
6757            AttemptId::from_u128(0x9191),
6758            harness.policy.id,
6759            runtime,
6760            harness.clock.now(),
6761        );
6762        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6763            harness.policy.execution_policy()
6764        else {
6765            unreachable!()
6766        };
6767        attempt
6768            .allocate_execution(AttemptExecution::Isolated {
6769                provider_kind: Backend::Oci,
6770                environment_id: None,
6771                resolved_image: image.clone(),
6772                generation: "prepare-generation".into(),
6773            })
6774            .unwrap();
6775        attempt.begin_prepare(harness.clock.now()).unwrap();
6776        attempt
6777            .conclude(
6778                AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
6779                harness.clock.now(),
6780            )
6781            .unwrap();
6782        harness.store.record_attempt(&attempt).unwrap();
6783
6784        provider.recovery_unavailable.store(true, Ordering::SeqCst);
6785        launcher
6786            .clean(attempt.id)
6787            .await
6788            .expect_err("absence unproven");
6789        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6790        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6791        assert!(deferred.counts_against_capacity());
6792
6793        provider.recovery_unavailable.store(false, Ordering::SeqCst);
6794        launcher.clean(attempt.id).await.expect("absence proven");
6795        assert_eq!(
6796            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6797            AttemptState::Cleaned
6798        );
6799    }
6800
6801    #[tokio::test]
6802    async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
6803        let harness = isolated_harness();
6804        let provider = Arc::new(FakeIsolatedProvider::default());
6805        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6806        launcher
6807            .recover_startup(std::slice::from_ref(&harness.policy))
6808            .await
6809            .expect("ready");
6810        let mut attempt = harness.launch_through(&launcher).await.expect("launch");
6811        attempt
6812            .conclude(
6813                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6814                harness.clock.now(),
6815            )
6816            .unwrap();
6817        harness.store.record_attempt(&attempt).unwrap();
6818        provider.destroy_deferred.store(true, Ordering::SeqCst);
6819        launcher
6820            .clean(attempt.id)
6821            .await
6822            .expect_err("provider deferred destroy");
6823        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6824        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6825        assert!(deferred.counts_against_capacity());
6826        assert_eq!(provider.resource_count(), 1);
6827        provider.destroy_deferred.store(false, Ordering::SeqCst);
6828        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6829        restarted
6830            .recover_startup(std::slice::from_ref(&harness.policy))
6831            .await
6832            .expect("retry cleanup");
6833        assert_eq!(provider.resource_count(), 0);
6834        assert!(
6835            !harness
6836                .store
6837                .attempt(attempt.id)
6838                .unwrap()
6839                .unwrap()
6840                .counts_against_capacity()
6841        );
6842
6843        // A reused runtime name with a different generation is not ours to delete.
6844        let second_id = AttemptId::from_u128(0x9999);
6845        let second_runtime = harness.host_root().join("generation-mismatch");
6846        fs::create_dir_all(&second_runtime).unwrap();
6847        let mut terminal = RunnerAttempt::allocate(
6848            second_id,
6849            harness.policy.id,
6850            second_runtime,
6851            harness.clock.now(),
6852        );
6853        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6854            harness.policy.execution_policy()
6855        else {
6856            unreachable!()
6857        };
6858        terminal
6859            .allocate_execution(AttemptExecution::Isolated {
6860                provider_kind: Backend::Oci,
6861                environment_id: None,
6862                resolved_image: image.clone(),
6863                generation: "expected-generation".into(),
6864            })
6865            .unwrap();
6866        let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
6867        let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
6868            unreachable!()
6869        };
6870        terminal.begin_prepare(harness.clock.now()).unwrap();
6871        terminal
6872            .prepared_environment(environment_id.clone())
6873            .unwrap();
6874        terminal.mark_prepared(harness.clock.now()).unwrap();
6875        terminal.jit_received(harness.clock.now()).unwrap();
6876        terminal.started_isolated(harness.clock.now()).unwrap();
6877        terminal
6878            .conclude(
6879                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6880                harness.clock.now(),
6881            )
6882            .unwrap();
6883        harness.store.record_attempt(&terminal).unwrap();
6884        let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
6885            unreachable!()
6886        };
6887        *generation = "another-generation".into();
6888        provider
6889            .resources
6890            .lock()
6891            .unwrap()
6892            .insert(second_id, (stranger, EnvironmentState::Running));
6893        restarted
6894            .clean(terminal.id)
6895            .await
6896            .expect_err("ownership mismatch quarantined");
6897        assert_eq!(provider.resource_count(), 1);
6898        assert_eq!(
6899            harness.store.attempt(terminal.id).unwrap().unwrap().state(),
6900            AttemptState::CleanupDeferred
6901        );
6902        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6903    }
6904
6905    #[tokio::test]
6906    async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
6907        for stage in [
6908            AttemptState::Allocated,
6909            AttemptState::Preparing,
6910            AttemptState::Prepared,
6911            AttemptState::JitReceived,
6912            AttemptState::Starting,
6913            AttemptState::Idle,
6914            AttemptState::Busy,
6915            AttemptState::Finished,
6916            AttemptState::Failed,
6917            AttemptState::Orphaned,
6918            AttemptState::Destroying,
6919            AttemptState::CleanupDeferred,
6920        ] {
6921            let harness = isolated_harness();
6922            let provider = Arc::new(FakeIsolatedProvider::default());
6923            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6924            let id = AttemptId::from_u128(0x1000 + stage as u128);
6925            let runtime = harness.host_root().join(format!("phase-{stage}"));
6926            fs::create_dir_all(&runtime).unwrap();
6927            let mut attempt =
6928                RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6929            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6930                harness.policy.execution_policy()
6931            else {
6932                unreachable!()
6933            };
6934            attempt
6935                .allocate_execution(AttemptExecution::Isolated {
6936                    provider_kind: Backend::Oci,
6937                    environment_id: None,
6938                    resolved_image: image.clone(),
6939                    generation: format!("generation-{stage}"),
6940                })
6941                .unwrap();
6942            if stage != AttemptState::Allocated {
6943                attempt.begin_prepare(harness.clock.now()).unwrap();
6944            }
6945            let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6946            if stage != AttemptState::Allocated {
6947                let resource_state = if matches!(
6948                    stage,
6949                    AttemptState::Finished
6950                        | AttemptState::Failed
6951                        | AttemptState::Orphaned
6952                        | AttemptState::Destroying
6953                        | AttemptState::CleanupDeferred
6954                ) {
6955                    EnvironmentState::Exited
6956                } else {
6957                    EnvironmentState::Running
6958                };
6959                provider
6960                    .resources
6961                    .lock()
6962                    .unwrap()
6963                    .insert(id, (identity.clone(), resource_state));
6964            }
6965            if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
6966                let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
6967                    unreachable!()
6968                };
6969                attempt
6970                    .prepared_environment(environment_id.clone())
6971                    .unwrap();
6972                attempt.mark_prepared(harness.clock.now()).unwrap();
6973            }
6974            if matches!(
6975                stage,
6976                AttemptState::JitReceived
6977                    | AttemptState::Starting
6978                    | AttemptState::Idle
6979                    | AttemptState::Busy
6980                    | AttemptState::Finished
6981                    | AttemptState::Failed
6982                    | AttemptState::Orphaned
6983                    | AttemptState::Destroying
6984                    | AttemptState::CleanupDeferred
6985            ) {
6986                attempt.jit_received(harness.clock.now()).unwrap();
6987            }
6988            if matches!(
6989                stage,
6990                AttemptState::Starting
6991                    | AttemptState::Idle
6992                    | AttemptState::Busy
6993                    | AttemptState::Finished
6994                    | AttemptState::Failed
6995                    | AttemptState::Orphaned
6996                    | AttemptState::Destroying
6997                    | AttemptState::CleanupDeferred
6998            ) {
6999                attempt.started_isolated(harness.clock.now()).unwrap();
7000            }
7001            if matches!(
7002                stage,
7003                AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
7004            ) {
7005                attempt.registered_idle(73, harness.clock.now()).unwrap();
7006            }
7007            if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
7008                attempt.assigned_job(73, harness.clock.now()).unwrap();
7009            }
7010            if matches!(
7011                stage,
7012                AttemptState::Finished
7013                    | AttemptState::Failed
7014                    | AttemptState::Orphaned
7015                    | AttemptState::Destroying
7016                    | AttemptState::CleanupDeferred
7017            ) {
7018                let outcome = match stage {
7019                    AttemptState::Finished => AttemptOutcome::CompletedJob,
7020                    AttemptState::Orphaned => AttemptOutcome::Orphaned,
7021                    _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7022                };
7023                attempt.conclude(outcome, harness.clock.now()).unwrap();
7024            }
7025            if matches!(
7026                stage,
7027                AttemptState::Destroying | AttemptState::CleanupDeferred
7028            ) {
7029                attempt.begin_destroy(harness.clock.now()).unwrap();
7030            }
7031            if stage == AttemptState::CleanupDeferred {
7032                attempt.defer_cleanup(harness.clock.now()).unwrap();
7033            }
7034            harness.store.record_attempt(&attempt).unwrap();
7035            launcher
7036                .recover_startup(std::slice::from_ref(&harness.policy))
7037                .await
7038                .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
7039            let resumed = harness.store.attempt(id).unwrap().unwrap();
7040            if matches!(
7041                stage,
7042                AttemptState::Allocated
7043                    | AttemptState::Preparing
7044                    | AttemptState::Prepared
7045                    | AttemptState::Finished
7046                    | AttemptState::Failed
7047                    | AttemptState::Orphaned
7048                    | AttemptState::Destroying
7049                    | AttemptState::CleanupDeferred
7050            ) {
7051                assert_eq!(
7052                    resumed.state(),
7053                    AttemptState::Cleaned,
7054                    "{stage} must retire after restart"
7055                );
7056            } else {
7057                assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
7058                assert_eq!(provider.resource_count(), 1);
7059                provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
7060                    EnvironmentState::Exited;
7061                launcher.supervise(&harness.policy).await.unwrap();
7062                assert_eq!(
7063                    harness.store.attempt(id).unwrap().unwrap().state(),
7064                    AttemptState::Cleaned
7065                );
7066            }
7067            assert_eq!(
7068                provider.resource_count(),
7069                0,
7070                "{stage} resource cleaned exactly once"
7071            );
7072            let destroyed = provider
7073                .actions
7074                .lock()
7075                .unwrap()
7076                .iter()
7077                .filter(|action| **action == "destroy")
7078                .count();
7079            assert_eq!(
7080                destroyed,
7081                usize::from(stage != AttemptState::Allocated),
7082                "{stage} destroy count"
7083            );
7084            assert_eq!(
7085                harness.github.registrations.load(Ordering::SeqCst),
7086                0,
7087                "restart from {stage} cannot duplicate JIT"
7088            );
7089            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7090        }
7091    }
7092
7093    #[tokio::test]
7094    async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
7095        let harness = isolated_harness();
7096        let provider = Arc::new(FakeIsolatedProvider::default());
7097        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7098        let id = AttemptId::from_u128(0x7777);
7099        let runtime = harness.host_root().join("duplicate-provider-ownership");
7100        fs::create_dir_all(&runtime).unwrap();
7101        let mut attempt =
7102            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7103        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7104            harness.policy.execution_policy()
7105        else {
7106            unreachable!()
7107        };
7108        attempt
7109            .allocate_execution(AttemptExecution::Isolated {
7110                provider_kind: Backend::Oci,
7111                environment_id: None,
7112                resolved_image: image.clone(),
7113                generation: "duplicate-generation".into(),
7114            })
7115            .unwrap();
7116        attempt.begin_prepare(harness.clock.now()).unwrap();
7117        harness.store.record_attempt(&attempt).unwrap();
7118        let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7119        let mut second = first.clone();
7120        let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
7121            unreachable!()
7122        };
7123        *environment_id = "another-environment".into();
7124        provider
7125            .resources
7126            .lock()
7127            .unwrap()
7128            .insert(id, (first, EnvironmentState::Running));
7129        provider.extra_resources.lock().unwrap().push(second);
7130        launcher
7131            .recover_startup(std::slice::from_ref(&harness.policy))
7132            .await
7133            .expect("duplicate is local");
7134        let deferred = harness.store.attempt(id).unwrap().unwrap();
7135        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7136        assert!(deferred.counts_against_capacity());
7137        assert_eq!(provider.resource_count(), 1);
7138        assert_eq!(
7139            provider
7140                .actions
7141                .lock()
7142                .unwrap()
7143                .iter()
7144                .filter(|action| **action == "destroy")
7145                .count(),
7146            0
7147        );
7148        provider.extra_resources.lock().unwrap().clear();
7149        // A tolerated cleanup failure is retried after its backoff delay.
7150        harness.clock.advance_secs(31);
7151        launcher
7152            .recover_startup(std::slice::from_ref(&harness.policy))
7153            .await
7154            .expect("single identity recovered");
7155        assert_eq!(provider.resource_count(), 0);
7156        assert_eq!(
7157            harness.store.attempt(id).unwrap().unwrap().state(),
7158            AttemptState::Cleaned
7159        );
7160    }
7161
7162    #[tokio::test]
7163    async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
7164        let harness = isolated_harness();
7165        let provider = Arc::new(FakeIsolatedProvider::default());
7166        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7167        let id = AttemptId::from_u128(0x8888);
7168        let runtime = harness.host_root().join("orphan-provider-resource");
7169        fs::create_dir_all(&runtime).unwrap();
7170        let mut attempt =
7171            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7172        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7173            harness.policy.execution_policy()
7174        else {
7175            unreachable!()
7176        };
7177        attempt
7178            .allocate_execution(AttemptExecution::Isolated {
7179                provider_kind: Backend::Oci,
7180                environment_id: None,
7181                resolved_image: image.clone(),
7182                generation: "orphan-generation".into(),
7183            })
7184            .unwrap();
7185        let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7186        provider
7187            .resources
7188            .lock()
7189            .unwrap()
7190            .insert(id, (identity, EnvironmentState::Running));
7191        launcher
7192            .recover_startup(std::slice::from_ref(&harness.policy))
7193            .await
7194            .expect("orphan cannot block host");
7195        assert_eq!(provider.resource_count(), 1);
7196        assert!(
7197            harness
7198                .events
7199                .events()
7200                .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
7201        );
7202        assert_eq!(
7203            provider
7204                .actions
7205                .lock()
7206                .unwrap()
7207                .iter()
7208                .filter(|action| **action == "destroy")
7209                .count(),
7210            0
7211        );
7212
7213        let EnvironmentIdentity::Isolated { environment_id, .. } = provider
7214            .resources
7215            .lock()
7216            .unwrap()
7217            .get(&id)
7218            .unwrap()
7219            .0
7220            .clone()
7221        else {
7222            unreachable!()
7223        };
7224        attempt.begin_prepare(harness.clock.now()).unwrap();
7225        attempt.prepared_environment(environment_id).unwrap();
7226        attempt.mark_prepared(harness.clock.now()).unwrap();
7227        attempt.jit_received(harness.clock.now()).unwrap();
7228        attempt.started_isolated(harness.clock.now()).unwrap();
7229        attempt
7230            .conclude(
7231                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7232                harness.clock.now(),
7233            )
7234            .unwrap();
7235        attempt.begin_destroy(harness.clock.now()).unwrap();
7236        attempt.clean_isolated(harness.clock.now()).unwrap();
7237        harness.store.record_attempt(&attempt).unwrap();
7238        launcher
7239            .recover_startup(std::slice::from_ref(&harness.policy))
7240            .await
7241            .expect("cleaned row cannot authorize deletion");
7242        assert_eq!(provider.resource_count(), 1);
7243        assert_eq!(
7244            provider
7245                .actions
7246                .lock()
7247                .unwrap()
7248                .iter()
7249                .filter(|action| **action == "destroy")
7250                .count(),
7251            0
7252        );
7253    }
7254
7255    /// The wiring the three-hour outage needed and did not have.
7256    ///
7257    /// A root that refuses a launch does so before `record_allocation`, so no
7258    /// attempt row carries it, and the daemon's log scrubs the paths out of the
7259    /// sentence. The record this asserts is the only surface left, and
7260    /// `service status` reads it -- so if this wiring is ever dropped, the
7261    /// failure goes back to reading `runner_start_failed reason=other` once per
7262    /// poll and nothing else.
7263    #[tokio::test]
7264    async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
7265        use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
7266
7267        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7268            .with_host_runner_root();
7269        harness.ready().await;
7270
7271        // A root under two directories that do not exist: refused by the
7272        // preflight, for a reason that has nothing to do with this platform, so
7273        // the assertion holds on all three.
7274        let unusable = harness
7275            ._root
7276            .path()
7277            .join("absent")
7278            .join("deeper")
7279            .join("runners");
7280        let mut host = harness.host.clone();
7281        host.runner_root_override = Some(
7282            LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
7283                .expect("a local absolute host root"),
7284        );
7285        harness.store.put_host(&host).unwrap();
7286
7287        let failure = harness
7288            .launch_result()
7289            .await
7290            .expect_err("a root whose parents are missing cannot hold a runner");
7291        assert!(
7292            matches!(failure.reason, FailureReason::Other(_)),
7293            "{failure:?}"
7294        );
7295
7296        let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
7297        let refusal = refusals
7298            .first()
7299            .expect("the refusal reached the one surface that can hold it");
7300        assert_eq!(refusal.policy, harness.policy.id.to_string());
7301        assert_eq!(refusal.kind, "missing_parents");
7302        assert!(
7303            refusal.root.contains("runners") && refusal.detail.contains("runners"),
7304            "the directory must be named in full: {refusal:?}"
7305        );
7306
7307        // And a root that works clears it, so a host that has been fixed stops
7308        // reporting a fault it no longer has.
7309        harness.store.put_host(&harness.host).unwrap();
7310        harness.launch().await;
7311        assert!(
7312            runner_root_refusals(&harness.app_paths)
7313                .expect("readable")
7314                .is_empty(),
7315            "a successful placement clears that policy's record"
7316        );
7317
7318        clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
7319            .expect("cleanup");
7320    }
7321
7322    #[tokio::test]
7323    async fn a_launch_reports_how_long_each_step_took() {
7324        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7325        harness.ready().await;
7326        let started = harness.launch().await;
7327        let events = harness.events.events();
7328        let launched = events
7329            .iter()
7330            .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
7331            .expect("a successful launch reports its step timings");
7332        let starting = events
7333            .iter()
7334            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
7335            .unwrap();
7336        assert!(starting < launched, "timings follow the start: {events:?}");
7337    }
7338
7339    #[tokio::test]
7340    async fn a_failed_launch_still_reports_its_step_timings() {
7341        let harness = Harness::new(
7342            FakeGithubLifecycle::default().fail(true),
7343            Arc::new(PersistentDemand),
7344        );
7345        harness.ready().await;
7346        let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
7347        assert!(failed.is_err());
7348
7349        let timings = harness
7350            .events
7351            .events()
7352            .into_iter()
7353            .find_map(|event| match event {
7354                AttemptEvent::Launched {
7355                    started: false,
7356                    timings,
7357                    ..
7358                } => Some(timings),
7359                _ => None,
7360            })
7361            .expect("a launch that failed at registration reports its timings");
7362        assert_eq!(timings.lock_wait, Duration::from_secs(90));
7363        assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
7364        assert!(timings.total() >= Duration::from_secs(90));
7365    }
7366
7367    #[cfg(target_os = "macos")]
7368    #[tokio::test]
7369    async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
7370        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7371        harness.ready().await;
7372
7373        // A runtime this host once placed under a root it no longer uses.
7374        let old_root = harness._root.path().join("old-root");
7375        let old_runtime = old_root.join("abandoned");
7376        fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
7377        fs::create_dir_all(&old_runtime).unwrap();
7378        let old = RunnerAttempt::allocate(
7379            AttemptId::new_random(),
7380            harness.policy.id,
7381            &old_runtime,
7382            harness.clock.now(),
7383        );
7384        harness.store.record_attempt(&old).unwrap();
7385        let kept = harness.host_root().join(PACKAGE_SEED_DIR);
7386        fs::create_dir_all(kept.join("2.336.0")).unwrap();
7387
7388        harness.launch().await;
7389
7390        assert!(
7391            !old_root.join(PACKAGE_SEED_DIR).exists(),
7392            "the abandoned root's seed is removed"
7393        );
7394        assert!(old_runtime.exists(), "only the seed is touched");
7395        assert!(kept.exists(), "the host root's own seed is kept");
7396    }
7397
7398    #[tokio::test]
7399    async fn a_job_walks_every_state_and_cleans_every_artifact() {
7400        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7401        harness.ready().await;
7402        let started = harness.launch().await;
7403        assert_eq!(started.state(), AttemptState::Starting);
7404        assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7405
7406        harness
7407            .github
7408            .observe(GithubRunnerObservation::Registered { busy: false });
7409        harness.launcher.supervise(&harness.policy).await.unwrap();
7410        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7411
7412        harness
7413            .github
7414            .observe(GithubRunnerObservation::Registered { busy: true });
7415        harness.launcher.supervise(&harness.policy).await.unwrap();
7416        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7417
7418        harness.processes.finish_successfully();
7419        harness
7420            .github
7421            .observe(GithubRunnerObservation::NotRegistered);
7422        harness.launcher.supervise(&harness.policy).await.unwrap();
7423        let cleaned = harness.only_attempt();
7424        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7425        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7426        assert!(!started.runtime_path().exists());
7427        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7428        assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7429
7430        let states: Vec<_> = harness
7431            .events
7432            .events()
7433            .into_iter()
7434            .filter_map(|event| match event {
7435                AttemptEvent::State { state, .. } => Some(state),
7436                _ => None,
7437            })
7438            .collect();
7439        assert_eq!(
7440            states,
7441            vec![
7442                AttemptState::Allocated,
7443                AttemptState::JitReceived,
7444                AttemptState::Starting,
7445                AttemptState::Idle,
7446                AttemptState::Busy,
7447                AttemptState::Finished,
7448                AttemptState::Cleaned,
7449            ]
7450        );
7451    }
7452
7453    #[tokio::test]
7454    async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7455        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7456        harness.ready().await;
7457        let started = harness.launch().await;
7458        let runtime = started.runtime_path().to_path_buf();
7459
7460        harness
7461            .github
7462            .observe(GithubRunnerObservation::Registered { busy: true });
7463        harness.launcher.supervise(&harness.policy).await.unwrap();
7464        harness.processes.finish_successfully();
7465        harness
7466            .github
7467            .observe(GithubRunnerObservation::NotRegistered);
7468        harness.launcher.supervise(&harness.policy).await.unwrap();
7469
7470        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7471        assert!(!runtime.exists());
7472        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7473
7474        // Reproduce a late orphan creating output after the successful removal
7475        // and journal transition. The next ordinary policy supervision must
7476        // remove it without replaying package release or another state change.
7477        let residue = runtime.join("_work").join("late-node-process");
7478        fs::create_dir_all(&residue).unwrap();
7479        fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7480
7481        harness.launcher.supervise(&harness.policy).await.unwrap();
7482
7483        assert!(!runtime.exists(), "late ephemeral residue is reaped");
7484        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7485        assert_eq!(
7486            harness.packages.releases.load(Ordering::SeqCst),
7487            1,
7488            "reaping residue does not release the package lease twice"
7489        );
7490    }
7491
7492    #[tokio::test]
7493    async fn locked_late_residue_never_blocks_startup_recovery() {
7494        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7495        harness.ready().await;
7496        let started = harness.launch().await;
7497        let runtime = started.runtime_path().to_path_buf();
7498
7499        harness
7500            .github
7501            .observe(GithubRunnerObservation::Registered { busy: true });
7502        harness.launcher.supervise(&harness.policy).await.unwrap();
7503        harness.processes.finish_successfully();
7504        harness
7505            .github
7506            .observe(GithubRunnerObservation::NotRegistered);
7507        harness.launcher.supervise(&harness.policy).await.unwrap();
7508        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7509
7510        let held = runtime.join("_work").join("late-node-process");
7511        fs::create_dir_all(&held).unwrap();
7512        fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7513        let Some(block) = BlockedDeletion::inject(&held) else {
7514            eprintln!(
7515                "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7516            );
7517            return;
7518        };
7519
7520        let restarted = harness.restart();
7521        restarted
7522            .recover_startup(std::slice::from_ref(&harness.policy))
7523            .await
7524            .expect("late residue cannot take the daemon offline");
7525        assert!(runtime.exists(), "the locked residue remains for a retry");
7526        assert!(
7527            harness
7528                .reconcile_events
7529                .events()
7530                .iter()
7531                .any(|event| matches!(
7532                    event,
7533                    LifecycleEvent::AttemptCleanFailed {
7534                        attempt,
7535                        reason: "late_ephemeral_workspace_could_not_be_removed",
7536                        ..
7537                    } if *attempt == started.id
7538                )),
7539            "the non-blocking cleanup failure remains visible"
7540        );
7541
7542        block.release();
7543        // A tolerated cleanup failure is retried after its backoff delay.
7544        harness.clock.advance_secs(31);
7545        restarted
7546            .supervise(&harness.policy)
7547            .await
7548            .expect("the ordinary retry succeeds after the lock is released");
7549        assert!(!runtime.exists(), "the retry removes the late residue");
7550        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7551        assert_eq!(
7552            harness.packages.releases.load(Ordering::SeqCst),
7553            1,
7554            "retrying residue never releases the package lease twice"
7555        );
7556    }
7557
7558    #[tokio::test]
7559    async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
7560        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7561        harness.ready().await;
7562
7563        // The first attempt is terminal but its initial cleanup cannot finish.
7564        // This is the state left by the original failed job on the affected
7565        // workstation.
7566        let terminal = harness.launch().await;
7567        let terminal_runtime = terminal.runtime_path().to_path_buf();
7568        harness.conclude(terminal.id);
7569        let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
7570        else {
7571            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7572            return;
7573        };
7574
7575        // The second attempt was cleaned successfully, then a child recreated
7576        // part of `_work`. Both real-world residue shapes existed together in
7577        // the incident, and neither may short-circuit recovery of the other.
7578        let cleaned = harness.launch().await;
7579        let cleaned_runtime = cleaned.runtime_path().to_path_buf();
7580        harness.conclude(cleaned.id);
7581        harness
7582            .launcher
7583            .clean(cleaned.id)
7584            .await
7585            .expect("the second attempt initially cleans");
7586        let Some(cleaned_block) =
7587            BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
7588        else {
7589            terminal_block.release();
7590            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7591            return;
7592        };
7593
7594        let restarted = harness.restart();
7595        for _ in 0..2 {
7596            restarted
7597                .recover_startup(std::slice::from_ref(&harness.policy))
7598                .await
7599                .expect("repeated recovery remains ready while both residues are locked");
7600        }
7601        assert_ne!(
7602            harness.attempt(terminal.id).state(),
7603            AttemptState::Cleaned,
7604            "the journal does not claim the terminal workspace was removed"
7605        );
7606        assert_eq!(
7607            harness.attempt(cleaned.id).state(),
7608            AttemptState::Cleaned,
7609            "late residue does not undo an already durable cleaned transition"
7610        );
7611        assert!(terminal_runtime.exists());
7612        assert!(cleaned_runtime.exists());
7613        assert_eq!(
7614            harness.packages.releases.load(Ordering::SeqCst),
7615            1,
7616            "repeated recovery neither loses nor duplicates package leases"
7617        );
7618
7619        terminal_block.release();
7620        cleaned_block.release();
7621        // A tolerated cleanup failure is retried after its backoff delay.
7622        harness.clock.advance_secs(31);
7623        restarted
7624            .supervise(&harness.policy)
7625            .await
7626            .expect("one ordinary pass clears both residues after their locks disappear");
7627        assert!(!terminal_runtime.exists());
7628        assert!(!cleaned_runtime.exists());
7629        assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7630        assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7631        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7632    }
7633
7634    #[tokio::test]
7635    async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7636        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7637        harness.ready().await;
7638        let attempt = harness.launch().await;
7639        let runtime = attempt.runtime_path().to_path_buf();
7640        harness.conclude(attempt.id);
7641        fs::remove_dir_all(&runtime).unwrap();
7642
7643        harness
7644            .restart()
7645            .recover_startup(std::slice::from_ref(&harness.policy))
7646            .await
7647            .expect("an already absent disposable runtime is successful cleanup");
7648
7649        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7650        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7651    }
7652
7653    #[tokio::test]
7654    async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7655        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7656        harness.ready().await;
7657        let attempt = harness.launch().await;
7658        let runtime = attempt.runtime_path().to_path_buf();
7659        harness.conclude(attempt.id);
7660        harness.packages.fail_releases(1);
7661
7662        let restarted = harness.restart();
7663        let failure = restarted
7664            .recover_startup(std::slice::from_ref(&harness.policy))
7665            .await
7666            .expect_err("a package-accounting failure is not safe to downgrade to residue");
7667        assert!(
7668            failure
7669                .reason()
7670                .to_string()
7671                .contains("package lease could not be released")
7672        );
7673        assert!(
7674            !runtime.exists(),
7675            "workspace removal completed before release failed"
7676        );
7677        assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7678
7679        restarted
7680            .recover_startup(std::slice::from_ref(&harness.policy))
7681            .await
7682            .expect("the same journal entry retries safely after the transient failure");
7683        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7684        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7685    }
7686
7687    #[tokio::test]
7688    async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7689        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7690            .with_persistent_workspace(1);
7691        harness.ready().await;
7692        let attempt = harness.launch().await;
7693        let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7694        fs::create_dir_all(retained.parent().unwrap()).unwrap();
7695        fs::write(&retained, b"persistent checkout").unwrap();
7696        harness.cleanup_retaining_work(attempt.id).await;
7697
7698        harness
7699            .restart()
7700            .recover_startup(std::slice::from_ref(&harness.policy))
7701            .await
7702            .expect("a cleaned persistent slot is already reconciled");
7703
7704        assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7705        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7706    }
7707
7708    #[tokio::test]
7709    async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7710        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7711        harness.ready().await;
7712        let started = harness.launch().await;
7713        harness
7714            .github
7715            .observe(GithubRunnerObservation::Registered { busy: false });
7716        harness.launcher.supervise(&harness.policy).await.unwrap();
7717        harness.clock.advance_secs(11);
7718        harness.processes.set_alive(false);
7719        harness
7720            .github
7721            .observe(GithubRunnerObservation::NotRegistered);
7722        harness.launcher.supervise(&harness.policy).await.unwrap();
7723
7724        let cleaned = harness.only_attempt();
7725        assert!(cleaned.outcome().unwrap().is_idle_exit());
7726        assert!(!cleaned.outcome().unwrap().is_failure());
7727        assert!(!started.runtime_path().exists());
7728        assert!(
7729            harness
7730                .reconcile_events
7731                .events()
7732                .iter()
7733                .any(|event| matches!(
7734                    event,
7735                    LifecycleEvent::AttemptCleaned {
7736                        outcome: OutcomeKind::IdleExit,
7737                        ..
7738                    }
7739                ))
7740        );
7741        assert!(!harness.events.events().iter().any(|event| matches!(
7742            event,
7743            AttemptEvent::Concluded {
7744                outcome: OutcomeKind::Failed,
7745                ..
7746            }
7747        )));
7748    }
7749
7750    #[tokio::test]
7751    async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
7752        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7753        harness.processes.fail_spawns(2);
7754        harness.ready().await;
7755        let attempt = harness.launch().await;
7756        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
7757        assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
7758        let names: Vec<_> = fs::read_dir(attempt.runtime_path())
7759            .unwrap()
7760            .map(|entry| entry.unwrap().file_name())
7761            .collect();
7762        assert!(
7763            names.iter().all(|name| {
7764                !name
7765                    .to_string_lossy()
7766                    .starts_with(RestrictiveHandoff::NAME_PREFIX)
7767            }),
7768            "JIT artifact survived: {names:?}"
7769        );
7770        assert_eq!(
7771            *harness.delay.0.lock().unwrap(),
7772            vec![Duration::from_millis(10), Duration::from_millis(20)]
7773        );
7774    }
7775
7776    #[tokio::test]
7777    async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
7778        let gone = Harness::new(
7779            FakeGithubLifecycle::default().fail(false),
7780            Arc::new(FakeDemand::answering([false])),
7781        );
7782        gone.ready().await;
7783        assert!(gone.launch_result().await.is_err());
7784        assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
7785        assert!(gone.delay.0.lock().unwrap().is_empty());
7786
7787        let forbidden = Harness::new(
7788            FakeGithubLifecycle::default().fail(true),
7789            Arc::new(PersistentDemand),
7790        );
7791        forbidden.ready().await;
7792        assert!(forbidden.launch_result().await.is_err());
7793        assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
7794        assert!(forbidden.delay.0.lock().unwrap().is_empty());
7795        assert!(matches!(
7796            forbidden.only_attempt().outcome(),
7797            Some(AttemptOutcome::Failed {
7798                reason: FailureReason::Other(action)
7799            }) if action.contains("403")
7800        ));
7801
7802        let transient = Harness::new(
7803            FakeGithubLifecycle::default().fail(false).fail(false),
7804            Arc::new(PersistentDemand),
7805        );
7806        transient.ready().await;
7807        transient.launch().await;
7808        assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
7809        assert_eq!(
7810            *transient.delay.0.lock().unwrap(),
7811            vec![Duration::from_millis(10), Duration::from_millis(20)]
7812        );
7813    }
7814
7815    /// The layout has to leave room for what the runner writes underneath it.
7816    ///
7817    /// Windows refuses a path over `MAX_PATH`, and this product's own CI hit
7818    /// that: 264 characters against a limit of 260, failing three checkout
7819    /// retries with `Filename too long`. The two identifiers in the old layout
7820    /// cost 74 characters between them for no benefit -- an attempt id is
7821    /// unique on its own.
7822    #[test]
7823    fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
7824        const MAX_PATH: usize = 260;
7825        // The real root on the machine this was found on.
7826        let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
7827        // What `actions/checkout` writes at its deepest: the work directory,
7828        // the repository named twice, and a pack keep-file with a 40-character
7829        // object name.
7830        let repo = "GitHub-Runner-Scaler-UI";
7831        let deepest = format!(
7832            r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
7833            "0".repeat(40)
7834        );
7835
7836        let name = workspace_name(AttemptId::new_random());
7837        assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
7838        assert!(
7839            name.chars().all(|c| c.is_ascii_hexdigit()),
7840            "a directory name must not carry the identifier's dashes: {name}"
7841        );
7842
7843        let full = format!(r"{root}\{name}\{deepest}");
7844        assert!(
7845            full.len() < MAX_PATH,
7846            "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
7847            full.len()
7848        );
7849
7850        // The discriminator: the layout this replaced does not fit, so a test
7851        // that passed for both would be proving nothing.
7852        let old = format!(
7853            r"{root}\{}\{}\{deepest}",
7854            PolicyId::new_random(),
7855            AttemptId::new_random()
7856        );
7857        assert!(
7858            old.len() > MAX_PATH,
7859            "the old layout is supposed to be the thing that did not fit: {} characters",
7860            old.len()
7861        );
7862    }
7863
7864    #[tokio::test]
7865    async fn two_attempts_never_share_a_workspace_even_after_failure() {
7866        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7867        harness.ready().await;
7868        let first = harness.launch().await;
7869        fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
7870        harness
7871            .github
7872            .observe(GithubRunnerObservation::Registered { busy: false });
7873        harness.launcher.supervise(&harness.policy).await.unwrap();
7874        harness.clock.advance_secs(11);
7875        harness.processes.set_alive(false);
7876        harness
7877            .github
7878            .observe(GithubRunnerObservation::NotRegistered);
7879        harness.launcher.supervise(&harness.policy).await.unwrap();
7880        assert!(!first.runtime_path().exists());
7881
7882        let second = harness.launch().await;
7883        assert_ne!(first.runtime_path(), second.runtime_path());
7884        assert!(!second.runtime_path().join("hostile-leftover").exists());
7885
7886        fs::write(
7887            second.runtime_path().join("hostile-on-failure"),
7888            b"second job",
7889        )
7890        .unwrap();
7891        harness.processes.set_alive(false);
7892        harness
7893            .github
7894            .observe(GithubRunnerObservation::NotRegistered);
7895        harness.launcher.supervise(&harness.policy).await.unwrap();
7896        assert!(
7897            !second.runtime_path().exists(),
7898            "failed workspace was retained"
7899        );
7900    }
7901
7902    #[tokio::test]
7903    async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
7904        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7905        harness.ready().await;
7906        let attempt = harness.launch().await;
7907
7908        // Registered and waiting, which is where it stays: the fake keeps
7909        // answering the same observation, exactly as GitHub does for a runner
7910        // nobody assigns work to.
7911        harness
7912            .github
7913            .observe(GithubRunnerObservation::Registered { busy: false });
7914        harness.launcher.supervise(&harness.policy).await.unwrap();
7915        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7916
7917        // One second inside the ten-second idle timeout nothing happens, which
7918        // is what keeps this from being a test that would pass on any clock.
7919        harness.clock.advance_secs(9);
7920        harness
7921            .github
7922            .observe(GithubRunnerObservation::Registered { busy: false });
7923        let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
7924        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7925        assert!(none_yet.is_empty());
7926        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
7927
7928        // Past it, the agent ends the runner itself.
7929        harness.clock.advance_secs(1);
7930        harness
7931            .github
7932            .observe(GithubRunnerObservation::Registered { busy: false });
7933        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7934
7935        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7936        assert_eq!(
7937            concluded.outcome(),
7938            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7939            "a surplus runner did not fail; recording one as a failure sends an operator \
7940             hunting a fault that does not exist"
7941        );
7942        assert_eq!(concluded.state(), AttemptState::Cleaned);
7943        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
7944        assert!(!attempt.runtime_path().exists());
7945
7946        // The registration goes with it. Without this the runner stays listed
7947        // in the target's runner settings after the process it named is gone.
7948        assert_eq!(
7949            *harness.github.deregistrations.lock().unwrap(),
7950            vec![73],
7951            "the attempt's own runner id, deleted exactly once"
7952        );
7953
7954        // And nothing is started in its place: the work it was launched for went
7955        // elsewhere, so a replacement would rebuild it every idle timeout.
7956        assert!(
7957            replacements.is_empty(),
7958            "a surplus exit must not request a replacement"
7959        );
7960    }
7961
7962    #[tokio::test]
7963    async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
7964        // The delete is best-effort by construction: the process is gone and the
7965        // slot has to come back. Holding the conclusion until GitHub cooperates
7966        // would leak a capacity slot on every unreachable moment.
7967        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7968        harness.ready().await;
7969        let attempt = harness.launch().await;
7970        harness
7971            .github
7972            .observe(GithubRunnerObservation::Registered { busy: false });
7973        harness.launcher.supervise(&harness.policy).await.unwrap();
7974
7975        harness
7976            .github
7977            .deregistration_fails
7978            .store(true, Ordering::SeqCst);
7979        harness.clock.advance_secs(11);
7980        harness
7981            .github
7982            .observe(GithubRunnerObservation::Registered { busy: false });
7983        harness.launcher.supervise(&harness.policy).await.unwrap();
7984
7985        assert_eq!(
7986            *harness.github.deregistrations.lock().unwrap(),
7987            vec![73],
7988            "the delete was attempted"
7989        );
7990        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7991        assert_eq!(
7992            concluded.outcome(),
7993            Some(&AttemptOutcome::ExitedIdleWithoutWork),
7994            "the attempt concluded anyway"
7995        );
7996        assert_eq!(concluded.state(), AttemptState::Cleaned);
7997        assert!(!attempt.runtime_path().exists());
7998    }
7999
8000    #[tokio::test]
8001    async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
8002        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8003        harness.ready().await;
8004        let first = harness.launch().await;
8005        harness.processes.set_alive(false);
8006        harness
8007            .github
8008            .observe(GithubRunnerObservation::NotRegistered);
8009        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8010        let failed = harness.store.attempt(first.id).unwrap().unwrap();
8011        assert!(matches!(
8012            failed.outcome(),
8013            Some(AttemptOutcome::Failed {
8014                reason: FailureReason::ProcessExitedUnexpectedly
8015            })
8016        ));
8017        assert!(!first.runtime_path().exists());
8018
8019        assert_eq!(
8020            replacements,
8021            vec![ReplacementIntent {
8022                policy: harness.policy.id,
8023                previous_attempt: first.id,
8024                operation: "exit_before_acceptance_replacement",
8025            }]
8026        );
8027        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8028        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8029        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8030        assert!(harness.delay.0.lock().unwrap().is_empty());
8031    }
8032
8033    /// Writes the runner's own diagnostics: always the listener's log, and the
8034    /// job worker's when `ran_a_job`. `Runner.Listener` starts `Runner.Worker`
8035    /// only for a job it has acquired.
8036    fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
8037        let diag = runtime.join("_diag");
8038        fs::create_dir_all(&diag).unwrap();
8039        fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
8040        if ran_a_job {
8041            fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
8042        }
8043    }
8044
8045    /// The macOS field case: a job of a few seconds starts and ends between two
8046    /// passes, so GitHub was never seen reporting the runner busy -- the attempt
8047    /// is still `starting` -- and the registration is already gone. It used to be
8048    /// recorded `process_exited_unexpectedly`, with a replacement runner.
8049    #[tokio::test]
8050    async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
8051        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8052        harness.ready().await;
8053        let started = harness.launch().await;
8054        assert_eq!(started.state(), AttemptState::Starting);
8055        write_runner_diagnostics(started.runtime_path(), true);
8056        harness.processes.finish_successfully();
8057        harness
8058            .github
8059            .observe(GithubRunnerObservation::NotRegistered);
8060
8061        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8062
8063        let cleaned = harness.only_attempt();
8064        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8065        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8066        assert_eq!(cleaned.github_runner_id(), Some(73));
8067        assert!(
8068            replacements.is_empty(),
8069            "a finished job needs no replacement"
8070        );
8071        let busy_seen = harness.events.events().into_iter().any(|event| {
8072            matches!(
8073                event,
8074                AttemptEvent::State {
8075                    state: AttemptState::Busy,
8076                    ..
8077                }
8078            )
8079        });
8080        assert!(busy_seen, "the journal walks the diagram to `busy` first");
8081    }
8082
8083    /// The same from `idle`: seen registered and waiting once, then gone.
8084    #[tokio::test]
8085    async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
8086        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8087        harness.ready().await;
8088        let started = harness.launch().await;
8089        harness
8090            .github
8091            .observe(GithubRunnerObservation::Registered { busy: false });
8092        harness.launcher.supervise(&harness.policy).await.unwrap();
8093        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8094
8095        write_runner_diagnostics(started.runtime_path(), true);
8096        harness.processes.finish_successfully();
8097        harness
8098            .github
8099            .observe(GithubRunnerObservation::NotRegistered);
8100        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8101
8102        assert_eq!(
8103            harness.only_attempt().outcome(),
8104            Some(&AttemptOutcome::CompletedJob)
8105        );
8106        assert!(replacements.is_empty());
8107    }
8108
8109    /// The reverse error, from the same sampling: GitHub showed the runner
8110    /// assigned, the runner never started the job (no worker log), and it exited
8111    /// 0. That is not a completed job.
8112    #[tokio::test]
8113    async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
8114        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8115        harness.ready().await;
8116        let started = harness.launch().await;
8117        harness
8118            .github
8119            .observe(GithubRunnerObservation::Registered { busy: true });
8120        harness.launcher.supervise(&harness.policy).await.unwrap();
8121        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8122
8123        write_runner_diagnostics(started.runtime_path(), false);
8124        harness.processes.finish_successfully();
8125        harness
8126            .github
8127            .observe(GithubRunnerObservation::NotRegistered);
8128        harness.launcher.supervise(&harness.policy).await.unwrap();
8129
8130        let cleaned = harness.only_attempt();
8131        assert_eq!(
8132            cleaned.outcome(),
8133            Some(&AttemptOutcome::ExitedIdleWithoutWork)
8134        );
8135        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8136    }
8137
8138    /// The mirror case: the runner really ran its job and exited 0 before GitHub
8139    /// took it off the list. It used to be recorded `orphaned`.
8140    #[tokio::test]
8141    async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
8142        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8143        harness.ready().await;
8144        let started = harness.launch().await;
8145        harness
8146            .github
8147            .observe(GithubRunnerObservation::Registered { busy: true });
8148        harness.launcher.supervise(&harness.policy).await.unwrap();
8149
8150        write_runner_diagnostics(started.runtime_path(), true);
8151        harness.processes.finish_successfully();
8152        harness
8153            .github
8154            .observe(GithubRunnerObservation::Registered { busy: true });
8155        harness.launcher.supervise(&harness.policy).await.unwrap();
8156
8157        assert_eq!(
8158            harness.only_attempt().outcome(),
8159            Some(&AttemptOutcome::CompletedJob)
8160        );
8161        assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
8162    }
8163
8164    /// A live runner journalled `busy` whose assignment GitHub withdrew goes
8165    /// back to `idle`, where the idle timeout holds it again.
8166    #[tokio::test]
8167    async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
8168        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8169        harness.ready().await;
8170        let started = harness.launch().await;
8171        harness
8172            .github
8173            .observe(GithubRunnerObservation::Registered { busy: true });
8174        harness.launcher.supervise(&harness.policy).await.unwrap();
8175        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8176
8177        write_runner_diagnostics(started.runtime_path(), false);
8178        harness
8179            .github
8180            .observe(GithubRunnerObservation::Registered { busy: false });
8181        harness.launcher.supervise(&harness.policy).await.unwrap();
8182        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8183
8184        // A runner that did start its job keeps `busy` whatever GitHub says.
8185        harness
8186            .github
8187            .observe(GithubRunnerObservation::Registered { busy: true });
8188        harness.launcher.supervise(&harness.policy).await.unwrap();
8189        write_runner_diagnostics(started.runtime_path(), true);
8190        harness
8191            .github
8192            .observe(GithubRunnerObservation::Registered { busy: false });
8193        harness.launcher.supervise(&harness.policy).await.unwrap();
8194        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8195    }
8196
8197    /// While GitHub cannot be reached nothing is concluded, so a registration
8198    /// still listed there is removed once it answers.
8199    #[tokio::test]
8200    async fn a_clean_exit_waits_for_github_to_answer() {
8201        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8202        harness.ready().await;
8203        let started = harness.launch().await;
8204        write_runner_diagnostics(started.runtime_path(), true);
8205        harness.processes.finish_successfully();
8206        harness.github.observe(GithubRunnerObservation::Unreachable);
8207        harness.launcher.supervise(&harness.policy).await.unwrap();
8208        assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
8209
8210        harness
8211            .github
8212            .observe(GithubRunnerObservation::NotRegistered);
8213        harness.launcher.supervise(&harness.policy).await.unwrap();
8214        assert_eq!(
8215            harness.only_attempt().outcome(),
8216            Some(&AttemptOutcome::CompletedJob)
8217        );
8218    }
8219
8220    /// A runner id nobody can name is not a reason to stop supervising the
8221    /// policy: the attempt is concluded the way it was before, without one.
8222    #[tokio::test]
8223    async fn a_short_job_with_no_runner_id_still_concludes() {
8224        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8225        harness.ready().await;
8226        let started = harness.launch().await;
8227        fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
8228        write_runner_diagnostics(started.runtime_path(), true);
8229        harness.processes.finish_successfully();
8230        harness
8231            .github
8232            .observe(GithubRunnerObservation::NotRegistered);
8233        harness
8234            .launcher
8235            .supervise(&harness.policy)
8236            .await
8237            .expect("supervision goes on");
8238        assert!(harness.only_attempt().is_terminal());
8239    }
8240
8241    /// Without the runner's diagnostics nothing changes: a clean exit seen only
8242    /// at `starting` is still a runner that exited before taking a job.
8243    #[tokio::test]
8244    async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
8245        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8246        harness.ready().await;
8247        harness.launch().await;
8248        harness.processes.finish_successfully();
8249        harness
8250            .github
8251            .observe(GithubRunnerObservation::NotRegistered);
8252        harness.launcher.supervise(&harness.policy).await.unwrap();
8253        assert!(matches!(
8254            harness.only_attempt().outcome(),
8255            Some(AttemptOutcome::Failed {
8256                reason: FailureReason::ProcessExitedUnexpectedly
8257            })
8258        ));
8259    }
8260
8261    #[tokio::test]
8262    async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
8263        let harness = Harness::new(
8264            FakeGithubLifecycle::default(),
8265            Arc::new(FakeDemand::answering([false])),
8266        );
8267        let id = AttemptId::new_random();
8268        let runtime = harness
8269            .launcher
8270            .app_paths
8271            .runtime_dir()
8272            .join(harness.policy.id.to_string())
8273            .join(id.to_string());
8274        fs::create_dir_all(&runtime).unwrap();
8275        let mut attempt =
8276            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8277        attempt.jit_received(harness.clock.now()).unwrap();
8278        harness.store.record_attempt(&attempt).unwrap();
8279        harness.clock.advance_secs(11);
8280        let replacements = harness
8281            .launcher
8282            .recover_startup(std::slice::from_ref(&harness.policy))
8283            .await
8284            .unwrap();
8285        assert_eq!(
8286            replacements,
8287            vec![ReplacementIntent {
8288                policy: harness.policy.id,
8289                previous_attempt: id,
8290                operation: "jit_expired_replacement",
8291            }]
8292        );
8293
8294        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8295        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8296        assert!(matches!(
8297            cleaned.outcome(),
8298            Some(AttemptOutcome::Failed {
8299                reason: FailureReason::JitExpired
8300            })
8301        ));
8302        assert!(!runtime.exists());
8303        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8304        assert!(harness.delay.0.lock().unwrap().is_empty());
8305    }
8306
8307    #[tokio::test]
8308    async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
8309        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8310        let id = AttemptId::new_random();
8311        let runtime = harness
8312            .launcher
8313            .app_paths
8314            .runtime_dir()
8315            .join("expired-with-demand");
8316        fs::create_dir_all(&runtime).unwrap();
8317        let mut attempt =
8318            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8319        attempt.jit_received(harness.clock.now()).unwrap();
8320        harness.store.record_attempt(&attempt).unwrap();
8321        harness.clock.advance_secs(11);
8322        let replacements = harness
8323            .launcher
8324            .recover_startup(std::slice::from_ref(&harness.policy))
8325            .await
8326            .unwrap();
8327
8328        let attempts = harness.store.attempts().unwrap();
8329        assert_eq!(attempts.len(), 1);
8330        assert_eq!(
8331            attempts
8332                .iter()
8333                .find(|attempt| attempt.id == id)
8334                .unwrap()
8335                .state(),
8336            AttemptState::Cleaned
8337        );
8338        assert_eq!(
8339            replacements,
8340            vec![ReplacementIntent {
8341                policy: harness.policy.id,
8342                previous_attempt: id,
8343                operation: "jit_expired_replacement",
8344            }]
8345        );
8346        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8347        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8348        assert!(harness.delay.0.lock().unwrap().is_empty());
8349    }
8350
8351    #[tokio::test]
8352    async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
8353        let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8354        persistent.packages.fail_materializations(2);
8355        persistent.ready().await;
8356        persistent.launch().await;
8357        assert_eq!(
8358            persistent.packages.materializations.load(Ordering::SeqCst),
8359            3
8360        );
8361        assert_eq!(
8362            *persistent.delay.0.lock().unwrap(),
8363            vec![Duration::from_millis(10), Duration::from_millis(20)]
8364        );
8365
8366        let gone_before_wait = Harness::new(
8367            FakeGithubLifecycle::default(),
8368            Arc::new(FakeDemand::answering([false])),
8369        );
8370        gone_before_wait.packages.fail_materializations(3);
8371        gone_before_wait.ready().await;
8372        assert!(gone_before_wait.launch_result().await.is_err());
8373        assert_eq!(
8374            gone_before_wait
8375                .packages
8376                .materializations
8377                .load(Ordering::SeqCst),
8378            1
8379        );
8380        assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
8381
8382        let gone_during_wait = Harness::new(
8383            FakeGithubLifecycle::default(),
8384            Arc::new(FakeDemand::answering([true, false])),
8385        );
8386        gone_during_wait.packages.fail_materializations(3);
8387        gone_during_wait.ready().await;
8388        assert!(gone_during_wait.launch_result().await.is_err());
8389        assert_eq!(
8390            gone_during_wait
8391                .packages
8392                .materializations
8393                .load(Ordering::SeqCst),
8394            1
8395        );
8396        assert_eq!(
8397            *gone_during_wait.delay.0.lock().unwrap(),
8398            vec![Duration::from_millis(10)]
8399        );
8400    }
8401
8402    #[tokio::test]
8403    async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8404        let harness = Harness::new(
8405            FakeGithubLifecycle::default(),
8406            Arc::new(FakeDemand::answering([true, false])),
8407        );
8408        harness.ready().await;
8409        let first = harness.launch().await;
8410        harness.processes.set_alive(false);
8411        harness
8412            .github
8413            .observe(GithubRunnerObservation::NotRegistered);
8414        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8415
8416        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8417        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8418        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8419        assert!(harness.delay.0.lock().unwrap().is_empty());
8420        assert_eq!(
8421            replacements,
8422            vec![ReplacementIntent {
8423                policy: harness.policy.id,
8424                previous_attempt: first.id,
8425                operation: "exit_before_acceptance_replacement",
8426            }]
8427        );
8428        assert_eq!(
8429            harness.store.attempt(first.id).unwrap().unwrap().state(),
8430            AttemptState::Cleaned
8431        );
8432    }
8433
8434    #[tokio::test]
8435    async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8436        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8437        let before = harness.launch_result().await;
8438        assert!(before.is_err());
8439        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8440
8441        let id = AttemptId::new_random();
8442        let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8443        fs::create_dir_all(&runtime).unwrap();
8444        let mut attempt =
8445            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8446        attempt.jit_received(harness.clock.now()).unwrap();
8447        attempt.started(4242, harness.clock.now()).unwrap();
8448        harness.store.record_attempt(&attempt).unwrap();
8449        harness.processes.set_alive(true);
8450        harness
8451            .github
8452            .observe(GithubRunnerObservation::NotRegistered);
8453        let replacements = harness
8454            .launcher
8455            .recover_startup(std::slice::from_ref(&harness.policy))
8456            .await
8457            .unwrap();
8458        assert!(replacements.is_empty());
8459        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8460        assert!(
8461            harness
8462                .events
8463                .events()
8464                .contains(&AttemptEvent::Adopted { attempt: id })
8465        );
8466    }
8467
8468    #[tokio::test]
8469    async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8470        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8471        let id = AttemptId::new_random();
8472        let runtime = harness
8473            .launcher
8474            .app_paths
8475            .runtime_dir()
8476            .join("spawn-before-starting");
8477        fs::create_dir_all(&runtime).unwrap();
8478        let mut attempt =
8479            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8480        attempt.jit_received(harness.clock.now()).unwrap();
8481        harness.store.record_attempt(&attempt).unwrap();
8482        harness.processes.set_alive(true);
8483        harness
8484            .github
8485            .observe(GithubRunnerObservation::Registered { busy: true });
8486
8487        let replacements = harness
8488            .launcher
8489            .recover_startup(std::slice::from_ref(&harness.policy))
8490            .await
8491            .unwrap();
8492        assert!(replacements.is_empty());
8493        let recovered = harness.store.attempt(id).unwrap().unwrap();
8494        assert_eq!(recovered.state(), AttemptState::Busy);
8495        assert_eq!(recovered.process_id(), Some(4242));
8496        assert_eq!(recovered.github_runner_id(), Some(73));
8497        let events = harness.events.events();
8498        let starting = events
8499            .iter()
8500            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8501            .unwrap();
8502        let busy = events
8503            .iter()
8504            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8505            .unwrap();
8506        assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8507
8508        harness.processes.finish_successfully();
8509        harness
8510            .github
8511            .observe(GithubRunnerObservation::NotRegistered);
8512        assert!(
8513            harness
8514                .launcher
8515                .supervise(&harness.policy)
8516                .await
8517                .unwrap()
8518                .is_empty()
8519        );
8520        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8521        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8522        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8523        assert!(!runtime.exists());
8524    }
8525
8526    #[tokio::test]
8527    async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
8528        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8529        harness.processes.fail_spawn_with_live_child();
8530        harness.ready().await;
8531        assert!(harness.launch_result().await.is_err());
8532
8533        let attempt = harness.only_attempt();
8534        assert_eq!(attempt.state(), AttemptState::Starting);
8535        assert_eq!(attempt.process_id(), Some(4242));
8536        assert!(attempt.outcome().is_none());
8537        assert!(attempt.state().counts_against_capacity());
8538        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8539        assert!(harness.delay.0.lock().unwrap().is_empty());
8540
8541        harness.processes.set_alive(false);
8542        harness
8543            .github
8544            .observe(GithubRunnerObservation::NotRegistered);
8545        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8546        assert_eq!(replacements.len(), 1);
8547        assert_eq!(
8548            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
8549            AttemptState::Cleaned
8550        );
8551    }
8552
8553    #[tokio::test]
8554    async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
8555        for sidecar_already_present in [false, true] {
8556            let harness = Harness::new(
8557                FakeGithubLifecycle::default(),
8558                Arc::new(FakeDemand::answering([false])),
8559            );
8560            let id = AttemptId::new_random();
8561            let runtime =
8562                harness
8563                    .launcher
8564                    .app_paths
8565                    .runtime_dir()
8566                    .join(if sidecar_already_present {
8567                        "after-id-sidecar"
8568                    } else {
8569                        "before-id-sidecar"
8570                    });
8571            fs::create_dir_all(&runtime).unwrap();
8572            let mut attempt =
8573                RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8574            if sidecar_already_present {
8575                write_runner_id(&runtime, 73).unwrap();
8576                attempt.jit_received(harness.clock.now()).unwrap();
8577            }
8578            harness.store.record_attempt(&attempt).unwrap();
8579            harness.processes.set_alive(true);
8580            harness
8581                .github
8582                .observe(GithubRunnerObservation::Registered { busy: false });
8583            harness
8584                .launcher
8585                .recover_startup(std::slice::from_ref(&harness.policy))
8586                .await
8587                .unwrap();
8588
8589            assert_eq!(read_runner_id(&runtime), Some(73));
8590            assert!(
8591                harness
8592                    .store
8593                    .attempt(id)
8594                    .unwrap()
8595                    .unwrap()
8596                    .outcome()
8597                    .is_none()
8598            );
8599            let events = harness.events.events();
8600            let recovered = events.iter().position(|event| {
8601                matches!(
8602                    event,
8603                    AttemptEvent::RemoteIdentityRecovered {
8604                        attempt,
8605                        runner_id: 73
8606                    } if *attempt == id
8607                )
8608            });
8609            assert_eq!(recovered.is_some(), !sidecar_already_present);
8610            if let Some(recovered) = recovered {
8611                let adopted = events
8612                    .iter()
8613                    .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
8614                    .unwrap();
8615                assert!(
8616                    recovered < adopted,
8617                    "identity was not durable before adoption: {events:?}"
8618                );
8619            }
8620            assert!(runtime.exists());
8621        }
8622    }
8623
8624    #[tokio::test]
8625    async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
8626        let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8627        let unknown_attempt = RunnerAttempt::allocate(
8628            AttemptId::new_random(),
8629            PolicyId::from_u128(0xfeed),
8630            unknown
8631                .launcher
8632                .app_paths
8633                .runtime_dir()
8634                .join("unknown-policy"),
8635            unknown.clock.now(),
8636        );
8637        unknown.store.record_attempt(&unknown_attempt).unwrap();
8638        let expired_id = AttemptId::new_random();
8639        let expired_runtime = unknown
8640            .launcher
8641            .app_paths
8642            .runtime_dir()
8643            .join("expired-beside-unknown");
8644        fs::create_dir_all(&expired_runtime).unwrap();
8645        let mut expired = RunnerAttempt::allocate(
8646            expired_id,
8647            unknown.policy.id,
8648            expired_runtime,
8649            unknown.clock.now(),
8650        );
8651        expired.jit_received(unknown.clock.now()).unwrap();
8652        unknown.store.record_attempt(&expired).unwrap();
8653        unknown.clock.advance_secs(11);
8654        assert!(matches!(
8655            unknown
8656                .launcher
8657                .recover_startup(std::slice::from_ref(&unknown.policy))
8658                .await,
8659            Err(LifecycleError::RecoveryIncomplete)
8660        ));
8661        assert!(unknown.launch_result().await.is_err());
8662        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8663        let recovered_policy = fixtures::policy()
8664            .id(PolicyId::from_u128(0xfeed))
8665            .repository("octo/repo")
8666            .autoscale("home", 2)
8667            .active()
8668            .build();
8669        let pending = unknown
8670            .launcher
8671            .recover_startup(&[unknown.policy.clone(), recovered_policy])
8672            .await
8673            .unwrap();
8674        assert_eq!(
8675            pending,
8676            vec![ReplacementIntent {
8677                policy: unknown.policy.id,
8678                previous_attempt: expired_id,
8679                operation: "jit_expired_replacement",
8680            }]
8681        );
8682        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8683
8684        let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8685        let id = AttemptId::new_random();
8686        let runtime = unreachable
8687            .launcher
8688            .app_paths
8689            .runtime_dir()
8690            .join("unreachable");
8691        fs::create_dir_all(&runtime).unwrap();
8692        unreachable
8693            .store
8694            .record_attempt(&RunnerAttempt::allocate(
8695                id,
8696                unreachable.policy.id,
8697                runtime,
8698                unreachable.clock.now(),
8699            ))
8700            .unwrap();
8701        unreachable
8702            .github
8703            .observe(GithubRunnerObservation::Unreachable);
8704        assert!(matches!(
8705            unreachable
8706                .launcher
8707                .recover_startup(std::slice::from_ref(&unreachable.policy))
8708                .await,
8709            Err(LifecycleError::RecoveryIncomplete)
8710        ));
8711        assert!(unreachable.launch_result().await.is_err());
8712        assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
8713    }
8714
8715    #[tokio::test]
8716    async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
8717        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8718        let id = AttemptId::new_random();
8719        let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
8720        fs::create_dir_all(&runtime).unwrap();
8721        let mut attempt =
8722            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8723        attempt.jit_received(harness.clock.now()).unwrap();
8724        attempt.started(4242, harness.clock.now()).unwrap();
8725        attempt.assigned_job(73, harness.clock.now()).unwrap();
8726        harness.store.record_attempt(&attempt).unwrap();
8727        harness.processes.set_alive(false);
8728        harness
8729            .github
8730            .observe(GithubRunnerObservation::NotRegistered);
8731        harness
8732            .launcher
8733            .recover_startup(std::slice::from_ref(&harness.policy))
8734            .await
8735            .unwrap();
8736        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8737        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8738        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
8739        assert!(!runtime.exists());
8740    }
8741
8742    #[tokio::test]
8743    async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
8744        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8745        harness.ready().await;
8746        let id = AttemptId::new_random();
8747        let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
8748        fs::create_dir_all(&runtime).unwrap();
8749        let mut attempt =
8750            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8751        attempt.jit_received(harness.clock.now()).unwrap();
8752        attempt.started(4242, harness.clock.now()).unwrap();
8753        harness.store.record_attempt(&attempt).unwrap();
8754        harness.clock.advance_secs(11);
8755        harness.processes.set_alive(true);
8756        harness
8757            .github
8758            .observe(GithubRunnerObservation::NotRegistered);
8759        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8760        assert_eq!(
8761            replacements,
8762            vec![ReplacementIntent {
8763                policy: harness.policy.id,
8764                previous_attempt: id,
8765                operation: "registration_timeout_replacement",
8766            }]
8767        );
8768
8769        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8770        assert!(!harness.processes.alive.load(Ordering::SeqCst));
8771        let actions = harness.processes.actions.lock().unwrap().clone();
8772        let intent = actions
8773            .iter()
8774            .position(|action| *action == "terminate_intent")
8775            .unwrap();
8776        let signal = actions
8777            .iter()
8778            .position(|action| *action == "terminate")
8779            .unwrap();
8780        assert!(
8781            intent < signal,
8782            "intent was not durable before signal: {actions:?}"
8783        );
8784
8785        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8786        assert!(matches!(
8787            cleaned.outcome(),
8788            Some(AttemptOutcome::Failed {
8789                reason: FailureReason::TerminatedAfterRegistrationTimeout
8790            })
8791        ));
8792        let events = harness.events.events();
8793        let intent = events
8794            .iter()
8795            .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
8796            .unwrap();
8797        let stopped = events
8798            .iter()
8799            .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
8800            .unwrap();
8801        let concluded = events
8802            .iter()
8803            .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
8804            .unwrap();
8805        assert!(intent < stopped && stopped < concluded, "{events:?}");
8806    }
8807
8808    #[tokio::test]
8809    async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
8810        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8811        let id = AttemptId::new_random();
8812        let runtime = harness
8813            .launcher
8814            .app_paths
8815            .runtime_dir()
8816            .join("timeout-after-crash");
8817        fs::create_dir_all(&runtime).unwrap();
8818        let mut attempt =
8819            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8820        attempt.jit_received(harness.clock.now()).unwrap();
8821        attempt.started(4242, harness.clock.now()).unwrap();
8822        harness.store.record_attempt(&attempt).unwrap();
8823        harness.processes.intent.store(true, Ordering::SeqCst);
8824        harness.processes.set_alive(false);
8825        harness
8826            .github
8827            .observe(GithubRunnerObservation::NotRegistered);
8828
8829        let replacements = harness
8830            .launcher
8831            .recover_startup(std::slice::from_ref(&harness.policy))
8832            .await
8833            .unwrap();
8834        assert_eq!(
8835            replacements,
8836            vec![ReplacementIntent {
8837                policy: harness.policy.id,
8838                previous_attempt: id,
8839                operation: "registration_timeout_replacement",
8840            }]
8841        );
8842        let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8843            .await
8844            .unwrap();
8845        assert_eq!(consumed, replacements);
8846        assert!(
8847            RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8848                .await
8849                .unwrap()
8850                .is_empty(),
8851            "startup replacement evidence must be consumed exactly once by e1"
8852        );
8853        assert!(matches!(
8854            harness.store.attempt(id).unwrap().unwrap().outcome(),
8855            Some(AttemptOutcome::Failed {
8856                reason: FailureReason::TerminatedAfterRegistrationTimeout
8857            })
8858        ));
8859    }
8860
8861    #[tokio::test]
8862    async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
8863        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8864        let id = AttemptId::new_random();
8865        let runtime = harness
8866            .launcher
8867            .app_paths
8868            .runtime_dir()
8869            .join("timeout-sync-failure");
8870        fs::create_dir_all(&runtime).unwrap();
8871        let mut attempt =
8872            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8873        attempt.jit_received(harness.clock.now()).unwrap();
8874        attempt.started(4242, harness.clock.now()).unwrap();
8875        harness.store.record_attempt(&attempt).unwrap();
8876        harness.clock.advance_secs(11);
8877        harness.processes.set_alive(true);
8878        harness.processes.fail_intent();
8879        harness
8880            .github
8881            .observe(GithubRunnerObservation::NotRegistered);
8882
8883        assert!(
8884            harness
8885                .launcher
8886                .recover_startup(std::slice::from_ref(&harness.policy))
8887                .await
8888                .is_err()
8889        );
8890        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8891        assert!(harness.processes.alive.load(Ordering::SeqCst));
8892        assert_eq!(
8893            harness.store.attempt(id).unwrap().unwrap().state(),
8894            AttemptState::Starting
8895        );
8896        assert!(!harness.events.events().iter().any(|event| matches!(
8897            event,
8898            AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
8899                if *attempt == id
8900        )));
8901    }
8902
8903    #[tokio::test]
8904    async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
8905        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8906        harness.ready().await;
8907        let attempt = harness.launch().await;
8908        harness
8909            .github
8910            .observe(GithubRunnerObservation::Registered { busy: false });
8911        harness.launcher.supervise(&harness.policy).await.unwrap();
8912        harness.clock.advance_secs(11);
8913        harness.processes.set_alive(false);
8914        harness
8915            .github
8916            .observe(GithubRunnerObservation::NotRegistered);
8917        harness.launcher.supervise(&harness.policy).await.unwrap();
8918        let diagnostic = fs::read_to_string(
8919            harness
8920                .launcher
8921                .diagnostics_root
8922                .join(format!("{}.log", attempt.id)),
8923        )
8924        .unwrap();
8925        assert!(diagnostic.contains("exited_idle_without_work"));
8926        assert!(!diagnostic.contains(JIT));
8927        assert!(!diagnostic.contains("ghp_"));
8928        assert!(!attempt.runtime_path().exists());
8929    }
8930
8931    #[test]
8932    fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
8933        let root = tempfile::tempdir().unwrap();
8934        let policy = fixtures::policy()
8935            .repository("octo/repo")
8936            .autoscale("home", 1)
8937            .active()
8938            .build();
8939        let runtime = root.path().join("successful");
8940        fs::create_dir_all(&runtime).unwrap();
8941        let processes = NativeProcesses::new();
8942        let config = EncodedJitConfig::new(JIT);
8943        let handoff =
8944            RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
8945                .unwrap();
8946        let mut child = native_inspection_spec()
8947            .spawn_runner_with_handoff(&handoff)
8948            .expect("native child starts");
8949        let pid = child.pid();
8950        handoff.delete().unwrap();
8951        let command_line = native_command_line(pid);
8952        assert!(
8953            !command_line.contains(JIT),
8954            "the encoded JIT configuration appeared in the native process listing"
8955        );
8956        assert_no_jit_file(&runtime);
8957        child
8958            .stop(Duration::from_secs(1))
8959            .expect("native child stops");
8960
8961        let failed_runtime = root.path().join("failed");
8962        fs::create_dir_all(&failed_runtime).unwrap();
8963        let failed = RunnerAttempt::allocate(
8964            AttemptId::new_random(),
8965            policy.id,
8966            &failed_runtime,
8967            FakeClock::default().now(),
8968        );
8969        assert!(
8970            processes
8971                .spawn(&failed, &EncodedJitConfig::new(JIT))
8972                .is_err(),
8973            "a runtime with no runner executable must fail"
8974        );
8975        assert_no_jit_file(&failed_runtime);
8976        processes
8977            .record_terminate_intent(&failed)
8978            .expect("the intent file and its directory entry are durably synced");
8979        assert_eq!(
8980            fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
8981            b"registration-timeout\n"
8982        );
8983    }
8984
8985    #[test]
8986    fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
8987        let root = tempfile::tempdir().unwrap();
8988        let policy = fixtures::policy()
8989            .repository("octo/repo")
8990            .autoscale("home", 1)
8991            .active()
8992            .build();
8993        let processes = NativeProcesses::new();
8994        processes.use_long_lived_test_listener();
8995        for (index, boundary) in [
8996            PostSpawnBoundary::HandoffDelete,
8997            PostSpawnBoundary::IdentitySerialize,
8998            PostSpawnBoundary::IdentityWrite,
8999            PostSpawnBoundary::ChildMapInsert,
9000        ]
9001        .into_iter()
9002        .enumerate()
9003        {
9004            let runtime = root.path().join(format!("post-spawn-{index}"));
9005            let bin = runtime.join("bin");
9006            fs::create_dir_all(&bin).unwrap();
9007            #[cfg(windows)]
9008            let listener = bin.join("Runner.Listener.exe");
9009            #[cfg(not(windows))]
9010            let listener = bin.join("Runner.Listener");
9011            fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9012            let attempt = RunnerAttempt::allocate(
9013                AttemptId::new_random(),
9014                policy.id,
9015                &runtime,
9016                FakeClock::default().now(),
9017            );
9018            processes.fail_post_spawn_at(boundary);
9019            let failure = processes
9020                .spawn(&attempt, &EncodedJitConfig::new(JIT))
9021                .expect_err("fault must cross the post-spawn cleanup path");
9022            assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
9023            assert!(
9024                !processes.is_alive(&attempt).unwrap(),
9025                "{boundary:?} left a child"
9026            );
9027            assert!(!NativeProcesses::identity_path(&attempt).exists());
9028            assert_no_jit_file(&runtime);
9029        }
9030        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9031
9032        let runtime = root.path().join("identity-and-stop-fail");
9033        let bin = runtime.join("bin");
9034        fs::create_dir_all(&bin).unwrap();
9035        #[cfg(windows)]
9036        let listener = bin.join("Runner.Listener.exe");
9037        #[cfg(not(windows))]
9038        let listener = bin.join("Runner.Listener");
9039        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9040        let attempt = RunnerAttempt::allocate(
9041            AttemptId::new_random(),
9042            policy.id,
9043            &runtime,
9044            FakeClock::default().now(),
9045        );
9046        // The first fault rejects the normal identity write; the second rejects
9047        // its retry after the first stop fails. The fallback sidecar must make
9048        // the live-child result durable without an unbounded reap loop.
9049        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9050        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9051        processes.fail_next_post_spawn_stop();
9052        let failure = processes
9053            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9054            .expect_err("the identity boundary must fail closed");
9055        assert!(failure.live_pid.is_some());
9056        assert_long_lived_listener_ready(&processes, &attempt);
9057        assert!(processes.is_alive(&attempt).unwrap());
9058        assert!(!NativeProcesses::identity_path(&attempt).exists());
9059        assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
9060        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9061        processes.terminate(&attempt).unwrap();
9062
9063        let runtime = root.path().join("persistent-stop-and-identity-failures");
9064        let bin = runtime.join("bin");
9065        fs::create_dir_all(&bin).unwrap();
9066        #[cfg(windows)]
9067        let listener = bin.join("Runner.Listener.exe");
9068        #[cfg(not(windows))]
9069        let listener = bin.join("Runner.Listener");
9070        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9071        let mut unresolved = RunnerAttempt::allocate(
9072            AttemptId::new_random(),
9073            policy.id,
9074            &runtime,
9075            FakeClock::default().now(),
9076        );
9077        for _ in 0..3 {
9078            processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9079        }
9080        processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
9081        let failure = processes
9082            .spawn(&unresolved, &EncodedJitConfig::new(JIT))
9083            .expect_err("bounded cleanup must return even when every stop errors");
9084        let pid = failure
9085            .live_pid
9086            .expect("the owned child remains supervised in this invocation");
9087        assert!(matches!(failure.reason, FailureReason::Other(_)));
9088        assert_long_lived_listener_ready(&processes, &unresolved);
9089        unresolved.jit_received(FakeClock::default().now()).unwrap();
9090        unresolved.started(pid, FakeClock::default().now()).unwrap();
9091        let journal = SqliteStore::open_in_memory().unwrap();
9092        journal.record_attempt(&unresolved).unwrap();
9093        let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
9094        assert_eq!(recovered.process_id(), Some(pid));
9095        assert_eq!(recovered.state(), AttemptState::Starting);
9096        assert!(processes.is_alive(&unresolved).unwrap());
9097        assert!(!NativeProcesses::identity_path(&unresolved).exists());
9098        assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
9099        assert_eq!(
9100            fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
9101            pid.to_string(),
9102            "bounded cleanup must leave durable unresolved-process evidence before returning"
9103        );
9104        assert!(
9105            NativeProcesses::new().is_alive(&recovered).is_err(),
9106            "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
9107        );
9108        processes.terminate(&unresolved).unwrap();
9109
9110        let runtime = root.path().join("post-spawn-stop-failed");
9111        let bin = runtime.join("bin");
9112        fs::create_dir_all(&bin).unwrap();
9113        #[cfg(windows)]
9114        let listener = bin.join("Runner.Listener.exe");
9115        #[cfg(not(windows))]
9116        let listener = bin.join("Runner.Listener");
9117        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9118        let attempt = RunnerAttempt::allocate(
9119            AttemptId::new_random(),
9120            policy.id,
9121            &runtime,
9122            FakeClock::default().now(),
9123        );
9124        processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
9125        processes.fail_next_post_spawn_stop();
9126        let failure = processes
9127            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9128            .expect_err("the injected stop failure must preserve supervision");
9129        let live_pid = failure
9130            .live_pid
9131            .expect("live PID is returned to the journal");
9132        assert!(!failure.retryable);
9133        assert_long_lived_listener_ready(&processes, &attempt);
9134        assert!(NativeProcesses::identity_path(&attempt).is_file());
9135        assert_eq!(
9136            NativeProcesses::read_identity(&attempt)
9137                .unwrap()
9138                .unwrap()
9139                .pid(),
9140            live_pid
9141        );
9142        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9143        processes.terminate(&attempt).unwrap();
9144    }
9145
9146    #[test]
9147    #[ignore = "spawned only as the platform-stable native listener fixture"]
9148    fn long_lived_native_listener_helper() {
9149        let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
9150            .map(PathBuf::from)
9151            .expect("the parent supplies the readiness path");
9152        // Atomically: the parent polls for the file and must never read it
9153        // created but not yet written.
9154        runner_manager_platform::host_fitness::write_atomically(&ready, b"ready\n")
9155            .expect("the listener publishes readiness");
9156        std::thread::sleep(Duration::from_secs(30));
9157    }
9158
9159    fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
9160        let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
9161        let deadline = std::time::Instant::now() + Duration::from_secs(5);
9162        loop {
9163            if ready.is_file() {
9164                assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
9165                return;
9166            }
9167            assert!(
9168                processes.is_alive(attempt).unwrap(),
9169                "the native listener exited before publishing readiness"
9170            );
9171            assert!(
9172                std::time::Instant::now() < deadline,
9173                "the native listener stayed alive but never published readiness"
9174            );
9175            std::thread::sleep(Duration::from_millis(10));
9176        }
9177    }
9178
9179    #[tokio::test]
9180    async fn a_reservation_is_journalled_before_any_effect_and_holds_its_slot() {
9181        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9182        harness.ready().await;
9183
9184        let reserved = harness.reserve().await.unwrap();
9185
9186        assert_eq!(reserved.state(), AttemptState::Allocated);
9187        assert!(reserved.counts_against_capacity());
9188        let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9189        assert_eq!(journalled.state(), AttemptState::Allocated);
9190        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
9191        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
9192
9193        let started = harness
9194            .launcher
9195            .finish_launch(&harness.policy, reserved, Duration::ZERO)
9196            .await
9197            .unwrap();
9198        assert_eq!(started.state(), AttemptState::Starting);
9199    }
9200
9201    /// A daemon that dies between the reservation and the process: the
9202    /// journalled attempt is what recovery finds, and the slot comes back.
9203    #[tokio::test]
9204    async fn a_crash_between_reservation_and_spawn_recovers_the_slot() {
9205        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9206        harness.ready().await;
9207        let reserved = harness.reserve().await.unwrap();
9208        harness.clock.advance_secs(31);
9209
9210        harness
9211            .launcher
9212            .recover_startup(std::slice::from_ref(&harness.policy))
9213            .await
9214            .unwrap();
9215
9216        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9217        assert!(
9218            !recovered.counts_against_capacity(),
9219            "the slot is free again: {:?}",
9220            recovered.state()
9221        );
9222    }
9223
9224    /// A crash once the package is there, before the process: same result.
9225    #[tokio::test]
9226    async fn a_crash_after_the_package_is_copied_recovers_the_slot() {
9227        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9228        harness.ready().await;
9229        let reserved = harness.reserve().await.unwrap();
9230        harness
9231            .packages
9232            .materialize(&reserved, &mut PackageTimings::default())
9233            .await
9234            .unwrap();
9235        harness.clock.advance_secs(31);
9236
9237        harness
9238            .launcher
9239            .recover_startup(std::slice::from_ref(&harness.policy))
9240            .await
9241            .unwrap();
9242
9243        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9244        assert!(!recovered.counts_against_capacity());
9245    }
9246
9247    #[tokio::test]
9248    async fn runners_prepare_at_once_within_the_bound() {
9249        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9250        harness.ready().await;
9251        let turns = harness.launcher.turns.available_permits();
9252        harness.launcher.turns.forget_permits(turns - 2);
9253        let mut reserved = Vec::new();
9254        for _ in 0..4 {
9255            reserved.push(harness.reserve().await.unwrap());
9256        }
9257
9258        let finished = futures::future::join_all(reserved.into_iter().map(|attempt| {
9259            harness
9260                .launcher
9261                .finish_launch(&harness.policy, attempt, Duration::ZERO)
9262        }))
9263        .await;
9264
9265        assert!(finished.iter().all(Result::is_ok));
9266        assert_eq!(
9267            harness.packages.peak_materializing.load(Ordering::SeqCst),
9268            2
9269        );
9270    }
9271
9272    #[tokio::test]
9273    async fn every_production_launch_prunes_under_the_same_allocation_guard() {
9274        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9275        harness.ready().await;
9276        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
9277        harness.launch().await;
9278        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
9279    }
9280
9281    fn copy_package_tree(
9282        source: &Path,
9283        destination: &Path,
9284        seeds: &TrustedTrees,
9285    ) -> std::io::Result<()> {
9286        let origin = copy_origin(source, destination, seeds);
9287        copy_package_from(source, &origin, destination, seeds)
9288    }
9289
9290    fn assert_no_jit_file(runtime: &Path) {
9291        for entry in fs::read_dir(runtime).unwrap() {
9292            let path = entry.unwrap().path();
9293            if path.is_file() {
9294                let bytes = fs::read(&path).unwrap();
9295                assert!(
9296                    !bytes
9297                        .windows(JIT.len())
9298                        .any(|window| window == JIT.as_bytes()),
9299                    "a JIT payload survived in a runtime file"
9300                );
9301            }
9302        }
9303    }
9304
9305    #[test]
9306    fn production_listener_command_uses_the_supported_jit_contract() {
9307        let runtime = Path::new("runtime");
9308        let spec = runner_listener_spec(
9309            PathBuf::from("Runner.Listener"),
9310            runtime,
9311            Vec::new(),
9312            &RunnerEnv::default(),
9313        );
9314        let arguments: Vec<_> = spec
9315            .arguments()
9316            .iter()
9317            .map(|argument| argument.to_string_lossy().into_owned())
9318            .collect();
9319
9320        assert_eq!(arguments, ["run"]);
9321        assert!(
9322            !arguments
9323                .iter()
9324                .any(|argument| argument == "--jit-config-file"),
9325            "the obsolete file option would be rejected by Runner.Listener 2.336.0"
9326        );
9327    }
9328
9329    /// What a production runner starts with: the platform defaults, the host's
9330    /// `runner.env` over them, the attempt's own temporary directory, and
9331    /// normal scheduling.
9332    #[test]
9333    fn production_listener_layers_runner_env_over_the_platform_defaults() {
9334        let root = tempfile::tempdir().unwrap();
9335        let runtime = root.path().join("attempt");
9336        let host_env =
9337            RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
9338                .unwrap();
9339        let caches = vec![
9340            (
9341                "ELECTRON_CACHE",
9342                std::ffi::OsString::from("/rman/_cache/o/r/electron"),
9343            ),
9344            (
9345                "npm_config_cache",
9346                std::ffi::OsString::from("/rman/_cache/o/r/npm"),
9347            ),
9348        ];
9349        let spec = runner_listener_spec(
9350            PathBuf::from("Runner.Listener"),
9351            &runtime,
9352            caches,
9353            &host_env,
9354        );
9355        let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
9356
9357        assert!(spec.expects_normal_scheduling());
9358        assert_eq!(
9359            env("ELECTRON_CACHE"),
9360            Some(PathBuf::from("/cache/electron")),
9361            "runner.env must win over a dependency cache of the same name"
9362        );
9363        assert_eq!(
9364            env("npm_config_cache"),
9365            Some(PathBuf::from("/rman/_cache/o/r/npm"))
9366        );
9367        assert_eq!(
9368            env("HOME"),
9369            Some(PathBuf::from("/operator/chose/this")),
9370            "runner.env must win over a platform default of the same name"
9371        );
9372        for tmp in ["TMPDIR", "TEMP", "TMP"] {
9373            assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
9374        }
9375
9376        let home = runtime.join(runner_env::RUNNER_HOME_DIR);
9377        if cfg!(windows) {
9378            // Each runner's own profile, created inside its attempt, so that
9379            // concurrent jobs stop sharing the service account's.
9380            assert_eq!(env("USERPROFILE"), Some(home.clone()));
9381            assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
9382            assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
9383            assert!(home.join(r"AppData\Roaming").is_dir());
9384            assert!(home.join(r"AppData\Local").is_dir());
9385        } else {
9386            // Unix keeps the account's own profile.
9387            assert_eq!(env("USERPROFILE"), None);
9388            assert!(!home.exists());
9389        }
9390    }
9391
9392    /// Every step shell a runner starts inherits PowerShell's telemetry
9393    /// opt-out from the listener, unless the daemon or `runner.env` decided
9394    /// otherwise; see [`runner_env::POWERSHELL_TELEMETRY_OPTOUT`].
9395    #[test]
9396    fn production_listener_opts_powershell_out_of_telemetry() {
9397        let root = tempfile::tempdir().unwrap();
9398        let runtime = root.path().join("attempt");
9399        let listener = |host_env: &RunnerEnv| {
9400            runner_listener_spec(
9401                PathBuf::from("Runner.Listener"),
9402                &runtime,
9403                Vec::new(),
9404                host_env,
9405            )
9406        };
9407        let optout = |spec: &SpawnSpec| {
9408            spec.configured_env(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9409                .map(OsStr::to_os_string)
9410        };
9411
9412        let spec = listener(&RunnerEnv::default());
9413        if std::env::var_os(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9414            .is_some_and(|value| !value.is_empty())
9415        {
9416            // The daemon's own value is inherited as it is.
9417            assert_eq!(optout(&spec), None);
9418        } else {
9419            assert_eq!(
9420                optout(&spec),
9421                Some(runner_env::POWERSHELL_TELEMETRY_OPTOUT_VALUE.into())
9422            );
9423        }
9424
9425        let operator = RunnerEnv::parse("POWERSHELL_TELEMETRY_OPTOUT=0\n").unwrap();
9426        assert_eq!(
9427            optout(&listener(&operator)),
9428            Some("0".into()),
9429            "runner.env must win over the telemetry opt-out"
9430        );
9431    }
9432
9433    #[test]
9434    fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
9435        let root = tempfile::tempdir().unwrap();
9436        let path = runner_env::path_in(root.path());
9437        fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
9438        let processes = NativeProcesses::new().with_runner_env_file(path.clone());
9439        let failure = processes.host_env().unwrap_err().to_string();
9440        assert!(
9441            failure.contains("runner.env") && failure.contains("line 2"),
9442            "{failure}"
9443        );
9444        assert!(
9445            !failure.contains("ghp_"),
9446            "the failure echoed a value: {failure}"
9447        );
9448
9449        // `prepare` runs before the JIT registration, so a broken file is
9450        // refused there, before GitHub is asked for anything.
9451        let policy = fixtures::policy()
9452            .repository("octo/repo")
9453            .autoscale("home", 1)
9454            .active()
9455            .build();
9456        let attempt = RunnerAttempt::allocate(
9457            AttemptId::new_random(),
9458            policy.id,
9459            root.path(),
9460            FakeClock::default().now(),
9461        );
9462        assert!(processes.prepare(&attempt, &policy).is_err());
9463
9464        fs::write(&path, "GOOD=1\n").unwrap();
9465        assert!(processes.prepare(&attempt, &policy).is_ok());
9466        assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
9467        assert!(NativeProcesses::new().host_env().unwrap().is_empty());
9468    }
9469
9470    /// The wiring from `prepare` (which sees the policy) to `spawn` (which
9471    /// does not): the policy's repository decides the namespace, and a broken
9472    /// `caches.toml` is refused before GitHub is asked for anything.
9473    #[test]
9474    fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
9475        let root = tempfile::tempdir().unwrap();
9476        let runner_root = root.path().join("rman");
9477        let runtime = runner_root.join("a1");
9478        fs::create_dir_all(runtime.join("bin")).unwrap();
9479        let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
9480        let source_root = runner_root.clone();
9481        let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
9482            config.clone(),
9483            Arc::new(move || Some(source_root.clone())),
9484            None,
9485        ));
9486        let policy = fixtures::policy()
9487            .repository("Octo/Repo")
9488            .autoscale("home", 1)
9489            .active()
9490            .build();
9491        let attempt = RunnerAttempt::allocate(
9492            AttemptId::new_random(),
9493            policy.id,
9494            &runtime,
9495            FakeClock::default().now(),
9496        );
9497        assert!(
9498            processes
9499                .cache_env(&attempt, None, &RunnerEnv::default())
9500                .is_empty(),
9501            "a launch with no prepared target, such as a bare `spawn`, has no cache"
9502        );
9503        let prepared = processes.prepare(&attempt, &policy).unwrap();
9504        let variables = processes.cache_env(
9505            &attempt,
9506            prepared.cache_target.as_ref(),
9507            &RunnerEnv::default(),
9508        );
9509        let npm = variables
9510            .iter()
9511            .find(|(name, _)| *name == "npm_config_cache")
9512            .map(|(_, value)| PathBuf::from(value));
9513        assert_eq!(
9514            npm,
9515            Some(
9516                runner_root
9517                    .join("_cache")
9518                    .join("octo")
9519                    .join("repo")
9520                    .join("npm")
9521            )
9522        );
9523
9524        fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
9525        let refused = processes
9526            .prepare(&attempt, &policy)
9527            .unwrap_err()
9528            .to_string();
9529        assert!(refused.contains("caches.toml"), "{refused}");
9530    }
9531
9532    #[cfg(windows)]
9533    fn native_inspection_spec() -> SpawnSpec {
9534        SpawnSpec::new("powershell.exe").args([
9535            "-NoProfile",
9536            "-NonInteractive",
9537            "-Command",
9538            "Start-Sleep -Seconds 30",
9539        ])
9540    }
9541
9542    #[cfg(unix)]
9543    fn native_inspection_spec() -> SpawnSpec {
9544        SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
9545    }
9546
9547    #[cfg(windows)]
9548    fn native_command_line(pid: u32) -> String {
9549        let output = std::process::Command::new("powershell.exe")
9550            .args([
9551                "-NoProfile",
9552                "-NonInteractive",
9553                "-Command",
9554                &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
9555            ])
9556            .output()
9557            .expect("PowerShell can inspect the native child");
9558        assert!(output.status.success(), "native process inspection failed");
9559        String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
9560    }
9561
9562    #[cfg(target_os = "linux")]
9563    fn native_command_line(pid: u32) -> String {
9564        fs::read(format!("/proc/{pid}/cmdline"))
9565            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
9566            .expect("/proc exposes the native child command line")
9567    }
9568
9569    #[cfg(target_os = "macos")]
9570    fn native_command_line(pid: u32) -> String {
9571        let output = std::process::Command::new("ps")
9572            .args(["-o", "command=", "-p", &pid.to_string()])
9573            .output()
9574            .expect("ps can inspect the native child");
9575        assert!(output.status.success(), "native process inspection failed");
9576        String::from_utf8(output.stdout).expect("the command line is UTF-8")
9577    }
9578
9579    // -- c2: persistent slot allocation -------------------------------------
9580
9581    #[tokio::test]
9582    async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
9583        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9584            .with_persistent_workspace(2);
9585        harness.github.watch_journal(Arc::clone(&harness.store));
9586        harness.ready().await;
9587
9588        let attempt = harness.launch().await;
9589
9590        assert_eq!(
9591            attempt.workspace(),
9592            AttemptWorkspace::persistent_slot(nz(1)),
9593            "the lowest free slot is leased"
9594        );
9595        assert_eq!(attempt.runtime_path(), harness.slot_path(1));
9596        assert!(attempt.holds_slot_lease());
9597        // The exact runtime path is journalled, not re-derived later.
9598        assert_eq!(
9599            harness.attempt(attempt.id).runtime_path(),
9600            harness.slot_path(1)
9601        );
9602
9603        // Step 7 of "Slot allocation": the lease exists before GitHub is asked
9604        // for anything, and the runner's work folder stays the relative `_work`
9605        // the slot root is laid out around.
9606        let facts = harness.github.registration_facts();
9607        assert_eq!(facts.len(), 1);
9608        assert_eq!(
9609            facts[0].leased_slots,
9610            vec![1],
9611            "the lease was journalled first"
9612        );
9613        assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
9614    }
9615
9616    #[tokio::test]
9617    async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
9618        let harness = Harness::new(
9619            FakeGithubLifecycle::default().fail(true),
9620            Arc::new(PersistentDemand),
9621        )
9622        .with_persistent_workspace(2);
9623        harness.ready().await;
9624
9625        // A terminal JIT refusal concludes the attempt without cleaning it.
9626        harness.launch_result().await.unwrap_err();
9627        let first = harness.store.attempts().unwrap().remove(0);
9628        assert_eq!(first.state(), AttemptState::Failed);
9629        assert!(
9630            !first.state().counts_against_capacity(),
9631            "a concluded attempt is invisible to host capacity"
9632        );
9633        assert!(
9634            first.holds_slot_lease(),
9635            "and still owns its directory, so its slot is not free"
9636        );
9637
9638        let second = harness.launch().await;
9639        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9640        assert_eq!(second.runtime_path(), harness.slot_path(2));
9641        assert_eq!(
9642            harness
9643                .store
9644                .slot_leases_for_policy(harness.policy.id)
9645                .unwrap()
9646                .len(),
9647            2
9648        );
9649    }
9650
9651    #[tokio::test]
9652    async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
9653        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9654            .with_persistent_workspace(1);
9655        harness.ready().await;
9656
9657        let first = harness.launch().await;
9658        assert_eq!(first.runtime_path(), harness.slot_path(1));
9659
9660        // What a job leaves behind, at the path the runner writes it to.
9661        let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
9662        fs::create_dir_all(&checkout).unwrap();
9663        fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
9664
9665        harness.cleanup_retaining_work(first.id).await;
9666
9667        let second = harness.launch().await;
9668        assert_ne!(second.id, first.id);
9669        assert_eq!(
9670            second.workspace(),
9671            AttemptWorkspace::persistent_slot(nz(1)),
9672            "a released slot is leased again rather than skipped"
9673        );
9674        assert_eq!(
9675            second.runtime_path(),
9676            first.runtime_path(),
9677            "the same slot is the same exact path"
9678        );
9679        assert_eq!(
9680            fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
9681            "from the first job",
9682            "the retained job workspace survived the second allocation"
9683        );
9684        // The attempt's own runner material was recreated for this attempt.
9685        assert!(harness.slot_path(1).join("runner-package").exists());
9686    }
9687
9688    #[tokio::test]
9689    async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
9690        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9691            .with_persistent_workspace(2);
9692        harness.ready().await;
9693
9694        let first = harness.launch().await;
9695        let second = harness.launch().await;
9696        assert_eq!(second.runtime_path(), harness.slot_path(2));
9697        let kept = harness
9698            .slot_path(2)
9699            .join(DEFAULT_WORK_FOLDER)
9700            .join("kept.txt");
9701        fs::create_dir_all(kept.parent().unwrap()).unwrap();
9702        fs::write(&kept, b"s2 was here").unwrap();
9703        harness.cleanup_retaining_work(second.id).await;
9704
9705        // The operator lowers the ceiling while s1 is still leased.
9706        harness.policy.set_max_capacity(nz(1)).unwrap();
9707        let refusal = harness.launch_result().await.unwrap_err().to_string();
9708        assert!(
9709            refusal.contains("s1 to s1"),
9710            "the refusal names the ceiling it reached: {refusal}"
9711        );
9712        assert!(
9713            harness.slot_path(2).exists() && kept.exists(),
9714            "lowering capacity deletes nothing; the higher slot is merely unusable"
9715        );
9716
9717        // Raising it again makes the free higher slot available.
9718        harness.policy.set_max_capacity(nz(2)).unwrap();
9719        let third = harness.launch().await;
9720        assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9721        assert_eq!(third.runtime_path(), harness.slot_path(2));
9722        assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
9723        assert!(first.holds_slot_lease(), "s1 was never disturbed");
9724    }
9725
9726    #[tokio::test]
9727    async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
9728        for policy in [
9729            fixtures::policy()
9730                .organization("octo")
9731                .autoscale("home", 2)
9732                .active()
9733                .build(),
9734            fixtures::policy()
9735                .repository("octo/repo")
9736                .autoscale("home", 2)
9737                .active()
9738                .build(),
9739        ] {
9740            let mut harness =
9741                Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9742                    .with_host_runner_root();
9743            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
9744            harness.policy = policy;
9745            harness.ready().await;
9746
9747            let attempt = harness.launch().await;
9748            assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9749            assert_eq!(attempt.workspace().slot_number(), None);
9750            assert!(!attempt.holds_slot_lease());
9751            assert_eq!(
9752                attempt.runtime_path().parent().unwrap(),
9753                harness.host_root(),
9754                "a disposable attempt is a child of the host root, never of a slot"
9755            );
9756            assert!(
9757                harness
9758                    .store
9759                    .slot_leases_for_policy(harness.policy.id)
9760                    .unwrap()
9761                    .is_empty()
9762            );
9763        }
9764    }
9765
9766    #[tokio::test]
9767    async fn two_concurrent_allocations_never_share_a_slot() {
9768        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9769            .with_persistent_workspace(2);
9770        harness.github.watch_journal(Arc::clone(&harness.store));
9771        harness.ready().await;
9772
9773        // Both allocators race for the same host allocation lock, which is what
9774        // orders slot *selection*; each one then journals its lease before it
9775        // asks GitHub for anything.
9776        let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
9777        let first = first.unwrap();
9778        let second = second.unwrap();
9779
9780        let slots: BTreeSet<u16> = [&first, &second]
9781            .iter()
9782            .map(|attempt| {
9783                attempt
9784                    .workspace()
9785                    .slot_number()
9786                    .expect("a persistent attempt leases a slot")
9787            })
9788            .collect();
9789        assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
9790        assert_ne!(first.runtime_path(), second.runtime_path());
9791        assert_eq!(
9792            harness
9793                .store
9794                .slot_leases_for_policy(harness.policy.id)
9795                .unwrap()
9796                .len(),
9797            2
9798        );
9799
9800        // Every registration saw *its own* lease already in the journal. Reading
9801        // the whole journal and asking only that it be non-empty would pass on
9802        // the other allocator's lease, which is precisely the ordering bug this
9803        // test exists to exclude.
9804        let facts = harness.github.registration_facts();
9805        assert_eq!(facts.len(), 2);
9806        for fact in facts {
9807            let attempt = [&first, &second]
9808                .into_iter()
9809                .find(|attempt| runner_name(attempt.id) == fact.runner_name)
9810                .expect("every registration belongs to one of the two attempts");
9811            let slot = attempt
9812                .workspace()
9813                .slot_number()
9814                .expect("a persistent attempt leases a slot");
9815            assert!(
9816                fact.leased_slots.contains(&slot),
9817                "a JIT request never precedes its own lease: s{slot} not in {:?}",
9818                fact.leased_slots
9819            );
9820            assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
9821        }
9822    }
9823
9824    #[tokio::test]
9825    async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
9826        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9827            .with_persistent_workspace(2);
9828        harness.ready().await;
9829        let first = harness.launch().await;
9830
9831        // What a second allocator that lost the race would write: the lock
9832        // orders selection, and this index is what catches a writer the lock
9833        // could not see.
9834        let clash = RunnerAttempt::allocate_in(
9835            AttemptId::new_random(),
9836            harness.policy.id,
9837            first.runtime_path(),
9838            AttemptWorkspace::persistent_slot(nz(1)),
9839            harness.clock.now(),
9840        );
9841        assert!(matches!(
9842            harness.store.record_attempt(&clash).unwrap_err(),
9843            StoreError::SlotAlreadyLeased { slot: 1, .. }
9844        ));
9845
9846        // And the launcher reports it as itself rather than as a generic
9847        // journal failure, so the operator reads what actually happened.
9848        let error = harness.launcher.record_allocation(&clash).unwrap_err();
9849        let rendered = error.to_string();
9850        assert!(rendered.contains("slot s1"), "{rendered}");
9851        assert!(rendered.contains("nothing was written"), "{rendered}");
9852        assert_eq!(
9853            harness.store.attempts().unwrap().len(),
9854            1,
9855            "the losing allocator journalled nothing"
9856        );
9857    }
9858
9859    #[test]
9860    fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
9861        let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
9862            slots
9863                .iter()
9864                .map(|slot| {
9865                    RunnerAttempt::allocate_in(
9866                        AttemptId::new_random(),
9867                        fixtures::POLICY_ID,
9868                        format!("/srv/rman/acme/s{slot}"),
9869                        AttemptWorkspace::persistent_slot(nz(*slot)),
9870                        fixtures::created_at(),
9871                    )
9872                })
9873                .collect()
9874        };
9875
9876        assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
9877        assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
9878        // The gap a released middle slot leaves is filled before the tail.
9879        assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
9880        // The ceiling is a refusal, never a reason to allocate past it.
9881        assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
9882        assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
9883        // An ephemeral attempt holds no slot and cannot block one.
9884        let ephemeral = vec![RunnerAttempt::allocate(
9885            AttemptId::new_random(),
9886            fixtures::POLICY_ID,
9887            "/srv/rman/host/abc",
9888            fixtures::created_at(),
9889        )];
9890        assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
9891    }
9892
9893    #[test]
9894    fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
9895        let root = tempfile::tempdir().unwrap();
9896        let slot = root.path().join("s1");
9897        fs::create_dir(&slot).unwrap();
9898        accept_reusable_slot(&slot).expect("an empty slot is reusable");
9899
9900        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9901        accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
9902
9903        // Runner material a previous attempt left behind is refused rather than
9904        // reused or removed: deciding those bytes are safe is cleanup's job.
9905        fs::create_dir(slot.join("bin")).unwrap();
9906        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9907        let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
9908        assert!(refusal.contains("bin"), "{refusal}");
9909        assert!(refusal.contains(".github-runner-id"), "{refusal}");
9910
9911        // A `_work` that is not a real directory is not a job workspace.
9912        let file_work = root.path().join("s2");
9913        fs::create_dir(&file_work).unwrap();
9914        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
9915        assert!(accept_reusable_slot(&file_work).is_err());
9916    }
9917
9918    #[cfg(unix)]
9919    #[test]
9920    fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
9921        // Windows needs a privilege to create either kind of link, so the
9922        // link-shaped cases are asserted here; the rule itself is
9923        // platform-independent because it is `symlink_metadata`'s answer.
9924        let root = tempfile::tempdir().unwrap();
9925        let elsewhere = root.path().join("elsewhere");
9926        fs::create_dir(&elsewhere).unwrap();
9927
9928        let slot = root.path().join("s1");
9929        fs::create_dir(&slot).unwrap();
9930        std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9931        assert!(accept_reusable_slot(&slot).is_err());
9932
9933        let linked_slot = root.path().join("s2");
9934        std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
9935        assert!(create_or_validate_slot(&linked_slot).is_err());
9936    }
9937
9938    #[test]
9939    fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
9940        let root = tempfile::tempdir().unwrap();
9941        let occupied = root.path().join("s1");
9942        fs::write(&occupied, b"an operator's file").unwrap();
9943        let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
9944        assert!(refusal.contains("is not a directory"), "{refusal}");
9945        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
9946
9947        let fresh = root.path().join("s2");
9948        create_or_validate_slot(&fresh).expect("a missing slot is created");
9949        assert!(fresh.is_dir());
9950        create_or_validate_slot(&fresh).expect("an existing directory is accepted");
9951    }
9952
9953    #[test]
9954    fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
9955        assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
9956        assert!(!is_work_folder(OsStr::new("_work2")));
9957        // A Windows filesystem is case-insensitive, so `_Work` *is* the retained
9958        // job workspace there and must never be removed as a leftover; on a
9959        // case-sensitive filesystem it is a different directory entirely.
9960        assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
9961    }
9962
9963    #[test]
9964    fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
9965        let root = tempfile::tempdir().unwrap();
9966        let package = root.path().join("package");
9967        fs::create_dir_all(package.join("bin")).unwrap();
9968        fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
9969        // A nested `_work` inside the package's own tree is an ordinary name.
9970        fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
9971
9972        let slot = root.path().join("s1");
9973        let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
9974        fs::create_dir_all(&retained).unwrap();
9975        fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
9976
9977        copy_package_tree(&package, &slot, &TrustedTrees::default())
9978            .expect("the package lays out around `_work`");
9979        assert!(slot.join("bin").join("Runner.Listener").exists());
9980        assert!(
9981            slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
9982            "the guard is top-level only"
9983        );
9984        assert_eq!(
9985            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9986            "from the first job"
9987        );
9988
9989        // A package that ever grew a top-level `_work` is refused, not merged.
9990        fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
9991        let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
9992        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
9993        assert_eq!(
9994            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9995            "from the first job"
9996        );
9997    }
9998
9999    #[test]
10000    fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
10001        let root = tempfile::tempdir().unwrap();
10002
10003        let slot = root.path().join("s1");
10004        let retained = slot.join(DEFAULT_WORK_FOLDER);
10005        fs::create_dir_all(retained.join("repo")).unwrap();
10006        fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
10007        fs::create_dir_all(slot.join("bin")).unwrap();
10008        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10009        let persistent = RunnerAttempt::allocate_in(
10010            AttemptId::new_random(),
10011            fixtures::POLICY_ID,
10012            &slot,
10013            AttemptWorkspace::persistent_slot(nz(1)),
10014            fixtures::created_at(),
10015        );
10016
10017        remove_materialized_package(&persistent).unwrap();
10018        assert!(slot.is_dir(), "the slot itself is not removed");
10019        assert!(!slot.join("bin").exists());
10020        assert!(!slot.join(".github-runner-id").exists());
10021        assert_eq!(
10022            fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
10023            "kept"
10024        );
10025
10026        let disposable_path = root.path().join("abcdef012345");
10027        fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
10028        let disposable = RunnerAttempt::allocate(
10029            AttemptId::new_random(),
10030            fixtures::POLICY_ID,
10031            &disposable_path,
10032            fixtures::created_at(),
10033        );
10034        remove_materialized_package(&disposable).unwrap();
10035        assert!(
10036            !disposable_path.exists(),
10037            "a disposable directory is still removed whole"
10038        );
10039    }
10040
10041    // -- c3: persistent cleanup and recovery --------------------------------
10042
10043    /// The runner state one attempt leaves at a slot root, as a real attempt
10044    /// leaves it: binaries, registration identity, a JIT handoff that outlived
10045    /// its process, and this agent's own lifecycle sidecars.
10046    ///
10047    /// Driven by [`SENSITIVE_SLOT_ENTRIES`] rather than by a second copy of it,
10048    /// so a name added to the thing cleanup must prove absent is a name every
10049    /// test here starts leaving behind.
10050    fn litter_the_slot(slot: &Path) {
10051        for directory in ["bin", "externals", "_diag"] {
10052            fs::create_dir_all(slot.join(directory)).unwrap();
10053        }
10054        fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
10055        for file in SENSITIVE_SLOT_ENTRIES
10056            .iter()
10057            .filter(|entry| !slot.join(entry).is_dir())
10058        {
10059            fs::write(slot.join(file), b"runner state").unwrap();
10060        }
10061        // A handoff whose owning process died before `Drop` could delete it.
10062        fs::write(
10063            slot.join(format!(
10064                "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
10065                RestrictiveHandoff::NAME_PREFIX
10066            )),
10067            JIT.as_bytes(),
10068        )
10069        .unwrap();
10070    }
10071
10072    /// A marker under `_work` of the kind a job leaves for the next one.
10073    fn retain_under_work(slot: &Path) -> PathBuf {
10074        let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
10075        fs::create_dir_all(&checkout).unwrap();
10076        let marker = checkout.join("build-output.bin");
10077        fs::write(&marker, RETAINED).unwrap();
10078        marker
10079    }
10080
10081    /// What a job leaves under `_work` for the next job to reuse.
10082    const RETAINED: &str = "a Git-ignored build output the next job reuses";
10083
10084    /// Every direct entry of a directory, sorted, as plain strings.
10085    fn entries_of(directory: &Path) -> Vec<String> {
10086        let mut names: Vec<String> = fs::read_dir(directory)
10087            .unwrap()
10088            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
10089            .collect();
10090        names.sort();
10091        names
10092    }
10093
10094    /// The one entry a cleaned slot is allowed to hold.
10095    fn only_the_job_workspace() -> Vec<String> {
10096        vec![DEFAULT_WORK_FOLDER.to_owned()]
10097    }
10098
10099    #[cfg(unix)]
10100    #[test]
10101    fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
10102        use std::sync::mpsc;
10103
10104        let temporary = tempfile::tempdir().unwrap();
10105        let tree = temporary.path().join("attempt");
10106        let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
10107        fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
10108        assert!(
10109            std::process::Command::new("mkfifo")
10110                .arg(&diagnostic)
10111                .status()
10112                .unwrap()
10113                .success()
10114        );
10115
10116        let (finished, result) = mpsc::channel();
10117        std::thread::spawn(move || {
10118            let removed = remove_runtime_tree(&tree);
10119            let _ = finished.send(removed);
10120        });
10121
10122        result
10123            .recv_timeout(Duration::from_secs(2))
10124            .expect("runtime deletion must not wait for a FIFO peer")
10125            .unwrap();
10126        assert!(!diagnostic.exists());
10127    }
10128
10129    /// The junction WinINet leaves in a job's profile, reproduced: its own DACL
10130    /// denies `Everyone` the right to list it.
10131    #[cfg(windows)]
10132    #[test]
10133    fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
10134        let temporary = tempfile::tempdir().unwrap();
10135        let tree = temporary.path().join("attempt");
10136        let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
10137        let outside = temporary.path().join("outside");
10138        fs::create_dir_all(&cache).unwrap();
10139        fs::create_dir_all(&outside).unwrap();
10140        fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
10141        fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
10142        let junction = cache.join("Content.IE5");
10143        let created = std::process::Command::new("cmd")
10144            .arg("/C")
10145            .arg("mklink")
10146            .arg("/J")
10147            .arg(&junction)
10148            .arg(&outside)
10149            .output()
10150            .unwrap();
10151        assert!(created.status.success(), "{created:?}");
10152        // `/L` puts the entry on the junction itself, as WinINet does, rather
10153        // than on the directory it points at. `*S-1-1-0` is `Everyone` in every
10154        // display language.
10155        let denied = std::process::Command::new("icacls")
10156            .arg(&junction)
10157            .args(["/L", "/deny", "*S-1-1-0:(RD)"])
10158            .output()
10159            .unwrap();
10160        assert!(denied.status.success(), "{denied:?}");
10161
10162        // The control: without the fix the tree cannot be removed at all.
10163        let unfixed = remove_dir_all::remove_dir_all(&tree)
10164            .expect_err("the fixture no longer reproduces the WinINet junction");
10165        assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
10166        assert!(junction.symlink_metadata().is_ok());
10167
10168        remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
10169        assert!(!tree.exists(), "the attempt tree survived");
10170        assert_eq!(
10171            fs::read(outside.join("keep.txt")).unwrap(),
10172            b"not the attempt's",
10173            "removing the junction followed it into the directory it points at"
10174        );
10175    }
10176
10177    /// One slot entry that refuses to be removed, and the undo that lets the
10178    /// temporary directory be torn down afterwards.
10179    ///
10180    /// The two operating systems refuse for different reasons and there is no
10181    /// portable third. Windows will not open a file for deletion while a handle
10182    /// with share mode zero is held on it; Unix will not unlink from a directory
10183    /// the caller cannot write. Both are states a real machine reaches -- a
10184    /// scanner holding a file open, a job that left a directory read-only -- so
10185    /// the injection is a filesystem fact rather than a seam cut into the
10186    /// product for a test to pull.
10187    ///
10188    /// The Unix half is a permission, and permissions do not apply to `root`.
10189    /// [`Self::inject`] proves the block on a throwaway directory before
10190    /// claiming it, so a suite running as `root` says it could not inject rather
10191    /// than asserting nothing and passing.
10192    struct BlockedDeletion {
10193        directory: PathBuf,
10194        #[cfg(windows)]
10195        _handle: fs::File,
10196    }
10197
10198    impl BlockedDeletion {
10199        const HELD: &'static str = "held-open";
10200
10201        /// Fill `directory` with a file that cannot be removed, or answer `None`
10202        /// when this account cannot be stopped from removing anything.
10203        fn inject(directory: &Path) -> Option<Self> {
10204            #[cfg(unix)]
10205            if !Self::refusal_is_possible() {
10206                return None;
10207            }
10208            fs::create_dir_all(directory).unwrap();
10209            fs::write(
10210                directory.join(Self::HELD),
10211                b"a file the scrub cannot remove",
10212            )
10213            .unwrap();
10214            #[cfg(windows)]
10215            let handle = {
10216                use std::os::windows::fs::OpenOptionsExt;
10217
10218                fs::OpenOptions::new()
10219                    .read(true)
10220                    .share_mode(0)
10221                    .open(directory.join(Self::HELD))
10222                    .expect("the blocking handle opens")
10223            };
10224            #[cfg(unix)]
10225            Self::set_mode(directory, 0o555);
10226            Some(Self {
10227                directory: directory.to_path_buf(),
10228                #[cfg(windows)]
10229                _handle: handle,
10230            })
10231        }
10232
10233        fn release(self) {
10234            drop(self);
10235        }
10236
10237        #[cfg(unix)]
10238        fn refusal_is_possible() -> bool {
10239            let probe = tempfile::tempdir().unwrap();
10240            let directory = probe.path().join("probe");
10241            fs::create_dir(&directory).unwrap();
10242            fs::write(directory.join("file"), b"probe").unwrap();
10243            Self::set_mode(&directory, 0o555);
10244            let refused = fs::remove_dir_all(&directory).is_err();
10245            Self::set_mode(&directory, 0o755);
10246            refused
10247        }
10248
10249        #[cfg(unix)]
10250        fn set_mode(directory: &Path, mode: u32) {
10251            use std::os::unix::fs::PermissionsExt;
10252
10253            let mut permissions = fs::metadata(directory).unwrap().permissions();
10254            permissions.set_mode(mode);
10255            fs::set_permissions(directory, permissions).unwrap();
10256        }
10257    }
10258
10259    impl Drop for BlockedDeletion {
10260        fn drop(&mut self) {
10261            #[cfg(unix)]
10262            Self::set_mode(&self.directory, 0o755);
10263            #[cfg(not(unix))]
10264            let _ = &self.directory;
10265        }
10266    }
10267
10268    #[tokio::test]
10269    async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
10270        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10271            .with_persistent_workspace(1);
10272        harness.ready().await;
10273
10274        let first = harness.launch().await;
10275        let slot = harness.slot_path(1);
10276        assert_eq!(first.runtime_path(), slot);
10277        assert_eq!(
10278            read_runner_id(&slot),
10279            Some(73),
10280            "the attempt registered, so its identity is on disk"
10281        );
10282        let marker = retain_under_work(&slot);
10283        litter_the_slot(&slot);
10284
10285        harness.cleanup_retaining_work(first.id).await;
10286
10287        // The allowlist is exactly one entry, so this assertion is the security
10288        // property in full: what is retained, and that nothing else is.
10289        assert_eq!(entries_of(&slot), only_the_job_workspace());
10290        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10291        assert_eq!(
10292            read_runner_id(&slot),
10293            None,
10294            "the first attempt's registration identity is gone before the second starts"
10295        );
10296        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10297        assert!(!harness.attempt(first.id).holds_slot_lease());
10298
10299        let second = harness.launch().await;
10300        assert_ne!(second.id, first.id);
10301        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
10302        assert_eq!(
10303            second.runtime_path(),
10304            slot,
10305            "the same slot, so the same retained `_work`"
10306        );
10307        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10308    }
10309
10310    #[tokio::test]
10311    async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
10312        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10313            .with_persistent_workspace(1);
10314        harness.ready().await;
10315        let attempt = harness.launch().await;
10316        let slot = harness.slot_path(1);
10317        let marker = retain_under_work(&slot);
10318        litter_the_slot(&slot);
10319        harness.conclude(attempt.id);
10320
10321        // A repository removed from the product between the attempt concluding
10322        // and the sweep reaching it. The journalled runtime path and slot are
10323        // the only facts left, and `04-security-recovery.md` requires them to be
10324        // enough: the alternative is scanning a root to work out which
10325        // directories were ours, which invariant 6 forbids.
10326        harness
10327            .store
10328            .remove_policy(harness.policy.id, harness.policy.revision())
10329            .unwrap();
10330        assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
10331
10332        harness
10333            .launcher
10334            .clean(attempt.id)
10335            .await
10336            .expect("journal facts alone are enough to clean the slot");
10337
10338        assert_eq!(entries_of(&slot), only_the_job_workspace());
10339        assert!(marker.exists());
10340        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10341    }
10342
10343    #[tokio::test]
10344    async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
10345        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10346            .with_persistent_workspace(2);
10347        harness.ready().await;
10348        let first = harness.launch().await;
10349        let slot = harness.slot_path(1);
10350        let marker = retain_under_work(&slot);
10351        litter_the_slot(&slot);
10352        harness.conclude(first.id);
10353
10354        let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
10355            eprintln!(
10356                "skipped: this account cannot be refused a deletion, so no partial deletion can \
10357                 be injected"
10358            );
10359            return;
10360        };
10361
10362        let refusal = harness
10363            .launcher
10364            .clean(first.id)
10365            .await
10366            .expect_err("a deletion that failed may not report a cleaned slot");
10367        let rendered = refusal.reason.to_string();
10368        assert!(rendered.contains("could not be removed"), "{rendered}");
10369
10370        let held = harness.attempt(first.id);
10371        assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
10372        assert!(held.holds_slot_lease(), "so the slot is still leased");
10373        assert!(
10374            !held.state().counts_against_capacity(),
10375            "and a concluded attempt still costs the host no capacity"
10376        );
10377
10378        // The same journal and the same directories, under a launcher that
10379        // remembers nothing. Recovery must complete: a host that can launch
10380        // nothing at all because one slot is stuck is not what "does not count
10381        // as active host capacity" means.
10382        let restarted = harness.restart();
10383        restarted
10384            .recover_startup(std::slice::from_ref(&harness.policy))
10385            .await
10386            .expect("one quarantined slot does not stop the host recovering");
10387        assert_eq!(
10388            harness.attempt(first.id).state(),
10389            AttemptState::Failed,
10390            "the quarantine survived the restart"
10391        );
10392        assert!(
10393            harness
10394                .reconcile_events
10395                .events()
10396                .iter()
10397                .any(|event| matches!(
10398                    event,
10399                    LifecycleEvent::AttemptCleanFailed {
10400                        reason: "slot_entry_could_not_be_removed",
10401                        ..
10402                    }
10403                )),
10404            "the refusal is reported rather than retried in silence"
10405        );
10406
10407        // Capacity two, slot one quarantined: the next attempt goes to s2 and
10408        // never to the slot still holding runner state.
10409        let second = harness
10410            .launch_through(&restarted)
10411            .await
10412            .expect("the host can still launch");
10413        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10414
10415        // And the same cleanup succeeds once the obstruction is gone, which is
10416        // what "retry through normal recovery" has to mean.
10417        block.release();
10418        restarted
10419            .clean(first.id)
10420            .await
10421            .expect("the retried cleanup completes");
10422        assert_eq!(entries_of(&slot), only_the_job_workspace());
10423        assert!(marker.exists());
10424        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10425    }
10426
10427    /// The disposable half of the same injection, which had no test of its own.
10428    ///
10429    /// `scrub_workspace`'s ephemeral arm turns a failed `remove_dir_all` into
10430    /// `"attempt workspace could not be removed"`, and until now that branch was
10431    /// only reachable in theory: every injected-deletion test drove a persistent
10432    /// slot. The property is the same one and matters for the same reason --
10433    /// `04-security-recovery.md`'s "Cleanup partly fails and the slot is reused
10434    /// anyway" -- but the disposable guarantee is stronger, so a cleanup that
10435    /// reported success over a directory it had not removed would be the
10436    /// contamination gate failing silently rather than a slot being held.
10437    #[tokio::test]
10438    async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
10439        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10440        harness.ready().await;
10441        let attempt = harness.launch().await;
10442        let runtime = attempt.runtime_path().to_path_buf();
10443        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10444        harness.conclude(attempt.id);
10445
10446        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10447            eprintln!(
10448                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10449            );
10450            return;
10451        };
10452
10453        let refusal = harness
10454            .launcher
10455            .clean(attempt.id)
10456            .await
10457            .expect_err("a deletion that failed may not report a removed workspace");
10458        let rendered = refusal.reason.to_string();
10459        assert!(
10460            rendered.contains("could not be removed"),
10461            "the refusal names what happened: {rendered}"
10462        );
10463        assert_ne!(
10464            harness.attempt(attempt.id).state(),
10465            AttemptState::Cleaned,
10466            "an attempt whose directory is still on disk is not cleaned"
10467        );
10468        assert!(
10469            runtime.is_dir(),
10470            "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
10471        );
10472
10473        let restarted = harness.restart();
10474        restarted
10475            .recover_startup(std::slice::from_ref(&harness.policy))
10476            .await
10477            .expect("one locked ephemeral workspace does not stop startup recovery");
10478        assert!(
10479            harness
10480                .reconcile_events
10481                .events()
10482                .iter()
10483                .any(|event| matches!(
10484                    event,
10485                    LifecycleEvent::AttemptCleanFailed {
10486                        attempt: failed_attempt,
10487                        reason: "ephemeral_workspace_could_not_be_removed",
10488                        ..
10489                    } if *failed_attempt == attempt.id
10490                )),
10491            "the deferred cleanup remains visible while the daemon keeps running"
10492        );
10493
10494        // And the ordinary retry -- the reconciler's next terminal sweep --
10495        // finishes it once the obstruction is gone.
10496        block.release();
10497        harness
10498            .launcher
10499            .clean(attempt.id)
10500            .await
10501            .expect("the retried cleanup completes");
10502        assert!(!runtime.exists(), "the whole attempt directory goes");
10503        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10504    }
10505
10506    /// Supervision retries a workspace it cannot remove with per-attempt
10507    /// backoff, so a stuck workspace is reported once per delay rather than on
10508    /// every pass.
10509    #[tokio::test]
10510    async fn supervision_backs_off_a_workspace_it_cannot_remove() {
10511        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10512        harness.ready().await;
10513        let attempt = harness.launch().await;
10514        let runtime = attempt.runtime_path().to_path_buf();
10515        harness.conclude(attempt.id);
10516        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10517            eprintln!(
10518                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10519            );
10520            return;
10521        };
10522        let failures = || {
10523            harness
10524                .reconcile_events
10525                .events()
10526                .iter()
10527                .filter(|event| {
10528                    matches!(
10529                        event,
10530                        LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
10531                            if *failed == attempt.id
10532                    )
10533                })
10534                .count()
10535        };
10536        let supervise = async || {
10537            harness
10538                .launcher
10539                .supervise(&harness.policy)
10540                .await
10541                .expect("a stuck workspace does not fail supervision");
10542        };
10543
10544        supervise().await;
10545        assert_eq!(failures(), 1);
10546        supervise().await;
10547        assert_eq!(
10548            failures(),
10549            1,
10550            "the failed cleanup was retried on the very next pass"
10551        );
10552        harness.clock.advance_secs(31);
10553        supervise().await;
10554        assert_eq!(
10555            failures(),
10556            2,
10557            "the cleanup was not retried once its delay passed"
10558        );
10559
10560        block.release();
10561        harness.clock.advance_secs(61);
10562        supervise().await;
10563        assert!(
10564            !runtime.exists(),
10565            "the retry removes the workspace once it can"
10566        );
10567        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10568    }
10569
10570    #[tokio::test]
10571    async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
10572        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10573            .with_persistent_workspace(1);
10574        harness.ready().await;
10575        let first = harness.launch().await;
10576        let slot = harness.slot_path(1);
10577        let marker = retain_under_work(&slot);
10578        harness.cleanup_retaining_work(first.id).await;
10579
10580        // Every attempt for this policy is cleaned, so the mutation is allowed
10581        // (`04-security-recovery.md`, "Recovery rules"). What it must not do is
10582        // move or delete anything the operator still owns.
10583        harness
10584            .policy
10585            .set_workspace_policy(WorkspacePolicy::Ephemeral)
10586            .unwrap();
10587
10588        let second = harness.launch().await;
10589        assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
10590        assert_eq!(
10591            second.runtime_path().parent().unwrap(),
10592            harness.host_root(),
10593            "a disposable attempt is a child of the host root"
10594        );
10595        assert!(slot.is_dir(), "the old slot is left where it stands");
10596        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10597
10598        // And cleaning the disposable attempt removes its own directory whole
10599        // without reaching the retained slot beside it.
10600        harness.conclude(second.id);
10601        harness.launcher.clean(second.id).await.unwrap();
10602        assert!(!second.runtime_path().exists());
10603        assert!(marker.exists());
10604    }
10605
10606    #[tokio::test]
10607    async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
10608        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10609            .with_persistent_workspace(1);
10610        harness.ready().await;
10611        let slot = harness.slot_path(1);
10612        fs::create_dir_all(&slot).unwrap();
10613        let marker = retain_under_work(&slot);
10614        litter_the_slot(&slot);
10615
10616        let id = AttemptId::new_random();
10617        let mut attempt = RunnerAttempt::allocate_in(
10618            id,
10619            harness.policy.id,
10620            &slot,
10621            AttemptWorkspace::persistent_slot(nz(1)),
10622            harness.clock.now(),
10623        );
10624        attempt.jit_received(harness.clock.now()).unwrap();
10625        attempt.started(4242, harness.clock.now()).unwrap();
10626        harness.store.record_attempt(&attempt).unwrap();
10627        harness.clock.advance_secs(11);
10628        harness.processes.set_alive(true);
10629        harness
10630            .github
10631            .observe(GithubRunnerObservation::NotRegistered);
10632
10633        harness.launcher.supervise(&harness.policy).await.unwrap();
10634
10635        // The identity and termination ordering `e3` established is unchanged by
10636        // the workspace kind: the intent is durable before the signal, and the
10637        // slot is scrubbed only once the process is gone.
10638        let actions = harness.processes.actions.lock().unwrap().clone();
10639        let intent = actions
10640            .iter()
10641            .position(|action| *action == "terminate_intent")
10642            .unwrap();
10643        let signal = actions
10644            .iter()
10645            .position(|action| *action == "terminate")
10646            .unwrap();
10647        assert!(intent < signal, "{actions:?}");
10648        assert!(!harness.processes.alive.load(Ordering::SeqCst));
10649
10650        let cleaned = harness.attempt(id);
10651        assert_eq!(cleaned.state(), AttemptState::Cleaned);
10652        assert!(matches!(
10653            cleaned.outcome(),
10654            Some(AttemptOutcome::Failed {
10655                reason: FailureReason::TerminatedAfterRegistrationTimeout
10656            })
10657        ));
10658        assert_eq!(entries_of(&slot), only_the_job_workspace());
10659        assert!(marker.exists());
10660    }
10661
10662    #[test]
10663    fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
10664        let root = tempfile::tempdir().unwrap();
10665        let slot = root.path().join("s1");
10666        fs::create_dir(&slot).unwrap();
10667        let marker = retain_under_work(&slot);
10668        litter_the_slot(&slot);
10669        fs::write(slot.join("runner-package"), b"verified").unwrap();
10670
10671        scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
10672        verify_slot_scrubbed(&slot).expect("and proves it afterwards");
10673
10674        assert_eq!(entries_of(&slot), only_the_job_workspace());
10675        assert!(marker.exists());
10676    }
10677
10678    #[test]
10679    fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
10680        let slot = Path::new("/runners/s1");
10681
10682        // The ordinary case: the listing counted, so the count is the fact and
10683        // the published names qualify it.
10684        let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
10685        assert!(counted.contains("2 entries other than"), "{counted}");
10686        assert!(counted.contains("including `bin`"), "{counted}");
10687        assert_eq!(
10688            residue_detail(slot, 1, &[]),
10689            format!(
10690                "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
10691                slot.display()
10692            )
10693        );
10694
10695        // The race the second pass exists for: the listing saw nothing and the
10696        // filesystem answered otherwise. Saying "0 entries survived" here would
10697        // state the under-count as the fact and contradict the rest of the
10698        // sentence.
10699        let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
10700        assert!(!raced.contains('0'), "{raced}");
10701        assert!(raced.contains("reported nothing but"), "{raced}");
10702        assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
10703    }
10704
10705    #[test]
10706    fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
10707        let root = tempfile::tempdir().unwrap();
10708        let slot = root.path().join("s1");
10709        fs::create_dir(&slot).unwrap();
10710        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10711        verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
10712
10713        // Runner binaries, registration identity, process identity and this
10714        // agent's lifecycle marks, one at a time, so a scrub that skipped
10715        // exactly one is still caught.
10716        for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
10717            fs::write(slot.join(survivor), b"left behind").unwrap();
10718            let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10719            assert_eq!(quarantine.refusal, SlotRefusal::Residue);
10720            assert!(
10721                quarantine.detail.contains(&format!("`{survivor}`")),
10722                "{quarantine}"
10723            );
10724            fs::remove_file(slot.join(survivor)).unwrap();
10725        }
10726
10727        // A handoff is named by its published prefix, never by the UUID that
10728        // follows it, and never by the payload it holds.
10729        let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
10730        fs::write(&handoff, JIT.as_bytes()).unwrap();
10731        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10732        assert!(
10733            quarantine.detail.contains("an encoded JIT handoff"),
10734            "{quarantine}"
10735        );
10736        assert!(!quarantine.detail.contains(JIT), "{quarantine}");
10737        fs::remove_file(&handoff).unwrap();
10738
10739        // A name a workflow chose is counted and never echoed: a slot root is
10740        // writable by the job, so a file named after a secret would be published
10741        // by any message that repeated the listing.
10742        fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
10743        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10744        assert!(
10745            quarantine.detail.contains("1 entry other than"),
10746            "{quarantine}"
10747        );
10748        assert!(
10749            !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
10750            "{quarantine}"
10751        );
10752    }
10753
10754    #[test]
10755    fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
10756        let root = tempfile::tempdir().unwrap();
10757        let configured =
10758            LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
10759        let slot = configured.as_path().join("s1");
10760        fs::create_dir(&slot).unwrap();
10761
10762        verify_journalled_slot(&slot, nz(1), Some(&configured))
10763            .expect("the journalled slot agrees");
10764        verify_journalled_slot(&slot, nz(1), None)
10765            .expect("and a policy that is gone removes a check, not the ability to clean");
10766
10767        // The journalled slot number is what names the directory. `s1` recorded
10768        // as slot two is corrupt state, not a slot to clean.
10769        assert_eq!(
10770            verify_journalled_slot(&slot, nz(2), None)
10771                .unwrap_err()
10772                .refusal,
10773            SlotRefusal::NotTheJournalledSlot
10774        );
10775        for stray in ["s1/nested", "not-a-slot", "s01"] {
10776            let path = configured.as_path().join(stray);
10777            assert_eq!(
10778                verify_journalled_slot(&path, nz(1), None)
10779                    .unwrap_err()
10780                    .refusal,
10781                SlotRefusal::NotTheJournalledSlot,
10782                "{}",
10783                path.display()
10784            );
10785        }
10786
10787        // A surviving policy that names a different root does not get to have
10788        // its disagreement resolved by deleting something.
10789        let elsewhere = tempfile::tempdir().unwrap();
10790        let other =
10791            LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
10792        assert_eq!(
10793            verify_journalled_slot(&slot, nz(1), Some(&other))
10794                .unwrap_err()
10795                .refusal,
10796            SlotRefusal::PolicyRootDisagrees
10797        );
10798    }
10799
10800    #[cfg(unix)]
10801    #[test]
10802    fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
10803        // Windows needs a privilege to create a junction or a symlink, so the
10804        // substitution is made here; the rule is platform-independent because it
10805        // is `symlink_metadata`'s answer plus the reparse attribute.
10806        let root = tempfile::tempdir().unwrap();
10807        let outside = root.path().join("operator-data");
10808        fs::create_dir(&outside).unwrap();
10809        let sentinel = outside.join("do-not-delete.txt");
10810        fs::write(
10811            &sentinel,
10812            b"an operator's data, outside every approved root",
10813        )
10814        .unwrap();
10815
10816        let slot = root.path().join("s1");
10817        fs::create_dir(&slot).unwrap();
10818        fs::create_dir(slot.join("bin")).unwrap();
10819        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10820
10821        let quarantine = scrub_slot_entries(&slot).unwrap_err();
10822        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10823        assert!(
10824            sentinel.exists(),
10825            "the deletion followed the link out of the slot"
10826        );
10827        assert!(outside.is_dir());
10828        assert!(
10829            slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
10830            "the substituted link is left for the operator, never unlinked as if it were ours"
10831        );
10832
10833        // A `_work` that is a plain file is the same refusal for the same
10834        // reason: it is not a job workspace, and this is not the code that
10835        // decides what to do about it.
10836        let file_work = root.path().join("s2");
10837        fs::create_dir(&file_work).unwrap();
10838        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10839        assert_eq!(
10840            scrub_slot_entries(&file_work).unwrap_err().refusal,
10841            SlotRefusal::WorkNotADirectory
10842        );
10843    }
10844
10845    #[cfg(unix)]
10846    #[test]
10847    fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
10848        let root = tempfile::tempdir().unwrap();
10849        let outside = root.path().join("operator-data");
10850        fs::create_dir(&outside).unwrap();
10851        let sentinel = outside.join("do-not-delete.txt");
10852        fs::write(
10853            &sentinel,
10854            b"an operator's data, outside every approved root",
10855        )
10856        .unwrap();
10857
10858        // The lexical half of containment passes -- the name is right and the
10859        // parent is right -- and canonical resolution is what catches it.
10860        let inside = root.path().join("inside");
10861        fs::create_dir(&inside).unwrap();
10862        let slot = inside.join("s1");
10863        std::os::unix::fs::symlink(&outside, &slot).unwrap();
10864
10865        assert_eq!(
10866            verify_journalled_slot(&slot, nz(1), None)
10867                .unwrap_err()
10868                .refusal,
10869            SlotRefusal::Containment
10870        );
10871        assert!(sentinel.exists());
10872        assert!(
10873            slot.symlink_metadata().is_ok(),
10874            "the link is left for the operator rather than removed as if it were ours"
10875        );
10876    }
10877
10878    /// The Windows half of the case above.
10879    ///
10880    /// Containment was proven only on Unix, and a symbolic link is the wrong
10881    /// instrument to prove it with on Windows: creating one needs a privilege an
10882    /// ordinary workflow does not have, so it is not the substitution an
10883    /// attacker would reach for. A **junction** needs none, which makes it both
10884    /// the realistic attack and the one this repository must refuse -- and
10885    /// `04-security-recovery.md` names it in the same breath as the symlink for
10886    /// exactly that reason.
10887    #[cfg(windows)]
10888    #[test]
10889    fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
10890        let root = tempfile::tempdir().unwrap();
10891        let outside = root.path().join("operator-data");
10892        fs::create_dir(&outside).unwrap();
10893        let sentinel = outside.join("do-not-delete.txt");
10894        fs::write(
10895            &sentinel,
10896            b"an operator's data, outside every approved root",
10897        )
10898        .unwrap();
10899
10900        // The lexical half of containment passes -- `s1` under the root the
10901        // journal names -- and canonical resolution is what catches it.
10902        let inside = root.path().join("inside");
10903        fs::create_dir(&inside).unwrap();
10904        let slot = inside.join("s1");
10905        let Some(()) = plant_junction(&slot, &outside) else {
10906            eprintln!("skipped: this machine would not create a directory junction");
10907            return;
10908        };
10909
10910        assert_eq!(
10911            verify_journalled_slot(&slot, nz(1), None)
10912                .unwrap_err()
10913                .refusal,
10914            SlotRefusal::Containment
10915        );
10916        assert!(
10917            sentinel.exists(),
10918            "the refusal resolved the junction and reached the operator's data"
10919        );
10920        assert!(
10921            slot.symlink_metadata().is_ok(),
10922            "the junction is left for the operator rather than removed as if it were ours"
10923        );
10924    }
10925
10926    /// Plant a directory junction at `link` pointing at `target`.
10927    ///
10928    /// A junction is the Windows substitution this has to refuse, and unlike a
10929    /// symbolic link it needs no privilege — which is exactly why it is the one
10930    /// an unprivileged workflow would reach for. `mklink` is a `cmd` builtin, so
10931    /// there is no binary to find and nothing to install; `None` means this
10932    /// machine would not make one and the caller says so rather than asserting
10933    /// nothing.
10934    #[cfg(windows)]
10935    fn plant_junction(link: &Path, target: &Path) -> Option<()> {
10936        let made = std::process::Command::new("cmd")
10937            .arg("/C")
10938            .arg("mklink")
10939            .arg("/J")
10940            .arg(link)
10941            .arg(target)
10942            .output()
10943            .ok()?;
10944        (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
10945    }
10946
10947    /// The dependency caches live beside attempts, under `<runner root>/_cache`,
10948    /// and jobs link into them (pnpm hard-links from its store; a workflow may
10949    /// symlink or junction a cache into its workspace). Removing an ephemeral
10950    /// attempt must take the links and never what they point at.
10951    #[test]
10952    fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
10953        let root = tempfile::tempdir().unwrap();
10954        let cache = root.path().join("_cache").join("o").join("r").join("npm");
10955        fs::create_dir_all(&cache).unwrap();
10956        let cached = cache.join("package.tgz");
10957        fs::write(&cached, b"kept between jobs").unwrap();
10958        let attempt = root.path().join("0123456789abcdef");
10959        let work = attempt.join("_work").join("repo");
10960        fs::create_dir_all(&work).unwrap();
10961        fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
10962        #[cfg(unix)]
10963        std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
10964        #[cfg(windows)]
10965        if plant_junction(&work.join("linked-cache"), &cache).is_none() {
10966            eprintln!("this machine would not create a junction; the hard link is still checked");
10967        }
10968
10969        remove_runtime_tree(&attempt).unwrap();
10970        assert!(!attempt.exists());
10971        assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
10972    }
10973
10974    #[cfg(windows)]
10975    #[test]
10976    fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
10977        let root = tempfile::tempdir().unwrap();
10978        let outside = root.path().join("operator-data");
10979        fs::create_dir(&outside).unwrap();
10980        let sentinel = outside.join("do-not-delete.txt");
10981        fs::write(
10982            &sentinel,
10983            b"an operator's data, outside every approved root",
10984        )
10985        .unwrap();
10986
10987        let slot = root.path().join("s1");
10988        fs::create_dir(&slot).unwrap();
10989        fs::create_dir(slot.join("bin")).unwrap();
10990        let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
10991            eprintln!("skipped: this machine would not create a directory junction");
10992            return;
10993        };
10994
10995        // The reparse point is what `is_link_like` answers on, so a junction is
10996        // refused for the same reason a symbolic link is and neither is
10997        // descended into.
10998        let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10999        assert!(is_link_like(&work), "a junction is a reparse point");
11000        let quarantine = scrub_slot_entries(&slot).unwrap_err();
11001        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11002        assert!(
11003            sentinel.exists(),
11004            "the deletion followed the junction out of the slot"
11005        );
11006        assert!(outside.is_dir());
11007
11008        // A junction standing where an ordinary entry was is unlinked rather
11009        // than followed, so the removal still cannot reach through it.
11010        let elsewhere = root.path().join("s2");
11011        fs::create_dir(&elsewhere).unwrap();
11012        fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
11013        if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
11014            scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
11015            verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
11016            assert!(sentinel.exists(), "the junction was followed, not unlinked");
11017            assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
11018        }
11019    }
11020
11021    #[cfg(unix)]
11022    #[tokio::test]
11023    async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
11024        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11025            .with_persistent_workspace(2);
11026        harness.ready().await;
11027        let first = harness.launch().await;
11028        let slot = harness.slot_path(1);
11029        harness.conclude(first.id);
11030
11031        let outside = harness._root.path().join("operator-data");
11032        fs::create_dir_all(&outside).unwrap();
11033        let sentinel = outside.join("do-not-delete.txt");
11034        fs::write(&sentinel, b"outside every approved root").unwrap();
11035        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11036
11037        harness
11038            .launcher
11039            .clean(first.id)
11040            .await
11041            .expect_err("a slot whose `_work` was substituted is quarantined");
11042        assert!(sentinel.exists());
11043
11044        let held = harness.attempt(first.id);
11045        assert_eq!(held.state(), AttemptState::Failed);
11046        assert!(held.holds_slot_lease());
11047
11048        // The quarantined slot is not silently chosen again.
11049        let second = harness.launch().await;
11050        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
11051    }
11052
11053    #[test]
11054    fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
11055        let root = tempfile::tempdir().unwrap();
11056        let occupied = root.path().join("s1");
11057        fs::write(&occupied, b"an operator's file").unwrap();
11058        // The journal check passes -- it is the right name under the right root
11059        // -- and the shape check is what refuses.
11060        verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
11061        assert_eq!(
11062            slot_is_present(&occupied).unwrap_err().refusal,
11063            SlotRefusal::SlotNotADirectory
11064        );
11065        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
11066
11067        // A directory that is simply not there leaves nothing to remove and
11068        // nothing to prove absent, so it is not a refusal.
11069        assert!(!slot_is_present(&root.path().join("s2")).unwrap());
11070        let present = root.path().join("s3");
11071        fs::create_dir(&present).unwrap();
11072        assert!(slot_is_present(&present).unwrap());
11073    }
11074
11075    #[test]
11076    fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
11077        let root = tempfile::tempdir().unwrap();
11078
11079        // A disposable directory that happens to contain a `_work` still goes
11080        // whole: the workspace kind is immutable so that the shape of a
11081        // directory a workflow can write to cannot choose its own algorithm.
11082        let disposable = root.path().join("abcdef012345");
11083        fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11084        let ephemeral = RunnerAttempt::allocate(
11085            AttemptId::new_random(),
11086            fixtures::POLICY_ID,
11087            &disposable,
11088            fixtures::created_at(),
11089        );
11090        remove_materialized_package(&ephemeral).unwrap();
11091        assert!(!disposable.exists());
11092
11093        // And a slot keeps its `_work` with the same contents beneath it.
11094        let slot = root.path().join("s1");
11095        fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11096        fs::create_dir_all(slot.join("bin")).unwrap();
11097        let persistent = RunnerAttempt::allocate_in(
11098            AttemptId::new_random(),
11099            fixtures::POLICY_ID,
11100            &slot,
11101            AttemptWorkspace::persistent_slot(nz(1)),
11102            fixtures::created_at(),
11103        );
11104        remove_materialized_package(&persistent).unwrap();
11105        assert_eq!(entries_of(&slot), only_the_job_workspace());
11106        assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
11107    }
11108
11109    #[test]
11110    fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
11111        let refusals = [
11112            SlotRefusal::NotTheJournalledSlot,
11113            SlotRefusal::PolicyRootDisagrees,
11114            SlotRefusal::Containment,
11115            SlotRefusal::SlotNotADirectory,
11116            SlotRefusal::Enumeration,
11117            SlotRefusal::WorkNotADirectory,
11118            SlotRefusal::Deletion,
11119            SlotRefusal::Residue,
11120        ];
11121        let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
11122        assert_eq!(
11123            classes.len(),
11124            refusals.len(),
11125            "an event class shared by two refusals tells an operator less than it appears to"
11126        );
11127        for refusal in refusals {
11128            // The event field is a closed vocabulary, so it has to look like
11129            // one: `d1`'s sink allows the name verbatim.
11130            assert!(
11131                refusal
11132                    .class()
11133                    .chars()
11134                    .all(|c| c.is_ascii_lowercase() || c == '_'),
11135                "{}",
11136                refusal.class()
11137            );
11138            assert!(
11139                refusal.remediation().contains("slot lease"),
11140                "every refusal has to say the lease is still held: {}",
11141                refusal.class()
11142            );
11143        }
11144    }
11145
11146    #[test]
11147    fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
11148        let root = tempfile::tempdir().unwrap();
11149        let source = root.path().join("source with spaces");
11150        let dest = root.path().join("dest with spaces");
11151
11152        fs::create_dir_all(&source).unwrap();
11153        fs::write(source.join("file1.txt"), b"hello").unwrap();
11154
11155        let nested = source.join("nested dir");
11156        fs::create_dir_all(&nested).unwrap();
11157        fs::write(nested.join("file2.txt"), b"world").unwrap();
11158
11159        // This is not a top-level `_work`, so it should be allowed
11160        let nested_work = nested.join(DEFAULT_WORK_FOLDER);
11161        fs::create_dir_all(&nested_work).unwrap();
11162        fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
11163
11164        copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
11165
11166        assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
11167        assert_eq!(
11168            fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
11169            "world"
11170        );
11171        assert_eq!(
11172            fs::read_to_string(
11173                dest.join("nested dir")
11174                    .join(DEFAULT_WORK_FOLDER)
11175                    .join("allowed.txt")
11176            )
11177            .unwrap(),
11178            "allowed"
11179        );
11180    }
11181
11182    #[test]
11183    fn every_field_name_the_attempt_sink_emits_is_allowed() {
11184        use runner_manager_platform::logging::is_field_allowed;
11185
11186        // Kept beside the sink rather than derived from it, as in `reconcile`.
11187        for field in [
11188            "event",
11189            "attempt_id",
11190            "reason",
11191            "retry_in_ms",
11192            "duration_ms",
11193            "lock_wait_ms",
11194            "turn_wait_ms",
11195            "materialize_ms",
11196            "package_wait_ms",
11197            "package_install_ms",
11198            "package_verify_ms",
11199            "package_copy_ms",
11200            "package_downloaded",
11201            "prepare_ms",
11202            "register_ms",
11203            "spawn_ms",
11204        ] {
11205            assert!(
11206                is_field_allowed(field),
11207                "`{field}` would be logged as `[redacted]`"
11208            );
11209        }
11210    }
11211
11212    #[cfg(unix)]
11213    fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
11214        let entry = root.join("packages").join(version);
11215        fs::create_dir_all(entry.join("bin")).unwrap();
11216        fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
11217        fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
11218        entry
11219    }
11220
11221    /// The seed's binary's inode: a rebuilt seed holds a new file.
11222    #[cfg(unix)]
11223    fn listener_inode(seed: &Path) -> u64 {
11224        use std::os::unix::fs::MetadataExt as _;
11225        fs::metadata(seed.join("bin").join("Runner.Listener"))
11226            .unwrap()
11227            .ino()
11228    }
11229
11230    #[cfg(unix)]
11231    #[test]
11232    fn a_package_seed_is_built_once_and_reused_while_untouched() {
11233        let root = tempfile::tempdir().unwrap();
11234        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11235        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11236        let trusted = TrustedTrees::default();
11237
11238        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11239        assert_eq!(seed, seeds.join("2.336.0"));
11240        assert_eq!(
11241            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11242            "2.336.0"
11243        );
11244
11245        let built = listener_inode(&seed);
11246        assert_eq!(
11247            refresh_package_seed(&source, &seeds, &trusted).unwrap(),
11248            seed
11249        );
11250        assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
11251    }
11252
11253    #[cfg(unix)]
11254    #[test]
11255    fn a_tampered_or_unrecorded_seed_is_rebuilt() {
11256        let root = tempfile::tempdir().unwrap();
11257        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11258        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11259        let trusted = TrustedTrees::default();
11260        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11261
11262        // A job rewrites the binary in place and leaves the manifest alone.
11263        fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
11264        refresh_package_seed(&source, &seeds, &trusted).unwrap();
11265        assert_eq!(
11266            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11267            "2.336.0",
11268            "a seed whose print moved is rebuilt from the cache entry"
11269        );
11270
11271        // A restarted daemon has no print of a seed it did not build.
11272        // Keep the old inode allocated until the replacement is complete;
11273        // otherwise the filesystem may immediately reuse its number.
11274        let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
11275        let built = listener_inode(&seed);
11276        refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
11277        assert_ne!(
11278            listener_inode(&seed),
11279            built,
11280            "an unrecorded seed is rebuilt"
11281        );
11282        drop(old_listener);
11283    }
11284
11285    #[cfg(unix)]
11286    #[test]
11287    fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
11288        let root = tempfile::tempdir().unwrap();
11289        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11290        let trusted = TrustedTrees::default();
11291        let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
11292        refresh_package_seed(&old, &seeds, &trusted).unwrap();
11293        fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
11294        fs::write(seeds.join("stray"), b"").unwrap();
11295
11296        let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
11297        let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
11298
11299        let mut left: Vec<_> = fs::read_dir(&seeds)
11300            .unwrap()
11301            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
11302            .collect();
11303        left.sort();
11304        assert_eq!(left, ["2.336.0"]);
11305
11306        // A reinstalled entry of the same version has a different manifest.
11307        fs::write(
11308            new.join(crate::package::MANIFEST_FILE),
11309            "{\"digest\":\"again\"}",
11310        )
11311        .unwrap();
11312        refresh_package_seed(&new, &seeds, &trusted).unwrap();
11313        assert_eq!(
11314            fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
11315            "{\"digest\":\"again\"}",
11316            "a stale seed is rebuilt from the changed entry"
11317        );
11318    }
11319
11320    #[cfg(unix)]
11321    #[test]
11322    fn a_package_on_the_runtime_volume_is_its_own_seed() {
11323        let root = tempfile::tempdir().unwrap();
11324        let source = package_entry(root.path(), "2.336.0", "{}");
11325        let runtime = root.path().join("runners").join("attempt");
11326        fs::create_dir_all(&runtime).unwrap();
11327
11328        let trusted = TrustedTrees::default();
11329        assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
11330        copy_package_tree(&source, &runtime, &trusted).unwrap();
11331        assert!(runtime.join("bin").join("Runner.Listener").exists());
11332        assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
11333    }
11334
11335    #[test]
11336    fn copy_package_tree_refuses_top_level_work_folder() {
11337        let root = tempfile::tempdir().unwrap();
11338        let source = root.path().join("source");
11339        let dest = root.path().join("dest");
11340
11341        fs::create_dir_all(&source).unwrap();
11342        fs::write(source.join("file1.txt"), b"hello").unwrap();
11343
11344        // Top-level `_work` should be refused
11345        let top_work = source.join(DEFAULT_WORK_FOLDER);
11346        fs::create_dir_all(&top_work).unwrap();
11347
11348        let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
11349        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
11350    }
11351}