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_account::{RunnerAccount, RunnerPool};
43use runner_manager_platform::runner_env::{self, RunnerEnv, RunnerPlatform};
44use runner_manager_platform::runner_root::{
45    self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
46};
47use secrecy::SecretString;
48
49use crate::dependency_caches::DependencyCaches;
50use crate::oci::OciProcesses;
51use crate::package::{
52    InstalledPackage, LastCheck, PackageCache, PackageContents, PackageError, RunnerVersion,
53};
54use crate::reconcile::{
55    AllocationGuard, CleanBackoff, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent,
56    OutcomeKind, ReplacementIntent, ReservationWaits, RunnerLauncher, failure_reason_kind,
57};
58
59mod windows_hyperv;
60pub use windows_hyperv::{WindowsHyperVContainers, WindowsHyperVHostState};
61
62const IDENTITY_FILE: &str = ".runner-process.json";
63const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
64const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
65const RUNNER_ID_FILE: &str = ".github-runner-id";
66const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
67const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
68
69/// Slot-root names a cleaned persistent attempt must not have left behind.
70///
71/// This is not the rule — the rule is that *nothing* but a real `_work`
72/// survives, and [`verify_slot_scrubbed`] enforces that by counting. This list
73/// is the second, independent question asked of the same directory: each name
74/// is stat-ed directly, so a scrub that skipped one is caught even if the
75/// enumeration that was supposed to find it under-reported. Every entry is one
76/// of the things `04-security-recovery.md` requires to be proven absent before a
77/// slot is released; the encoded JIT handoff is the one exception, matched by
78/// its published prefix in [`verify_slot_scrubbed`] because the rest of its name
79/// is a UUID. Being compile-time constants, these are also the only entry names
80/// a refusal message is allowed to print.
81const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
82    // Runner binaries and the launchers beside them.
83    "bin",
84    "externals",
85    "run.sh",
86    "run.cmd",
87    "config.sh",
88    "config.cmd",
89    // The registration identity GitHub's runner writes for itself, and the
90    // per-run environment it reads back.
91    ".runner",
92    ".credentials",
93    ".credentials_rsaparams",
94    ".env",
95    ".path",
96    "_diag",
97    // This agent's own process-identity and lifecycle sidecars.
98    IDENTITY_FILE,
99    FALLBACK_IDENTITY_FILE,
100    UNRESOLVED_PROCESS_FILE,
101    RUNNER_ID_FILE,
102    TERMINATE_INTENT_FILE,
103];
104#[cfg(test)]
105const TEST_LISTENER_READY: &str = ".test-listener-ready";
106
107/// GitHub Runner v2.336.0 accepts JIT configuration for `run` through its
108/// secret `ACTIONS_RUNNER_INPUT_JITCONFIG` input. The platform spawn boundary
109/// supplies that input from the restrictive handoff; the listener command line
110/// must contain only the supported `run` command.
111///
112/// The environment is the daemon's plus [`runner_env::platform_defaults`], then
113/// the dependency caches (`caches`, from [`DependencyCaches::for_launch`]), then
114/// the host's `runner.env`, which wins over either of the others.
115/// The per-attempt temporary directory goes last and `runner.env` cannot name
116/// it. On macOS a runner that still starts throttled is reported
117/// ([`SpawnSpec::expect_normal_scheduling`]).
118fn runner_listener_spec(
119    program: PathBuf,
120    runtime: &Path,
121    caches: Vec<(&'static str, std::ffi::OsString)>,
122    host_env: &RunnerEnv,
123) -> SpawnSpec {
124    let tmp = runtime.join("tmp");
125    let _ = std::fs::create_dir_all(&tmp);
126    let defaults = runner_env::platform_defaults(
127        RunnerPlatform::current(),
128        runtime,
129        &runner_env::Inherited::current(),
130        Path::exists,
131    );
132    // The Windows profile, or the Linux home, the defaults point at. Created
133    // even when `runner.env` overrides part of it: it is inside the attempt, so
134    // it costs nothing and goes with the attempt.
135    for (name, dir) in &defaults {
136        if runner_env::DIRECTORY_VARIABLES.contains(name) {
137            let _ = std::fs::create_dir_all(dir);
138        }
139    }
140    let mut spec = SpawnSpec::new(program)
141        .arg("run")
142        .working_dir(runtime)
143        .expect_normal_scheduling();
144    for (name, value) in runner_env::runner_environment(defaults, caches, host_env) {
145        spec = spec.env(name, value);
146    }
147    spec.env("TMPDIR", &tmp).env("TEMP", &tmp).env("TMP", &tmp)
148}
149
150/// Where in an attempt the Docker proxy listens when the runner runs as another
151/// account: `state/` is the daemon's alone, so the socket has to be somewhere
152/// the runner can reach.
153const DOCKER_SOCKET_DIR: &str = ".docker-proxy";
154
155/// Hands `runtime` to `account`, and shares every cache directory `spec`
156/// names with the whole `pool`.
157///
158/// The attempt is the account's alone: the runner writes its configuration,
159/// `_diag` and `_work` beside its own binaries. A cache is the opposite, the
160/// one thing runners are meant to share, so it goes to the pool's group
161/// instead. A cache directory already shared is not walked again.
162fn hand_over(
163    account: RunnerAccount,
164    pool: &RunnerPool,
165    runtime: &Path,
166    spec: &SpawnSpec,
167    cache_names: &[&str],
168) -> std::io::Result<()> {
169    hand_over_attempt(account, runtime)?;
170    for name in cache_names {
171        let Some(value) = spec.configured_env(name) else {
172            continue;
173        };
174        let path = Path::new(value);
175        if !path.is_absolute() {
176            continue;
177        }
178        match std::fs::symlink_metadata(path) {
179            Ok(metadata) if metadata.is_dir() && !pool.is_shared(&metadata) => {
180                pool.share_tree(path)?;
181            }
182            Ok(_) => {}
183            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
184            Err(error) => return Err(error),
185        }
186    }
187    Ok(())
188}
189
190/// Hands the attempt directory at `runtime` to `account`, and lets the pool's
191/// group into the runner root above it.
192fn hand_over_attempt(account: RunnerAccount, runtime: &Path) -> std::io::Result<()> {
193    account.give_tree(runtime)?;
194    #[cfg(unix)]
195    {
196        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
197        // The attempt is its account's alone; no other account, another
198        // runner's included, may enter it. Its parent, the runner root, the
199        // pool's group may read and pass through: root owns it, so no job can
200        // rename or replace another job's attempt. Read as well as search,
201        // because `Runner.Listener` refuses to start unless it can list every
202        // directory above its own.
203        fs::set_permissions(runtime, fs::Permissions::from_mode(0o700))?;
204        if let Some(root) = runtime.parent() {
205            let metadata = fs::symlink_metadata(root)?;
206            if metadata.is_dir() && metadata.uid() == 0 {
207                if metadata.gid() != account.gid {
208                    std::os::unix::fs::lchown(root, None, Some(account.gid))?;
209                }
210                if metadata.mode() & 0o7777 != 0o750 {
211                    fs::set_permissions(root, fs::Permissions::from_mode(0o750))?;
212                }
213            }
214        }
215    }
216    Ok(())
217}
218
219/// The pool account that owns `path`, if any.
220fn owner_in_pool(pool: &RunnerPool, path: &Path) -> Option<RunnerAccount> {
221    #[cfg(unix)]
222    {
223        use std::os::unix::fs::MetadataExt as _;
224        let metadata = fs::symlink_metadata(path).ok()?;
225        pool.account(metadata.uid()).filter(|_| metadata.is_dir())
226    }
227    #[cfg(not(unix))]
228    {
229        let _ = (pool, path);
230        None
231    }
232}
233
234/// What an attempt's process-identity records name.
235#[derive(Debug)]
236enum RecordedProcess {
237    /// Nothing is recorded.
238    None,
239    /// A process the runner account runs, or one that has exited.
240    Runner(ProcessIdentity),
241    /// A live process the runner account does not run: not this daemon's
242    /// runner, so never signalled, and treated as gone.
243    Foreign,
244}
245
246impl RecordedProcess {
247    fn runner(self) -> Option<ProcessIdentity> {
248        match self {
249            Self::Runner(identity) => Some(identity),
250            Self::None | Self::Foreign => None,
251        }
252    }
253}
254
255/// Who a native runner runs as.
256#[derive(Debug, Clone, Default)]
257enum RunnerIdentity {
258    /// As the daemon itself.
259    #[default]
260    Daemon,
261    /// As an account of this pool, its own for the life of its attempt.
262    Pool(RunnerPool),
263    /// As nobody: a confined `root` without the pool starts no runner, and
264    /// this says why.
265    Missing(String),
266}
267
268impl RunnerIdentity {
269    /// The pool runners run as, `None` for the daemon's own account, or why no
270    /// runner may start.
271    fn pool(&self) -> Result<Option<&RunnerPool>, FailureReason> {
272        match self {
273            Self::Daemon => Ok(None),
274            Self::Pool(pool) => Ok(Some(pool)),
275            Self::Missing(why) => Err(FailureReason::Other(why.clone())),
276        }
277    }
278}
279
280/// Retry bounds for failures that can resolve without operator action.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct RetryPolicy {
283    pub max_attempts: u32,
284    pub initial: Duration,
285    pub maximum: Duration,
286}
287
288impl RetryPolicy {
289    #[must_use]
290    pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
291        Self {
292            max_attempts,
293            initial,
294            maximum,
295        }
296    }
297
298    pub(crate) fn delay(self, failure_index: u32) -> Duration {
299        let shift = failure_index.saturating_sub(1).min(31);
300        self.initial
301            .saturating_mul(1_u32 << shift)
302            .min(self.maximum)
303    }
304}
305
306/// Non-secret lifecycle evidence.  Payloads are identifiers and closed enums;
307/// neither the encoded configuration nor child output can enter this type.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum AttemptEvent {
310    State {
311        attempt: AttemptId,
312        state: AttemptState,
313    },
314    Retry {
315        attempt: AttemptId,
316        operation: &'static str,
317        delay: Duration,
318    },
319    /// How long each step of a launch took, whether or not it started a
320    /// runner, so a slow or failed start can be attributed to the wait for the
321    /// allocation lock, the package copy, the JIT request, or the process start.
322    Launched {
323        attempt: AttemptId,
324        started: bool,
325        timings: LaunchTimings,
326    },
327    Adopted {
328        attempt: AttemptId,
329    },
330    RemoteIdentityRecovered {
331        attempt: AttemptId,
332        runner_id: u64,
333    },
334    TerminateIntent {
335        attempt: AttemptId,
336    },
337    Terminated {
338        attempt: AttemptId,
339    },
340    /// The attempt's GitHub registration was removed by this agent. Carries the
341    /// runner id because that is the identifier an operator sees in the
342    /// target's runner settings, and the attempt id is not shown there.
343    Deregistered {
344        attempt: AttemptId,
345        runner_id: u64,
346    },
347    Concluded {
348        attempt: AttemptId,
349        outcome: OutcomeKind,
350    },
351    Cleaned {
352        attempt: AttemptId,
353        outcome: OutcomeKind,
354    },
355    QuarantinedOwnedResource {
356        attempt: AttemptId,
357    },
358}
359
360pub trait AttemptEventSink: fmt::Debug + Send + Sync {
361    fn emit(&self, event: AttemptEvent);
362}
363
364#[derive(Debug, Default)]
365pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
366
367impl AttemptEventLog {
368    #[must_use]
369    pub fn events(&self) -> Vec<AttemptEvent> {
370        self.0
371            .lock()
372            .map(|events| events.clone())
373            .unwrap_or_default()
374    }
375}
376
377impl AttemptEventSink for AttemptEventLog {
378    fn emit(&self, event: AttemptEvent) {
379        if let Ok(mut events) = self.0.lock() {
380            events.push(event);
381        }
382    }
383}
384
385#[derive(Debug, Clone, Copy, Default)]
386pub struct NoAttemptEvents;
387
388impl AttemptEventSink for NoAttemptEvents {
389    fn emit(&self, _event: AttemptEvent) {}
390}
391
392/// Where one launch spent its time. A step the launch never reached is zero.
393#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
394pub struct LaunchTimings {
395    /// Waiting for older launches of this host to reserve first.
396    pub order_wait: Duration,
397    /// Waiting for the host allocation lock, which another launch may hold, and
398    /// for the rest of its pass to be reserved after it.
399    pub lock_wait: Duration,
400    /// Waiting for a turn to prepare: see [`preparation_turns`].
401    pub turn_wait: Duration,
402    pub materialize: Duration,
403    /// Where `materialize` went.
404    pub package: PackageTimings,
405    /// The execution provider's preparation: its environment, and the checks
406    /// of the host's runner environment and dependency caches.
407    pub prepare: Duration,
408    pub register: Duration,
409    pub spawn: Duration,
410}
411
412impl LaunchTimings {
413    #[must_use]
414    pub fn total(&self) -> Duration {
415        self.order_wait
416            + self.lock_wait
417            + self.turn_wait
418            + self.materialize
419            + self.prepare
420            + self.register
421            + self.spawn
422    }
423}
424
425/// Where a launch's package step went, across its retries.
426#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
427pub struct PackageTimings {
428    /// Waiting for another launch's install, or for copies an install must
429    /// not run under.
430    pub wait: Duration,
431    /// Checking the catalog, and downloading and extracting a package.
432    pub install: Duration,
433    /// Fingerprinting the trees a copy may trust.
434    pub verify: Duration,
435    /// Copying or cloning the tree into the runner's runtime.
436    pub copy: Duration,
437    /// A package was downloaded for this launch.
438    pub downloaded: bool,
439}
440
441#[derive(Debug, Clone, Copy)]
442enum LaunchStep {
443    Turn,
444    Materialize,
445    Prepare,
446    Register,
447    Spawn,
448}
449
450/// Times a launch's steps; beginning one ends the one before, so an early
451/// return still leaves the step it failed in measured once [`Self::finish`]
452/// runs.
453#[derive(Debug)]
454struct LaunchStopwatch {
455    timings: LaunchTimings,
456    running: Option<(LaunchStep, std::time::Instant)>,
457}
458
459impl LaunchStopwatch {
460    fn new(waits: ReservationWaits) -> Self {
461        Self {
462            timings: LaunchTimings {
463                order_wait: waits.order,
464                lock_wait: waits.lock,
465                ..LaunchTimings::default()
466            },
467            running: None,
468        }
469    }
470
471    fn begin(&mut self, step: LaunchStep) {
472        self.finish();
473        self.running = Some((step, std::time::Instant::now()));
474    }
475
476    fn finish(&mut self) {
477        if let Some((step, since)) = self.running.take() {
478            let slot = match step {
479                LaunchStep::Turn => &mut self.timings.turn_wait,
480                LaunchStep::Materialize => &mut self.timings.materialize,
481                LaunchStep::Prepare => &mut self.timings.prepare,
482                LaunchStep::Register => &mut self.timings.register,
483                LaunchStep::Spawn => &mut self.timings.spawn,
484            };
485            *slot = since.elapsed();
486        }
487    }
488}
489
490/// A launch slower than this is logged as a warning, with its steps.
491///
492/// A healthy launch is a few seconds; a queued job waits for all of it, and
493/// the daemon's default filter drops the `info` line that times every launch.
494const SLOW_LAUNCH: Duration = Duration::from_secs(60);
495
496/// The daemon's sink for retries and launch timings; the other attempt events
497/// already reach the log through the reconcile sink.
498///
499/// Every field name is on `d1`'s allow-list, or the redacting layer would log
500/// `[redacted]` in its place; `tests::every_field_name_the_attempt_sink_emits_is_allowed`
501/// keeps that true.
502#[derive(Debug, Clone, Copy, Default)]
503pub struct TracingAttemptEvents;
504
505impl AttemptEventSink for TracingAttemptEvents {
506    fn emit(&self, event: AttemptEvent) {
507        match event {
508            AttemptEvent::Retry {
509                attempt,
510                operation,
511                delay,
512            } => tracing::warn!(
513                event = "attempt_retry",
514                attempt_id = %attempt,
515                reason = operation,
516                retry_in_ms = millis(delay),
517            ),
518            AttemptEvent::Launched {
519                attempt,
520                started,
521                timings,
522            } => {
523                macro_rules! launched {
524                    ($level:ident, $name:literal) => {
525                        tracing::$level!(
526                            event = $name,
527                            attempt_id = %attempt,
528                            duration_ms = millis(timings.total()),
529                            order_wait_ms = millis(timings.order_wait),
530                            lock_wait_ms = millis(timings.lock_wait),
531                            turn_wait_ms = millis(timings.turn_wait),
532                            materialize_ms = millis(timings.materialize),
533                            package_wait_ms = millis(timings.package.wait),
534                            package_install_ms = millis(timings.package.install),
535                            package_verify_ms = millis(timings.package.verify),
536                            package_copy_ms = millis(timings.package.copy),
537                            package_downloaded = timings.package.downloaded,
538                            prepare_ms = millis(timings.prepare),
539                            register_ms = millis(timings.register),
540                            spawn_ms = millis(timings.spawn),
541                        )
542                    };
543                }
544                if !started {
545                    launched!(warn, "attempt_launch_failed");
546                } else if timings.total() >= SLOW_LAUNCH {
547                    launched!(warn, "attempt_launch_slow");
548                } else {
549                    launched!(info, "attempt_launched");
550                }
551            }
552            _ => {}
553        }
554    }
555}
556
557fn millis(duration: Duration) -> u64 {
558    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
559}
560
561/// Whether the demand that justified a retry still exists.
562#[async_trait]
563pub trait DemandPersistence: fmt::Debug + Send + Sync {
564    async fn persists(&self, policy: PolicyId) -> bool;
565}
566
567#[derive(Debug, Clone, Copy, Default)]
568pub struct PersistentDemand;
569
570#[async_trait]
571impl DemandPersistence for PersistentDemand {
572    async fn persists(&self, _policy: PolicyId) -> bool {
573        true
574    }
575}
576
577#[async_trait]
578pub trait RetryDelay: fmt::Debug + Send + Sync {
579    async fn wait(&self, duration: Duration);
580}
581
582#[derive(Debug, Clone, Copy, Default)]
583pub struct TokioRetryDelay;
584
585#[async_trait]
586impl RetryDelay for TokioRetryDelay {
587    async fn wait(&self, duration: Duration) {
588        tokio::time::sleep(duration).await;
589    }
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct JitRequestFailure {
594    pub terminal: bool,
595    pub reason: FailureReason,
596    pub retry_after: Option<Duration>,
597}
598
599/// GitHub's authoritative runner state plus the identity returned by inventory.
600/// The id is carried independently of the local sidecar so recovery can close
601/// the crash boundary immediately after a successful remote registration.
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603pub struct LifecycleGithubObservation {
604    pub status: GithubRunnerObservation,
605    pub runner_id: Option<u64>,
606}
607
608impl LifecycleGithubObservation {
609    #[must_use]
610    pub const fn unreachable() -> Self {
611        Self {
612            status: GithubRunnerObservation::Unreachable,
613            runner_id: None,
614        }
615    }
616
617    #[must_use]
618    pub const fn not_registered() -> Self {
619        Self {
620            status: GithubRunnerObservation::NotRegistered,
621            runner_id: None,
622        }
623    }
624
625    #[must_use]
626    pub const fn registered(runner_id: u64, busy: bool) -> Self {
627        Self {
628            status: GithubRunnerObservation::Registered { busy },
629            runner_id: Some(runner_id),
630        }
631    }
632}
633
634/// The two GitHub views the lifecycle needs, combined so one fake can drive
635/// registration and authoritative runner telemetry.
636#[async_trait]
637pub trait LifecycleGithub: fmt::Debug + Send + Sync {
638    async fn register(
639        &self,
640        target: &ScaleTarget,
641        request: &JitRunnerRequest,
642        cancel: &CancelToken,
643    ) -> Result<JitRegistration, JitRequestFailure>;
644
645    async fn observe(
646        &self,
647        target: &ScaleTarget,
648        attempt: AttemptId,
649        cancel: &CancelToken,
650    ) -> LifecycleGithubObservation;
651
652    /// Remove one runner registration this agent created.
653    ///
654    /// Answers whether the registration is gone, and is deliberately not
655    /// fallible in the `Result` sense: no caller may abandon a conclusion
656    /// because GitHub was unreachable. See
657    /// [`LifecycleLauncher::deregister_runner`].
658    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
659}
660
661#[async_trait]
662impl<T> LifecycleGithub for T
663where
664    T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
665{
666    async fn register(
667        &self,
668        target: &ScaleTarget,
669        request: &JitRunnerRequest,
670        cancel: &CancelToken,
671    ) -> Result<JitRegistration, JitRequestFailure> {
672        self.generate_jit_config(target, request, cancel)
673            .await
674            .map_err(|error| {
675                let reason = if matches!(&error, JitError::Forbidden { .. }) {
676                    FailureReason::Other(
677                        "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
678                            .into(),
679                    )
680                } else {
681                    FailureReason::JitRequestFailed
682                };
683                JitRequestFailure {
684                    terminal: error.is_terminal(),
685                    reason,
686                    retry_after: error
687                        .rate_limited()
688                        .map(|limit| limit.delay_from(self.now())),
689                }
690            })
691    }
692
693    async fn observe(
694        &self,
695        target: &ScaleTarget,
696        attempt: AttemptId,
697        cancel: &CancelToken,
698    ) -> LifecycleGithubObservation {
699        let expected_name = runner_name(attempt);
700        match self.list_runners(target, cancel).await {
701            Ok(inventory) => inventory
702                .runners()
703                .iter()
704                .find(|runner| runner.name == expected_name)
705                .map_or(LifecycleGithubObservation::not_registered(), |runner| {
706                    LifecycleGithubObservation::registered(runner.id, runner.busy)
707                }),
708            Err(_) => LifecycleGithubObservation::unreachable(),
709        }
710    }
711
712    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
713        self.remove_runner(target, runner_id, cancel).await.is_ok()
714    }
715}
716
717/// Package/cache operations used by one attempt.
718#[async_trait]
719pub trait RuntimePackages: fmt::Debug + Send + Sync {
720    /// Copy the package into `attempt`'s runtime, adding where the time went
721    /// to `timings`.
722    async fn materialize(
723        &self,
724        attempt: &RunnerAttempt,
725        timings: &mut PackageTimings,
726    ) -> Result<RunnerVersion, FailureReason>;
727    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
728    fn prune_obsolete_guarded(
729        &self,
730        authority: PruneAuthority<'_>,
731        attempts: &[RunnerAttempt],
732    ) -> Result<(), FailureReason>;
733}
734
735/// Unforgeable evidence that pruning was reached through e1's launch request.
736/// The type is public only because it appears in the public adapter trait; its
737/// private field and constructor prevent callers from substituting a guard
738/// acquired from an unrelated lock.
739pub struct PruneAuthority<'a> {
740    _guard: &'a AllocationGuard,
741}
742
743impl fmt::Debug for PruneAuthority<'_> {
744    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
745        f.write_str("PruneAuthority")
746    }
747}
748
749impl<'a> PruneAuthority<'a> {
750    fn from_launch_request(guard: &'a AllocationGuard) -> Self {
751        Self { _guard: guard }
752    }
753
754    #[cfg(test)]
755    pub(crate) const fn for_test(guard: &'a AllocationGuard) -> Self {
756        Self { _guard: guard }
757    }
758}
759
760/// What every target of one daemon shares about the one package directory they
761/// all use: which trees this process vouches for, and who may touch them.
762///
763/// Per target, one target's reinstall replaces the tree another target
764/// recorded, whose next launch finds it changed and reinstalls in turn: on a
765/// host serving two targets, every switch between them was a full download.
766#[derive(Debug, Clone, Default)]
767pub struct SharedPackages {
768    /// Held shared to copy from a tree, exclusive to change one. A cache entry
769    /// or seed is not replaced under a copy.
770    trees: Arc<tokio::sync::RwLock<()>>,
771    trusted: Arc<TrustedTrees>,
772    last_check: LastCheck,
773    /// The last install that failed, and when: the launches queued behind it
774    /// get its answer. See [`CachedRuntimePackages::materialize`].
775    failed_install: Arc<Mutex<Option<(std::time::Instant, FailureReason)>>>,
776}
777
778impl SharedPackages {
779    /// Stops trusting a shared package, unless a later install has replaced the
780    /// record this launch read: that one is another package's. The next launch
781    /// prepares it again.
782    fn discard(
783        &self,
784        root: &Path,
785        origin: &Path,
786        contents: &Arc<PackageContents>,
787        reason: &'static str,
788    ) {
789        if self.trusted.forget_if_holding(root, contents) {
790            tracing::warn!(
791                event = "package_verify_failed",
792                reason,
793                "a shared runner package does not match its verified archive; it is discarded \
794                 and prepared again"
795            );
796            self.trusted.forget(origin);
797        }
798    }
799
800    /// What an install ended in, for the launches queued behind it. A terminal
801    /// failure is not shared: it would close each of them, with no retry of its
802    /// own, and one that succeeded clears the last failure.
803    fn note_install(&self, result: Result<(), &FailureReason>) {
804        *self
805            .failed_install
806            .lock()
807            .unwrap_or_else(std::sync::PoisonError::into_inner) = result
808            .err()
809            .filter(|reason| !package_failure_is_terminal(reason))
810            .map(|reason| (std::time::Instant::now(), reason.clone()));
811    }
812
813    /// What an install that failed while a launch queued since `queued` for it
814    /// answered.
815    fn install_failed_since(&self, queued: std::time::Instant) -> Option<FailureReason> {
816        self.failed_install
817            .lock()
818            .unwrap_or_else(std::sync::PoisonError::into_inner)
819            .as_ref()
820            .filter(|(at, _)| *at >= queued)
821            .map(|(_, reason)| reason.clone())
822    }
823
824    /// For each target's [`PackageCache`]: the published version is one fact,
825    /// so one target's check spares the others theirs, and each check holds
826    /// every copy on this host back.
827    #[must_use]
828    pub fn last_check(&self) -> LastCheck {
829        self.last_check.clone()
830    }
831}
832
833#[cfg(test)]
834impl SharedPackages {
835    pub(crate) async fn copying(&self) -> tokio::sync::RwLockReadGuard<'_, ()> {
836        self.trees.read().await
837    }
838
839    /// Trusts `root`'s look as it is now, keeping what it must hold: what an edit
840    /// no fingerprint can see leaves behind, as on Windows, or by a superuser.
841    pub(crate) fn blind_to_edits_of(&self, root: &Path) {
842        let contents = self.trusted.contents_of(root);
843        self.trusted.record(root, contents).expect("recorded");
844    }
845
846    pub(crate) fn vouches_for(&self, root: &Path) -> bool {
847        self.trusted.contents_of(root).is_some()
848    }
849}
850
851/// Production package adapter.  It takes an e2 lease before returning, so a
852/// cache entry can never look unused while its runtime is starting.
853#[derive(Debug)]
854pub struct CachedRuntimePackages {
855    cache: Arc<PackageCache>,
856    shared: SharedPackages,
857}
858
859/// Add how long `work` took to `slot`.
860async fn timed<T>(slot: &mut Duration, work: impl std::future::Future<Output = T>) -> T {
861    let began = std::time::Instant::now();
862    let result = work.await;
863    *slot += began.elapsed();
864    result
865}
866
867impl CachedRuntimePackages {
868    /// Run `work` against a package tree on the blocking pool: a fingerprint
869    /// walks ~9,000 entries and a copy can take minutes.
870    async fn blocking<T: Send + 'static>(
871        &self,
872        root: &Path,
873        work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
874    ) -> Result<T, FailureReason> {
875        let trusted = Arc::clone(&self.shared.trusted);
876        let root = root.to_path_buf();
877        tokio::task::spawn_blocking(move || work(&trusted, &root))
878            .await
879            .map_err(|_| FailureReason::ProcessStartFailed)
880    }
881
882    /// One target's view of the package directory the targets of `shared` use.
883    #[must_use]
884    pub const fn new(cache: Arc<PackageCache>, shared: SharedPackages) -> Self {
885        Self { cache, shared }
886    }
887
888    /// A copy that cannot be used: removed, and the trees it came from read to see
889    /// whose fault it is. A runtime can be touched by a job, or sit on a disk with a
890    /// fault, and that is no reason to download anything. A tree that differs is
891    /// discarded, and prepared again by the next launch that needs it.
892    async fn reject_copy(
893        &self,
894        attempt: &RunnerAttempt,
895        root: &Path,
896        origin: &Path,
897        contents: &Arc<PackageContents>,
898        timings: &mut PackageTimings,
899    ) -> Result<(), FailureReason> {
900        let (attempt_copy, checking, from) =
901            (attempt.clone(), Arc::clone(contents), origin.to_path_buf());
902        let differs = timed(
903            &mut timings.verify,
904            self.blocking(root, move |_, source| {
905                let _ = remove_materialized_package(&attempt_copy);
906                let shared = checking.verify(source, |_| false);
907                shared.and_then(|()| {
908                    if from == source {
909                        Ok(())
910                    } else {
911                        checking.verify(&from, |_| false)
912                    }
913                })
914            }),
915        )
916        .await?;
917        if let Err(mismatch) = differs {
918            self.shared
919                .discard(root, origin, contents, mismatch.as_str());
920        }
921        Ok(())
922    }
923
924    /// The tree a runtime can be copied from right now, under nothing more than
925    /// the shared hold: nothing to check for or install, and a tree still as
926    /// this process left it.
927    async fn ready(
928        &self,
929        destination: &Path,
930        timings: &mut PackageTimings,
931    ) -> Result<Option<(InstalledPackage, PathBuf)>, FailureReason> {
932        let Some(installed) = self.cache.reusable().map_err(package_failure)? else {
933            return Ok(None);
934        };
935        let destination = destination.to_path_buf();
936        let verifying = self.blocking(installed.root(), move |trusted, source| {
937            if trusted.holds(source) {
938                copy_origin_ready(source, &destination, trusted)
939            } else {
940                None
941            }
942        });
943        let origin = timed(&mut timings.verify, verifying).await?;
944        Ok(origin.map(|origin| (installed, origin)))
945    }
946
947    /// Under the exclusive hold: a package installed and trusted, and the tree
948    /// to copy it from.
949    async fn install_trusted(
950        &self,
951        destination: &Path,
952        timings: &mut PackageTimings,
953    ) -> Result<(InstalledPackage, PathBuf), FailureReason> {
954        let (mut installed, downloaded) = timed(
955            &mut timings.install,
956            self.cache.ensure_installed_reporting(),
957        )
958        .await
959        .map_err(package_failure)?;
960        timings.downloaded |= downloaded;
961        // A cache entry this process did not install, or one that has changed
962        // since, may hold a job's edits; replace it with one downloaded and
963        // verified now. One this call just downloaded is trusted as it lands.
964        // See `TrustedTrees`.
965        let fresh = downloaded
966            || !timed(
967                &mut timings.verify,
968                self.blocking(installed.root(), TrustedTrees::holds),
969            )
970            .await?;
971        if fresh && !downloaded {
972            // After a restart there is simply no record. With one, this is an edit.
973            if self.shared.trusted.contents_of(installed.root()).is_some() {
974                tracing::warn!(
975                    event = "package_verify_failed",
976                    reason = "changed",
977                    "a shared runner package changed since it was prepared; it is discarded and \
978                     prepared again"
979                );
980            }
981            installed = timed(&mut timings.install, self.cache.reinstall())
982                .await
983                .map_err(package_failure)?;
984            timings.downloaded = true;
985        }
986        let destination = destination.to_path_buf();
987        let contents = installed.contents();
988        let origin = timed(
989            &mut timings.verify,
990            self.blocking(installed.root(), move |trusted, source| {
991                if fresh {
992                    trusted.record(source, contents)?;
993                }
994                Ok::<_, std::io::Error>(copy_origin(source, &destination, trusted))
995            }),
996        )
997        .await?
998        .map_err(|_| FailureReason::ProcessStartFailed)?;
999        Ok((installed, origin))
1000    }
1001}
1002
1003#[async_trait]
1004impl RuntimePackages for CachedRuntimePackages {
1005    async fn materialize(
1006        &self,
1007        attempt: &RunnerAttempt,
1008        timings: &mut PackageTimings,
1009    ) -> Result<RunnerVersion, FailureReason> {
1010        let destination = attempt.runtime_path().to_path_buf();
1011        let trees = &self.shared.trees;
1012        // Only a launch that has something to change goes exclusive, and it lets
1013        // go before it copies, so the launches queued behind it do not wait for
1014        // its copy too. Once more if the tree still is not ready: a job edited
1015        // it meanwhile, or a seed cannot be made here. Then it copies under what
1016        // it just made, which no other launch can change.
1017        let mut installs = 0;
1018        let (hold, (installed, origin)) = loop {
1019            let queued = std::time::Instant::now();
1020            let hold = timed(&mut timings.wait, trees.read()).await;
1021            if let Some(ready) = self.ready(&destination, timings).await? {
1022                break (hold, ready);
1023            }
1024            drop(hold);
1025            // An install that failed while this launch queued for it answers
1026            // this launch too. Trying again at once, one queued launch after
1027            // another, would hold every copy of the package back for as many
1028            // stalled downloads as there are launches; the launch's own retry,
1029            // after its back-off, may try the download.
1030            let exclusive = timed(&mut timings.wait, trees.write()).await;
1031            if let Some(reason) = self.shared.install_failed_since(queued) {
1032                return Err(reason);
1033            }
1034            let installed = self.install_trusted(&destination, timings).await;
1035            self.shared.note_install(installed.as_ref().map(|_| ()));
1036            let ready = installed?;
1037            installs += 1;
1038            if installs == 2 {
1039                break (exclusive.downgrade(), ready);
1040            }
1041        };
1042        // Taken under the hold, so it is the record of the tree this copies. None
1043        // vouches for a tree with no record: before it is copied, not after.
1044        let Some(contents) = self.shared.trusted.contents_of(installed.root()) else {
1045            self.shared.trusted.forget(installed.root());
1046            return Err(FailureReason::ProcessStartFailed);
1047        };
1048        let copied_from = origin.clone();
1049        // Off the async thread: the daemon runs a current-thread runtime, and a
1050        // copy can take minutes on a busy disk.
1051        let copied = timed(
1052            &mut timings.copy,
1053            self.blocking(installed.root(), {
1054                let destination = destination.clone();
1055                move |trusted, source| {
1056                    copy_package_from(source, &origin, &destination, trusted)?;
1057                    // Checked again after the copy: an edit a job made while it ran
1058                    // moved the entry's print, and the runtime may hold it.
1059                    if trusted.holds(source) {
1060                        Ok(())
1061                    } else {
1062                        Err(std::io::Error::other(
1063                            "the runner package changed while copied",
1064                        ))
1065                    }
1066                }
1067            }),
1068        )
1069        .await?;
1070        if copied.is_err() {
1071            // Nothing a failed or tainted copy left may reach a retry's copy,
1072            // which lays out over the same runtime.
1073            drop(hold);
1074            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
1075                .await?;
1076            return Err(FailureReason::ProcessStartFailed);
1077        }
1078        if let Err(error) = self.cache.lease(attempt, installed.version()) {
1079            // Undoing the copy must not undo the *job* workspace: a persistent
1080            // slot's `_work` is retained across attempts, and this rollback
1081            // runs before the attempt that would have owned it ever started.
1082            drop(hold);
1083            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
1084                .await?;
1085            return Err(package_failure(error));
1086        }
1087        // Leased while still holding the trees, so a prune that gets the
1088        // exclusive hold finds this runtime's lease.
1089        drop(hold);
1090        // What runs is this copy, so this copy is what is checked, whatever
1091        // happened to the shared trees meanwhile: read in full against the
1092        // archive, under neither the host lock nor the trees' hold.
1093        let persistent = matches!(attempt.workspace(), AttemptWorkspace::PersistentSlot { .. });
1094        let checking = Arc::clone(&contents);
1095        let verified = timed(
1096            &mut timings.verify,
1097            self.blocking(installed.root(), move |_, _| {
1098                checking.verify(&destination, |name| persistent && is_work_folder(name))
1099            }),
1100        )
1101        .await?;
1102        if let Err(mismatch) = verified {
1103            let _ = self.cache.release(attempt.id);
1104            tracing::warn!(
1105                event = "package_copy_differs",
1106                attempt_id = %attempt.id,
1107                reason = mismatch.as_str(),
1108                "a runner's package copy does not match the package's verified archive; it is \
1109                 being removed and will not be used"
1110            );
1111            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
1112                .await?;
1113            return Err(FailureReason::ProcessStartFailed);
1114        }
1115        Ok(installed.version().clone())
1116    }
1117
1118    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
1119        self.cache.release(attempt).map_err(package_failure)
1120    }
1121
1122    /// Everything but the newest version, when nothing is using the trees: a
1123    /// copy from an older version, or an install in progress, makes this a
1124    /// no-op, and a later launch prunes what it left, which costs only disk.
1125    fn prune_obsolete_guarded(
1126        &self,
1127        _authority: PruneAuthority<'_>,
1128        attempts: &[RunnerAttempt],
1129    ) -> Result<(), FailureReason> {
1130        let Ok(_exclusive) = self.shared.trees.try_write() else {
1131            return Ok(());
1132        };
1133        let Some(current) = self.cache.newest_installed().map_err(package_failure)? else {
1134            return Ok(());
1135        };
1136        for installed in self.cache.installed().map_err(package_failure)? {
1137            if installed.version() != current.version() {
1138                match self.cache.prune(installed.version(), attempts) {
1139                    Ok(()) => self.shared.trusted.forget(installed.root()),
1140                    Err(PackageError::VersionInUse { .. }) => {}
1141                    Err(error) => return Err(package_failure(error)),
1142                }
1143            }
1144        }
1145        Ok(())
1146    }
1147}
1148
1149fn package_failure(error: PackageError) -> FailureReason {
1150    error.failure_reason().unwrap_or(FailureReason::Other(
1151        "runner package cache operation failed".into(),
1152    ))
1153}
1154
1155fn package_failure_is_terminal(reason: &FailureReason) -> bool {
1156    matches!(
1157        reason,
1158        FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
1159    )
1160}
1161
1162/// How many hex characters of the attempt id name its workspace.
1163///
1164/// # Why this is not the whole identifier, and why the policy is not in the path
1165///
1166/// Windows refuses a path over `MAX_PATH`, and the runner writes deep inside
1167/// this directory: `_work/<repo>/<repo>/.git/objects/pack/pack-<40 hex>.keep`
1168/// is 100 characters on its own before the repository is named twice. The
1169/// layout used to add two full identifiers -- the policy's and the attempt's,
1170/// 74 characters between them -- and that was enough to put a real checkout
1171/// over the line. Measured, not guessed: this repository's own CI failed here
1172/// three times in a row at 264 characters against a limit of 260, with
1173/// `fatal: cannot write keep file ...: Filename too long`. A repository whose
1174/// name is ten characters longer would have missed by fourteen.
1175///
1176/// The policy identifier is simply redundant -- an attempt identifier is
1177/// unique on its own, and nothing reads the directory tree to find a policy's
1178/// attempts, because [`crate::lifecycle::LifecycleLauncher`] asks the journal.
1179/// Twelve hex characters of the attempt is 48 bits, which for the handful of
1180/// directories one host holds at once is not a collision anybody will see, and
1181/// the journal keeps the full identifier either way.
1182///
1183/// Together that is 61 characters returned to the repository name.
1184const WORKSPACE_NAME_LEN: usize = 12;
1185
1186/// The directory name for one attempt's workspace.
1187fn workspace_name(id: AttemptId) -> String {
1188    let full = id.to_string();
1189    full.chars()
1190        .filter(|c| *c != '-')
1191        .take(WORKSPACE_NAME_LEN)
1192        .collect()
1193}
1194
1195/// Where one attempt's files go, and which cleanup algorithm they are owed.
1196///
1197/// The pair travels together because journalling them apart is exactly the bug
1198/// `AttemptWorkspace` exists to prevent: a `runtime_path` under a persistent
1199/// root recorded as ephemeral would be removed whole, taking the retained job
1200/// workspace with it.
1201#[derive(Debug, Clone)]
1202struct Placement {
1203    runtime: PathBuf,
1204    workspace: AttemptWorkspace,
1205}
1206
1207/// A runner-root refusal, rendered for the operator who has to fix it.
1208///
1209/// `RunnerRootError`'s `Display` already names the path, the relation and the
1210/// remediation command, and none of its variants can carry a credential — they
1211/// are paths, and `03-migration-rollout.md` requires the remediation command to
1212/// reach the operator verbatim.
1213fn root_failure(error: RunnerRootError) -> LifecycleError {
1214    // Logged here because here is the last place this is known at all. A launch
1215    // refused by the runner root fails *before* `record_allocation`, so no
1216    // attempt row is ever written: `b2` has nothing to carry the failure on and
1217    // `g2` has nothing to show it from. The lifecycle event keeps only
1218    // `reason=other`, by `failure_reason_kind`'s rule that no free text may
1219    // reach an event -- which left the whole refusal reading
1220    // `runner_start_failed reason=other`, once per poll, naming nothing.
1221    //
1222    // What travels is the *kind* and not the sentence, and that is forced rather
1223    // than chosen: `crate::logging` redacts every field it does not allow-list
1224    // and then scrubs anything path-shaped out of the ones it does, so a
1225    // rendered `RunnerRootError` -- which is mostly paths -- reaches the log as
1226    // `[redacted]`. `error_kind` is allow-listed and `RunnerRootError::kind` is
1227    // a closed vocabulary that survives the scrub, so this names which of a
1228    // dozen causes the operator has. The paths and the remediation reach them
1229    // through the command line, which is not redacted.
1230    tracing::warn!(
1231        error_kind = error.kind(),
1232        "the runner root refused this launch, so no attempt was created; the host will \
1233         retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
1234         with the same path re-runs this check and prints the directory and the \
1235         remediation in full"
1236    );
1237    LifecycleError::Failed(FailureReason::Other(error.to_string()))
1238}
1239
1240/// The lowest positive slot inside `ceiling` that no uncleaned attempt holds.
1241///
1242/// `leases` is the journal's answer to "which slots are leased"
1243/// (`Store::slot_leases_for_policy`), which deliberately includes a terminal
1244/// attempt whose cleanup has not finished: that attempt still owns its
1245/// directory, so its slot is not free even though it no longer counts against
1246/// host capacity. `None` means the ceiling is reached, which is a refusal and
1247/// not a reason to allocate `s(ceiling + 1)`.
1248fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
1249    let held: BTreeSet<u16> = leases
1250        .iter()
1251        .filter_map(|attempt| attempt.workspace().slot_number())
1252        .collect();
1253    (1..=ceiling.get())
1254        .find(|slot| !held.contains(slot))
1255        .and_then(NonZeroU16::new)
1256}
1257
1258/// Create `<root>/sN`, or prove that what is already there is a real directory.
1259///
1260/// A symlink, junction or reparse point standing where the slot should be is
1261/// refused rather than followed: it is the one thing that could put an attempt's
1262/// files outside the root the operator configured, and
1263/// `04-security-recovery.md` requires that case to fail closed rather than to
1264/// be repaired here.
1265fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
1266    match fs::symlink_metadata(slot) {
1267        // [`is_link_like`] and not `is_symlink`, so that this is the same
1268        // question cleanup asks in [`slot_is_present`]: a reparse tag the
1269        // standard library has no name for is refused here rather than
1270        // allocated into and then quarantined forever by a cleanup that will
1271        // not scrub it.
1272        Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
1273            slot,
1274            "is a symbolic link, junction or other reparse point, which could place runner \
1275             files outside the configured root",
1276        )),
1277        Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
1278        Ok(_) => Ok(()),
1279        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
1280            .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
1281        Err(source) => Err(slot_refusal(
1282            slot,
1283            format!("could not be inspected: {source}"),
1284        )),
1285    }
1286}
1287
1288/// Accept a slot for reuse only when it is empty or holds one real `_work`.
1289///
1290/// `02-target-architecture.md`: "Before materialization, a reusable slot must
1291/// contain only a valid real `_work` directory or be empty." Everything else —
1292/// a leftover `bin/`, a link-shaped `_work`, a stray file — is refused here
1293/// rather than cleaned, because deciding whether those bytes are safe is
1294/// cleanup's and recovery's job (`c3`), and quietly reusing them would hand one
1295/// repository's retained state to the next attempt without anybody choosing to.
1296///
1297/// The inspection is one level deep and uses `symlink_metadata`, so nothing is
1298/// followed while it is being judged.
1299fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
1300    let unreadable =
1301        |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
1302    let entries = fs::read_dir(slot).map_err(unreadable)?;
1303    let mut refused: Vec<String> = Vec::new();
1304    for entry in entries {
1305        let entry = entry.map_err(unreadable)?;
1306        let name = entry.file_name();
1307        let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
1308            slot_refusal(
1309                slot,
1310                format!("entry {name:?} could not be inspected: {source}"),
1311            )
1312        })?;
1313        // The same predicate cleanup retains by, so a `_work` this accepts is
1314        // one [`scrub_slot_entries`] will keep rather than refuse: a link, a
1315        // junction or any other reparse point is not a job workspace to either
1316        // of them.
1317        if is_retainable_work_folder(&name, &metadata) {
1318            continue;
1319        }
1320        refused.push(name.to_string_lossy().into_owned());
1321    }
1322    if refused.is_empty() {
1323        return Ok(());
1324    }
1325    refused.sort();
1326    Err(slot_refusal(
1327        slot,
1328        format!(
1329            "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
1330             one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
1331             entries listed, or let cleanup and recovery resolve them",
1332            if refused.len() == 1 {
1333                "an entry"
1334            } else {
1335                "entries"
1336            },
1337            refused.join(", ")
1338        ),
1339    ))
1340}
1341
1342fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
1343    LifecycleError::Failed(FailureReason::Other(format!(
1344        "the persistent slot {} {detail}",
1345        slot.display()
1346    )))
1347}
1348
1349/// Whether a directory entry names the retained job workspace.
1350///
1351/// The comparison folds case on Windows because the filesystem does: there
1352/// `_Work` and `_work` are one directory, so a case-sensitive test would let
1353/// [`scrub_slot_entries`] delete the very directory it exists to keep, let
1354/// [`accept_reusable_slot`] refuse a slot that holds nothing but a valid job
1355/// workspace, and let a package's top-level `_Work` merge itself into the
1356/// previous attempt's `_work`. Elsewhere the two names really are two
1357/// directories and only the exact one is the job workspace.
1358fn is_work_folder(name: &OsStr) -> bool {
1359    if cfg!(windows) {
1360        name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
1361    } else {
1362        name == OsStr::new(DEFAULT_WORK_FOLDER)
1363    }
1364}
1365
1366/// Whether the operating system would follow this entry somewhere else.
1367///
1368/// `FileType::is_symlink` is the whole answer on Unix. On Windows it is not:
1369/// the standard library reports only the symlink and mount-point reparse tags,
1370/// and the substitution this has to refuse is *any* reparse point standing
1371/// where a real directory should be. So the attribute bit is the test there,
1372/// and a tag the standard library has no name for fails closed with the two it
1373/// does.
1374fn is_link_like(metadata: &fs::Metadata) -> bool {
1375    if metadata.file_type().is_symlink() {
1376        return true;
1377    }
1378    #[cfg(windows)]
1379    {
1380        use std::os::windows::fs::MetadataExt;
1381
1382        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1383        metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
1384    }
1385    #[cfg(not(windows))]
1386    false
1387}
1388
1389/// Whether an entry is the retained job workspace and is safe to retain.
1390///
1391/// The two halves are one question. A `_work` that is a file, a symlink, a
1392/// junction or any other reparse point is not a job workspace, and it is also
1393/// the exact substitution a hostile workflow makes to send cleanup somewhere
1394/// else (`04-security-recovery.md`, "A workflow replaces `_work` with a
1395/// junction or symlink to escape cleanup").
1396fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
1397    is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
1398}
1399
1400/// Undo one package materialization, dispatching on the journalled workspace.
1401///
1402/// The ephemeral half is what this always did: the directory is the attempt's
1403/// alone, so it goes whole. The persistent half removes the copy and nothing
1404/// else, because the slot's `_work` predates this attempt and outlives it
1405/// (`02-target-architecture.md`, "Persistent repository").
1406fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
1407    match attempt.workspace() {
1408        AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
1409        AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
1410            .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
1411    }
1412}
1413
1414/// Remove a disposable runner tree without opening workflow-created special files.
1415///
1416/// On Unix, `remove_dir_all` 1.0 can open a FIFO while walking a directory and
1417/// wait forever for its peer. The .NET runner routinely leaves diagnostic FIFOs
1418/// in its private `tmp`, so use the standard library's fd-relative Unix remover,
1419/// which unlinks non-directories without opening them. Keep the external remover
1420/// on Windows for its existing read-only and long-path handling; when it is
1421/// refused, [`unlink_reparse_points`] takes every link out of its way and it
1422/// runs once more.
1423fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
1424    #[cfg(windows)]
1425    {
1426        match remove_dir_all::remove_dir_all(path) {
1427            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1428                unlink_reparse_points(path);
1429                remove_dir_all::remove_dir_all(path)
1430            }
1431            other => other,
1432        }
1433    }
1434    #[cfg(not(windows))]
1435    {
1436        fs::remove_dir_all(path)
1437    }
1438}
1439
1440/// Remove every symlink, junction and other reparse point under `dir` as a
1441/// link, without ever descending into one.
1442///
1443/// WinINet creates `…\INetCache\Content.IE5` as a junction whose own DACL denies
1444/// `Everyone` the right to list it, and a job that points `USERPROFILE` or
1445/// `LOCALAPPDATA` into its workspace gets one there. Both `remove_dir_all` 1.0
1446/// and `std::fs::remove_dir_all` then fail on the whole tree with
1447/// `ERROR_ACCESS_DENIED`, so the attempt could never be cleaned. Removing the
1448/// link itself is a different operation (`RemoveDirectoryW` on the reparse
1449/// point) that the deny entry does not cover, and once the links are gone the
1450/// tree is ordinary.
1451///
1452/// Only walked after a refusal, so an ordinary tree is traversed once. The
1453/// metadata comes from the directory listing itself, which on Windows
1454/// describes a reparse point without opening it.
1455///
1456/// Best effort: a failure here is not reported, because
1457/// [`remove_runtime_tree`]'s second `remove_dir_all` runs next and its result
1458/// is the authority on whether the tree went.
1459#[cfg(windows)]
1460fn unlink_reparse_points(dir: &Path) {
1461    let Ok(entries) = fs::read_dir(dir) else {
1462        return;
1463    };
1464    for entry in entries.flatten() {
1465        let path = entry.path();
1466        let Ok(metadata) = entry.metadata() else {
1467            continue;
1468        };
1469        if is_link_like(&metadata) {
1470            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
1471        } else if metadata.is_dir() {
1472            unlink_reparse_points(&path);
1473        }
1474    }
1475}
1476
1477// ---------------------------------------------------------------------------
1478// Persistent cleanup (`04-security-recovery.md`, "Safe path handling")
1479// ---------------------------------------------------------------------------
1480
1481/// Why a persistent slot could not be proven safe to scrub.
1482///
1483/// A closed set rather than a formatted string, for two reasons that point the
1484/// same way. [`LifecycleEvent::AttemptCleanFailed`] takes a `&'static str` for
1485/// exactly the reason [`crate::reconcile::failure_reason_kind`] documents —
1486/// free text is the one shape that can carry a credential past a field
1487/// allow-list. And the entries under a slot root are *workflow-controlled*: a
1488/// job that writes a file named after a secret would publish it through any
1489/// message that echoed a directory listing, which is why nothing here ever
1490/// renders an entry name that did not come from this module's own constants.
1491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1492enum SlotRefusal {
1493    /// The journalled runtime path is not `<root>/sN` for the journalled slot.
1494    NotTheJournalledSlot,
1495    /// A policy that still exists names a different root than the journal does.
1496    PolicyRootDisagrees,
1497    /// The slot is not strictly inside its root once components resolve.
1498    Containment,
1499    /// The slot itself is a file, a link, or could not be inspected.
1500    SlotNotADirectory,
1501    /// The slot's direct entries could not be listed.
1502    Enumeration,
1503    /// `_work` is a file, a symlink, a junction or another reparse point.
1504    WorkNotADirectory,
1505    /// An entry that had to go could not be removed.
1506    Deletion,
1507    /// Something other than the job workspace survived removal.
1508    Residue,
1509}
1510
1511impl SlotRefusal {
1512    /// The event field: a fixed vocabulary, never operator or workflow text.
1513    const fn class(self) -> &'static str {
1514        match self {
1515            Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1516            Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1517            Self::Containment => "slot_escapes_its_root",
1518            Self::SlotNotADirectory => "slot_is_not_a_directory",
1519            Self::Enumeration => "slot_could_not_be_enumerated",
1520            Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1521            Self::Deletion => "slot_entry_could_not_be_removed",
1522            Self::Residue => "slot_still_holds_runner_state",
1523        }
1524    }
1525
1526    /// What the operator has to do, in one sentence and with no path in it.
1527    const fn remediation(self) -> &'static str {
1528        match self {
1529            Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1530                "the attempt keeps its slot lease and nothing was removed; correct the \
1531                 repository's persistent workspace path, or remove the slot directory by hand \
1532                 once you have confirmed what is in it"
1533            }
1534            Self::SlotNotADirectory | Self::WorkNotADirectory => {
1535                "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1536                 slot or its `_work` with a link, so inspect it before deleting anything and \
1537                 treat the retained workspace as untrusted"
1538            }
1539            Self::Enumeration | Self::Deletion | Self::Residue => {
1540                "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1541                 release whatever is holding the files open, or remove the slot's contents by \
1542                 hand leaving only `_work`"
1543            }
1544        }
1545    }
1546}
1547
1548/// A refusal that leaves one persistent slot quarantined.
1549///
1550/// `detail` is redacted by construction: it may hold paths this product
1551/// configured, `std::io::ErrorKind` values, counts, and names drawn from
1552/// [`SENSITIVE_SLOT_ENTRIES`] — and nothing that came out of a directory
1553/// listing.
1554#[derive(Debug, Clone, PartialEq, Eq)]
1555struct SlotQuarantine {
1556    refusal: SlotRefusal,
1557    detail: String,
1558}
1559
1560impl SlotQuarantine {
1561    fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1562        Self {
1563            refusal,
1564            detail: detail.into(),
1565        }
1566    }
1567}
1568
1569impl fmt::Display for SlotQuarantine {
1570    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1571        write!(f, "{}; {}", self.detail, self.refusal.remediation())
1572    }
1573}
1574
1575/// Steps 1 to 3: the journalled root, the journalled slot, and containment.
1576///
1577/// Everything comes from the two immutable allocation facts — the exact runtime
1578/// path and the slot number. Not from the filesystem: scanning a root to decide
1579/// which directories are "mine" is what invariant 6 forbids, and it is also
1580/// impossible for an attempt whose policy has since been deleted. `configured`
1581/// is therefore a *cross-check* and not a source. A policy that survives has to
1582/// agree; a policy that does not survive removes a check rather than the
1583/// ability to clean the directory the journal already names.
1584///
1585/// The name test comes before the parent is used, so `<root>/s1` for a journal
1586/// row that says `s2` — and anything at all that is not one `sN` component —
1587/// is refused before a root is derived from it.
1588fn verify_journalled_slot(
1589    runtime: &Path,
1590    slot: NonZeroU16,
1591    configured: Option<&LocalAbsolutePath>,
1592) -> Result<(), SlotQuarantine> {
1593    let mislaid = || {
1594        SlotQuarantine::new(
1595            SlotRefusal::NotTheJournalledSlot,
1596            format!(
1597                "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1598                runtime.display()
1599            ),
1600        )
1601    };
1602    let local = |path: &Path| {
1603        path.to_str()
1604            .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1605            .ok_or_else(mislaid)
1606    };
1607
1608    let runtime_path = local(runtime)?;
1609    let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1610    // Containment's lexical half, by construction: `derive_child` accepts one
1611    // component, so the equality below can only hold when the journalled path
1612    // really is this root's `sN` and nothing else.
1613    // The directory name comes from the domain that allocation named it with,
1614    // never from a second `s{n}` spelled out here: a convention with two
1615    // spellings would let cleanup refuse every slot the allocator created.
1616    let name = AttemptWorkspace::persistent_slot(slot)
1617        .slot_directory_name()
1618        .expect("a persistent workspace names its slot directory");
1619    let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1620    if derived != runtime_path {
1621        return Err(mislaid());
1622    }
1623    if let Some(configured) = configured
1624        && configured != &root
1625    {
1626        return Err(SlotQuarantine::new(
1627            SlotRefusal::PolicyRootDisagrees,
1628            format!(
1629                "the journalled slot {} is not under the repository's configured persistent root \
1630                 {}",
1631                runtime.display(),
1632                configured.as_str()
1633            ),
1634        ));
1635    }
1636    // And containment's canonical half, which is what a junction planted inside
1637    // the root between allocation and cleanup has to get past.
1638    runner_root::verify_containment(&root, &derived).map_err(|source| {
1639        SlotQuarantine::new(
1640            SlotRefusal::Containment,
1641            format!("the journalled slot is not inside the root it was allocated from: {source}"),
1642        )
1643    })
1644}
1645
1646/// Whether the slot is there to be scrubbed at all, before its entries are.
1647///
1648/// `Ok(false)` — the directory is gone — is not a refusal. There is nothing to
1649/// remove and nothing to prove absent, which is the same tolerance the
1650/// disposable arm has always had for a runtime that vanished under it.
1651///
1652/// A slot that is a file, a link, a junction or any other reparse point *is* a
1653/// refusal, and it is checked here rather than inside the enumeration so that
1654/// the reason an operator reads names the shape rather than reporting that a
1655/// directory could not be listed.
1656fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1657    match fs::symlink_metadata(slot) {
1658        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1659        Err(source) => Err(SlotQuarantine::new(
1660            SlotRefusal::SlotNotADirectory,
1661            format!(
1662                "the slot {} could not be inspected: {:?}",
1663                slot.display(),
1664                source.kind()
1665            ),
1666        )),
1667        Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1668            SlotRefusal::SlotNotADirectory,
1669            format!(
1670                "the slot {} is a link or a file rather than a real directory",
1671                slot.display()
1672            ),
1673        )),
1674        Ok(_) => Ok(true),
1675    }
1676}
1677
1678/// Steps 4 to 6: list the slot's direct entries, keep one real `_work`, remove
1679/// every other one.
1680///
1681/// Every call is a literal filesystem API against a path built from the slot
1682/// root and one entry name — no glob, no shell string, no repository-controlled
1683/// fragment. Nothing is followed: `symlink_metadata` says what each entry *is*,
1684/// and a link-shaped entry is unlinked rather than descended into, so a junction
1685/// planted where `bin` used to be cannot take the deletion outside the slot.
1686///
1687/// A `_work` that is not a real directory is the one entry this refuses to act
1688/// on at all. Removing it could destroy an operator's data if the link were
1689/// theirs; keeping it would hand the next attempt a workspace pointing
1690/// anywhere. `04-security-recovery.md` requires that case to quarantine the
1691/// slot, so the whole scrub stops there with nothing removed after it.
1692fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1693    let unreadable = |source: std::io::Error| {
1694        SlotQuarantine::new(
1695            SlotRefusal::Enumeration,
1696            format!(
1697                "the entries of {} could not be listed: {:?}",
1698                slot.display(),
1699                source.kind()
1700            ),
1701        )
1702    };
1703    for entry in fs::read_dir(slot).map_err(unreadable)? {
1704        let name = entry.map_err(unreadable)?.file_name();
1705        let path = slot.join(&name);
1706        // Nothing is assumed from an entry that vanished: `verify_slot_scrubbed`
1707        // asks the filesystem again afterwards and refuses if it is still there.
1708        let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1709            continue;
1710        };
1711        if is_work_folder(&name) {
1712            if is_retainable_work_folder(&name, &metadata) {
1713                continue;
1714            }
1715            return Err(SlotQuarantine::new(
1716                SlotRefusal::WorkNotADirectory,
1717                format!(
1718                    "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1719                     real directory",
1720                    slot.display()
1721                ),
1722            ));
1723        }
1724        remove_slot_entry(&path, &metadata).map_err(|source| {
1725            SlotQuarantine::new(
1726                SlotRefusal::Deletion,
1727                format!(
1728                    "an entry of {} could not be removed: {:?}",
1729                    slot.display(),
1730                    source.kind()
1731                ),
1732            )
1733        })?;
1734    }
1735    Ok(())
1736}
1737
1738/// What a listed entry *is*, or `None` when it is no longer there.
1739///
1740/// An entry named by a listing and gone by the time it is stat-ed is absent,
1741/// which is a fact both passes over a slot want rather than an enumeration that
1742/// failed. Nothing is followed: `symlink_metadata` reports a link as a link.
1743fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1744    match fs::symlink_metadata(path) {
1745        Ok(metadata) => Ok(Some(metadata)),
1746        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1747        Err(source) => Err(source),
1748    }
1749}
1750
1751/// Remove one slot entry without following it.
1752///
1753/// An entry that is already gone is removed: the post-condition this serves is
1754/// "not there", and racing with whatever removed it first is not a refusal.
1755fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1756    let removed = if is_link_like(metadata) {
1757        // A file symlink unlinks with `remove_file`; a directory symlink or a
1758        // Windows junction needs `remove_dir`. Neither follows the link.
1759        fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1760    } else if metadata.is_dir() {
1761        remove_runtime_tree(path)
1762    } else {
1763        fs::remove_file(path)
1764    };
1765    match removed {
1766        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1767        other => other,
1768    }
1769}
1770
1771/// Step 7: prove that nothing but the retained job workspace is left.
1772///
1773/// Two passes, deliberately independent, because the interesting failure is an
1774/// enumeration that under-reports. The first asks the directory what remains
1775/// and counts everything that is not a real `_work`. The second ignores the
1776/// listing entirely and stats the names an attempt is known to write — the
1777/// runner's binaries, the registration identity it stores beside them, the
1778/// process-identity sidecars and this agent's lifecycle marks — so a scrub that
1779/// silently skipped one is caught by a question that never consulted the
1780/// listing that skipped it.
1781///
1782/// Only the second pass names anything, and the encoded JIT handoff is reported
1783/// by its published prefix rather than by the UUID that follows it. A slot root
1784/// is workflow-writable, so a job that named a file after a secret would publish
1785/// it through any message that echoed the listing; the first pass therefore
1786/// reports a count.
1787fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1788    let unreadable = |source: std::io::Error| {
1789        SlotQuarantine::new(
1790            SlotRefusal::Enumeration,
1791            format!(
1792                "the entries of {} could not be listed to verify the scrub: {:?}",
1793                slot.display(),
1794                source.kind()
1795            ),
1796        )
1797    };
1798    let mut residue = 0_usize;
1799    let mut named: Vec<String> = Vec::new();
1800    for entry in fs::read_dir(slot).map_err(unreadable)? {
1801        let name = entry.map_err(unreadable)?.file_name();
1802        // Absent is what this pass is here to establish. The named half below
1803        // stats every sensitive entry again, so an entry that only *looks*
1804        // absent to this listing is still caught.
1805        let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1806            continue;
1807        };
1808        if is_retainable_work_folder(&name, &metadata) {
1809            continue;
1810        }
1811        residue = residue.saturating_add(1);
1812        if name
1813            .to_string_lossy()
1814            .starts_with(RestrictiveHandoff::NAME_PREFIX)
1815        {
1816            named.push("an encoded JIT handoff".to_owned());
1817        }
1818    }
1819    named.extend(
1820        SENSITIVE_SLOT_ENTRIES
1821            .iter()
1822            .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1823            .map(|entry| format!("`{entry}`")),
1824    );
1825    if residue == 0 && named.is_empty() {
1826        return Ok(());
1827    }
1828    named.sort_unstable();
1829    named.dedup();
1830    Err(SlotQuarantine::new(
1831        SlotRefusal::Residue,
1832        residue_detail(slot, residue, &named),
1833    ))
1834}
1835
1836/// Word a [`SlotRefusal::Residue`] refusal from the two facts that produced it.
1837///
1838/// Separate from [`verify_slot_scrubbed`] because the disagreement it has to
1839/// report — a listing that counted nothing and a filesystem that answered
1840/// otherwise — is a race no test can stage, and a message that contradicts
1841/// itself is exactly what an operator reads at three in the morning.
1842///
1843/// `named` is the sanitized half: entries this crate published the names of.
1844/// A name a workflow chose is only ever counted, never echoed.
1845fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1846    if residue == 0 {
1847        // The whole reason the second pass ignores the listing: the listing
1848        // reported a clean slot and the filesystem disagrees. Saying "0
1849        // entries survived" here would report the under-count as the fact.
1850        format!(
1851            "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1852                 cleanup",
1853            slot.display(),
1854            named.join(", ")
1855        )
1856    } else {
1857        format!(
1858            "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1859            if residue == 1 { "y" } else { "ies" },
1860            slot.display(),
1861            if named.is_empty() {
1862                String::new()
1863            } else {
1864                format!(", including {}", named.join(", "))
1865            }
1866        )
1867    }
1868}
1869
1870fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1871    match outcome {
1872        AttemptOutcome::Failed {
1873            reason: FailureReason::JitExpired,
1874        } => Some("jit_expired_replacement"),
1875        AttemptOutcome::Failed {
1876            reason: FailureReason::ProcessExitedUnexpectedly,
1877        } => Some("exit_before_acceptance_replacement"),
1878        _ => None,
1879    }
1880}
1881
1882/// Lay the verified runner package out *around* whatever the slot retains.
1883///
1884/// `02-target-architecture.md`: "The verified runner package is copied into the
1885/// slot for the attempt", beside a `_work` that survives every attempt. Two
1886/// properties make that safe, and both are structural rather than documented:
1887///
1888/// * the walk is of the **source** tree, so a retained `_work` in the
1889///   destination is never opened, never descended into, and cannot be followed
1890///   wherever it might point;
1891/// * a top-level source entry named `_work` is refused rather than copied, so a
1892///   package that ever grew one could not merge itself into, or replace, the
1893///   job workspace of the attempt before it. The refusal is top-level only,
1894///   because the retained directory is a direct child of the slot; a `_work`
1895///   nested inside the package's own tree is an ordinary name.
1896///
1897/// On Unix the copy is a copy-on-write clone where the filesystem offers one,
1898/// taken from a [`package_seed`] on the destination's own volume. A plain
1899/// byte copy of the ~9,000-file package across volumes took four minutes on a
1900/// host whose runner root shared a busy disk with a build, and a queued job
1901/// waits for every second of it.
1902/// The tree to copy `source` from, making a seed where one helps; a change to
1903/// a tree, so only under the exclusive hold of [`SharedPackages::trees`].
1904///
1905/// A seed that cannot be made is a slower launch, not a failed one. The seed
1906/// sits under the runner root, beside job runtimes, so the refusal is re-checked
1907/// on it; a seed that fails it is bypassed.
1908fn copy_origin(source: &Path, destination: &Path, seeds: &TrustedTrees) -> PathBuf {
1909    #[cfg(unix)]
1910    {
1911        package_seed(source, destination, seeds)
1912            .ok()
1913            .filter(|seed| refuse_work_folder(seed).is_ok())
1914            .unwrap_or_else(|| source.to_path_buf())
1915    }
1916    #[cfg(not(unix))]
1917    {
1918        let _ = (destination, seeds);
1919        source.to_path_buf()
1920    }
1921}
1922
1923/// The tree to copy `source` from if it needs no change: `None` is a seed to
1924/// rebuild first.
1925fn copy_origin_ready(source: &Path, destination: &Path, seeds: &TrustedTrees) -> Option<PathBuf> {
1926    #[cfg(unix)]
1927    {
1928        match seed_location(source, destination) {
1929            Ok(None) => Some(source.to_path_buf()),
1930            Ok(Some(seeds_root)) => held_seed(source, &seeds_root, seeds)
1931                .ok()
1932                .flatten()
1933                .filter(|seed| refuse_work_folder(seed).is_ok()),
1934            Err(_) => None,
1935        }
1936    }
1937    #[cfg(not(unix))]
1938    {
1939        let _ = (destination, seeds);
1940        Some(source.to_path_buf())
1941    }
1942}
1943
1944/// Copy the package at `source` into `destination`, from `origin`: itself or
1945/// its seed. Under the shared hold of [`SharedPackages::trees`].
1946fn copy_package_from(
1947    source: &Path,
1948    origin: &Path,
1949    destination: &Path,
1950    seeds: &TrustedTrees,
1951) -> std::io::Result<()> {
1952    refuse_work_folder(source)?;
1953
1954    #[cfg(unix)]
1955    {
1956        clone_tree(origin, destination)?;
1957        // A seed a job edited during the clone no longer holds; the caller
1958        // re-checks the cache entry itself.
1959        if origin != source && !seeds.holds(origin) {
1960            return Err(std::io::Error::other(
1961                "the package seed changed while cloned",
1962            ));
1963        }
1964        Ok(())
1965    }
1966    #[cfg(not(unix))]
1967    {
1968        let _ = (origin, seeds);
1969        copy_package_entries(source, destination, true)
1970    }
1971}
1972
1973fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1974    if source.join(DEFAULT_WORK_FOLDER).exists() {
1975        return Err(std::io::Error::new(
1976            std::io::ErrorKind::InvalidData,
1977            "a cached runner package contains a _work folder, which means it was used \
1978             to run a job before it was archived; the cache must only contain clean \
1979             extracts to prevent data leakage",
1980        ));
1981    }
1982    Ok(())
1983}
1984
1985/// Where runtime copies of the package are cloned from, beside the runtimes.
1986#[cfg(unix)]
1987const PACKAGE_SEED_DIR: &str = ".runner-package";
1988
1989/// A copy of the cache entry on the destination's volume, so that every
1990/// runtime after the first is a same-volume clone rather than a cross-volume
1991/// byte copy. Returns `source` itself when it already shares the volume.
1992///
1993/// The seed lives at `<runner root>/.runner-package/<version>` and is replaced
1994/// whenever its manifest stops matching the cache entry's, which covers both a
1995/// new version and a reinstalled one. Superseded versions and interrupted
1996/// stagings are removed on that same pass. A seed is only made under the
1997/// exclusive hold of [`SharedPackages::trees`], so no launch is cloning from it
1998/// while it is replaced.
1999///
2000/// The seed sits one `..` from every job's runtime and is owned by the account
2001/// jobs run as, so it is only reused while it is provably the tree this
2002/// process built: see [`TrustedTrees`].
2003///
2004/// Only on macOS, where the runner root is all but certainly APFS and a clone
2005/// is free. On a Linux filesystem without reflinks a seed would only move every
2006/// copy's reads onto the runner root's own, possibly busy, disk.
2007#[cfg(unix)]
2008fn package_seed(
2009    source: &Path,
2010    destination: &Path,
2011    seeds: &TrustedTrees,
2012) -> std::io::Result<PathBuf> {
2013    match seed_location(source, destination)? {
2014        Some(root) => refresh_package_seed(source, &root, seeds),
2015        None => Ok(source.to_path_buf()),
2016    }
2017}
2018
2019/// Where `destination`'s seeds live, if copies to it use one.
2020#[cfg(unix)]
2021fn seed_location(source: &Path, destination: &Path) -> std::io::Result<Option<PathBuf>> {
2022    use std::os::unix::fs::MetadataExt as _;
2023
2024    if !cfg!(target_os = "macos") {
2025        return Ok(None);
2026    }
2027    let root = destination
2028        .parent()
2029        .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
2030    if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
2031        return Ok(None);
2032    }
2033    Ok(Some(root.join(PACKAGE_SEED_DIR)))
2034}
2035
2036/// The package trees this process built or installed: what each must hold, and
2037/// what each looked like right after. They are the cache entry runtimes are copied
2038/// from and the seeds they are cloned from.
2039///
2040/// Both are owned by the account jobs run as, so a job can edit any file in them,
2041/// and nothing on disk can say what they should hold: the published digest covers
2042/// the archive, not the extracted tree, and a record kept on disk could be
2043/// rewritten by the same account. So a tree without a record here, as every tree
2044/// has after a restart, is rebuilt before it is used.
2045///
2046/// Two checks, for two costs. [`PackageContents`] reads every file against the
2047/// verified archive, on every runtime a launch is about to run: that is the one
2048/// that decides. On Unix the tree's [`tree_fingerprint`] also spots a changed
2049/// tree cheaply before any of it is copied, since every edit, replacement or
2050/// rename moves the change time (`ctime`) of the entry or of its directory, and
2051/// only the superuser can set a change time.
2052#[derive(Debug, Default)]
2053pub(crate) struct TrustedTrees {
2054    trees: Mutex<HashMap<PathBuf, TrustedTree>>,
2055}
2056
2057#[derive(Debug, Clone)]
2058struct TrustedTree {
2059    #[cfg(unix)]
2060    print: [u8; 32],
2061    contents: Option<Arc<PackageContents>>,
2062}
2063
2064impl TrustedTrees {
2065    fn trees(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, TrustedTree>> {
2066        self.trees
2067            .lock()
2068            .unwrap_or_else(std::sync::PoisonError::into_inner)
2069    }
2070
2071    /// Whether `root` is a tree recorded here and, where that can be told, still
2072    /// looks as it did.
2073    fn holds(&self, root: &Path) -> bool {
2074        let Some(tree) = self.trees().get(root).cloned() else {
2075            return false;
2076        };
2077        #[cfg(unix)]
2078        {
2079            tree_fingerprint(root).is_ok_and(|current| current == tree.print)
2080        }
2081        #[cfg(not(unix))]
2082        {
2083            let _ = tree;
2084            true
2085        }
2086    }
2087
2088    /// What `root` must hold.
2089    fn contents_of(&self, root: &Path) -> Option<Arc<PackageContents>> {
2090        self.trees()
2091            .get(root)
2092            .and_then(|tree| tree.contents.clone())
2093    }
2094
2095    /// Trust `root` as it is now; called right after this process made it.
2096    fn record(&self, root: &Path, contents: Option<Arc<PackageContents>>) -> std::io::Result<()> {
2097        let tree = TrustedTree {
2098            #[cfg(unix)]
2099            print: tree_fingerprint(root)?,
2100            contents,
2101        };
2102        self.trees().insert(root.to_path_buf(), tree);
2103        Ok(())
2104    }
2105
2106    /// Stops trusting `root` if `contents` is still what is recorded for it, and
2107    /// answers whether it did.
2108    fn forget_if_holding(&self, root: &Path, contents: &Arc<PackageContents>) -> bool {
2109        let mut trees = self.trees();
2110        let holding = trees
2111            .get(root)
2112            .and_then(|tree| tree.contents.as_ref())
2113            .is_some_and(|current| Arc::ptr_eq(current, contents));
2114        if holding {
2115            trees.remove(root);
2116        }
2117        holding
2118    }
2119
2120    /// Stop trusting `root`: its next use rebuilds it.
2121    fn forget(&self, root: &Path) {
2122        self.trees().remove(root);
2123    }
2124}
2125
2126/// A digest of every entry under `root`: its relative path, inode, size, mode,
2127/// owner and change time.
2128#[cfg(unix)]
2129fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
2130    use sha2::{Digest as _, Sha256};
2131    use std::os::unix::fs::MetadataExt as _;
2132
2133    fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
2134        let metadata = fs::symlink_metadata(path)?;
2135        let relative = path.strip_prefix(root).unwrap_or(path);
2136        hasher.update(relative.as_os_str().as_encoded_bytes());
2137        hasher.update([0]);
2138        for field in [
2139            metadata.ino(),
2140            metadata.size(),
2141            u64::from(metadata.mode()),
2142            u64::from(metadata.uid()),
2143            u64::from(metadata.gid()),
2144        ] {
2145            hasher.update(field.to_le_bytes());
2146        }
2147        hasher.update(metadata.ctime().to_le_bytes());
2148        hasher.update(metadata.ctime_nsec().to_le_bytes());
2149        if metadata.is_dir() {
2150            let mut children = fs::read_dir(path)?
2151                .map(|entry| entry.map(|entry| entry.path()))
2152                .collect::<std::io::Result<Vec<_>>>()?;
2153            children.sort();
2154            for child in children {
2155                walk(root, &child, hasher)?;
2156            }
2157        }
2158        Ok(())
2159    }
2160
2161    let mut hasher = Sha256::new();
2162    walk(root, root, &mut hasher)?;
2163    Ok(hasher.finalize().into())
2164}
2165
2166#[cfg(unix)]
2167fn seed_path(source: &Path, seeds: &Path) -> std::io::Result<PathBuf> {
2168    let version = source
2169        .file_name()
2170        .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
2171    Ok(seeds.join(version))
2172}
2173
2174/// The seed of `source` under `seeds`, if its manifest matches the cache
2175/// entry's and its fingerprint the one this process recorded.
2176#[cfg(unix)]
2177fn held_seed(
2178    source: &Path,
2179    seeds: &Path,
2180    trusted: &TrustedTrees,
2181) -> std::io::Result<Option<PathBuf>> {
2182    use crate::package::MANIFEST_FILE;
2183
2184    let seed = seed_path(source, seeds)?;
2185    let manifest = fs::read(source.join(MANIFEST_FILE))?;
2186    let held = fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest)
2187        && trusted.holds(&seed);
2188    Ok(held.then_some(seed))
2189}
2190
2191/// The seed of `source` under `seeds`, rebuilt unless [`held_seed`] finds it.
2192#[cfg(unix)]
2193fn refresh_package_seed(
2194    source: &Path,
2195    seeds: &Path,
2196    trusted: &TrustedTrees,
2197) -> std::io::Result<PathBuf> {
2198    if let Some(seed) = held_seed(source, seeds, trusted)? {
2199        return Ok(seed);
2200    }
2201    let seed = seed_path(source, seeds)?;
2202
2203    // Superseded versions, a stale seed and interrupted stagings all go.
2204    match remove_runtime_tree(seeds) {
2205        Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
2206        _ => {}
2207    }
2208    fs::create_dir_all(seeds)?;
2209    let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
2210    fs::create_dir(&staging)?;
2211    // A half-made staging would hold the disk space the fallback copy needs.
2212    if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
2213        let _ = remove_runtime_tree(&staging);
2214        return Err(error);
2215    }
2216    // After the rename, which itself moves the seed directory's change time.
2217    trusted.record(&seed, None)?;
2218    Ok(seed)
2219}
2220
2221/// `cp -a`, asking for a copy-on-write clone where the platform's `cp` can.
2222///
2223/// macOS `cp -c` and GNU `--reflink=auto` both fall back to a byte copy across
2224/// volumes; a `cp` that does not know the flag at all gets a plain `cp -a`.
2225#[cfg(unix)]
2226fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
2227    #[cfg(target_os = "macos")]
2228    const CLONE: &[&str] = &["-c"];
2229    #[cfg(target_os = "linux")]
2230    const CLONE: &[&str] = &["--reflink=auto"];
2231    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2232    const CLONE: &[&str] = &[];
2233
2234    let from = format!("{}/.", source.display());
2235    let cp = |extra: &[&str]| {
2236        std::process::Command::new("cp")
2237            .arg("-a")
2238            .args(extra)
2239            .arg(&from)
2240            .arg(destination)
2241            .status()
2242    };
2243    if cp(CLONE)?.success() || cp(&[])?.success() {
2244        Ok(())
2245    } else {
2246        Err(std::io::Error::other("cp failed"))
2247    }
2248}
2249
2250#[cfg(not(unix))]
2251fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
2252    fs::create_dir_all(destination)?;
2253    for entry in fs::read_dir(source)? {
2254        let entry = entry?;
2255        if top_level && is_work_folder(&entry.file_name()) {
2256            return Err(std::io::Error::new(
2257                std::io::ErrorKind::InvalidData,
2258                format!(
2259                    "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
2260                     it would overwrite the job workspace a persistent slot retains"
2261                ),
2262            ));
2263        }
2264        let target = destination.join(entry.file_name());
2265        if entry.file_type()?.is_dir() {
2266            copy_package_entries(&entry.path(), &target, false)?;
2267        } else {
2268            fs::copy(entry.path(), target)?;
2269        }
2270    }
2271    Ok(())
2272}
2273
2274/// Process operations are attempt-addressed so a recovered process and a child
2275/// started in this invocation are supervised through one port.
2276#[derive(Debug, Clone, PartialEq, Eq)]
2277pub struct ProcessStartFailure {
2278    pub reason: FailureReason,
2279    /// False once a child existed: the one-shot JIT value may have been
2280    /// consumed, so retrying it could start a duplicate.
2281    pub retryable: bool,
2282    /// Set only when cleanup could not prove the spawned process dead.  The
2283    /// caller must journal `starting` and retain capacity/supervision.
2284    pub live_pid: Option<u32>,
2285}
2286
2287impl ProcessStartFailure {
2288    pub(crate) fn before_spawn(reason: FailureReason) -> Self {
2289        Self {
2290            reason,
2291            retryable: true,
2292            live_pid: None,
2293        }
2294    }
2295
2296    fn after_spawn_stopped() -> Self {
2297        Self {
2298            reason: FailureReason::ProcessStartFailed,
2299            retryable: false,
2300            live_pid: None,
2301        }
2302    }
2303
2304    fn after_spawn_live(pid: u32) -> Self {
2305        Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
2306    }
2307
2308    fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
2309        Self {
2310            reason,
2311            retryable: false,
2312            live_pid: Some(pid),
2313        }
2314    }
2315}
2316
2317/// A credential-free capability report. An isolated policy never treats a
2318/// native-ready result as permission to launch.
2319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2320pub enum ProviderCapability {
2321    Ready,
2322    Unsupported,
2323    NotInstalled,
2324    PermissionDenied,
2325    ImageUnavailableOrIncompatible,
2326    DiskQuotaUnavailable,
2327    Degraded,
2328}
2329
2330impl ProviderCapability {
2331    #[must_use]
2332    pub const fn display_name(self) -> &'static str {
2333        match self {
2334            Self::Ready => "ready",
2335            Self::Unsupported => "unsupported",
2336            Self::NotInstalled => "not installed",
2337            Self::PermissionDenied => "permission denied",
2338            Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
2339            Self::DiskQuotaUnavailable => "disk quota unavailable",
2340            Self::Degraded => "degraded",
2341        }
2342    }
2343
2344    pub(crate) fn refusal(self) -> FailureReason {
2345        FailureReason::IsolationProvider(match self {
2346            Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
2347            Self::Unsupported => IsolationProviderFailure::Unsupported,
2348            Self::NotInstalled => IsolationProviderFailure::NotInstalled,
2349            Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
2350            Self::ImageUnavailableOrIncompatible => {
2351                IsolationProviderFailure::ImageUnavailableOrIncompatible
2352            }
2353            Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
2354        })
2355    }
2356}
2357
2358/// Only the closed isolation category may cross from an adapter into the
2359/// attempt journal or operator error. Arbitrary adapter text could echo JIT.
2360fn safe_isolation_reason(
2361    reason: FailureReason,
2362    fallback: IsolationProviderFailure,
2363) -> FailureReason {
2364    match reason {
2365        FailureReason::IsolationProvider(_) => reason,
2366        _ => FailureReason::IsolationProvider(fallback),
2367    }
2368}
2369
2370#[derive(Debug, Clone, PartialEq, Eq)]
2371pub struct PreparedEnvironment {
2372    attempt: AttemptId,
2373    identity: Option<EnvironmentIdentity>,
2374    /// The target whose dependency-cache namespace a native runner uses:
2375    /// `prepare` sees the policy and `start` does not.
2376    cache_target: Option<runner_manager_domain::model::ScaleTarget>,
2377}
2378
2379impl PreparedEnvironment {
2380    /// An adapter must return the identity independently recorded on its resource.
2381    #[must_use]
2382    pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
2383        Self {
2384            attempt,
2385            identity: Some(identity),
2386            cache_target: None,
2387        }
2388    }
2389
2390    #[must_use]
2391    pub fn identity(&self) -> Option<&EnvironmentIdentity> {
2392        self.identity.as_ref()
2393    }
2394}
2395
2396#[derive(Debug, Clone, PartialEq, Eq)]
2397pub struct ResolvedEnvironment {
2398    pub provider_kind: Backend,
2399    pub image: ImageReference,
2400}
2401
2402/// Closed, non-secret provider diagnostics; adapters cannot attach arbitrary output.
2403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2404pub enum ProviderDiagnostic {
2405    CapabilityUnavailable,
2406    ImageRejected,
2407    PrepareFailed,
2408    StartFailed,
2409    OwnershipMismatch,
2410    CleanupDeferred,
2411}
2412
2413#[derive(Debug, Clone, PartialEq, Eq)]
2414pub enum EnvironmentIdentity {
2415    NativeProcess(u32),
2416    Isolated {
2417        host: HostId,
2418        attempt: AttemptId,
2419        provider_kind: Backend,
2420        environment_id: String,
2421        resolved_image: ImageReference,
2422        generation: String,
2423    },
2424}
2425
2426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2427pub enum EnvironmentState {
2428    Starting,
2429    Running,
2430    Exited,
2431    Missing,
2432}
2433
2434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2435pub enum ProviderStop {
2436    Stopped,
2437    StillRunning,
2438}
2439
2440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2441pub enum ProviderDestroy {
2442    Destroyed,
2443    Deferred(&'static str),
2444}
2445
2446fn identity_matches_intent(
2447    host: HostId,
2448    attempt: &RunnerAttempt,
2449    identity: &EnvironmentIdentity,
2450) -> bool {
2451    identity_same_allocation(host, attempt, identity)
2452        && match (attempt.execution(), identity) {
2453            (
2454                AttemptExecution::Isolated { environment_id, .. },
2455                EnvironmentIdentity::Isolated {
2456                    environment_id: id, ..
2457                },
2458            ) => environment_id
2459                .as_ref()
2460                .is_none_or(|journal_id| journal_id == id),
2461            _ => false,
2462        }
2463}
2464
2465fn identity_same_allocation(
2466    host: HostId,
2467    attempt: &RunnerAttempt,
2468    identity: &EnvironmentIdentity,
2469) -> bool {
2470    match (attempt.execution(), identity) {
2471        (
2472            AttemptExecution::Isolated {
2473                provider_kind,
2474                environment_id: _,
2475                resolved_image,
2476                generation,
2477            },
2478            EnvironmentIdentity::Isolated {
2479                host: owner,
2480                attempt: owner_attempt,
2481                provider_kind: kind,
2482                environment_id: _,
2483                resolved_image: image,
2484                generation: resource_generation,
2485            },
2486        ) => {
2487            *owner == host
2488                && *owner_attempt == attempt.id
2489                && provider_kind == kind
2490                && resolved_image == image
2491                && generation == resource_generation
2492        }
2493        _ => false,
2494    }
2495}
2496
2497/// The JIT value can be passed into a provider start exactly once. It must never
2498/// become a provider identity, label, argument, persisted configuration or log field.
2499pub struct OneTimeJitHandoff<'a> {
2500    config: &'a EncodedJitConfig,
2501}
2502
2503impl fmt::Debug for OneTimeJitHandoff<'_> {
2504    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2505        f.write_str("OneTimeJitHandoff([redacted])")
2506    }
2507}
2508
2509impl<'a> OneTimeJitHandoff<'a> {
2510    pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
2511        Self { config }
2512    }
2513
2514    /// Consuming the handoff prevents the same token being delivered twice by one call.
2515    pub fn consume(self) -> &'a EncodedJitConfig {
2516        self.config
2517    }
2518}
2519
2520/// Provider lifecycle and native process supervision. The default lifecycle
2521/// methods adapt the existing native spawn/recovery implementation, and refuse
2522/// every isolated allocation. Future platform adapters must implement their
2523/// own prepare/start/inspect/stop/destroy paths rather than fall back here.
2524pub trait ExecutionProvider: fmt::Debug + Send + Sync {
2525    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2526        if policy.execution_policy().is_native() {
2527            ProviderCapability::Ready
2528        } else {
2529            ProviderCapability::Unsupported
2530        }
2531    }
2532
2533    /// Resolve Auto and image identity before allocation or GitHub JIT. Native has no image.
2534    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2535        if policy.execution_policy().is_native() {
2536            Ok(None)
2537        } else {
2538            Err(FailureReason::Other(
2539                "execution provider unavailable".into(),
2540            ))
2541        }
2542    }
2543
2544    fn prepare(
2545        &self,
2546        attempt: &RunnerAttempt,
2547        policy: &ScalePolicy,
2548    ) -> Result<PreparedEnvironment, FailureReason> {
2549        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2550            return Err(FailureReason::Other(
2551                "execution provider unavailable".into(),
2552            ));
2553        }
2554        Ok(PreparedEnvironment {
2555            attempt: attempt.id,
2556            identity: None,
2557            cache_target: None,
2558        })
2559    }
2560
2561    fn start(
2562        &self,
2563        prepared: PreparedEnvironment,
2564        attempt: &RunnerAttempt,
2565        handoff: OneTimeJitHandoff<'_>,
2566    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2567        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2568            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2569                "execution provider identity mismatch".into(),
2570            )));
2571        }
2572        self.spawn(attempt, handoff.consume())
2573            .map(EnvironmentIdentity::NativeProcess)
2574    }
2575
2576    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2577        if !attempt.execution().is_native() {
2578            return Err(FailureReason::Other(
2579                "execution provider unavailable".into(),
2580            ));
2581        }
2582        self.is_alive(attempt).map(|alive| {
2583            if alive {
2584                EnvironmentState::Running
2585            } else {
2586                EnvironmentState::Missing
2587            }
2588        })
2589    }
2590
2591    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2592        if !attempt.execution().is_native() {
2593            return Err(FailureReason::Other(
2594                "execution provider unavailable".into(),
2595            ));
2596        }
2597        self.terminate(attempt).map(|()| ProviderStop::Stopped)
2598    }
2599
2600    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2601        if !attempt.execution().is_native() {
2602            return Err(FailureReason::Other(
2603                "execution provider unavailable".into(),
2604            ));
2605        }
2606        // Native termination is already settled by the existing recovery
2607        // decision before workspace cleanup. There is no separate sandbox
2608        // resource to destroy, and repeating an observation here changes the
2609        // established native cleanup behaviour.
2610        Ok(ProviderDestroy::Destroyed)
2611    }
2612
2613    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2614        self.inspect(attempt)
2615    }
2616
2617    fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2618        Vec::new() // Native processes have no independent provider resources.
2619    }
2620
2621    fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2622        Vec::new() // No job or JIT output enters provider metadata.
2623    }
2624
2625    /// Independent resource metadata must match the journal before destructive calls.
2626    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2627        Ok(attempt.execution().is_native())
2628    }
2629
2630    fn spawn(
2631        &self,
2632        attempt: &RunnerAttempt,
2633        config: &EncodedJitConfig,
2634    ) -> Result<u32, ProcessStartFailure>;
2635    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2636    /// Durable identity observed for a process that spawned before the
2637    /// `starting` journal write survived.
2638    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2639    /// True only for a child this invocation owned and reaped with a successful
2640    /// exit status.  A recovered process that is merely gone answers false.
2641    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2642    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2643    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2644    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2645}
2646
2647/// Compatibility name for existing native process tests and callers.
2648pub use ExecutionProvider as ProcessSupervisor;
2649
2650/// Native process supervision.  The start token is stored beside the runtime;
2651/// recovery never trusts a recycled PID merely because SQLite contains it.
2652#[derive(Debug, Default)]
2653pub struct NativeProcesses {
2654    children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2655    successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2656    /// The host's `runner.env`, read at every launch so that `host env set`
2657    /// reaches the next runner without a daemon restart. `None` applies none.
2658    runner_env_file: Option<PathBuf>,
2659    /// Persistent dependency caches. `None` gives runners none.
2660    dependency_caches: Option<DependencyCaches>,
2661    docker_cleanup: Option<crate::docker_cleanup::DockerCleanup>,
2662    /// Who runners run as. See [`runner_manager_platform::runner_account`].
2663    runner: RunnerIdentity,
2664    /// The pool account each attempt with a runner runs as.
2665    assignments: Mutex<BTreeMap<AttemptId, RunnerAccount>>,
2666    #[cfg(test)]
2667    post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2668    #[cfg(test)]
2669    post_spawn_reaps: std::sync::atomic::AtomicUsize,
2670    #[cfg(test)]
2671    post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2672    #[cfg(test)]
2673    use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2674}
2675
2676/// Production provider set. Native and isolated allocations are routed by the
2677/// immutable execution kind in the policy/journal; an unavailable isolated
2678/// backend can never fall through to native process launch.
2679#[derive(Debug)]
2680pub struct PlatformExecutionProvider {
2681    native: NativeProcesses,
2682    oci: OciProcesses,
2683    windows_hyper_v: WindowsHyperVContainers,
2684}
2685
2686impl PlatformExecutionProvider {
2687    #[must_use]
2688    pub fn new(host_id: HostId) -> Self {
2689        Self {
2690            native: NativeProcesses::new(),
2691            oci: OciProcesses::new(host_id),
2692            windows_hyper_v: WindowsHyperVContainers::new(host_id),
2693        }
2694    }
2695
2696    /// Applies the operator's isolation settings to the OCI provider.
2697    #[must_use]
2698    pub fn with_isolation_settings(mut self, settings: crate::oci::IsolationSettings) -> Self {
2699        self.oci.apply(settings);
2700        self
2701    }
2702
2703    /// Applies the host's `runner.env` at `path` to every native runner.
2704    /// Isolated runners do not get it: their environment is the image's.
2705    #[must_use]
2706    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2707        self.native = self.native.with_runner_env_file(path);
2708        self
2709    }
2710
2711    /// Gives every native runner persistent dependency caches. Isolated
2712    /// runners do not get them: their providers mount no host directory.
2713    #[must_use]
2714    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2715        self.native = self.native.with_dependency_caches(caches);
2716        self
2717    }
2718
2719    #[must_use]
2720    pub fn with_docker_cleanup(mut self, cleanup: crate::docker_cleanup::DockerCleanup) -> Self {
2721        self.native.docker_cleanup = Some(cleanup);
2722        self
2723    }
2724
2725    /// Starts every native runner as an account of `pool` of its own, which
2726    /// owns its attempt directory; the pool shares the caches. `None` keeps
2727    /// runners in the daemon's account.
2728    #[must_use]
2729    pub fn with_runner_pool(mut self, pool: Result<Option<RunnerPool>, String>) -> Self {
2730        self.native.runner = match pool {
2731            Ok(None) => RunnerIdentity::Daemon,
2732            Ok(Some(pool)) => {
2733                self.native.dependency_caches = self
2734                    .native
2735                    .dependency_caches
2736                    .take()
2737                    .map(DependencyCaches::without_git);
2738                RunnerIdentity::Pool(pool)
2739            }
2740            Err(why) => {
2741                tracing::error!(reason = "runner_account_missing", "{why}");
2742                RunnerIdentity::Missing(why)
2743            }
2744        };
2745        self
2746    }
2747
2748    fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2749        match backend {
2750            Backend::Auto => {
2751                #[cfg(target_os = "windows")]
2752                {
2753                    Some(&self.windows_hyper_v)
2754                }
2755                #[cfg(not(target_os = "windows"))]
2756                {
2757                    Some(&self.oci)
2758                }
2759            }
2760            Backend::Oci => Some(&self.oci),
2761            Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2762            Backend::VirtualMachine => None,
2763        }
2764    }
2765
2766    fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2767        match attempt.execution() {
2768            AttemptExecution::Native { .. } => Some(&self.native),
2769            AttemptExecution::Isolated { provider_kind, .. } => {
2770                self.isolated_provider(*provider_kind)
2771            }
2772        }
2773    }
2774
2775    fn required_provider_for_attempt(
2776        &self,
2777        attempt: &RunnerAttempt,
2778    ) -> Result<&dyn ExecutionProvider, FailureReason> {
2779        self.provider_for_attempt(attempt)
2780            .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2781    }
2782}
2783
2784impl ExecutionProvider for PlatformExecutionProvider {
2785    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2786        match policy.execution_policy() {
2787            runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2788            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2789                .isolated_provider(*backend)
2790                .map_or(ProviderCapability::Unsupported, |provider| {
2791                    provider.probe(policy)
2792                }),
2793        }
2794    }
2795
2796    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2797        match policy.execution_policy() {
2798            runner_manager_domain::execution::ExecutionPolicy::Native => {
2799                self.native.resolve(policy)
2800            }
2801            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2802                .isolated_provider(*backend)
2803                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2804                .resolve(policy),
2805        }
2806    }
2807
2808    fn prepare(
2809        &self,
2810        attempt: &RunnerAttempt,
2811        policy: &ScalePolicy,
2812    ) -> Result<PreparedEnvironment, FailureReason> {
2813        match policy.execution_policy() {
2814            runner_manager_domain::execution::ExecutionPolicy::Native => {
2815                self.native.prepare(attempt, policy)
2816            }
2817            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2818                .isolated_provider(*backend)
2819                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2820                .prepare(attempt, policy),
2821        }
2822    }
2823
2824    fn start(
2825        &self,
2826        prepared: PreparedEnvironment,
2827        attempt: &RunnerAttempt,
2828        handoff: OneTimeJitHandoff<'_>,
2829    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2830        self.provider_for_attempt(attempt)
2831            .ok_or_else(|| {
2832                ProcessStartFailure::before_spawn(FailureReason::Other(
2833                    "execution provider unavailable".into(),
2834                ))
2835            })?
2836            .start(prepared, attempt, handoff)
2837    }
2838
2839    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2840        self.required_provider_for_attempt(attempt)?
2841            .inspect(attempt)
2842    }
2843
2844    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2845        self.required_provider_for_attempt(attempt)?.stop(attempt)
2846    }
2847
2848    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2849        self.required_provider_for_attempt(attempt)?
2850            .destroy(attempt)
2851    }
2852
2853    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2854        self.required_provider_for_attempt(attempt)?
2855            .recover(attempt)
2856    }
2857
2858    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2859        let mut owned = self.oci.enumerate_owned(host_id);
2860        owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2861        owned
2862    }
2863
2864    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2865        self.provider_for_attempt(attempt)
2866            .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2867    }
2868
2869    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2870        self.required_provider_for_attempt(attempt)?.owns(attempt)
2871    }
2872
2873    fn spawn(
2874        &self,
2875        attempt: &RunnerAttempt,
2876        config: &EncodedJitConfig,
2877    ) -> Result<u32, ProcessStartFailure> {
2878        if !attempt.execution().is_native() {
2879            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2880                "native process launch refused for an isolated allocation".into(),
2881            )));
2882        }
2883        self.native.spawn(attempt, config)
2884    }
2885
2886    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2887        self.required_provider_for_attempt(attempt)?
2888            .is_alive(attempt)
2889    }
2890
2891    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2892        self.required_provider_for_attempt(attempt)?
2893            .recovered_pid(attempt)
2894    }
2895
2896    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2897        self.provider_for_attempt(attempt)
2898            .is_some_and(|provider| provider.completed_successfully(attempt))
2899    }
2900
2901    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2902        self.required_provider_for_attempt(attempt)?
2903            .record_terminate_intent(attempt)
2904    }
2905
2906    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2907        self.provider_for_attempt(attempt)
2908            .is_some_and(|provider| provider.has_terminate_intent(attempt))
2909    }
2910
2911    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2912        self.required_provider_for_attempt(attempt)?
2913            .terminate(attempt)
2914    }
2915}
2916
2917#[cfg(test)]
2918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2919enum PostSpawnBoundary {
2920    HandoffDelete,
2921    IdentitySerialize,
2922    IdentityWrite,
2923    ChildMapInsert,
2924}
2925
2926impl NativeProcesses {
2927    #[must_use]
2928    pub fn new() -> Self {
2929        Self::default()
2930    }
2931
2932    /// Applies the host's `runner.env` at `path` to every runner launched.
2933    #[must_use]
2934    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2935        self.runner_env_file = Some(path);
2936        self
2937    }
2938
2939    /// Gives every runner launched persistent dependency caches, without the
2940    /// Git checkout cache when runners run as another account.
2941    #[must_use]
2942    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2943        self.dependency_caches = Some(match self.runner {
2944            RunnerIdentity::Pool(_) => caches.without_git(),
2945            RunnerIdentity::Daemon | RunnerIdentity::Missing(_) => caches,
2946        });
2947        self
2948    }
2949
2950    /// The cache variables for `attempt`, when `prepare` named its target.
2951    fn cache_env(
2952        &self,
2953        attempt: &RunnerAttempt,
2954        target: Option<&runner_manager_domain::model::ScaleTarget>,
2955        host_env: &RunnerEnv,
2956    ) -> Vec<(&'static str, std::ffi::OsString)> {
2957        match (&self.dependency_caches, target) {
2958            (Some(caches), Some(target)) => {
2959                caches.for_launch(target, attempt.runtime_path(), host_env)
2960            }
2961            _ => Vec::new(),
2962        }
2963    }
2964
2965    /// Starts `Runner.Listener` for `attempt`, with the dependency caches of
2966    /// `cache_target` when there is one.
2967    fn spawn_listener(
2968        &self,
2969        attempt: &RunnerAttempt,
2970        config: &EncodedJitConfig,
2971        cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2972    ) -> Result<u32, ProcessStartFailure> {
2973        let pool = self
2974            .runner
2975            .pool()
2976            .map_err(ProcessStartFailure::before_spawn)?;
2977        let account = self
2978            .account_for(attempt)
2979            .map_err(ProcessStartFailure::before_spawn)?;
2980        let handoff = RestrictiveHandoff::create(
2981            attempt.runtime_path(),
2982            SecretString::from(config.expose().to_owned()),
2983        )
2984        .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2985        #[cfg(windows)]
2986        let program = attempt
2987            .runtime_path()
2988            .join("bin")
2989            .join("Runner.Listener.exe");
2990        #[cfg(not(windows))]
2991        let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2992        // Checked after the handoff exists on purpose: the error path below is
2993        // a real post-handoff launch failure, and unwinding must delete it.
2994        if !program.is_file() {
2995            return Err(ProcessStartFailure::before_spawn(
2996                FailureReason::ProcessStartFailed,
2997            ));
2998        }
2999        let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
3000        let caches = self.cache_env(attempt, cache_target, &host_env);
3001        let cache_names: Vec<&'static str> = caches.iter().map(|(name, _)| *name).collect();
3002        #[cfg(test)]
3003        let spec = if self
3004            .use_long_lived_test_listener
3005            .load(std::sync::atomic::Ordering::SeqCst)
3006        {
3007            SpawnSpec::new(program)
3008                .args([
3009                    "--ignored",
3010                    "--exact",
3011                    "lifecycle::tests::long_lived_native_listener_helper",
3012                    "--nocapture",
3013                ])
3014                .env(
3015                    "RUNNER_MANAGER_TEST_LISTENER_READY",
3016                    attempt.runtime_path().join(TEST_LISTENER_READY),
3017                )
3018                .working_dir(attempt.runtime_path())
3019        } else {
3020            runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
3021        };
3022        #[cfg(not(test))]
3023        let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
3024        let spec = match account.zip(pool) {
3025            Some((account, pool)) => {
3026                hand_over(account, pool, attempt.runtime_path(), &spec, &cache_names).map_err(
3027                    |error| {
3028                        tracing::warn!(attempt_id = %attempt.id, event = "runner_hand_over_failed", %error, "the attempt could not be handed to the runner account");
3029                        ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed)
3030                    },
3031                )?;
3032                spec.run_as(account)
3033            }
3034            None => spec,
3035        };
3036        let (spec, docker_proxy) = if let Some(cleanup) = &self.docker_cleanup {
3037            let repository = cache_target.map(ToString::to_string).unwrap_or_default();
3038            let listen =
3039                account.map(|account| (attempt.runtime_path().join(DOCKER_SOCKET_DIR), account));
3040            // What the job may bind-mount into its containers: its attempt and
3041            // the cache directories it was given.
3042            let own_directories: Vec<PathBuf> =
3043                std::iter::once(attempt.runtime_path().to_path_buf())
3044                    .chain(cache_names.iter().filter_map(|name| {
3045                        spec.configured_env(name)
3046                            .map(PathBuf::from)
3047                            .filter(|path| path.is_absolute())
3048                    }))
3049                    .collect();
3050            match cleanup.launch_environment(
3051                attempt.id,
3052                &repository,
3053                &host_env,
3054                listen,
3055                &own_directories,
3056            ) {
3057                Ok(Some(endpoint)) => (
3058                    spec.env("DOCKER_HOST", endpoint),
3059                    Some(cleanup.root.join(attempt.id.to_string())),
3060                ),
3061                Ok(None) => (spec, None),
3062                Err(error) => {
3063                    tracing::warn!(attempt_id = %attempt.id, event = "docker_accounting_unavailable", reason = ?error.kind(), "native Docker accounting could not start");
3064                    if crate::docker_cleanup::Config::load(&cleanup.config).map_or(true, |config| {
3065                        config.mode == crate::docker_cleanup::Mode::Apply
3066                    }) {
3067                        return Err(ProcessStartFailure::before_spawn(
3068                            FailureReason::ProcessStartFailed,
3069                        ));
3070                    }
3071                    (spec, None)
3072                }
3073            }
3074        } else {
3075            (spec, None)
3076        };
3077        let child = spec
3078            .spawn_runner_with_handoff(&handoff)
3079            .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
3080        // The payload is gone before any state saying "starting" is persisted.
3081        #[cfg(test)]
3082        if self.faults_at(PostSpawnBoundary::HandoffDelete) {
3083            drop(handoff);
3084            return Err(self.abort_spawned_child(child, attempt, false));
3085        }
3086        if handoff.delete().is_err() {
3087            return Err(self.abort_spawned_child(child, attempt, false));
3088        }
3089        #[cfg(test)]
3090        if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
3091            return Err(self.abort_spawned_child(child, attempt, false));
3092        }
3093        let identity = match serde_json::to_vec(child.identity()) {
3094            Ok(identity) => identity,
3095            Err(_) => {
3096                return Err(self.abort_spawned_child(child, attempt, false));
3097            }
3098        };
3099        if self.persist_identity(attempt, &identity).is_err() {
3100            return Err(self.abort_spawned_child(child, attempt, true));
3101        }
3102        if let Some(directory) = docker_proxy
3103            && crate::docker_cleanup::write_json(&directory.join("runner.json"), child.identity())
3104                .is_err()
3105        {
3106            return Err(self.abort_spawned_child(child, attempt, true));
3107        }
3108        let pid = child.pid();
3109        #[cfg(test)]
3110        if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
3111            return Err(self.abort_spawned_child(child, attempt, true));
3112        }
3113        let mut children = self
3114            .children
3115            .lock()
3116            .unwrap_or_else(std::sync::PoisonError::into_inner);
3117        children.insert(attempt.id, child);
3118        Ok(pid)
3119    }
3120
3121    /// The host's `runner.env`, read now.
3122    ///
3123    /// A file that cannot be read or parsed fails the launch rather than
3124    /// starting a runner without the environment the operator configured.
3125    /// [`ExecutionProvider::prepare`] asks first, before a JIT runner is
3126    /// registered, so a broken file costs no registration. The reason names
3127    /// the file and the line or variable, never a value.
3128    fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
3129        let Some(path) = &self.runner_env_file else {
3130            return Ok(RunnerEnv::default());
3131        };
3132        RunnerEnv::load(path).map_err(|error| {
3133            tracing::warn!(
3134                reason = "runner_env_file_invalid",
3135                "runner.env could not be applied, so no runner starts until it is fixed: {error}"
3136            );
3137            FailureReason::Other(format!(
3138                "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
3139            ))
3140        })
3141    }
3142
3143    #[cfg(test)]
3144    fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
3145        self.post_spawn_faults.lock().unwrap().push_back(boundary);
3146    }
3147
3148    #[cfg(test)]
3149    fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
3150        let mut faults = self.post_spawn_faults.lock().unwrap();
3151        if faults.front() == Some(&boundary) {
3152            faults.pop_front();
3153            true
3154        } else {
3155            false
3156        }
3157    }
3158
3159    #[cfg(test)]
3160    fn fail_post_spawn_stops(&self, count: usize) {
3161        self.post_spawn_stop_failures
3162            .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
3163    }
3164
3165    #[cfg(test)]
3166    fn fail_next_post_spawn_stop(&self) {
3167        self.fail_post_spawn_stops(1);
3168    }
3169
3170    #[cfg(test)]
3171    fn use_long_lived_test_listener(&self) {
3172        self.use_long_lived_test_listener
3173            .store(true, std::sync::atomic::Ordering::SeqCst);
3174    }
3175
3176    fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
3177        #[cfg(test)]
3178        if self
3179            .post_spawn_stop_failures
3180            .fetch_update(
3181                std::sync::atomic::Ordering::SeqCst,
3182                std::sync::atomic::Ordering::SeqCst,
3183                |left| if left > 0 { Some(left - 1) } else { None },
3184            )
3185            .is_ok()
3186        {
3187            return Err(FailureReason::Other("injected runner stop failure".into()));
3188        }
3189        child
3190            .stop(Duration::from_secs(1))
3191            .map(|_| ())
3192            .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
3193    }
3194
3195    fn abort_spawned_child(
3196        &self,
3197        mut child: ChildProcess,
3198        attempt: &RunnerAttempt,
3199        remove_identity: bool,
3200    ) -> ProcessStartFailure {
3201        let mut reaped = self.stop_spawned_child(&mut child).is_ok();
3202        if reaped {
3203            if remove_identity {
3204                Self::remove_identity_files(attempt);
3205            }
3206        } else {
3207            // A failed stop is not a failed attempt yet.  Persist enough truth
3208            // for crash recovery, and retain the owned child when possible.
3209            let identity_durable =
3210                serde_json::to_vec(child.identity())
3211                    .ok()
3212                    .is_some_and(|identity| {
3213                        self.persist_identity(attempt, &identity).is_ok()
3214                            || self.persist_fallback_identity(attempt, &identity).is_ok()
3215                    });
3216            if !identity_durable {
3217                // Returning a live PID as though recovery were complete would
3218                // make the next boot trust a recyclable PID. Reaping is bounded;
3219                // if it cannot finish, the durable `starting` journal entry is
3220                // deliberately unresolved on restart and blocks new launches.
3221                for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
3222                    if self.stop_spawned_child(&mut child).is_ok() {
3223                        reaped = true;
3224                        break;
3225                    }
3226                }
3227                if !reaped {
3228                    // The attempt journal will durably record `starting` and
3229                    // its PID. Recovery treats a missing full identity as
3230                    // unresolved and starts nothing, so bounded stop failure
3231                    // cannot turn into either a hang or a duplicate runner.
3232                    let pid = child.pid();
3233                    let marker = write_durable_file(
3234                        &Self::unresolved_process_path(attempt),
3235                        pid.to_string().as_bytes(),
3236                    );
3237                    self.children
3238                        .lock()
3239                        .unwrap_or_else(std::sync::PoisonError::into_inner)
3240                        .insert(attempt.id, child);
3241                    let reason = if marker.is_ok() {
3242                        FailureReason::Other(
3243                            "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
3244                                .into(),
3245                        )
3246                    } else {
3247                        FailureReason::Other(
3248                            "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
3249                                .into(),
3250                        )
3251                    };
3252                    return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
3253                }
3254            } else {
3255                let pid = child.pid();
3256                self.children
3257                    .lock()
3258                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3259                    .insert(attempt.id, child);
3260                return ProcessStartFailure::after_spawn_live(pid);
3261            }
3262        }
3263        #[cfg(test)]
3264        if reaped {
3265            self.post_spawn_reaps
3266                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3267        }
3268        #[cfg(not(test))]
3269        let _ = reaped;
3270        ProcessStartFailure::after_spawn_stopped()
3271    }
3272
3273    fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
3274        attempt.runtime_path().join(IDENTITY_FILE)
3275    }
3276
3277    fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
3278        attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
3279    }
3280
3281    fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
3282        attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
3283    }
3284
3285    fn remove_identity_files(attempt: &RunnerAttempt) {
3286        let _ = fs::remove_file(Self::identity_path(attempt));
3287        let _ = fs::remove_file(Self::fallback_identity_path(attempt));
3288        let _ = fs::remove_file(Self::unresolved_process_path(attempt));
3289    }
3290
3291    fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
3292        self.persist_identity_at(&Self::identity_path(attempt), bytes)
3293    }
3294
3295    fn persist_fallback_identity(
3296        &self,
3297        attempt: &RunnerAttempt,
3298        bytes: &[u8],
3299    ) -> std::io::Result<()> {
3300        self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
3301    }
3302
3303    fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
3304        #[cfg(test)]
3305        if self.faults_at(PostSpawnBoundary::IdentityWrite) {
3306            return Err(std::io::Error::other("injected identity write failure"));
3307        }
3308        write_durable_file(path, bytes)
3309    }
3310
3311    fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
3312        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
3313    }
3314
3315    /// The recorded runner process, `None` when none was recorded.
3316    ///
3317    /// A recorded process that is alive but not the runner account's -- a
3318    /// forged record, or a PID the kernel has since given to another account
3319    /// -- is reported as [`RecordedProcess::Foreign`], which every caller
3320    /// treats as a runner that is gone. It is not `None`: an attempt that has
3321    /// a PID and no record refuses recovery, and an exited runner whose PID was
3322    /// reused must not be held up that way.
3323    fn read_identity(&self, attempt: &RunnerAttempt) -> Result<RecordedProcess, FailureReason> {
3324        let identity = match Self::read_identity_at(&Self::identity_path(attempt))? {
3325            Some(identity) => Some(identity),
3326            None => Self::read_identity_at(&Self::fallback_identity_path(attempt))?,
3327        };
3328        Ok(match identity {
3329            None => RecordedProcess::None,
3330            Some(identity) if self.runs_as_runner(attempt, &identity) => {
3331                RecordedProcess::Runner(identity)
3332            }
3333            Some(_) => RecordedProcess::Foreign,
3334        })
3335    }
3336
3337    /// Whether the process `identity` names belongs to the runner account.
3338    ///
3339    /// The identity files live in the attempt directory, which the runner's
3340    /// account owns when it is not this process's, so a job could rewrite one
3341    /// to name any process on the host for this process to signal. A recorded
3342    /// process the account does not run is therefore not one this daemon
3343    /// started, and is treated as gone. A process that has exited passes, and
3344    /// the identity's own start-time check then reports it gone.
3345    fn runs_as_runner(&self, attempt: &RunnerAttempt, identity: &ProcessIdentity) -> bool {
3346        let RunnerIdentity::Pool(pool) = &self.runner else {
3347            return true;
3348        };
3349        // The attempt's own account, read from its directory, since every
3350        // runner on the host has one: a record naming another runner's process
3351        // is as foreign as one naming any other process.
3352        let Some(account) = owner_in_pool(pool, attempt.runtime_path()) else {
3353            return false;
3354        };
3355        #[cfg(unix)]
3356        {
3357            use std::os::unix::fs::MetadataExt as _;
3358            match fs::metadata(format!("/proc/{}", identity.pid())) {
3359                Ok(metadata) => metadata.uid() == account.uid,
3360                Err(error) => error.kind() == std::io::ErrorKind::NotFound,
3361            }
3362        }
3363        #[cfg(not(unix))]
3364        {
3365            let _ = (account, identity);
3366            true
3367        }
3368    }
3369
3370    /// The pool account `attempt`'s runner runs as, assigned now when it has
3371    /// none yet; `None` when runners run as the daemon.
3372    ///
3373    /// An attempt directory a pool account already owns -- a retried launch, a
3374    /// persistent slot, an attempt that outlived a restart -- keeps that
3375    /// account. A new one gets the first account no live attempt holds: none
3376    /// in this process's assignments, and none owning a directory beside it
3377    /// under the runner root, which covers attempts that outlived a restart.
3378    /// Two runners never share an account, so a launch with every account
3379    /// busy fails rather than weaken that. A pool with only the legacy
3380    /// single account, from before the pool existed, shares it as before.
3381    fn account_for(&self, attempt: &RunnerAttempt) -> Result<Option<RunnerAccount>, FailureReason> {
3382        let Some(pool) = self.runner.pool()? else {
3383            return Ok(None);
3384        };
3385        let mut assignments = self
3386            .assignments
3387            .lock()
3388            .unwrap_or_else(std::sync::PoisonError::into_inner);
3389        if let Some(account) = assignments.get(&attempt.id) {
3390            return Ok(Some(*account));
3391        }
3392        let runtime = attempt.runtime_path();
3393        let held = assignments.values().map(|account| account.uid).collect();
3394        // The accounts owning the other attempts' directories, including ones
3395        // a previous daemon left behind.
3396        let busy = || {
3397            runtime
3398                .parent()
3399                .and_then(|root| fs::read_dir(root).ok())
3400                .into_iter()
3401                .flatten()
3402                .flatten()
3403                .filter_map(|entry| owner_in_pool(pool, &entry.path()))
3404                .map(|account| account.uid)
3405                .collect()
3406        };
3407        let account = pool
3408            .allocate(owner_in_pool(pool, runtime), &held, busy)
3409            .ok_or_else(|| {
3410                FailureReason::Other(format!(
3411                    "every one of the {} runner accounts is in use by another attempt, and no \
3412                     two runners share one",
3413                    pool.accounts.len()
3414                ))
3415            })?;
3416        assignments.insert(attempt.id, account);
3417        Ok(Some(account))
3418    }
3419
3420    fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
3421        match fs::read(path) {
3422            Ok(bytes) => serde_json::from_slice(&bytes)
3423                .map(Some)
3424                .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
3425            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3426            Err(_) => Err(FailureReason::Other(
3427                "process identity journal could not be read".into(),
3428            )),
3429        }
3430    }
3431}
3432
3433impl ProcessSupervisor for NativeProcesses {
3434    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
3435        if !attempt.execution().is_native() {
3436            return Err(FailureReason::Other(
3437                "execution provider unavailable".into(),
3438            ));
3439        }
3440        // Existing native reconciliation has already settled process termination
3441        // before this boundary. Capture before ephemeral workspace deletion.
3442        if let Some(caches) = &self.dependency_caches {
3443            caches.capture_git(attempt.runtime_path());
3444        }
3445        // The account is free for another attempt once its runner is gone; the
3446        // directory it still owns until cleanup keeps it busy until then.
3447        self.assignments
3448            .lock()
3449            .unwrap_or_else(std::sync::PoisonError::into_inner)
3450            .remove(&attempt.id);
3451        Ok(ProviderDestroy::Destroyed)
3452    }
3453
3454    fn prepare(
3455        &self,
3456        attempt: &RunnerAttempt,
3457        policy: &ScalePolicy,
3458    ) -> Result<PreparedEnvironment, FailureReason> {
3459        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
3460            return Err(FailureReason::Other(
3461                "execution provider unavailable".into(),
3462            ));
3463        }
3464        self.host_env()?;
3465        // The materialized attempt is handed to the runner's account here, off
3466        // the async thread and before the JIT registration: it is the one walk
3467        // over the whole package copy, and the launch repeats only the cheap
3468        // part of it for what it creates afterwards.
3469        if let Some(account) = self.account_for(attempt)? {
3470            hand_over_attempt(account, attempt.runtime_path()).map_err(|error| {
3471                tracing::warn!(attempt_id = %attempt.id, event = "runner_hand_over_failed", %error, "the attempt could not be handed to the runner account");
3472                FailureReason::ProcessStartFailed
3473            })?;
3474        }
3475        // Asked here, before the JIT registration, for the reason `host_env`
3476        // is: a broken file must cost no registration.
3477        let cache_target = match &self.dependency_caches {
3478            Some(caches) => {
3479                caches.config()?;
3480                Some(policy.target.clone())
3481            }
3482            None => None,
3483        };
3484        Ok(PreparedEnvironment {
3485            attempt: attempt.id,
3486            identity: None,
3487            cache_target,
3488        })
3489    }
3490
3491    fn start(
3492        &self,
3493        prepared: PreparedEnvironment,
3494        attempt: &RunnerAttempt,
3495        handoff: OneTimeJitHandoff<'_>,
3496    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
3497        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
3498            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
3499                "execution provider identity mismatch".into(),
3500            )));
3501        }
3502        self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
3503            .map(EnvironmentIdentity::NativeProcess)
3504    }
3505
3506    fn spawn(
3507        &self,
3508        attempt: &RunnerAttempt,
3509        config: &EncodedJitConfig,
3510    ) -> Result<u32, ProcessStartFailure> {
3511        self.spawn_listener(attempt, config, None)
3512    }
3513
3514    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3515        let mut children = self
3516            .children
3517            .lock()
3518            .unwrap_or_else(std::sync::PoisonError::into_inner);
3519        if let Some(child) = children.get_mut(&attempt.id) {
3520            return match child
3521                .try_exit_status()
3522                .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
3523            {
3524                None => Ok(true),
3525                Some(status) => {
3526                    if let Ok(mut exits) = self.successful_exits.lock() {
3527                        exits.insert(attempt.id, status.success());
3528                    }
3529                    Ok(false)
3530                }
3531            };
3532        }
3533        let identity = match self.read_identity(attempt)? {
3534            RecordedProcess::Runner(identity) => identity,
3535            RecordedProcess::Foreign => return Ok(false),
3536            RecordedProcess::None => {
3537                if attempt.process_id().is_some()
3538                    || Self::unresolved_process_path(attempt).is_file()
3539                {
3540                    return Err(FailureReason::Other(
3541                        "runner process identity is missing; refusing recovery until the process is resolved"
3542                            .into(),
3543                    ));
3544                }
3545                return Ok(false);
3546            }
3547        };
3548        match identity.recheck() {
3549            Ok(Adoption::Live) => Ok(true),
3550            Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
3551            Err(_) => Ok(false),
3552        }
3553    }
3554
3555    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3556        Ok(self
3557            .read_identity(attempt)?
3558            .runner()
3559            .map(|identity| identity.pid()))
3560    }
3561
3562    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
3563        self.successful_exits
3564            .lock()
3565            .ok()
3566            .and_then(|exits| exits.get(&attempt.id).copied())
3567            .unwrap_or(false)
3568    }
3569
3570    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3571        let path = Self::intent_path(attempt);
3572        write_durable_file(&path, b"registration-timeout\n")
3573            .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
3574    }
3575
3576    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
3577        Self::intent_path(attempt).is_file()
3578    }
3579
3580    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3581        let mut children = self
3582            .children
3583            .lock()
3584            .unwrap_or_else(std::sync::PoisonError::into_inner);
3585        if let Some(child) = children.get_mut(&attempt.id) {
3586            child
3587                .stop(Duration::from_secs(10))
3588                .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
3589            return Ok(());
3590        }
3591        let Some(identity) = self.read_identity(attempt)?.runner() else {
3592            return Ok(());
3593        };
3594        match identity
3595            .terminate(Duration::from_secs(10))
3596            .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
3597        {
3598            Termination::Terminated | Termination::AlreadyGone => Ok(()),
3599            Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
3600                "runner PID was recycled; refusing to signal it".into(),
3601            )),
3602        }
3603    }
3604}
3605
3606pub struct LifecyclePorts {
3607    pub store: Arc<dyn Store>,
3608    pub github: Arc<dyn LifecycleGithub>,
3609    pub packages: Arc<dyn RuntimePackages>,
3610    pub processes: Arc<dyn ExecutionProvider>,
3611    pub clock: Arc<dyn Clock>,
3612    pub demand: Arc<dyn DemandPersistence>,
3613    pub delay: Arc<dyn RetryDelay>,
3614    pub events: Arc<dyn AttemptEventSink>,
3615    pub reconcile_events: Arc<dyn EventSink>,
3616}
3617
3618impl fmt::Debug for LifecyclePorts {
3619    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3620        f.debug_struct("LifecyclePorts")
3621            .field("store", &self.store)
3622            .field("github", &self.github)
3623            .field("packages", &self.packages)
3624            .field("processes", &self.processes)
3625            .finish_non_exhaustive()
3626    }
3627}
3628
3629#[derive(Debug, thiserror::Error)]
3630pub enum LifecycleError {
3631    #[error("attempt journal operation failed")]
3632    Journal,
3633    #[error("attempt {0} is not in the journal")]
3634    Missing(AttemptId),
3635    #[error("attempt lifecycle transition was refused")]
3636    Transition,
3637    #[error("startup recovery has not completed")]
3638    RecoveryIncomplete,
3639    /// A persistent slot could not be proven safe to scrub, so the attempt keeps
3640    /// its uncleaned state and, with it, its slot lease.
3641    ///
3642    /// Separate from [`Self::Failed`] because the two demand opposite handling
3643    /// from the same call sites. A failure aborts the pass; a quarantine must
3644    /// not, or one stuck slot would stop the host launching anything at all —
3645    /// which is precisely what `04-security-recovery.md` rules out when it says
3646    /// such an attempt "does not count as active host capacity" and "recovery
3647    /// retries the same cleanup".
3648    #[error("the persistent slot was not cleaned: {detail}")]
3649    SlotQuarantined {
3650        /// The closed-vocabulary event field; never operator or workflow text.
3651        class: &'static str,
3652        detail: String,
3653    },
3654    /// An ephemeral runtime is still held by a late process. Its journal state
3655    /// remains unchanged and ordinary supervision retries it, but startup
3656    /// recovery must not take unrelated repositories offline because of it.
3657    #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
3658    WorkspaceCleanupDeferred,
3659    #[error("isolated environment cleanup is deferred")]
3660    EnvironmentCleanupDeferred,
3661    #[error("runner lifecycle failed: {0}")]
3662    Failed(FailureReason),
3663}
3664
3665impl LifecycleError {
3666    fn reason(&self) -> FailureReason {
3667        match self {
3668            Self::Failed(reason) => reason.clone(),
3669            Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
3670            Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
3671            Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
3672            Self::Transition => FailureReason::Other("attempt transition was refused".into()),
3673            // Rendered through `Display` rather than a second copy of the same
3674            // sentence, so the two cannot drift apart.
3675            Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
3676            Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
3677            Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
3678        }
3679    }
3680}
3681
3682/// Production implementation of e1's launcher port.
3683#[derive(Debug)]
3684pub struct LifecycleLauncher {
3685    host_id: HostId,
3686    app_paths: runner_manager_platform::paths::AppPaths,
3687    diagnostics_root: PathBuf,
3688    runner_group_id: u64,
3689    timeouts: RecoveryTimeouts,
3690    retry: RetryPolicy,
3691    cancel: CancelToken,
3692    ports: LifecyclePorts,
3693    recovery_complete: Mutex<bool>,
3694    versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
3695    pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
3696    /// Backoff for cleanups [`Self::supervise`] tolerates and retries, so a
3697    /// workspace that cannot be removed is not retried and reported on every
3698    /// pass.
3699    cleanup_retries: Mutex<CleanBackoff>,
3700    turns: Arc<tokio::sync::Semaphore>,
3701    /// The attempts reserved and not yet up or failed: their bring-up is writing
3702    /// their journal entries, and it runs while the target's pass supervises.
3703    bringing_up: Mutex<BTreeSet<AttemptId>>,
3704}
3705
3706/// Takes an attempt out of [`LifecycleLauncher::bringing_up`], however its
3707/// bring-up ends.
3708struct BroughtUpOnDrop<'a> {
3709    attempts: &'a Mutex<BTreeSet<AttemptId>>,
3710    id: AttemptId,
3711}
3712
3713impl Drop for BroughtUpOnDrop<'_> {
3714    fn drop(&mut self) {
3715        self.attempts
3716            .lock()
3717            .unwrap_or_else(std::sync::PoisonError::into_inner)
3718            .remove(&self.id);
3719    }
3720}
3721
3722/// How many runners of one host prepare at once, whatever their target: a
3723/// quarter of its cores, at least two. A runner's package is ~9,000 files, and
3724/// on a disk jobs are using, more at once only make all of them slower. Fair,
3725/// so the ones waiting go in the order they were reserved.
3726#[must_use]
3727pub fn preparation_turns() -> Arc<tokio::sync::Semaphore> {
3728    let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
3729    Arc::new(tokio::sync::Semaphore::new((cores / 4).max(2)))
3730}
3731
3732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3733enum ReconcileProgress {
3734    Reconciled,
3735    Deferred,
3736    Replacement {
3737        attempt: AttemptId,
3738        operation: &'static str,
3739    },
3740}
3741
3742impl LifecycleLauncher {
3743    #[must_use]
3744    pub fn new(
3745        host_id: HostId,
3746        app_paths: runner_manager_platform::paths::AppPaths,
3747        diagnostics_root: impl Into<PathBuf>,
3748        runner_group_id: u64,
3749        timeouts: RecoveryTimeouts,
3750        retry: RetryPolicy,
3751        ports: LifecyclePorts,
3752    ) -> Self {
3753        Self {
3754            host_id,
3755            app_paths,
3756            diagnostics_root: diagnostics_root.into(),
3757            runner_group_id,
3758            timeouts,
3759            retry,
3760            cancel: CancelToken::new(),
3761            ports,
3762            recovery_complete: Mutex::new(false),
3763            versions: Mutex::new(BTreeMap::new()),
3764            pending_replacements: Mutex::new(BTreeMap::new()),
3765            cleanup_retries: Mutex::new(CleanBackoff::default()),
3766            turns: preparation_turns(),
3767            bringing_up: Mutex::default(),
3768        }
3769    }
3770
3771    /// Takes turns to prepare with every launcher holding `turns`, rather than
3772    /// only with itself: the targets of one host.
3773    #[must_use]
3774    pub fn with_preparation_turns(mut self, turns: Arc<tokio::sync::Semaphore>) -> Self {
3775        self.turns = turns;
3776        self
3777    }
3778
3779    /// Reconcile the entire journal before allowing a launch.  Unknown-policy
3780    /// attempts are left untouched rather than being acted on without an
3781    /// ownership proof.
3782    pub async fn recover_startup(
3783        &self,
3784        policies: &[ScalePolicy],
3785    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3786        *self
3787            .recovery_complete
3788            .lock()
3789            .map_err(|_| LifecycleError::Journal)? = false;
3790        let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
3791        let attempts = self
3792            .ports
3793            .store
3794            .attempts()
3795            .map_err(|_| LifecycleError::Journal)?;
3796        let owned = self.ports.processes.enumerate_owned(self.host_id);
3797        for identity in &owned {
3798            if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
3799                && !attempts.iter().any(|attempt| {
3800                    attempt.state() != AttemptState::Cleaned
3801                        && identity_matches_intent(self.host_id, attempt, identity)
3802                })
3803            {
3804                // Enumeration is discovery, not authority to delete.
3805                self.ports
3806                    .events
3807                    .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
3808                tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
3809            }
3810        }
3811        let mut unresolved = false;
3812        for mut attempt in attempts {
3813            let Some(policy) = by_id.get(&attempt.policy_id) else {
3814                if attempt.counts_against_capacity() {
3815                    unresolved = true;
3816                }
3817                continue;
3818            };
3819            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3820            let allocation_resources = owned
3821                .iter()
3822                .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
3823                .collect::<Vec<_>>();
3824            if allocation_resources.len() > 1 {
3825                self.ports
3826                    .events
3827                    .emit(AttemptEvent::QuarantinedOwnedResource {
3828                        attempt: attempt.id,
3829                    });
3830                tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
3831            }
3832            if attempt.state() != AttemptState::Cleaned
3833                && matches!(
3834                    attempt.execution(),
3835                    AttemptExecution::Isolated {
3836                        environment_id: None,
3837                        ..
3838                    }
3839                )
3840                && allocation_resources.len() == 1
3841                && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
3842                    allocation_resources.first().copied()
3843            {
3844                attempt
3845                    .prepared_environment(environment_id.clone())
3846                    .map_err(|_| LifecycleError::Transition)?;
3847                if attempt.state() == AttemptState::Preparing {
3848                    attempt
3849                        .mark_prepared(self.ports.clock.now())
3850                        .map_err(|_| LifecycleError::Transition)?;
3851                }
3852                self.record(&attempt)?;
3853            }
3854            match self.reconcile_one(policy, attempt).await? {
3855                ReconcileProgress::Deferred => {
3856                    if policy.execution_policy().is_native() {
3857                        unresolved = true;
3858                    }
3859                }
3860                ReconcileProgress::Replacement { attempt, operation } => {
3861                    self.pending_replacements
3862                        .lock()
3863                        .map_err(|_| LifecycleError::Journal)?
3864                        .insert(
3865                            attempt,
3866                            ReplacementIntent {
3867                                policy: policy.id,
3868                                previous_attempt: attempt,
3869                                operation,
3870                            },
3871                        );
3872                }
3873                ReconcileProgress::Reconciled => {}
3874            }
3875        }
3876        if unresolved {
3877            return Err(LifecycleError::RecoveryIncomplete);
3878        }
3879        *self
3880            .recovery_complete
3881            .lock()
3882            .map_err(|_| LifecycleError::Journal)? = true;
3883        Ok(self
3884            .pending_replacements
3885            .lock()
3886            .map_err(|_| LifecycleError::Journal)?
3887            .values()
3888            .copied()
3889            .collect())
3890    }
3891
3892    /// Supervise all attempts of one policy during an ordinary poll.
3893    pub async fn supervise(
3894        &self,
3895        policy: &ScalePolicy,
3896    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3897        let mut replacements = Vec::new();
3898        self.pending_replacements
3899            .lock()
3900            .map_err(|_| LifecycleError::Journal)?
3901            .retain(|_, intent| {
3902                if intent.policy == policy.id {
3903                    replacements.push(*intent);
3904                    false
3905                } else {
3906                    true
3907                }
3908            });
3909        // Read under the set a reservation joins before it is journalled, so
3910        // every attempt read here that a bring-up may write to is known to be
3911        // one: it has no process yet and no registration, which this pass would
3912        // read as a runner gone, and what was read of it is out of date at once.
3913        let attempts = {
3914            let bringing_up = self
3915                .bringing_up
3916                .lock()
3917                .map_err(|_| LifecycleError::Journal)?;
3918            let mut attempts = self
3919                .ports
3920                .store
3921                .attempts_for_policy(policy.id)
3922                .map_err(|_| LifecycleError::Journal)?;
3923            attempts.retain(|attempt| !bringing_up.contains(&attempt.id));
3924            attempts
3925        };
3926        for attempt in attempts {
3927            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3928            if let ReconcileProgress::Replacement { attempt, operation } =
3929                self.reconcile_one(policy, attempt).await?
3930            {
3931                replacements.push(ReplacementIntent {
3932                    policy: policy.id,
3933                    previous_attempt: attempt,
3934                    operation,
3935                });
3936            }
3937        }
3938        Ok(replacements)
3939    }
3940
3941    async fn reconcile_one(
3942        &self,
3943        policy: &ScalePolicy,
3944        mut attempt: RunnerAttempt,
3945    ) -> Result<ReconcileProgress, LifecycleError> {
3946        if !attempt.execution().is_native() {
3947            return self.reconcile_isolated_one(policy, attempt).await;
3948        }
3949        if attempt.state() == AttemptState::Cleaned {
3950            // A child of Runner.Worker can outlive the runner process briefly.
3951            // If it recreates `_work` after `clean_attempt` removed the tree but
3952            // before the journal's final write, the journal truthfully reaches
3953            // `cleaned` and the directory appears again afterwards. Cleaned
3954            // attempts used to return here forever, making that late residue
3955            // invisible. The journalled ephemeral mode is the ownership proof:
3956            // retry the whole-tree removal, while a persistent slot deliberately
3957            // remains on disk with its retained `_work`.
3958            if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3959                && attempt.runtime_path().exists()
3960            {
3961                // The attempt is already terminal, has released its package
3962                // lease, and consumes no capacity. A late child may still hold
3963                // one of its recreated files open on Windows, so failure here
3964                // is residue to retry, not a reason to abort startup recovery
3965                // and take every unrelated repository offline.
3966                // Retried with backoff; the event is the one report, which the
3967                // event sink logs as a warning.
3968                let now = self.ports.clock.now();
3969                if !self.cleanup_retries().is_due(attempt.id, now) {
3970                    return Ok(ReconcileProgress::Reconciled);
3971                }
3972                match self.scrub_workspace(&attempt) {
3973                    Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3974                    Err(LifecycleError::WorkspaceCleanupDeferred) => {
3975                        self.cleanup_retries().failed(attempt.id, now);
3976                        self.ports
3977                            .reconcile_events
3978                            .emit(LifecycleEvent::AttemptCleanFailed {
3979                                policy: attempt.policy_id,
3980                                attempt: attempt.id,
3981                                reason: "late_ephemeral_workspace_could_not_be_removed",
3982                            });
3983                    }
3984                    Err(error) => return Err(error),
3985                }
3986            }
3987            return Ok(ReconcileProgress::Reconciled);
3988        }
3989        if attempt.is_terminal() {
3990            self.clean_or_quarantine(&mut attempt)?;
3991            return Ok(ReconcileProgress::Reconciled);
3992        }
3993        let process_alive = self
3994            .ports
3995            .processes
3996            .inspect(&attempt)
3997            .map_err(LifecycleError::Failed)?
3998            == EnvironmentState::Running;
3999        let github = self
4000            .ports
4001            .github
4002            .observe(&policy.target, attempt.id, &self.cancel)
4003            .await;
4004
4005        // If the agent died after GitHub accepted the registration but before
4006        // the non-secret runner-id sidecar landed, inventory closes the gap.
4007        // Persist it before making any state decision so a second crash moves
4008        // the boundary forward rather than repeating it.
4009        if let Some(runner_id) = github.runner_id
4010            && read_runner_id(attempt.runtime_path()).is_none()
4011        {
4012            write_runner_id(attempt.runtime_path(), runner_id)?;
4013            self.ports
4014                .events
4015                .emit(AttemptEvent::RemoteIdentityRecovered {
4016                    attempt: attempt.id,
4017                    runner_id,
4018                });
4019        }
4020
4021        // The child identity is synced before `spawn` returns.  If the agent
4022        // crashed before the following `starting` journal write, recover that
4023        // exact PID and take the legal `jit_received -> starting` edge before
4024        // applying GitHub's authoritative idle/busy observation below.
4025        if attempt.state() == AttemptState::JitReceived
4026            && process_alive
4027            && let Some(pid) = self
4028                .ports
4029                .processes
4030                .recovered_pid(&attempt)
4031                .map_err(LifecycleError::Failed)?
4032        {
4033            attempt
4034                .started(pid, self.ports.clock.now())
4035                .map_err(|_| LifecycleError::Transition)?;
4036            self.record(&attempt)?;
4037        }
4038
4039        // What the runner's own diagnostics say about whether it ran a job.
4040        // Read before anything below can conclude the attempt, because cleanup
4041        // deletes `_diag` with the rest of the runtime directory.
4042        // A one-shot child owned by this invocation exited successfully. This
4043        // concludes the *runner attempt*, never the workflow outcome; GitHub
4044        // remains authoritative for that. GitHub's inventory is a sampled
4045        // witness: a job of a few seconds starts and ends between two passes,
4046        // and an assignment GitHub shows can be one the runner never took up.
4047        // The runner's own diagnostics decide, read before cleanup deletes
4048        // them, and GitHub's sample only decides when they say nothing. Not
4049        // while GitHub is unreachable: a registration still listed there could
4050        // not be removed, and nothing visits a concluded attempt again.
4051        if !process_alive
4052            && github.status != GithubRunnerObservation::Unreachable
4053            && matches!(
4054                attempt.state(),
4055                AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
4056            )
4057            && self.ports.processes.completed_successfully(&attempt)
4058        {
4059            let state = attempt.state();
4060            let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
4061                // Without a runner id there is no edge to `busy`; the recovery
4062                // path below concludes the attempt without one.
4063                (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
4064                    known_runner_id(&attempt, github.runner_id)
4065                        .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
4066                }
4067                (AttemptState::Busy, JobEvidence::RanAJob) => {
4068                    Some((None, AttemptOutcome::CompletedJob))
4069                }
4070                (AttemptState::Busy, JobEvidence::NoJob) => {
4071                    Some((None, AttemptOutcome::ExitedIdleWithoutWork))
4072                }
4073                (AttemptState::Busy, JobEvidence::Unknown)
4074                    if github.status == GithubRunnerObservation::NotRegistered =>
4075                {
4076                    Some((None, AttemptOutcome::CompletedJob))
4077                }
4078                _ => None,
4079            };
4080            if let Some((walk_to_busy, outcome)) = outcome {
4081                // An ephemeral runner leaves GitHub's list a little after its
4082                // process exits; one still listed is removed here rather than
4083                // left for GitHub to time out. First, so the journal's last
4084                // intermediate state and the conclusion are written back to back:
4085                // an agent that dies between them recovers from the state it was
4086                // in before this pass.
4087                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
4088                    self.deregister_runner(policy, &attempt).await;
4089                }
4090                let now = self.ports.clock.now();
4091                match (walk_to_busy, &outcome) {
4092                    (Some(runner_id), _) => {
4093                        self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
4094                    }
4095                    (None, AttemptOutcome::ExitedIdleWithoutWork) => {
4096                        self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
4097                    }
4098                    (None, _) => {}
4099                }
4100                self.conclude(&mut attempt, outcome)?;
4101                self.clean_or_quarantine(&mut attempt)?;
4102                return Ok(ReconcileProgress::Reconciled);
4103            }
4104        }
4105
4106        // A live runner journalled `busy` that GitHub now shows waiting, and
4107        // whose diagnostics show no job: the assignment was never taken up.
4108        // Back to `idle`, where the idle timeout applies to it again; the
4109        // `busy` arm below would otherwise adopt it for as long as it runs.
4110        if process_alive
4111            && attempt.state() == AttemptState::Busy
4112            && github.status == (GithubRunnerObservation::Registered { busy: false })
4113            && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
4114        {
4115            let now = self.ports.clock.now();
4116            self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
4117            return Ok(ReconcileProgress::Reconciled);
4118        }
4119
4120        // This durable mark is more authoritative than a later observation
4121        // which cannot distinguish an agent kill from a crash.
4122        if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
4123            self.deregister_runner(policy, &attempt).await;
4124            self.conclude(
4125                &mut attempt,
4126                AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
4127            )?;
4128            self.clean_or_quarantine(&mut attempt)?;
4129            return Ok(ReconcileProgress::Replacement {
4130                attempt: attempt.id,
4131                operation: "registration_timeout_replacement",
4132            });
4133        }
4134
4135        // A remote registration with no surviving process has lost its
4136        // one-shot JIT secret.  Walking it to `starting` would require inventing
4137        // a PID; retrying the same registration would require inventing the
4138        // secret.  Record the configuration as expired and let the bounded
4139        // replacement path request a fresh one only if demand remains.
4140        if matches!(
4141            attempt.state(),
4142            AttemptState::Allocated | AttemptState::JitReceived
4143        ) && !process_alive
4144            && matches!(github.status, GithubRunnerObservation::Registered { .. })
4145        {
4146            if attempt.state() == AttemptState::Allocated {
4147                attempt
4148                    .jit_received(self.ports.clock.now())
4149                    .map_err(|_| LifecycleError::Transition)?;
4150                self.record(&attempt)?;
4151            }
4152            self.deregister_runner(policy, &attempt).await;
4153            self.conclude(
4154                &mut attempt,
4155                AttemptOutcome::failed(FailureReason::JitExpired),
4156            )?;
4157            self.clean_or_quarantine(&mut attempt)?;
4158            return Ok(ReconcileProgress::Replacement {
4159                attempt: attempt.id,
4160                operation: "jit_expired_replacement",
4161            });
4162        }
4163
4164        match recovery_decision(
4165            &attempt,
4166            RecoveryObservation {
4167                process_alive,
4168                github: github.status,
4169            },
4170            self.timeouts,
4171            self.ports.clock.as_ref(),
4172        ) {
4173            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
4174            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
4175            RecoveryDecision::Adopt => {
4176                self.ports.events.emit(AttemptEvent::Adopted {
4177                    attempt: attempt.id,
4178                });
4179                Ok(ReconcileProgress::Reconciled)
4180            }
4181            RecoveryDecision::Clean => {
4182                self.clean_or_quarantine(&mut attempt)?;
4183                Ok(ReconcileProgress::Reconciled)
4184            }
4185            RecoveryDecision::Observe(state) => {
4186                let runner_id = known_runner_id(&attempt, github.runner_id)
4187                    .ok_or(LifecycleError::Transition)?;
4188                match state {
4189                    AttemptState::JitReceived => attempt
4190                        .jit_received(self.ports.clock.now())
4191                        .map_err(|_| LifecycleError::Transition)?,
4192                    AttemptState::Starting => {
4193                        let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
4194                        attempt
4195                            .started(pid, self.ports.clock.now())
4196                            .map_err(|_| LifecycleError::Transition)?;
4197                    }
4198                    AttemptState::Idle => attempt
4199                        .registered_idle(runner_id, self.ports.clock.now())
4200                        .map_err(|_| LifecycleError::Transition)?,
4201                    AttemptState::Busy => attempt
4202                        .assigned_job(runner_id, self.ports.clock.now())
4203                        .map_err(|_| LifecycleError::Transition)?,
4204                    _ => return Err(LifecycleError::Transition),
4205                }
4206                self.record(&attempt)?;
4207                Ok(ReconcileProgress::Reconciled)
4208            }
4209            RecoveryDecision::Conclude(outcome) => {
4210                let replacement = replacement_operation(&outcome);
4211                // Only when GitHub still holds one. Every other conclusion here
4212                // was reached *because* the observation was `NotRegistered`, and
4213                // spending a DELETE to be told so again would put a request per
4214                // concluded attempt on a budget `rest.rs` prices to the request.
4215                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
4216                    self.deregister_runner(policy, &attempt).await;
4217                }
4218                self.conclude(&mut attempt, outcome)?;
4219                self.clean_or_quarantine(&mut attempt)?;
4220                Ok(
4221                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
4222                        ReconcileProgress::Replacement {
4223                            attempt: attempt.id,
4224                            operation,
4225                        }
4226                    }),
4227                )
4228            }
4229            RecoveryDecision::Terminate(payload) => {
4230                // The mark is synced first, and what proves the process died is
4231                // the `is_alive` re-read below -- not the outcome recorded after
4232                // it. Which outcome that is depends on why the termination was
4233                // ordered, and only the payload knows: a `starting` runner that
4234                // never registered is a failure this agent then stopped, while
4235                // an `idle` one past its timeout is flow 2.7's surplus exit and
4236                // no failure at all. Hardcoding the first reason here labelled
4237                // the second as a registration timeout and asked the allocator
4238                // for a replacement to boot.
4239                let idle_exit = payload.is_idle_exit();
4240                self.ports
4241                    .processes
4242                    .record_terminate_intent(&attempt)
4243                    .map_err(LifecycleError::Failed)?;
4244                self.ports.events.emit(AttemptEvent::TerminateIntent {
4245                    attempt: attempt.id,
4246                });
4247                if self
4248                    .ports
4249                    .processes
4250                    .stop(&attempt)
4251                    .map_err(LifecycleError::Failed)?
4252                    == ProviderStop::StillRunning
4253                {
4254                    return Ok(ReconcileProgress::Deferred);
4255                }
4256                if self
4257                    .ports
4258                    .processes
4259                    .inspect(&attempt)
4260                    .map_err(LifecycleError::Failed)?
4261                    == EnvironmentState::Running
4262                {
4263                    return Ok(ReconcileProgress::Deferred);
4264                }
4265                self.ports.events.emit(AttemptEvent::Terminated {
4266                    attempt: attempt.id,
4267                });
4268                // The registration-timeout path keeps deriving its own reason
4269                // rather than applying the payload: on the pass that reads the
4270                // journalled mark back the process is dead, and
4271                // `TerminatedAfterRegistrationTimeout` is the reason that stays
4272                // true of a dead process. See `RecoveryDecision::Terminate`.
4273                let outcome = if idle_exit {
4274                    AttemptOutcome::ExitedIdleWithoutWork
4275                } else {
4276                    AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
4277                };
4278                self.deregister_runner(policy, &attempt).await;
4279                self.conclude(&mut attempt, outcome)?;
4280                self.clean_or_quarantine(&mut attempt)?;
4281                // A surplus runner is not replaced. It was stopped precisely
4282                // because the work it was started for went elsewhere; asking the
4283                // allocator for another one rebuilds it every idle timeout.
4284                if idle_exit {
4285                    Ok(ReconcileProgress::Reconciled)
4286                } else {
4287                    Ok(ReconcileProgress::Replacement {
4288                        attempt: attempt.id,
4289                        operation: "registration_timeout_replacement",
4290                    })
4291                }
4292            }
4293        }
4294    }
4295
4296    async fn reconcile_isolated_one(
4297        &self,
4298        policy: &ScalePolicy,
4299        mut attempt: RunnerAttempt,
4300    ) -> Result<ReconcileProgress, LifecycleError> {
4301        if attempt.state() == AttemptState::Cleaned {
4302            return Ok(ReconcileProgress::Reconciled);
4303        }
4304        if attempt.is_terminal() {
4305            self.clean_or_quarantine(&mut attempt)?;
4306            return Ok(ReconcileProgress::Reconciled);
4307        }
4308        if matches!(
4309            attempt.state(),
4310            AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
4311        ) {
4312            // No JIT was requested yet. Restart can safely retire this attempt;
4313            // a matching owned resource is stopped by the durable cleanup path.
4314            self.conclude(
4315                &mut attempt,
4316                AttemptOutcome::failed(FailureReason::Other(
4317                    "isolated preparation interrupted".into(),
4318                )),
4319            )?;
4320            self.clean_or_quarantine(&mut attempt)?;
4321            return Ok(ReconcileProgress::Reconciled);
4322        }
4323        let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
4324            return Ok(ReconcileProgress::Deferred);
4325        };
4326        if provider_state != EnvironmentState::Missing {
4327            let Ok(owned) = self.ports.processes.owns(&attempt) else {
4328                return Ok(ReconcileProgress::Deferred);
4329            };
4330            if !owned {
4331                self.ports
4332                    .events
4333                    .emit(AttemptEvent::QuarantinedOwnedResource {
4334                        attempt: attempt.id,
4335                    });
4336                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4337                return Ok(ReconcileProgress::Deferred);
4338            }
4339        }
4340        let live = matches!(
4341            provider_state,
4342            EnvironmentState::Starting | EnvironmentState::Running
4343        );
4344        if attempt.state() == AttemptState::JitReceived && live {
4345            attempt
4346                .started_isolated(self.ports.clock.now())
4347                .map_err(|_| LifecycleError::Transition)?;
4348            self.record(&attempt)?;
4349        }
4350        let github = self
4351            .ports
4352            .github
4353            .observe(&policy.target, attempt.id, &self.cancel)
4354            .await;
4355        if let Some(runner_id) = github.runner_id
4356            && read_runner_id(attempt.runtime_path()).is_none()
4357        {
4358            write_runner_id(attempt.runtime_path(), runner_id)?;
4359        }
4360        // A successfully reaped one-shot listener is stronger evidence than a
4361        // missing registration. Recovered guests have no attached child and
4362        // still follow the conservative orphan path below.
4363        if attempt.state() == AttemptState::Busy
4364            && !live
4365            && github.status == GithubRunnerObservation::NotRegistered
4366            && self.ports.processes.completed_successfully(&attempt)
4367        {
4368            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
4369            self.clean_or_quarantine(&mut attempt)?;
4370            return Ok(ReconcileProgress::Reconciled);
4371        }
4372        match recovery_decision(
4373            &attempt,
4374            RecoveryObservation {
4375                process_alive: live,
4376                github: github.status,
4377            },
4378            self.timeouts,
4379            self.ports.clock.as_ref(),
4380        ) {
4381            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
4382            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
4383            RecoveryDecision::Adopt => {
4384                self.ports.events.emit(AttemptEvent::Adopted {
4385                    attempt: attempt.id,
4386                });
4387                Ok(ReconcileProgress::Reconciled)
4388            }
4389            RecoveryDecision::Clean => {
4390                self.clean_or_quarantine(&mut attempt)?;
4391                Ok(ReconcileProgress::Reconciled)
4392            }
4393            RecoveryDecision::Observe(state) => {
4394                let runner_id = github
4395                    .runner_id
4396                    .or_else(|| read_runner_id(attempt.runtime_path()))
4397                    .ok_or(LifecycleError::Transition)?;
4398                match state {
4399                    AttemptState::Idle => attempt
4400                        .registered_idle(runner_id, self.ports.clock.now())
4401                        .map_err(|_| LifecycleError::Transition)?,
4402                    AttemptState::Busy => attempt
4403                        .assigned_job(runner_id, self.ports.clock.now())
4404                        .map_err(|_| LifecycleError::Transition)?,
4405                    _ => return Err(LifecycleError::Transition),
4406                }
4407                self.record(&attempt)?;
4408                Ok(ReconcileProgress::Reconciled)
4409            }
4410            RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
4411                let replacement = replacement_operation(&outcome);
4412                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
4413                    self.deregister_runner(policy, &attempt).await;
4414                }
4415                self.conclude(&mut attempt, outcome)?;
4416                self.clean_or_quarantine(&mut attempt)?;
4417                Ok(
4418                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
4419                        ReconcileProgress::Replacement {
4420                            attempt: attempt.id,
4421                            operation,
4422                        }
4423                    }),
4424                )
4425            }
4426        }
4427    }
4428
4429    /// Takes one domain edge and journals it.
4430    fn transition(
4431        &self,
4432        attempt: &mut RunnerAttempt,
4433        edge: impl FnOnce(
4434            &mut RunnerAttempt,
4435        ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
4436    ) -> Result<(), LifecycleError> {
4437        edge(attempt).map_err(|_| LifecycleError::Transition)?;
4438        self.record(attempt)
4439    }
4440
4441    fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4442        self.ports
4443            .store
4444            .record_attempt(attempt)
4445            .map_err(|_| LifecycleError::Journal)?;
4446        self.ports.events.emit(AttemptEvent::State {
4447            attempt: attempt.id,
4448            state: attempt.state(),
4449        });
4450        Ok(())
4451    }
4452
4453    /// Remove the GitHub registration an attempt is about to leave behind.
4454    ///
4455    /// # Why this is not fallible, and does not block the conclusion
4456    ///
4457    /// GitHub retires an ephemeral runner itself once that runner *completes a
4458    /// job*, and for the ordinary path that is the whole story. The paths that
4459    /// reach here are the ones where it does not: a runner stopped before it
4460    /// was ever assigned work, a registration whose process died still holding
4461    /// it, a JIT configuration that expired. Nothing else deletes those, and
4462    /// before this existed nothing did — they accumulated in the target's
4463    /// runner settings, one row per attempt, for the life of the repository.
4464    ///
4465    /// It returns `()` rather than a `Result` because the alternative is worse
4466    /// in both directions. The attempt is over: its process is gone and its
4467    /// slot has to come back, so a failed delete may not abort the conclusion
4468    /// or the host leaks capacity every time GitHub is unreachable. And a
4469    /// registration that outlives this call is not lost — it is exactly the
4470    /// `Registered` + dead-process observation that
4471    /// [`AttemptOutcome::Orphaned`] already names, which a later pass can still
4472    /// see. So a failure is logged and stepped over, deliberately.
4473    async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
4474        let Some(runner_id) = attempt
4475            .github_runner_id()
4476            .or_else(|| read_runner_id(attempt.runtime_path()))
4477        else {
4478            return;
4479        };
4480        if self
4481            .ports
4482            .github
4483            .deregister(&policy.target, runner_id, &self.cancel)
4484            .await
4485        {
4486            self.ports.events.emit(AttemptEvent::Deregistered {
4487                attempt: attempt.id,
4488                runner_id,
4489            });
4490        } else {
4491            tracing::warn!(
4492                attempt = %attempt.id,
4493                runner_id,
4494                "the runner registration could not be removed from GitHub; it will show in the \
4495                 target's runner settings until GitHub retires it or a later pass removes it"
4496            );
4497        }
4498    }
4499
4500    fn conclude(
4501        &self,
4502        attempt: &mut RunnerAttempt,
4503        outcome: AttemptOutcome,
4504    ) -> Result<(), LifecycleError> {
4505        attempt
4506            .conclude(outcome.clone(), self.ports.clock.now())
4507            .map_err(|_| LifecycleError::Transition)?;
4508        self.record(attempt)?;
4509        self.ports.events.emit(AttemptEvent::Concluded {
4510            attempt: attempt.id,
4511            outcome: OutcomeKind::of(&outcome),
4512        });
4513        Ok(())
4514    }
4515
4516    /// Clean a concluded attempt, tolerating a quarantined persistent slot.
4517    ///
4518    /// The quarantine is reported and stepped over rather than raised, because
4519    /// raising it aborts the whole pass: on the startup path that leaves
4520    /// `recovery_complete` false and stops the host launching anything, which is
4521    /// the opposite of `04-security-recovery.md`'s "it does not count as active
4522    /// host capacity" and "recovery retries the same cleanup". The attempt keeps
4523    /// its state, so it keeps its slot lease and its directory, and the next
4524    /// pass — [`crate::reconcile::Reconciler`]'s terminal sweep on every poll,
4525    /// or the next startup — attempts exactly the same cleanup again.
4526    ///
4527    /// A persistent-slot quarantine and an ephemeral directory still held by a
4528    /// late process are tolerated. A journal failure or a package lease that
4529    /// cannot be released still propagates: those are not one workspace's
4530    /// problem.
4531    ///
4532    /// A tolerated failure is retried with per-attempt backoff: until it is
4533    /// due again the attempt is skipped silently rather than re-tried and
4534    /// re-reported on every pass.
4535    fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4536        let now = self.ports.clock.now();
4537        if !self.cleanup_retries().is_due(attempt.id, now) {
4538            return Ok(());
4539        }
4540        let reason = match self.clean_attempt(attempt) {
4541            Err(LifecycleError::SlotQuarantined { class, .. }) => class,
4542            Err(LifecycleError::WorkspaceCleanupDeferred) => {
4543                failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
4544            }
4545            Err(LifecycleError::EnvironmentCleanupDeferred) => {
4546                "isolated_environment_cleanup_deferred"
4547            }
4548            other => {
4549                if other.is_ok() {
4550                    self.cleanup_retries().succeeded(attempt.id);
4551                }
4552                return other;
4553            }
4554        };
4555        self.cleanup_retries().failed(attempt.id, now);
4556        self.ports
4557            .reconcile_events
4558            .emit(LifecycleEvent::AttemptCleanFailed {
4559                policy: attempt.policy_id,
4560                attempt: attempt.id,
4561                reason,
4562            });
4563        Ok(())
4564    }
4565
4566    fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
4567        self.cleanup_retries
4568            .lock()
4569            .unwrap_or_else(std::sync::PoisonError::into_inner)
4570    }
4571
4572    fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4573        if !attempt.execution().is_native() {
4574            return self.clean_isolated_attempt(attempt);
4575        }
4576        match self
4577            .ports
4578            .processes
4579            .destroy(attempt)
4580            .map_err(LifecycleError::Failed)?
4581        {
4582            ProviderDestroy::Destroyed => {}
4583            ProviderDestroy::Deferred(reason) => {
4584                return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
4585            }
4586        }
4587        let outcome = attempt
4588            .outcome()
4589            .cloned()
4590            .ok_or(LifecycleError::Transition)?;
4591        self.preserve_diagnostics(attempt, &outcome)?;
4592        self.scrub_workspace(attempt)?;
4593        self.ports
4594            .packages
4595            .release(attempt.id)
4596            .map_err(LifecycleError::Failed)?;
4597        attempt
4598            .clean(self.ports.clock.now())
4599            .map_err(|_| LifecycleError::Transition)?;
4600        self.record(attempt)?;
4601        let kind = OutcomeKind::of(&outcome);
4602        self.ports.events.emit(AttemptEvent::Cleaned {
4603            attempt: attempt.id,
4604            outcome: kind,
4605        });
4606        self.ports
4607            .reconcile_events
4608            .emit(LifecycleEvent::AttemptCleaned {
4609                policy: attempt.policy_id,
4610                attempt: attempt.id,
4611                outcome: kind,
4612            });
4613        Ok(())
4614    }
4615
4616    fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4617        if attempt.state() != AttemptState::Destroying {
4618            attempt
4619                .begin_destroy(self.ports.clock.now())
4620                .map_err(|_| LifecycleError::Transition)?;
4621            self.record(attempt)?;
4622        }
4623        let provider = self.ports.processes.as_ref();
4624        let allocation_resources = provider.enumerate_owned(self.host_id);
4625        let matching_allocation = allocation_resources
4626            .iter()
4627            .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
4628            .collect::<Vec<_>>();
4629        if matching_allocation.len() > 1
4630            || matching_allocation
4631                .iter()
4632                .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
4633        {
4634            self.ports
4635                .events
4636                .emit(AttemptEvent::QuarantinedOwnedResource {
4637                    attempt: attempt.id,
4638                });
4639            tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
4640            return Err(self.defer_environment_cleanup(attempt)?);
4641        }
4642        let has_identity = matches!(
4643            attempt.execution(),
4644            AttemptExecution::Isolated {
4645                environment_id: Some(_),
4646                ..
4647            }
4648        );
4649        if !has_identity
4650            && allocation_resources
4651                .iter()
4652                .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
4653        {
4654            return Err(self.defer_environment_cleanup(attempt)?);
4655        }
4656        let current = if has_identity {
4657            provider.inspect(attempt)
4658        } else {
4659            // A prepare effect may have succeeded before its identity could be
4660            // journalled. Empty enumeration alone is not proof of absence.
4661            provider.recover(attempt)
4662        };
4663        let current = match current {
4664            Ok(state) => state,
4665            Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
4666        };
4667        if !has_identity && current != EnvironmentState::Missing {
4668            return Err(self.defer_environment_cleanup(attempt)?);
4669        }
4670        if current != EnvironmentState::Missing {
4671            if !matches!(provider.owns(attempt), Ok(true)) {
4672                self.ports
4673                    .events
4674                    .emit(AttemptEvent::QuarantinedOwnedResource {
4675                        attempt: attempt.id,
4676                    });
4677                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4678                return Err(self.defer_environment_cleanup(attempt)?);
4679            }
4680            if matches!(
4681                current,
4682                EnvironmentState::Starting | EnvironmentState::Running
4683            ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
4684            {
4685                return Err(self.defer_environment_cleanup(attempt)?);
4686            }
4687            if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
4688                return Err(self.defer_environment_cleanup(attempt)?);
4689            }
4690            if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
4691                return Err(self.defer_environment_cleanup(attempt)?);
4692            }
4693        }
4694        if provider
4695            .enumerate_owned(self.host_id)
4696            .iter()
4697            .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
4698        {
4699            return Err(self.defer_environment_cleanup(attempt)?);
4700        }
4701        let outcome = attempt
4702            .outcome()
4703            .cloned()
4704            .ok_or(LifecycleError::Transition)?;
4705        // Provider diagnostics are a closed vocabulary and bounded before they
4706        // can enter tracing, even if an adapter returns an unbounded vector.
4707        for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
4708            tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
4709        }
4710        self.preserve_diagnostics(attempt, &outcome)?;
4711        self.scrub_workspace(attempt)?;
4712        self.ports
4713            .packages
4714            .release(attempt.id)
4715            .map_err(LifecycleError::Failed)?;
4716        attempt
4717            .clean_isolated(self.ports.clock.now())
4718            .map_err(|_| LifecycleError::Transition)?;
4719        self.record(attempt)?;
4720        self.ports.events.emit(AttemptEvent::Cleaned {
4721            attempt: attempt.id,
4722            outcome: OutcomeKind::of(&outcome),
4723        });
4724        self.ports
4725            .reconcile_events
4726            .emit(LifecycleEvent::AttemptCleaned {
4727                policy: attempt.policy_id,
4728                attempt: attempt.id,
4729                outcome: OutcomeKind::of(&outcome),
4730            });
4731        Ok(())
4732    }
4733
4734    fn defer_environment_cleanup(
4735        &self,
4736        attempt: &mut RunnerAttempt,
4737    ) -> Result<LifecycleError, LifecycleError> {
4738        attempt
4739            .defer_cleanup(self.ports.clock.now())
4740            .map_err(|_| LifecycleError::Transition)?;
4741        self.record(attempt)?;
4742        Ok(LifecycleError::EnvironmentCleanupDeferred)
4743    }
4744
4745    /// Undo an attempt's placement by the algorithm its journalled workspace
4746    /// kind makes legal (`02-target-architecture.md`, "Cleanup and recovery").
4747    ///
4748    /// The dispatch is on the *journal*, never on what the directory looks like
4749    /// now. A slot whose `_work` was replaced by a junction is still scrubbed as
4750    /// a slot rather than removed whole, and a disposable directory that happens
4751    /// to contain a `_work` still goes whole rather than being spared: the
4752    /// workspace kind is immutable precisely so that the shape of a directory a
4753    /// workflow can write to cannot choose the algorithm applied to it.
4754    fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4755        #[cfg(test)]
4756        {
4757            // The two contamination mutants `f1` drives the security gates with.
4758            // They sit in front of the dispatch rather than inside one arm so
4759            // that a skipped cleanup is equally observable in both modes.
4760            if matches!(
4761                std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
4762                Ok("skip_workspace_cleanup" | "reuse_job_workspace")
4763            ) {
4764                return Ok(());
4765            }
4766        }
4767        match attempt.workspace() {
4768            AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
4769                Ok(()) => Ok(()),
4770                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4771                Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
4772            },
4773            AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
4774        }
4775    }
4776
4777    /// Retain exactly `_work` and prove everything else is gone.
4778    ///
4779    /// The seven ordered checks of `04-security-recovery.md`, "Safe path
4780    /// handling": the journalled root and slot, a surviving policy's agreement,
4781    /// lexical and canonical containment, a literal enumeration, the one-entry
4782    /// allowlist, and a verification pass before the caller releases the package
4783    /// lease and marks the attempt cleaned. Any of them may refuse, and a
4784    /// refusal removes nothing after it.
4785    ///
4786    /// A slot directory that is already gone is not a refusal. There is nothing
4787    /// to scrub and nothing to prove absent, which is the same tolerance the
4788    /// disposable arm has always had for a runtime that vanished under it.
4789    fn scrub_persistent_slot(
4790        &self,
4791        attempt: &RunnerAttempt,
4792        slot: NonZeroU16,
4793    ) -> Result<(), LifecycleError> {
4794        // The policy is read from the journal, never inferred from the
4795        // directory tree, and its absence is legal: `Reconciler` cleans every
4796        // concluded attempt whether or not its policy still exists, and this
4797        // one's root is already journalled on the attempt itself.
4798        let configured = self
4799            .ports
4800            .store
4801            .policy(attempt.policy_id)
4802            .map_err(|_| LifecycleError::Journal)?
4803            .and_then(|policy| match policy.workspace_policy() {
4804                WorkspacePolicy::Persistent { root } => Some(root.clone()),
4805                WorkspacePolicy::Ephemeral => None,
4806            });
4807        let runtime = attempt.runtime_path();
4808        self.quarantine_on_refusal(
4809            attempt,
4810            verify_journalled_slot(runtime, slot, configured.as_ref())
4811                .and_then(|()| slot_is_present(runtime))
4812                .and_then(|present| {
4813                    if present {
4814                        scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
4815                    } else {
4816                        Ok(())
4817                    }
4818                }),
4819        )
4820    }
4821
4822    /// Turn a slot refusal into the error that keeps the attempt uncleaned.
4823    ///
4824    /// The warning is emitted here, at the one place a quarantine is minted, so
4825    /// that both routes out of cleanup carry it: the reconciler's terminal sweep,
4826    /// which receives the error through the launcher port, and
4827    /// [`Self::clean_or_quarantine`], which swallows it to keep the pass alive.
4828    /// Everything logged is either a path this product configured or a constant
4829    /// from this module — see [`SlotQuarantine`] for why that matters.
4830    fn quarantine_on_refusal(
4831        &self,
4832        attempt: &RunnerAttempt,
4833        outcome: Result<(), SlotQuarantine>,
4834    ) -> Result<(), LifecycleError> {
4835        let Err(quarantine) = outcome else {
4836            return Ok(());
4837        };
4838        let detail = quarantine.to_string();
4839        tracing::warn!(
4840            attempt = %attempt.id,
4841            policy = %attempt.policy_id,
4842            slot = attempt.workspace().slot_number(),
4843            refusal = quarantine.refusal.class(),
4844            "{detail}"
4845        );
4846        Err(LifecycleError::SlotQuarantined {
4847            class: quarantine.refusal.class(),
4848            detail,
4849        })
4850    }
4851
4852    fn preserve_diagnostics(
4853        &self,
4854        attempt: &RunnerAttempt,
4855        outcome: &AttemptOutcome,
4856    ) -> Result<(), LifecycleError> {
4857        fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
4858            LifecycleError::Failed(FailureReason::Other(
4859                "diagnostics directory could not be created".into(),
4860            ))
4861        })?;
4862        // Intentionally constructed from typed local facts, not runner output.
4863        // Raw child output can contain workflow secrets and is never copied.
4864        let reason_kind = match outcome {
4865            AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4866            _ => "none",
4867        };
4868        let diagnostic = format!(
4869            "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4870            attempt.id,
4871            attempt.policy_id,
4872            OutcomeKind::of(outcome).as_str(),
4873            reason_kind,
4874        );
4875        fs::write(
4876            self.diagnostics_root.join(format!("{}.log", attempt.id)),
4877            diagnostic,
4878        )
4879        .map_err(|_| {
4880            LifecycleError::Failed(FailureReason::Other(
4881                "redacted diagnostics could not be preserved".into(),
4882            ))
4883        })
4884    }
4885
4886    async fn materialize_with_retry(
4887        &self,
4888        policy: &ScalePolicy,
4889        attempt: &RunnerAttempt,
4890        timings: &mut PackageTimings,
4891    ) -> Result<RunnerVersion, FailureReason> {
4892        let mut issued = 0_u32;
4893        loop {
4894            issued = issued.saturating_add(1);
4895            match self.ports.packages.materialize(attempt, timings).await {
4896                Ok(version) => return Ok(version),
4897                Err(reason)
4898                    if package_failure_is_terminal(&reason)
4899                        || issued >= self.retry.max_attempts.max(1) =>
4900                {
4901                    return Err(reason);
4902                }
4903                Err(reason) => {
4904                    if !self.ports.demand.persists(policy.id).await {
4905                        return Err(reason);
4906                    }
4907                    let delay = self.retry.delay(issued);
4908                    self.ports.events.emit(AttemptEvent::Retry {
4909                        attempt: attempt.id,
4910                        operation: "package_materialization",
4911                        delay,
4912                    });
4913                    self.ports.delay.wait(delay).await;
4914                    if !self.ports.demand.persists(policy.id).await {
4915                        return Err(reason);
4916                    }
4917                }
4918            }
4919        }
4920    }
4921
4922    async fn register_with_retry(
4923        &self,
4924        policy: &ScalePolicy,
4925        attempt: AttemptId,
4926        request: &JitRunnerRequest,
4927    ) -> Result<JitRegistration, LifecycleError> {
4928        let mut issued = 0_u32;
4929        loop {
4930            issued = issued.saturating_add(1);
4931            match self
4932                .ports
4933                .github
4934                .register(&policy.target, request, &self.cancel)
4935                .await
4936            {
4937                Ok(registration) => return Ok(registration),
4938                Err(error) if error.terminal => {
4939                    return Err(LifecycleError::Failed(error.reason));
4940                }
4941                Err(error) => {
4942                    if issued >= self.retry.max_attempts.max(1)
4943                        || !self.ports.demand.persists(policy.id).await
4944                    {
4945                        return Err(LifecycleError::Failed(error.reason));
4946                    }
4947                    let delay = error
4948                        .retry_after
4949                        .unwrap_or_else(|| self.retry.delay(issued));
4950                    self.ports.events.emit(AttemptEvent::Retry {
4951                        attempt,
4952                        operation: "jit_request",
4953                        delay,
4954                    });
4955                    self.ports.delay.wait(delay).await;
4956                    if !self.ports.demand.persists(policy.id).await {
4957                        return Err(LifecycleError::Failed(error.reason));
4958                    }
4959                }
4960            }
4961        }
4962    }
4963
4964    /// Where one attempt's files go, decided while the host allocation lock is
4965    /// held and before anything external happens.
4966    ///
4967    /// The branch is on the *repository's configured* workspace policy, so an
4968    /// organization policy and an ephemeral repository never reach slot
4969    /// selection at all: a persistent policy is unrepresentable for an
4970    /// organization target (D7, refused by `WorkspacePolicy::permitted_for` in
4971    /// both the constructor and the loader), and an ephemeral repository takes
4972    /// the disposable arm that existed before slots did.
4973    fn allocate_workspace(
4974        &self,
4975        policy: &ScalePolicy,
4976        id: AttemptId,
4977    ) -> Result<Placement, LifecycleError> {
4978        let placement = match policy.workspace_policy() {
4979            // Precedence (`02-target-architecture.md`): the repository's
4980            // persistent root is selected *before* the host root, which is why
4981            // this arm is first and why it never makes resolving the host
4982            // default a precondition of its own success.
4983            WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4984            WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4985        };
4986        // Here rather than inside the two arms, so that every path a root can
4987        // accept clears the record and none can be forgotten.
4988        if placement.is_ok() {
4989            self.root_accepted(policy.id);
4990        }
4991        placement
4992    }
4993
4994    /// `Host.runner_root_override`, read from the journal.
4995    ///
4996    /// Separated from [`Self::effective_host_root`] so that the two failures it
4997    /// folds together stay apart: an unreadable or missing host row is a journal
4998    /// problem and is always fatal, while an unresolvable *platform default* is
4999    /// only fatal to a placement that actually needs the host root.
5000    fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
5001        let host = self
5002            .ports
5003            .store
5004            .host(self.host_id)
5005            .map_err(|_| LifecycleError::Journal)?
5006            .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
5007        Ok(host.runner_root_override.clone())
5008    }
5009
5010    /// `Host.runner_root_override`, or the platform default when that resolves;
5011    /// `None` when neither does. Unlike [`Self::effective_host_root`] it records
5012    /// no refusal, for callers to whom an unresolvable root is not a failure.
5013    fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
5014        Ok(self
5015            .configured_host_root()?
5016            .or_else(|| default_runner_root(&self.app_paths).ok()))
5017    }
5018
5019    /// `Host.runner_root_override`, or the platform default standing in for it.
5020    ///
5021    /// Takes the policy because an unresolvable default is a refusal like any
5022    /// other, and the record it leaves is that policy's.
5023    fn effective_host_root(
5024        &self,
5025        policy: &ScalePolicy,
5026    ) -> Result<LocalAbsolutePath, LifecycleError> {
5027        match self.configured_host_root()? {
5028            Some(configured) => Ok(configured),
5029            None => default_runner_root(&self.app_paths).map_err(|error| {
5030                // No root resolved, so there is no path to name but the one the
5031                // platform would have produced.
5032                self.root_refused(policy.id, "the platform default runner root", error)
5033            }),
5034        }
5035    }
5036
5037    /// Turns a runner-root refusal into a failure, and leaves the sentence
5038    /// somewhere an operator can read it.
5039    ///
5040    /// Per policy, because the policies on a host do not share a fate: one with
5041    /// its own persistent root places runners while another on a withheld
5042    /// volume places none, and a host-wide record would have the first clear
5043    /// the second's on the same pass.
5044    ///
5045    /// The recording is best-effort and its failure is deliberately swallowed:
5046    /// this runs on the path that is already failing, and a host that cannot
5047    /// write a diagnostic file must still report the refusal it came to report.
5048    /// `service status` says so itself when the file is unreadable.
5049    fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
5050        let _ = runner_manager_platform::service::record_runner_root_refusal(
5051            &self.app_paths,
5052            &policy.to_string(),
5053            self.ports.clock.now(),
5054            error.kind(),
5055            root,
5056            &error.to_string(),
5057        );
5058        root_failure(error)
5059    }
5060
5061    /// Clears that policy's record, because its root accepted a placement.
5062    ///
5063    /// Called on every successful placement rather than only after a failure:
5064    /// the daemon that recovers is often not the process that failed -- a
5065    /// self-update restarts it -- so "clear it if we wrote it" would leave a
5066    /// stale note on `service status` for as long as the host ran.
5067    fn root_accepted(&self, policy: PolicyId) {
5068        let _ = runner_manager_platform::service::clear_runner_root_refusal(
5069            &self.app_paths,
5070            &policy.to_string(),
5071        );
5072    }
5073
5074    /// D3's disposable placement: a unique child of the effective host root,
5075    /// removed whole on cleanup. `c1`'s behaviour, moved behind the branch.
5076    fn allocate_disposable(
5077        &self,
5078        policy: &ScalePolicy,
5079        id: AttemptId,
5080    ) -> Result<Placement, LifecycleError> {
5081        let effective_root = self.effective_host_root(policy)?;
5082        RootPreflight::new(&self.app_paths)
5083            .check(&RootOwner::Host, &effective_root)
5084            .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
5085        let runtime = effective_root.as_path().join({
5086            #[cfg(test)]
5087            {
5088                if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
5089                    == Ok("reuse_job_workspace")
5090                {
5091                    "mutant-shared-workspace".to_owned()
5092                } else {
5093                    workspace_name(id)
5094                }
5095            }
5096            #[cfg(not(test))]
5097            {
5098                workspace_name(id)
5099            }
5100        });
5101        fs::create_dir_all(&runtime)
5102            .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
5103        Ok(Placement {
5104            runtime,
5105            workspace: AttemptWorkspace::Ephemeral,
5106        })
5107    }
5108
5109    /// D4/D5's persistent placement: the lowest free `sN` under the repository's
5110    /// configured root.
5111    ///
5112    /// Steps 1 to 6 of `02-target-architecture.md`, "Slot allocation", in order;
5113    /// step 7 is the journal write [`Self::record_allocation`] owns. All of them
5114    /// run under the host allocation lock, because [`Self::reserve_attempt`] is
5115    /// reachable only through a `LaunchRequest` and that carries the guard.
5116    ///
5117    /// **The filesystem is never consulted to decide which slots are taken**
5118    /// (invariant 6). The leases come from the journal; the directory is
5119    /// inspected only to decide whether *this* slot is safe to reuse.
5120    fn allocate_persistent_slot(
5121        &self,
5122        policy: &ScalePolicy,
5123        root: &LocalAbsolutePath,
5124    ) -> Result<Placement, LifecycleError> {
5125        // 1-4. The lowest positive slot no uncleaned attempt holds, refused
5126        // above the policy ceiling.
5127        let ceiling = policy.max_capacity().ok_or_else(|| {
5128            LifecycleError::Failed(FailureReason::Other(
5129                "a persistent workspace needs the policy's max_capacity to bound its slots"
5130                    .to_string(),
5131            ))
5132        })?;
5133        let leases = self
5134            .ports
5135            .store
5136            .slot_leases_for_policy(policy.id)
5137            .map_err(|_| LifecycleError::Journal)?;
5138        let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
5139            LifecycleError::Failed(FailureReason::Other(format!(
5140                "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
5141                 not been cleaned, so no slot is free; raise the repository's max capacity, or \
5142                 finish cleaning a concluded attempt",
5143                policy.target
5144            )))
5145        })?;
5146        let workspace = AttemptWorkspace::persistent_slot(slot);
5147        let name = workspace
5148            .slot_directory_name()
5149            .expect("a persistent allocation names its slot directory");
5150
5151        // The operational preflight, for the reasons the host root gets one: a
5152        // root that is remote, unwritable, or overlapping application data has
5153        // to fail before a directory is created rather than after. The host
5154        // root is registered only as something *not* to overlap; a host default
5155        // that cannot be resolved is a host-root problem and does not block a
5156        // repository that configured a root of its own.
5157        //
5158        // Only *that* failure is tolerated. An unreadable host row is a journal
5159        // failure and propagates, because silently continuing would drop the
5160        // overlap check entirely and accept a repository root that sits inside
5161        // the host root — the pair `RootPreflight` exists to refuse.
5162        let host_root = self.resolvable_host_root()?;
5163        let mut preflight = RootPreflight::new(&self.app_paths);
5164        if let Some(host_root) = host_root {
5165            preflight = preflight.against(RootOwner::Host, host_root);
5166        }
5167        let checked = preflight
5168            .check(&RootOwner::Repository(policy.target.to_string()), root)
5169            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
5170        if let Some(leaf) = checked.leaf_to_create() {
5171            fs::create_dir(leaf).map_err(|source| {
5172                LifecycleError::Failed(FailureReason::Other(format!(
5173                    "the persistent workspace root {} could not be created: {source}",
5174                    leaf.display()
5175                )))
5176            })?;
5177        }
5178
5179        // 5-6. `<root>/sN`, contained lexically by construction, then created or
5180        // validated, then contained canonically now that it resolves, and only
5181        // then accepted for reuse.
5182        let slot_path = runner_root::derive_child(root, &name)
5183            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
5184        create_or_validate_slot(slot_path.as_path())?;
5185        runner_root::verify_containment(root, &slot_path)
5186            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
5187        accept_reusable_slot(slot_path.as_path())?;
5188        Ok(Placement {
5189            runtime: slot_path.as_path().to_path_buf(),
5190            workspace,
5191        })
5192    }
5193
5194    /// The first journal write of an attempt, where a duplicate slot lease is
5195    /// still possible and has to be reported as itself.
5196    ///
5197    /// [`Self::record`] flattens every store failure into
5198    /// [`LifecycleError::Journal`], which is right for a state transition and
5199    /// wrong here: the partial unique index
5200    /// `one_uncleaned_persistent_attempt_per_slot` is the final race fence
5201    /// (`04-security-recovery.md`, "two attempts use one slot concurrently"),
5202    /// and an operator who reaches it needs to read that rather than "attempt
5203    /// journal operation failed". Nothing was written, so the caller returns
5204    /// without concluding an attempt that is not in the journal.
5205    fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
5206        match self.ports.store.record_attempt(attempt) {
5207            Ok(()) => {
5208                self.ports.events.emit(AttemptEvent::State {
5209                    attempt: attempt.id,
5210                    state: attempt.state(),
5211                });
5212                Ok(())
5213            }
5214            Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
5215                FailureReason::Other(error.to_string()),
5216            )),
5217            Err(_) => Err(LifecycleError::Journal),
5218        }
5219    }
5220
5221    /// Under the allocation lock: what no other launch may decide at the same
5222    /// time. The rest is [`Self::finish_attempt`], outside it.
5223    fn reserve_attempt(
5224        &self,
5225        policy: &ScalePolicy,
5226        allocation_guard: &AllocationGuard,
5227    ) -> Result<RunnerAttempt, LifecycleError> {
5228        // What a prune leaves costs only disk, and a tree it cannot remove is one
5229        // the launch's install may yet repair: not a reason to refuse the launch.
5230        if let Err(error) = self.prune_under_allocation_lock(allocation_guard) {
5231            tracing::warn!(
5232                event = "package_prune_failed",
5233                reason = failure_reason_kind(&error.reason()),
5234            );
5235        }
5236        self.allocate_attempt(policy)
5237    }
5238
5239    async fn finish_attempt(
5240        &self,
5241        policy: &ScalePolicy,
5242        attempt: RunnerAttempt,
5243        waits: ReservationWaits,
5244    ) -> Result<RunnerAttempt, LifecycleError> {
5245        let id = attempt.id;
5246        let _bringing_up = BroughtUpOnDrop {
5247            attempts: &self.bringing_up,
5248            id,
5249        };
5250        let mut stopwatch = LaunchStopwatch::new(waits);
5251        let result = self.launch_steps(policy, attempt, &mut stopwatch).await;
5252        stopwatch.finish();
5253        self.ports.events.emit(AttemptEvent::Launched {
5254            attempt: id,
5255            started: result.is_ok(),
5256            timings: stopwatch.timings,
5257        });
5258        result
5259    }
5260
5261    /// Place and journal a new attempt: everything before its first external
5262    /// effect, and so before there is anything to time.
5263    fn allocate_attempt(&self, policy: &ScalePolicy) -> Result<RunnerAttempt, LifecycleError> {
5264        if !*self
5265            .recovery_complete
5266            .lock()
5267            .map_err(|_| LifecycleError::Journal)?
5268        {
5269            return Err(LifecycleError::RecoveryIncomplete);
5270        }
5271        let capability = self.ports.processes.probe(policy);
5272        if capability != ProviderCapability::Ready {
5273            return Err(LifecycleError::Failed(capability.refusal()));
5274        }
5275        let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
5276            LifecycleError::Failed(if policy.execution_policy().is_native() {
5277                reason
5278            } else {
5279                safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
5280            })
5281        })?;
5282        if policy.execution_policy().is_native() != resolved.is_none() {
5283            return Err(LifecycleError::Failed(FailureReason::Other(
5284                "provider resolution mismatch".into(),
5285            )));
5286        }
5287        if let Some(resolution) = &resolved {
5288            if resolution.provider_kind == Backend::Auto {
5289                return Err(LifecycleError::Failed(FailureReason::Other(
5290                    "provider resolution mismatch".into(),
5291                )));
5292            }
5293            AttemptExecution::Isolated {
5294                provider_kind: resolution.provider_kind,
5295                environment_id: None,
5296                resolved_image: resolution.image.clone(),
5297                generation: "generation-check".into(),
5298            }
5299            .validate()
5300            .map_err(|_| {
5301                LifecycleError::Failed(FailureReason::Other(
5302                    "resolved image is incompatible".into(),
5303                ))
5304            })?;
5305        }
5306        policy
5307            .routing_labels()
5308            .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
5309        let id = AttemptId::new_random();
5310        let placement = self.allocate_workspace(policy, id)?;
5311        let mut attempt = RunnerAttempt::allocate_in(
5312            id,
5313            policy.id,
5314            placement.runtime,
5315            placement.workspace,
5316            self.ports.clock.now(),
5317        );
5318        if let Some(resolution) = &resolved {
5319            attempt
5320                .allocate_execution(AttemptExecution::Isolated {
5321                    provider_kind: resolution.provider_kind,
5322                    environment_id: None,
5323                    resolved_image: resolution.image.clone(),
5324                    generation: uuid::Uuid::new_v4().to_string(),
5325                })
5326                .map_err(|_| LifecycleError::Transition)?;
5327        }
5328        // This is deliberately the first effect after directory allocation, and
5329        // for a persistent attempt it is also what makes the slot lease durable
5330        // before any package or GitHub effect
5331        // (`02-target-architecture.md`, "Slot allocation", step 7).
5332        self.bringing_up
5333            .lock()
5334            .map_err(|_| LifecycleError::Journal)?
5335            .insert(id);
5336        if let Err(error) = self.record_allocation(&attempt) {
5337            self.bringing_up
5338                .lock()
5339                .map_err(|_| LifecycleError::Journal)?
5340                .remove(&id);
5341            return Err(error);
5342        }
5343        Ok(attempt)
5344    }
5345
5346    async fn launch_steps(
5347        &self,
5348        policy: &ScalePolicy,
5349        mut attempt: RunnerAttempt,
5350        stopwatch: &mut LaunchStopwatch,
5351    ) -> Result<RunnerAttempt, LifecycleError> {
5352        let id = attempt.id;
5353        let isolated = !attempt.execution().is_native();
5354        let Some(labels) = policy.routing_labels() else {
5355            return self.fail_launch(&mut attempt, FailureReason::JitRequestFailed);
5356        };
5357        stopwatch.begin(LaunchStep::Turn);
5358        // Never closed; held through the preparation below.
5359        let turn = self.turns.acquire().await.ok();
5360        stopwatch.begin(LaunchStep::Materialize);
5361        let package_timings = &mut stopwatch.timings.package;
5362        let version = match self
5363            .materialize_with_retry(policy, &attempt, package_timings)
5364            .await
5365        {
5366            Ok(version) => version,
5367            Err(reason) => return self.fail_launch(&mut attempt, reason),
5368        };
5369        self.versions
5370            .lock()
5371            .map_err(|_| LifecycleError::Journal)?
5372            .insert(id, version);
5373
5374        if isolated {
5375            attempt
5376                .begin_prepare(self.ports.clock.now())
5377                .map_err(|_| LifecycleError::Transition)?;
5378            self.record(&attempt)?;
5379        }
5380        stopwatch.begin(LaunchStep::Prepare);
5381        // Off the async thread: a provider may pull an image, and every target
5382        // of this host shares that thread.
5383        let preparing = {
5384            let (processes, attempt, policy) = (
5385                Arc::clone(&self.ports.processes),
5386                attempt.clone(),
5387                policy.clone(),
5388            );
5389            tokio::task::spawn_blocking(move || processes.prepare(&attempt, &policy)).await
5390        };
5391        drop(turn);
5392        let prepared = match preparing.unwrap_or(Err(FailureReason::ProcessStartFailed)) {
5393            Ok(prepared) => prepared,
5394            Err(reason) => {
5395                let safe_reason = if isolated {
5396                    safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
5397                } else {
5398                    reason
5399                };
5400                return self.fail_launch(&mut attempt, safe_reason);
5401            }
5402        };
5403        if isolated {
5404            let Some(identity) = prepared.identity() else {
5405                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5406                    IsolationProviderFailure::OwnershipMismatch,
5407                )));
5408            };
5409            if !identity_matches_intent(self.host_id, &attempt, identity) {
5410                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5411                    IsolationProviderFailure::OwnershipMismatch,
5412                )));
5413            }
5414            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
5415                unreachable!()
5416            };
5417            attempt
5418                .prepared_environment(environment_id.clone())
5419                .map_err(|_| LifecycleError::Transition)?;
5420            attempt
5421                .mark_prepared(self.ports.clock.now())
5422                .map_err(|_| LifecycleError::Transition)?;
5423            self.record(&attempt)?;
5424        }
5425
5426        stopwatch.begin(LaunchStep::Register);
5427        let jit_request =
5428            JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
5429        let registration = match self.register_with_retry(policy, id, &jit_request).await {
5430            Ok(registration) => registration,
5431            Err(error) => return self.fail_launch(&mut attempt, error.reason()),
5432        };
5433        stopwatch.begin(LaunchStep::Spawn);
5434        let runner_id = registration.runner().id;
5435        write_runner_id(attempt.runtime_path(), runner_id)?;
5436        attempt
5437            .jit_received(self.ports.clock.now())
5438            .map_err(|_| LifecycleError::Transition)?;
5439        self.record(&attempt)?;
5440        let config = registration.into_config();
5441        if isolated {
5442            let started = self
5443                .ports
5444                .processes
5445                .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
5446                .map_err(|failure| {
5447                    LifecycleError::Failed(safe_isolation_reason(
5448                        failure.reason,
5449                        IsolationProviderFailure::RuntimeOperationFailed,
5450                    ))
5451                })?;
5452            if !identity_matches_intent(self.host_id, &attempt, &started) {
5453                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5454                    IsolationProviderFailure::OwnershipMismatch,
5455                )));
5456            }
5457            attempt
5458                .started_isolated(self.ports.clock.now())
5459                .map_err(|_| LifecycleError::Transition)?;
5460            self.record(&attempt)?;
5461            return Ok(attempt);
5462        }
5463        let mut issued = 0_u32;
5464        let pid = loop {
5465            issued = issued.saturating_add(1);
5466            match self.ports.processes.start(
5467                prepared.clone(),
5468                &attempt,
5469                OneTimeJitHandoff::new(&config),
5470            ) {
5471                Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
5472                Ok(EnvironmentIdentity::Isolated { .. }) => {
5473                    // No isolated launch sequence is wired in this task. Keep
5474                    // the nonterminal JIT journal as a capacity fence rather
5475                    // than treating an unexpected resource as a native PID.
5476                    return Err(LifecycleError::Failed(FailureReason::Other(
5477                        "unexpected isolated environment identity".into(),
5478                    )));
5479                }
5480                Err(error) => {
5481                    if let Some(pid) = error.live_pid {
5482                        attempt
5483                            .started(pid, self.ports.clock.now())
5484                            .map_err(|_| LifecycleError::Transition)?;
5485                        self.record(&attempt)?;
5486                        return Err(LifecycleError::Failed(error.reason));
5487                    }
5488                    if !error.retryable
5489                        || issued >= self.retry.max_attempts.max(1)
5490                        || !self.ports.demand.persists(policy.id).await
5491                    {
5492                        return self.fail_launch(&mut attempt, error.reason);
5493                    }
5494                    let delay = self.retry.delay(issued);
5495                    self.ports.events.emit(AttemptEvent::Retry {
5496                        attempt: attempt.id,
5497                        operation: "process_start",
5498                        delay,
5499                    });
5500                    self.ports.delay.wait(delay).await;
5501                    if !self.ports.demand.persists(policy.id).await {
5502                        return self.fail_launch(&mut attempt, error.reason);
5503                    }
5504                }
5505            }
5506        };
5507        attempt
5508            .started(pid, self.ports.clock.now())
5509            .map_err(|_| LifecycleError::Transition)?;
5510        self.record(&attempt)?;
5511        Ok(attempt)
5512    }
5513
5514    fn fail_launch<T>(
5515        &self,
5516        attempt: &mut RunnerAttempt,
5517        reason: FailureReason,
5518    ) -> Result<T, LifecycleError> {
5519        self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
5520        Err(LifecycleError::Failed(reason))
5521    }
5522
5523    /// e2's prune guard is invoked only with e1's allocation guard borrowed.
5524    /// The otherwise-unused argument is a compile-time witness of the ordering.
5525    fn prune_under_allocation_lock(&self, guard: &AllocationGuard) -> Result<(), LifecycleError> {
5526        let attempts = self
5527            .ports
5528            .store
5529            .attempts()
5530            .map_err(|_| LifecycleError::Journal)?;
5531        self.ports
5532            .packages
5533            .prune_obsolete_guarded(PruneAuthority::from_launch_request(guard), &attempts)
5534            .map_err(LifecycleError::Failed)?;
5535        #[cfg(target_os = "macos")]
5536        self.prune_abandoned_seeds(&attempts);
5537        Ok(())
5538    }
5539
5540    /// Remove the package seed from every runner root this host once placed a
5541    /// runtime in and no longer does: a changed host root override, a removed
5542    /// persistent policy, or one that moved its workspace.
5543    ///
5544    /// The roots ever used are the parents of the journal's runtime paths; the
5545    /// roots in use are the effective host root and every persistent policy's
5546    /// root. When the effective host root cannot be resolved nothing is
5547    /// removed, because a seed still in use cannot then be told apart. No
5548    /// launch clones from a seed under a root no policy uses, and it is
5549    /// best-effort: a seed left behind costs disk, never a launch.
5550    #[cfg(target_os = "macos")]
5551    fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
5552        let Ok(policies) = self.ports.store.policies() else {
5553            return;
5554        };
5555        let Ok(Some(host_root)) = self.resolvable_host_root() else {
5556            return;
5557        };
5558        let in_use: BTreeSet<PathBuf> = policies
5559            .iter()
5560            .filter_map(|policy| match policy.workspace_policy() {
5561                WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
5562                WorkspacePolicy::Ephemeral => None,
5563            })
5564            .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
5565            .collect();
5566        // Distinct parents first: the journal keeps every attempt ever made.
5567        let used: BTreeSet<&Path> = attempts
5568            .iter()
5569            .filter_map(|attempt| attempt.runtime_path().parent())
5570            .collect();
5571        for root in used.into_iter().map(canonical_or_raw) {
5572            if !in_use.contains(&root) {
5573                let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
5574            }
5575        }
5576    }
5577}
5578
5579#[cfg(target_os = "macos")]
5580fn canonical_or_raw(path: &Path) -> PathBuf {
5581    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
5582}
5583
5584#[async_trait]
5585impl RunnerLauncher for LifecycleLauncher {
5586    async fn host_capacity(
5587        &self,
5588        host: &runner_manager_domain::model::Host,
5589    ) -> Result<std::num::NonZeroU16, crate::reconcile::HostCapacityError> {
5590        self.ports
5591            .store
5592            .host(host.id)
5593            .map_err(|_| crate::reconcile::HostCapacityError::JournalUnreadable)?
5594            .map(|current| current.host_capacity)
5595            .ok_or(crate::reconcile::HostCapacityError::MissingHost)
5596    }
5597
5598    async fn supervise(
5599        &self,
5600        policy: &ScalePolicy,
5601    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
5602        LifecycleLauncher::supervise(self, policy)
5603            .await
5604            .map_err(|error| LaunchFailure::new(error.reason()))
5605    }
5606
5607    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
5608        self.ports.store.attempts().map_err(|_| {
5609            LaunchFailure::new(FailureReason::Other(
5610                "attempt journal could not be read".into(),
5611            ))
5612        })
5613    }
5614
5615    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
5616        self.reserve_attempt(request.policy, request.allocation_guard)
5617            .map_err(|error| LaunchFailure::new(error.reason()))
5618    }
5619
5620    async fn finish_launch(
5621        &self,
5622        policy: &ScalePolicy,
5623        attempt: RunnerAttempt,
5624        waits: ReservationWaits,
5625    ) -> Result<RunnerAttempt, LaunchFailure> {
5626        self.finish_attempt(policy, attempt, waits)
5627            .await
5628            .map_err(|error| LaunchFailure::new(error.reason()))
5629    }
5630
5631    async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
5632        let mut attempt = self
5633            .ports
5634            .store
5635            .attempt(id)
5636            .map_err(|_| {
5637                LaunchFailure::new(FailureReason::Other(
5638                    "attempt journal could not be read".into(),
5639                ))
5640            })?
5641            .ok_or_else(|| {
5642                LaunchFailure::new(FailureReason::Other(
5643                    "attempt disappeared from the journal".into(),
5644                ))
5645            })?;
5646        self.clean_attempt(&mut attempt)
5647            .map_err(|error| LaunchFailure::new(error.reason()))?;
5648        self.cleanup_retries().succeeded(id);
5649        Ok(())
5650    }
5651}
5652
5653/// The name an attempt's runner registers with GitHub, which a
5654/// `workflow_job` webhook's `runner_name` repeats.
5655#[must_use]
5656pub fn runner_name(attempt: AttemptId) -> String {
5657    format!("runner-manager-{attempt}")
5658}
5659
5660/// How long reading a runner's diagnostics may take.
5661const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
5662
5663/// What a native runner's own diagnostics say about whether it ran a job.
5664///
5665/// `Runner.Listener` writes `_diag/Runner_*.log` when it starts, and starts
5666/// `Runner.Worker` -- which writes `_diag/Worker_*.log` -- only for a job it has
5667/// acquired. So a worker log is proof a job ran, and a listener log without one
5668/// is proof none did. With neither, the directory is not where the listener
5669/// wrote, and nothing is concluded from it. `_diag` is scrubbed with the rest of
5670/// the runtime after every attempt, persistent slots included, so a log found
5671/// here belongs to this attempt.
5672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5673enum JobEvidence {
5674    RanAJob,
5675    NoJob,
5676    Unknown,
5677}
5678
5679impl JobEvidence {
5680    fn of(runtime: &Path) -> Self {
5681        let diag = runtime.join("_diag");
5682        // Bounded: the daemon's loop runs on one thread, and a runtime on a
5683        // volume that does not answer (a hung disk, a pending macOS question)
5684        // must not stall it for longer than this.
5685        if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
5686            != runner_manager_platform::host_fitness::Responsiveness::Responds
5687        {
5688            return Self::Unknown;
5689        }
5690        let Ok(entries) = fs::read_dir(diag) else {
5691            return Self::Unknown;
5692        };
5693        let mut listener = false;
5694        for entry in entries.flatten() {
5695            let name = entry.file_name();
5696            let name = name.to_string_lossy();
5697            if !name.ends_with(".log") {
5698                continue;
5699            }
5700            if name.starts_with("Worker_") {
5701                return Self::RanAJob;
5702            }
5703            listener |= name.starts_with("Runner_");
5704        }
5705        if listener { Self::NoJob } else { Self::Unknown }
5706    }
5707}
5708
5709/// The attempt's GitHub runner id: journalled, observed, or from the sidecar
5710/// written at registration.
5711fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
5712    attempt
5713        .github_runner_id()
5714        .or(observed)
5715        .or_else(|| read_runner_id(attempt.runtime_path()))
5716}
5717
5718fn read_runner_id(runtime: &Path) -> Option<u64> {
5719    fs::read_to_string(runtime.join(RUNNER_ID_FILE))
5720        .ok()?
5721        .trim()
5722        .parse()
5723        .ok()
5724}
5725
5726fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
5727    let target = runtime.join(RUNNER_ID_FILE);
5728    if let Some(existing) = read_runner_id(runtime) {
5729        return (existing == runner_id)
5730            .then_some(())
5731            .ok_or(LifecycleError::Journal);
5732    }
5733    let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
5734    write_durable_file(&temporary, runner_id.to_string().as_bytes())
5735        .map_err(|_| LifecycleError::Journal)?;
5736    match fs::rename(&temporary, &target) {
5737        Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
5738        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
5739            let _ = fs::remove_file(&temporary);
5740            (read_runner_id(runtime) == Some(runner_id))
5741                .then_some(())
5742                .ok_or(LifecycleError::Journal)
5743        }
5744        Err(_) => {
5745            let _ = fs::remove_file(&temporary);
5746            Err(LifecycleError::Journal)
5747        }
5748    }
5749}
5750
5751fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
5752    let mut options = fs::OpenOptions::new();
5753    options.create(true).truncate(true).write(true);
5754    // Never through a link: the attempt directory belongs to the runner's
5755    // account when that is not this process's, and a link a job left at this
5756    // name would otherwise turn the write onto any file this process can reach.
5757    #[cfg(unix)]
5758    std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NOFOLLOW);
5759    let mut file = options.open(path)?;
5760    file.write_all(bytes)?;
5761    file.sync_all()?;
5762    let parent = path.parent().ok_or_else(|| {
5763        std::io::Error::new(
5764            std::io::ErrorKind::InvalidInput,
5765            "file has no parent directory",
5766        )
5767    })?;
5768    sync_directory(parent)
5769}
5770
5771#[cfg(unix)]
5772fn sync_directory(path: &Path) -> std::io::Result<()> {
5773    fs::File::open(path)?.sync_all()
5774}
5775
5776#[cfg(windows)]
5777fn sync_directory(path: &Path) -> std::io::Result<()> {
5778    use std::os::windows::fs::OpenOptionsExt;
5779
5780    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
5781    const FILE_SHARE_ALL: u32 = 0x0000_0007;
5782    const GENERIC_WRITE: u32 = 0x4000_0000;
5783    fs::OpenOptions::new()
5784        .access_mode(GENERIC_WRITE)
5785        .share_mode(FILE_SHARE_ALL)
5786        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
5787        .open(path)?
5788        .sync_all()
5789}
5790
5791#[cfg(test)]
5792mod tests {
5793    use super::*;
5794
5795    /// A native attempt whose directory, `name` under `root`, already exists.
5796    #[cfg(unix)]
5797    fn attempt_owned_in(root: &Path, name: &str) -> RunnerAttempt {
5798        let runtime = root.join(name);
5799        fs::create_dir_all(&runtime).unwrap();
5800        RunnerAttempt::allocate(
5801            AttemptId::new_random(),
5802            PolicyId::from_u128(0xfeed),
5803            &runtime,
5804            FakeClock::default().now(),
5805        )
5806    }
5807
5808    #[cfg(unix)]
5809    #[test]
5810    fn no_two_live_attempts_share_a_runner_account() {
5811        use std::os::unix::fs::MetadataExt as _;
5812
5813        // This test cannot hand anything over, so every directory it makes is
5814        // its own: its uid plays the account that owns them all.
5815        let root = tempfile::tempdir().unwrap();
5816        let me = fs::metadata(root.path()).unwrap();
5817        let mine = RunnerAccount {
5818            uid: me.uid(),
5819            gid: me.gid(),
5820        };
5821        let other = RunnerAccount {
5822            uid: me.uid().wrapping_add(1),
5823            gid: me.gid(),
5824        };
5825        let processes = NativeProcesses {
5826            runner: RunnerIdentity::Pool(RunnerPool {
5827                gid: me.gid(),
5828                accounts: vec![other, mine],
5829                legacy: None,
5830            }),
5831            ..NativeProcesses::default()
5832        };
5833
5834        let first = attempt_owned_in(root.path(), "a");
5835        assert_eq!(
5836            processes.account_for(&first).unwrap(),
5837            Some(mine),
5838            "an attempt an account already owns keeps it"
5839        );
5840        assert_eq!(processes.account_for(&first).unwrap(), Some(mine));
5841        let second = attempt_owned_in(root.path(), "b");
5842        assert_eq!(
5843            processes.account_for(&second).unwrap(),
5844            Some(other),
5845            "the account a live attempt holds is not given to another"
5846        );
5847        let third = attempt_owned_in(root.path(), "c");
5848        assert!(
5849            processes.account_for(&third).is_err(),
5850            "with every account busy no runner starts on a shared one"
5851        );
5852
5853        processes.destroy(&first).unwrap();
5854        fs::remove_dir(first.runtime_path()).unwrap();
5855        assert_eq!(processes.account_for(&third).unwrap(), Some(mine));
5856    }
5857
5858    #[cfg(unix)]
5859    #[test]
5860    fn a_pool_with_only_the_earlier_single_account_shares_it_as_before() {
5861        use std::os::unix::fs::MetadataExt as _;
5862
5863        let root = tempfile::tempdir().unwrap();
5864        let me = fs::metadata(root.path()).unwrap();
5865        let legacy = RunnerAccount {
5866            uid: me.uid(),
5867            gid: me.gid(),
5868        };
5869        let processes = NativeProcesses {
5870            runner: RunnerIdentity::Pool(RunnerPool {
5871                gid: me.gid(),
5872                accounts: Vec::new(),
5873                legacy: Some(legacy),
5874            }),
5875            ..NativeProcesses::default()
5876        };
5877        for name in ["a", "b"] {
5878            assert_eq!(
5879                processes
5880                    .account_for(&attempt_owned_in(root.path(), name))
5881                    .unwrap(),
5882                Some(legacy)
5883            );
5884        }
5885    }
5886
5887    #[test]
5888    fn a_confined_root_without_the_pool_starts_no_runner() {
5889        let processes = NativeProcesses {
5890            runner: RunnerIdentity::Missing("no pool".into()),
5891            ..NativeProcesses::default()
5892        };
5893        let attempt = RunnerAttempt::allocate(
5894            AttemptId::new_random(),
5895            PolicyId::from_u128(0xfeed),
5896            Path::new("/nonexistent/attempt"),
5897            FakeClock::default().now(),
5898        );
5899        assert_eq!(
5900            processes.account_for(&attempt),
5901            Err(FailureReason::Other("no pool".into()))
5902        );
5903    }
5904    use std::collections::BTreeSet;
5905    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5906
5907    use crate::reconcile::{AllocationLock, InProcessAllocationLock};
5908    use runner_manager_domain::model::{Elapsed, TargetScope};
5909    use runner_manager_domain::store::SqliteStore;
5910    use runner_manager_github::jit::JitRunner;
5911    use runner_manager_testkit::clock::FakeClock;
5912    use runner_manager_testkit::fixtures;
5913
5914    #[test]
5915    fn production_provider_set_routes_every_implemented_isolated_backend() {
5916        let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
5917        assert!(providers.isolated_provider(Backend::Auto).is_some());
5918        assert!(providers.isolated_provider(Backend::Oci).is_some());
5919        assert!(
5920            providers
5921                .isolated_provider(Backend::WindowsHyperVContainer)
5922                .is_some()
5923        );
5924        assert!(
5925            providers
5926                .isolated_provider(Backend::VirtualMachine)
5927                .is_none()
5928        );
5929    }
5930
5931    /// One event's fields, in the order they were recorded.
5932    type CapturedFields = Vec<(String, String)>;
5933
5934    /// Keeps the `(name, value)` fields of every `tracing` event emitted while
5935    /// it is installed.
5936    ///
5937    /// The names are kept and not just the rendered line, so a test can hold the
5938    /// event to `crate::logging`'s two rules -- the field allow-list and the
5939    /// value scrub -- instead of asserting that some string was passed to a
5940    /// macro.
5941    #[derive(Clone, Default)]
5942    struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
5943
5944    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
5945        fn on_event(
5946            &self,
5947            event: &tracing::Event<'_>,
5948            _context: tracing_subscriber::layer::Context<'_, S>,
5949        ) {
5950            struct Collect(Vec<(String, String)>);
5951            impl tracing::field::Visit for Collect {
5952                fn record_debug(
5953                    &mut self,
5954                    field: &tracing::field::Field,
5955                    value: &dyn std::fmt::Debug,
5956                ) {
5957                    // `{value:?}` on a `&str` field would keep the quotes, and
5958                    // the redaction rules are about the value, not its literal.
5959                    self.0.push((
5960                        field.name().to_owned(),
5961                        format!("{value:?}").trim_matches('"').to_owned(),
5962                    ));
5963                }
5964            }
5965            let mut collected = Collect(Vec::new());
5966            event.record(&mut collected);
5967            self.0
5968                .lock()
5969                .expect("the capture mutex is not poisoned")
5970                .push(collected.0);
5971        }
5972    }
5973
5974    /// The regression behind a three-hour outage that showed nothing an operator
5975    /// could act on.
5976    ///
5977    /// A root the daemon cannot use refuses the launch *before*
5978    /// `record_allocation`, so no attempt row is ever written: `b2` has nothing
5979    /// to carry the failure on and `g2` has nothing to show it from. The
5980    /// lifecycle event keeps only the variant -- `failure_reason_kind` allows no
5981    /// free text on an event -- so the whole failure read
5982    /// `runner_start_failed reason=other`, once per poll, for hours.
5983    ///
5984    /// The assertion is deliberately made against the **production redaction
5985    /// rules** and not merely against what was emitted. An earlier attempt at
5986    /// this fix logged the rendered error on a `detail` field and passed a test
5987    /// exactly like this one, while shipping `detail="[redacted]"`: the field
5988    /// was not allow-listed, and had it been, the value is mostly paths and
5989    /// would have been scrubbed to `[path]`. A test that does not ask
5990    /// `crate::logging` what survives is a test that proves nothing.
5991    #[test]
5992    fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
5993        use runner_manager_platform::logging;
5994        use tracing_subscriber::layer::SubscriberExt as _;
5995
5996        let captured = CapturedEvents::default();
5997        let error = RunnerRootError::DeniedByPrivacyPolicy {
5998            requested: PathBuf::from("/Volumes/NVME/runners"),
5999            refused: PathBuf::from("/Volumes/NVME"),
6000            remediation: RootOwner::Host.remediation(),
6001        };
6002        let kind = error.kind();
6003
6004        let failure = tracing::subscriber::with_default(
6005            tracing_subscriber::registry().with(captured.clone()),
6006            || root_failure(error),
6007        );
6008
6009        // The reason still carries the whole sentence. Nothing on *this* path
6010        // renders it -- there is no attempt row, and the event carries only the
6011        // variant -- so this is a guard against a refactor that drops the detail
6012        // before some future surface can show it, and not a claim that one does.
6013        assert!(
6014            matches!(
6015                &failure,
6016                LifecycleError::Failed(FailureReason::Other(detail))
6017                    if detail.contains("/Volumes/NVME/runners")
6018                        && detail.contains("Full Disk Access")
6019            ),
6020            "the reason must still carry the detail: {failure:?}"
6021        );
6022
6023        let events = captured
6024            .0
6025            .lock()
6026            .expect("the capture mutex is not poisoned")
6027            .clone();
6028        let event = events
6029            .iter()
6030            .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
6031            .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
6032
6033        // Held to the rules the real sink applies, by name and by value. An
6034        // earlier fix put the rendered error on an unlisted `detail` field and
6035        // shipped `[redacted]`; asserting only that something was emitted would
6036        // have passed then too.
6037        for (name, value) in event {
6038            assert!(
6039                logging::is_field_allowed(name),
6040                "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
6041                logging::REDACTION
6042            );
6043            assert_eq!(
6044                &logging::redact(value),
6045                value,
6046                "`{name}` does not survive value-shape scrubbing: {event:?}"
6047            );
6048        }
6049    }
6050
6051    /// A slot number, for the tests that name one.
6052    fn nz(slot: u16) -> NonZeroU16 {
6053        NonZeroU16::new(slot).expect("a positive slot")
6054    }
6055
6056    const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
6057
6058    #[derive(Debug, Default)]
6059    struct FakeGithubLifecycle {
6060        registration_failures: Mutex<VecDeque<bool>>,
6061        observations: Mutex<VecDeque<LifecycleGithubObservation>>,
6062        registrations: AtomicUsize,
6063        remaining_runners: AtomicUsize,
6064        /// Every runner id `deregister` was asked to remove, in order. A count
6065        /// would not do: the assertions worth making are that the *right*
6066        /// registration was deleted and that it was deleted once.
6067        deregistrations: Mutex<Vec<u64>>,
6068        /// Set to make `deregister` answer `false`, standing for a GitHub that
6069        /// could not be reached at the moment the attempt concluded.
6070        deregistration_fails: AtomicBool,
6071        /// The journal to read *during* a registration, for the ordering
6072        /// assertion `02-target-architecture.md` makes: the slot lease is
6073        /// written "before package or GitHub effects". Reading it afterwards
6074        /// would pass even if the write happened second.
6075        journal: Mutex<Option<Arc<SqliteStore>>>,
6076        /// One entry per registration, in order.
6077        registration_facts: Mutex<Vec<RegistrationFact>>,
6078    }
6079
6080    /// What one JIT registration saw of the world at the moment it was issued.
6081    ///
6082    /// `runner_name` is what ties the other two fields to *one* attempt: with
6083    /// two allocators racing, "some slot was journalled" is a much weaker claim
6084    /// than "the slot this very request belongs to was journalled", and only the
6085    /// name distinguishes them.
6086    #[derive(Debug, Clone)]
6087    struct RegistrationFact {
6088        /// The persistent slots the journal already held.
6089        leased_slots: Vec<u16>,
6090        /// The `work_folder` the request carried.
6091        work_folder: String,
6092        /// The runner name the request carried, i.e. [`runner_name`] of the
6093        /// registering attempt.
6094        runner_name: String,
6095        journal_states: Vec<AttemptState>,
6096    }
6097
6098    impl FakeGithubLifecycle {
6099        fn fail(mut self, terminal: bool) -> Self {
6100            self.registration_failures
6101                .get_mut()
6102                .expect("unpoisoned")
6103                .push_back(terminal);
6104            self
6105        }
6106
6107        fn watch_journal(&self, store: Arc<SqliteStore>) {
6108            *self.journal.lock().unwrap() = Some(store);
6109        }
6110
6111        fn registration_facts(&self) -> Vec<RegistrationFact> {
6112            self.registration_facts.lock().unwrap().clone()
6113        }
6114
6115        fn observe(&self, observation: GithubRunnerObservation) {
6116            let observation = match observation {
6117                GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
6118                GithubRunnerObservation::NotRegistered => {
6119                    LifecycleGithubObservation::not_registered()
6120                }
6121                GithubRunnerObservation::Registered { busy } => {
6122                    LifecycleGithubObservation::registered(73, busy)
6123                }
6124            };
6125            self.observations.lock().unwrap().push_back(observation);
6126        }
6127    }
6128
6129    #[async_trait]
6130    impl LifecycleGithub for FakeGithubLifecycle {
6131        async fn register(
6132            &self,
6133            _target: &ScaleTarget,
6134            request: &JitRunnerRequest,
6135            _cancel: &CancelToken,
6136        ) -> Result<JitRegistration, JitRequestFailure> {
6137            self.registrations.fetch_add(1, Ordering::SeqCst);
6138            if let Some(store) = self.journal.lock().unwrap().as_ref() {
6139                let attempts = store.attempts().expect("the journal is readable");
6140                let slots = attempts
6141                    .iter()
6142                    .filter_map(|attempt| attempt.workspace().slot_number())
6143                    .collect();
6144                self.registration_facts
6145                    .lock()
6146                    .unwrap()
6147                    .push(RegistrationFact {
6148                        leased_slots: slots,
6149                        work_folder: request.work_folder().to_string(),
6150                        runner_name: request.name().to_string(),
6151                        journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
6152                    });
6153            }
6154            if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
6155                return Err(JitRequestFailure {
6156                    terminal,
6157                    reason: if terminal {
6158                        FailureReason::Other("GitHub refused JIT registration with 403".into())
6159                    } else {
6160                        FailureReason::JitRequestFailed
6161                    },
6162                    retry_after: None,
6163                });
6164            }
6165            self.remaining_runners.store(1, Ordering::SeqCst);
6166            Ok(JitRegistration::new(
6167                EncodedJitConfig::new(JIT),
6168                JitRunner {
6169                    id: 73,
6170                    name: request.name().to_string(),
6171                    os: "windows".into(),
6172                    status: "offline".into(),
6173                    busy: false,
6174                    runner_group_id: Some(1),
6175                    labels: request.labels().to_vec(),
6176                },
6177            ))
6178        }
6179
6180        async fn observe(
6181            &self,
6182            _target: &ScaleTarget,
6183            _attempt: AttemptId,
6184            _cancel: &CancelToken,
6185        ) -> LifecycleGithubObservation {
6186            let observation = self
6187                .observations
6188                .lock()
6189                .unwrap()
6190                .pop_front()
6191                .unwrap_or(LifecycleGithubObservation::not_registered());
6192            if observation.status == GithubRunnerObservation::NotRegistered {
6193                self.remaining_runners.store(0, Ordering::SeqCst);
6194            }
6195            observation
6196        }
6197
6198        async fn deregister(
6199            &self,
6200            _target: &ScaleTarget,
6201            runner_id: u64,
6202            _cancel: &CancelToken,
6203        ) -> bool {
6204            self.deregistrations.lock().unwrap().push(runner_id);
6205            if self.deregistration_fails.load(Ordering::SeqCst) {
6206                return false;
6207            }
6208            self.remaining_runners.store(0, Ordering::SeqCst);
6209            true
6210        }
6211    }
6212
6213    #[derive(Debug)]
6214    struct FakePackages {
6215        version: RunnerVersion,
6216        leases: Mutex<BTreeSet<AttemptId>>,
6217        materializations: AtomicUsize,
6218        materializing: AtomicUsize,
6219        peak_materializing: AtomicUsize,
6220        materialization_failures: AtomicUsize,
6221        release_failures: AtomicUsize,
6222        releases: AtomicUsize,
6223        prunes: AtomicUsize,
6224    }
6225
6226    impl Default for FakePackages {
6227        fn default() -> Self {
6228            Self {
6229                version: RunnerVersion::parse("2.330.0").unwrap(),
6230                leases: Mutex::new(BTreeSet::new()),
6231                materializations: AtomicUsize::new(0),
6232                materializing: AtomicUsize::new(0),
6233                peak_materializing: AtomicUsize::new(0),
6234                materialization_failures: AtomicUsize::new(0),
6235                release_failures: AtomicUsize::new(0),
6236                releases: AtomicUsize::new(0),
6237                prunes: AtomicUsize::new(0),
6238            }
6239        }
6240    }
6241
6242    impl FakePackages {
6243        fn fail_materializations(&self, count: usize) {
6244            self.materialization_failures.store(count, Ordering::SeqCst);
6245        }
6246
6247        fn fail_releases(&self, count: usize) {
6248            self.release_failures.store(count, Ordering::SeqCst);
6249        }
6250    }
6251
6252    #[async_trait]
6253    impl RuntimePackages for FakePackages {
6254        async fn materialize(
6255            &self,
6256            attempt: &RunnerAttempt,
6257            _timings: &mut PackageTimings,
6258        ) -> Result<RunnerVersion, FailureReason> {
6259            self.materializations.fetch_add(1, Ordering::SeqCst);
6260            let now = self.materializing.fetch_add(1, Ordering::SeqCst) + 1;
6261            self.peak_materializing.fetch_max(now, Ordering::SeqCst);
6262            for _ in 0..5 {
6263                tokio::task::yield_now().await;
6264            }
6265            self.materializing.fetch_sub(1, Ordering::SeqCst);
6266            if self
6267                .materialization_failures
6268                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
6269                    if left > 0 { Some(left - 1) } else { None }
6270                })
6271                .is_ok()
6272            {
6273                return Err(FailureReason::Other(
6274                    "runner package materialization failed transiently".into(),
6275                ));
6276            }
6277            fs::create_dir_all(attempt.runtime_path()).unwrap();
6278            fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
6279            self.leases.lock().unwrap().insert(attempt.id);
6280            Ok(self.version.clone())
6281        }
6282
6283        fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
6284            if self
6285                .release_failures
6286                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
6287                    if left > 0 { Some(left - 1) } else { None }
6288                })
6289                .is_ok()
6290            {
6291                return Err(FailureReason::Other(
6292                    "runner package lease could not be released".into(),
6293                ));
6294            }
6295            self.leases.lock().unwrap().remove(&attempt);
6296            self.releases.fetch_add(1, Ordering::SeqCst);
6297            Ok(())
6298        }
6299
6300        fn prune_obsolete_guarded(
6301            &self,
6302            _authority: PruneAuthority<'_>,
6303            _attempts: &[RunnerAttempt],
6304        ) -> Result<(), FailureReason> {
6305            self.prunes.fetch_add(1, Ordering::SeqCst);
6306            Ok(())
6307        }
6308    }
6309
6310    #[derive(Debug, Default)]
6311    struct FakeProcesses {
6312        alive: AtomicBool,
6313        completed_successfully: AtomicBool,
6314        spawns: AtomicUsize,
6315        spawn_failures: AtomicUsize,
6316        live_spawn_failure: AtomicBool,
6317        terminations: AtomicUsize,
6318        intent: AtomicBool,
6319        intent_failure: AtomicBool,
6320        actions: Mutex<Vec<&'static str>>,
6321        saw_secret: AtomicBool,
6322    }
6323
6324    impl FakeProcesses {
6325        fn fail_spawns(&self, count: usize) {
6326            self.spawn_failures.store(count, Ordering::SeqCst);
6327        }
6328
6329        fn fail_spawn_with_live_child(&self) {
6330            self.live_spawn_failure.store(true, Ordering::SeqCst);
6331        }
6332
6333        fn set_alive(&self, alive: bool) {
6334            self.alive.store(alive, Ordering::SeqCst);
6335        }
6336
6337        fn finish_successfully(&self) {
6338            self.completed_successfully.store(true, Ordering::SeqCst);
6339            self.alive.store(false, Ordering::SeqCst);
6340        }
6341
6342        fn fail_intent(&self) {
6343            self.intent_failure.store(true, Ordering::SeqCst);
6344        }
6345    }
6346
6347    impl ProcessSupervisor for FakeProcesses {
6348        fn spawn(
6349            &self,
6350            attempt: &RunnerAttempt,
6351            config: &EncodedJitConfig,
6352        ) -> Result<u32, ProcessStartFailure> {
6353            self.spawns.fetch_add(1, Ordering::SeqCst);
6354            // Model the production handoff on both paths: the sensitive file is
6355            // scoped to this call and absent when it returns.
6356            let handoff = RestrictiveHandoff::create(
6357                attempt.runtime_path(),
6358                SecretString::from(config.expose().to_owned()),
6359            )
6360            .unwrap();
6361            self.saw_secret
6362                .store(config.expose() == JIT, Ordering::SeqCst);
6363            let handoff_path = handoff.path().to_path_buf();
6364            let failing = self
6365                .spawn_failures
6366                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
6367                    if left > 0 { Some(left - 1) } else { None }
6368                })
6369                .is_ok();
6370            drop(handoff);
6371            assert!(!handoff_path.exists(), "handoff must be absent on return");
6372            if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
6373                self.alive.store(true, Ordering::SeqCst);
6374                return Err(ProcessStartFailure::after_spawn_live(4242));
6375            }
6376            if failing {
6377                return Err(ProcessStartFailure::before_spawn(
6378                    FailureReason::ProcessStartFailed,
6379                ));
6380            }
6381            self.alive.store(true, Ordering::SeqCst);
6382            Ok(4242)
6383        }
6384
6385        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6386            self.actions.lock().unwrap().push("observe_process");
6387            Ok(self.alive.load(Ordering::SeqCst))
6388        }
6389
6390        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
6391            Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
6392        }
6393
6394        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
6395            self.completed_successfully.load(Ordering::SeqCst)
6396        }
6397
6398        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6399            self.actions.lock().unwrap().push("terminate_intent");
6400            if self.intent_failure.load(Ordering::SeqCst) {
6401                return Err(FailureReason::Other(
6402                    "terminate intent directory sync failed".into(),
6403                ));
6404            }
6405            self.intent.store(true, Ordering::SeqCst);
6406            Ok(())
6407        }
6408
6409        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
6410            self.intent.load(Ordering::SeqCst)
6411        }
6412
6413        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6414            assert!(
6415                self.intent.load(Ordering::SeqCst),
6416                "the durable intent must exist before signalling"
6417            );
6418            self.actions.lock().unwrap().push("terminate");
6419            self.terminations.fetch_add(1, Ordering::SeqCst);
6420            self.alive.store(false, Ordering::SeqCst);
6421            Ok(())
6422        }
6423    }
6424
6425    #[derive(Debug, Default)]
6426    struct FakeIsolatedProvider {
6427        resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
6428        extra_resources: Mutex<Vec<EnvironmentIdentity>>,
6429        unsupported_host: AtomicBool,
6430        permission_denied: AtomicBool,
6431        incompatible_image: AtomicBool,
6432        prepare_failure: AtomicBool,
6433        recovery_unavailable: AtomicBool,
6434        destroy_deferred: AtomicBool,
6435        handoffs: AtomicUsize,
6436        completed_successfully: AtomicBool,
6437        native_calls: AtomicUsize,
6438        actions: Mutex<Vec<&'static str>>,
6439    }
6440
6441    impl FakeIsolatedProvider {
6442        fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
6443            let AttemptExecution::Isolated {
6444                provider_kind,
6445                resolved_image,
6446                generation,
6447                ..
6448            } = attempt.execution()
6449            else {
6450                panic!("isolated intent required")
6451            };
6452            EnvironmentIdentity::Isolated {
6453                host,
6454                attempt: attempt.id,
6455                provider_kind: *provider_kind,
6456                environment_id: format!("environment-{}", attempt.id),
6457                resolved_image: resolved_image.clone(),
6458                generation: generation.clone(),
6459            }
6460        }
6461
6462        fn resource_count(&self) -> usize {
6463            self.resources.lock().unwrap().len()
6464        }
6465    }
6466
6467    impl ExecutionProvider for FakeIsolatedProvider {
6468        fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
6469            if self.unsupported_host.load(Ordering::SeqCst) {
6470                ProviderCapability::Unsupported
6471            } else if self.permission_denied.load(Ordering::SeqCst) {
6472                ProviderCapability::PermissionDenied
6473            } else if policy.execution_policy().is_native() {
6474                ProviderCapability::Unsupported
6475            } else {
6476                ProviderCapability::Ready
6477            }
6478        }
6479
6480        fn resolve(
6481            &self,
6482            policy: &ScalePolicy,
6483        ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
6484            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6485                policy.execution_policy()
6486            else {
6487                return Err(FailureReason::Other("isolated policy required".into()));
6488            };
6489            Ok(Some(ResolvedEnvironment {
6490                provider_kind: Backend::Oci,
6491                image: if self.incompatible_image.load(Ordering::SeqCst) {
6492                    ImageReference::new("vm-version:wrong-provider")
6493                        .expect("valid but incompatible")
6494                } else {
6495                    image.clone()
6496                },
6497            }))
6498        }
6499
6500        fn prepare(
6501            &self,
6502            attempt: &RunnerAttempt,
6503            policy: &ScalePolicy,
6504        ) -> Result<PreparedEnvironment, FailureReason> {
6505            assert_eq!(
6506                attempt.state(),
6507                AttemptState::Preparing,
6508                "intent must be durable before provider effect"
6509            );
6510            self.actions.lock().unwrap().push("prepare");
6511            let identity = Self::identity(attempt, policy.host_id);
6512            self.resources
6513                .lock()
6514                .unwrap()
6515                .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
6516            if self.prepare_failure.swap(false, Ordering::SeqCst) {
6517                return Err(FailureReason::Other("provider-secret-needle".into()));
6518            }
6519            Ok(PreparedEnvironment::isolated(attempt.id, identity))
6520        }
6521
6522        fn start(
6523            &self,
6524            prepared: PreparedEnvironment,
6525            attempt: &RunnerAttempt,
6526            handoff: OneTimeJitHandoff<'_>,
6527        ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
6528            assert_eq!(prepared.attempt, attempt.id);
6529            assert_eq!(attempt.state(), AttemptState::JitReceived);
6530            assert_eq!(handoff.consume().expose(), JIT);
6531            self.handoffs.fetch_add(1, Ordering::SeqCst);
6532            self.actions.lock().unwrap().push("start");
6533            let mut resources = self.resources.lock().unwrap();
6534            let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
6535            *state = EnvironmentState::Running;
6536            Ok(identity.clone())
6537        }
6538
6539        fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6540            self.actions.lock().unwrap().push("inspect");
6541            Ok(self
6542                .resources
6543                .lock()
6544                .unwrap()
6545                .get(&attempt.id)
6546                .map_or(EnvironmentState::Missing, |(_, state)| *state))
6547        }
6548
6549        fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6550            if self.recovery_unavailable.load(Ordering::SeqCst) {
6551                return Err(FailureReason::Other(
6552                    "provider discovery unavailable".into(),
6553                ));
6554            }
6555            self.inspect(attempt)
6556        }
6557
6558        fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6559            Ok(self
6560                .resources
6561                .lock()
6562                .unwrap()
6563                .get(&attempt.id)
6564                .is_some_and(|(identity, _)| {
6565                    identity_matches_intent(attempt_host(identity), attempt, identity)
6566                }))
6567        }
6568
6569        fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
6570            assert!(self.owns(attempt)?);
6571            self.actions.lock().unwrap().push("stop");
6572            if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
6573                *state = EnvironmentState::Exited;
6574            }
6575            Ok(ProviderStop::Stopped)
6576        }
6577
6578        fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
6579            assert!(self.owns(attempt)?);
6580            self.actions.lock().unwrap().push("destroy");
6581            if self.destroy_deferred.load(Ordering::SeqCst) {
6582                return Ok(ProviderDestroy::Deferred("provider busy"));
6583            }
6584            self.resources.lock().unwrap().remove(&attempt.id);
6585            Ok(ProviderDestroy::Destroyed)
6586        }
6587
6588        fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
6589            let mut owned = self
6590                .resources
6591                .lock()
6592                .unwrap()
6593                .values()
6594                .filter(|(identity, _)| attempt_host(identity) == host)
6595                .map(|(identity, _)| identity.clone())
6596                .collect::<Vec<_>>();
6597            owned.extend(
6598                self.extra_resources
6599                    .lock()
6600                    .unwrap()
6601                    .iter()
6602                    .filter(|identity| attempt_host(identity) == host)
6603                    .cloned(),
6604            );
6605            owned
6606        }
6607
6608        fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
6609            vec![ProviderDiagnostic::CleanupDeferred; 40]
6610        }
6611
6612        fn spawn(
6613            &self,
6614            _attempt: &RunnerAttempt,
6615            _config: &EncodedJitConfig,
6616        ) -> Result<u32, ProcessStartFailure> {
6617            self.native_calls.fetch_add(1, Ordering::SeqCst);
6618            panic!("isolated provider must never spawn natively")
6619        }
6620        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6621            panic!("native fallback")
6622        }
6623        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
6624            panic!("native fallback")
6625        }
6626        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
6627            self.completed_successfully.load(Ordering::SeqCst)
6628        }
6629        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6630            panic!("native fallback")
6631        }
6632        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
6633            false
6634        }
6635        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6636            panic!("native fallback")
6637        }
6638    }
6639
6640    fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
6641        let EnvironmentIdentity::Isolated { host, .. } = identity else {
6642            panic!("isolated identity")
6643        };
6644        *host
6645    }
6646
6647    #[derive(Debug, Default)]
6648    struct FakeDemand {
6649        answers: Mutex<VecDeque<bool>>,
6650    }
6651
6652    impl FakeDemand {
6653        fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
6654            Self {
6655                answers: Mutex::new(answers.into_iter().collect()),
6656            }
6657        }
6658    }
6659
6660    #[async_trait]
6661    impl DemandPersistence for FakeDemand {
6662        async fn persists(&self, _policy: PolicyId) -> bool {
6663            self.answers.lock().unwrap().pop_front().unwrap_or(true)
6664        }
6665    }
6666
6667    #[derive(Debug, Default)]
6668    struct FakeDelay(Mutex<Vec<Duration>>);
6669
6670    #[async_trait]
6671    impl RetryDelay for FakeDelay {
6672        async fn wait(&self, duration: Duration) {
6673            self.0.lock().unwrap().push(duration);
6674        }
6675    }
6676
6677    #[tokio::test]
6678    async fn live_capacity_reads_the_updated_sqlite_host_and_rejects_a_missing_host() {
6679        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6680        let path = harness._root.path().join("live-capacity.sqlite3");
6681        harness.store = Arc::new(SqliteStore::open(&path).unwrap());
6682        harness.store.put_host(&harness.host).unwrap();
6683        harness.launcher = harness.restart();
6684        assert_eq!(
6685            harness.launcher.host_capacity(&harness.host).await.unwrap(),
6686            harness.host.host_capacity
6687        );
6688        // A separate CLI/TUI connection updates the journal while the existing
6689        // launcher keeps its original store connection and startup host.
6690        let writer = SqliteStore::open(&path).unwrap();
6691        let mut updated = harness.host.clone();
6692        updated.host_capacity = std::num::NonZeroU16::new(12).unwrap();
6693        writer.put_host(&updated).unwrap();
6694        assert_eq!(
6695            harness
6696                .launcher
6697                .host_capacity(&harness.host)
6698                .await
6699                .unwrap()
6700                .get(),
6701            12
6702        );
6703
6704        updated.host_capacity = std::num::NonZeroU16::new(2).unwrap();
6705        writer.put_host(&updated).unwrap();
6706        assert_eq!(
6707            harness
6708                .launcher
6709                .host_capacity(&harness.host)
6710                .await
6711                .unwrap()
6712                .get(),
6713            2
6714        );
6715
6716        let mut missing = harness.host.clone();
6717        missing.id = HostId::from_u128(999);
6718        assert_eq!(
6719            harness.launcher.host_capacity(&missing).await,
6720            Err(crate::reconcile::HostCapacityError::MissingHost)
6721        );
6722    }
6723
6724    struct Harness {
6725        _root: tempfile::TempDir,
6726        app_paths: runner_manager_platform::paths::AppPaths,
6727        launcher: LifecycleLauncher,
6728        demand: Arc<dyn DemandPersistence>,
6729        store: Arc<SqliteStore>,
6730        github: Arc<FakeGithubLifecycle>,
6731        packages: Arc<FakePackages>,
6732        processes: Arc<FakeProcesses>,
6733        clock: Arc<FakeClock>,
6734        events: Arc<AttemptEventLog>,
6735        reconcile_events: Arc<crate::reconcile::EventLog>,
6736        delay: Arc<FakeDelay>,
6737        host: runner_manager_domain::model::Host,
6738        policy: ScalePolicy,
6739        allocation_lock: InProcessAllocationLock,
6740        /// The repository persistent root, once one is configured.
6741        workspace_root: Option<LocalAbsolutePath>,
6742    }
6743
6744    impl Harness {
6745        fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
6746            let root = tempfile::tempdir().unwrap();
6747            let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
6748            paths.create_all().unwrap();
6749            let policy = fixtures::policy()
6750                .repository("octo/repo")
6751                .autoscale("home", 2)
6752                .active()
6753                .build();
6754            // A unit harness must never inspect or write the workstation's
6755            // production runner root. On Windows that is C:\rman, which is
6756            // intentionally inaccessible to an ordinary account after a boot
6757            // service is installed. Using it made the same suite pass on a
6758            // clean CI image and fail on a correctly secured developer host.
6759            let host_root = root.path().join("host-root");
6760            fs::create_dir_all(&host_root).unwrap();
6761            let mut host = fixtures::host().build();
6762            host.runner_root_override = Some(
6763                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6764                    .expect("a local absolute host root"),
6765            );
6766            let store = Arc::new(SqliteStore::open_in_memory().unwrap());
6767            store.put_host(&host).unwrap();
6768            let github = Arc::new(github);
6769            let packages = Arc::new(FakePackages::default());
6770            let processes = Arc::new(FakeProcesses::default());
6771            let clock = Arc::new(FakeClock::default());
6772            let events = Arc::new(AttemptEventLog::default());
6773            let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
6774            let delay = Arc::new(FakeDelay::default());
6775            let ports = LifecyclePorts {
6776                store: Arc::clone(&store) as Arc<dyn Store>,
6777                github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
6778                packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
6779                processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
6780                clock: Arc::clone(&clock) as Arc<dyn Clock>,
6781                demand: Arc::clone(&demand),
6782                delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
6783                events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
6784                reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
6785            };
6786            let launcher = Self::launcher_over(policy.host_id, &paths, ports);
6787            Self {
6788                _root: root,
6789                app_paths: paths,
6790                launcher,
6791                demand,
6792                store,
6793                github,
6794                packages,
6795                processes,
6796                clock,
6797                events,
6798                reconcile_events,
6799                delay,
6800                host,
6801                policy,
6802                allocation_lock: InProcessAllocationLock::new(),
6803                workspace_root: None,
6804            }
6805        }
6806
6807        /// Put disposable attempts under a host root of this harness's own.
6808        ///
6809        /// Without it the launcher resolves the *platform* default, which on
6810        /// Windows is `%SystemDrive%\rman` — a real directory on the machine
6811        /// running the suite. Every test added by `c2` places its files inside
6812        /// its own temporary directory instead.
6813        fn with_host_runner_root(mut self) -> Self {
6814            let host_root = self.host_root();
6815            fs::create_dir_all(&host_root).unwrap();
6816            self.host.runner_root_override = Some(
6817                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6818                    .expect("a local absolute host root"),
6819            );
6820            self.store.put_host(&self.host).unwrap();
6821            self
6822        }
6823
6824        /// Opt this harness's repository into a persistent workspace (D4).
6825        fn with_persistent_workspace(mut self, capacity: u16) -> Self {
6826            self = self.with_host_runner_root();
6827            let root = self._root.path().join("persist");
6828            let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
6829                .expect("a local absolute workspace root");
6830            self.policy = fixtures::policy()
6831                .repository("octo/repo")
6832                .autoscale("home", capacity)
6833                .active()
6834                .build();
6835            self.policy
6836                .set_workspace_policy(
6837                    WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
6838                        .expect("a repository may be persistent"),
6839                )
6840                .expect("a repository may be persistent");
6841            self.workspace_root = Some(root);
6842            // The journal's copy, so that cleanup's cross-check against a
6843            // surviving policy is exercised rather than skipped.
6844            self.store.insert_policy(&self.policy).unwrap();
6845            self
6846        }
6847
6848        fn workspace_root(&self) -> &LocalAbsolutePath {
6849            self.workspace_root
6850                .as_ref()
6851                .expect("this harness configured a persistent workspace")
6852        }
6853
6854        fn slot_path(&self, slot: u16) -> PathBuf {
6855            self.workspace_root().as_path().join(format!("s{slot}"))
6856        }
6857
6858        fn host_root(&self) -> PathBuf {
6859            self._root.path().join("host-root")
6860        }
6861
6862        fn attempt(&self, id: AttemptId) -> RunnerAttempt {
6863            self.store
6864                .attempt(id)
6865                .unwrap()
6866                .expect("the attempt is journalled")
6867        }
6868
6869        /// Conclude an attempt and run the real cleanup over it.
6870        fn conclude(&self, id: AttemptId) -> RunnerAttempt {
6871            let mut attempt = self.attempt(id);
6872            attempt
6873                .conclude(
6874                    AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
6875                    self.clock.now(),
6876                )
6877                .unwrap();
6878            self.store.record_attempt(&attempt).unwrap();
6879            attempt
6880        }
6881
6882        async fn cleanup_retaining_work(&self, id: AttemptId) {
6883            self.conclude(id);
6884            self.launcher
6885                .clean(id)
6886                .await
6887                .expect("the slot is scrubbed and the lease released");
6888        }
6889
6890        /// The launcher configuration every launcher in this harness shares, so
6891        /// that the one a restart mints cannot drift from the original.
6892        fn launcher_over(
6893            host: HostId,
6894            paths: &runner_manager_platform::paths::AppPaths,
6895            ports: LifecyclePorts,
6896        ) -> LifecycleLauncher {
6897            LifecycleLauncher::new(
6898                host,
6899                paths.clone(),
6900                paths.logs_dir(),
6901                1,
6902                RecoveryTimeouts::new(
6903                    Elapsed::seconds(10),
6904                    Elapsed::seconds(10),
6905                    Elapsed::seconds(10),
6906                ),
6907                RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
6908                ports,
6909            )
6910        }
6911
6912        /// The same journal, the same directories, a launcher that remembers
6913        /// nothing — which is what a daemon restart is.
6914        fn restart(&self) -> LifecycleLauncher {
6915            Self::launcher_over(
6916                self.policy.host_id,
6917                &self.app_paths,
6918                LifecyclePorts {
6919                    store: Arc::clone(&self.store) as Arc<dyn Store>,
6920                    github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
6921                    packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
6922                    processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
6923                    clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
6924                    demand: Arc::clone(&self.demand),
6925                    delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
6926                    events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
6927                    reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
6928                },
6929            )
6930        }
6931
6932        async fn ready(&self) {
6933            self.launcher
6934                .recover_startup(std::slice::from_ref(&self.policy))
6935                .await
6936                .unwrap();
6937        }
6938
6939        async fn launch(&self) -> RunnerAttempt {
6940            self.launch_result().await.unwrap()
6941        }
6942
6943        async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
6944            self.launch_after_waiting(Duration::ZERO).await
6945        }
6946
6947        async fn launch_after_waiting(
6948            &self,
6949            lock_wait: Duration,
6950        ) -> Result<RunnerAttempt, LaunchFailure> {
6951            let reserved = self.reserve().await?;
6952            self.launcher
6953                .finish_launch(
6954                    &self.policy,
6955                    reserved,
6956                    ReservationWaits {
6957                        lock: lock_wait,
6958                        order: Duration::from_secs(2),
6959                    },
6960                )
6961                .await
6962        }
6963
6964        /// A whole launch through `launcher`, as a reconciliation pass makes it.
6965        async fn launch_through(
6966            &self,
6967            launcher: &LifecycleLauncher,
6968        ) -> Result<RunnerAttempt, LaunchFailure> {
6969            let reserved = self.reserve_through(launcher).await?;
6970            launcher
6971                .finish_launch(&self.policy, reserved, ReservationWaits::default())
6972                .await
6973        }
6974
6975        async fn reserve(&self) -> Result<RunnerAttempt, LaunchFailure> {
6976            self.reserve_through(&self.launcher).await
6977        }
6978
6979        async fn reserve_through(
6980            &self,
6981            launcher: &LifecycleLauncher,
6982        ) -> Result<RunnerAttempt, LaunchFailure> {
6983            let guard = self.allocation_lock.acquire().await.unwrap();
6984            launcher
6985                .launch(LaunchRequest {
6986                    host: &self.host,
6987                    policy: &self.policy,
6988                    allocation_guard: &guard,
6989                })
6990                .await
6991        }
6992
6993        fn only_attempt(&self) -> RunnerAttempt {
6994            self.store.attempts().unwrap().into_iter().next().unwrap()
6995        }
6996    }
6997
6998    fn isolated_harness() -> Harness {
6999        use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
7000        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7001            .with_host_runner_root();
7002        let image =
7003            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
7004                .expect("pinned image");
7005        harness
7006            .policy
7007            .set_execution_policy(ExecutionPolicy::Isolated {
7008                backend: Backend::Oci,
7009                image,
7010                resources: ResourceLimits {
7011                    cpu_millis: 1000,
7012                    memory_mib: 1024,
7013                    disk_mib: 4096,
7014                },
7015            })
7016            .expect("isolated policy");
7017        harness.github.watch_journal(Arc::clone(&harness.store));
7018        harness
7019    }
7020
7021    fn launcher_with_isolated(
7022        harness: &Harness,
7023        provider: Arc<FakeIsolatedProvider>,
7024    ) -> LifecycleLauncher {
7025        Harness::launcher_over(
7026            harness.policy.host_id,
7027            &harness.app_paths,
7028            LifecyclePorts {
7029                store: Arc::clone(&harness.store) as Arc<dyn Store>,
7030                github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
7031                packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
7032                processes: provider as Arc<dyn ExecutionProvider>,
7033                clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
7034                demand: Arc::clone(&harness.demand),
7035                delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
7036                events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
7037                reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
7038            },
7039        )
7040    }
7041
7042    #[tokio::test]
7043    async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
7044        use runner_manager_domain::execution::{
7045            AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
7046        };
7047
7048        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7049            .with_host_runner_root();
7050        let image =
7051            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
7052                .expect("pinned image");
7053        harness
7054            .policy
7055            .set_execution_policy(ExecutionPolicy::Isolated {
7056                backend: Backend::Oci,
7057                image: image.clone(),
7058                resources: ResourceLimits {
7059                    cpu_millis: 1000,
7060                    memory_mib: 1024,
7061                    disk_mib: 4096,
7062                },
7063            })
7064            .expect("valid isolated profile");
7065
7066        harness.ready().await;
7067        assert_eq!(
7068            harness.processes.probe(&harness.policy),
7069            ProviderCapability::Unsupported
7070        );
7071        let failure = harness
7072            .launch_result()
7073            .await
7074            .expect_err("provider is unavailable");
7075        assert_eq!(
7076            failure.reason,
7077            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
7078        );
7079
7080        let runtime = harness.host_root().join("isolated-owned-environment");
7081        fs::create_dir_all(&runtime).expect("provider resource fixture");
7082        fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
7083        let mut attempt = RunnerAttempt::allocate(
7084            AttemptId::from_u128(0x123),
7085            harness.policy.id,
7086            runtime.clone(),
7087            harness.clock.now(),
7088        );
7089        attempt
7090            .allocate_execution(AttemptExecution::Isolated {
7091                provider_kind: Backend::Oci,
7092                environment_id: None,
7093                resolved_image: image,
7094                generation: "generation-123".into(),
7095            })
7096            .expect("isolated intent");
7097        attempt
7098            .prepared_environment("environment-123".into())
7099            .expect("prepared identity");
7100        harness
7101            .store
7102            .record_attempt(&attempt)
7103            .expect("durable isolated journal");
7104
7105        harness
7106            .launcher
7107            .recover_startup(std::slice::from_ref(&harness.policy))
7108            .await
7109            .expect("unsupported isolated cleanup is profile-local");
7110        let failure = harness
7111            .launch_result()
7112            .await
7113            .expect_err("provider unavailable for this policy");
7114        assert_eq!(
7115            failure.reason,
7116            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
7117        );
7118        harness
7119            .launcher
7120            .supervise(&harness.policy)
7121            .await
7122            .expect("unavailable attempt is deferred");
7123        assert!(harness.launcher.clean(attempt.id).await.is_err());
7124        assert_eq!(
7125            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7126            AttemptState::CleanupDeferred
7127        );
7128        assert!(matches!(
7129            harness.launcher.recover_startup(&[]).await,
7130            Err(LifecycleError::RecoveryIncomplete)
7131        ));
7132        assert!(
7133            runtime.join("owned-marker").is_file(),
7134            "native cleanup touched the isolated environment"
7135        );
7136        assert!(
7137            harness.processes.actions.lock().unwrap().is_empty(),
7138            "native recovery inspected an isolated identity"
7139        );
7140        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
7141        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7142        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
7143    }
7144
7145    #[tokio::test]
7146    async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
7147        let harness = isolated_harness();
7148        let provider = Arc::new(FakeIsolatedProvider::default());
7149        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7150        launcher
7151            .recover_startup(std::slice::from_ref(&harness.policy))
7152            .await
7153            .expect("ready");
7154        let attempt = harness
7155            .launch_through(&launcher)
7156            .await
7157            .expect("isolated launch");
7158        assert_eq!(attempt.state(), AttemptState::Starting);
7159        assert_eq!(provider.resource_count(), 1);
7160        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
7161        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7162        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
7163        let fact = harness
7164            .github
7165            .registration_facts()
7166            .pop()
7167            .expect("JIT registration");
7168        assert_eq!(
7169            fact.journal_states,
7170            vec![AttemptState::Prepared],
7171            "provider readiness preceded GitHub JIT"
7172        );
7173        assert_eq!(
7174            harness
7175                .events
7176                .events()
7177                .into_iter()
7178                .filter_map(|event| match event {
7179                    AttemptEvent::State { state, .. } => Some(state),
7180                    _ => None,
7181                })
7182                .collect::<Vec<_>>(),
7183            vec![
7184                AttemptState::Allocated,
7185                AttemptState::Preparing,
7186                AttemptState::Prepared,
7187                AttemptState::JitReceived,
7188                AttemptState::Starting
7189            ]
7190        );
7191        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
7192        restarted
7193            .recover_startup(std::slice::from_ref(&harness.policy))
7194            .await
7195            .expect("adopt live sandbox");
7196        assert_eq!(provider.resource_count(), 1);
7197        assert_eq!(
7198            harness.github.registrations.load(Ordering::SeqCst),
7199            1,
7200            "restart cannot issue duplicate JIT"
7201        );
7202        harness
7203            .github
7204            .observe(GithubRunnerObservation::Registered { busy: true });
7205        restarted
7206            .supervise(&harness.policy)
7207            .await
7208            .expect("job observed");
7209        assert_eq!(
7210            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7211            AttemptState::Busy
7212        );
7213        provider
7214            .resources
7215            .lock()
7216            .unwrap()
7217            .get_mut(&attempt.id)
7218            .unwrap()
7219            .1 = EnvironmentState::Exited;
7220        harness
7221            .github
7222            .observe(GithubRunnerObservation::NotRegistered);
7223        restarted
7224            .supervise(&harness.policy)
7225            .await
7226            .expect("terminal cleanup");
7227        assert_eq!(provider.resource_count(), 0);
7228        assert_eq!(
7229            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7230            AttemptState::Cleaned
7231        );
7232    }
7233
7234    #[tokio::test]
7235    async fn attached_isolated_job_exit_is_recorded_as_completed() {
7236        let harness = isolated_harness();
7237        let provider = Arc::new(FakeIsolatedProvider::default());
7238        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7239        launcher
7240            .recover_startup(std::slice::from_ref(&harness.policy))
7241            .await
7242            .unwrap();
7243        let attempt = harness.launch_through(&launcher).await.unwrap();
7244        harness
7245            .github
7246            .observe(GithubRunnerObservation::Registered { busy: true });
7247        launcher.supervise(&harness.policy).await.unwrap();
7248        provider
7249            .resources
7250            .lock()
7251            .unwrap()
7252            .get_mut(&attempt.id)
7253            .unwrap()
7254            .1 = EnvironmentState::Exited;
7255        provider
7256            .completed_successfully
7257            .store(true, Ordering::SeqCst);
7258        harness
7259            .github
7260            .observe(GithubRunnerObservation::NotRegistered);
7261        launcher.supervise(&harness.policy).await.unwrap();
7262        let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
7263        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7264        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7265    }
7266
7267    #[tokio::test]
7268    async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
7269        let harness = isolated_harness();
7270        let provider = Arc::new(FakeIsolatedProvider::default());
7271        provider.prepare_failure.store(true, Ordering::SeqCst);
7272        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7273        launcher
7274            .recover_startup(std::slice::from_ref(&harness.policy))
7275            .await
7276            .expect("ready");
7277        let failure = harness
7278            .launch_through(&launcher)
7279            .await
7280            .expect_err("prepare failed after creating resource");
7281        assert_eq!(
7282            failure.reason,
7283            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
7284        );
7285        assert_eq!(provider.resource_count(), 1);
7286        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7287        assert!(
7288            !harness
7289                .store
7290                .dump_text()
7291                .unwrap()
7292                .contains("provider-secret-needle")
7293        );
7294        let id = harness.store.attempts().unwrap()[0].id;
7295        assert_eq!(
7296            harness.store.attempt(id).unwrap().unwrap().outcome(),
7297            Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
7298                IsolationProviderFailure::RuntimeOperationFailed
7299            )))
7300        );
7301        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
7302        restarted
7303            .recover_startup(std::slice::from_ref(&harness.policy))
7304            .await
7305            .expect("profile-local recovery");
7306        assert_eq!(
7307            provider.resource_count(),
7308            0,
7309            "the exact owned resource was destroyed once"
7310        );
7311        assert_eq!(
7312            harness.store.attempt(id).unwrap().unwrap().state(),
7313            AttemptState::Cleaned
7314        );
7315        let diagnostic =
7316            fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
7317        assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
7318        assert!(!diagnostic.contains("provider-secret-needle"));
7319        assert_eq!(
7320            provider
7321                .actions
7322                .lock()
7323                .unwrap()
7324                .iter()
7325                .filter(|action| **action == "destroy")
7326                .count(),
7327            1
7328        );
7329        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7330    }
7331
7332    #[test]
7333    fn isolation_capability_and_adapter_failures_keep_closed_categories() {
7334        let cases = [
7335            (
7336                ProviderCapability::NotInstalled,
7337                IsolationProviderFailure::NotInstalled,
7338            ),
7339            (
7340                ProviderCapability::PermissionDenied,
7341                IsolationProviderFailure::PermissionDenied,
7342            ),
7343            (
7344                ProviderCapability::ImageUnavailableOrIncompatible,
7345                IsolationProviderFailure::ImageUnavailableOrIncompatible,
7346            ),
7347            (
7348                ProviderCapability::DiskQuotaUnavailable,
7349                IsolationProviderFailure::DiskQuotaUnavailable,
7350            ),
7351            (
7352                ProviderCapability::Degraded,
7353                IsolationProviderFailure::Degraded,
7354            ),
7355        ];
7356        for (capability, category) in cases {
7357            assert_eq!(
7358                capability.refusal(),
7359                FailureReason::IsolationProvider(category)
7360            );
7361        }
7362        let raw = FailureReason::Other("ghp_secret_from_provider".into());
7363        let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
7364        assert_eq!(
7365            safe,
7366            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
7367        );
7368        assert!(!safe.to_string().contains("ghp_secret"));
7369        assert_eq!(
7370            safe_isolation_reason(
7371                FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
7372                IsolationProviderFailure::RuntimeOperationFailed,
7373            ),
7374            FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
7375        );
7376    }
7377
7378    #[tokio::test]
7379    async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
7380        for image_failure in [false, true] {
7381            let harness = isolated_harness();
7382            let provider = Arc::new(FakeIsolatedProvider::default());
7383            provider
7384                .permission_denied
7385                .store(!image_failure, Ordering::SeqCst);
7386            provider
7387                .incompatible_image
7388                .store(image_failure, Ordering::SeqCst);
7389            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7390            launcher
7391                .recover_startup(std::slice::from_ref(&harness.policy))
7392                .await
7393                .unwrap();
7394            let error = harness
7395                .launch_through(&launcher)
7396                .await
7397                .expect_err("pre-JIT refusal");
7398            let expected = if image_failure {
7399                "resolved image is incompatible"
7400            } else {
7401                "permission denied"
7402            };
7403            assert!(error.reason.to_string().contains(expected));
7404            assert!(
7405                harness.store.attempts().unwrap().is_empty(),
7406                "no allocation was journalled"
7407            );
7408            assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7409            assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
7410            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7411        }
7412    }
7413
7414    #[tokio::test]
7415    async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
7416        let harness = isolated_harness();
7417        let provider = Arc::new(FakeIsolatedProvider::default());
7418        provider.unsupported_host.store(true, Ordering::SeqCst);
7419        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7420        launcher
7421            .recover_startup(std::slice::from_ref(&harness.policy))
7422            .await
7423            .unwrap();
7424        let error = harness
7425            .launch_through(&launcher)
7426            .await
7427            .expect_err("unsupported host must be refused before JIT");
7428
7429        assert_eq!(
7430            error.reason,
7431            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
7432        );
7433        assert!(harness.store.attempts().unwrap().is_empty());
7434        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7435        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
7436        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7437        assert!(provider.actions.lock().unwrap().is_empty());
7438    }
7439
7440    #[tokio::test]
7441    async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
7442        let harness = isolated_harness();
7443        let provider = Arc::new(FakeIsolatedProvider::default());
7444        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7445        let runtime = harness.host_root().join("prepare-without-identity");
7446        fs::create_dir_all(&runtime).unwrap();
7447        let mut attempt = RunnerAttempt::allocate(
7448            AttemptId::from_u128(0x9191),
7449            harness.policy.id,
7450            runtime,
7451            harness.clock.now(),
7452        );
7453        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7454            harness.policy.execution_policy()
7455        else {
7456            unreachable!()
7457        };
7458        attempt
7459            .allocate_execution(AttemptExecution::Isolated {
7460                provider_kind: Backend::Oci,
7461                environment_id: None,
7462                resolved_image: image.clone(),
7463                generation: "prepare-generation".into(),
7464            })
7465            .unwrap();
7466        attempt.begin_prepare(harness.clock.now()).unwrap();
7467        attempt
7468            .conclude(
7469                AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
7470                harness.clock.now(),
7471            )
7472            .unwrap();
7473        harness.store.record_attempt(&attempt).unwrap();
7474
7475        provider.recovery_unavailable.store(true, Ordering::SeqCst);
7476        launcher
7477            .clean(attempt.id)
7478            .await
7479            .expect_err("absence unproven");
7480        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7481        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7482        assert!(deferred.counts_against_capacity());
7483
7484        provider.recovery_unavailable.store(false, Ordering::SeqCst);
7485        launcher.clean(attempt.id).await.expect("absence proven");
7486        assert_eq!(
7487            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7488            AttemptState::Cleaned
7489        );
7490    }
7491
7492    #[tokio::test]
7493    async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
7494        let harness = isolated_harness();
7495        let provider = Arc::new(FakeIsolatedProvider::default());
7496        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7497        launcher
7498            .recover_startup(std::slice::from_ref(&harness.policy))
7499            .await
7500            .expect("ready");
7501        let mut attempt = harness.launch_through(&launcher).await.expect("launch");
7502        attempt
7503            .conclude(
7504                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7505                harness.clock.now(),
7506            )
7507            .unwrap();
7508        harness.store.record_attempt(&attempt).unwrap();
7509        provider.destroy_deferred.store(true, Ordering::SeqCst);
7510        launcher
7511            .clean(attempt.id)
7512            .await
7513            .expect_err("provider deferred destroy");
7514        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7515        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7516        assert!(deferred.counts_against_capacity());
7517        assert_eq!(provider.resource_count(), 1);
7518        provider.destroy_deferred.store(false, Ordering::SeqCst);
7519        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
7520        restarted
7521            .recover_startup(std::slice::from_ref(&harness.policy))
7522            .await
7523            .expect("retry cleanup");
7524        assert_eq!(provider.resource_count(), 0);
7525        assert!(
7526            !harness
7527                .store
7528                .attempt(attempt.id)
7529                .unwrap()
7530                .unwrap()
7531                .counts_against_capacity()
7532        );
7533
7534        // A reused runtime name with a different generation is not ours to delete.
7535        let second_id = AttemptId::from_u128(0x9999);
7536        let second_runtime = harness.host_root().join("generation-mismatch");
7537        fs::create_dir_all(&second_runtime).unwrap();
7538        let mut terminal = RunnerAttempt::allocate(
7539            second_id,
7540            harness.policy.id,
7541            second_runtime,
7542            harness.clock.now(),
7543        );
7544        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7545            harness.policy.execution_policy()
7546        else {
7547            unreachable!()
7548        };
7549        terminal
7550            .allocate_execution(AttemptExecution::Isolated {
7551                provider_kind: Backend::Oci,
7552                environment_id: None,
7553                resolved_image: image.clone(),
7554                generation: "expected-generation".into(),
7555            })
7556            .unwrap();
7557        let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
7558        let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
7559            unreachable!()
7560        };
7561        terminal.begin_prepare(harness.clock.now()).unwrap();
7562        terminal
7563            .prepared_environment(environment_id.clone())
7564            .unwrap();
7565        terminal.mark_prepared(harness.clock.now()).unwrap();
7566        terminal.jit_received(harness.clock.now()).unwrap();
7567        terminal.started_isolated(harness.clock.now()).unwrap();
7568        terminal
7569            .conclude(
7570                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7571                harness.clock.now(),
7572            )
7573            .unwrap();
7574        harness.store.record_attempt(&terminal).unwrap();
7575        let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
7576            unreachable!()
7577        };
7578        *generation = "another-generation".into();
7579        provider
7580            .resources
7581            .lock()
7582            .unwrap()
7583            .insert(second_id, (stranger, EnvironmentState::Running));
7584        restarted
7585            .clean(terminal.id)
7586            .await
7587            .expect_err("ownership mismatch quarantined");
7588        assert_eq!(provider.resource_count(), 1);
7589        assert_eq!(
7590            harness.store.attempt(terminal.id).unwrap().unwrap().state(),
7591            AttemptState::CleanupDeferred
7592        );
7593        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7594    }
7595
7596    #[tokio::test]
7597    async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
7598        for stage in [
7599            AttemptState::Allocated,
7600            AttemptState::Preparing,
7601            AttemptState::Prepared,
7602            AttemptState::JitReceived,
7603            AttemptState::Starting,
7604            AttemptState::Idle,
7605            AttemptState::Busy,
7606            AttemptState::Finished,
7607            AttemptState::Failed,
7608            AttemptState::Orphaned,
7609            AttemptState::Destroying,
7610            AttemptState::CleanupDeferred,
7611        ] {
7612            let harness = isolated_harness();
7613            let provider = Arc::new(FakeIsolatedProvider::default());
7614            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7615            let id = AttemptId::from_u128(0x1000 + stage as u128);
7616            let runtime = harness.host_root().join(format!("phase-{stage}"));
7617            fs::create_dir_all(&runtime).unwrap();
7618            let mut attempt =
7619                RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7620            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7621                harness.policy.execution_policy()
7622            else {
7623                unreachable!()
7624            };
7625            attempt
7626                .allocate_execution(AttemptExecution::Isolated {
7627                    provider_kind: Backend::Oci,
7628                    environment_id: None,
7629                    resolved_image: image.clone(),
7630                    generation: format!("generation-{stage}"),
7631                })
7632                .unwrap();
7633            if stage != AttemptState::Allocated {
7634                attempt.begin_prepare(harness.clock.now()).unwrap();
7635            }
7636            let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7637            if stage != AttemptState::Allocated {
7638                let resource_state = if matches!(
7639                    stage,
7640                    AttemptState::Finished
7641                        | AttemptState::Failed
7642                        | AttemptState::Orphaned
7643                        | AttemptState::Destroying
7644                        | AttemptState::CleanupDeferred
7645                ) {
7646                    EnvironmentState::Exited
7647                } else {
7648                    EnvironmentState::Running
7649                };
7650                provider
7651                    .resources
7652                    .lock()
7653                    .unwrap()
7654                    .insert(id, (identity.clone(), resource_state));
7655            }
7656            if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
7657                let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
7658                    unreachable!()
7659                };
7660                attempt
7661                    .prepared_environment(environment_id.clone())
7662                    .unwrap();
7663                attempt.mark_prepared(harness.clock.now()).unwrap();
7664            }
7665            if matches!(
7666                stage,
7667                AttemptState::JitReceived
7668                    | AttemptState::Starting
7669                    | AttemptState::Idle
7670                    | AttemptState::Busy
7671                    | AttemptState::Finished
7672                    | AttemptState::Failed
7673                    | AttemptState::Orphaned
7674                    | AttemptState::Destroying
7675                    | AttemptState::CleanupDeferred
7676            ) {
7677                attempt.jit_received(harness.clock.now()).unwrap();
7678            }
7679            if matches!(
7680                stage,
7681                AttemptState::Starting
7682                    | AttemptState::Idle
7683                    | AttemptState::Busy
7684                    | AttemptState::Finished
7685                    | AttemptState::Failed
7686                    | AttemptState::Orphaned
7687                    | AttemptState::Destroying
7688                    | AttemptState::CleanupDeferred
7689            ) {
7690                attempt.started_isolated(harness.clock.now()).unwrap();
7691            }
7692            if matches!(
7693                stage,
7694                AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
7695            ) {
7696                attempt.registered_idle(73, harness.clock.now()).unwrap();
7697            }
7698            if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
7699                attempt.assigned_job(73, harness.clock.now()).unwrap();
7700            }
7701            if matches!(
7702                stage,
7703                AttemptState::Finished
7704                    | AttemptState::Failed
7705                    | AttemptState::Orphaned
7706                    | AttemptState::Destroying
7707                    | AttemptState::CleanupDeferred
7708            ) {
7709                let outcome = match stage {
7710                    AttemptState::Finished => AttemptOutcome::CompletedJob,
7711                    AttemptState::Orphaned => AttemptOutcome::Orphaned,
7712                    _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7713                };
7714                attempt.conclude(outcome, harness.clock.now()).unwrap();
7715            }
7716            if matches!(
7717                stage,
7718                AttemptState::Destroying | AttemptState::CleanupDeferred
7719            ) {
7720                attempt.begin_destroy(harness.clock.now()).unwrap();
7721            }
7722            if stage == AttemptState::CleanupDeferred {
7723                attempt.defer_cleanup(harness.clock.now()).unwrap();
7724            }
7725            harness.store.record_attempt(&attempt).unwrap();
7726            launcher
7727                .recover_startup(std::slice::from_ref(&harness.policy))
7728                .await
7729                .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
7730            let resumed = harness.store.attempt(id).unwrap().unwrap();
7731            if matches!(
7732                stage,
7733                AttemptState::Allocated
7734                    | AttemptState::Preparing
7735                    | AttemptState::Prepared
7736                    | AttemptState::Finished
7737                    | AttemptState::Failed
7738                    | AttemptState::Orphaned
7739                    | AttemptState::Destroying
7740                    | AttemptState::CleanupDeferred
7741            ) {
7742                assert_eq!(
7743                    resumed.state(),
7744                    AttemptState::Cleaned,
7745                    "{stage} must retire after restart"
7746                );
7747            } else {
7748                assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
7749                assert_eq!(provider.resource_count(), 1);
7750                provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
7751                    EnvironmentState::Exited;
7752                launcher.supervise(&harness.policy).await.unwrap();
7753                assert_eq!(
7754                    harness.store.attempt(id).unwrap().unwrap().state(),
7755                    AttemptState::Cleaned
7756                );
7757            }
7758            assert_eq!(
7759                provider.resource_count(),
7760                0,
7761                "{stage} resource cleaned exactly once"
7762            );
7763            let destroyed = provider
7764                .actions
7765                .lock()
7766                .unwrap()
7767                .iter()
7768                .filter(|action| **action == "destroy")
7769                .count();
7770            assert_eq!(
7771                destroyed,
7772                usize::from(stage != AttemptState::Allocated),
7773                "{stage} destroy count"
7774            );
7775            assert_eq!(
7776                harness.github.registrations.load(Ordering::SeqCst),
7777                0,
7778                "restart from {stage} cannot duplicate JIT"
7779            );
7780            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7781        }
7782    }
7783
7784    #[tokio::test]
7785    async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
7786        let harness = isolated_harness();
7787        let provider = Arc::new(FakeIsolatedProvider::default());
7788        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7789        let id = AttemptId::from_u128(0x7777);
7790        let runtime = harness.host_root().join("duplicate-provider-ownership");
7791        fs::create_dir_all(&runtime).unwrap();
7792        let mut attempt =
7793            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7794        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7795            harness.policy.execution_policy()
7796        else {
7797            unreachable!()
7798        };
7799        attempt
7800            .allocate_execution(AttemptExecution::Isolated {
7801                provider_kind: Backend::Oci,
7802                environment_id: None,
7803                resolved_image: image.clone(),
7804                generation: "duplicate-generation".into(),
7805            })
7806            .unwrap();
7807        attempt.begin_prepare(harness.clock.now()).unwrap();
7808        harness.store.record_attempt(&attempt).unwrap();
7809        let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7810        let mut second = first.clone();
7811        let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
7812            unreachable!()
7813        };
7814        *environment_id = "another-environment".into();
7815        provider
7816            .resources
7817            .lock()
7818            .unwrap()
7819            .insert(id, (first, EnvironmentState::Running));
7820        provider.extra_resources.lock().unwrap().push(second);
7821        launcher
7822            .recover_startup(std::slice::from_ref(&harness.policy))
7823            .await
7824            .expect("duplicate is local");
7825        let deferred = harness.store.attempt(id).unwrap().unwrap();
7826        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7827        assert!(deferred.counts_against_capacity());
7828        assert_eq!(provider.resource_count(), 1);
7829        assert_eq!(
7830            provider
7831                .actions
7832                .lock()
7833                .unwrap()
7834                .iter()
7835                .filter(|action| **action == "destroy")
7836                .count(),
7837            0
7838        );
7839        provider.extra_resources.lock().unwrap().clear();
7840        // A tolerated cleanup failure is retried after its backoff delay.
7841        harness.clock.advance_secs(31);
7842        launcher
7843            .recover_startup(std::slice::from_ref(&harness.policy))
7844            .await
7845            .expect("single identity recovered");
7846        assert_eq!(provider.resource_count(), 0);
7847        assert_eq!(
7848            harness.store.attempt(id).unwrap().unwrap().state(),
7849            AttemptState::Cleaned
7850        );
7851    }
7852
7853    #[tokio::test]
7854    async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
7855        let harness = isolated_harness();
7856        let provider = Arc::new(FakeIsolatedProvider::default());
7857        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7858        let id = AttemptId::from_u128(0x8888);
7859        let runtime = harness.host_root().join("orphan-provider-resource");
7860        fs::create_dir_all(&runtime).unwrap();
7861        let mut attempt =
7862            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7863        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7864            harness.policy.execution_policy()
7865        else {
7866            unreachable!()
7867        };
7868        attempt
7869            .allocate_execution(AttemptExecution::Isolated {
7870                provider_kind: Backend::Oci,
7871                environment_id: None,
7872                resolved_image: image.clone(),
7873                generation: "orphan-generation".into(),
7874            })
7875            .unwrap();
7876        let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7877        provider
7878            .resources
7879            .lock()
7880            .unwrap()
7881            .insert(id, (identity, EnvironmentState::Running));
7882        launcher
7883            .recover_startup(std::slice::from_ref(&harness.policy))
7884            .await
7885            .expect("orphan cannot block host");
7886        assert_eq!(provider.resource_count(), 1);
7887        assert!(
7888            harness
7889                .events
7890                .events()
7891                .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
7892        );
7893        assert_eq!(
7894            provider
7895                .actions
7896                .lock()
7897                .unwrap()
7898                .iter()
7899                .filter(|action| **action == "destroy")
7900                .count(),
7901            0
7902        );
7903
7904        let EnvironmentIdentity::Isolated { environment_id, .. } = provider
7905            .resources
7906            .lock()
7907            .unwrap()
7908            .get(&id)
7909            .unwrap()
7910            .0
7911            .clone()
7912        else {
7913            unreachable!()
7914        };
7915        attempt.begin_prepare(harness.clock.now()).unwrap();
7916        attempt.prepared_environment(environment_id).unwrap();
7917        attempt.mark_prepared(harness.clock.now()).unwrap();
7918        attempt.jit_received(harness.clock.now()).unwrap();
7919        attempt.started_isolated(harness.clock.now()).unwrap();
7920        attempt
7921            .conclude(
7922                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7923                harness.clock.now(),
7924            )
7925            .unwrap();
7926        attempt.begin_destroy(harness.clock.now()).unwrap();
7927        attempt.clean_isolated(harness.clock.now()).unwrap();
7928        harness.store.record_attempt(&attempt).unwrap();
7929        launcher
7930            .recover_startup(std::slice::from_ref(&harness.policy))
7931            .await
7932            .expect("cleaned row cannot authorize deletion");
7933        assert_eq!(provider.resource_count(), 1);
7934        assert_eq!(
7935            provider
7936                .actions
7937                .lock()
7938                .unwrap()
7939                .iter()
7940                .filter(|action| **action == "destroy")
7941                .count(),
7942            0
7943        );
7944    }
7945
7946    /// The wiring the three-hour outage needed and did not have.
7947    ///
7948    /// A root that refuses a launch does so before `record_allocation`, so no
7949    /// attempt row carries it, and the daemon's log scrubs the paths out of the
7950    /// sentence. The record this asserts is the only surface left, and
7951    /// `service status` reads it -- so if this wiring is ever dropped, the
7952    /// failure goes back to reading `runner_start_failed reason=other` once per
7953    /// poll and nothing else.
7954    #[tokio::test]
7955    async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
7956        use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
7957
7958        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7959            .with_host_runner_root();
7960        harness.ready().await;
7961
7962        // A root under two directories that do not exist: refused by the
7963        // preflight, for a reason that has nothing to do with this platform, so
7964        // the assertion holds on all three.
7965        let unusable = harness
7966            ._root
7967            .path()
7968            .join("absent")
7969            .join("deeper")
7970            .join("runners");
7971        let mut host = harness.host.clone();
7972        host.runner_root_override = Some(
7973            LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
7974                .expect("a local absolute host root"),
7975        );
7976        harness.store.put_host(&host).unwrap();
7977
7978        let failure = harness
7979            .launch_result()
7980            .await
7981            .expect_err("a root whose parents are missing cannot hold a runner");
7982        assert!(
7983            matches!(failure.reason, FailureReason::Other(_)),
7984            "{failure:?}"
7985        );
7986
7987        let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
7988        let refusal = refusals
7989            .first()
7990            .expect("the refusal reached the one surface that can hold it");
7991        assert_eq!(refusal.policy, harness.policy.id.to_string());
7992        assert_eq!(refusal.kind, "missing_parents");
7993        assert!(
7994            refusal.root.contains("runners") && refusal.detail.contains("runners"),
7995            "the directory must be named in full: {refusal:?}"
7996        );
7997
7998        // And a root that works clears it, so a host that has been fixed stops
7999        // reporting a fault it no longer has.
8000        harness.store.put_host(&harness.host).unwrap();
8001        harness.launch().await;
8002        assert!(
8003            runner_root_refusals(&harness.app_paths)
8004                .expect("readable")
8005                .is_empty(),
8006            "a successful placement clears that policy's record"
8007        );
8008
8009        clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
8010            .expect("cleanup");
8011    }
8012
8013    #[tokio::test]
8014    async fn a_launch_reports_how_long_each_step_took() {
8015        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8016        harness.ready().await;
8017        let started = harness.launch().await;
8018        let events = harness.events.events();
8019        let launched = events
8020            .iter()
8021            .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
8022            .expect("a successful launch reports its step timings");
8023        let starting = events
8024            .iter()
8025            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
8026            .unwrap();
8027        assert!(starting < launched, "timings follow the start: {events:?}");
8028    }
8029
8030    #[tokio::test]
8031    async fn a_failed_launch_still_reports_its_step_timings() {
8032        let harness = Harness::new(
8033            FakeGithubLifecycle::default().fail(true),
8034            Arc::new(PersistentDemand),
8035        );
8036        harness.ready().await;
8037        let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
8038        assert!(failed.is_err());
8039
8040        let timings = harness
8041            .events
8042            .events()
8043            .into_iter()
8044            .find_map(|event| match event {
8045                AttemptEvent::Launched {
8046                    started: false,
8047                    timings,
8048                    ..
8049                } => Some(timings),
8050                _ => None,
8051            })
8052            .expect("a launch that failed at registration reports its timings");
8053        assert_eq!(timings.lock_wait, Duration::from_secs(90));
8054        assert_eq!(timings.order_wait, Duration::from_secs(2));
8055        assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
8056        assert!(timings.total() >= Duration::from_secs(92));
8057    }
8058
8059    #[cfg(target_os = "macos")]
8060    #[tokio::test]
8061    async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
8062        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8063        harness.ready().await;
8064
8065        // A runtime this host once placed under a root it no longer uses.
8066        let old_root = harness._root.path().join("old-root");
8067        let old_runtime = old_root.join("abandoned");
8068        fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
8069        fs::create_dir_all(&old_runtime).unwrap();
8070        let old = RunnerAttempt::allocate(
8071            AttemptId::new_random(),
8072            harness.policy.id,
8073            &old_runtime,
8074            harness.clock.now(),
8075        );
8076        harness.store.record_attempt(&old).unwrap();
8077        let kept = harness.host_root().join(PACKAGE_SEED_DIR);
8078        fs::create_dir_all(kept.join("2.336.0")).unwrap();
8079
8080        harness.launch().await;
8081
8082        assert!(
8083            !old_root.join(PACKAGE_SEED_DIR).exists(),
8084            "the abandoned root's seed is removed"
8085        );
8086        assert!(old_runtime.exists(), "only the seed is touched");
8087        assert!(kept.exists(), "the host root's own seed is kept");
8088    }
8089
8090    #[tokio::test]
8091    async fn a_job_walks_every_state_and_cleans_every_artifact() {
8092        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8093        harness.ready().await;
8094        let started = harness.launch().await;
8095        assert_eq!(started.state(), AttemptState::Starting);
8096        assert_eq!(read_runner_id(started.runtime_path()), Some(73));
8097
8098        harness
8099            .github
8100            .observe(GithubRunnerObservation::Registered { busy: false });
8101        harness.launcher.supervise(&harness.policy).await.unwrap();
8102        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8103
8104        harness
8105            .github
8106            .observe(GithubRunnerObservation::Registered { busy: true });
8107        harness.launcher.supervise(&harness.policy).await.unwrap();
8108        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8109
8110        harness.processes.finish_successfully();
8111        harness
8112            .github
8113            .observe(GithubRunnerObservation::NotRegistered);
8114        harness.launcher.supervise(&harness.policy).await.unwrap();
8115        let cleaned = harness.only_attempt();
8116        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8117        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8118        assert!(!started.runtime_path().exists());
8119        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8120        assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
8121
8122        let states: Vec<_> = harness
8123            .events
8124            .events()
8125            .into_iter()
8126            .filter_map(|event| match event {
8127                AttemptEvent::State { state, .. } => Some(state),
8128                _ => None,
8129            })
8130            .collect();
8131        assert_eq!(
8132            states,
8133            vec![
8134                AttemptState::Allocated,
8135                AttemptState::JitReceived,
8136                AttemptState::Starting,
8137                AttemptState::Idle,
8138                AttemptState::Busy,
8139                AttemptState::Finished,
8140                AttemptState::Cleaned,
8141            ]
8142        );
8143    }
8144
8145    #[tokio::test]
8146    async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
8147        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8148        harness.ready().await;
8149        let started = harness.launch().await;
8150        let runtime = started.runtime_path().to_path_buf();
8151
8152        harness
8153            .github
8154            .observe(GithubRunnerObservation::Registered { busy: true });
8155        harness.launcher.supervise(&harness.policy).await.unwrap();
8156        harness.processes.finish_successfully();
8157        harness
8158            .github
8159            .observe(GithubRunnerObservation::NotRegistered);
8160        harness.launcher.supervise(&harness.policy).await.unwrap();
8161
8162        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
8163        assert!(!runtime.exists());
8164        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8165
8166        // Reproduce a late orphan creating output after the successful removal
8167        // and journal transition. The next ordinary policy supervision must
8168        // remove it without replaying package release or another state change.
8169        let residue = runtime.join("_work").join("late-node-process");
8170        fs::create_dir_all(&residue).unwrap();
8171        fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
8172
8173        harness.launcher.supervise(&harness.policy).await.unwrap();
8174
8175        assert!(!runtime.exists(), "late ephemeral residue is reaped");
8176        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
8177        assert_eq!(
8178            harness.packages.releases.load(Ordering::SeqCst),
8179            1,
8180            "reaping residue does not release the package lease twice"
8181        );
8182    }
8183
8184    #[tokio::test]
8185    async fn locked_late_residue_never_blocks_startup_recovery() {
8186        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8187        harness.ready().await;
8188        let started = harness.launch().await;
8189        let runtime = started.runtime_path().to_path_buf();
8190
8191        harness
8192            .github
8193            .observe(GithubRunnerObservation::Registered { busy: true });
8194        harness.launcher.supervise(&harness.policy).await.unwrap();
8195        harness.processes.finish_successfully();
8196        harness
8197            .github
8198            .observe(GithubRunnerObservation::NotRegistered);
8199        harness.launcher.supervise(&harness.policy).await.unwrap();
8200        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
8201
8202        let held = runtime.join("_work").join("late-node-process");
8203        fs::create_dir_all(&held).unwrap();
8204        fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
8205        let Some(block) = BlockedDeletion::inject(&held) else {
8206            eprintln!(
8207                "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
8208            );
8209            return;
8210        };
8211
8212        let restarted = harness.restart();
8213        restarted
8214            .recover_startup(std::slice::from_ref(&harness.policy))
8215            .await
8216            .expect("late residue cannot take the daemon offline");
8217        assert!(runtime.exists(), "the locked residue remains for a retry");
8218        assert!(
8219            harness
8220                .reconcile_events
8221                .events()
8222                .iter()
8223                .any(|event| matches!(
8224                    event,
8225                    LifecycleEvent::AttemptCleanFailed {
8226                        attempt,
8227                        reason: "late_ephemeral_workspace_could_not_be_removed",
8228                        ..
8229                    } if *attempt == started.id
8230                )),
8231            "the non-blocking cleanup failure remains visible"
8232        );
8233
8234        block.release();
8235        // A tolerated cleanup failure is retried after its backoff delay.
8236        harness.clock.advance_secs(31);
8237        restarted
8238            .supervise(&harness.policy)
8239            .await
8240            .expect("the ordinary retry succeeds after the lock is released");
8241        assert!(!runtime.exists(), "the retry removes the late residue");
8242        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
8243        assert_eq!(
8244            harness.packages.releases.load(Ordering::SeqCst),
8245            1,
8246            "retrying residue never releases the package lease twice"
8247        );
8248    }
8249
8250    #[tokio::test]
8251    async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
8252        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8253        harness.ready().await;
8254
8255        // The first attempt is terminal but its initial cleanup cannot finish.
8256        // This is the state left by the original failed job on the affected
8257        // workstation.
8258        let terminal = harness.launch().await;
8259        let terminal_runtime = terminal.runtime_path().to_path_buf();
8260        harness.conclude(terminal.id);
8261        let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
8262        else {
8263            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
8264            return;
8265        };
8266
8267        // The second attempt was cleaned successfully, then a child recreated
8268        // part of `_work`. Both real-world residue shapes existed together in
8269        // the incident, and neither may short-circuit recovery of the other.
8270        let cleaned = harness.launch().await;
8271        let cleaned_runtime = cleaned.runtime_path().to_path_buf();
8272        harness.conclude(cleaned.id);
8273        harness
8274            .launcher
8275            .clean(cleaned.id)
8276            .await
8277            .expect("the second attempt initially cleans");
8278        let Some(cleaned_block) =
8279            BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
8280        else {
8281            terminal_block.release();
8282            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
8283            return;
8284        };
8285
8286        let restarted = harness.restart();
8287        for _ in 0..2 {
8288            restarted
8289                .recover_startup(std::slice::from_ref(&harness.policy))
8290                .await
8291                .expect("repeated recovery remains ready while both residues are locked");
8292        }
8293        assert_ne!(
8294            harness.attempt(terminal.id).state(),
8295            AttemptState::Cleaned,
8296            "the journal does not claim the terminal workspace was removed"
8297        );
8298        assert_eq!(
8299            harness.attempt(cleaned.id).state(),
8300            AttemptState::Cleaned,
8301            "late residue does not undo an already durable cleaned transition"
8302        );
8303        assert!(terminal_runtime.exists());
8304        assert!(cleaned_runtime.exists());
8305        assert_eq!(
8306            harness.packages.releases.load(Ordering::SeqCst),
8307            1,
8308            "repeated recovery neither loses nor duplicates package leases"
8309        );
8310
8311        terminal_block.release();
8312        cleaned_block.release();
8313        // A tolerated cleanup failure is retried after its backoff delay.
8314        harness.clock.advance_secs(31);
8315        restarted
8316            .supervise(&harness.policy)
8317            .await
8318            .expect("one ordinary pass clears both residues after their locks disappear");
8319        assert!(!terminal_runtime.exists());
8320        assert!(!cleaned_runtime.exists());
8321        assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
8322        assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
8323        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
8324    }
8325
8326    #[tokio::test]
8327    async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
8328        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8329        harness.ready().await;
8330        let attempt = harness.launch().await;
8331        let runtime = attempt.runtime_path().to_path_buf();
8332        harness.conclude(attempt.id);
8333        fs::remove_dir_all(&runtime).unwrap();
8334
8335        harness
8336            .restart()
8337            .recover_startup(std::slice::from_ref(&harness.policy))
8338            .await
8339            .expect("an already absent disposable runtime is successful cleanup");
8340
8341        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
8342        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8343    }
8344
8345    #[tokio::test]
8346    async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
8347        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8348        harness.ready().await;
8349        let attempt = harness.launch().await;
8350        let runtime = attempt.runtime_path().to_path_buf();
8351        harness.conclude(attempt.id);
8352        harness.packages.fail_releases(1);
8353
8354        let restarted = harness.restart();
8355        let failure = restarted
8356            .recover_startup(std::slice::from_ref(&harness.policy))
8357            .await
8358            .expect_err("a package-accounting failure is not safe to downgrade to residue");
8359        assert!(
8360            failure
8361                .reason()
8362                .to_string()
8363                .contains("package lease could not be released")
8364        );
8365        assert!(
8366            !runtime.exists(),
8367            "workspace removal completed before release failed"
8368        );
8369        assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
8370
8371        restarted
8372            .recover_startup(std::slice::from_ref(&harness.policy))
8373            .await
8374            .expect("the same journal entry retries safely after the transient failure");
8375        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
8376        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8377    }
8378
8379    #[tokio::test]
8380    async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
8381        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8382            .with_persistent_workspace(1);
8383        harness.ready().await;
8384        let attempt = harness.launch().await;
8385        let retained = attempt.runtime_path().join("_work").join("checkout-marker");
8386        fs::create_dir_all(retained.parent().unwrap()).unwrap();
8387        fs::write(&retained, b"persistent checkout").unwrap();
8388        harness.cleanup_retaining_work(attempt.id).await;
8389
8390        harness
8391            .restart()
8392            .recover_startup(std::slice::from_ref(&harness.policy))
8393            .await
8394            .expect("a cleaned persistent slot is already reconciled");
8395
8396        assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
8397        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8398    }
8399
8400    #[tokio::test]
8401    async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
8402        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8403        harness.ready().await;
8404        let started = harness.launch().await;
8405        harness
8406            .github
8407            .observe(GithubRunnerObservation::Registered { busy: false });
8408        harness.launcher.supervise(&harness.policy).await.unwrap();
8409        harness.clock.advance_secs(11);
8410        harness.processes.set_alive(false);
8411        harness
8412            .github
8413            .observe(GithubRunnerObservation::NotRegistered);
8414        harness.launcher.supervise(&harness.policy).await.unwrap();
8415
8416        let cleaned = harness.only_attempt();
8417        assert!(cleaned.outcome().unwrap().is_idle_exit());
8418        assert!(!cleaned.outcome().unwrap().is_failure());
8419        assert!(!started.runtime_path().exists());
8420        assert!(
8421            harness
8422                .reconcile_events
8423                .events()
8424                .iter()
8425                .any(|event| matches!(
8426                    event,
8427                    LifecycleEvent::AttemptCleaned {
8428                        outcome: OutcomeKind::IdleExit,
8429                        ..
8430                    }
8431                ))
8432        );
8433        assert!(!harness.events.events().iter().any(|event| matches!(
8434            event,
8435            AttemptEvent::Concluded {
8436                outcome: OutcomeKind::Failed,
8437                ..
8438            }
8439        )));
8440    }
8441
8442    #[tokio::test]
8443    async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
8444        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8445        harness.processes.fail_spawns(2);
8446        harness.ready().await;
8447        let attempt = harness.launch().await;
8448        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
8449        assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
8450        let names: Vec<_> = fs::read_dir(attempt.runtime_path())
8451            .unwrap()
8452            .map(|entry| entry.unwrap().file_name())
8453            .collect();
8454        assert!(
8455            names.iter().all(|name| {
8456                !name
8457                    .to_string_lossy()
8458                    .starts_with(RestrictiveHandoff::NAME_PREFIX)
8459            }),
8460            "JIT artifact survived: {names:?}"
8461        );
8462        assert_eq!(
8463            *harness.delay.0.lock().unwrap(),
8464            vec![Duration::from_millis(10), Duration::from_millis(20)]
8465        );
8466    }
8467
8468    #[tokio::test]
8469    async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
8470        let gone = Harness::new(
8471            FakeGithubLifecycle::default().fail(false),
8472            Arc::new(FakeDemand::answering([false])),
8473        );
8474        gone.ready().await;
8475        assert!(gone.launch_result().await.is_err());
8476        assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
8477        assert!(gone.delay.0.lock().unwrap().is_empty());
8478
8479        let forbidden = Harness::new(
8480            FakeGithubLifecycle::default().fail(true),
8481            Arc::new(PersistentDemand),
8482        );
8483        forbidden.ready().await;
8484        assert!(forbidden.launch_result().await.is_err());
8485        assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
8486        assert!(forbidden.delay.0.lock().unwrap().is_empty());
8487        assert!(matches!(
8488            forbidden.only_attempt().outcome(),
8489            Some(AttemptOutcome::Failed {
8490                reason: FailureReason::Other(action)
8491            }) if action.contains("403")
8492        ));
8493
8494        let transient = Harness::new(
8495            FakeGithubLifecycle::default().fail(false).fail(false),
8496            Arc::new(PersistentDemand),
8497        );
8498        transient.ready().await;
8499        transient.launch().await;
8500        assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
8501        assert_eq!(
8502            *transient.delay.0.lock().unwrap(),
8503            vec![Duration::from_millis(10), Duration::from_millis(20)]
8504        );
8505    }
8506
8507    /// The layout has to leave room for what the runner writes underneath it.
8508    ///
8509    /// Windows refuses a path over `MAX_PATH`, and this product's own CI hit
8510    /// that: 264 characters against a limit of 260, failing three checkout
8511    /// retries with `Filename too long`. The two identifiers in the old layout
8512    /// cost 74 characters between them for no benefit -- an attempt id is
8513    /// unique on its own.
8514    #[test]
8515    fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
8516        const MAX_PATH: usize = 260;
8517        // The real root on the machine this was found on.
8518        let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
8519        // What `actions/checkout` writes at its deepest: the work directory,
8520        // the repository named twice, and a pack keep-file with a 40-character
8521        // object name.
8522        let repo = "GitHub-Runner-Scaler-UI";
8523        let deepest = format!(
8524            r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
8525            "0".repeat(40)
8526        );
8527
8528        let name = workspace_name(AttemptId::new_random());
8529        assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
8530        assert!(
8531            name.chars().all(|c| c.is_ascii_hexdigit()),
8532            "a directory name must not carry the identifier's dashes: {name}"
8533        );
8534
8535        let full = format!(r"{root}\{name}\{deepest}");
8536        assert!(
8537            full.len() < MAX_PATH,
8538            "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
8539            full.len()
8540        );
8541
8542        // The discriminator: the layout this replaced does not fit, so a test
8543        // that passed for both would be proving nothing.
8544        let old = format!(
8545            r"{root}\{}\{}\{deepest}",
8546            PolicyId::new_random(),
8547            AttemptId::new_random()
8548        );
8549        assert!(
8550            old.len() > MAX_PATH,
8551            "the old layout is supposed to be the thing that did not fit: {} characters",
8552            old.len()
8553        );
8554    }
8555
8556    #[tokio::test]
8557    async fn two_attempts_never_share_a_workspace_even_after_failure() {
8558        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8559        harness.ready().await;
8560        let first = harness.launch().await;
8561        fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
8562        harness
8563            .github
8564            .observe(GithubRunnerObservation::Registered { busy: false });
8565        harness.launcher.supervise(&harness.policy).await.unwrap();
8566        harness.clock.advance_secs(11);
8567        harness.processes.set_alive(false);
8568        harness
8569            .github
8570            .observe(GithubRunnerObservation::NotRegistered);
8571        harness.launcher.supervise(&harness.policy).await.unwrap();
8572        assert!(!first.runtime_path().exists());
8573
8574        let second = harness.launch().await;
8575        assert_ne!(first.runtime_path(), second.runtime_path());
8576        assert!(!second.runtime_path().join("hostile-leftover").exists());
8577
8578        fs::write(
8579            second.runtime_path().join("hostile-on-failure"),
8580            b"second job",
8581        )
8582        .unwrap();
8583        harness.processes.set_alive(false);
8584        harness
8585            .github
8586            .observe(GithubRunnerObservation::NotRegistered);
8587        harness.launcher.supervise(&harness.policy).await.unwrap();
8588        assert!(
8589            !second.runtime_path().exists(),
8590            "failed workspace was retained"
8591        );
8592    }
8593
8594    #[tokio::test]
8595    async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
8596        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8597        harness.ready().await;
8598        let attempt = harness.launch().await;
8599
8600        // Registered and waiting, which is where it stays: the fake keeps
8601        // answering the same observation, exactly as GitHub does for a runner
8602        // nobody assigns work to.
8603        harness
8604            .github
8605            .observe(GithubRunnerObservation::Registered { busy: false });
8606        harness.launcher.supervise(&harness.policy).await.unwrap();
8607        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8608
8609        // One second inside the ten-second idle timeout nothing happens, which
8610        // is what keeps this from being a test that would pass on any clock.
8611        harness.clock.advance_secs(9);
8612        harness
8613            .github
8614            .observe(GithubRunnerObservation::Registered { busy: false });
8615        let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
8616        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8617        assert!(none_yet.is_empty());
8618        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8619
8620        // Past it, the agent ends the runner itself.
8621        harness.clock.advance_secs(1);
8622        harness
8623            .github
8624            .observe(GithubRunnerObservation::Registered { busy: false });
8625        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8626
8627        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8628        assert_eq!(
8629            concluded.outcome(),
8630            Some(&AttemptOutcome::ExitedIdleWithoutWork),
8631            "a surplus runner did not fail; recording one as a failure sends an operator \
8632             hunting a fault that does not exist"
8633        );
8634        assert_eq!(concluded.state(), AttemptState::Cleaned);
8635        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8636        assert!(!attempt.runtime_path().exists());
8637
8638        // The registration goes with it. Without this the runner stays listed
8639        // in the target's runner settings after the process it named is gone.
8640        assert_eq!(
8641            *harness.github.deregistrations.lock().unwrap(),
8642            vec![73],
8643            "the attempt's own runner id, deleted exactly once"
8644        );
8645
8646        // And nothing is started in its place: the work it was launched for went
8647        // elsewhere, so a replacement would rebuild it every idle timeout.
8648        assert!(
8649            replacements.is_empty(),
8650            "a surplus exit must not request a replacement"
8651        );
8652    }
8653
8654    #[tokio::test]
8655    async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
8656        // The delete is best-effort by construction: the process is gone and the
8657        // slot has to come back. Holding the conclusion until GitHub cooperates
8658        // would leak a capacity slot on every unreachable moment.
8659        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8660        harness.ready().await;
8661        let attempt = harness.launch().await;
8662        harness
8663            .github
8664            .observe(GithubRunnerObservation::Registered { busy: false });
8665        harness.launcher.supervise(&harness.policy).await.unwrap();
8666
8667        harness
8668            .github
8669            .deregistration_fails
8670            .store(true, Ordering::SeqCst);
8671        harness.clock.advance_secs(11);
8672        harness
8673            .github
8674            .observe(GithubRunnerObservation::Registered { busy: false });
8675        harness.launcher.supervise(&harness.policy).await.unwrap();
8676
8677        assert_eq!(
8678            *harness.github.deregistrations.lock().unwrap(),
8679            vec![73],
8680            "the delete was attempted"
8681        );
8682        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8683        assert_eq!(
8684            concluded.outcome(),
8685            Some(&AttemptOutcome::ExitedIdleWithoutWork),
8686            "the attempt concluded anyway"
8687        );
8688        assert_eq!(concluded.state(), AttemptState::Cleaned);
8689        assert!(!attempt.runtime_path().exists());
8690    }
8691
8692    #[tokio::test]
8693    async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
8694        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8695        harness.ready().await;
8696        let first = harness.launch().await;
8697        harness.processes.set_alive(false);
8698        harness
8699            .github
8700            .observe(GithubRunnerObservation::NotRegistered);
8701        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8702        let failed = harness.store.attempt(first.id).unwrap().unwrap();
8703        assert!(matches!(
8704            failed.outcome(),
8705            Some(AttemptOutcome::Failed {
8706                reason: FailureReason::ProcessExitedUnexpectedly
8707            })
8708        ));
8709        assert!(!first.runtime_path().exists());
8710
8711        assert_eq!(
8712            replacements,
8713            vec![ReplacementIntent {
8714                policy: harness.policy.id,
8715                previous_attempt: first.id,
8716                operation: "exit_before_acceptance_replacement",
8717            }]
8718        );
8719        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8720        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8721        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8722        assert!(harness.delay.0.lock().unwrap().is_empty());
8723    }
8724
8725    /// Writes the runner's own diagnostics: always the listener's log, and the
8726    /// job worker's when `ran_a_job`. `Runner.Listener` starts `Runner.Worker`
8727    /// only for a job it has acquired.
8728    fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
8729        let diag = runtime.join("_diag");
8730        fs::create_dir_all(&diag).unwrap();
8731        fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
8732        if ran_a_job {
8733            fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
8734        }
8735    }
8736
8737    /// The macOS field case: a job of a few seconds starts and ends between two
8738    /// passes, so GitHub was never seen reporting the runner busy -- the attempt
8739    /// is still `starting` -- and the registration is already gone. It used to be
8740    /// recorded `process_exited_unexpectedly`, with a replacement runner.
8741    #[tokio::test]
8742    async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
8743        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8744        harness.ready().await;
8745        let started = harness.launch().await;
8746        assert_eq!(started.state(), AttemptState::Starting);
8747        write_runner_diagnostics(started.runtime_path(), true);
8748        harness.processes.finish_successfully();
8749        harness
8750            .github
8751            .observe(GithubRunnerObservation::NotRegistered);
8752
8753        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8754
8755        let cleaned = harness.only_attempt();
8756        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8757        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8758        assert_eq!(cleaned.github_runner_id(), Some(73));
8759        assert!(
8760            replacements.is_empty(),
8761            "a finished job needs no replacement"
8762        );
8763        let busy_seen = harness.events.events().into_iter().any(|event| {
8764            matches!(
8765                event,
8766                AttemptEvent::State {
8767                    state: AttemptState::Busy,
8768                    ..
8769                }
8770            )
8771        });
8772        assert!(busy_seen, "the journal walks the diagram to `busy` first");
8773    }
8774
8775    /// The same from `idle`: seen registered and waiting once, then gone.
8776    #[tokio::test]
8777    async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
8778        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8779        harness.ready().await;
8780        let started = harness.launch().await;
8781        harness
8782            .github
8783            .observe(GithubRunnerObservation::Registered { busy: false });
8784        harness.launcher.supervise(&harness.policy).await.unwrap();
8785        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8786
8787        write_runner_diagnostics(started.runtime_path(), true);
8788        harness.processes.finish_successfully();
8789        harness
8790            .github
8791            .observe(GithubRunnerObservation::NotRegistered);
8792        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8793
8794        assert_eq!(
8795            harness.only_attempt().outcome(),
8796            Some(&AttemptOutcome::CompletedJob)
8797        );
8798        assert!(replacements.is_empty());
8799    }
8800
8801    /// The reverse error, from the same sampling: GitHub showed the runner
8802    /// assigned, the runner never started the job (no worker log), and it exited
8803    /// 0. That is not a completed job.
8804    #[tokio::test]
8805    async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
8806        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8807        harness.ready().await;
8808        let started = harness.launch().await;
8809        harness
8810            .github
8811            .observe(GithubRunnerObservation::Registered { busy: true });
8812        harness.launcher.supervise(&harness.policy).await.unwrap();
8813        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8814
8815        write_runner_diagnostics(started.runtime_path(), false);
8816        harness.processes.finish_successfully();
8817        harness
8818            .github
8819            .observe(GithubRunnerObservation::NotRegistered);
8820        harness.launcher.supervise(&harness.policy).await.unwrap();
8821
8822        let cleaned = harness.only_attempt();
8823        assert_eq!(
8824            cleaned.outcome(),
8825            Some(&AttemptOutcome::ExitedIdleWithoutWork)
8826        );
8827        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8828    }
8829
8830    /// The mirror case: the runner really ran its job and exited 0 before GitHub
8831    /// took it off the list. It used to be recorded `orphaned`.
8832    #[tokio::test]
8833    async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
8834        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8835        harness.ready().await;
8836        let started = harness.launch().await;
8837        harness
8838            .github
8839            .observe(GithubRunnerObservation::Registered { busy: true });
8840        harness.launcher.supervise(&harness.policy).await.unwrap();
8841
8842        write_runner_diagnostics(started.runtime_path(), true);
8843        harness.processes.finish_successfully();
8844        harness
8845            .github
8846            .observe(GithubRunnerObservation::Registered { busy: true });
8847        harness.launcher.supervise(&harness.policy).await.unwrap();
8848
8849        assert_eq!(
8850            harness.only_attempt().outcome(),
8851            Some(&AttemptOutcome::CompletedJob)
8852        );
8853        assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
8854    }
8855
8856    /// A live runner journalled `busy` whose assignment GitHub withdrew goes
8857    /// back to `idle`, where the idle timeout holds it again.
8858    #[tokio::test]
8859    async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
8860        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8861        harness.ready().await;
8862        let started = harness.launch().await;
8863        harness
8864            .github
8865            .observe(GithubRunnerObservation::Registered { busy: true });
8866        harness.launcher.supervise(&harness.policy).await.unwrap();
8867        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8868
8869        write_runner_diagnostics(started.runtime_path(), false);
8870        harness
8871            .github
8872            .observe(GithubRunnerObservation::Registered { busy: false });
8873        harness.launcher.supervise(&harness.policy).await.unwrap();
8874        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8875
8876        // A runner that did start its job keeps `busy` whatever GitHub says.
8877        harness
8878            .github
8879            .observe(GithubRunnerObservation::Registered { busy: true });
8880        harness.launcher.supervise(&harness.policy).await.unwrap();
8881        write_runner_diagnostics(started.runtime_path(), true);
8882        harness
8883            .github
8884            .observe(GithubRunnerObservation::Registered { busy: false });
8885        harness.launcher.supervise(&harness.policy).await.unwrap();
8886        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8887    }
8888
8889    /// While GitHub cannot be reached nothing is concluded, so a registration
8890    /// still listed there is removed once it answers.
8891    #[tokio::test]
8892    async fn a_clean_exit_waits_for_github_to_answer() {
8893        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8894        harness.ready().await;
8895        let started = harness.launch().await;
8896        write_runner_diagnostics(started.runtime_path(), true);
8897        harness.processes.finish_successfully();
8898        harness.github.observe(GithubRunnerObservation::Unreachable);
8899        harness.launcher.supervise(&harness.policy).await.unwrap();
8900        assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
8901
8902        harness
8903            .github
8904            .observe(GithubRunnerObservation::NotRegistered);
8905        harness.launcher.supervise(&harness.policy).await.unwrap();
8906        assert_eq!(
8907            harness.only_attempt().outcome(),
8908            Some(&AttemptOutcome::CompletedJob)
8909        );
8910    }
8911
8912    /// A runner id nobody can name is not a reason to stop supervising the
8913    /// policy: the attempt is concluded the way it was before, without one.
8914    #[tokio::test]
8915    async fn a_short_job_with_no_runner_id_still_concludes() {
8916        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8917        harness.ready().await;
8918        let started = harness.launch().await;
8919        fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
8920        write_runner_diagnostics(started.runtime_path(), true);
8921        harness.processes.finish_successfully();
8922        harness
8923            .github
8924            .observe(GithubRunnerObservation::NotRegistered);
8925        harness
8926            .launcher
8927            .supervise(&harness.policy)
8928            .await
8929            .expect("supervision goes on");
8930        assert!(harness.only_attempt().is_terminal());
8931    }
8932
8933    /// Without the runner's diagnostics nothing changes: a clean exit seen only
8934    /// at `starting` is still a runner that exited before taking a job.
8935    #[tokio::test]
8936    async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
8937        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8938        harness.ready().await;
8939        harness.launch().await;
8940        harness.processes.finish_successfully();
8941        harness
8942            .github
8943            .observe(GithubRunnerObservation::NotRegistered);
8944        harness.launcher.supervise(&harness.policy).await.unwrap();
8945        assert!(matches!(
8946            harness.only_attempt().outcome(),
8947            Some(AttemptOutcome::Failed {
8948                reason: FailureReason::ProcessExitedUnexpectedly
8949            })
8950        ));
8951    }
8952
8953    #[tokio::test]
8954    async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
8955        let harness = Harness::new(
8956            FakeGithubLifecycle::default(),
8957            Arc::new(FakeDemand::answering([false])),
8958        );
8959        let id = AttemptId::new_random();
8960        let runtime = harness
8961            .launcher
8962            .app_paths
8963            .runtime_dir()
8964            .join(harness.policy.id.to_string())
8965            .join(id.to_string());
8966        fs::create_dir_all(&runtime).unwrap();
8967        let mut attempt =
8968            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8969        attempt.jit_received(harness.clock.now()).unwrap();
8970        harness.store.record_attempt(&attempt).unwrap();
8971        harness.clock.advance_secs(11);
8972        let replacements = harness
8973            .launcher
8974            .recover_startup(std::slice::from_ref(&harness.policy))
8975            .await
8976            .unwrap();
8977        assert_eq!(
8978            replacements,
8979            vec![ReplacementIntent {
8980                policy: harness.policy.id,
8981                previous_attempt: id,
8982                operation: "jit_expired_replacement",
8983            }]
8984        );
8985
8986        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8987        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8988        assert!(matches!(
8989            cleaned.outcome(),
8990            Some(AttemptOutcome::Failed {
8991                reason: FailureReason::JitExpired
8992            })
8993        ));
8994        assert!(!runtime.exists());
8995        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8996        assert!(harness.delay.0.lock().unwrap().is_empty());
8997    }
8998
8999    #[tokio::test]
9000    async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
9001        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9002        let id = AttemptId::new_random();
9003        let runtime = harness
9004            .launcher
9005            .app_paths
9006            .runtime_dir()
9007            .join("expired-with-demand");
9008        fs::create_dir_all(&runtime).unwrap();
9009        let mut attempt =
9010            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9011        attempt.jit_received(harness.clock.now()).unwrap();
9012        harness.store.record_attempt(&attempt).unwrap();
9013        harness.clock.advance_secs(11);
9014        let replacements = harness
9015            .launcher
9016            .recover_startup(std::slice::from_ref(&harness.policy))
9017            .await
9018            .unwrap();
9019
9020        let attempts = harness.store.attempts().unwrap();
9021        assert_eq!(attempts.len(), 1);
9022        assert_eq!(
9023            attempts
9024                .iter()
9025                .find(|attempt| attempt.id == id)
9026                .unwrap()
9027                .state(),
9028            AttemptState::Cleaned
9029        );
9030        assert_eq!(
9031            replacements,
9032            vec![ReplacementIntent {
9033                policy: harness.policy.id,
9034                previous_attempt: id,
9035                operation: "jit_expired_replacement",
9036            }]
9037        );
9038        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
9039        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
9040        assert!(harness.delay.0.lock().unwrap().is_empty());
9041    }
9042
9043    #[tokio::test]
9044    async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
9045        let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9046        persistent.packages.fail_materializations(2);
9047        persistent.ready().await;
9048        persistent.launch().await;
9049        assert_eq!(
9050            persistent.packages.materializations.load(Ordering::SeqCst),
9051            3
9052        );
9053        assert_eq!(
9054            *persistent.delay.0.lock().unwrap(),
9055            vec![Duration::from_millis(10), Duration::from_millis(20)]
9056        );
9057
9058        let gone_before_wait = Harness::new(
9059            FakeGithubLifecycle::default(),
9060            Arc::new(FakeDemand::answering([false])),
9061        );
9062        gone_before_wait.packages.fail_materializations(3);
9063        gone_before_wait.ready().await;
9064        assert!(gone_before_wait.launch_result().await.is_err());
9065        assert_eq!(
9066            gone_before_wait
9067                .packages
9068                .materializations
9069                .load(Ordering::SeqCst),
9070            1
9071        );
9072        assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
9073
9074        let gone_during_wait = Harness::new(
9075            FakeGithubLifecycle::default(),
9076            Arc::new(FakeDemand::answering([true, false])),
9077        );
9078        gone_during_wait.packages.fail_materializations(3);
9079        gone_during_wait.ready().await;
9080        assert!(gone_during_wait.launch_result().await.is_err());
9081        assert_eq!(
9082            gone_during_wait
9083                .packages
9084                .materializations
9085                .load(Ordering::SeqCst),
9086            1
9087        );
9088        assert_eq!(
9089            *gone_during_wait.delay.0.lock().unwrap(),
9090            vec![Duration::from_millis(10)]
9091        );
9092    }
9093
9094    #[tokio::test]
9095    async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
9096        let harness = Harness::new(
9097            FakeGithubLifecycle::default(),
9098            Arc::new(FakeDemand::answering([true, false])),
9099        );
9100        harness.ready().await;
9101        let first = harness.launch().await;
9102        harness.processes.set_alive(false);
9103        harness
9104            .github
9105            .observe(GithubRunnerObservation::NotRegistered);
9106        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
9107
9108        assert_eq!(harness.store.attempts().unwrap().len(), 1);
9109        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
9110        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
9111        assert!(harness.delay.0.lock().unwrap().is_empty());
9112        assert_eq!(
9113            replacements,
9114            vec![ReplacementIntent {
9115                policy: harness.policy.id,
9116                previous_attempt: first.id,
9117                operation: "exit_before_acceptance_replacement",
9118            }]
9119        );
9120        assert_eq!(
9121            harness.store.attempt(first.id).unwrap().unwrap().state(),
9122            AttemptState::Cleaned
9123        );
9124    }
9125
9126    #[tokio::test]
9127    async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
9128        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9129        let before = harness.launch_result().await;
9130        assert!(before.is_err());
9131        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
9132
9133        let id = AttemptId::new_random();
9134        let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
9135        fs::create_dir_all(&runtime).unwrap();
9136        let mut attempt =
9137            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9138        attempt.jit_received(harness.clock.now()).unwrap();
9139        attempt.started(4242, harness.clock.now()).unwrap();
9140        harness.store.record_attempt(&attempt).unwrap();
9141        harness.processes.set_alive(true);
9142        harness
9143            .github
9144            .observe(GithubRunnerObservation::NotRegistered);
9145        let replacements = harness
9146            .launcher
9147            .recover_startup(std::slice::from_ref(&harness.policy))
9148            .await
9149            .unwrap();
9150        assert!(replacements.is_empty());
9151        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
9152        assert!(
9153            harness
9154                .events
9155                .events()
9156                .contains(&AttemptEvent::Adopted { attempt: id })
9157        );
9158    }
9159
9160    #[tokio::test]
9161    async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
9162        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9163        let id = AttemptId::new_random();
9164        let runtime = harness
9165            .launcher
9166            .app_paths
9167            .runtime_dir()
9168            .join("spawn-before-starting");
9169        fs::create_dir_all(&runtime).unwrap();
9170        let mut attempt =
9171            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9172        attempt.jit_received(harness.clock.now()).unwrap();
9173        harness.store.record_attempt(&attempt).unwrap();
9174        harness.processes.set_alive(true);
9175        harness
9176            .github
9177            .observe(GithubRunnerObservation::Registered { busy: true });
9178
9179        let replacements = harness
9180            .launcher
9181            .recover_startup(std::slice::from_ref(&harness.policy))
9182            .await
9183            .unwrap();
9184        assert!(replacements.is_empty());
9185        let recovered = harness.store.attempt(id).unwrap().unwrap();
9186        assert_eq!(recovered.state(), AttemptState::Busy);
9187        assert_eq!(recovered.process_id(), Some(4242));
9188        assert_eq!(recovered.github_runner_id(), Some(73));
9189        let events = harness.events.events();
9190        let starting = events
9191            .iter()
9192            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
9193            .unwrap();
9194        let busy = events
9195            .iter()
9196            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
9197            .unwrap();
9198        assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
9199
9200        harness.processes.finish_successfully();
9201        harness
9202            .github
9203            .observe(GithubRunnerObservation::NotRegistered);
9204        assert!(
9205            harness
9206                .launcher
9207                .supervise(&harness.policy)
9208                .await
9209                .unwrap()
9210                .is_empty()
9211        );
9212        let cleaned = harness.store.attempt(id).unwrap().unwrap();
9213        assert_eq!(cleaned.state(), AttemptState::Cleaned);
9214        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
9215        assert!(!runtime.exists());
9216    }
9217
9218    #[tokio::test]
9219    async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
9220        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9221        harness.processes.fail_spawn_with_live_child();
9222        harness.ready().await;
9223        assert!(harness.launch_result().await.is_err());
9224
9225        let attempt = harness.only_attempt();
9226        assert_eq!(attempt.state(), AttemptState::Starting);
9227        assert_eq!(attempt.process_id(), Some(4242));
9228        assert!(attempt.outcome().is_none());
9229        assert!(attempt.state().counts_against_capacity());
9230        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
9231        assert!(harness.delay.0.lock().unwrap().is_empty());
9232
9233        harness.processes.set_alive(false);
9234        harness
9235            .github
9236            .observe(GithubRunnerObservation::NotRegistered);
9237        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
9238        assert_eq!(replacements.len(), 1);
9239        assert_eq!(
9240            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
9241            AttemptState::Cleaned
9242        );
9243    }
9244
9245    #[tokio::test]
9246    async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
9247        for sidecar_already_present in [false, true] {
9248            let harness = Harness::new(
9249                FakeGithubLifecycle::default(),
9250                Arc::new(FakeDemand::answering([false])),
9251            );
9252            let id = AttemptId::new_random();
9253            let runtime =
9254                harness
9255                    .launcher
9256                    .app_paths
9257                    .runtime_dir()
9258                    .join(if sidecar_already_present {
9259                        "after-id-sidecar"
9260                    } else {
9261                        "before-id-sidecar"
9262                    });
9263            fs::create_dir_all(&runtime).unwrap();
9264            let mut attempt =
9265                RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9266            if sidecar_already_present {
9267                write_runner_id(&runtime, 73).unwrap();
9268                attempt.jit_received(harness.clock.now()).unwrap();
9269            }
9270            harness.store.record_attempt(&attempt).unwrap();
9271            harness.processes.set_alive(true);
9272            harness
9273                .github
9274                .observe(GithubRunnerObservation::Registered { busy: false });
9275            harness
9276                .launcher
9277                .recover_startup(std::slice::from_ref(&harness.policy))
9278                .await
9279                .unwrap();
9280
9281            assert_eq!(read_runner_id(&runtime), Some(73));
9282            assert!(
9283                harness
9284                    .store
9285                    .attempt(id)
9286                    .unwrap()
9287                    .unwrap()
9288                    .outcome()
9289                    .is_none()
9290            );
9291            let events = harness.events.events();
9292            let recovered = events.iter().position(|event| {
9293                matches!(
9294                    event,
9295                    AttemptEvent::RemoteIdentityRecovered {
9296                        attempt,
9297                        runner_id: 73
9298                    } if *attempt == id
9299                )
9300            });
9301            assert_eq!(recovered.is_some(), !sidecar_already_present);
9302            if let Some(recovered) = recovered {
9303                let adopted = events
9304                    .iter()
9305                    .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
9306                    .unwrap();
9307                assert!(
9308                    recovered < adopted,
9309                    "identity was not durable before adoption: {events:?}"
9310                );
9311            }
9312            assert!(runtime.exists());
9313        }
9314    }
9315
9316    #[tokio::test]
9317    async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
9318        let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9319        let unknown_attempt = RunnerAttempt::allocate(
9320            AttemptId::new_random(),
9321            PolicyId::from_u128(0xfeed),
9322            unknown
9323                .launcher
9324                .app_paths
9325                .runtime_dir()
9326                .join("unknown-policy"),
9327            unknown.clock.now(),
9328        );
9329        unknown.store.record_attempt(&unknown_attempt).unwrap();
9330        let expired_id = AttemptId::new_random();
9331        let expired_runtime = unknown
9332            .launcher
9333            .app_paths
9334            .runtime_dir()
9335            .join("expired-beside-unknown");
9336        fs::create_dir_all(&expired_runtime).unwrap();
9337        let mut expired = RunnerAttempt::allocate(
9338            expired_id,
9339            unknown.policy.id,
9340            expired_runtime,
9341            unknown.clock.now(),
9342        );
9343        expired.jit_received(unknown.clock.now()).unwrap();
9344        unknown.store.record_attempt(&expired).unwrap();
9345        unknown.clock.advance_secs(11);
9346        assert!(matches!(
9347            unknown
9348                .launcher
9349                .recover_startup(std::slice::from_ref(&unknown.policy))
9350                .await,
9351            Err(LifecycleError::RecoveryIncomplete)
9352        ));
9353        assert!(unknown.launch_result().await.is_err());
9354        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
9355        let recovered_policy = fixtures::policy()
9356            .id(PolicyId::from_u128(0xfeed))
9357            .repository("octo/repo")
9358            .autoscale("home", 2)
9359            .active()
9360            .build();
9361        let pending = unknown
9362            .launcher
9363            .recover_startup(&[unknown.policy.clone(), recovered_policy])
9364            .await
9365            .unwrap();
9366        assert_eq!(
9367            pending,
9368            vec![ReplacementIntent {
9369                policy: unknown.policy.id,
9370                previous_attempt: expired_id,
9371                operation: "jit_expired_replacement",
9372            }]
9373        );
9374        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
9375
9376        let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9377        let id = AttemptId::new_random();
9378        let runtime = unreachable
9379            .launcher
9380            .app_paths
9381            .runtime_dir()
9382            .join("unreachable");
9383        fs::create_dir_all(&runtime).unwrap();
9384        unreachable
9385            .store
9386            .record_attempt(&RunnerAttempt::allocate(
9387                id,
9388                unreachable.policy.id,
9389                runtime,
9390                unreachable.clock.now(),
9391            ))
9392            .unwrap();
9393        unreachable
9394            .github
9395            .observe(GithubRunnerObservation::Unreachable);
9396        assert!(matches!(
9397            unreachable
9398                .launcher
9399                .recover_startup(std::slice::from_ref(&unreachable.policy))
9400                .await,
9401            Err(LifecycleError::RecoveryIncomplete)
9402        ));
9403        assert!(unreachable.launch_result().await.is_err());
9404        assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
9405    }
9406
9407    #[tokio::test]
9408    async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
9409        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9410        let id = AttemptId::new_random();
9411        let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
9412        fs::create_dir_all(&runtime).unwrap();
9413        let mut attempt =
9414            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9415        attempt.jit_received(harness.clock.now()).unwrap();
9416        attempt.started(4242, harness.clock.now()).unwrap();
9417        attempt.assigned_job(73, harness.clock.now()).unwrap();
9418        harness.store.record_attempt(&attempt).unwrap();
9419        harness.processes.set_alive(false);
9420        harness
9421            .github
9422            .observe(GithubRunnerObservation::NotRegistered);
9423        harness
9424            .launcher
9425            .recover_startup(std::slice::from_ref(&harness.policy))
9426            .await
9427            .unwrap();
9428        let cleaned = harness.store.attempt(id).unwrap().unwrap();
9429        assert_eq!(cleaned.state(), AttemptState::Cleaned);
9430        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
9431        assert!(!runtime.exists());
9432    }
9433
9434    #[tokio::test]
9435    async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
9436        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9437        harness.ready().await;
9438        let id = AttemptId::new_random();
9439        let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
9440        fs::create_dir_all(&runtime).unwrap();
9441        let mut attempt =
9442            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9443        attempt.jit_received(harness.clock.now()).unwrap();
9444        attempt.started(4242, harness.clock.now()).unwrap();
9445        harness.store.record_attempt(&attempt).unwrap();
9446        harness.clock.advance_secs(11);
9447        harness.processes.set_alive(true);
9448        harness
9449            .github
9450            .observe(GithubRunnerObservation::NotRegistered);
9451        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
9452        assert_eq!(
9453            replacements,
9454            vec![ReplacementIntent {
9455                policy: harness.policy.id,
9456                previous_attempt: id,
9457                operation: "registration_timeout_replacement",
9458            }]
9459        );
9460
9461        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
9462        assert!(!harness.processes.alive.load(Ordering::SeqCst));
9463        let actions = harness.processes.actions.lock().unwrap().clone();
9464        let intent = actions
9465            .iter()
9466            .position(|action| *action == "terminate_intent")
9467            .unwrap();
9468        let signal = actions
9469            .iter()
9470            .position(|action| *action == "terminate")
9471            .unwrap();
9472        assert!(
9473            intent < signal,
9474            "intent was not durable before signal: {actions:?}"
9475        );
9476
9477        let cleaned = harness.store.attempt(id).unwrap().unwrap();
9478        assert!(matches!(
9479            cleaned.outcome(),
9480            Some(AttemptOutcome::Failed {
9481                reason: FailureReason::TerminatedAfterRegistrationTimeout
9482            })
9483        ));
9484        let events = harness.events.events();
9485        let intent = events
9486            .iter()
9487            .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
9488            .unwrap();
9489        let stopped = events
9490            .iter()
9491            .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
9492            .unwrap();
9493        let concluded = events
9494            .iter()
9495            .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
9496            .unwrap();
9497        assert!(intent < stopped && stopped < concluded, "{events:?}");
9498    }
9499
9500    #[tokio::test]
9501    async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
9502        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9503        let id = AttemptId::new_random();
9504        let runtime = harness
9505            .launcher
9506            .app_paths
9507            .runtime_dir()
9508            .join("timeout-after-crash");
9509        fs::create_dir_all(&runtime).unwrap();
9510        let mut attempt =
9511            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9512        attempt.jit_received(harness.clock.now()).unwrap();
9513        attempt.started(4242, harness.clock.now()).unwrap();
9514        harness.store.record_attempt(&attempt).unwrap();
9515        harness.processes.intent.store(true, Ordering::SeqCst);
9516        harness.processes.set_alive(false);
9517        harness
9518            .github
9519            .observe(GithubRunnerObservation::NotRegistered);
9520
9521        let replacements = harness
9522            .launcher
9523            .recover_startup(std::slice::from_ref(&harness.policy))
9524            .await
9525            .unwrap();
9526        assert_eq!(
9527            replacements,
9528            vec![ReplacementIntent {
9529                policy: harness.policy.id,
9530                previous_attempt: id,
9531                operation: "registration_timeout_replacement",
9532            }]
9533        );
9534        let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9535            .await
9536            .unwrap();
9537        assert_eq!(consumed, replacements);
9538        assert!(
9539            RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9540                .await
9541                .unwrap()
9542                .is_empty(),
9543            "startup replacement evidence must be consumed exactly once by e1"
9544        );
9545        assert!(matches!(
9546            harness.store.attempt(id).unwrap().unwrap().outcome(),
9547            Some(AttemptOutcome::Failed {
9548                reason: FailureReason::TerminatedAfterRegistrationTimeout
9549            })
9550        ));
9551    }
9552
9553    #[tokio::test]
9554    async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
9555        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9556        let id = AttemptId::new_random();
9557        let runtime = harness
9558            .launcher
9559            .app_paths
9560            .runtime_dir()
9561            .join("timeout-sync-failure");
9562        fs::create_dir_all(&runtime).unwrap();
9563        let mut attempt =
9564            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9565        attempt.jit_received(harness.clock.now()).unwrap();
9566        attempt.started(4242, harness.clock.now()).unwrap();
9567        harness.store.record_attempt(&attempt).unwrap();
9568        harness.clock.advance_secs(11);
9569        harness.processes.set_alive(true);
9570        harness.processes.fail_intent();
9571        harness
9572            .github
9573            .observe(GithubRunnerObservation::NotRegistered);
9574
9575        assert!(
9576            harness
9577                .launcher
9578                .recover_startup(std::slice::from_ref(&harness.policy))
9579                .await
9580                .is_err()
9581        );
9582        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
9583        assert!(harness.processes.alive.load(Ordering::SeqCst));
9584        assert_eq!(
9585            harness.store.attempt(id).unwrap().unwrap().state(),
9586            AttemptState::Starting
9587        );
9588        assert!(!harness.events.events().iter().any(|event| matches!(
9589            event,
9590            AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
9591                if *attempt == id
9592        )));
9593    }
9594
9595    #[tokio::test]
9596    async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
9597        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9598        harness.ready().await;
9599        let attempt = harness.launch().await;
9600        harness
9601            .github
9602            .observe(GithubRunnerObservation::Registered { busy: false });
9603        harness.launcher.supervise(&harness.policy).await.unwrap();
9604        harness.clock.advance_secs(11);
9605        harness.processes.set_alive(false);
9606        harness
9607            .github
9608            .observe(GithubRunnerObservation::NotRegistered);
9609        harness.launcher.supervise(&harness.policy).await.unwrap();
9610        let diagnostic = fs::read_to_string(
9611            harness
9612                .launcher
9613                .diagnostics_root
9614                .join(format!("{}.log", attempt.id)),
9615        )
9616        .unwrap();
9617        assert!(diagnostic.contains("exited_idle_without_work"));
9618        assert!(!diagnostic.contains(JIT));
9619        assert!(!diagnostic.contains("ghp_"));
9620        assert!(!attempt.runtime_path().exists());
9621    }
9622
9623    #[test]
9624    fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
9625        let root = tempfile::tempdir().unwrap();
9626        let policy = fixtures::policy()
9627            .repository("octo/repo")
9628            .autoscale("home", 1)
9629            .active()
9630            .build();
9631        let runtime = root.path().join("successful");
9632        fs::create_dir_all(&runtime).unwrap();
9633        let processes = NativeProcesses::new();
9634        let config = EncodedJitConfig::new(JIT);
9635        let handoff =
9636            RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
9637                .unwrap();
9638        let mut child = native_inspection_spec()
9639            .spawn_runner_with_handoff(&handoff)
9640            .expect("native child starts");
9641        let pid = child.pid();
9642        handoff.delete().unwrap();
9643        let command_line = native_command_line(pid);
9644        assert!(
9645            !command_line.contains(JIT),
9646            "the encoded JIT configuration appeared in the native process listing"
9647        );
9648        assert_no_jit_file(&runtime);
9649        child
9650            .stop(Duration::from_secs(1))
9651            .expect("native child stops");
9652
9653        let failed_runtime = root.path().join("failed");
9654        fs::create_dir_all(&failed_runtime).unwrap();
9655        let failed = RunnerAttempt::allocate(
9656            AttemptId::new_random(),
9657            policy.id,
9658            &failed_runtime,
9659            FakeClock::default().now(),
9660        );
9661        assert!(
9662            processes
9663                .spawn(&failed, &EncodedJitConfig::new(JIT))
9664                .is_err(),
9665            "a runtime with no runner executable must fail"
9666        );
9667        assert_no_jit_file(&failed_runtime);
9668        processes
9669            .record_terminate_intent(&failed)
9670            .expect("the intent file and its directory entry are durably synced");
9671        assert_eq!(
9672            fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
9673            b"registration-timeout\n"
9674        );
9675    }
9676
9677    #[test]
9678    fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
9679        let root = tempfile::tempdir().unwrap();
9680        let policy = fixtures::policy()
9681            .repository("octo/repo")
9682            .autoscale("home", 1)
9683            .active()
9684            .build();
9685        let processes = NativeProcesses::new();
9686        processes.use_long_lived_test_listener();
9687        for (index, boundary) in [
9688            PostSpawnBoundary::HandoffDelete,
9689            PostSpawnBoundary::IdentitySerialize,
9690            PostSpawnBoundary::IdentityWrite,
9691            PostSpawnBoundary::ChildMapInsert,
9692        ]
9693        .into_iter()
9694        .enumerate()
9695        {
9696            let runtime = root.path().join(format!("post-spawn-{index}"));
9697            let bin = runtime.join("bin");
9698            fs::create_dir_all(&bin).unwrap();
9699            #[cfg(windows)]
9700            let listener = bin.join("Runner.Listener.exe");
9701            #[cfg(not(windows))]
9702            let listener = bin.join("Runner.Listener");
9703            fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9704            let attempt = RunnerAttempt::allocate(
9705                AttemptId::new_random(),
9706                policy.id,
9707                &runtime,
9708                FakeClock::default().now(),
9709            );
9710            processes.fail_post_spawn_at(boundary);
9711            let failure = processes
9712                .spawn(&attempt, &EncodedJitConfig::new(JIT))
9713                .expect_err("fault must cross the post-spawn cleanup path");
9714            assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
9715            assert!(
9716                !processes.is_alive(&attempt).unwrap(),
9717                "{boundary:?} left a child"
9718            );
9719            assert!(!NativeProcesses::identity_path(&attempt).exists());
9720            assert_no_jit_file(&runtime);
9721        }
9722        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9723
9724        let runtime = root.path().join("identity-and-stop-fail");
9725        let bin = runtime.join("bin");
9726        fs::create_dir_all(&bin).unwrap();
9727        #[cfg(windows)]
9728        let listener = bin.join("Runner.Listener.exe");
9729        #[cfg(not(windows))]
9730        let listener = bin.join("Runner.Listener");
9731        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9732        let attempt = RunnerAttempt::allocate(
9733            AttemptId::new_random(),
9734            policy.id,
9735            &runtime,
9736            FakeClock::default().now(),
9737        );
9738        // The first fault rejects the normal identity write; the second rejects
9739        // its retry after the first stop fails. The fallback sidecar must make
9740        // the live-child result durable without an unbounded reap loop.
9741        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9742        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9743        processes.fail_next_post_spawn_stop();
9744        let failure = processes
9745            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9746            .expect_err("the identity boundary must fail closed");
9747        assert!(failure.live_pid.is_some());
9748        assert_long_lived_listener_ready(&processes, &attempt);
9749        assert!(processes.is_alive(&attempt).unwrap());
9750        assert!(!NativeProcesses::identity_path(&attempt).exists());
9751        assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
9752        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9753        processes.terminate(&attempt).unwrap();
9754
9755        let runtime = root.path().join("persistent-stop-and-identity-failures");
9756        let bin = runtime.join("bin");
9757        fs::create_dir_all(&bin).unwrap();
9758        #[cfg(windows)]
9759        let listener = bin.join("Runner.Listener.exe");
9760        #[cfg(not(windows))]
9761        let listener = bin.join("Runner.Listener");
9762        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9763        let mut unresolved = RunnerAttempt::allocate(
9764            AttemptId::new_random(),
9765            policy.id,
9766            &runtime,
9767            FakeClock::default().now(),
9768        );
9769        for _ in 0..3 {
9770            processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9771        }
9772        processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
9773        let failure = processes
9774            .spawn(&unresolved, &EncodedJitConfig::new(JIT))
9775            .expect_err("bounded cleanup must return even when every stop errors");
9776        let pid = failure
9777            .live_pid
9778            .expect("the owned child remains supervised in this invocation");
9779        assert!(matches!(failure.reason, FailureReason::Other(_)));
9780        assert_long_lived_listener_ready(&processes, &unresolved);
9781        unresolved.jit_received(FakeClock::default().now()).unwrap();
9782        unresolved.started(pid, FakeClock::default().now()).unwrap();
9783        let journal = SqliteStore::open_in_memory().unwrap();
9784        journal.record_attempt(&unresolved).unwrap();
9785        let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
9786        assert_eq!(recovered.process_id(), Some(pid));
9787        assert_eq!(recovered.state(), AttemptState::Starting);
9788        assert!(processes.is_alive(&unresolved).unwrap());
9789        assert!(!NativeProcesses::identity_path(&unresolved).exists());
9790        assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
9791        assert_eq!(
9792            fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
9793            pid.to_string(),
9794            "bounded cleanup must leave durable unresolved-process evidence before returning"
9795        );
9796        assert!(
9797            NativeProcesses::new().is_alive(&recovered).is_err(),
9798            "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
9799        );
9800        processes.terminate(&unresolved).unwrap();
9801
9802        let runtime = root.path().join("post-spawn-stop-failed");
9803        let bin = runtime.join("bin");
9804        fs::create_dir_all(&bin).unwrap();
9805        #[cfg(windows)]
9806        let listener = bin.join("Runner.Listener.exe");
9807        #[cfg(not(windows))]
9808        let listener = bin.join("Runner.Listener");
9809        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9810        let attempt = RunnerAttempt::allocate(
9811            AttemptId::new_random(),
9812            policy.id,
9813            &runtime,
9814            FakeClock::default().now(),
9815        );
9816        processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
9817        processes.fail_next_post_spawn_stop();
9818        let failure = processes
9819            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9820            .expect_err("the injected stop failure must preserve supervision");
9821        let live_pid = failure
9822            .live_pid
9823            .expect("live PID is returned to the journal");
9824        assert!(!failure.retryable);
9825        assert_long_lived_listener_ready(&processes, &attempt);
9826        assert!(NativeProcesses::identity_path(&attempt).is_file());
9827        assert_eq!(
9828            NativeProcesses::new()
9829                .read_identity(&attempt)
9830                .unwrap()
9831                .runner()
9832                .unwrap()
9833                .pid(),
9834            live_pid
9835        );
9836        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9837        processes.terminate(&attempt).unwrap();
9838    }
9839
9840    #[test]
9841    #[ignore = "spawned only as the platform-stable native listener fixture"]
9842    fn long_lived_native_listener_helper() {
9843        let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
9844            .map(PathBuf::from)
9845            .expect("the parent supplies the readiness path");
9846        // Atomically: the parent polls for the file and must never read it
9847        // created but not yet written.
9848        runner_manager_platform::host_fitness::write_atomically(&ready, b"ready\n")
9849            .expect("the listener publishes readiness");
9850        std::thread::sleep(Duration::from_secs(30));
9851    }
9852
9853    fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
9854        let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
9855        let deadline = std::time::Instant::now() + Duration::from_secs(5);
9856        loop {
9857            if ready.is_file() {
9858                assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
9859                return;
9860            }
9861            assert!(
9862                processes.is_alive(attempt).unwrap(),
9863                "the native listener exited before publishing readiness"
9864            );
9865            assert!(
9866                std::time::Instant::now() < deadline,
9867                "the native listener stayed alive but never published readiness"
9868            );
9869            std::thread::sleep(Duration::from_millis(10));
9870        }
9871    }
9872
9873    #[tokio::test]
9874    async fn a_reservation_is_journalled_before_any_effect_and_holds_its_slot() {
9875        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9876        harness.ready().await;
9877
9878        let reserved = harness.reserve().await.unwrap();
9879
9880        assert_eq!(reserved.state(), AttemptState::Allocated);
9881        assert!(reserved.counts_against_capacity());
9882        let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9883        assert_eq!(journalled.state(), AttemptState::Allocated);
9884        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
9885        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
9886
9887        let started = harness
9888            .launcher
9889            .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9890            .await
9891            .unwrap();
9892        assert_eq!(started.state(), AttemptState::Starting);
9893    }
9894
9895    /// A launch runs beside its target's pass: the pass does not read a runner
9896    /// still coming up as one that is gone. It has no process and no registration
9897    /// yet, and the bring-up is writing its journal entry.
9898    #[tokio::test]
9899    async fn a_pass_leaves_a_runner_coming_up_alone_and_supervises_it_once_up() {
9900        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9901        harness.ready().await;
9902        let reserved = harness.reserve().await.unwrap();
9903        let looks_left = || harness.github.observations.lock().unwrap().len();
9904        harness
9905            .github
9906            .observations
9907            .lock()
9908            .unwrap()
9909            .push_back(LifecycleGithubObservation::not_registered());
9910
9911        harness.launcher.supervise(&harness.policy).await.unwrap();
9912        assert_eq!(looks_left(), 1, "it did not look");
9913        let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9914        assert_eq!(journalled.state(), AttemptState::Allocated);
9915
9916        harness
9917            .launcher
9918            .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9919            .await
9920            .unwrap();
9921        assert!(harness.launcher.bringing_up.lock().unwrap().is_empty());
9922
9923        harness.launcher.supervise(&harness.policy).await.unwrap();
9924        assert_eq!(looks_left(), 0, "up, it is this pass's to supervise");
9925    }
9926
9927    /// A daemon that dies between the reservation and the process: the
9928    /// journalled attempt is what recovery finds, and the slot comes back.
9929    #[tokio::test]
9930    async fn a_crash_between_reservation_and_spawn_recovers_the_slot() {
9931        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9932        harness.ready().await;
9933        let reserved = harness.reserve().await.unwrap();
9934        harness.clock.advance_secs(31);
9935
9936        harness
9937            .launcher
9938            .recover_startup(std::slice::from_ref(&harness.policy))
9939            .await
9940            .unwrap();
9941
9942        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9943        assert!(
9944            !recovered.counts_against_capacity(),
9945            "the slot is free again: {:?}",
9946            recovered.state()
9947        );
9948    }
9949
9950    /// A crash once the package is there, before the process: same result.
9951    #[tokio::test]
9952    async fn a_crash_after_the_package_is_copied_recovers_the_slot() {
9953        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9954        harness.ready().await;
9955        let reserved = harness.reserve().await.unwrap();
9956        harness
9957            .packages
9958            .materialize(&reserved, &mut PackageTimings::default())
9959            .await
9960            .unwrap();
9961        harness.clock.advance_secs(31);
9962
9963        harness
9964            .launcher
9965            .recover_startup(std::slice::from_ref(&harness.policy))
9966            .await
9967            .unwrap();
9968
9969        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9970        assert!(!recovered.counts_against_capacity());
9971    }
9972
9973    #[tokio::test]
9974    async fn runners_prepare_at_once_within_the_bound() {
9975        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9976        harness.ready().await;
9977        let turns = harness.launcher.turns.available_permits();
9978        harness.launcher.turns.forget_permits(turns - 2);
9979        let mut reserved = Vec::new();
9980        for _ in 0..4 {
9981            reserved.push(harness.reserve().await.unwrap());
9982        }
9983
9984        let finished = futures::future::join_all(reserved.into_iter().map(|attempt| {
9985            harness
9986                .launcher
9987                .finish_launch(&harness.policy, attempt, ReservationWaits::default())
9988        }))
9989        .await;
9990
9991        assert!(finished.iter().all(Result::is_ok));
9992        assert_eq!(
9993            harness.packages.peak_materializing.load(Ordering::SeqCst),
9994            2
9995        );
9996    }
9997
9998    #[tokio::test]
9999    async fn every_production_launch_prunes_under_the_same_allocation_guard() {
10000        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10001        harness.ready().await;
10002        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
10003        harness.launch().await;
10004        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
10005    }
10006
10007    fn copy_package_tree(
10008        source: &Path,
10009        destination: &Path,
10010        seeds: &TrustedTrees,
10011    ) -> std::io::Result<()> {
10012        let origin = copy_origin(source, destination, seeds);
10013        copy_package_from(source, &origin, destination, seeds)
10014    }
10015
10016    fn assert_no_jit_file(runtime: &Path) {
10017        for entry in fs::read_dir(runtime).unwrap() {
10018            let path = entry.unwrap().path();
10019            if path.is_file() {
10020                let bytes = fs::read(&path).unwrap();
10021                assert!(
10022                    !bytes
10023                        .windows(JIT.len())
10024                        .any(|window| window == JIT.as_bytes()),
10025                    "a JIT payload survived in a runtime file"
10026                );
10027            }
10028        }
10029    }
10030
10031    #[test]
10032    fn production_listener_command_uses_the_supported_jit_contract() {
10033        let runtime = Path::new("runtime");
10034        let spec = runner_listener_spec(
10035            PathBuf::from("Runner.Listener"),
10036            runtime,
10037            Vec::new(),
10038            &RunnerEnv::default(),
10039        );
10040        let arguments: Vec<_> = spec
10041            .arguments()
10042            .iter()
10043            .map(|argument| argument.to_string_lossy().into_owned())
10044            .collect();
10045
10046        assert_eq!(arguments, ["run"]);
10047        assert!(
10048            !arguments
10049                .iter()
10050                .any(|argument| argument == "--jit-config-file"),
10051            "the obsolete file option would be rejected by Runner.Listener 2.336.0"
10052        );
10053    }
10054
10055    /// What a production runner starts with: the platform defaults, the host's
10056    /// `runner.env` over them, the attempt's own temporary directory, and
10057    /// normal scheduling.
10058    #[test]
10059    fn production_listener_layers_runner_env_over_the_platform_defaults() {
10060        let root = tempfile::tempdir().unwrap();
10061        let runtime = root.path().join("attempt");
10062        let host_env =
10063            RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
10064                .unwrap();
10065        let caches = vec![
10066            (
10067                "ELECTRON_CACHE",
10068                std::ffi::OsString::from("/rman/_cache/o/r/electron"),
10069            ),
10070            (
10071                "npm_config_cache",
10072                std::ffi::OsString::from("/rman/_cache/o/r/npm"),
10073            ),
10074        ];
10075        let spec = runner_listener_spec(
10076            PathBuf::from("Runner.Listener"),
10077            &runtime,
10078            caches,
10079            &host_env,
10080        );
10081        let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
10082
10083        assert!(spec.expects_normal_scheduling());
10084        assert_eq!(
10085            env("ELECTRON_CACHE"),
10086            Some(PathBuf::from("/cache/electron")),
10087            "runner.env must win over a dependency cache of the same name"
10088        );
10089        assert_eq!(
10090            env("npm_config_cache"),
10091            Some(PathBuf::from("/rman/_cache/o/r/npm"))
10092        );
10093        assert_eq!(
10094            env("HOME"),
10095            Some(PathBuf::from("/operator/chose/this")),
10096            "runner.env must win over a platform default of the same name"
10097        );
10098        for tmp in ["TMPDIR", "TEMP", "TMP"] {
10099            assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
10100        }
10101
10102        let home = runtime.join(runner_env::RUNNER_HOME_DIR);
10103        if cfg!(windows) {
10104            // Each runner's own profile, created inside its attempt, so that
10105            // concurrent jobs stop sharing the service account's.
10106            assert_eq!(env("USERPROFILE"), Some(home.clone()));
10107            assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
10108            assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
10109            assert!(home.join(r"AppData\Roaming").is_dir());
10110            assert!(home.join(r"AppData\Local").is_dir());
10111        } else {
10112            // Unix keeps the account's own profile.
10113            assert_eq!(env("USERPROFILE"), None);
10114            assert!(!home.exists());
10115        }
10116    }
10117
10118    /// Every step shell a runner starts inherits PowerShell's telemetry
10119    /// opt-out from the listener, unless the daemon or `runner.env` decided
10120    /// otherwise; see [`runner_env::POWERSHELL_TELEMETRY_OPTOUT`].
10121    #[test]
10122    fn production_listener_opts_powershell_out_of_telemetry() {
10123        let root = tempfile::tempdir().unwrap();
10124        let runtime = root.path().join("attempt");
10125        let listener = |host_env: &RunnerEnv| {
10126            runner_listener_spec(
10127                PathBuf::from("Runner.Listener"),
10128                &runtime,
10129                Vec::new(),
10130                host_env,
10131            )
10132        };
10133        let optout = |spec: &SpawnSpec| {
10134            spec.configured_env(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
10135                .map(OsStr::to_os_string)
10136        };
10137
10138        let spec = listener(&RunnerEnv::default());
10139        if std::env::var_os(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
10140            .is_some_and(|value| !value.is_empty())
10141        {
10142            // The daemon's own value is inherited as it is.
10143            assert_eq!(optout(&spec), None);
10144        } else {
10145            assert_eq!(
10146                optout(&spec),
10147                Some(runner_env::POWERSHELL_TELEMETRY_OPTOUT_VALUE.into())
10148            );
10149        }
10150
10151        let operator = RunnerEnv::parse("POWERSHELL_TELEMETRY_OPTOUT=0\n").unwrap();
10152        assert_eq!(
10153            optout(&listener(&operator)),
10154            Some("0".into()),
10155            "runner.env must win over the telemetry opt-out"
10156        );
10157    }
10158
10159    #[test]
10160    fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
10161        let root = tempfile::tempdir().unwrap();
10162        let path = runner_env::path_in(root.path());
10163        fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
10164        let processes = NativeProcesses::new().with_runner_env_file(path.clone());
10165        let failure = processes.host_env().unwrap_err().to_string();
10166        assert!(
10167            failure.contains("runner.env") && failure.contains("line 2"),
10168            "{failure}"
10169        );
10170        assert!(
10171            !failure.contains("ghp_"),
10172            "the failure echoed a value: {failure}"
10173        );
10174
10175        // `prepare` runs before the JIT registration, so a broken file is
10176        // refused there, before GitHub is asked for anything.
10177        let policy = fixtures::policy()
10178            .repository("octo/repo")
10179            .autoscale("home", 1)
10180            .active()
10181            .build();
10182        let attempt = RunnerAttempt::allocate(
10183            AttemptId::new_random(),
10184            policy.id,
10185            root.path(),
10186            FakeClock::default().now(),
10187        );
10188        assert!(processes.prepare(&attempt, &policy).is_err());
10189
10190        fs::write(&path, "GOOD=1\n").unwrap();
10191        assert!(processes.prepare(&attempt, &policy).is_ok());
10192        assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
10193        assert!(NativeProcesses::new().host_env().unwrap().is_empty());
10194    }
10195
10196    /// The wiring from `prepare` (which sees the policy) to `spawn` (which
10197    /// does not): the policy's repository decides the namespace, and a broken
10198    /// `caches.toml` is refused before GitHub is asked for anything.
10199    #[test]
10200    fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
10201        let root = tempfile::tempdir().unwrap();
10202        let runner_root = root.path().join("rman");
10203        let runtime = runner_root.join("a1");
10204        fs::create_dir_all(runtime.join("bin")).unwrap();
10205        let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
10206        let source_root = runner_root.clone();
10207        let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
10208            config.clone(),
10209            Arc::new(move || Some(source_root.clone())),
10210            None,
10211        ));
10212        let policy = fixtures::policy()
10213            .repository("Octo/Repo")
10214            .autoscale("home", 1)
10215            .active()
10216            .build();
10217        let attempt = RunnerAttempt::allocate(
10218            AttemptId::new_random(),
10219            policy.id,
10220            &runtime,
10221            FakeClock::default().now(),
10222        );
10223        assert!(
10224            processes
10225                .cache_env(&attempt, None, &RunnerEnv::default())
10226                .is_empty(),
10227            "a launch with no prepared target, such as a bare `spawn`, has no cache"
10228        );
10229        let prepared = processes.prepare(&attempt, &policy).unwrap();
10230        let variables = processes.cache_env(
10231            &attempt,
10232            prepared.cache_target.as_ref(),
10233            &RunnerEnv::default(),
10234        );
10235        let npm = variables
10236            .iter()
10237            .find(|(name, _)| *name == "npm_config_cache")
10238            .map(|(_, value)| PathBuf::from(value));
10239        assert_eq!(
10240            npm,
10241            Some(
10242                runner_root
10243                    .join("_cache")
10244                    .join("octo")
10245                    .join("repo")
10246                    .join("npm")
10247            )
10248        );
10249
10250        fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
10251        let refused = processes
10252            .prepare(&attempt, &policy)
10253            .unwrap_err()
10254            .to_string();
10255        assert!(refused.contains("caches.toml"), "{refused}");
10256    }
10257
10258    #[cfg(windows)]
10259    fn native_inspection_spec() -> SpawnSpec {
10260        SpawnSpec::new("powershell.exe").args([
10261            "-NoProfile",
10262            "-NonInteractive",
10263            "-Command",
10264            "Start-Sleep -Seconds 30",
10265        ])
10266    }
10267
10268    #[cfg(unix)]
10269    fn native_inspection_spec() -> SpawnSpec {
10270        SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
10271    }
10272
10273    #[cfg(windows)]
10274    fn native_command_line(pid: u32) -> String {
10275        let output = std::process::Command::new("powershell.exe")
10276            .args([
10277                "-NoProfile",
10278                "-NonInteractive",
10279                "-Command",
10280                &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
10281            ])
10282            .output()
10283            .expect("PowerShell can inspect the native child");
10284        assert!(output.status.success(), "native process inspection failed");
10285        String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
10286    }
10287
10288    #[cfg(target_os = "linux")]
10289    fn native_command_line(pid: u32) -> String {
10290        fs::read(format!("/proc/{pid}/cmdline"))
10291            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
10292            .expect("/proc exposes the native child command line")
10293    }
10294
10295    #[cfg(target_os = "macos")]
10296    fn native_command_line(pid: u32) -> String {
10297        let output = std::process::Command::new("ps")
10298            .args(["-o", "command=", "-p", &pid.to_string()])
10299            .output()
10300            .expect("ps can inspect the native child");
10301        assert!(output.status.success(), "native process inspection failed");
10302        String::from_utf8(output.stdout).expect("the command line is UTF-8")
10303    }
10304
10305    // -- c2: persistent slot allocation -------------------------------------
10306
10307    #[tokio::test]
10308    async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
10309        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10310            .with_persistent_workspace(2);
10311        harness.github.watch_journal(Arc::clone(&harness.store));
10312        harness.ready().await;
10313
10314        let attempt = harness.launch().await;
10315
10316        assert_eq!(
10317            attempt.workspace(),
10318            AttemptWorkspace::persistent_slot(nz(1)),
10319            "the lowest free slot is leased"
10320        );
10321        assert_eq!(attempt.runtime_path(), harness.slot_path(1));
10322        assert!(attempt.holds_slot_lease());
10323        // The exact runtime path is journalled, not re-derived later.
10324        assert_eq!(
10325            harness.attempt(attempt.id).runtime_path(),
10326            harness.slot_path(1)
10327        );
10328
10329        // Step 7 of "Slot allocation": the lease exists before GitHub is asked
10330        // for anything, and the runner's work folder stays the relative `_work`
10331        // the slot root is laid out around.
10332        let facts = harness.github.registration_facts();
10333        assert_eq!(facts.len(), 1);
10334        assert_eq!(
10335            facts[0].leased_slots,
10336            vec![1],
10337            "the lease was journalled first"
10338        );
10339        assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
10340    }
10341
10342    #[tokio::test]
10343    async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
10344        let harness = Harness::new(
10345            FakeGithubLifecycle::default().fail(true),
10346            Arc::new(PersistentDemand),
10347        )
10348        .with_persistent_workspace(2);
10349        harness.ready().await;
10350
10351        // A terminal JIT refusal concludes the attempt without cleaning it.
10352        harness.launch_result().await.unwrap_err();
10353        let first = harness.store.attempts().unwrap().remove(0);
10354        assert_eq!(first.state(), AttemptState::Failed);
10355        assert!(
10356            !first.state().counts_against_capacity(),
10357            "a concluded attempt is invisible to host capacity"
10358        );
10359        assert!(
10360            first.holds_slot_lease(),
10361            "and still owns its directory, so its slot is not free"
10362        );
10363
10364        let second = harness.launch().await;
10365        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10366        assert_eq!(second.runtime_path(), harness.slot_path(2));
10367        assert_eq!(
10368            harness
10369                .store
10370                .slot_leases_for_policy(harness.policy.id)
10371                .unwrap()
10372                .len(),
10373            2
10374        );
10375    }
10376
10377    #[tokio::test]
10378    async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
10379        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10380            .with_persistent_workspace(1);
10381        harness.ready().await;
10382
10383        let first = harness.launch().await;
10384        assert_eq!(first.runtime_path(), harness.slot_path(1));
10385
10386        // What a job leaves behind, at the path the runner writes it to.
10387        let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
10388        fs::create_dir_all(&checkout).unwrap();
10389        fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
10390
10391        harness.cleanup_retaining_work(first.id).await;
10392
10393        let second = harness.launch().await;
10394        assert_ne!(second.id, first.id);
10395        assert_eq!(
10396            second.workspace(),
10397            AttemptWorkspace::persistent_slot(nz(1)),
10398            "a released slot is leased again rather than skipped"
10399        );
10400        assert_eq!(
10401            second.runtime_path(),
10402            first.runtime_path(),
10403            "the same slot is the same exact path"
10404        );
10405        assert_eq!(
10406            fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
10407            "from the first job",
10408            "the retained job workspace survived the second allocation"
10409        );
10410        // The attempt's own runner material was recreated for this attempt.
10411        assert!(harness.slot_path(1).join("runner-package").exists());
10412    }
10413
10414    #[tokio::test]
10415    async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
10416        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10417            .with_persistent_workspace(2);
10418        harness.ready().await;
10419
10420        let first = harness.launch().await;
10421        let second = harness.launch().await;
10422        assert_eq!(second.runtime_path(), harness.slot_path(2));
10423        let kept = harness
10424            .slot_path(2)
10425            .join(DEFAULT_WORK_FOLDER)
10426            .join("kept.txt");
10427        fs::create_dir_all(kept.parent().unwrap()).unwrap();
10428        fs::write(&kept, b"s2 was here").unwrap();
10429        harness.cleanup_retaining_work(second.id).await;
10430
10431        // The operator lowers the ceiling while s1 is still leased.
10432        harness.policy.set_max_capacity(nz(1)).unwrap();
10433        let refusal = harness.launch_result().await.unwrap_err().to_string();
10434        assert!(
10435            refusal.contains("s1 to s1"),
10436            "the refusal names the ceiling it reached: {refusal}"
10437        );
10438        assert!(
10439            harness.slot_path(2).exists() && kept.exists(),
10440            "lowering capacity deletes nothing; the higher slot is merely unusable"
10441        );
10442
10443        // Raising it again makes the free higher slot available.
10444        harness.policy.set_max_capacity(nz(2)).unwrap();
10445        let third = harness.launch().await;
10446        assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10447        assert_eq!(third.runtime_path(), harness.slot_path(2));
10448        assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
10449        assert!(first.holds_slot_lease(), "s1 was never disturbed");
10450    }
10451
10452    #[tokio::test]
10453    async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
10454        for policy in [
10455            fixtures::policy()
10456                .organization("octo")
10457                .autoscale("home", 2)
10458                .active()
10459                .build(),
10460            fixtures::policy()
10461                .repository("octo/repo")
10462                .autoscale("home", 2)
10463                .active()
10464                .build(),
10465        ] {
10466            let mut harness =
10467                Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10468                    .with_host_runner_root();
10469            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
10470            harness.policy = policy;
10471            harness.ready().await;
10472
10473            let attempt = harness.launch().await;
10474            assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10475            assert_eq!(attempt.workspace().slot_number(), None);
10476            assert!(!attempt.holds_slot_lease());
10477            assert_eq!(
10478                attempt.runtime_path().parent().unwrap(),
10479                harness.host_root(),
10480                "a disposable attempt is a child of the host root, never of a slot"
10481            );
10482            assert!(
10483                harness
10484                    .store
10485                    .slot_leases_for_policy(harness.policy.id)
10486                    .unwrap()
10487                    .is_empty()
10488            );
10489        }
10490    }
10491
10492    #[tokio::test]
10493    async fn two_concurrent_allocations_never_share_a_slot() {
10494        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10495            .with_persistent_workspace(2);
10496        harness.github.watch_journal(Arc::clone(&harness.store));
10497        harness.ready().await;
10498
10499        // Both allocators race for the same host allocation lock, which is what
10500        // orders slot *selection*; each one then journals its lease before it
10501        // asks GitHub for anything.
10502        let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
10503        let first = first.unwrap();
10504        let second = second.unwrap();
10505
10506        let slots: BTreeSet<u16> = [&first, &second]
10507            .iter()
10508            .map(|attempt| {
10509                attempt
10510                    .workspace()
10511                    .slot_number()
10512                    .expect("a persistent attempt leases a slot")
10513            })
10514            .collect();
10515        assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
10516        assert_ne!(first.runtime_path(), second.runtime_path());
10517        assert_eq!(
10518            harness
10519                .store
10520                .slot_leases_for_policy(harness.policy.id)
10521                .unwrap()
10522                .len(),
10523            2
10524        );
10525
10526        // Every registration saw *its own* lease already in the journal. Reading
10527        // the whole journal and asking only that it be non-empty would pass on
10528        // the other allocator's lease, which is precisely the ordering bug this
10529        // test exists to exclude.
10530        let facts = harness.github.registration_facts();
10531        assert_eq!(facts.len(), 2);
10532        for fact in facts {
10533            let attempt = [&first, &second]
10534                .into_iter()
10535                .find(|attempt| runner_name(attempt.id) == fact.runner_name)
10536                .expect("every registration belongs to one of the two attempts");
10537            let slot = attempt
10538                .workspace()
10539                .slot_number()
10540                .expect("a persistent attempt leases a slot");
10541            assert!(
10542                fact.leased_slots.contains(&slot),
10543                "a JIT request never precedes its own lease: s{slot} not in {:?}",
10544                fact.leased_slots
10545            );
10546            assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
10547        }
10548    }
10549
10550    #[tokio::test]
10551    async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
10552        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10553            .with_persistent_workspace(2);
10554        harness.ready().await;
10555        let first = harness.launch().await;
10556
10557        // What a second allocator that lost the race would write: the lock
10558        // orders selection, and this index is what catches a writer the lock
10559        // could not see.
10560        let clash = RunnerAttempt::allocate_in(
10561            AttemptId::new_random(),
10562            harness.policy.id,
10563            first.runtime_path(),
10564            AttemptWorkspace::persistent_slot(nz(1)),
10565            harness.clock.now(),
10566        );
10567        assert!(matches!(
10568            harness.store.record_attempt(&clash).unwrap_err(),
10569            StoreError::SlotAlreadyLeased { slot: 1, .. }
10570        ));
10571
10572        // And the launcher reports it as itself rather than as a generic
10573        // journal failure, so the operator reads what actually happened.
10574        let error = harness.launcher.record_allocation(&clash).unwrap_err();
10575        let rendered = error.to_string();
10576        assert!(rendered.contains("slot s1"), "{rendered}");
10577        assert!(rendered.contains("nothing was written"), "{rendered}");
10578        assert_eq!(
10579            harness.store.attempts().unwrap().len(),
10580            1,
10581            "the losing allocator journalled nothing"
10582        );
10583    }
10584
10585    #[test]
10586    fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
10587        let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
10588            slots
10589                .iter()
10590                .map(|slot| {
10591                    RunnerAttempt::allocate_in(
10592                        AttemptId::new_random(),
10593                        fixtures::POLICY_ID,
10594                        format!("/srv/rman/acme/s{slot}"),
10595                        AttemptWorkspace::persistent_slot(nz(*slot)),
10596                        fixtures::created_at(),
10597                    )
10598                })
10599                .collect()
10600        };
10601
10602        assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
10603        assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
10604        // The gap a released middle slot leaves is filled before the tail.
10605        assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
10606        // The ceiling is a refusal, never a reason to allocate past it.
10607        assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
10608        assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
10609        // An ephemeral attempt holds no slot and cannot block one.
10610        let ephemeral = vec![RunnerAttempt::allocate(
10611            AttemptId::new_random(),
10612            fixtures::POLICY_ID,
10613            "/srv/rman/host/abc",
10614            fixtures::created_at(),
10615        )];
10616        assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
10617    }
10618
10619    #[test]
10620    fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
10621        let root = tempfile::tempdir().unwrap();
10622        let slot = root.path().join("s1");
10623        fs::create_dir(&slot).unwrap();
10624        accept_reusable_slot(&slot).expect("an empty slot is reusable");
10625
10626        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10627        accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
10628
10629        // Runner material a previous attempt left behind is refused rather than
10630        // reused or removed: deciding those bytes are safe is cleanup's job.
10631        fs::create_dir(slot.join("bin")).unwrap();
10632        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10633        let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
10634        assert!(refusal.contains("bin"), "{refusal}");
10635        assert!(refusal.contains(".github-runner-id"), "{refusal}");
10636
10637        // A `_work` that is not a real directory is not a job workspace.
10638        let file_work = root.path().join("s2");
10639        fs::create_dir(&file_work).unwrap();
10640        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10641        assert!(accept_reusable_slot(&file_work).is_err());
10642    }
10643
10644    #[cfg(unix)]
10645    #[test]
10646    fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
10647        // Windows needs a privilege to create either kind of link, so the
10648        // link-shaped cases are asserted here; the rule itself is
10649        // platform-independent because it is `symlink_metadata`'s answer.
10650        let root = tempfile::tempdir().unwrap();
10651        let elsewhere = root.path().join("elsewhere");
10652        fs::create_dir(&elsewhere).unwrap();
10653
10654        let slot = root.path().join("s1");
10655        fs::create_dir(&slot).unwrap();
10656        std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10657        assert!(accept_reusable_slot(&slot).is_err());
10658
10659        let linked_slot = root.path().join("s2");
10660        std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
10661        assert!(create_or_validate_slot(&linked_slot).is_err());
10662    }
10663
10664    #[test]
10665    fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
10666        let root = tempfile::tempdir().unwrap();
10667        let occupied = root.path().join("s1");
10668        fs::write(&occupied, b"an operator's file").unwrap();
10669        let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
10670        assert!(refusal.contains("is not a directory"), "{refusal}");
10671        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10672
10673        let fresh = root.path().join("s2");
10674        create_or_validate_slot(&fresh).expect("a missing slot is created");
10675        assert!(fresh.is_dir());
10676        create_or_validate_slot(&fresh).expect("an existing directory is accepted");
10677    }
10678
10679    #[test]
10680    fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
10681        assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
10682        assert!(!is_work_folder(OsStr::new("_work2")));
10683        // A Windows filesystem is case-insensitive, so `_Work` *is* the retained
10684        // job workspace there and must never be removed as a leftover; on a
10685        // case-sensitive filesystem it is a different directory entirely.
10686        assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
10687    }
10688
10689    #[test]
10690    fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
10691        let root = tempfile::tempdir().unwrap();
10692        let package = root.path().join("package");
10693        fs::create_dir_all(package.join("bin")).unwrap();
10694        fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
10695        // A nested `_work` inside the package's own tree is an ordinary name.
10696        fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
10697
10698        let slot = root.path().join("s1");
10699        let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
10700        fs::create_dir_all(&retained).unwrap();
10701        fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
10702
10703        copy_package_tree(&package, &slot, &TrustedTrees::default())
10704            .expect("the package lays out around `_work`");
10705        assert!(slot.join("bin").join("Runner.Listener").exists());
10706        assert!(
10707            slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
10708            "the guard is top-level only"
10709        );
10710        assert_eq!(
10711            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10712            "from the first job"
10713        );
10714
10715        // A package that ever grew a top-level `_work` is refused, not merged.
10716        fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
10717        let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
10718        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
10719        assert_eq!(
10720            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10721            "from the first job"
10722        );
10723    }
10724
10725    #[test]
10726    fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
10727        let root = tempfile::tempdir().unwrap();
10728
10729        let slot = root.path().join("s1");
10730        let retained = slot.join(DEFAULT_WORK_FOLDER);
10731        fs::create_dir_all(retained.join("repo")).unwrap();
10732        fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
10733        fs::create_dir_all(slot.join("bin")).unwrap();
10734        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10735        let persistent = RunnerAttempt::allocate_in(
10736            AttemptId::new_random(),
10737            fixtures::POLICY_ID,
10738            &slot,
10739            AttemptWorkspace::persistent_slot(nz(1)),
10740            fixtures::created_at(),
10741        );
10742
10743        remove_materialized_package(&persistent).unwrap();
10744        assert!(slot.is_dir(), "the slot itself is not removed");
10745        assert!(!slot.join("bin").exists());
10746        assert!(!slot.join(".github-runner-id").exists());
10747        assert_eq!(
10748            fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
10749            "kept"
10750        );
10751
10752        let disposable_path = root.path().join("abcdef012345");
10753        fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
10754        let disposable = RunnerAttempt::allocate(
10755            AttemptId::new_random(),
10756            fixtures::POLICY_ID,
10757            &disposable_path,
10758            fixtures::created_at(),
10759        );
10760        remove_materialized_package(&disposable).unwrap();
10761        assert!(
10762            !disposable_path.exists(),
10763            "a disposable directory is still removed whole"
10764        );
10765    }
10766
10767    // -- c3: persistent cleanup and recovery --------------------------------
10768
10769    /// The runner state one attempt leaves at a slot root, as a real attempt
10770    /// leaves it: binaries, registration identity, a JIT handoff that outlived
10771    /// its process, and this agent's own lifecycle sidecars.
10772    ///
10773    /// Driven by [`SENSITIVE_SLOT_ENTRIES`] rather than by a second copy of it,
10774    /// so a name added to the thing cleanup must prove absent is a name every
10775    /// test here starts leaving behind.
10776    fn litter_the_slot(slot: &Path) {
10777        for directory in ["bin", "externals", "_diag"] {
10778            fs::create_dir_all(slot.join(directory)).unwrap();
10779        }
10780        fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
10781        for file in SENSITIVE_SLOT_ENTRIES
10782            .iter()
10783            .filter(|entry| !slot.join(entry).is_dir())
10784        {
10785            fs::write(slot.join(file), b"runner state").unwrap();
10786        }
10787        // A handoff whose owning process died before `Drop` could delete it.
10788        fs::write(
10789            slot.join(format!(
10790                "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
10791                RestrictiveHandoff::NAME_PREFIX
10792            )),
10793            JIT.as_bytes(),
10794        )
10795        .unwrap();
10796    }
10797
10798    /// A marker under `_work` of the kind a job leaves for the next one.
10799    fn retain_under_work(slot: &Path) -> PathBuf {
10800        let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
10801        fs::create_dir_all(&checkout).unwrap();
10802        let marker = checkout.join("build-output.bin");
10803        fs::write(&marker, RETAINED).unwrap();
10804        marker
10805    }
10806
10807    /// What a job leaves under `_work` for the next job to reuse.
10808    const RETAINED: &str = "a Git-ignored build output the next job reuses";
10809
10810    /// Every direct entry of a directory, sorted, as plain strings.
10811    fn entries_of(directory: &Path) -> Vec<String> {
10812        let mut names: Vec<String> = fs::read_dir(directory)
10813            .unwrap()
10814            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
10815            .collect();
10816        names.sort();
10817        names
10818    }
10819
10820    /// The one entry a cleaned slot is allowed to hold.
10821    fn only_the_job_workspace() -> Vec<String> {
10822        vec![DEFAULT_WORK_FOLDER.to_owned()]
10823    }
10824
10825    #[cfg(unix)]
10826    #[test]
10827    fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
10828        use std::sync::mpsc;
10829
10830        let temporary = tempfile::tempdir().unwrap();
10831        let tree = temporary.path().join("attempt");
10832        let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
10833        fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
10834        assert!(
10835            std::process::Command::new("mkfifo")
10836                .arg(&diagnostic)
10837                .status()
10838                .unwrap()
10839                .success()
10840        );
10841
10842        let (finished, result) = mpsc::channel();
10843        std::thread::spawn(move || {
10844            let removed = remove_runtime_tree(&tree);
10845            let _ = finished.send(removed);
10846        });
10847
10848        result
10849            .recv_timeout(Duration::from_secs(2))
10850            .expect("runtime deletion must not wait for a FIFO peer")
10851            .unwrap();
10852        assert!(!diagnostic.exists());
10853    }
10854
10855    /// The junction WinINet leaves in a job's profile, reproduced: its own DACL
10856    /// denies `Everyone` the right to list it.
10857    #[cfg(windows)]
10858    #[test]
10859    fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
10860        let temporary = tempfile::tempdir().unwrap();
10861        let tree = temporary.path().join("attempt");
10862        let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
10863        let outside = temporary.path().join("outside");
10864        fs::create_dir_all(&cache).unwrap();
10865        fs::create_dir_all(&outside).unwrap();
10866        fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
10867        fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
10868        let junction = cache.join("Content.IE5");
10869        let created = std::process::Command::new("cmd")
10870            .arg("/C")
10871            .arg("mklink")
10872            .arg("/J")
10873            .arg(&junction)
10874            .arg(&outside)
10875            .output()
10876            .unwrap();
10877        assert!(created.status.success(), "{created:?}");
10878        // `/L` puts the entry on the junction itself, as WinINet does, rather
10879        // than on the directory it points at. `*S-1-1-0` is `Everyone` in every
10880        // display language.
10881        let denied = std::process::Command::new("icacls")
10882            .arg(&junction)
10883            .args(["/L", "/deny", "*S-1-1-0:(RD)"])
10884            .output()
10885            .unwrap();
10886        assert!(denied.status.success(), "{denied:?}");
10887
10888        // The control: without the fix the tree cannot be removed at all.
10889        let unfixed = remove_dir_all::remove_dir_all(&tree)
10890            .expect_err("the fixture no longer reproduces the WinINet junction");
10891        assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
10892        assert!(junction.symlink_metadata().is_ok());
10893
10894        remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
10895        assert!(!tree.exists(), "the attempt tree survived");
10896        assert_eq!(
10897            fs::read(outside.join("keep.txt")).unwrap(),
10898            b"not the attempt's",
10899            "removing the junction followed it into the directory it points at"
10900        );
10901    }
10902
10903    /// One slot entry that refuses to be removed, and the undo that lets the
10904    /// temporary directory be torn down afterwards.
10905    ///
10906    /// The two operating systems refuse for different reasons and there is no
10907    /// portable third. Windows will not open a file for deletion while a handle
10908    /// with share mode zero is held on it; Unix will not unlink from a directory
10909    /// the caller cannot write. Both are states a real machine reaches -- a
10910    /// scanner holding a file open, a job that left a directory read-only -- so
10911    /// the injection is a filesystem fact rather than a seam cut into the
10912    /// product for a test to pull.
10913    ///
10914    /// The Unix half is a permission, and permissions do not apply to `root`.
10915    /// [`Self::inject`] proves the block on a throwaway directory before
10916    /// claiming it, so a suite running as `root` says it could not inject rather
10917    /// than asserting nothing and passing.
10918    struct BlockedDeletion {
10919        directory: PathBuf,
10920        #[cfg(windows)]
10921        _handle: fs::File,
10922    }
10923
10924    impl BlockedDeletion {
10925        const HELD: &'static str = "held-open";
10926
10927        /// Fill `directory` with a file that cannot be removed, or answer `None`
10928        /// when this account cannot be stopped from removing anything.
10929        fn inject(directory: &Path) -> Option<Self> {
10930            #[cfg(unix)]
10931            if !Self::refusal_is_possible() {
10932                return None;
10933            }
10934            fs::create_dir_all(directory).unwrap();
10935            fs::write(
10936                directory.join(Self::HELD),
10937                b"a file the scrub cannot remove",
10938            )
10939            .unwrap();
10940            #[cfg(windows)]
10941            let handle = {
10942                use std::os::windows::fs::OpenOptionsExt;
10943
10944                fs::OpenOptions::new()
10945                    .read(true)
10946                    .share_mode(0)
10947                    .open(directory.join(Self::HELD))
10948                    .expect("the blocking handle opens")
10949            };
10950            #[cfg(unix)]
10951            Self::set_mode(directory, 0o555);
10952            Some(Self {
10953                directory: directory.to_path_buf(),
10954                #[cfg(windows)]
10955                _handle: handle,
10956            })
10957        }
10958
10959        fn release(self) {
10960            drop(self);
10961        }
10962
10963        #[cfg(unix)]
10964        fn refusal_is_possible() -> bool {
10965            let probe = tempfile::tempdir().unwrap();
10966            let directory = probe.path().join("probe");
10967            fs::create_dir(&directory).unwrap();
10968            fs::write(directory.join("file"), b"probe").unwrap();
10969            Self::set_mode(&directory, 0o555);
10970            let refused = fs::remove_dir_all(&directory).is_err();
10971            Self::set_mode(&directory, 0o755);
10972            refused
10973        }
10974
10975        #[cfg(unix)]
10976        fn set_mode(directory: &Path, mode: u32) {
10977            use std::os::unix::fs::PermissionsExt;
10978
10979            let mut permissions = fs::metadata(directory).unwrap().permissions();
10980            permissions.set_mode(mode);
10981            fs::set_permissions(directory, permissions).unwrap();
10982        }
10983    }
10984
10985    impl Drop for BlockedDeletion {
10986        fn drop(&mut self) {
10987            #[cfg(unix)]
10988            Self::set_mode(&self.directory, 0o755);
10989            #[cfg(not(unix))]
10990            let _ = &self.directory;
10991        }
10992    }
10993
10994    #[tokio::test]
10995    async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
10996        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10997            .with_persistent_workspace(1);
10998        harness.ready().await;
10999
11000        let first = harness.launch().await;
11001        let slot = harness.slot_path(1);
11002        assert_eq!(first.runtime_path(), slot);
11003        assert_eq!(
11004            read_runner_id(&slot),
11005            Some(73),
11006            "the attempt registered, so its identity is on disk"
11007        );
11008        let marker = retain_under_work(&slot);
11009        litter_the_slot(&slot);
11010
11011        harness.cleanup_retaining_work(first.id).await;
11012
11013        // The allowlist is exactly one entry, so this assertion is the security
11014        // property in full: what is retained, and that nothing else is.
11015        assert_eq!(entries_of(&slot), only_the_job_workspace());
11016        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
11017        assert_eq!(
11018            read_runner_id(&slot),
11019            None,
11020            "the first attempt's registration identity is gone before the second starts"
11021        );
11022        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
11023        assert!(!harness.attempt(first.id).holds_slot_lease());
11024
11025        let second = harness.launch().await;
11026        assert_ne!(second.id, first.id);
11027        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
11028        assert_eq!(
11029            second.runtime_path(),
11030            slot,
11031            "the same slot, so the same retained `_work`"
11032        );
11033        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
11034    }
11035
11036    #[tokio::test]
11037    async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
11038        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11039            .with_persistent_workspace(1);
11040        harness.ready().await;
11041        let attempt = harness.launch().await;
11042        let slot = harness.slot_path(1);
11043        let marker = retain_under_work(&slot);
11044        litter_the_slot(&slot);
11045        harness.conclude(attempt.id);
11046
11047        // A repository removed from the product between the attempt concluding
11048        // and the sweep reaching it. The journalled runtime path and slot are
11049        // the only facts left, and `04-security-recovery.md` requires them to be
11050        // enough: the alternative is scanning a root to work out which
11051        // directories were ours, which invariant 6 forbids.
11052        harness
11053            .store
11054            .remove_policy(harness.policy.id, harness.policy.revision())
11055            .unwrap();
11056        assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
11057
11058        harness
11059            .launcher
11060            .clean(attempt.id)
11061            .await
11062            .expect("journal facts alone are enough to clean the slot");
11063
11064        assert_eq!(entries_of(&slot), only_the_job_workspace());
11065        assert!(marker.exists());
11066        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
11067    }
11068
11069    #[tokio::test]
11070    async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
11071        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11072            .with_persistent_workspace(2);
11073        harness.ready().await;
11074        let first = harness.launch().await;
11075        let slot = harness.slot_path(1);
11076        let marker = retain_under_work(&slot);
11077        litter_the_slot(&slot);
11078        harness.conclude(first.id);
11079
11080        let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
11081            eprintln!(
11082                "skipped: this account cannot be refused a deletion, so no partial deletion can \
11083                 be injected"
11084            );
11085            return;
11086        };
11087
11088        let refusal = harness
11089            .launcher
11090            .clean(first.id)
11091            .await
11092            .expect_err("a deletion that failed may not report a cleaned slot");
11093        let rendered = refusal.reason.to_string();
11094        assert!(rendered.contains("could not be removed"), "{rendered}");
11095
11096        let held = harness.attempt(first.id);
11097        assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
11098        assert!(held.holds_slot_lease(), "so the slot is still leased");
11099        assert!(
11100            !held.state().counts_against_capacity(),
11101            "and a concluded attempt still costs the host no capacity"
11102        );
11103
11104        // The same journal and the same directories, under a launcher that
11105        // remembers nothing. Recovery must complete: a host that can launch
11106        // nothing at all because one slot is stuck is not what "does not count
11107        // as active host capacity" means.
11108        let restarted = harness.restart();
11109        restarted
11110            .recover_startup(std::slice::from_ref(&harness.policy))
11111            .await
11112            .expect("one quarantined slot does not stop the host recovering");
11113        assert_eq!(
11114            harness.attempt(first.id).state(),
11115            AttemptState::Failed,
11116            "the quarantine survived the restart"
11117        );
11118        assert!(
11119            harness
11120                .reconcile_events
11121                .events()
11122                .iter()
11123                .any(|event| matches!(
11124                    event,
11125                    LifecycleEvent::AttemptCleanFailed {
11126                        reason: "slot_entry_could_not_be_removed",
11127                        ..
11128                    }
11129                )),
11130            "the refusal is reported rather than retried in silence"
11131        );
11132
11133        // Capacity two, slot one quarantined: the next attempt goes to s2 and
11134        // never to the slot still holding runner state.
11135        let second = harness
11136            .launch_through(&restarted)
11137            .await
11138            .expect("the host can still launch");
11139        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
11140
11141        // And the same cleanup succeeds once the obstruction is gone, which is
11142        // what "retry through normal recovery" has to mean.
11143        block.release();
11144        restarted
11145            .clean(first.id)
11146            .await
11147            .expect("the retried cleanup completes");
11148        assert_eq!(entries_of(&slot), only_the_job_workspace());
11149        assert!(marker.exists());
11150        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
11151    }
11152
11153    /// The disposable half of the same injection, which had no test of its own.
11154    ///
11155    /// `scrub_workspace`'s ephemeral arm turns a failed `remove_dir_all` into
11156    /// `"attempt workspace could not be removed"`, and until now that branch was
11157    /// only reachable in theory: every injected-deletion test drove a persistent
11158    /// slot. The property is the same one and matters for the same reason --
11159    /// `04-security-recovery.md`'s "Cleanup partly fails and the slot is reused
11160    /// anyway" -- but the disposable guarantee is stronger, so a cleanup that
11161    /// reported success over a directory it had not removed would be the
11162    /// contamination gate failing silently rather than a slot being held.
11163    #[tokio::test]
11164    async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
11165        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
11166        harness.ready().await;
11167        let attempt = harness.launch().await;
11168        let runtime = attempt.runtime_path().to_path_buf();
11169        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
11170        harness.conclude(attempt.id);
11171
11172        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
11173            eprintln!(
11174                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
11175            );
11176            return;
11177        };
11178
11179        let refusal = harness
11180            .launcher
11181            .clean(attempt.id)
11182            .await
11183            .expect_err("a deletion that failed may not report a removed workspace");
11184        let rendered = refusal.reason.to_string();
11185        assert!(
11186            rendered.contains("could not be removed"),
11187            "the refusal names what happened: {rendered}"
11188        );
11189        assert_ne!(
11190            harness.attempt(attempt.id).state(),
11191            AttemptState::Cleaned,
11192            "an attempt whose directory is still on disk is not cleaned"
11193        );
11194        assert!(
11195            runtime.is_dir(),
11196            "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
11197        );
11198
11199        let restarted = harness.restart();
11200        restarted
11201            .recover_startup(std::slice::from_ref(&harness.policy))
11202            .await
11203            .expect("one locked ephemeral workspace does not stop startup recovery");
11204        assert!(
11205            harness
11206                .reconcile_events
11207                .events()
11208                .iter()
11209                .any(|event| matches!(
11210                    event,
11211                    LifecycleEvent::AttemptCleanFailed {
11212                        attempt: failed_attempt,
11213                        reason: "ephemeral_workspace_could_not_be_removed",
11214                        ..
11215                    } if *failed_attempt == attempt.id
11216                )),
11217            "the deferred cleanup remains visible while the daemon keeps running"
11218        );
11219
11220        // And the ordinary retry -- the reconciler's next terminal sweep --
11221        // finishes it once the obstruction is gone.
11222        block.release();
11223        harness
11224            .launcher
11225            .clean(attempt.id)
11226            .await
11227            .expect("the retried cleanup completes");
11228        assert!(!runtime.exists(), "the whole attempt directory goes");
11229        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
11230    }
11231
11232    /// Supervision retries a workspace it cannot remove with per-attempt
11233    /// backoff, so a stuck workspace is reported once per delay rather than on
11234    /// every pass.
11235    #[tokio::test]
11236    async fn supervision_backs_off_a_workspace_it_cannot_remove() {
11237        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
11238        harness.ready().await;
11239        let attempt = harness.launch().await;
11240        let runtime = attempt.runtime_path().to_path_buf();
11241        harness.conclude(attempt.id);
11242        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
11243            eprintln!(
11244                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
11245            );
11246            return;
11247        };
11248        let failures = || {
11249            harness
11250                .reconcile_events
11251                .events()
11252                .iter()
11253                .filter(|event| {
11254                    matches!(
11255                        event,
11256                        LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
11257                            if *failed == attempt.id
11258                    )
11259                })
11260                .count()
11261        };
11262        let supervise = async || {
11263            harness
11264                .launcher
11265                .supervise(&harness.policy)
11266                .await
11267                .expect("a stuck workspace does not fail supervision");
11268        };
11269
11270        supervise().await;
11271        assert_eq!(failures(), 1);
11272        supervise().await;
11273        assert_eq!(
11274            failures(),
11275            1,
11276            "the failed cleanup was retried on the very next pass"
11277        );
11278        harness.clock.advance_secs(31);
11279        supervise().await;
11280        assert_eq!(
11281            failures(),
11282            2,
11283            "the cleanup was not retried once its delay passed"
11284        );
11285
11286        block.release();
11287        harness.clock.advance_secs(61);
11288        supervise().await;
11289        assert!(
11290            !runtime.exists(),
11291            "the retry removes the workspace once it can"
11292        );
11293        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
11294    }
11295
11296    #[tokio::test]
11297    async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
11298        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11299            .with_persistent_workspace(1);
11300        harness.ready().await;
11301        let first = harness.launch().await;
11302        let slot = harness.slot_path(1);
11303        let marker = retain_under_work(&slot);
11304        harness.cleanup_retaining_work(first.id).await;
11305
11306        // Every attempt for this policy is cleaned, so the mutation is allowed
11307        // (`04-security-recovery.md`, "Recovery rules"). What it must not do is
11308        // move or delete anything the operator still owns.
11309        harness
11310            .policy
11311            .set_workspace_policy(WorkspacePolicy::Ephemeral)
11312            .unwrap();
11313
11314        let second = harness.launch().await;
11315        assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
11316        assert_eq!(
11317            second.runtime_path().parent().unwrap(),
11318            harness.host_root(),
11319            "a disposable attempt is a child of the host root"
11320        );
11321        assert!(slot.is_dir(), "the old slot is left where it stands");
11322        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
11323
11324        // And cleaning the disposable attempt removes its own directory whole
11325        // without reaching the retained slot beside it.
11326        harness.conclude(second.id);
11327        harness.launcher.clean(second.id).await.unwrap();
11328        assert!(!second.runtime_path().exists());
11329        assert!(marker.exists());
11330    }
11331
11332    #[tokio::test]
11333    async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
11334        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11335            .with_persistent_workspace(1);
11336        harness.ready().await;
11337        let slot = harness.slot_path(1);
11338        fs::create_dir_all(&slot).unwrap();
11339        let marker = retain_under_work(&slot);
11340        litter_the_slot(&slot);
11341
11342        let id = AttemptId::new_random();
11343        let mut attempt = RunnerAttempt::allocate_in(
11344            id,
11345            harness.policy.id,
11346            &slot,
11347            AttemptWorkspace::persistent_slot(nz(1)),
11348            harness.clock.now(),
11349        );
11350        attempt.jit_received(harness.clock.now()).unwrap();
11351        attempt.started(4242, harness.clock.now()).unwrap();
11352        harness.store.record_attempt(&attempt).unwrap();
11353        harness.clock.advance_secs(11);
11354        harness.processes.set_alive(true);
11355        harness
11356            .github
11357            .observe(GithubRunnerObservation::NotRegistered);
11358
11359        harness.launcher.supervise(&harness.policy).await.unwrap();
11360
11361        // The identity and termination ordering `e3` established is unchanged by
11362        // the workspace kind: the intent is durable before the signal, and the
11363        // slot is scrubbed only once the process is gone.
11364        let actions = harness.processes.actions.lock().unwrap().clone();
11365        let intent = actions
11366            .iter()
11367            .position(|action| *action == "terminate_intent")
11368            .unwrap();
11369        let signal = actions
11370            .iter()
11371            .position(|action| *action == "terminate")
11372            .unwrap();
11373        assert!(intent < signal, "{actions:?}");
11374        assert!(!harness.processes.alive.load(Ordering::SeqCst));
11375
11376        let cleaned = harness.attempt(id);
11377        assert_eq!(cleaned.state(), AttemptState::Cleaned);
11378        assert!(matches!(
11379            cleaned.outcome(),
11380            Some(AttemptOutcome::Failed {
11381                reason: FailureReason::TerminatedAfterRegistrationTimeout
11382            })
11383        ));
11384        assert_eq!(entries_of(&slot), only_the_job_workspace());
11385        assert!(marker.exists());
11386    }
11387
11388    #[test]
11389    fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
11390        let root = tempfile::tempdir().unwrap();
11391        let slot = root.path().join("s1");
11392        fs::create_dir(&slot).unwrap();
11393        let marker = retain_under_work(&slot);
11394        litter_the_slot(&slot);
11395        fs::write(slot.join("runner-package"), b"verified").unwrap();
11396
11397        scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
11398        verify_slot_scrubbed(&slot).expect("and proves it afterwards");
11399
11400        assert_eq!(entries_of(&slot), only_the_job_workspace());
11401        assert!(marker.exists());
11402    }
11403
11404    #[test]
11405    fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
11406        let slot = Path::new("/runners/s1");
11407
11408        // The ordinary case: the listing counted, so the count is the fact and
11409        // the published names qualify it.
11410        let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
11411        assert!(counted.contains("2 entries other than"), "{counted}");
11412        assert!(counted.contains("including `bin`"), "{counted}");
11413        assert_eq!(
11414            residue_detail(slot, 1, &[]),
11415            format!(
11416                "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
11417                slot.display()
11418            )
11419        );
11420
11421        // The race the second pass exists for: the listing saw nothing and the
11422        // filesystem answered otherwise. Saying "0 entries survived" here would
11423        // state the under-count as the fact and contradict the rest of the
11424        // sentence.
11425        let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
11426        assert!(!raced.contains('0'), "{raced}");
11427        assert!(raced.contains("reported nothing but"), "{raced}");
11428        assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
11429    }
11430
11431    #[test]
11432    fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
11433        let root = tempfile::tempdir().unwrap();
11434        let slot = root.path().join("s1");
11435        fs::create_dir(&slot).unwrap();
11436        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11437        verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
11438
11439        // Runner binaries, registration identity, process identity and this
11440        // agent's lifecycle marks, one at a time, so a scrub that skipped
11441        // exactly one is still caught.
11442        for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
11443            fs::write(slot.join(survivor), b"left behind").unwrap();
11444            let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11445            assert_eq!(quarantine.refusal, SlotRefusal::Residue);
11446            assert!(
11447                quarantine.detail.contains(&format!("`{survivor}`")),
11448                "{quarantine}"
11449            );
11450            fs::remove_file(slot.join(survivor)).unwrap();
11451        }
11452
11453        // A handoff is named by its published prefix, never by the UUID that
11454        // follows it, and never by the payload it holds.
11455        let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
11456        fs::write(&handoff, JIT.as_bytes()).unwrap();
11457        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11458        assert!(
11459            quarantine.detail.contains("an encoded JIT handoff"),
11460            "{quarantine}"
11461        );
11462        assert!(!quarantine.detail.contains(JIT), "{quarantine}");
11463        fs::remove_file(&handoff).unwrap();
11464
11465        // A name a workflow chose is counted and never echoed: a slot root is
11466        // writable by the job, so a file named after a secret would be published
11467        // by any message that repeated the listing.
11468        fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
11469        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11470        assert!(
11471            quarantine.detail.contains("1 entry other than"),
11472            "{quarantine}"
11473        );
11474        assert!(
11475            !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
11476            "{quarantine}"
11477        );
11478    }
11479
11480    #[test]
11481    fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
11482        let root = tempfile::tempdir().unwrap();
11483        let configured =
11484            LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
11485        let slot = configured.as_path().join("s1");
11486        fs::create_dir(&slot).unwrap();
11487
11488        verify_journalled_slot(&slot, nz(1), Some(&configured))
11489            .expect("the journalled slot agrees");
11490        verify_journalled_slot(&slot, nz(1), None)
11491            .expect("and a policy that is gone removes a check, not the ability to clean");
11492
11493        // The journalled slot number is what names the directory. `s1` recorded
11494        // as slot two is corrupt state, not a slot to clean.
11495        assert_eq!(
11496            verify_journalled_slot(&slot, nz(2), None)
11497                .unwrap_err()
11498                .refusal,
11499            SlotRefusal::NotTheJournalledSlot
11500        );
11501        for stray in ["s1/nested", "not-a-slot", "s01"] {
11502            let path = configured.as_path().join(stray);
11503            assert_eq!(
11504                verify_journalled_slot(&path, nz(1), None)
11505                    .unwrap_err()
11506                    .refusal,
11507                SlotRefusal::NotTheJournalledSlot,
11508                "{}",
11509                path.display()
11510            );
11511        }
11512
11513        // A surviving policy that names a different root does not get to have
11514        // its disagreement resolved by deleting something.
11515        let elsewhere = tempfile::tempdir().unwrap();
11516        let other =
11517            LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
11518        assert_eq!(
11519            verify_journalled_slot(&slot, nz(1), Some(&other))
11520                .unwrap_err()
11521                .refusal,
11522            SlotRefusal::PolicyRootDisagrees
11523        );
11524    }
11525
11526    #[cfg(unix)]
11527    #[test]
11528    fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
11529        // Windows needs a privilege to create a junction or a symlink, so the
11530        // substitution is made here; the rule is platform-independent because it
11531        // is `symlink_metadata`'s answer plus the reparse attribute.
11532        let root = tempfile::tempdir().unwrap();
11533        let outside = root.path().join("operator-data");
11534        fs::create_dir(&outside).unwrap();
11535        let sentinel = outside.join("do-not-delete.txt");
11536        fs::write(
11537            &sentinel,
11538            b"an operator's data, outside every approved root",
11539        )
11540        .unwrap();
11541
11542        let slot = root.path().join("s1");
11543        fs::create_dir(&slot).unwrap();
11544        fs::create_dir(slot.join("bin")).unwrap();
11545        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11546
11547        let quarantine = scrub_slot_entries(&slot).unwrap_err();
11548        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11549        assert!(
11550            sentinel.exists(),
11551            "the deletion followed the link out of the slot"
11552        );
11553        assert!(outside.is_dir());
11554        assert!(
11555            slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
11556            "the substituted link is left for the operator, never unlinked as if it were ours"
11557        );
11558
11559        // A `_work` that is a plain file is the same refusal for the same
11560        // reason: it is not a job workspace, and this is not the code that
11561        // decides what to do about it.
11562        let file_work = root.path().join("s2");
11563        fs::create_dir(&file_work).unwrap();
11564        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
11565        assert_eq!(
11566            scrub_slot_entries(&file_work).unwrap_err().refusal,
11567            SlotRefusal::WorkNotADirectory
11568        );
11569    }
11570
11571    #[cfg(unix)]
11572    #[test]
11573    fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
11574        let root = tempfile::tempdir().unwrap();
11575        let outside = root.path().join("operator-data");
11576        fs::create_dir(&outside).unwrap();
11577        let sentinel = outside.join("do-not-delete.txt");
11578        fs::write(
11579            &sentinel,
11580            b"an operator's data, outside every approved root",
11581        )
11582        .unwrap();
11583
11584        // The lexical half of containment passes -- the name is right and the
11585        // parent is right -- and canonical resolution is what catches it.
11586        let inside = root.path().join("inside");
11587        fs::create_dir(&inside).unwrap();
11588        let slot = inside.join("s1");
11589        std::os::unix::fs::symlink(&outside, &slot).unwrap();
11590
11591        assert_eq!(
11592            verify_journalled_slot(&slot, nz(1), None)
11593                .unwrap_err()
11594                .refusal,
11595            SlotRefusal::Containment
11596        );
11597        assert!(sentinel.exists());
11598        assert!(
11599            slot.symlink_metadata().is_ok(),
11600            "the link is left for the operator rather than removed as if it were ours"
11601        );
11602    }
11603
11604    /// The Windows half of the case above.
11605    ///
11606    /// Containment was proven only on Unix, and a symbolic link is the wrong
11607    /// instrument to prove it with on Windows: creating one needs a privilege an
11608    /// ordinary workflow does not have, so it is not the substitution an
11609    /// attacker would reach for. A **junction** needs none, which makes it both
11610    /// the realistic attack and the one this repository must refuse -- and
11611    /// `04-security-recovery.md` names it in the same breath as the symlink for
11612    /// exactly that reason.
11613    #[cfg(windows)]
11614    #[test]
11615    fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
11616        let root = tempfile::tempdir().unwrap();
11617        let outside = root.path().join("operator-data");
11618        fs::create_dir(&outside).unwrap();
11619        let sentinel = outside.join("do-not-delete.txt");
11620        fs::write(
11621            &sentinel,
11622            b"an operator's data, outside every approved root",
11623        )
11624        .unwrap();
11625
11626        // The lexical half of containment passes -- `s1` under the root the
11627        // journal names -- and canonical resolution is what catches it.
11628        let inside = root.path().join("inside");
11629        fs::create_dir(&inside).unwrap();
11630        let slot = inside.join("s1");
11631        let Some(()) = plant_junction(&slot, &outside) else {
11632            eprintln!("skipped: this machine would not create a directory junction");
11633            return;
11634        };
11635
11636        assert_eq!(
11637            verify_journalled_slot(&slot, nz(1), None)
11638                .unwrap_err()
11639                .refusal,
11640            SlotRefusal::Containment
11641        );
11642        assert!(
11643            sentinel.exists(),
11644            "the refusal resolved the junction and reached the operator's data"
11645        );
11646        assert!(
11647            slot.symlink_metadata().is_ok(),
11648            "the junction is left for the operator rather than removed as if it were ours"
11649        );
11650    }
11651
11652    /// Plant a directory junction at `link` pointing at `target`.
11653    ///
11654    /// A junction is the Windows substitution this has to refuse, and unlike a
11655    /// symbolic link it needs no privilege — which is exactly why it is the one
11656    /// an unprivileged workflow would reach for. `mklink` is a `cmd` builtin, so
11657    /// there is no binary to find and nothing to install; `None` means this
11658    /// machine would not make one and the caller says so rather than asserting
11659    /// nothing.
11660    #[cfg(windows)]
11661    fn plant_junction(link: &Path, target: &Path) -> Option<()> {
11662        let made = std::process::Command::new("cmd")
11663            .arg("/C")
11664            .arg("mklink")
11665            .arg("/J")
11666            .arg(link)
11667            .arg(target)
11668            .output()
11669            .ok()?;
11670        (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
11671    }
11672
11673    /// The dependency caches live beside attempts, under `<runner root>/_cache`,
11674    /// and jobs link into them (pnpm hard-links from its store; a workflow may
11675    /// symlink or junction a cache into its workspace). Removing an ephemeral
11676    /// attempt must take the links and never what they point at.
11677    #[test]
11678    fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
11679        let root = tempfile::tempdir().unwrap();
11680        let cache = root.path().join("_cache").join("o").join("r").join("npm");
11681        fs::create_dir_all(&cache).unwrap();
11682        let cached = cache.join("package.tgz");
11683        fs::write(&cached, b"kept between jobs").unwrap();
11684        let attempt = root.path().join("0123456789abcdef");
11685        let work = attempt.join("_work").join("repo");
11686        fs::create_dir_all(&work).unwrap();
11687        fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
11688        #[cfg(unix)]
11689        std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
11690        #[cfg(windows)]
11691        if plant_junction(&work.join("linked-cache"), &cache).is_none() {
11692            eprintln!("this machine would not create a junction; the hard link is still checked");
11693        }
11694
11695        remove_runtime_tree(&attempt).unwrap();
11696        assert!(!attempt.exists());
11697        assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
11698    }
11699
11700    #[cfg(windows)]
11701    #[test]
11702    fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
11703        let root = tempfile::tempdir().unwrap();
11704        let outside = root.path().join("operator-data");
11705        fs::create_dir(&outside).unwrap();
11706        let sentinel = outside.join("do-not-delete.txt");
11707        fs::write(
11708            &sentinel,
11709            b"an operator's data, outside every approved root",
11710        )
11711        .unwrap();
11712
11713        let slot = root.path().join("s1");
11714        fs::create_dir(&slot).unwrap();
11715        fs::create_dir(slot.join("bin")).unwrap();
11716        let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
11717            eprintln!("skipped: this machine would not create a directory junction");
11718            return;
11719        };
11720
11721        // The reparse point is what `is_link_like` answers on, so a junction is
11722        // refused for the same reason a symbolic link is and neither is
11723        // descended into.
11724        let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11725        assert!(is_link_like(&work), "a junction is a reparse point");
11726        let quarantine = scrub_slot_entries(&slot).unwrap_err();
11727        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11728        assert!(
11729            sentinel.exists(),
11730            "the deletion followed the junction out of the slot"
11731        );
11732        assert!(outside.is_dir());
11733
11734        // A junction standing where an ordinary entry was is unlinked rather
11735        // than followed, so the removal still cannot reach through it.
11736        let elsewhere = root.path().join("s2");
11737        fs::create_dir(&elsewhere).unwrap();
11738        fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
11739        if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
11740            scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
11741            verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
11742            assert!(sentinel.exists(), "the junction was followed, not unlinked");
11743            assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
11744        }
11745    }
11746
11747    #[cfg(unix)]
11748    #[tokio::test]
11749    async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
11750        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11751            .with_persistent_workspace(2);
11752        harness.ready().await;
11753        let first = harness.launch().await;
11754        let slot = harness.slot_path(1);
11755        harness.conclude(first.id);
11756
11757        let outside = harness._root.path().join("operator-data");
11758        fs::create_dir_all(&outside).unwrap();
11759        let sentinel = outside.join("do-not-delete.txt");
11760        fs::write(&sentinel, b"outside every approved root").unwrap();
11761        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11762
11763        harness
11764            .launcher
11765            .clean(first.id)
11766            .await
11767            .expect_err("a slot whose `_work` was substituted is quarantined");
11768        assert!(sentinel.exists());
11769
11770        let held = harness.attempt(first.id);
11771        assert_eq!(held.state(), AttemptState::Failed);
11772        assert!(held.holds_slot_lease());
11773
11774        // The quarantined slot is not silently chosen again.
11775        let second = harness.launch().await;
11776        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
11777    }
11778
11779    #[test]
11780    fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
11781        let root = tempfile::tempdir().unwrap();
11782        let occupied = root.path().join("s1");
11783        fs::write(&occupied, b"an operator's file").unwrap();
11784        // The journal check passes -- it is the right name under the right root
11785        // -- and the shape check is what refuses.
11786        verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
11787        assert_eq!(
11788            slot_is_present(&occupied).unwrap_err().refusal,
11789            SlotRefusal::SlotNotADirectory
11790        );
11791        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
11792
11793        // A directory that is simply not there leaves nothing to remove and
11794        // nothing to prove absent, so it is not a refusal.
11795        assert!(!slot_is_present(&root.path().join("s2")).unwrap());
11796        let present = root.path().join("s3");
11797        fs::create_dir(&present).unwrap();
11798        assert!(slot_is_present(&present).unwrap());
11799    }
11800
11801    #[test]
11802    fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
11803        let root = tempfile::tempdir().unwrap();
11804
11805        // A disposable directory that happens to contain a `_work` still goes
11806        // whole: the workspace kind is immutable so that the shape of a
11807        // directory a workflow can write to cannot choose its own algorithm.
11808        let disposable = root.path().join("abcdef012345");
11809        fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11810        let ephemeral = RunnerAttempt::allocate(
11811            AttemptId::new_random(),
11812            fixtures::POLICY_ID,
11813            &disposable,
11814            fixtures::created_at(),
11815        );
11816        remove_materialized_package(&ephemeral).unwrap();
11817        assert!(!disposable.exists());
11818
11819        // And a slot keeps its `_work` with the same contents beneath it.
11820        let slot = root.path().join("s1");
11821        fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11822        fs::create_dir_all(slot.join("bin")).unwrap();
11823        let persistent = RunnerAttempt::allocate_in(
11824            AttemptId::new_random(),
11825            fixtures::POLICY_ID,
11826            &slot,
11827            AttemptWorkspace::persistent_slot(nz(1)),
11828            fixtures::created_at(),
11829        );
11830        remove_materialized_package(&persistent).unwrap();
11831        assert_eq!(entries_of(&slot), only_the_job_workspace());
11832        assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
11833    }
11834
11835    #[test]
11836    fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
11837        let refusals = [
11838            SlotRefusal::NotTheJournalledSlot,
11839            SlotRefusal::PolicyRootDisagrees,
11840            SlotRefusal::Containment,
11841            SlotRefusal::SlotNotADirectory,
11842            SlotRefusal::Enumeration,
11843            SlotRefusal::WorkNotADirectory,
11844            SlotRefusal::Deletion,
11845            SlotRefusal::Residue,
11846        ];
11847        let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
11848        assert_eq!(
11849            classes.len(),
11850            refusals.len(),
11851            "an event class shared by two refusals tells an operator less than it appears to"
11852        );
11853        for refusal in refusals {
11854            // The event field is a closed vocabulary, so it has to look like
11855            // one: `d1`'s sink allows the name verbatim.
11856            assert!(
11857                refusal
11858                    .class()
11859                    .chars()
11860                    .all(|c| c.is_ascii_lowercase() || c == '_'),
11861                "{}",
11862                refusal.class()
11863            );
11864            assert!(
11865                refusal.remediation().contains("slot lease"),
11866                "every refusal has to say the lease is still held: {}",
11867                refusal.class()
11868            );
11869        }
11870    }
11871
11872    #[test]
11873    fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
11874        let root = tempfile::tempdir().unwrap();
11875        let source = root.path().join("source with spaces");
11876        let dest = root.path().join("dest with spaces");
11877
11878        fs::create_dir_all(&source).unwrap();
11879        fs::write(source.join("file1.txt"), b"hello").unwrap();
11880
11881        let nested = source.join("nested dir");
11882        fs::create_dir_all(&nested).unwrap();
11883        fs::write(nested.join("file2.txt"), b"world").unwrap();
11884
11885        // This is not a top-level `_work`, so it should be allowed
11886        let nested_work = nested.join(DEFAULT_WORK_FOLDER);
11887        fs::create_dir_all(&nested_work).unwrap();
11888        fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
11889
11890        copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
11891
11892        assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
11893        assert_eq!(
11894            fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
11895            "world"
11896        );
11897        assert_eq!(
11898            fs::read_to_string(
11899                dest.join("nested dir")
11900                    .join(DEFAULT_WORK_FOLDER)
11901                    .join("allowed.txt")
11902            )
11903            .unwrap(),
11904            "allowed"
11905        );
11906    }
11907
11908    #[test]
11909    fn every_field_name_the_attempt_sink_emits_is_allowed() {
11910        use runner_manager_platform::logging::is_field_allowed;
11911
11912        // Kept beside the sink rather than derived from it, as in `reconcile`.
11913        for field in [
11914            "event",
11915            "attempt_id",
11916            "reason",
11917            "retry_in_ms",
11918            "duration_ms",
11919            "order_wait_ms",
11920            "lock_wait_ms",
11921            "turn_wait_ms",
11922            "materialize_ms",
11923            "package_wait_ms",
11924            "package_install_ms",
11925            "package_verify_ms",
11926            "package_copy_ms",
11927            "package_downloaded",
11928            "prepare_ms",
11929            "register_ms",
11930            "spawn_ms",
11931        ] {
11932            assert!(
11933                is_field_allowed(field),
11934                "`{field}` would be logged as `[redacted]`"
11935            );
11936        }
11937    }
11938
11939    #[cfg(unix)]
11940    fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
11941        let entry = root.join("packages").join(version);
11942        fs::create_dir_all(entry.join("bin")).unwrap();
11943        fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
11944        fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
11945        entry
11946    }
11947
11948    /// The seed's binary's inode: a rebuilt seed holds a new file.
11949    #[cfg(unix)]
11950    fn listener_inode(seed: &Path) -> u64 {
11951        use std::os::unix::fs::MetadataExt as _;
11952        fs::metadata(seed.join("bin").join("Runner.Listener"))
11953            .unwrap()
11954            .ino()
11955    }
11956
11957    #[cfg(unix)]
11958    #[test]
11959    fn a_package_seed_is_built_once_and_reused_while_untouched() {
11960        let root = tempfile::tempdir().unwrap();
11961        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11962        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11963        let trusted = TrustedTrees::default();
11964
11965        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11966        assert_eq!(seed, seeds.join("2.336.0"));
11967        assert_eq!(
11968            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11969            "2.336.0"
11970        );
11971
11972        let built = listener_inode(&seed);
11973        assert_eq!(
11974            refresh_package_seed(&source, &seeds, &trusted).unwrap(),
11975            seed
11976        );
11977        assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
11978    }
11979
11980    #[cfg(unix)]
11981    #[test]
11982    fn a_tampered_or_unrecorded_seed_is_rebuilt() {
11983        let root = tempfile::tempdir().unwrap();
11984        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11985        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11986        let trusted = TrustedTrees::default();
11987        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11988
11989        // A job rewrites the binary in place and leaves the manifest alone.
11990        fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
11991        refresh_package_seed(&source, &seeds, &trusted).unwrap();
11992        assert_eq!(
11993            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11994            "2.336.0",
11995            "a seed whose print moved is rebuilt from the cache entry"
11996        );
11997
11998        // A restarted daemon has no print of a seed it did not build.
11999        // Keep the old inode allocated until the replacement is complete;
12000        // otherwise the filesystem may immediately reuse its number.
12001        let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
12002        let built = listener_inode(&seed);
12003        refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
12004        assert_ne!(
12005            listener_inode(&seed),
12006            built,
12007            "an unrecorded seed is rebuilt"
12008        );
12009        drop(old_listener);
12010    }
12011
12012    #[cfg(unix)]
12013    #[test]
12014    fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
12015        let root = tempfile::tempdir().unwrap();
12016        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
12017        let trusted = TrustedTrees::default();
12018        let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
12019        refresh_package_seed(&old, &seeds, &trusted).unwrap();
12020        fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
12021        fs::write(seeds.join("stray"), b"").unwrap();
12022
12023        let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
12024        let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
12025
12026        let mut left: Vec<_> = fs::read_dir(&seeds)
12027            .unwrap()
12028            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
12029            .collect();
12030        left.sort();
12031        assert_eq!(left, ["2.336.0"]);
12032
12033        // A reinstalled entry of the same version has a different manifest.
12034        fs::write(
12035            new.join(crate::package::MANIFEST_FILE),
12036            "{\"digest\":\"again\"}",
12037        )
12038        .unwrap();
12039        refresh_package_seed(&new, &seeds, &trusted).unwrap();
12040        assert_eq!(
12041            fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
12042            "{\"digest\":\"again\"}",
12043            "a stale seed is rebuilt from the changed entry"
12044        );
12045    }
12046
12047    #[cfg(unix)]
12048    #[test]
12049    fn a_package_on_the_runtime_volume_is_its_own_seed() {
12050        let root = tempfile::tempdir().unwrap();
12051        let source = package_entry(root.path(), "2.336.0", "{}");
12052        let runtime = root.path().join("runners").join("attempt");
12053        fs::create_dir_all(&runtime).unwrap();
12054
12055        let trusted = TrustedTrees::default();
12056        assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
12057        copy_package_tree(&source, &runtime, &trusted).unwrap();
12058        assert!(runtime.join("bin").join("Runner.Listener").exists());
12059        assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
12060    }
12061
12062    #[test]
12063    fn copy_package_tree_refuses_top_level_work_folder() {
12064        let root = tempfile::tempdir().unwrap();
12065        let source = root.path().join("source");
12066        let dest = root.path().join("dest");
12067
12068        fs::create_dir_all(&source).unwrap();
12069        fs::write(source.join("file1.txt"), b"hello").unwrap();
12070
12071        // Top-level `_work` should be refused
12072        let top_work = source.join(DEFAULT_WORK_FOLDER);
12073        fs::create_dir_all(&top_work).unwrap();
12074
12075        let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
12076        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
12077    }
12078}