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;
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`, and every cache directory `spec` names, to `account`.
156///
157/// The attempt is handed over whole: the runner writes its configuration,
158/// `_diag` and `_work` beside its own binaries. A cache directory is handed
159/// over whole only while its top is not the account's yet, which is the first
160/// launch after the account was introduced; after that the runner is the only
161/// one writing there, so the walk would find nothing to change.
162fn hand_over(
163    account: RunnerAccount,
164    runtime: &Path,
165    spec: &SpawnSpec,
166    cache_names: &[&str],
167) -> std::io::Result<()> {
168    hand_over_attempt(account, runtime)?;
169    for name in cache_names {
170        let Some(value) = spec.configured_env(name) else {
171            continue;
172        };
173        let path = Path::new(value);
174        if !path.is_absolute() {
175            continue;
176        }
177        match std::fs::symlink_metadata(path) {
178            Ok(metadata) if metadata.is_dir() && !account.owns(&metadata) => {
179                account.give_tree(path)?;
180            }
181            Ok(_) => {}
182            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
183            Err(error) => return Err(error),
184        }
185    }
186    Ok(())
187}
188
189/// Hands the attempt directory at `runtime` to `account`, and lets the
190/// account's group into the runner root above it.
191fn hand_over_attempt(account: RunnerAccount, runtime: &Path) -> std::io::Result<()> {
192    account.give_tree(runtime)?;
193    #[cfg(unix)]
194    {
195        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
196        // The attempt belongs to the runners' account, which every runner on
197        // this host shares; no other account may enter it. Its parent, the
198        // runner root, that account's group may read and pass through: root
199        // owns it, so no job can rename or replace another job's attempt.
200        // Read as well as search, because `Runner.Listener` refuses to start
201        // unless it can list every directory above its own.
202        fs::set_permissions(runtime, fs::Permissions::from_mode(0o700))?;
203        if let Some(root) = runtime.parent() {
204            let metadata = fs::symlink_metadata(root)?;
205            if metadata.is_dir() && metadata.uid() == 0 {
206                if metadata.gid() != account.gid {
207                    std::os::unix::fs::lchown(root, None, Some(account.gid))?;
208                }
209                if metadata.mode() & 0o7777 != 0o750 {
210                    fs::set_permissions(root, fs::Permissions::from_mode(0o750))?;
211                }
212            }
213        }
214    }
215    Ok(())
216}
217
218/// What an attempt's process-identity records name.
219#[derive(Debug)]
220enum RecordedProcess {
221    /// Nothing is recorded.
222    None,
223    /// A process the runner account runs, or one that has exited.
224    Runner(ProcessIdentity),
225    /// A live process the runner account does not run: not this daemon's
226    /// runner, so never signalled, and treated as gone.
227    Foreign,
228}
229
230impl RecordedProcess {
231    fn runner(self) -> Option<ProcessIdentity> {
232        match self {
233            Self::Runner(identity) => Some(identity),
234            Self::None | Self::Foreign => None,
235        }
236    }
237}
238
239/// Who a native runner runs as.
240#[derive(Debug, Clone, Default)]
241enum RunnerIdentity {
242    /// As the daemon itself.
243    #[default]
244    Daemon,
245    /// As this account, which owns the runner's attempt and caches.
246    Account(RunnerAccount),
247    /// As nobody: a confined `root` without the account starts no runner, and
248    /// this says why.
249    Missing(String),
250}
251
252impl RunnerIdentity {
253    /// The account to start a runner as, `None` for the daemon's own, or why
254    /// no runner may start.
255    fn account(&self) -> Result<Option<RunnerAccount>, FailureReason> {
256        match self {
257            Self::Daemon => Ok(None),
258            Self::Account(account) => Ok(Some(*account)),
259            Self::Missing(why) => Err(FailureReason::Other(why.clone())),
260        }
261    }
262}
263
264/// Retry bounds for failures that can resolve without operator action.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub struct RetryPolicy {
267    pub max_attempts: u32,
268    pub initial: Duration,
269    pub maximum: Duration,
270}
271
272impl RetryPolicy {
273    #[must_use]
274    pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
275        Self {
276            max_attempts,
277            initial,
278            maximum,
279        }
280    }
281
282    pub(crate) fn delay(self, failure_index: u32) -> Duration {
283        let shift = failure_index.saturating_sub(1).min(31);
284        self.initial
285            .saturating_mul(1_u32 << shift)
286            .min(self.maximum)
287    }
288}
289
290/// Non-secret lifecycle evidence.  Payloads are identifiers and closed enums;
291/// neither the encoded configuration nor child output can enter this type.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum AttemptEvent {
294    State {
295        attempt: AttemptId,
296        state: AttemptState,
297    },
298    Retry {
299        attempt: AttemptId,
300        operation: &'static str,
301        delay: Duration,
302    },
303    /// How long each step of a launch took, whether or not it started a
304    /// runner, so a slow or failed start can be attributed to the wait for the
305    /// allocation lock, the package copy, the JIT request, or the process start.
306    Launched {
307        attempt: AttemptId,
308        started: bool,
309        timings: LaunchTimings,
310    },
311    Adopted {
312        attempt: AttemptId,
313    },
314    RemoteIdentityRecovered {
315        attempt: AttemptId,
316        runner_id: u64,
317    },
318    TerminateIntent {
319        attempt: AttemptId,
320    },
321    Terminated {
322        attempt: AttemptId,
323    },
324    /// The attempt's GitHub registration was removed by this agent. Carries the
325    /// runner id because that is the identifier an operator sees in the
326    /// target's runner settings, and the attempt id is not shown there.
327    Deregistered {
328        attempt: AttemptId,
329        runner_id: u64,
330    },
331    Concluded {
332        attempt: AttemptId,
333        outcome: OutcomeKind,
334    },
335    Cleaned {
336        attempt: AttemptId,
337        outcome: OutcomeKind,
338    },
339    QuarantinedOwnedResource {
340        attempt: AttemptId,
341    },
342}
343
344pub trait AttemptEventSink: fmt::Debug + Send + Sync {
345    fn emit(&self, event: AttemptEvent);
346}
347
348#[derive(Debug, Default)]
349pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
350
351impl AttemptEventLog {
352    #[must_use]
353    pub fn events(&self) -> Vec<AttemptEvent> {
354        self.0
355            .lock()
356            .map(|events| events.clone())
357            .unwrap_or_default()
358    }
359}
360
361impl AttemptEventSink for AttemptEventLog {
362    fn emit(&self, event: AttemptEvent) {
363        if let Ok(mut events) = self.0.lock() {
364            events.push(event);
365        }
366    }
367}
368
369#[derive(Debug, Clone, Copy, Default)]
370pub struct NoAttemptEvents;
371
372impl AttemptEventSink for NoAttemptEvents {
373    fn emit(&self, _event: AttemptEvent) {}
374}
375
376/// Where one launch spent its time. A step the launch never reached is zero.
377#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
378pub struct LaunchTimings {
379    /// Waiting for older launches of this host to reserve first.
380    pub order_wait: Duration,
381    /// Waiting for the host allocation lock, which another launch may hold, and
382    /// for the rest of its pass to be reserved after it.
383    pub lock_wait: Duration,
384    /// Waiting for a turn to prepare: see [`preparation_turns`].
385    pub turn_wait: Duration,
386    pub materialize: Duration,
387    /// Where `materialize` went.
388    pub package: PackageTimings,
389    /// The execution provider's preparation: its environment, and the checks
390    /// of the host's runner environment and dependency caches.
391    pub prepare: Duration,
392    pub register: Duration,
393    pub spawn: Duration,
394}
395
396impl LaunchTimings {
397    #[must_use]
398    pub fn total(&self) -> Duration {
399        self.order_wait
400            + self.lock_wait
401            + self.turn_wait
402            + self.materialize
403            + self.prepare
404            + self.register
405            + self.spawn
406    }
407}
408
409/// Where a launch's package step went, across its retries.
410#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
411pub struct PackageTimings {
412    /// Waiting for another launch's install, or for copies an install must
413    /// not run under.
414    pub wait: Duration,
415    /// Checking the catalog, and downloading and extracting a package.
416    pub install: Duration,
417    /// Fingerprinting the trees a copy may trust.
418    pub verify: Duration,
419    /// Copying or cloning the tree into the runner's runtime.
420    pub copy: Duration,
421    /// A package was downloaded for this launch.
422    pub downloaded: bool,
423}
424
425#[derive(Debug, Clone, Copy)]
426enum LaunchStep {
427    Turn,
428    Materialize,
429    Prepare,
430    Register,
431    Spawn,
432}
433
434/// Times a launch's steps; beginning one ends the one before, so an early
435/// return still leaves the step it failed in measured once [`Self::finish`]
436/// runs.
437#[derive(Debug)]
438struct LaunchStopwatch {
439    timings: LaunchTimings,
440    running: Option<(LaunchStep, std::time::Instant)>,
441}
442
443impl LaunchStopwatch {
444    fn new(waits: ReservationWaits) -> Self {
445        Self {
446            timings: LaunchTimings {
447                order_wait: waits.order,
448                lock_wait: waits.lock,
449                ..LaunchTimings::default()
450            },
451            running: None,
452        }
453    }
454
455    fn begin(&mut self, step: LaunchStep) {
456        self.finish();
457        self.running = Some((step, std::time::Instant::now()));
458    }
459
460    fn finish(&mut self) {
461        if let Some((step, since)) = self.running.take() {
462            let slot = match step {
463                LaunchStep::Turn => &mut self.timings.turn_wait,
464                LaunchStep::Materialize => &mut self.timings.materialize,
465                LaunchStep::Prepare => &mut self.timings.prepare,
466                LaunchStep::Register => &mut self.timings.register,
467                LaunchStep::Spawn => &mut self.timings.spawn,
468            };
469            *slot = since.elapsed();
470        }
471    }
472}
473
474/// A launch slower than this is logged as a warning, with its steps.
475///
476/// A healthy launch is a few seconds; a queued job waits for all of it, and
477/// the daemon's default filter drops the `info` line that times every launch.
478const SLOW_LAUNCH: Duration = Duration::from_secs(60);
479
480/// The daemon's sink for retries and launch timings; the other attempt events
481/// already reach the log through the reconcile sink.
482///
483/// Every field name is on `d1`'s allow-list, or the redacting layer would log
484/// `[redacted]` in its place; `tests::every_field_name_the_attempt_sink_emits_is_allowed`
485/// keeps that true.
486#[derive(Debug, Clone, Copy, Default)]
487pub struct TracingAttemptEvents;
488
489impl AttemptEventSink for TracingAttemptEvents {
490    fn emit(&self, event: AttemptEvent) {
491        match event {
492            AttemptEvent::Retry {
493                attempt,
494                operation,
495                delay,
496            } => tracing::warn!(
497                event = "attempt_retry",
498                attempt_id = %attempt,
499                reason = operation,
500                retry_in_ms = millis(delay),
501            ),
502            AttemptEvent::Launched {
503                attempt,
504                started,
505                timings,
506            } => {
507                macro_rules! launched {
508                    ($level:ident, $name:literal) => {
509                        tracing::$level!(
510                            event = $name,
511                            attempt_id = %attempt,
512                            duration_ms = millis(timings.total()),
513                            order_wait_ms = millis(timings.order_wait),
514                            lock_wait_ms = millis(timings.lock_wait),
515                            turn_wait_ms = millis(timings.turn_wait),
516                            materialize_ms = millis(timings.materialize),
517                            package_wait_ms = millis(timings.package.wait),
518                            package_install_ms = millis(timings.package.install),
519                            package_verify_ms = millis(timings.package.verify),
520                            package_copy_ms = millis(timings.package.copy),
521                            package_downloaded = timings.package.downloaded,
522                            prepare_ms = millis(timings.prepare),
523                            register_ms = millis(timings.register),
524                            spawn_ms = millis(timings.spawn),
525                        )
526                    };
527                }
528                if !started {
529                    launched!(warn, "attempt_launch_failed");
530                } else if timings.total() >= SLOW_LAUNCH {
531                    launched!(warn, "attempt_launch_slow");
532                } else {
533                    launched!(info, "attempt_launched");
534                }
535            }
536            _ => {}
537        }
538    }
539}
540
541fn millis(duration: Duration) -> u64 {
542    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
543}
544
545/// Whether the demand that justified a retry still exists.
546#[async_trait]
547pub trait DemandPersistence: fmt::Debug + Send + Sync {
548    async fn persists(&self, policy: PolicyId) -> bool;
549}
550
551#[derive(Debug, Clone, Copy, Default)]
552pub struct PersistentDemand;
553
554#[async_trait]
555impl DemandPersistence for PersistentDemand {
556    async fn persists(&self, _policy: PolicyId) -> bool {
557        true
558    }
559}
560
561#[async_trait]
562pub trait RetryDelay: fmt::Debug + Send + Sync {
563    async fn wait(&self, duration: Duration);
564}
565
566#[derive(Debug, Clone, Copy, Default)]
567pub struct TokioRetryDelay;
568
569#[async_trait]
570impl RetryDelay for TokioRetryDelay {
571    async fn wait(&self, duration: Duration) {
572        tokio::time::sleep(duration).await;
573    }
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub struct JitRequestFailure {
578    pub terminal: bool,
579    pub reason: FailureReason,
580    pub retry_after: Option<Duration>,
581}
582
583/// GitHub's authoritative runner state plus the identity returned by inventory.
584/// The id is carried independently of the local sidecar so recovery can close
585/// the crash boundary immediately after a successful remote registration.
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub struct LifecycleGithubObservation {
588    pub status: GithubRunnerObservation,
589    pub runner_id: Option<u64>,
590}
591
592impl LifecycleGithubObservation {
593    #[must_use]
594    pub const fn unreachable() -> Self {
595        Self {
596            status: GithubRunnerObservation::Unreachable,
597            runner_id: None,
598        }
599    }
600
601    #[must_use]
602    pub const fn not_registered() -> Self {
603        Self {
604            status: GithubRunnerObservation::NotRegistered,
605            runner_id: None,
606        }
607    }
608
609    #[must_use]
610    pub const fn registered(runner_id: u64, busy: bool) -> Self {
611        Self {
612            status: GithubRunnerObservation::Registered { busy },
613            runner_id: Some(runner_id),
614        }
615    }
616}
617
618/// The two GitHub views the lifecycle needs, combined so one fake can drive
619/// registration and authoritative runner telemetry.
620#[async_trait]
621pub trait LifecycleGithub: fmt::Debug + Send + Sync {
622    async fn register(
623        &self,
624        target: &ScaleTarget,
625        request: &JitRunnerRequest,
626        cancel: &CancelToken,
627    ) -> Result<JitRegistration, JitRequestFailure>;
628
629    async fn observe(
630        &self,
631        target: &ScaleTarget,
632        attempt: AttemptId,
633        cancel: &CancelToken,
634    ) -> LifecycleGithubObservation;
635
636    /// Remove one runner registration this agent created.
637    ///
638    /// Answers whether the registration is gone, and is deliberately not
639    /// fallible in the `Result` sense: no caller may abandon a conclusion
640    /// because GitHub was unreachable. See
641    /// [`LifecycleLauncher::deregister_runner`].
642    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
643}
644
645#[async_trait]
646impl<T> LifecycleGithub for T
647where
648    T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
649{
650    async fn register(
651        &self,
652        target: &ScaleTarget,
653        request: &JitRunnerRequest,
654        cancel: &CancelToken,
655    ) -> Result<JitRegistration, JitRequestFailure> {
656        self.generate_jit_config(target, request, cancel)
657            .await
658            .map_err(|error| {
659                let reason = if matches!(&error, JitError::Forbidden { .. }) {
660                    FailureReason::Other(
661                        "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
662                            .into(),
663                    )
664                } else {
665                    FailureReason::JitRequestFailed
666                };
667                JitRequestFailure {
668                    terminal: error.is_terminal(),
669                    reason,
670                    retry_after: error
671                        .rate_limited()
672                        .map(|limit| limit.delay_from(self.now())),
673                }
674            })
675    }
676
677    async fn observe(
678        &self,
679        target: &ScaleTarget,
680        attempt: AttemptId,
681        cancel: &CancelToken,
682    ) -> LifecycleGithubObservation {
683        let expected_name = runner_name(attempt);
684        match self.list_runners(target, cancel).await {
685            Ok(inventory) => inventory
686                .runners()
687                .iter()
688                .find(|runner| runner.name == expected_name)
689                .map_or(LifecycleGithubObservation::not_registered(), |runner| {
690                    LifecycleGithubObservation::registered(runner.id, runner.busy)
691                }),
692            Err(_) => LifecycleGithubObservation::unreachable(),
693        }
694    }
695
696    async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
697        self.remove_runner(target, runner_id, cancel).await.is_ok()
698    }
699}
700
701/// Package/cache operations used by one attempt.
702#[async_trait]
703pub trait RuntimePackages: fmt::Debug + Send + Sync {
704    /// Copy the package into `attempt`'s runtime, adding where the time went
705    /// to `timings`.
706    async fn materialize(
707        &self,
708        attempt: &RunnerAttempt,
709        timings: &mut PackageTimings,
710    ) -> Result<RunnerVersion, FailureReason>;
711    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
712    fn prune_obsolete_guarded(
713        &self,
714        authority: PruneAuthority<'_>,
715        attempts: &[RunnerAttempt],
716    ) -> Result<(), FailureReason>;
717}
718
719/// Unforgeable evidence that pruning was reached through e1's launch request.
720/// The type is public only because it appears in the public adapter trait; its
721/// private field and constructor prevent callers from substituting a guard
722/// acquired from an unrelated lock.
723pub struct PruneAuthority<'a> {
724    _guard: &'a AllocationGuard,
725}
726
727impl fmt::Debug for PruneAuthority<'_> {
728    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
729        f.write_str("PruneAuthority")
730    }
731}
732
733impl<'a> PruneAuthority<'a> {
734    fn from_launch_request(guard: &'a AllocationGuard) -> Self {
735        Self { _guard: guard }
736    }
737
738    #[cfg(test)]
739    pub(crate) const fn for_test(guard: &'a AllocationGuard) -> Self {
740        Self { _guard: guard }
741    }
742}
743
744/// What every target of one daemon shares about the one package directory they
745/// all use: which trees this process vouches for, and who may touch them.
746///
747/// Per target, one target's reinstall replaces the tree another target
748/// recorded, whose next launch finds it changed and reinstalls in turn: on a
749/// host serving two targets, every switch between them was a full download.
750#[derive(Debug, Clone, Default)]
751pub struct SharedPackages {
752    /// Held shared to copy from a tree, exclusive to change one. A cache entry
753    /// or seed is not replaced under a copy.
754    trees: Arc<tokio::sync::RwLock<()>>,
755    trusted: Arc<TrustedTrees>,
756    last_check: LastCheck,
757    /// The last install that failed, and when: the launches queued behind it
758    /// get its answer. See [`CachedRuntimePackages::materialize`].
759    failed_install: Arc<Mutex<Option<(std::time::Instant, FailureReason)>>>,
760}
761
762impl SharedPackages {
763    /// Stops trusting a shared package, unless a later install has replaced the
764    /// record this launch read: that one is another package's. The next launch
765    /// prepares it again.
766    fn discard(
767        &self,
768        root: &Path,
769        origin: &Path,
770        contents: &Arc<PackageContents>,
771        reason: &'static str,
772    ) {
773        if self.trusted.forget_if_holding(root, contents) {
774            tracing::warn!(
775                event = "package_verify_failed",
776                reason,
777                "a shared runner package does not match its verified archive; it is discarded \
778                 and prepared again"
779            );
780            self.trusted.forget(origin);
781        }
782    }
783
784    /// What an install ended in, for the launches queued behind it. A terminal
785    /// failure is not shared: it would close each of them, with no retry of its
786    /// own, and one that succeeded clears the last failure.
787    fn note_install(&self, result: Result<(), &FailureReason>) {
788        *self
789            .failed_install
790            .lock()
791            .unwrap_or_else(std::sync::PoisonError::into_inner) = result
792            .err()
793            .filter(|reason| !package_failure_is_terminal(reason))
794            .map(|reason| (std::time::Instant::now(), reason.clone()));
795    }
796
797    /// What an install that failed while a launch queued since `queued` for it
798    /// answered.
799    fn install_failed_since(&self, queued: std::time::Instant) -> Option<FailureReason> {
800        self.failed_install
801            .lock()
802            .unwrap_or_else(std::sync::PoisonError::into_inner)
803            .as_ref()
804            .filter(|(at, _)| *at >= queued)
805            .map(|(_, reason)| reason.clone())
806    }
807
808    /// For each target's [`PackageCache`]: the published version is one fact,
809    /// so one target's check spares the others theirs, and each check holds
810    /// every copy on this host back.
811    #[must_use]
812    pub fn last_check(&self) -> LastCheck {
813        self.last_check.clone()
814    }
815}
816
817#[cfg(test)]
818impl SharedPackages {
819    pub(crate) async fn copying(&self) -> tokio::sync::RwLockReadGuard<'_, ()> {
820        self.trees.read().await
821    }
822
823    /// Trusts `root`'s look as it is now, keeping what it must hold: what an edit
824    /// no fingerprint can see leaves behind, as on Windows, or by a superuser.
825    pub(crate) fn blind_to_edits_of(&self, root: &Path) {
826        let contents = self.trusted.contents_of(root);
827        self.trusted.record(root, contents).expect("recorded");
828    }
829
830    pub(crate) fn vouches_for(&self, root: &Path) -> bool {
831        self.trusted.contents_of(root).is_some()
832    }
833}
834
835/// Production package adapter.  It takes an e2 lease before returning, so a
836/// cache entry can never look unused while its runtime is starting.
837#[derive(Debug)]
838pub struct CachedRuntimePackages {
839    cache: Arc<PackageCache>,
840    shared: SharedPackages,
841}
842
843/// Add how long `work` took to `slot`.
844async fn timed<T>(slot: &mut Duration, work: impl std::future::Future<Output = T>) -> T {
845    let began = std::time::Instant::now();
846    let result = work.await;
847    *slot += began.elapsed();
848    result
849}
850
851impl CachedRuntimePackages {
852    /// Run `work` against a package tree on the blocking pool: a fingerprint
853    /// walks ~9,000 entries and a copy can take minutes.
854    async fn blocking<T: Send + 'static>(
855        &self,
856        root: &Path,
857        work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
858    ) -> Result<T, FailureReason> {
859        let trusted = Arc::clone(&self.shared.trusted);
860        let root = root.to_path_buf();
861        tokio::task::spawn_blocking(move || work(&trusted, &root))
862            .await
863            .map_err(|_| FailureReason::ProcessStartFailed)
864    }
865
866    /// One target's view of the package directory the targets of `shared` use.
867    #[must_use]
868    pub const fn new(cache: Arc<PackageCache>, shared: SharedPackages) -> Self {
869        Self { cache, shared }
870    }
871
872    /// A copy that cannot be used: removed, and the trees it came from read to see
873    /// whose fault it is. A runtime can be touched by a job, or sit on a disk with a
874    /// fault, and that is no reason to download anything. A tree that differs is
875    /// discarded, and prepared again by the next launch that needs it.
876    async fn reject_copy(
877        &self,
878        attempt: &RunnerAttempt,
879        root: &Path,
880        origin: &Path,
881        contents: &Arc<PackageContents>,
882        timings: &mut PackageTimings,
883    ) -> Result<(), FailureReason> {
884        let (attempt_copy, checking, from) =
885            (attempt.clone(), Arc::clone(contents), origin.to_path_buf());
886        let differs = timed(
887            &mut timings.verify,
888            self.blocking(root, move |_, source| {
889                let _ = remove_materialized_package(&attempt_copy);
890                let shared = checking.verify(source, |_| false);
891                shared.and_then(|()| {
892                    if from == source {
893                        Ok(())
894                    } else {
895                        checking.verify(&from, |_| false)
896                    }
897                })
898            }),
899        )
900        .await?;
901        if let Err(mismatch) = differs {
902            self.shared
903                .discard(root, origin, contents, mismatch.as_str());
904        }
905        Ok(())
906    }
907
908    /// The tree a runtime can be copied from right now, under nothing more than
909    /// the shared hold: nothing to check for or install, and a tree still as
910    /// this process left it.
911    async fn ready(
912        &self,
913        destination: &Path,
914        timings: &mut PackageTimings,
915    ) -> Result<Option<(InstalledPackage, PathBuf)>, FailureReason> {
916        let Some(installed) = self.cache.reusable().map_err(package_failure)? else {
917            return Ok(None);
918        };
919        let destination = destination.to_path_buf();
920        let verifying = self.blocking(installed.root(), move |trusted, source| {
921            if trusted.holds(source) {
922                copy_origin_ready(source, &destination, trusted)
923            } else {
924                None
925            }
926        });
927        let origin = timed(&mut timings.verify, verifying).await?;
928        Ok(origin.map(|origin| (installed, origin)))
929    }
930
931    /// Under the exclusive hold: a package installed and trusted, and the tree
932    /// to copy it from.
933    async fn install_trusted(
934        &self,
935        destination: &Path,
936        timings: &mut PackageTimings,
937    ) -> Result<(InstalledPackage, PathBuf), FailureReason> {
938        let (mut installed, downloaded) = timed(
939            &mut timings.install,
940            self.cache.ensure_installed_reporting(),
941        )
942        .await
943        .map_err(package_failure)?;
944        timings.downloaded |= downloaded;
945        // A cache entry this process did not install, or one that has changed
946        // since, may hold a job's edits; replace it with one downloaded and
947        // verified now. One this call just downloaded is trusted as it lands.
948        // See `TrustedTrees`.
949        let fresh = downloaded
950            || !timed(
951                &mut timings.verify,
952                self.blocking(installed.root(), TrustedTrees::holds),
953            )
954            .await?;
955        if fresh && !downloaded {
956            // After a restart there is simply no record. With one, this is an edit.
957            if self.shared.trusted.contents_of(installed.root()).is_some() {
958                tracing::warn!(
959                    event = "package_verify_failed",
960                    reason = "changed",
961                    "a shared runner package changed since it was prepared; it is discarded and \
962                     prepared again"
963                );
964            }
965            installed = timed(&mut timings.install, self.cache.reinstall())
966                .await
967                .map_err(package_failure)?;
968            timings.downloaded = true;
969        }
970        let destination = destination.to_path_buf();
971        let contents = installed.contents();
972        let origin = timed(
973            &mut timings.verify,
974            self.blocking(installed.root(), move |trusted, source| {
975                if fresh {
976                    trusted.record(source, contents)?;
977                }
978                Ok::<_, std::io::Error>(copy_origin(source, &destination, trusted))
979            }),
980        )
981        .await?
982        .map_err(|_| FailureReason::ProcessStartFailed)?;
983        Ok((installed, origin))
984    }
985}
986
987#[async_trait]
988impl RuntimePackages for CachedRuntimePackages {
989    async fn materialize(
990        &self,
991        attempt: &RunnerAttempt,
992        timings: &mut PackageTimings,
993    ) -> Result<RunnerVersion, FailureReason> {
994        let destination = attempt.runtime_path().to_path_buf();
995        let trees = &self.shared.trees;
996        // Only a launch that has something to change goes exclusive, and it lets
997        // go before it copies, so the launches queued behind it do not wait for
998        // its copy too. Once more if the tree still is not ready: a job edited
999        // it meanwhile, or a seed cannot be made here. Then it copies under what
1000        // it just made, which no other launch can change.
1001        let mut installs = 0;
1002        let (hold, (installed, origin)) = loop {
1003            let queued = std::time::Instant::now();
1004            let hold = timed(&mut timings.wait, trees.read()).await;
1005            if let Some(ready) = self.ready(&destination, timings).await? {
1006                break (hold, ready);
1007            }
1008            drop(hold);
1009            // An install that failed while this launch queued for it answers
1010            // this launch too. Trying again at once, one queued launch after
1011            // another, would hold every copy of the package back for as many
1012            // stalled downloads as there are launches; the launch's own retry,
1013            // after its back-off, may try the download.
1014            let exclusive = timed(&mut timings.wait, trees.write()).await;
1015            if let Some(reason) = self.shared.install_failed_since(queued) {
1016                return Err(reason);
1017            }
1018            let installed = self.install_trusted(&destination, timings).await;
1019            self.shared.note_install(installed.as_ref().map(|_| ()));
1020            let ready = installed?;
1021            installs += 1;
1022            if installs == 2 {
1023                break (exclusive.downgrade(), ready);
1024            }
1025        };
1026        // Taken under the hold, so it is the record of the tree this copies. None
1027        // vouches for a tree with no record: before it is copied, not after.
1028        let Some(contents) = self.shared.trusted.contents_of(installed.root()) else {
1029            self.shared.trusted.forget(installed.root());
1030            return Err(FailureReason::ProcessStartFailed);
1031        };
1032        let copied_from = origin.clone();
1033        // Off the async thread: the daemon runs a current-thread runtime, and a
1034        // copy can take minutes on a busy disk.
1035        let copied = timed(
1036            &mut timings.copy,
1037            self.blocking(installed.root(), {
1038                let destination = destination.clone();
1039                move |trusted, source| {
1040                    copy_package_from(source, &origin, &destination, trusted)?;
1041                    // Checked again after the copy: an edit a job made while it ran
1042                    // moved the entry's print, and the runtime may hold it.
1043                    if trusted.holds(source) {
1044                        Ok(())
1045                    } else {
1046                        Err(std::io::Error::other(
1047                            "the runner package changed while copied",
1048                        ))
1049                    }
1050                }
1051            }),
1052        )
1053        .await?;
1054        if copied.is_err() {
1055            // Nothing a failed or tainted copy left may reach a retry's copy,
1056            // which lays out over the same runtime.
1057            drop(hold);
1058            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
1059                .await?;
1060            return Err(FailureReason::ProcessStartFailed);
1061        }
1062        if let Err(error) = self.cache.lease(attempt, installed.version()) {
1063            // Undoing the copy must not undo the *job* workspace: a persistent
1064            // slot's `_work` is retained across attempts, and this rollback
1065            // runs before the attempt that would have owned it ever started.
1066            drop(hold);
1067            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
1068                .await?;
1069            return Err(package_failure(error));
1070        }
1071        // Leased while still holding the trees, so a prune that gets the
1072        // exclusive hold finds this runtime's lease.
1073        drop(hold);
1074        // What runs is this copy, so this copy is what is checked, whatever
1075        // happened to the shared trees meanwhile: read in full against the
1076        // archive, under neither the host lock nor the trees' hold.
1077        let persistent = matches!(attempt.workspace(), AttemptWorkspace::PersistentSlot { .. });
1078        let checking = Arc::clone(&contents);
1079        let verified = timed(
1080            &mut timings.verify,
1081            self.blocking(installed.root(), move |_, _| {
1082                checking.verify(&destination, |name| persistent && is_work_folder(name))
1083            }),
1084        )
1085        .await?;
1086        if let Err(mismatch) = verified {
1087            let _ = self.cache.release(attempt.id);
1088            tracing::warn!(
1089                event = "package_copy_differs",
1090                attempt_id = %attempt.id,
1091                reason = mismatch.as_str(),
1092                "a runner's package copy does not match the package's verified archive; it is \
1093                 being removed and will not be used"
1094            );
1095            self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
1096                .await?;
1097            return Err(FailureReason::ProcessStartFailed);
1098        }
1099        Ok(installed.version().clone())
1100    }
1101
1102    fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
1103        self.cache.release(attempt).map_err(package_failure)
1104    }
1105
1106    /// Everything but the newest version, when nothing is using the trees: a
1107    /// copy from an older version, or an install in progress, makes this a
1108    /// no-op, and a later launch prunes what it left, which costs only disk.
1109    fn prune_obsolete_guarded(
1110        &self,
1111        _authority: PruneAuthority<'_>,
1112        attempts: &[RunnerAttempt],
1113    ) -> Result<(), FailureReason> {
1114        let Ok(_exclusive) = self.shared.trees.try_write() else {
1115            return Ok(());
1116        };
1117        let Some(current) = self.cache.newest_installed().map_err(package_failure)? else {
1118            return Ok(());
1119        };
1120        for installed in self.cache.installed().map_err(package_failure)? {
1121            if installed.version() != current.version() {
1122                match self.cache.prune(installed.version(), attempts) {
1123                    Ok(()) => self.shared.trusted.forget(installed.root()),
1124                    Err(PackageError::VersionInUse { .. }) => {}
1125                    Err(error) => return Err(package_failure(error)),
1126                }
1127            }
1128        }
1129        Ok(())
1130    }
1131}
1132
1133fn package_failure(error: PackageError) -> FailureReason {
1134    error.failure_reason().unwrap_or(FailureReason::Other(
1135        "runner package cache operation failed".into(),
1136    ))
1137}
1138
1139fn package_failure_is_terminal(reason: &FailureReason) -> bool {
1140    matches!(
1141        reason,
1142        FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
1143    )
1144}
1145
1146/// How many hex characters of the attempt id name its workspace.
1147///
1148/// # Why this is not the whole identifier, and why the policy is not in the path
1149///
1150/// Windows refuses a path over `MAX_PATH`, and the runner writes deep inside
1151/// this directory: `_work/<repo>/<repo>/.git/objects/pack/pack-<40 hex>.keep`
1152/// is 100 characters on its own before the repository is named twice. The
1153/// layout used to add two full identifiers -- the policy's and the attempt's,
1154/// 74 characters between them -- and that was enough to put a real checkout
1155/// over the line. Measured, not guessed: this repository's own CI failed here
1156/// three times in a row at 264 characters against a limit of 260, with
1157/// `fatal: cannot write keep file ...: Filename too long`. A repository whose
1158/// name is ten characters longer would have missed by fourteen.
1159///
1160/// The policy identifier is simply redundant -- an attempt identifier is
1161/// unique on its own, and nothing reads the directory tree to find a policy's
1162/// attempts, because [`crate::lifecycle::LifecycleLauncher`] asks the journal.
1163/// Twelve hex characters of the attempt is 48 bits, which for the handful of
1164/// directories one host holds at once is not a collision anybody will see, and
1165/// the journal keeps the full identifier either way.
1166///
1167/// Together that is 61 characters returned to the repository name.
1168const WORKSPACE_NAME_LEN: usize = 12;
1169
1170/// The directory name for one attempt's workspace.
1171fn workspace_name(id: AttemptId) -> String {
1172    let full = id.to_string();
1173    full.chars()
1174        .filter(|c| *c != '-')
1175        .take(WORKSPACE_NAME_LEN)
1176        .collect()
1177}
1178
1179/// Where one attempt's files go, and which cleanup algorithm they are owed.
1180///
1181/// The pair travels together because journalling them apart is exactly the bug
1182/// `AttemptWorkspace` exists to prevent: a `runtime_path` under a persistent
1183/// root recorded as ephemeral would be removed whole, taking the retained job
1184/// workspace with it.
1185#[derive(Debug, Clone)]
1186struct Placement {
1187    runtime: PathBuf,
1188    workspace: AttemptWorkspace,
1189}
1190
1191/// A runner-root refusal, rendered for the operator who has to fix it.
1192///
1193/// `RunnerRootError`'s `Display` already names the path, the relation and the
1194/// remediation command, and none of its variants can carry a credential — they
1195/// are paths, and `03-migration-rollout.md` requires the remediation command to
1196/// reach the operator verbatim.
1197fn root_failure(error: RunnerRootError) -> LifecycleError {
1198    // Logged here because here is the last place this is known at all. A launch
1199    // refused by the runner root fails *before* `record_allocation`, so no
1200    // attempt row is ever written: `b2` has nothing to carry the failure on and
1201    // `g2` has nothing to show it from. The lifecycle event keeps only
1202    // `reason=other`, by `failure_reason_kind`'s rule that no free text may
1203    // reach an event -- which left the whole refusal reading
1204    // `runner_start_failed reason=other`, once per poll, naming nothing.
1205    //
1206    // What travels is the *kind* and not the sentence, and that is forced rather
1207    // than chosen: `crate::logging` redacts every field it does not allow-list
1208    // and then scrubs anything path-shaped out of the ones it does, so a
1209    // rendered `RunnerRootError` -- which is mostly paths -- reaches the log as
1210    // `[redacted]`. `error_kind` is allow-listed and `RunnerRootError::kind` is
1211    // a closed vocabulary that survives the scrub, so this names which of a
1212    // dozen causes the operator has. The paths and the remediation reach them
1213    // through the command line, which is not redacted.
1214    tracing::warn!(
1215        error_kind = error.kind(),
1216        "the runner root refused this launch, so no attempt was created; the host will \
1217         retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
1218         with the same path re-runs this check and prints the directory and the \
1219         remediation in full"
1220    );
1221    LifecycleError::Failed(FailureReason::Other(error.to_string()))
1222}
1223
1224/// The lowest positive slot inside `ceiling` that no uncleaned attempt holds.
1225///
1226/// `leases` is the journal's answer to "which slots are leased"
1227/// (`Store::slot_leases_for_policy`), which deliberately includes a terminal
1228/// attempt whose cleanup has not finished: that attempt still owns its
1229/// directory, so its slot is not free even though it no longer counts against
1230/// host capacity. `None` means the ceiling is reached, which is a refusal and
1231/// not a reason to allocate `s(ceiling + 1)`.
1232fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
1233    let held: BTreeSet<u16> = leases
1234        .iter()
1235        .filter_map(|attempt| attempt.workspace().slot_number())
1236        .collect();
1237    (1..=ceiling.get())
1238        .find(|slot| !held.contains(slot))
1239        .and_then(NonZeroU16::new)
1240}
1241
1242/// Create `<root>/sN`, or prove that what is already there is a real directory.
1243///
1244/// A symlink, junction or reparse point standing where the slot should be is
1245/// refused rather than followed: it is the one thing that could put an attempt's
1246/// files outside the root the operator configured, and
1247/// `04-security-recovery.md` requires that case to fail closed rather than to
1248/// be repaired here.
1249fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
1250    match fs::symlink_metadata(slot) {
1251        // [`is_link_like`] and not `is_symlink`, so that this is the same
1252        // question cleanup asks in [`slot_is_present`]: a reparse tag the
1253        // standard library has no name for is refused here rather than
1254        // allocated into and then quarantined forever by a cleanup that will
1255        // not scrub it.
1256        Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
1257            slot,
1258            "is a symbolic link, junction or other reparse point, which could place runner \
1259             files outside the configured root",
1260        )),
1261        Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
1262        Ok(_) => Ok(()),
1263        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
1264            .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
1265        Err(source) => Err(slot_refusal(
1266            slot,
1267            format!("could not be inspected: {source}"),
1268        )),
1269    }
1270}
1271
1272/// Accept a slot for reuse only when it is empty or holds one real `_work`.
1273///
1274/// `02-target-architecture.md`: "Before materialization, a reusable slot must
1275/// contain only a valid real `_work` directory or be empty." Everything else —
1276/// a leftover `bin/`, a link-shaped `_work`, a stray file — is refused here
1277/// rather than cleaned, because deciding whether those bytes are safe is
1278/// cleanup's and recovery's job (`c3`), and quietly reusing them would hand one
1279/// repository's retained state to the next attempt without anybody choosing to.
1280///
1281/// The inspection is one level deep and uses `symlink_metadata`, so nothing is
1282/// followed while it is being judged.
1283fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
1284    let unreadable =
1285        |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
1286    let entries = fs::read_dir(slot).map_err(unreadable)?;
1287    let mut refused: Vec<String> = Vec::new();
1288    for entry in entries {
1289        let entry = entry.map_err(unreadable)?;
1290        let name = entry.file_name();
1291        let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
1292            slot_refusal(
1293                slot,
1294                format!("entry {name:?} could not be inspected: {source}"),
1295            )
1296        })?;
1297        // The same predicate cleanup retains by, so a `_work` this accepts is
1298        // one [`scrub_slot_entries`] will keep rather than refuse: a link, a
1299        // junction or any other reparse point is not a job workspace to either
1300        // of them.
1301        if is_retainable_work_folder(&name, &metadata) {
1302            continue;
1303        }
1304        refused.push(name.to_string_lossy().into_owned());
1305    }
1306    if refused.is_empty() {
1307        return Ok(());
1308    }
1309    refused.sort();
1310    Err(slot_refusal(
1311        slot,
1312        format!(
1313            "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
1314             one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
1315             entries listed, or let cleanup and recovery resolve them",
1316            if refused.len() == 1 {
1317                "an entry"
1318            } else {
1319                "entries"
1320            },
1321            refused.join(", ")
1322        ),
1323    ))
1324}
1325
1326fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
1327    LifecycleError::Failed(FailureReason::Other(format!(
1328        "the persistent slot {} {detail}",
1329        slot.display()
1330    )))
1331}
1332
1333/// Whether a directory entry names the retained job workspace.
1334///
1335/// The comparison folds case on Windows because the filesystem does: there
1336/// `_Work` and `_work` are one directory, so a case-sensitive test would let
1337/// [`scrub_slot_entries`] delete the very directory it exists to keep, let
1338/// [`accept_reusable_slot`] refuse a slot that holds nothing but a valid job
1339/// workspace, and let a package's top-level `_Work` merge itself into the
1340/// previous attempt's `_work`. Elsewhere the two names really are two
1341/// directories and only the exact one is the job workspace.
1342fn is_work_folder(name: &OsStr) -> bool {
1343    if cfg!(windows) {
1344        name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
1345    } else {
1346        name == OsStr::new(DEFAULT_WORK_FOLDER)
1347    }
1348}
1349
1350/// Whether the operating system would follow this entry somewhere else.
1351///
1352/// `FileType::is_symlink` is the whole answer on Unix. On Windows it is not:
1353/// the standard library reports only the symlink and mount-point reparse tags,
1354/// and the substitution this has to refuse is *any* reparse point standing
1355/// where a real directory should be. So the attribute bit is the test there,
1356/// and a tag the standard library has no name for fails closed with the two it
1357/// does.
1358fn is_link_like(metadata: &fs::Metadata) -> bool {
1359    if metadata.file_type().is_symlink() {
1360        return true;
1361    }
1362    #[cfg(windows)]
1363    {
1364        use std::os::windows::fs::MetadataExt;
1365
1366        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1367        metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
1368    }
1369    #[cfg(not(windows))]
1370    false
1371}
1372
1373/// Whether an entry is the retained job workspace and is safe to retain.
1374///
1375/// The two halves are one question. A `_work` that is a file, a symlink, a
1376/// junction or any other reparse point is not a job workspace, and it is also
1377/// the exact substitution a hostile workflow makes to send cleanup somewhere
1378/// else (`04-security-recovery.md`, "A workflow replaces `_work` with a
1379/// junction or symlink to escape cleanup").
1380fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
1381    is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
1382}
1383
1384/// Undo one package materialization, dispatching on the journalled workspace.
1385///
1386/// The ephemeral half is what this always did: the directory is the attempt's
1387/// alone, so it goes whole. The persistent half removes the copy and nothing
1388/// else, because the slot's `_work` predates this attempt and outlives it
1389/// (`02-target-architecture.md`, "Persistent repository").
1390fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
1391    match attempt.workspace() {
1392        AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
1393        AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
1394            .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
1395    }
1396}
1397
1398/// Remove a disposable runner tree without opening workflow-created special files.
1399///
1400/// On Unix, `remove_dir_all` 1.0 can open a FIFO while walking a directory and
1401/// wait forever for its peer. The .NET runner routinely leaves diagnostic FIFOs
1402/// in its private `tmp`, so use the standard library's fd-relative Unix remover,
1403/// which unlinks non-directories without opening them. Keep the external remover
1404/// on Windows for its existing read-only and long-path handling; when it is
1405/// refused, [`unlink_reparse_points`] takes every link out of its way and it
1406/// runs once more.
1407fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
1408    #[cfg(windows)]
1409    {
1410        match remove_dir_all::remove_dir_all(path) {
1411            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1412                unlink_reparse_points(path);
1413                remove_dir_all::remove_dir_all(path)
1414            }
1415            other => other,
1416        }
1417    }
1418    #[cfg(not(windows))]
1419    {
1420        fs::remove_dir_all(path)
1421    }
1422}
1423
1424/// Remove every symlink, junction and other reparse point under `dir` as a
1425/// link, without ever descending into one.
1426///
1427/// WinINet creates `…\INetCache\Content.IE5` as a junction whose own DACL denies
1428/// `Everyone` the right to list it, and a job that points `USERPROFILE` or
1429/// `LOCALAPPDATA` into its workspace gets one there. Both `remove_dir_all` 1.0
1430/// and `std::fs::remove_dir_all` then fail on the whole tree with
1431/// `ERROR_ACCESS_DENIED`, so the attempt could never be cleaned. Removing the
1432/// link itself is a different operation (`RemoveDirectoryW` on the reparse
1433/// point) that the deny entry does not cover, and once the links are gone the
1434/// tree is ordinary.
1435///
1436/// Only walked after a refusal, so an ordinary tree is traversed once. The
1437/// metadata comes from the directory listing itself, which on Windows
1438/// describes a reparse point without opening it.
1439///
1440/// Best effort: a failure here is not reported, because
1441/// [`remove_runtime_tree`]'s second `remove_dir_all` runs next and its result
1442/// is the authority on whether the tree went.
1443#[cfg(windows)]
1444fn unlink_reparse_points(dir: &Path) {
1445    let Ok(entries) = fs::read_dir(dir) else {
1446        return;
1447    };
1448    for entry in entries.flatten() {
1449        let path = entry.path();
1450        let Ok(metadata) = entry.metadata() else {
1451            continue;
1452        };
1453        if is_link_like(&metadata) {
1454            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
1455        } else if metadata.is_dir() {
1456            unlink_reparse_points(&path);
1457        }
1458    }
1459}
1460
1461// ---------------------------------------------------------------------------
1462// Persistent cleanup (`04-security-recovery.md`, "Safe path handling")
1463// ---------------------------------------------------------------------------
1464
1465/// Why a persistent slot could not be proven safe to scrub.
1466///
1467/// A closed set rather than a formatted string, for two reasons that point the
1468/// same way. [`LifecycleEvent::AttemptCleanFailed`] takes a `&'static str` for
1469/// exactly the reason [`crate::reconcile::failure_reason_kind`] documents —
1470/// free text is the one shape that can carry a credential past a field
1471/// allow-list. And the entries under a slot root are *workflow-controlled*: a
1472/// job that writes a file named after a secret would publish it through any
1473/// message that echoed a directory listing, which is why nothing here ever
1474/// renders an entry name that did not come from this module's own constants.
1475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1476enum SlotRefusal {
1477    /// The journalled runtime path is not `<root>/sN` for the journalled slot.
1478    NotTheJournalledSlot,
1479    /// A policy that still exists names a different root than the journal does.
1480    PolicyRootDisagrees,
1481    /// The slot is not strictly inside its root once components resolve.
1482    Containment,
1483    /// The slot itself is a file, a link, or could not be inspected.
1484    SlotNotADirectory,
1485    /// The slot's direct entries could not be listed.
1486    Enumeration,
1487    /// `_work` is a file, a symlink, a junction or another reparse point.
1488    WorkNotADirectory,
1489    /// An entry that had to go could not be removed.
1490    Deletion,
1491    /// Something other than the job workspace survived removal.
1492    Residue,
1493}
1494
1495impl SlotRefusal {
1496    /// The event field: a fixed vocabulary, never operator or workflow text.
1497    const fn class(self) -> &'static str {
1498        match self {
1499            Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1500            Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1501            Self::Containment => "slot_escapes_its_root",
1502            Self::SlotNotADirectory => "slot_is_not_a_directory",
1503            Self::Enumeration => "slot_could_not_be_enumerated",
1504            Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1505            Self::Deletion => "slot_entry_could_not_be_removed",
1506            Self::Residue => "slot_still_holds_runner_state",
1507        }
1508    }
1509
1510    /// What the operator has to do, in one sentence and with no path in it.
1511    const fn remediation(self) -> &'static str {
1512        match self {
1513            Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1514                "the attempt keeps its slot lease and nothing was removed; correct the \
1515                 repository's persistent workspace path, or remove the slot directory by hand \
1516                 once you have confirmed what is in it"
1517            }
1518            Self::SlotNotADirectory | Self::WorkNotADirectory => {
1519                "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1520                 slot or its `_work` with a link, so inspect it before deleting anything and \
1521                 treat the retained workspace as untrusted"
1522            }
1523            Self::Enumeration | Self::Deletion | Self::Residue => {
1524                "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1525                 release whatever is holding the files open, or remove the slot's contents by \
1526                 hand leaving only `_work`"
1527            }
1528        }
1529    }
1530}
1531
1532/// A refusal that leaves one persistent slot quarantined.
1533///
1534/// `detail` is redacted by construction: it may hold paths this product
1535/// configured, `std::io::ErrorKind` values, counts, and names drawn from
1536/// [`SENSITIVE_SLOT_ENTRIES`] — and nothing that came out of a directory
1537/// listing.
1538#[derive(Debug, Clone, PartialEq, Eq)]
1539struct SlotQuarantine {
1540    refusal: SlotRefusal,
1541    detail: String,
1542}
1543
1544impl SlotQuarantine {
1545    fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1546        Self {
1547            refusal,
1548            detail: detail.into(),
1549        }
1550    }
1551}
1552
1553impl fmt::Display for SlotQuarantine {
1554    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1555        write!(f, "{}; {}", self.detail, self.refusal.remediation())
1556    }
1557}
1558
1559/// Steps 1 to 3: the journalled root, the journalled slot, and containment.
1560///
1561/// Everything comes from the two immutable allocation facts — the exact runtime
1562/// path and the slot number. Not from the filesystem: scanning a root to decide
1563/// which directories are "mine" is what invariant 6 forbids, and it is also
1564/// impossible for an attempt whose policy has since been deleted. `configured`
1565/// is therefore a *cross-check* and not a source. A policy that survives has to
1566/// agree; a policy that does not survive removes a check rather than the
1567/// ability to clean the directory the journal already names.
1568///
1569/// The name test comes before the parent is used, so `<root>/s1` for a journal
1570/// row that says `s2` — and anything at all that is not one `sN` component —
1571/// is refused before a root is derived from it.
1572fn verify_journalled_slot(
1573    runtime: &Path,
1574    slot: NonZeroU16,
1575    configured: Option<&LocalAbsolutePath>,
1576) -> Result<(), SlotQuarantine> {
1577    let mislaid = || {
1578        SlotQuarantine::new(
1579            SlotRefusal::NotTheJournalledSlot,
1580            format!(
1581                "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1582                runtime.display()
1583            ),
1584        )
1585    };
1586    let local = |path: &Path| {
1587        path.to_str()
1588            .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1589            .ok_or_else(mislaid)
1590    };
1591
1592    let runtime_path = local(runtime)?;
1593    let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1594    // Containment's lexical half, by construction: `derive_child` accepts one
1595    // component, so the equality below can only hold when the journalled path
1596    // really is this root's `sN` and nothing else.
1597    // The directory name comes from the domain that allocation named it with,
1598    // never from a second `s{n}` spelled out here: a convention with two
1599    // spellings would let cleanup refuse every slot the allocator created.
1600    let name = AttemptWorkspace::persistent_slot(slot)
1601        .slot_directory_name()
1602        .expect("a persistent workspace names its slot directory");
1603    let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1604    if derived != runtime_path {
1605        return Err(mislaid());
1606    }
1607    if let Some(configured) = configured
1608        && configured != &root
1609    {
1610        return Err(SlotQuarantine::new(
1611            SlotRefusal::PolicyRootDisagrees,
1612            format!(
1613                "the journalled slot {} is not under the repository's configured persistent root \
1614                 {}",
1615                runtime.display(),
1616                configured.as_str()
1617            ),
1618        ));
1619    }
1620    // And containment's canonical half, which is what a junction planted inside
1621    // the root between allocation and cleanup has to get past.
1622    runner_root::verify_containment(&root, &derived).map_err(|source| {
1623        SlotQuarantine::new(
1624            SlotRefusal::Containment,
1625            format!("the journalled slot is not inside the root it was allocated from: {source}"),
1626        )
1627    })
1628}
1629
1630/// Whether the slot is there to be scrubbed at all, before its entries are.
1631///
1632/// `Ok(false)` — the directory is gone — is not a refusal. There is nothing to
1633/// remove and nothing to prove absent, which is the same tolerance the
1634/// disposable arm has always had for a runtime that vanished under it.
1635///
1636/// A slot that is a file, a link, a junction or any other reparse point *is* a
1637/// refusal, and it is checked here rather than inside the enumeration so that
1638/// the reason an operator reads names the shape rather than reporting that a
1639/// directory could not be listed.
1640fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1641    match fs::symlink_metadata(slot) {
1642        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1643        Err(source) => Err(SlotQuarantine::new(
1644            SlotRefusal::SlotNotADirectory,
1645            format!(
1646                "the slot {} could not be inspected: {:?}",
1647                slot.display(),
1648                source.kind()
1649            ),
1650        )),
1651        Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1652            SlotRefusal::SlotNotADirectory,
1653            format!(
1654                "the slot {} is a link or a file rather than a real directory",
1655                slot.display()
1656            ),
1657        )),
1658        Ok(_) => Ok(true),
1659    }
1660}
1661
1662/// Steps 4 to 6: list the slot's direct entries, keep one real `_work`, remove
1663/// every other one.
1664///
1665/// Every call is a literal filesystem API against a path built from the slot
1666/// root and one entry name — no glob, no shell string, no repository-controlled
1667/// fragment. Nothing is followed: `symlink_metadata` says what each entry *is*,
1668/// and a link-shaped entry is unlinked rather than descended into, so a junction
1669/// planted where `bin` used to be cannot take the deletion outside the slot.
1670///
1671/// A `_work` that is not a real directory is the one entry this refuses to act
1672/// on at all. Removing it could destroy an operator's data if the link were
1673/// theirs; keeping it would hand the next attempt a workspace pointing
1674/// anywhere. `04-security-recovery.md` requires that case to quarantine the
1675/// slot, so the whole scrub stops there with nothing removed after it.
1676fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1677    let unreadable = |source: std::io::Error| {
1678        SlotQuarantine::new(
1679            SlotRefusal::Enumeration,
1680            format!(
1681                "the entries of {} could not be listed: {:?}",
1682                slot.display(),
1683                source.kind()
1684            ),
1685        )
1686    };
1687    for entry in fs::read_dir(slot).map_err(unreadable)? {
1688        let name = entry.map_err(unreadable)?.file_name();
1689        let path = slot.join(&name);
1690        // Nothing is assumed from an entry that vanished: `verify_slot_scrubbed`
1691        // asks the filesystem again afterwards and refuses if it is still there.
1692        let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1693            continue;
1694        };
1695        if is_work_folder(&name) {
1696            if is_retainable_work_folder(&name, &metadata) {
1697                continue;
1698            }
1699            return Err(SlotQuarantine::new(
1700                SlotRefusal::WorkNotADirectory,
1701                format!(
1702                    "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1703                     real directory",
1704                    slot.display()
1705                ),
1706            ));
1707        }
1708        remove_slot_entry(&path, &metadata).map_err(|source| {
1709            SlotQuarantine::new(
1710                SlotRefusal::Deletion,
1711                format!(
1712                    "an entry of {} could not be removed: {:?}",
1713                    slot.display(),
1714                    source.kind()
1715                ),
1716            )
1717        })?;
1718    }
1719    Ok(())
1720}
1721
1722/// What a listed entry *is*, or `None` when it is no longer there.
1723///
1724/// An entry named by a listing and gone by the time it is stat-ed is absent,
1725/// which is a fact both passes over a slot want rather than an enumeration that
1726/// failed. Nothing is followed: `symlink_metadata` reports a link as a link.
1727fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1728    match fs::symlink_metadata(path) {
1729        Ok(metadata) => Ok(Some(metadata)),
1730        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1731        Err(source) => Err(source),
1732    }
1733}
1734
1735/// Remove one slot entry without following it.
1736///
1737/// An entry that is already gone is removed: the post-condition this serves is
1738/// "not there", and racing with whatever removed it first is not a refusal.
1739fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1740    let removed = if is_link_like(metadata) {
1741        // A file symlink unlinks with `remove_file`; a directory symlink or a
1742        // Windows junction needs `remove_dir`. Neither follows the link.
1743        fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1744    } else if metadata.is_dir() {
1745        remove_runtime_tree(path)
1746    } else {
1747        fs::remove_file(path)
1748    };
1749    match removed {
1750        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1751        other => other,
1752    }
1753}
1754
1755/// Step 7: prove that nothing but the retained job workspace is left.
1756///
1757/// Two passes, deliberately independent, because the interesting failure is an
1758/// enumeration that under-reports. The first asks the directory what remains
1759/// and counts everything that is not a real `_work`. The second ignores the
1760/// listing entirely and stats the names an attempt is known to write — the
1761/// runner's binaries, the registration identity it stores beside them, the
1762/// process-identity sidecars and this agent's lifecycle marks — so a scrub that
1763/// silently skipped one is caught by a question that never consulted the
1764/// listing that skipped it.
1765///
1766/// Only the second pass names anything, and the encoded JIT handoff is reported
1767/// by its published prefix rather than by the UUID that follows it. A slot root
1768/// is workflow-writable, so a job that named a file after a secret would publish
1769/// it through any message that echoed the listing; the first pass therefore
1770/// reports a count.
1771fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1772    let unreadable = |source: std::io::Error| {
1773        SlotQuarantine::new(
1774            SlotRefusal::Enumeration,
1775            format!(
1776                "the entries of {} could not be listed to verify the scrub: {:?}",
1777                slot.display(),
1778                source.kind()
1779            ),
1780        )
1781    };
1782    let mut residue = 0_usize;
1783    let mut named: Vec<String> = Vec::new();
1784    for entry in fs::read_dir(slot).map_err(unreadable)? {
1785        let name = entry.map_err(unreadable)?.file_name();
1786        // Absent is what this pass is here to establish. The named half below
1787        // stats every sensitive entry again, so an entry that only *looks*
1788        // absent to this listing is still caught.
1789        let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1790            continue;
1791        };
1792        if is_retainable_work_folder(&name, &metadata) {
1793            continue;
1794        }
1795        residue = residue.saturating_add(1);
1796        if name
1797            .to_string_lossy()
1798            .starts_with(RestrictiveHandoff::NAME_PREFIX)
1799        {
1800            named.push("an encoded JIT handoff".to_owned());
1801        }
1802    }
1803    named.extend(
1804        SENSITIVE_SLOT_ENTRIES
1805            .iter()
1806            .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1807            .map(|entry| format!("`{entry}`")),
1808    );
1809    if residue == 0 && named.is_empty() {
1810        return Ok(());
1811    }
1812    named.sort_unstable();
1813    named.dedup();
1814    Err(SlotQuarantine::new(
1815        SlotRefusal::Residue,
1816        residue_detail(slot, residue, &named),
1817    ))
1818}
1819
1820/// Word a [`SlotRefusal::Residue`] refusal from the two facts that produced it.
1821///
1822/// Separate from [`verify_slot_scrubbed`] because the disagreement it has to
1823/// report — a listing that counted nothing and a filesystem that answered
1824/// otherwise — is a race no test can stage, and a message that contradicts
1825/// itself is exactly what an operator reads at three in the morning.
1826///
1827/// `named` is the sanitized half: entries this crate published the names of.
1828/// A name a workflow chose is only ever counted, never echoed.
1829fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1830    if residue == 0 {
1831        // The whole reason the second pass ignores the listing: the listing
1832        // reported a clean slot and the filesystem disagrees. Saying "0
1833        // entries survived" here would report the under-count as the fact.
1834        format!(
1835            "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1836                 cleanup",
1837            slot.display(),
1838            named.join(", ")
1839        )
1840    } else {
1841        format!(
1842            "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1843            if residue == 1 { "y" } else { "ies" },
1844            slot.display(),
1845            if named.is_empty() {
1846                String::new()
1847            } else {
1848                format!(", including {}", named.join(", "))
1849            }
1850        )
1851    }
1852}
1853
1854fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1855    match outcome {
1856        AttemptOutcome::Failed {
1857            reason: FailureReason::JitExpired,
1858        } => Some("jit_expired_replacement"),
1859        AttemptOutcome::Failed {
1860            reason: FailureReason::ProcessExitedUnexpectedly,
1861        } => Some("exit_before_acceptance_replacement"),
1862        _ => None,
1863    }
1864}
1865
1866/// Lay the verified runner package out *around* whatever the slot retains.
1867///
1868/// `02-target-architecture.md`: "The verified runner package is copied into the
1869/// slot for the attempt", beside a `_work` that survives every attempt. Two
1870/// properties make that safe, and both are structural rather than documented:
1871///
1872/// * the walk is of the **source** tree, so a retained `_work` in the
1873///   destination is never opened, never descended into, and cannot be followed
1874///   wherever it might point;
1875/// * a top-level source entry named `_work` is refused rather than copied, so a
1876///   package that ever grew one could not merge itself into, or replace, the
1877///   job workspace of the attempt before it. The refusal is top-level only,
1878///   because the retained directory is a direct child of the slot; a `_work`
1879///   nested inside the package's own tree is an ordinary name.
1880///
1881/// On Unix the copy is a copy-on-write clone where the filesystem offers one,
1882/// taken from a [`package_seed`] on the destination's own volume. A plain
1883/// byte copy of the ~9,000-file package across volumes took four minutes on a
1884/// host whose runner root shared a busy disk with a build, and a queued job
1885/// waits for every second of it.
1886/// The tree to copy `source` from, making a seed where one helps; a change to
1887/// a tree, so only under the exclusive hold of [`SharedPackages::trees`].
1888///
1889/// A seed that cannot be made is a slower launch, not a failed one. The seed
1890/// sits under the runner root, beside job runtimes, so the refusal is re-checked
1891/// on it; a seed that fails it is bypassed.
1892fn copy_origin(source: &Path, destination: &Path, seeds: &TrustedTrees) -> PathBuf {
1893    #[cfg(unix)]
1894    {
1895        package_seed(source, destination, seeds)
1896            .ok()
1897            .filter(|seed| refuse_work_folder(seed).is_ok())
1898            .unwrap_or_else(|| source.to_path_buf())
1899    }
1900    #[cfg(not(unix))]
1901    {
1902        let _ = (destination, seeds);
1903        source.to_path_buf()
1904    }
1905}
1906
1907/// The tree to copy `source` from if it needs no change: `None` is a seed to
1908/// rebuild first.
1909fn copy_origin_ready(source: &Path, destination: &Path, seeds: &TrustedTrees) -> Option<PathBuf> {
1910    #[cfg(unix)]
1911    {
1912        match seed_location(source, destination) {
1913            Ok(None) => Some(source.to_path_buf()),
1914            Ok(Some(seeds_root)) => held_seed(source, &seeds_root, seeds)
1915                .ok()
1916                .flatten()
1917                .filter(|seed| refuse_work_folder(seed).is_ok()),
1918            Err(_) => None,
1919        }
1920    }
1921    #[cfg(not(unix))]
1922    {
1923        let _ = (destination, seeds);
1924        Some(source.to_path_buf())
1925    }
1926}
1927
1928/// Copy the package at `source` into `destination`, from `origin`: itself or
1929/// its seed. Under the shared hold of [`SharedPackages::trees`].
1930fn copy_package_from(
1931    source: &Path,
1932    origin: &Path,
1933    destination: &Path,
1934    seeds: &TrustedTrees,
1935) -> std::io::Result<()> {
1936    refuse_work_folder(source)?;
1937
1938    #[cfg(unix)]
1939    {
1940        clone_tree(origin, destination)?;
1941        // A seed a job edited during the clone no longer holds; the caller
1942        // re-checks the cache entry itself.
1943        if origin != source && !seeds.holds(origin) {
1944            return Err(std::io::Error::other(
1945                "the package seed changed while cloned",
1946            ));
1947        }
1948        Ok(())
1949    }
1950    #[cfg(not(unix))]
1951    {
1952        let _ = (origin, seeds);
1953        copy_package_entries(source, destination, true)
1954    }
1955}
1956
1957fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1958    if source.join(DEFAULT_WORK_FOLDER).exists() {
1959        return Err(std::io::Error::new(
1960            std::io::ErrorKind::InvalidData,
1961            "a cached runner package contains a _work folder, which means it was used \
1962             to run a job before it was archived; the cache must only contain clean \
1963             extracts to prevent data leakage",
1964        ));
1965    }
1966    Ok(())
1967}
1968
1969/// Where runtime copies of the package are cloned from, beside the runtimes.
1970#[cfg(unix)]
1971const PACKAGE_SEED_DIR: &str = ".runner-package";
1972
1973/// A copy of the cache entry on the destination's volume, so that every
1974/// runtime after the first is a same-volume clone rather than a cross-volume
1975/// byte copy. Returns `source` itself when it already shares the volume.
1976///
1977/// The seed lives at `<runner root>/.runner-package/<version>` and is replaced
1978/// whenever its manifest stops matching the cache entry's, which covers both a
1979/// new version and a reinstalled one. Superseded versions and interrupted
1980/// stagings are removed on that same pass. A seed is only made under the
1981/// exclusive hold of [`SharedPackages::trees`], so no launch is cloning from it
1982/// while it is replaced.
1983///
1984/// The seed sits one `..` from every job's runtime and is owned by the account
1985/// jobs run as, so it is only reused while it is provably the tree this
1986/// process built: see [`TrustedTrees`].
1987///
1988/// Only on macOS, where the runner root is all but certainly APFS and a clone
1989/// is free. On a Linux filesystem without reflinks a seed would only move every
1990/// copy's reads onto the runner root's own, possibly busy, disk.
1991#[cfg(unix)]
1992fn package_seed(
1993    source: &Path,
1994    destination: &Path,
1995    seeds: &TrustedTrees,
1996) -> std::io::Result<PathBuf> {
1997    match seed_location(source, destination)? {
1998        Some(root) => refresh_package_seed(source, &root, seeds),
1999        None => Ok(source.to_path_buf()),
2000    }
2001}
2002
2003/// Where `destination`'s seeds live, if copies to it use one.
2004#[cfg(unix)]
2005fn seed_location(source: &Path, destination: &Path) -> std::io::Result<Option<PathBuf>> {
2006    use std::os::unix::fs::MetadataExt as _;
2007
2008    if !cfg!(target_os = "macos") {
2009        return Ok(None);
2010    }
2011    let root = destination
2012        .parent()
2013        .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
2014    if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
2015        return Ok(None);
2016    }
2017    Ok(Some(root.join(PACKAGE_SEED_DIR)))
2018}
2019
2020/// The package trees this process built or installed: what each must hold, and
2021/// what each looked like right after. They are the cache entry runtimes are copied
2022/// from and the seeds they are cloned from.
2023///
2024/// Both are owned by the account jobs run as, so a job can edit any file in them,
2025/// and nothing on disk can say what they should hold: the published digest covers
2026/// the archive, not the extracted tree, and a record kept on disk could be
2027/// rewritten by the same account. So a tree without a record here, as every tree
2028/// has after a restart, is rebuilt before it is used.
2029///
2030/// Two checks, for two costs. [`PackageContents`] reads every file against the
2031/// verified archive, on every runtime a launch is about to run: that is the one
2032/// that decides. On Unix the tree's [`tree_fingerprint`] also spots a changed
2033/// tree cheaply before any of it is copied, since every edit, replacement or
2034/// rename moves the change time (`ctime`) of the entry or of its directory, and
2035/// only the superuser can set a change time.
2036#[derive(Debug, Default)]
2037pub(crate) struct TrustedTrees {
2038    trees: Mutex<HashMap<PathBuf, TrustedTree>>,
2039}
2040
2041#[derive(Debug, Clone)]
2042struct TrustedTree {
2043    #[cfg(unix)]
2044    print: [u8; 32],
2045    contents: Option<Arc<PackageContents>>,
2046}
2047
2048impl TrustedTrees {
2049    fn trees(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, TrustedTree>> {
2050        self.trees
2051            .lock()
2052            .unwrap_or_else(std::sync::PoisonError::into_inner)
2053    }
2054
2055    /// Whether `root` is a tree recorded here and, where that can be told, still
2056    /// looks as it did.
2057    fn holds(&self, root: &Path) -> bool {
2058        let Some(tree) = self.trees().get(root).cloned() else {
2059            return false;
2060        };
2061        #[cfg(unix)]
2062        {
2063            tree_fingerprint(root).is_ok_and(|current| current == tree.print)
2064        }
2065        #[cfg(not(unix))]
2066        {
2067            let _ = tree;
2068            true
2069        }
2070    }
2071
2072    /// What `root` must hold.
2073    fn contents_of(&self, root: &Path) -> Option<Arc<PackageContents>> {
2074        self.trees()
2075            .get(root)
2076            .and_then(|tree| tree.contents.clone())
2077    }
2078
2079    /// Trust `root` as it is now; called right after this process made it.
2080    fn record(&self, root: &Path, contents: Option<Arc<PackageContents>>) -> std::io::Result<()> {
2081        let tree = TrustedTree {
2082            #[cfg(unix)]
2083            print: tree_fingerprint(root)?,
2084            contents,
2085        };
2086        self.trees().insert(root.to_path_buf(), tree);
2087        Ok(())
2088    }
2089
2090    /// Stops trusting `root` if `contents` is still what is recorded for it, and
2091    /// answers whether it did.
2092    fn forget_if_holding(&self, root: &Path, contents: &Arc<PackageContents>) -> bool {
2093        let mut trees = self.trees();
2094        let holding = trees
2095            .get(root)
2096            .and_then(|tree| tree.contents.as_ref())
2097            .is_some_and(|current| Arc::ptr_eq(current, contents));
2098        if holding {
2099            trees.remove(root);
2100        }
2101        holding
2102    }
2103
2104    /// Stop trusting `root`: its next use rebuilds it.
2105    fn forget(&self, root: &Path) {
2106        self.trees().remove(root);
2107    }
2108}
2109
2110/// A digest of every entry under `root`: its relative path, inode, size, mode,
2111/// owner and change time.
2112#[cfg(unix)]
2113fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
2114    use sha2::{Digest as _, Sha256};
2115    use std::os::unix::fs::MetadataExt as _;
2116
2117    fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
2118        let metadata = fs::symlink_metadata(path)?;
2119        let relative = path.strip_prefix(root).unwrap_or(path);
2120        hasher.update(relative.as_os_str().as_encoded_bytes());
2121        hasher.update([0]);
2122        for field in [
2123            metadata.ino(),
2124            metadata.size(),
2125            u64::from(metadata.mode()),
2126            u64::from(metadata.uid()),
2127            u64::from(metadata.gid()),
2128        ] {
2129            hasher.update(field.to_le_bytes());
2130        }
2131        hasher.update(metadata.ctime().to_le_bytes());
2132        hasher.update(metadata.ctime_nsec().to_le_bytes());
2133        if metadata.is_dir() {
2134            let mut children = fs::read_dir(path)?
2135                .map(|entry| entry.map(|entry| entry.path()))
2136                .collect::<std::io::Result<Vec<_>>>()?;
2137            children.sort();
2138            for child in children {
2139                walk(root, &child, hasher)?;
2140            }
2141        }
2142        Ok(())
2143    }
2144
2145    let mut hasher = Sha256::new();
2146    walk(root, root, &mut hasher)?;
2147    Ok(hasher.finalize().into())
2148}
2149
2150#[cfg(unix)]
2151fn seed_path(source: &Path, seeds: &Path) -> std::io::Result<PathBuf> {
2152    let version = source
2153        .file_name()
2154        .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
2155    Ok(seeds.join(version))
2156}
2157
2158/// The seed of `source` under `seeds`, if its manifest matches the cache
2159/// entry's and its fingerprint the one this process recorded.
2160#[cfg(unix)]
2161fn held_seed(
2162    source: &Path,
2163    seeds: &Path,
2164    trusted: &TrustedTrees,
2165) -> std::io::Result<Option<PathBuf>> {
2166    use crate::package::MANIFEST_FILE;
2167
2168    let seed = seed_path(source, seeds)?;
2169    let manifest = fs::read(source.join(MANIFEST_FILE))?;
2170    let held = fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest)
2171        && trusted.holds(&seed);
2172    Ok(held.then_some(seed))
2173}
2174
2175/// The seed of `source` under `seeds`, rebuilt unless [`held_seed`] finds it.
2176#[cfg(unix)]
2177fn refresh_package_seed(
2178    source: &Path,
2179    seeds: &Path,
2180    trusted: &TrustedTrees,
2181) -> std::io::Result<PathBuf> {
2182    if let Some(seed) = held_seed(source, seeds, trusted)? {
2183        return Ok(seed);
2184    }
2185    let seed = seed_path(source, seeds)?;
2186
2187    // Superseded versions, a stale seed and interrupted stagings all go.
2188    match remove_runtime_tree(seeds) {
2189        Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
2190        _ => {}
2191    }
2192    fs::create_dir_all(seeds)?;
2193    let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
2194    fs::create_dir(&staging)?;
2195    // A half-made staging would hold the disk space the fallback copy needs.
2196    if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
2197        let _ = remove_runtime_tree(&staging);
2198        return Err(error);
2199    }
2200    // After the rename, which itself moves the seed directory's change time.
2201    trusted.record(&seed, None)?;
2202    Ok(seed)
2203}
2204
2205/// `cp -a`, asking for a copy-on-write clone where the platform's `cp` can.
2206///
2207/// macOS `cp -c` and GNU `--reflink=auto` both fall back to a byte copy across
2208/// volumes; a `cp` that does not know the flag at all gets a plain `cp -a`.
2209#[cfg(unix)]
2210fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
2211    #[cfg(target_os = "macos")]
2212    const CLONE: &[&str] = &["-c"];
2213    #[cfg(target_os = "linux")]
2214    const CLONE: &[&str] = &["--reflink=auto"];
2215    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2216    const CLONE: &[&str] = &[];
2217
2218    let from = format!("{}/.", source.display());
2219    let cp = |extra: &[&str]| {
2220        std::process::Command::new("cp")
2221            .arg("-a")
2222            .args(extra)
2223            .arg(&from)
2224            .arg(destination)
2225            .status()
2226    };
2227    if cp(CLONE)?.success() || cp(&[])?.success() {
2228        Ok(())
2229    } else {
2230        Err(std::io::Error::other("cp failed"))
2231    }
2232}
2233
2234#[cfg(not(unix))]
2235fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
2236    fs::create_dir_all(destination)?;
2237    for entry in fs::read_dir(source)? {
2238        let entry = entry?;
2239        if top_level && is_work_folder(&entry.file_name()) {
2240            return Err(std::io::Error::new(
2241                std::io::ErrorKind::InvalidData,
2242                format!(
2243                    "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
2244                     it would overwrite the job workspace a persistent slot retains"
2245                ),
2246            ));
2247        }
2248        let target = destination.join(entry.file_name());
2249        if entry.file_type()?.is_dir() {
2250            copy_package_entries(&entry.path(), &target, false)?;
2251        } else {
2252            fs::copy(entry.path(), target)?;
2253        }
2254    }
2255    Ok(())
2256}
2257
2258/// Process operations are attempt-addressed so a recovered process and a child
2259/// started in this invocation are supervised through one port.
2260#[derive(Debug, Clone, PartialEq, Eq)]
2261pub struct ProcessStartFailure {
2262    pub reason: FailureReason,
2263    /// False once a child existed: the one-shot JIT value may have been
2264    /// consumed, so retrying it could start a duplicate.
2265    pub retryable: bool,
2266    /// Set only when cleanup could not prove the spawned process dead.  The
2267    /// caller must journal `starting` and retain capacity/supervision.
2268    pub live_pid: Option<u32>,
2269}
2270
2271impl ProcessStartFailure {
2272    pub(crate) fn before_spawn(reason: FailureReason) -> Self {
2273        Self {
2274            reason,
2275            retryable: true,
2276            live_pid: None,
2277        }
2278    }
2279
2280    fn after_spawn_stopped() -> Self {
2281        Self {
2282            reason: FailureReason::ProcessStartFailed,
2283            retryable: false,
2284            live_pid: None,
2285        }
2286    }
2287
2288    fn after_spawn_live(pid: u32) -> Self {
2289        Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
2290    }
2291
2292    fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
2293        Self {
2294            reason,
2295            retryable: false,
2296            live_pid: Some(pid),
2297        }
2298    }
2299}
2300
2301/// A credential-free capability report. An isolated policy never treats a
2302/// native-ready result as permission to launch.
2303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2304pub enum ProviderCapability {
2305    Ready,
2306    Unsupported,
2307    NotInstalled,
2308    PermissionDenied,
2309    ImageUnavailableOrIncompatible,
2310    DiskQuotaUnavailable,
2311    Degraded,
2312}
2313
2314impl ProviderCapability {
2315    #[must_use]
2316    pub const fn display_name(self) -> &'static str {
2317        match self {
2318            Self::Ready => "ready",
2319            Self::Unsupported => "unsupported",
2320            Self::NotInstalled => "not installed",
2321            Self::PermissionDenied => "permission denied",
2322            Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
2323            Self::DiskQuotaUnavailable => "disk quota unavailable",
2324            Self::Degraded => "degraded",
2325        }
2326    }
2327
2328    pub(crate) fn refusal(self) -> FailureReason {
2329        FailureReason::IsolationProvider(match self {
2330            Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
2331            Self::Unsupported => IsolationProviderFailure::Unsupported,
2332            Self::NotInstalled => IsolationProviderFailure::NotInstalled,
2333            Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
2334            Self::ImageUnavailableOrIncompatible => {
2335                IsolationProviderFailure::ImageUnavailableOrIncompatible
2336            }
2337            Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
2338        })
2339    }
2340}
2341
2342/// Only the closed isolation category may cross from an adapter into the
2343/// attempt journal or operator error. Arbitrary adapter text could echo JIT.
2344fn safe_isolation_reason(
2345    reason: FailureReason,
2346    fallback: IsolationProviderFailure,
2347) -> FailureReason {
2348    match reason {
2349        FailureReason::IsolationProvider(_) => reason,
2350        _ => FailureReason::IsolationProvider(fallback),
2351    }
2352}
2353
2354#[derive(Debug, Clone, PartialEq, Eq)]
2355pub struct PreparedEnvironment {
2356    attempt: AttemptId,
2357    identity: Option<EnvironmentIdentity>,
2358    /// The target whose dependency-cache namespace a native runner uses:
2359    /// `prepare` sees the policy and `start` does not.
2360    cache_target: Option<runner_manager_domain::model::ScaleTarget>,
2361}
2362
2363impl PreparedEnvironment {
2364    /// An adapter must return the identity independently recorded on its resource.
2365    #[must_use]
2366    pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
2367        Self {
2368            attempt,
2369            identity: Some(identity),
2370            cache_target: None,
2371        }
2372    }
2373
2374    #[must_use]
2375    pub fn identity(&self) -> Option<&EnvironmentIdentity> {
2376        self.identity.as_ref()
2377    }
2378}
2379
2380#[derive(Debug, Clone, PartialEq, Eq)]
2381pub struct ResolvedEnvironment {
2382    pub provider_kind: Backend,
2383    pub image: ImageReference,
2384}
2385
2386/// Closed, non-secret provider diagnostics; adapters cannot attach arbitrary output.
2387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2388pub enum ProviderDiagnostic {
2389    CapabilityUnavailable,
2390    ImageRejected,
2391    PrepareFailed,
2392    StartFailed,
2393    OwnershipMismatch,
2394    CleanupDeferred,
2395}
2396
2397#[derive(Debug, Clone, PartialEq, Eq)]
2398pub enum EnvironmentIdentity {
2399    NativeProcess(u32),
2400    Isolated {
2401        host: HostId,
2402        attempt: AttemptId,
2403        provider_kind: Backend,
2404        environment_id: String,
2405        resolved_image: ImageReference,
2406        generation: String,
2407    },
2408}
2409
2410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2411pub enum EnvironmentState {
2412    Starting,
2413    Running,
2414    Exited,
2415    Missing,
2416}
2417
2418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2419pub enum ProviderStop {
2420    Stopped,
2421    StillRunning,
2422}
2423
2424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2425pub enum ProviderDestroy {
2426    Destroyed,
2427    Deferred(&'static str),
2428}
2429
2430fn identity_matches_intent(
2431    host: HostId,
2432    attempt: &RunnerAttempt,
2433    identity: &EnvironmentIdentity,
2434) -> bool {
2435    identity_same_allocation(host, attempt, identity)
2436        && match (attempt.execution(), identity) {
2437            (
2438                AttemptExecution::Isolated { environment_id, .. },
2439                EnvironmentIdentity::Isolated {
2440                    environment_id: id, ..
2441                },
2442            ) => environment_id
2443                .as_ref()
2444                .is_none_or(|journal_id| journal_id == id),
2445            _ => false,
2446        }
2447}
2448
2449fn identity_same_allocation(
2450    host: HostId,
2451    attempt: &RunnerAttempt,
2452    identity: &EnvironmentIdentity,
2453) -> bool {
2454    match (attempt.execution(), identity) {
2455        (
2456            AttemptExecution::Isolated {
2457                provider_kind,
2458                environment_id: _,
2459                resolved_image,
2460                generation,
2461            },
2462            EnvironmentIdentity::Isolated {
2463                host: owner,
2464                attempt: owner_attempt,
2465                provider_kind: kind,
2466                environment_id: _,
2467                resolved_image: image,
2468                generation: resource_generation,
2469            },
2470        ) => {
2471            *owner == host
2472                && *owner_attempt == attempt.id
2473                && provider_kind == kind
2474                && resolved_image == image
2475                && generation == resource_generation
2476        }
2477        _ => false,
2478    }
2479}
2480
2481/// The JIT value can be passed into a provider start exactly once. It must never
2482/// become a provider identity, label, argument, persisted configuration or log field.
2483pub struct OneTimeJitHandoff<'a> {
2484    config: &'a EncodedJitConfig,
2485}
2486
2487impl fmt::Debug for OneTimeJitHandoff<'_> {
2488    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2489        f.write_str("OneTimeJitHandoff([redacted])")
2490    }
2491}
2492
2493impl<'a> OneTimeJitHandoff<'a> {
2494    pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
2495        Self { config }
2496    }
2497
2498    /// Consuming the handoff prevents the same token being delivered twice by one call.
2499    pub fn consume(self) -> &'a EncodedJitConfig {
2500        self.config
2501    }
2502}
2503
2504/// Provider lifecycle and native process supervision. The default lifecycle
2505/// methods adapt the existing native spawn/recovery implementation, and refuse
2506/// every isolated allocation. Future platform adapters must implement their
2507/// own prepare/start/inspect/stop/destroy paths rather than fall back here.
2508pub trait ExecutionProvider: fmt::Debug + Send + Sync {
2509    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2510        if policy.execution_policy().is_native() {
2511            ProviderCapability::Ready
2512        } else {
2513            ProviderCapability::Unsupported
2514        }
2515    }
2516
2517    /// Resolve Auto and image identity before allocation or GitHub JIT. Native has no image.
2518    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2519        if policy.execution_policy().is_native() {
2520            Ok(None)
2521        } else {
2522            Err(FailureReason::Other(
2523                "execution provider unavailable".into(),
2524            ))
2525        }
2526    }
2527
2528    fn prepare(
2529        &self,
2530        attempt: &RunnerAttempt,
2531        policy: &ScalePolicy,
2532    ) -> Result<PreparedEnvironment, FailureReason> {
2533        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2534            return Err(FailureReason::Other(
2535                "execution provider unavailable".into(),
2536            ));
2537        }
2538        Ok(PreparedEnvironment {
2539            attempt: attempt.id,
2540            identity: None,
2541            cache_target: None,
2542        })
2543    }
2544
2545    fn start(
2546        &self,
2547        prepared: PreparedEnvironment,
2548        attempt: &RunnerAttempt,
2549        handoff: OneTimeJitHandoff<'_>,
2550    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2551        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2552            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2553                "execution provider identity mismatch".into(),
2554            )));
2555        }
2556        self.spawn(attempt, handoff.consume())
2557            .map(EnvironmentIdentity::NativeProcess)
2558    }
2559
2560    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2561        if !attempt.execution().is_native() {
2562            return Err(FailureReason::Other(
2563                "execution provider unavailable".into(),
2564            ));
2565        }
2566        self.is_alive(attempt).map(|alive| {
2567            if alive {
2568                EnvironmentState::Running
2569            } else {
2570                EnvironmentState::Missing
2571            }
2572        })
2573    }
2574
2575    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2576        if !attempt.execution().is_native() {
2577            return Err(FailureReason::Other(
2578                "execution provider unavailable".into(),
2579            ));
2580        }
2581        self.terminate(attempt).map(|()| ProviderStop::Stopped)
2582    }
2583
2584    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2585        if !attempt.execution().is_native() {
2586            return Err(FailureReason::Other(
2587                "execution provider unavailable".into(),
2588            ));
2589        }
2590        // Native termination is already settled by the existing recovery
2591        // decision before workspace cleanup. There is no separate sandbox
2592        // resource to destroy, and repeating an observation here changes the
2593        // established native cleanup behaviour.
2594        Ok(ProviderDestroy::Destroyed)
2595    }
2596
2597    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2598        self.inspect(attempt)
2599    }
2600
2601    fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2602        Vec::new() // Native processes have no independent provider resources.
2603    }
2604
2605    fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2606        Vec::new() // No job or JIT output enters provider metadata.
2607    }
2608
2609    /// Independent resource metadata must match the journal before destructive calls.
2610    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2611        Ok(attempt.execution().is_native())
2612    }
2613
2614    fn spawn(
2615        &self,
2616        attempt: &RunnerAttempt,
2617        config: &EncodedJitConfig,
2618    ) -> Result<u32, ProcessStartFailure>;
2619    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2620    /// Durable identity observed for a process that spawned before the
2621    /// `starting` journal write survived.
2622    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2623    /// True only for a child this invocation owned and reaped with a successful
2624    /// exit status.  A recovered process that is merely gone answers false.
2625    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2626    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2627    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2628    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2629}
2630
2631/// Compatibility name for existing native process tests and callers.
2632pub use ExecutionProvider as ProcessSupervisor;
2633
2634/// Native process supervision.  The start token is stored beside the runtime;
2635/// recovery never trusts a recycled PID merely because SQLite contains it.
2636#[derive(Debug, Default)]
2637pub struct NativeProcesses {
2638    children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2639    successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2640    /// The host's `runner.env`, read at every launch so that `host env set`
2641    /// reaches the next runner without a daemon restart. `None` applies none.
2642    runner_env_file: Option<PathBuf>,
2643    /// Persistent dependency caches. `None` gives runners none.
2644    dependency_caches: Option<DependencyCaches>,
2645    docker_cleanup: Option<crate::docker_cleanup::DockerCleanup>,
2646    /// Who runners run as. See [`runner_manager_platform::runner_account`].
2647    runner: RunnerIdentity,
2648    #[cfg(test)]
2649    post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2650    #[cfg(test)]
2651    post_spawn_reaps: std::sync::atomic::AtomicUsize,
2652    #[cfg(test)]
2653    post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2654    #[cfg(test)]
2655    use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2656}
2657
2658/// Production provider set. Native and isolated allocations are routed by the
2659/// immutable execution kind in the policy/journal; an unavailable isolated
2660/// backend can never fall through to native process launch.
2661#[derive(Debug)]
2662pub struct PlatformExecutionProvider {
2663    native: NativeProcesses,
2664    oci: OciProcesses,
2665    windows_hyper_v: WindowsHyperVContainers,
2666}
2667
2668impl PlatformExecutionProvider {
2669    #[must_use]
2670    pub fn new(host_id: HostId) -> Self {
2671        Self {
2672            native: NativeProcesses::new(),
2673            oci: OciProcesses::new(host_id),
2674            windows_hyper_v: WindowsHyperVContainers::new(host_id),
2675        }
2676    }
2677
2678    /// Applies the host's `runner.env` at `path` to every native runner.
2679    /// Isolated runners do not get it: their environment is the image's.
2680    #[must_use]
2681    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2682        self.native = self.native.with_runner_env_file(path);
2683        self
2684    }
2685
2686    /// Gives every native runner persistent dependency caches. Isolated
2687    /// runners do not get them: their providers mount no host directory.
2688    #[must_use]
2689    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2690        self.native = self.native.with_dependency_caches(caches);
2691        self
2692    }
2693
2694    #[must_use]
2695    pub fn with_docker_cleanup(mut self, cleanup: crate::docker_cleanup::DockerCleanup) -> Self {
2696        self.native.docker_cleanup = Some(cleanup);
2697        self
2698    }
2699
2700    /// Starts every native runner as `account`, which owns its attempt
2701    /// directory and its caches. `None` keeps runners in the daemon's account.
2702    #[must_use]
2703    pub fn with_runner_account(mut self, account: Result<Option<RunnerAccount>, String>) -> Self {
2704        self.native.runner = match account {
2705            Ok(None) => RunnerIdentity::Daemon,
2706            Ok(Some(account)) => {
2707                self.native.dependency_caches = self
2708                    .native
2709                    .dependency_caches
2710                    .take()
2711                    .map(DependencyCaches::without_git);
2712                RunnerIdentity::Account(account)
2713            }
2714            Err(why) => {
2715                tracing::error!(reason = "runner_account_missing", "{why}");
2716                RunnerIdentity::Missing(why)
2717            }
2718        };
2719        self
2720    }
2721
2722    fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2723        match backend {
2724            Backend::Auto => {
2725                #[cfg(target_os = "windows")]
2726                {
2727                    Some(&self.windows_hyper_v)
2728                }
2729                #[cfg(not(target_os = "windows"))]
2730                {
2731                    Some(&self.oci)
2732                }
2733            }
2734            Backend::Oci => Some(&self.oci),
2735            Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2736            Backend::VirtualMachine => None,
2737        }
2738    }
2739
2740    fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2741        match attempt.execution() {
2742            AttemptExecution::Native { .. } => Some(&self.native),
2743            AttemptExecution::Isolated { provider_kind, .. } => {
2744                self.isolated_provider(*provider_kind)
2745            }
2746        }
2747    }
2748
2749    fn required_provider_for_attempt(
2750        &self,
2751        attempt: &RunnerAttempt,
2752    ) -> Result<&dyn ExecutionProvider, FailureReason> {
2753        self.provider_for_attempt(attempt)
2754            .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2755    }
2756}
2757
2758impl ExecutionProvider for PlatformExecutionProvider {
2759    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2760        match policy.execution_policy() {
2761            runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2762            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2763                .isolated_provider(*backend)
2764                .map_or(ProviderCapability::Unsupported, |provider| {
2765                    provider.probe(policy)
2766                }),
2767        }
2768    }
2769
2770    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2771        match policy.execution_policy() {
2772            runner_manager_domain::execution::ExecutionPolicy::Native => {
2773                self.native.resolve(policy)
2774            }
2775            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2776                .isolated_provider(*backend)
2777                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2778                .resolve(policy),
2779        }
2780    }
2781
2782    fn prepare(
2783        &self,
2784        attempt: &RunnerAttempt,
2785        policy: &ScalePolicy,
2786    ) -> Result<PreparedEnvironment, FailureReason> {
2787        match policy.execution_policy() {
2788            runner_manager_domain::execution::ExecutionPolicy::Native => {
2789                self.native.prepare(attempt, policy)
2790            }
2791            runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2792                .isolated_provider(*backend)
2793                .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2794                .prepare(attempt, policy),
2795        }
2796    }
2797
2798    fn start(
2799        &self,
2800        prepared: PreparedEnvironment,
2801        attempt: &RunnerAttempt,
2802        handoff: OneTimeJitHandoff<'_>,
2803    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2804        self.provider_for_attempt(attempt)
2805            .ok_or_else(|| {
2806                ProcessStartFailure::before_spawn(FailureReason::Other(
2807                    "execution provider unavailable".into(),
2808                ))
2809            })?
2810            .start(prepared, attempt, handoff)
2811    }
2812
2813    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2814        self.required_provider_for_attempt(attempt)?
2815            .inspect(attempt)
2816    }
2817
2818    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2819        self.required_provider_for_attempt(attempt)?.stop(attempt)
2820    }
2821
2822    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2823        self.required_provider_for_attempt(attempt)?
2824            .destroy(attempt)
2825    }
2826
2827    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2828        self.required_provider_for_attempt(attempt)?
2829            .recover(attempt)
2830    }
2831
2832    fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2833        let mut owned = self.oci.enumerate_owned(host_id);
2834        owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2835        owned
2836    }
2837
2838    fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2839        self.provider_for_attempt(attempt)
2840            .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2841    }
2842
2843    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2844        self.required_provider_for_attempt(attempt)?.owns(attempt)
2845    }
2846
2847    fn spawn(
2848        &self,
2849        attempt: &RunnerAttempt,
2850        config: &EncodedJitConfig,
2851    ) -> Result<u32, ProcessStartFailure> {
2852        if !attempt.execution().is_native() {
2853            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2854                "native process launch refused for an isolated allocation".into(),
2855            )));
2856        }
2857        self.native.spawn(attempt, config)
2858    }
2859
2860    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2861        self.required_provider_for_attempt(attempt)?
2862            .is_alive(attempt)
2863    }
2864
2865    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2866        self.required_provider_for_attempt(attempt)?
2867            .recovered_pid(attempt)
2868    }
2869
2870    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2871        self.provider_for_attempt(attempt)
2872            .is_some_and(|provider| provider.completed_successfully(attempt))
2873    }
2874
2875    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2876        self.required_provider_for_attempt(attempt)?
2877            .record_terminate_intent(attempt)
2878    }
2879
2880    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2881        self.provider_for_attempt(attempt)
2882            .is_some_and(|provider| provider.has_terminate_intent(attempt))
2883    }
2884
2885    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2886        self.required_provider_for_attempt(attempt)?
2887            .terminate(attempt)
2888    }
2889}
2890
2891#[cfg(test)]
2892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2893enum PostSpawnBoundary {
2894    HandoffDelete,
2895    IdentitySerialize,
2896    IdentityWrite,
2897    ChildMapInsert,
2898}
2899
2900impl NativeProcesses {
2901    #[must_use]
2902    pub fn new() -> Self {
2903        Self::default()
2904    }
2905
2906    /// Applies the host's `runner.env` at `path` to every runner launched.
2907    #[must_use]
2908    pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2909        self.runner_env_file = Some(path);
2910        self
2911    }
2912
2913    /// Gives every runner launched persistent dependency caches, without the
2914    /// Git checkout cache when runners run as another account.
2915    #[must_use]
2916    pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2917        self.dependency_caches = Some(match self.runner {
2918            RunnerIdentity::Account(_) => caches.without_git(),
2919            RunnerIdentity::Daemon | RunnerIdentity::Missing(_) => caches,
2920        });
2921        self
2922    }
2923
2924    /// The cache variables for `attempt`, when `prepare` named its target.
2925    fn cache_env(
2926        &self,
2927        attempt: &RunnerAttempt,
2928        target: Option<&runner_manager_domain::model::ScaleTarget>,
2929        host_env: &RunnerEnv,
2930    ) -> Vec<(&'static str, std::ffi::OsString)> {
2931        match (&self.dependency_caches, target) {
2932            (Some(caches), Some(target)) => {
2933                caches.for_launch(target, attempt.runtime_path(), host_env)
2934            }
2935            _ => Vec::new(),
2936        }
2937    }
2938
2939    /// Starts `Runner.Listener` for `attempt`, with the dependency caches of
2940    /// `cache_target` when there is one.
2941    fn spawn_listener(
2942        &self,
2943        attempt: &RunnerAttempt,
2944        config: &EncodedJitConfig,
2945        cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2946    ) -> Result<u32, ProcessStartFailure> {
2947        let account = self
2948            .runner
2949            .account()
2950            .map_err(ProcessStartFailure::before_spawn)?;
2951        let handoff = RestrictiveHandoff::create(
2952            attempt.runtime_path(),
2953            SecretString::from(config.expose().to_owned()),
2954        )
2955        .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2956        #[cfg(windows)]
2957        let program = attempt
2958            .runtime_path()
2959            .join("bin")
2960            .join("Runner.Listener.exe");
2961        #[cfg(not(windows))]
2962        let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2963        // Checked after the handoff exists on purpose: the error path below is
2964        // a real post-handoff launch failure, and unwinding must delete it.
2965        if !program.is_file() {
2966            return Err(ProcessStartFailure::before_spawn(
2967                FailureReason::ProcessStartFailed,
2968            ));
2969        }
2970        let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2971        let caches = self.cache_env(attempt, cache_target, &host_env);
2972        let cache_names: Vec<&'static str> = caches.iter().map(|(name, _)| *name).collect();
2973        #[cfg(test)]
2974        let spec = if self
2975            .use_long_lived_test_listener
2976            .load(std::sync::atomic::Ordering::SeqCst)
2977        {
2978            SpawnSpec::new(program)
2979                .args([
2980                    "--ignored",
2981                    "--exact",
2982                    "lifecycle::tests::long_lived_native_listener_helper",
2983                    "--nocapture",
2984                ])
2985                .env(
2986                    "RUNNER_MANAGER_TEST_LISTENER_READY",
2987                    attempt.runtime_path().join(TEST_LISTENER_READY),
2988                )
2989                .working_dir(attempt.runtime_path())
2990        } else {
2991            runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2992        };
2993        #[cfg(not(test))]
2994        let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2995        let spec = match account {
2996            Some(account) => {
2997                hand_over(account, attempt.runtime_path(), &spec, &cache_names).map_err(
2998                    |error| {
2999                        tracing::warn!(attempt_id = %attempt.id, event = "runner_hand_over_failed", %error, "the attempt could not be handed to the runner account");
3000                        ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed)
3001                    },
3002                )?;
3003                spec.run_as(account)
3004            }
3005            None => spec,
3006        };
3007        let (spec, docker_proxy) = if let Some(cleanup) = &self.docker_cleanup {
3008            let repository = cache_target.map(ToString::to_string).unwrap_or_default();
3009            let listen =
3010                account.map(|account| (attempt.runtime_path().join(DOCKER_SOCKET_DIR), account));
3011            match cleanup.launch_environment(attempt.id, &repository, &host_env, listen) {
3012                Ok(Some(endpoint)) => (
3013                    spec.env("DOCKER_HOST", endpoint),
3014                    Some(cleanup.root.join(attempt.id.to_string())),
3015                ),
3016                Ok(None) => (spec, None),
3017                Err(error) => {
3018                    tracing::warn!(attempt_id = %attempt.id, event = "docker_accounting_unavailable", reason = ?error.kind(), "native Docker accounting could not start");
3019                    if crate::docker_cleanup::Config::load(&cleanup.config).map_or(true, |config| {
3020                        config.mode == crate::docker_cleanup::Mode::Apply
3021                    }) {
3022                        return Err(ProcessStartFailure::before_spawn(
3023                            FailureReason::ProcessStartFailed,
3024                        ));
3025                    }
3026                    (spec, None)
3027                }
3028            }
3029        } else {
3030            (spec, None)
3031        };
3032        let child = spec
3033            .spawn_runner_with_handoff(&handoff)
3034            .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
3035        // The payload is gone before any state saying "starting" is persisted.
3036        #[cfg(test)]
3037        if self.faults_at(PostSpawnBoundary::HandoffDelete) {
3038            drop(handoff);
3039            return Err(self.abort_spawned_child(child, attempt, false));
3040        }
3041        if handoff.delete().is_err() {
3042            return Err(self.abort_spawned_child(child, attempt, false));
3043        }
3044        #[cfg(test)]
3045        if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
3046            return Err(self.abort_spawned_child(child, attempt, false));
3047        }
3048        let identity = match serde_json::to_vec(child.identity()) {
3049            Ok(identity) => identity,
3050            Err(_) => {
3051                return Err(self.abort_spawned_child(child, attempt, false));
3052            }
3053        };
3054        if self.persist_identity(attempt, &identity).is_err() {
3055            return Err(self.abort_spawned_child(child, attempt, true));
3056        }
3057        if let Some(directory) = docker_proxy
3058            && crate::docker_cleanup::write_json(&directory.join("runner.json"), child.identity())
3059                .is_err()
3060        {
3061            return Err(self.abort_spawned_child(child, attempt, true));
3062        }
3063        let pid = child.pid();
3064        #[cfg(test)]
3065        if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
3066            return Err(self.abort_spawned_child(child, attempt, true));
3067        }
3068        let mut children = self
3069            .children
3070            .lock()
3071            .unwrap_or_else(std::sync::PoisonError::into_inner);
3072        children.insert(attempt.id, child);
3073        Ok(pid)
3074    }
3075
3076    /// The host's `runner.env`, read now.
3077    ///
3078    /// A file that cannot be read or parsed fails the launch rather than
3079    /// starting a runner without the environment the operator configured.
3080    /// [`ExecutionProvider::prepare`] asks first, before a JIT runner is
3081    /// registered, so a broken file costs no registration. The reason names
3082    /// the file and the line or variable, never a value.
3083    fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
3084        let Some(path) = &self.runner_env_file else {
3085            return Ok(RunnerEnv::default());
3086        };
3087        RunnerEnv::load(path).map_err(|error| {
3088            tracing::warn!(
3089                reason = "runner_env_file_invalid",
3090                "runner.env could not be applied, so no runner starts until it is fixed: {error}"
3091            );
3092            FailureReason::Other(format!(
3093                "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
3094            ))
3095        })
3096    }
3097
3098    #[cfg(test)]
3099    fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
3100        self.post_spawn_faults.lock().unwrap().push_back(boundary);
3101    }
3102
3103    #[cfg(test)]
3104    fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
3105        let mut faults = self.post_spawn_faults.lock().unwrap();
3106        if faults.front() == Some(&boundary) {
3107            faults.pop_front();
3108            true
3109        } else {
3110            false
3111        }
3112    }
3113
3114    #[cfg(test)]
3115    fn fail_post_spawn_stops(&self, count: usize) {
3116        self.post_spawn_stop_failures
3117            .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
3118    }
3119
3120    #[cfg(test)]
3121    fn fail_next_post_spawn_stop(&self) {
3122        self.fail_post_spawn_stops(1);
3123    }
3124
3125    #[cfg(test)]
3126    fn use_long_lived_test_listener(&self) {
3127        self.use_long_lived_test_listener
3128            .store(true, std::sync::atomic::Ordering::SeqCst);
3129    }
3130
3131    fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
3132        #[cfg(test)]
3133        if self
3134            .post_spawn_stop_failures
3135            .fetch_update(
3136                std::sync::atomic::Ordering::SeqCst,
3137                std::sync::atomic::Ordering::SeqCst,
3138                |left| if left > 0 { Some(left - 1) } else { None },
3139            )
3140            .is_ok()
3141        {
3142            return Err(FailureReason::Other("injected runner stop failure".into()));
3143        }
3144        child
3145            .stop(Duration::from_secs(1))
3146            .map(|_| ())
3147            .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
3148    }
3149
3150    fn abort_spawned_child(
3151        &self,
3152        mut child: ChildProcess,
3153        attempt: &RunnerAttempt,
3154        remove_identity: bool,
3155    ) -> ProcessStartFailure {
3156        let mut reaped = self.stop_spawned_child(&mut child).is_ok();
3157        if reaped {
3158            if remove_identity {
3159                Self::remove_identity_files(attempt);
3160            }
3161        } else {
3162            // A failed stop is not a failed attempt yet.  Persist enough truth
3163            // for crash recovery, and retain the owned child when possible.
3164            let identity_durable =
3165                serde_json::to_vec(child.identity())
3166                    .ok()
3167                    .is_some_and(|identity| {
3168                        self.persist_identity(attempt, &identity).is_ok()
3169                            || self.persist_fallback_identity(attempt, &identity).is_ok()
3170                    });
3171            if !identity_durable {
3172                // Returning a live PID as though recovery were complete would
3173                // make the next boot trust a recyclable PID. Reaping is bounded;
3174                // if it cannot finish, the durable `starting` journal entry is
3175                // deliberately unresolved on restart and blocks new launches.
3176                for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
3177                    if self.stop_spawned_child(&mut child).is_ok() {
3178                        reaped = true;
3179                        break;
3180                    }
3181                }
3182                if !reaped {
3183                    // The attempt journal will durably record `starting` and
3184                    // its PID. Recovery treats a missing full identity as
3185                    // unresolved and starts nothing, so bounded stop failure
3186                    // cannot turn into either a hang or a duplicate runner.
3187                    let pid = child.pid();
3188                    let marker = write_durable_file(
3189                        &Self::unresolved_process_path(attempt),
3190                        pid.to_string().as_bytes(),
3191                    );
3192                    self.children
3193                        .lock()
3194                        .unwrap_or_else(std::sync::PoisonError::into_inner)
3195                        .insert(attempt.id, child);
3196                    let reason = if marker.is_ok() {
3197                        FailureReason::Other(
3198                            "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
3199                                .into(),
3200                        )
3201                    } else {
3202                        FailureReason::Other(
3203                            "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
3204                                .into(),
3205                        )
3206                    };
3207                    return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
3208                }
3209            } else {
3210                let pid = child.pid();
3211                self.children
3212                    .lock()
3213                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3214                    .insert(attempt.id, child);
3215                return ProcessStartFailure::after_spawn_live(pid);
3216            }
3217        }
3218        #[cfg(test)]
3219        if reaped {
3220            self.post_spawn_reaps
3221                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3222        }
3223        #[cfg(not(test))]
3224        let _ = reaped;
3225        ProcessStartFailure::after_spawn_stopped()
3226    }
3227
3228    fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
3229        attempt.runtime_path().join(IDENTITY_FILE)
3230    }
3231
3232    fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
3233        attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
3234    }
3235
3236    fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
3237        attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
3238    }
3239
3240    fn remove_identity_files(attempt: &RunnerAttempt) {
3241        let _ = fs::remove_file(Self::identity_path(attempt));
3242        let _ = fs::remove_file(Self::fallback_identity_path(attempt));
3243        let _ = fs::remove_file(Self::unresolved_process_path(attempt));
3244    }
3245
3246    fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
3247        self.persist_identity_at(&Self::identity_path(attempt), bytes)
3248    }
3249
3250    fn persist_fallback_identity(
3251        &self,
3252        attempt: &RunnerAttempt,
3253        bytes: &[u8],
3254    ) -> std::io::Result<()> {
3255        self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
3256    }
3257
3258    fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
3259        #[cfg(test)]
3260        if self.faults_at(PostSpawnBoundary::IdentityWrite) {
3261            return Err(std::io::Error::other("injected identity write failure"));
3262        }
3263        write_durable_file(path, bytes)
3264    }
3265
3266    fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
3267        attempt.runtime_path().join(TERMINATE_INTENT_FILE)
3268    }
3269
3270    /// The recorded runner process, `None` when none was recorded.
3271    ///
3272    /// A recorded process that is alive but not the runner account's -- a
3273    /// forged record, or a PID the kernel has since given to another account
3274    /// -- is reported as [`RecordedProcess::Foreign`], which every caller
3275    /// treats as a runner that is gone. It is not `None`: an attempt that has
3276    /// a PID and no record refuses recovery, and an exited runner whose PID was
3277    /// reused must not be held up that way.
3278    fn read_identity(&self, attempt: &RunnerAttempt) -> Result<RecordedProcess, FailureReason> {
3279        let identity = match Self::read_identity_at(&Self::identity_path(attempt))? {
3280            Some(identity) => Some(identity),
3281            None => Self::read_identity_at(&Self::fallback_identity_path(attempt))?,
3282        };
3283        Ok(match identity {
3284            None => RecordedProcess::None,
3285            Some(identity) if self.runs_as_runner(&identity) => RecordedProcess::Runner(identity),
3286            Some(_) => RecordedProcess::Foreign,
3287        })
3288    }
3289
3290    /// Whether the process `identity` names belongs to the runner account.
3291    ///
3292    /// The identity files live in the attempt directory, which the runner's
3293    /// account owns when it is not this process's, so a job could rewrite one
3294    /// to name any process on the host for this process to signal. A recorded
3295    /// process the account does not run is therefore not one this daemon
3296    /// started, and is treated as gone. A process that has exited passes, and
3297    /// the identity's own start-time check then reports it gone.
3298    fn runs_as_runner(&self, identity: &ProcessIdentity) -> bool {
3299        let RunnerIdentity::Account(account) = self.runner else {
3300            return true;
3301        };
3302        #[cfg(unix)]
3303        {
3304            use std::os::unix::fs::MetadataExt as _;
3305            match fs::metadata(format!("/proc/{}", identity.pid())) {
3306                Ok(metadata) => metadata.uid() == account.uid,
3307                Err(error) => error.kind() == std::io::ErrorKind::NotFound,
3308            }
3309        }
3310        #[cfg(not(unix))]
3311        {
3312            let _ = (account, identity);
3313            true
3314        }
3315    }
3316
3317    fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
3318        match fs::read(path) {
3319            Ok(bytes) => serde_json::from_slice(&bytes)
3320                .map(Some)
3321                .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
3322            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3323            Err(_) => Err(FailureReason::Other(
3324                "process identity journal could not be read".into(),
3325            )),
3326        }
3327    }
3328}
3329
3330impl ProcessSupervisor for NativeProcesses {
3331    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
3332        if !attempt.execution().is_native() {
3333            return Err(FailureReason::Other(
3334                "execution provider unavailable".into(),
3335            ));
3336        }
3337        // Existing native reconciliation has already settled process termination
3338        // before this boundary. Capture before ephemeral workspace deletion.
3339        if let Some(caches) = &self.dependency_caches {
3340            caches.capture_git(attempt.runtime_path());
3341        }
3342        Ok(ProviderDestroy::Destroyed)
3343    }
3344
3345    fn prepare(
3346        &self,
3347        attempt: &RunnerAttempt,
3348        policy: &ScalePolicy,
3349    ) -> Result<PreparedEnvironment, FailureReason> {
3350        if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
3351            return Err(FailureReason::Other(
3352                "execution provider unavailable".into(),
3353            ));
3354        }
3355        self.host_env()?;
3356        // The materialized attempt is handed to the runner's account here, off
3357        // the async thread and before the JIT registration: it is the one walk
3358        // over the whole package copy, and the launch repeats only the cheap
3359        // part of it for what it creates afterwards.
3360        if let Some(account) = self.runner.account()? {
3361            hand_over_attempt(account, attempt.runtime_path()).map_err(|error| {
3362                tracing::warn!(attempt_id = %attempt.id, event = "runner_hand_over_failed", %error, "the attempt could not be handed to the runner account");
3363                FailureReason::ProcessStartFailed
3364            })?;
3365        }
3366        // Asked here, before the JIT registration, for the reason `host_env`
3367        // is: a broken file must cost no registration.
3368        let cache_target = match &self.dependency_caches {
3369            Some(caches) => {
3370                caches.config()?;
3371                Some(policy.target.clone())
3372            }
3373            None => None,
3374        };
3375        Ok(PreparedEnvironment {
3376            attempt: attempt.id,
3377            identity: None,
3378            cache_target,
3379        })
3380    }
3381
3382    fn start(
3383        &self,
3384        prepared: PreparedEnvironment,
3385        attempt: &RunnerAttempt,
3386        handoff: OneTimeJitHandoff<'_>,
3387    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
3388        if prepared.attempt != attempt.id || !attempt.execution().is_native() {
3389            return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
3390                "execution provider identity mismatch".into(),
3391            )));
3392        }
3393        self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
3394            .map(EnvironmentIdentity::NativeProcess)
3395    }
3396
3397    fn spawn(
3398        &self,
3399        attempt: &RunnerAttempt,
3400        config: &EncodedJitConfig,
3401    ) -> Result<u32, ProcessStartFailure> {
3402        self.spawn_listener(attempt, config, None)
3403    }
3404
3405    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3406        let mut children = self
3407            .children
3408            .lock()
3409            .unwrap_or_else(std::sync::PoisonError::into_inner);
3410        if let Some(child) = children.get_mut(&attempt.id) {
3411            return match child
3412                .try_exit_status()
3413                .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
3414            {
3415                None => Ok(true),
3416                Some(status) => {
3417                    if let Ok(mut exits) = self.successful_exits.lock() {
3418                        exits.insert(attempt.id, status.success());
3419                    }
3420                    Ok(false)
3421                }
3422            };
3423        }
3424        let identity = match self.read_identity(attempt)? {
3425            RecordedProcess::Runner(identity) => identity,
3426            RecordedProcess::Foreign => return Ok(false),
3427            RecordedProcess::None => {
3428                if attempt.process_id().is_some()
3429                    || Self::unresolved_process_path(attempt).is_file()
3430                {
3431                    return Err(FailureReason::Other(
3432                        "runner process identity is missing; refusing recovery until the process is resolved"
3433                            .into(),
3434                    ));
3435                }
3436                return Ok(false);
3437            }
3438        };
3439        match identity.recheck() {
3440            Ok(Adoption::Live) => Ok(true),
3441            Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
3442            Err(_) => Ok(false),
3443        }
3444    }
3445
3446    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3447        Ok(self
3448            .read_identity(attempt)?
3449            .runner()
3450            .map(|identity| identity.pid()))
3451    }
3452
3453    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
3454        self.successful_exits
3455            .lock()
3456            .ok()
3457            .and_then(|exits| exits.get(&attempt.id).copied())
3458            .unwrap_or(false)
3459    }
3460
3461    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3462        let path = Self::intent_path(attempt);
3463        write_durable_file(&path, b"registration-timeout\n")
3464            .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
3465    }
3466
3467    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
3468        Self::intent_path(attempt).is_file()
3469    }
3470
3471    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3472        let mut children = self
3473            .children
3474            .lock()
3475            .unwrap_or_else(std::sync::PoisonError::into_inner);
3476        if let Some(child) = children.get_mut(&attempt.id) {
3477            child
3478                .stop(Duration::from_secs(10))
3479                .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
3480            return Ok(());
3481        }
3482        let Some(identity) = self.read_identity(attempt)?.runner() else {
3483            return Ok(());
3484        };
3485        match identity
3486            .terminate(Duration::from_secs(10))
3487            .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
3488        {
3489            Termination::Terminated | Termination::AlreadyGone => Ok(()),
3490            Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
3491                "runner PID was recycled; refusing to signal it".into(),
3492            )),
3493        }
3494    }
3495}
3496
3497pub struct LifecyclePorts {
3498    pub store: Arc<dyn Store>,
3499    pub github: Arc<dyn LifecycleGithub>,
3500    pub packages: Arc<dyn RuntimePackages>,
3501    pub processes: Arc<dyn ExecutionProvider>,
3502    pub clock: Arc<dyn Clock>,
3503    pub demand: Arc<dyn DemandPersistence>,
3504    pub delay: Arc<dyn RetryDelay>,
3505    pub events: Arc<dyn AttemptEventSink>,
3506    pub reconcile_events: Arc<dyn EventSink>,
3507}
3508
3509impl fmt::Debug for LifecyclePorts {
3510    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3511        f.debug_struct("LifecyclePorts")
3512            .field("store", &self.store)
3513            .field("github", &self.github)
3514            .field("packages", &self.packages)
3515            .field("processes", &self.processes)
3516            .finish_non_exhaustive()
3517    }
3518}
3519
3520#[derive(Debug, thiserror::Error)]
3521pub enum LifecycleError {
3522    #[error("attempt journal operation failed")]
3523    Journal,
3524    #[error("attempt {0} is not in the journal")]
3525    Missing(AttemptId),
3526    #[error("attempt lifecycle transition was refused")]
3527    Transition,
3528    #[error("startup recovery has not completed")]
3529    RecoveryIncomplete,
3530    /// A persistent slot could not be proven safe to scrub, so the attempt keeps
3531    /// its uncleaned state and, with it, its slot lease.
3532    ///
3533    /// Separate from [`Self::Failed`] because the two demand opposite handling
3534    /// from the same call sites. A failure aborts the pass; a quarantine must
3535    /// not, or one stuck slot would stop the host launching anything at all —
3536    /// which is precisely what `04-security-recovery.md` rules out when it says
3537    /// such an attempt "does not count as active host capacity" and "recovery
3538    /// retries the same cleanup".
3539    #[error("the persistent slot was not cleaned: {detail}")]
3540    SlotQuarantined {
3541        /// The closed-vocabulary event field; never operator or workflow text.
3542        class: &'static str,
3543        detail: String,
3544    },
3545    /// An ephemeral runtime is still held by a late process. Its journal state
3546    /// remains unchanged and ordinary supervision retries it, but startup
3547    /// recovery must not take unrelated repositories offline because of it.
3548    #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
3549    WorkspaceCleanupDeferred,
3550    #[error("isolated environment cleanup is deferred")]
3551    EnvironmentCleanupDeferred,
3552    #[error("runner lifecycle failed: {0}")]
3553    Failed(FailureReason),
3554}
3555
3556impl LifecycleError {
3557    fn reason(&self) -> FailureReason {
3558        match self {
3559            Self::Failed(reason) => reason.clone(),
3560            Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
3561            Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
3562            Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
3563            Self::Transition => FailureReason::Other("attempt transition was refused".into()),
3564            // Rendered through `Display` rather than a second copy of the same
3565            // sentence, so the two cannot drift apart.
3566            Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
3567            Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
3568            Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
3569        }
3570    }
3571}
3572
3573/// Production implementation of e1's launcher port.
3574#[derive(Debug)]
3575pub struct LifecycleLauncher {
3576    host_id: HostId,
3577    app_paths: runner_manager_platform::paths::AppPaths,
3578    diagnostics_root: PathBuf,
3579    runner_group_id: u64,
3580    timeouts: RecoveryTimeouts,
3581    retry: RetryPolicy,
3582    cancel: CancelToken,
3583    ports: LifecyclePorts,
3584    recovery_complete: Mutex<bool>,
3585    versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
3586    pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
3587    /// Backoff for cleanups [`Self::supervise`] tolerates and retries, so a
3588    /// workspace that cannot be removed is not retried and reported on every
3589    /// pass.
3590    cleanup_retries: Mutex<CleanBackoff>,
3591    turns: Arc<tokio::sync::Semaphore>,
3592    /// The attempts reserved and not yet up or failed: their bring-up is writing
3593    /// their journal entries, and it runs while the target's pass supervises.
3594    bringing_up: Mutex<BTreeSet<AttemptId>>,
3595}
3596
3597/// Takes an attempt out of [`LifecycleLauncher::bringing_up`], however its
3598/// bring-up ends.
3599struct BroughtUpOnDrop<'a> {
3600    attempts: &'a Mutex<BTreeSet<AttemptId>>,
3601    id: AttemptId,
3602}
3603
3604impl Drop for BroughtUpOnDrop<'_> {
3605    fn drop(&mut self) {
3606        self.attempts
3607            .lock()
3608            .unwrap_or_else(std::sync::PoisonError::into_inner)
3609            .remove(&self.id);
3610    }
3611}
3612
3613/// How many runners of one host prepare at once, whatever their target: a
3614/// quarter of its cores, at least two. A runner's package is ~9,000 files, and
3615/// on a disk jobs are using, more at once only make all of them slower. Fair,
3616/// so the ones waiting go in the order they were reserved.
3617#[must_use]
3618pub fn preparation_turns() -> Arc<tokio::sync::Semaphore> {
3619    let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
3620    Arc::new(tokio::sync::Semaphore::new((cores / 4).max(2)))
3621}
3622
3623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3624enum ReconcileProgress {
3625    Reconciled,
3626    Deferred,
3627    Replacement {
3628        attempt: AttemptId,
3629        operation: &'static str,
3630    },
3631}
3632
3633impl LifecycleLauncher {
3634    #[must_use]
3635    pub fn new(
3636        host_id: HostId,
3637        app_paths: runner_manager_platform::paths::AppPaths,
3638        diagnostics_root: impl Into<PathBuf>,
3639        runner_group_id: u64,
3640        timeouts: RecoveryTimeouts,
3641        retry: RetryPolicy,
3642        ports: LifecyclePorts,
3643    ) -> Self {
3644        Self {
3645            host_id,
3646            app_paths,
3647            diagnostics_root: diagnostics_root.into(),
3648            runner_group_id,
3649            timeouts,
3650            retry,
3651            cancel: CancelToken::new(),
3652            ports,
3653            recovery_complete: Mutex::new(false),
3654            versions: Mutex::new(BTreeMap::new()),
3655            pending_replacements: Mutex::new(BTreeMap::new()),
3656            cleanup_retries: Mutex::new(CleanBackoff::default()),
3657            turns: preparation_turns(),
3658            bringing_up: Mutex::default(),
3659        }
3660    }
3661
3662    /// Takes turns to prepare with every launcher holding `turns`, rather than
3663    /// only with itself: the targets of one host.
3664    #[must_use]
3665    pub fn with_preparation_turns(mut self, turns: Arc<tokio::sync::Semaphore>) -> Self {
3666        self.turns = turns;
3667        self
3668    }
3669
3670    /// Reconcile the entire journal before allowing a launch.  Unknown-policy
3671    /// attempts are left untouched rather than being acted on without an
3672    /// ownership proof.
3673    pub async fn recover_startup(
3674        &self,
3675        policies: &[ScalePolicy],
3676    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3677        *self
3678            .recovery_complete
3679            .lock()
3680            .map_err(|_| LifecycleError::Journal)? = false;
3681        let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
3682        let attempts = self
3683            .ports
3684            .store
3685            .attempts()
3686            .map_err(|_| LifecycleError::Journal)?;
3687        let owned = self.ports.processes.enumerate_owned(self.host_id);
3688        for identity in &owned {
3689            if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
3690                && !attempts.iter().any(|attempt| {
3691                    attempt.state() != AttemptState::Cleaned
3692                        && identity_matches_intent(self.host_id, attempt, identity)
3693                })
3694            {
3695                // Enumeration is discovery, not authority to delete.
3696                self.ports
3697                    .events
3698                    .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
3699                tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
3700            }
3701        }
3702        let mut unresolved = false;
3703        for mut attempt in attempts {
3704            let Some(policy) = by_id.get(&attempt.policy_id) else {
3705                if attempt.counts_against_capacity() {
3706                    unresolved = true;
3707                }
3708                continue;
3709            };
3710            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3711            let allocation_resources = owned
3712                .iter()
3713                .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
3714                .collect::<Vec<_>>();
3715            if allocation_resources.len() > 1 {
3716                self.ports
3717                    .events
3718                    .emit(AttemptEvent::QuarantinedOwnedResource {
3719                        attempt: attempt.id,
3720                    });
3721                tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
3722            }
3723            if attempt.state() != AttemptState::Cleaned
3724                && matches!(
3725                    attempt.execution(),
3726                    AttemptExecution::Isolated {
3727                        environment_id: None,
3728                        ..
3729                    }
3730                )
3731                && allocation_resources.len() == 1
3732                && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
3733                    allocation_resources.first().copied()
3734            {
3735                attempt
3736                    .prepared_environment(environment_id.clone())
3737                    .map_err(|_| LifecycleError::Transition)?;
3738                if attempt.state() == AttemptState::Preparing {
3739                    attempt
3740                        .mark_prepared(self.ports.clock.now())
3741                        .map_err(|_| LifecycleError::Transition)?;
3742                }
3743                self.record(&attempt)?;
3744            }
3745            match self.reconcile_one(policy, attempt).await? {
3746                ReconcileProgress::Deferred => {
3747                    if policy.execution_policy().is_native() {
3748                        unresolved = true;
3749                    }
3750                }
3751                ReconcileProgress::Replacement { attempt, operation } => {
3752                    self.pending_replacements
3753                        .lock()
3754                        .map_err(|_| LifecycleError::Journal)?
3755                        .insert(
3756                            attempt,
3757                            ReplacementIntent {
3758                                policy: policy.id,
3759                                previous_attempt: attempt,
3760                                operation,
3761                            },
3762                        );
3763                }
3764                ReconcileProgress::Reconciled => {}
3765            }
3766        }
3767        if unresolved {
3768            return Err(LifecycleError::RecoveryIncomplete);
3769        }
3770        *self
3771            .recovery_complete
3772            .lock()
3773            .map_err(|_| LifecycleError::Journal)? = true;
3774        Ok(self
3775            .pending_replacements
3776            .lock()
3777            .map_err(|_| LifecycleError::Journal)?
3778            .values()
3779            .copied()
3780            .collect())
3781    }
3782
3783    /// Supervise all attempts of one policy during an ordinary poll.
3784    pub async fn supervise(
3785        &self,
3786        policy: &ScalePolicy,
3787    ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3788        let mut replacements = Vec::new();
3789        self.pending_replacements
3790            .lock()
3791            .map_err(|_| LifecycleError::Journal)?
3792            .retain(|_, intent| {
3793                if intent.policy == policy.id {
3794                    replacements.push(*intent);
3795                    false
3796                } else {
3797                    true
3798                }
3799            });
3800        // Read under the set a reservation joins before it is journalled, so
3801        // every attempt read here that a bring-up may write to is known to be
3802        // one: it has no process yet and no registration, which this pass would
3803        // read as a runner gone, and what was read of it is out of date at once.
3804        let attempts = {
3805            let bringing_up = self
3806                .bringing_up
3807                .lock()
3808                .map_err(|_| LifecycleError::Journal)?;
3809            let mut attempts = self
3810                .ports
3811                .store
3812                .attempts_for_policy(policy.id)
3813                .map_err(|_| LifecycleError::Journal)?;
3814            attempts.retain(|attempt| !bringing_up.contains(&attempt.id));
3815            attempts
3816        };
3817        for attempt in attempts {
3818            authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3819            if let ReconcileProgress::Replacement { attempt, operation } =
3820                self.reconcile_one(policy, attempt).await?
3821            {
3822                replacements.push(ReplacementIntent {
3823                    policy: policy.id,
3824                    previous_attempt: attempt,
3825                    operation,
3826                });
3827            }
3828        }
3829        Ok(replacements)
3830    }
3831
3832    async fn reconcile_one(
3833        &self,
3834        policy: &ScalePolicy,
3835        mut attempt: RunnerAttempt,
3836    ) -> Result<ReconcileProgress, LifecycleError> {
3837        if !attempt.execution().is_native() {
3838            return self.reconcile_isolated_one(policy, attempt).await;
3839        }
3840        if attempt.state() == AttemptState::Cleaned {
3841            // A child of Runner.Worker can outlive the runner process briefly.
3842            // If it recreates `_work` after `clean_attempt` removed the tree but
3843            // before the journal's final write, the journal truthfully reaches
3844            // `cleaned` and the directory appears again afterwards. Cleaned
3845            // attempts used to return here forever, making that late residue
3846            // invisible. The journalled ephemeral mode is the ownership proof:
3847            // retry the whole-tree removal, while a persistent slot deliberately
3848            // remains on disk with its retained `_work`.
3849            if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3850                && attempt.runtime_path().exists()
3851            {
3852                // The attempt is already terminal, has released its package
3853                // lease, and consumes no capacity. A late child may still hold
3854                // one of its recreated files open on Windows, so failure here
3855                // is residue to retry, not a reason to abort startup recovery
3856                // and take every unrelated repository offline.
3857                // Retried with backoff; the event is the one report, which the
3858                // event sink logs as a warning.
3859                let now = self.ports.clock.now();
3860                if !self.cleanup_retries().is_due(attempt.id, now) {
3861                    return Ok(ReconcileProgress::Reconciled);
3862                }
3863                match self.scrub_workspace(&attempt) {
3864                    Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3865                    Err(LifecycleError::WorkspaceCleanupDeferred) => {
3866                        self.cleanup_retries().failed(attempt.id, now);
3867                        self.ports
3868                            .reconcile_events
3869                            .emit(LifecycleEvent::AttemptCleanFailed {
3870                                policy: attempt.policy_id,
3871                                attempt: attempt.id,
3872                                reason: "late_ephemeral_workspace_could_not_be_removed",
3873                            });
3874                    }
3875                    Err(error) => return Err(error),
3876                }
3877            }
3878            return Ok(ReconcileProgress::Reconciled);
3879        }
3880        if attempt.is_terminal() {
3881            self.clean_or_quarantine(&mut attempt)?;
3882            return Ok(ReconcileProgress::Reconciled);
3883        }
3884        let process_alive = self
3885            .ports
3886            .processes
3887            .inspect(&attempt)
3888            .map_err(LifecycleError::Failed)?
3889            == EnvironmentState::Running;
3890        let github = self
3891            .ports
3892            .github
3893            .observe(&policy.target, attempt.id, &self.cancel)
3894            .await;
3895
3896        // If the agent died after GitHub accepted the registration but before
3897        // the non-secret runner-id sidecar landed, inventory closes the gap.
3898        // Persist it before making any state decision so a second crash moves
3899        // the boundary forward rather than repeating it.
3900        if let Some(runner_id) = github.runner_id
3901            && read_runner_id(attempt.runtime_path()).is_none()
3902        {
3903            write_runner_id(attempt.runtime_path(), runner_id)?;
3904            self.ports
3905                .events
3906                .emit(AttemptEvent::RemoteIdentityRecovered {
3907                    attempt: attempt.id,
3908                    runner_id,
3909                });
3910        }
3911
3912        // The child identity is synced before `spawn` returns.  If the agent
3913        // crashed before the following `starting` journal write, recover that
3914        // exact PID and take the legal `jit_received -> starting` edge before
3915        // applying GitHub's authoritative idle/busy observation below.
3916        if attempt.state() == AttemptState::JitReceived
3917            && process_alive
3918            && let Some(pid) = self
3919                .ports
3920                .processes
3921                .recovered_pid(&attempt)
3922                .map_err(LifecycleError::Failed)?
3923        {
3924            attempt
3925                .started(pid, self.ports.clock.now())
3926                .map_err(|_| LifecycleError::Transition)?;
3927            self.record(&attempt)?;
3928        }
3929
3930        // What the runner's own diagnostics say about whether it ran a job.
3931        // Read before anything below can conclude the attempt, because cleanup
3932        // deletes `_diag` with the rest of the runtime directory.
3933        // A one-shot child owned by this invocation exited successfully. This
3934        // concludes the *runner attempt*, never the workflow outcome; GitHub
3935        // remains authoritative for that. GitHub's inventory is a sampled
3936        // witness: a job of a few seconds starts and ends between two passes,
3937        // and an assignment GitHub shows can be one the runner never took up.
3938        // The runner's own diagnostics decide, read before cleanup deletes
3939        // them, and GitHub's sample only decides when they say nothing. Not
3940        // while GitHub is unreachable: a registration still listed there could
3941        // not be removed, and nothing visits a concluded attempt again.
3942        if !process_alive
3943            && github.status != GithubRunnerObservation::Unreachable
3944            && matches!(
3945                attempt.state(),
3946                AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3947            )
3948            && self.ports.processes.completed_successfully(&attempt)
3949        {
3950            let state = attempt.state();
3951            let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3952                // Without a runner id there is no edge to `busy`; the recovery
3953                // path below concludes the attempt without one.
3954                (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3955                    known_runner_id(&attempt, github.runner_id)
3956                        .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3957                }
3958                (AttemptState::Busy, JobEvidence::RanAJob) => {
3959                    Some((None, AttemptOutcome::CompletedJob))
3960                }
3961                (AttemptState::Busy, JobEvidence::NoJob) => {
3962                    Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3963                }
3964                (AttemptState::Busy, JobEvidence::Unknown)
3965                    if github.status == GithubRunnerObservation::NotRegistered =>
3966                {
3967                    Some((None, AttemptOutcome::CompletedJob))
3968                }
3969                _ => None,
3970            };
3971            if let Some((walk_to_busy, outcome)) = outcome {
3972                // An ephemeral runner leaves GitHub's list a little after its
3973                // process exits; one still listed is removed here rather than
3974                // left for GitHub to time out. First, so the journal's last
3975                // intermediate state and the conclusion are written back to back:
3976                // an agent that dies between them recovers from the state it was
3977                // in before this pass.
3978                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3979                    self.deregister_runner(policy, &attempt).await;
3980                }
3981                let now = self.ports.clock.now();
3982                match (walk_to_busy, &outcome) {
3983                    (Some(runner_id), _) => {
3984                        self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3985                    }
3986                    (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3987                        self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3988                    }
3989                    (None, _) => {}
3990                }
3991                self.conclude(&mut attempt, outcome)?;
3992                self.clean_or_quarantine(&mut attempt)?;
3993                return Ok(ReconcileProgress::Reconciled);
3994            }
3995        }
3996
3997        // A live runner journalled `busy` that GitHub now shows waiting, and
3998        // whose diagnostics show no job: the assignment was never taken up.
3999        // Back to `idle`, where the idle timeout applies to it again; the
4000        // `busy` arm below would otherwise adopt it for as long as it runs.
4001        if process_alive
4002            && attempt.state() == AttemptState::Busy
4003            && github.status == (GithubRunnerObservation::Registered { busy: false })
4004            && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
4005        {
4006            let now = self.ports.clock.now();
4007            self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
4008            return Ok(ReconcileProgress::Reconciled);
4009        }
4010
4011        // This durable mark is more authoritative than a later observation
4012        // which cannot distinguish an agent kill from a crash.
4013        if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
4014            self.deregister_runner(policy, &attempt).await;
4015            self.conclude(
4016                &mut attempt,
4017                AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
4018            )?;
4019            self.clean_or_quarantine(&mut attempt)?;
4020            return Ok(ReconcileProgress::Replacement {
4021                attempt: attempt.id,
4022                operation: "registration_timeout_replacement",
4023            });
4024        }
4025
4026        // A remote registration with no surviving process has lost its
4027        // one-shot JIT secret.  Walking it to `starting` would require inventing
4028        // a PID; retrying the same registration would require inventing the
4029        // secret.  Record the configuration as expired and let the bounded
4030        // replacement path request a fresh one only if demand remains.
4031        if matches!(
4032            attempt.state(),
4033            AttemptState::Allocated | AttemptState::JitReceived
4034        ) && !process_alive
4035            && matches!(github.status, GithubRunnerObservation::Registered { .. })
4036        {
4037            if attempt.state() == AttemptState::Allocated {
4038                attempt
4039                    .jit_received(self.ports.clock.now())
4040                    .map_err(|_| LifecycleError::Transition)?;
4041                self.record(&attempt)?;
4042            }
4043            self.deregister_runner(policy, &attempt).await;
4044            self.conclude(
4045                &mut attempt,
4046                AttemptOutcome::failed(FailureReason::JitExpired),
4047            )?;
4048            self.clean_or_quarantine(&mut attempt)?;
4049            return Ok(ReconcileProgress::Replacement {
4050                attempt: attempt.id,
4051                operation: "jit_expired_replacement",
4052            });
4053        }
4054
4055        match recovery_decision(
4056            &attempt,
4057            RecoveryObservation {
4058                process_alive,
4059                github: github.status,
4060            },
4061            self.timeouts,
4062            self.ports.clock.as_ref(),
4063        ) {
4064            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
4065            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
4066            RecoveryDecision::Adopt => {
4067                self.ports.events.emit(AttemptEvent::Adopted {
4068                    attempt: attempt.id,
4069                });
4070                Ok(ReconcileProgress::Reconciled)
4071            }
4072            RecoveryDecision::Clean => {
4073                self.clean_or_quarantine(&mut attempt)?;
4074                Ok(ReconcileProgress::Reconciled)
4075            }
4076            RecoveryDecision::Observe(state) => {
4077                let runner_id = known_runner_id(&attempt, github.runner_id)
4078                    .ok_or(LifecycleError::Transition)?;
4079                match state {
4080                    AttemptState::JitReceived => attempt
4081                        .jit_received(self.ports.clock.now())
4082                        .map_err(|_| LifecycleError::Transition)?,
4083                    AttemptState::Starting => {
4084                        let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
4085                        attempt
4086                            .started(pid, self.ports.clock.now())
4087                            .map_err(|_| LifecycleError::Transition)?;
4088                    }
4089                    AttemptState::Idle => attempt
4090                        .registered_idle(runner_id, self.ports.clock.now())
4091                        .map_err(|_| LifecycleError::Transition)?,
4092                    AttemptState::Busy => attempt
4093                        .assigned_job(runner_id, self.ports.clock.now())
4094                        .map_err(|_| LifecycleError::Transition)?,
4095                    _ => return Err(LifecycleError::Transition),
4096                }
4097                self.record(&attempt)?;
4098                Ok(ReconcileProgress::Reconciled)
4099            }
4100            RecoveryDecision::Conclude(outcome) => {
4101                let replacement = replacement_operation(&outcome);
4102                // Only when GitHub still holds one. Every other conclusion here
4103                // was reached *because* the observation was `NotRegistered`, and
4104                // spending a DELETE to be told so again would put a request per
4105                // concluded attempt on a budget `rest.rs` prices to the request.
4106                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
4107                    self.deregister_runner(policy, &attempt).await;
4108                }
4109                self.conclude(&mut attempt, outcome)?;
4110                self.clean_or_quarantine(&mut attempt)?;
4111                Ok(
4112                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
4113                        ReconcileProgress::Replacement {
4114                            attempt: attempt.id,
4115                            operation,
4116                        }
4117                    }),
4118                )
4119            }
4120            RecoveryDecision::Terminate(payload) => {
4121                // The mark is synced first, and what proves the process died is
4122                // the `is_alive` re-read below -- not the outcome recorded after
4123                // it. Which outcome that is depends on why the termination was
4124                // ordered, and only the payload knows: a `starting` runner that
4125                // never registered is a failure this agent then stopped, while
4126                // an `idle` one past its timeout is flow 2.7's surplus exit and
4127                // no failure at all. Hardcoding the first reason here labelled
4128                // the second as a registration timeout and asked the allocator
4129                // for a replacement to boot.
4130                let idle_exit = payload.is_idle_exit();
4131                self.ports
4132                    .processes
4133                    .record_terminate_intent(&attempt)
4134                    .map_err(LifecycleError::Failed)?;
4135                self.ports.events.emit(AttemptEvent::TerminateIntent {
4136                    attempt: attempt.id,
4137                });
4138                if self
4139                    .ports
4140                    .processes
4141                    .stop(&attempt)
4142                    .map_err(LifecycleError::Failed)?
4143                    == ProviderStop::StillRunning
4144                {
4145                    return Ok(ReconcileProgress::Deferred);
4146                }
4147                if self
4148                    .ports
4149                    .processes
4150                    .inspect(&attempt)
4151                    .map_err(LifecycleError::Failed)?
4152                    == EnvironmentState::Running
4153                {
4154                    return Ok(ReconcileProgress::Deferred);
4155                }
4156                self.ports.events.emit(AttemptEvent::Terminated {
4157                    attempt: attempt.id,
4158                });
4159                // The registration-timeout path keeps deriving its own reason
4160                // rather than applying the payload: on the pass that reads the
4161                // journalled mark back the process is dead, and
4162                // `TerminatedAfterRegistrationTimeout` is the reason that stays
4163                // true of a dead process. See `RecoveryDecision::Terminate`.
4164                let outcome = if idle_exit {
4165                    AttemptOutcome::ExitedIdleWithoutWork
4166                } else {
4167                    AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
4168                };
4169                self.deregister_runner(policy, &attempt).await;
4170                self.conclude(&mut attempt, outcome)?;
4171                self.clean_or_quarantine(&mut attempt)?;
4172                // A surplus runner is not replaced. It was stopped precisely
4173                // because the work it was started for went elsewhere; asking the
4174                // allocator for another one rebuilds it every idle timeout.
4175                if idle_exit {
4176                    Ok(ReconcileProgress::Reconciled)
4177                } else {
4178                    Ok(ReconcileProgress::Replacement {
4179                        attempt: attempt.id,
4180                        operation: "registration_timeout_replacement",
4181                    })
4182                }
4183            }
4184        }
4185    }
4186
4187    async fn reconcile_isolated_one(
4188        &self,
4189        policy: &ScalePolicy,
4190        mut attempt: RunnerAttempt,
4191    ) -> Result<ReconcileProgress, LifecycleError> {
4192        if attempt.state() == AttemptState::Cleaned {
4193            return Ok(ReconcileProgress::Reconciled);
4194        }
4195        if attempt.is_terminal() {
4196            self.clean_or_quarantine(&mut attempt)?;
4197            return Ok(ReconcileProgress::Reconciled);
4198        }
4199        if matches!(
4200            attempt.state(),
4201            AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
4202        ) {
4203            // No JIT was requested yet. Restart can safely retire this attempt;
4204            // a matching owned resource is stopped by the durable cleanup path.
4205            self.conclude(
4206                &mut attempt,
4207                AttemptOutcome::failed(FailureReason::Other(
4208                    "isolated preparation interrupted".into(),
4209                )),
4210            )?;
4211            self.clean_or_quarantine(&mut attempt)?;
4212            return Ok(ReconcileProgress::Reconciled);
4213        }
4214        let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
4215            return Ok(ReconcileProgress::Deferred);
4216        };
4217        if provider_state != EnvironmentState::Missing {
4218            let Ok(owned) = self.ports.processes.owns(&attempt) else {
4219                return Ok(ReconcileProgress::Deferred);
4220            };
4221            if !owned {
4222                self.ports
4223                    .events
4224                    .emit(AttemptEvent::QuarantinedOwnedResource {
4225                        attempt: attempt.id,
4226                    });
4227                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4228                return Ok(ReconcileProgress::Deferred);
4229            }
4230        }
4231        let live = matches!(
4232            provider_state,
4233            EnvironmentState::Starting | EnvironmentState::Running
4234        );
4235        if attempt.state() == AttemptState::JitReceived && live {
4236            attempt
4237                .started_isolated(self.ports.clock.now())
4238                .map_err(|_| LifecycleError::Transition)?;
4239            self.record(&attempt)?;
4240        }
4241        let github = self
4242            .ports
4243            .github
4244            .observe(&policy.target, attempt.id, &self.cancel)
4245            .await;
4246        if let Some(runner_id) = github.runner_id
4247            && read_runner_id(attempt.runtime_path()).is_none()
4248        {
4249            write_runner_id(attempt.runtime_path(), runner_id)?;
4250        }
4251        // A successfully reaped one-shot listener is stronger evidence than a
4252        // missing registration. Recovered guests have no attached child and
4253        // still follow the conservative orphan path below.
4254        if attempt.state() == AttemptState::Busy
4255            && !live
4256            && github.status == GithubRunnerObservation::NotRegistered
4257            && self.ports.processes.completed_successfully(&attempt)
4258        {
4259            self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
4260            self.clean_or_quarantine(&mut attempt)?;
4261            return Ok(ReconcileProgress::Reconciled);
4262        }
4263        match recovery_decision(
4264            &attempt,
4265            RecoveryObservation {
4266                process_alive: live,
4267                github: github.status,
4268            },
4269            self.timeouts,
4270            self.ports.clock.as_ref(),
4271        ) {
4272            RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
4273            RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
4274            RecoveryDecision::Adopt => {
4275                self.ports.events.emit(AttemptEvent::Adopted {
4276                    attempt: attempt.id,
4277                });
4278                Ok(ReconcileProgress::Reconciled)
4279            }
4280            RecoveryDecision::Clean => {
4281                self.clean_or_quarantine(&mut attempt)?;
4282                Ok(ReconcileProgress::Reconciled)
4283            }
4284            RecoveryDecision::Observe(state) => {
4285                let runner_id = github
4286                    .runner_id
4287                    .or_else(|| read_runner_id(attempt.runtime_path()))
4288                    .ok_or(LifecycleError::Transition)?;
4289                match state {
4290                    AttemptState::Idle => attempt
4291                        .registered_idle(runner_id, self.ports.clock.now())
4292                        .map_err(|_| LifecycleError::Transition)?,
4293                    AttemptState::Busy => attempt
4294                        .assigned_job(runner_id, self.ports.clock.now())
4295                        .map_err(|_| LifecycleError::Transition)?,
4296                    _ => return Err(LifecycleError::Transition),
4297                }
4298                self.record(&attempt)?;
4299                Ok(ReconcileProgress::Reconciled)
4300            }
4301            RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
4302                let replacement = replacement_operation(&outcome);
4303                if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
4304                    self.deregister_runner(policy, &attempt).await;
4305                }
4306                self.conclude(&mut attempt, outcome)?;
4307                self.clean_or_quarantine(&mut attempt)?;
4308                Ok(
4309                    replacement.map_or(ReconcileProgress::Reconciled, |operation| {
4310                        ReconcileProgress::Replacement {
4311                            attempt: attempt.id,
4312                            operation,
4313                        }
4314                    }),
4315                )
4316            }
4317        }
4318    }
4319
4320    /// Takes one domain edge and journals it.
4321    fn transition(
4322        &self,
4323        attempt: &mut RunnerAttempt,
4324        edge: impl FnOnce(
4325            &mut RunnerAttempt,
4326        ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
4327    ) -> Result<(), LifecycleError> {
4328        edge(attempt).map_err(|_| LifecycleError::Transition)?;
4329        self.record(attempt)
4330    }
4331
4332    fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4333        self.ports
4334            .store
4335            .record_attempt(attempt)
4336            .map_err(|_| LifecycleError::Journal)?;
4337        self.ports.events.emit(AttemptEvent::State {
4338            attempt: attempt.id,
4339            state: attempt.state(),
4340        });
4341        Ok(())
4342    }
4343
4344    /// Remove the GitHub registration an attempt is about to leave behind.
4345    ///
4346    /// # Why this is not fallible, and does not block the conclusion
4347    ///
4348    /// GitHub retires an ephemeral runner itself once that runner *completes a
4349    /// job*, and for the ordinary path that is the whole story. The paths that
4350    /// reach here are the ones where it does not: a runner stopped before it
4351    /// was ever assigned work, a registration whose process died still holding
4352    /// it, a JIT configuration that expired. Nothing else deletes those, and
4353    /// before this existed nothing did — they accumulated in the target's
4354    /// runner settings, one row per attempt, for the life of the repository.
4355    ///
4356    /// It returns `()` rather than a `Result` because the alternative is worse
4357    /// in both directions. The attempt is over: its process is gone and its
4358    /// slot has to come back, so a failed delete may not abort the conclusion
4359    /// or the host leaks capacity every time GitHub is unreachable. And a
4360    /// registration that outlives this call is not lost — it is exactly the
4361    /// `Registered` + dead-process observation that
4362    /// [`AttemptOutcome::Orphaned`] already names, which a later pass can still
4363    /// see. So a failure is logged and stepped over, deliberately.
4364    async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
4365        let Some(runner_id) = attempt
4366            .github_runner_id()
4367            .or_else(|| read_runner_id(attempt.runtime_path()))
4368        else {
4369            return;
4370        };
4371        if self
4372            .ports
4373            .github
4374            .deregister(&policy.target, runner_id, &self.cancel)
4375            .await
4376        {
4377            self.ports.events.emit(AttemptEvent::Deregistered {
4378                attempt: attempt.id,
4379                runner_id,
4380            });
4381        } else {
4382            tracing::warn!(
4383                attempt = %attempt.id,
4384                runner_id,
4385                "the runner registration could not be removed from GitHub; it will show in the \
4386                 target's runner settings until GitHub retires it or a later pass removes it"
4387            );
4388        }
4389    }
4390
4391    fn conclude(
4392        &self,
4393        attempt: &mut RunnerAttempt,
4394        outcome: AttemptOutcome,
4395    ) -> Result<(), LifecycleError> {
4396        attempt
4397            .conclude(outcome.clone(), self.ports.clock.now())
4398            .map_err(|_| LifecycleError::Transition)?;
4399        self.record(attempt)?;
4400        self.ports.events.emit(AttemptEvent::Concluded {
4401            attempt: attempt.id,
4402            outcome: OutcomeKind::of(&outcome),
4403        });
4404        Ok(())
4405    }
4406
4407    /// Clean a concluded attempt, tolerating a quarantined persistent slot.
4408    ///
4409    /// The quarantine is reported and stepped over rather than raised, because
4410    /// raising it aborts the whole pass: on the startup path that leaves
4411    /// `recovery_complete` false and stops the host launching anything, which is
4412    /// the opposite of `04-security-recovery.md`'s "it does not count as active
4413    /// host capacity" and "recovery retries the same cleanup". The attempt keeps
4414    /// its state, so it keeps its slot lease and its directory, and the next
4415    /// pass — [`crate::reconcile::Reconciler`]'s terminal sweep on every poll,
4416    /// or the next startup — attempts exactly the same cleanup again.
4417    ///
4418    /// A persistent-slot quarantine and an ephemeral directory still held by a
4419    /// late process are tolerated. A journal failure or a package lease that
4420    /// cannot be released still propagates: those are not one workspace's
4421    /// problem.
4422    ///
4423    /// A tolerated failure is retried with per-attempt backoff: until it is
4424    /// due again the attempt is skipped silently rather than re-tried and
4425    /// re-reported on every pass.
4426    fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4427        let now = self.ports.clock.now();
4428        if !self.cleanup_retries().is_due(attempt.id, now) {
4429            return Ok(());
4430        }
4431        let reason = match self.clean_attempt(attempt) {
4432            Err(LifecycleError::SlotQuarantined { class, .. }) => class,
4433            Err(LifecycleError::WorkspaceCleanupDeferred) => {
4434                failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
4435            }
4436            Err(LifecycleError::EnvironmentCleanupDeferred) => {
4437                "isolated_environment_cleanup_deferred"
4438            }
4439            other => {
4440                if other.is_ok() {
4441                    self.cleanup_retries().succeeded(attempt.id);
4442                }
4443                return other;
4444            }
4445        };
4446        self.cleanup_retries().failed(attempt.id, now);
4447        self.ports
4448            .reconcile_events
4449            .emit(LifecycleEvent::AttemptCleanFailed {
4450                policy: attempt.policy_id,
4451                attempt: attempt.id,
4452                reason,
4453            });
4454        Ok(())
4455    }
4456
4457    fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
4458        self.cleanup_retries
4459            .lock()
4460            .unwrap_or_else(std::sync::PoisonError::into_inner)
4461    }
4462
4463    fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4464        if !attempt.execution().is_native() {
4465            return self.clean_isolated_attempt(attempt);
4466        }
4467        match self
4468            .ports
4469            .processes
4470            .destroy(attempt)
4471            .map_err(LifecycleError::Failed)?
4472        {
4473            ProviderDestroy::Destroyed => {}
4474            ProviderDestroy::Deferred(reason) => {
4475                return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
4476            }
4477        }
4478        let outcome = attempt
4479            .outcome()
4480            .cloned()
4481            .ok_or(LifecycleError::Transition)?;
4482        self.preserve_diagnostics(attempt, &outcome)?;
4483        self.scrub_workspace(attempt)?;
4484        self.ports
4485            .packages
4486            .release(attempt.id)
4487            .map_err(LifecycleError::Failed)?;
4488        attempt
4489            .clean(self.ports.clock.now())
4490            .map_err(|_| LifecycleError::Transition)?;
4491        self.record(attempt)?;
4492        let kind = OutcomeKind::of(&outcome);
4493        self.ports.events.emit(AttemptEvent::Cleaned {
4494            attempt: attempt.id,
4495            outcome: kind,
4496        });
4497        self.ports
4498            .reconcile_events
4499            .emit(LifecycleEvent::AttemptCleaned {
4500                policy: attempt.policy_id,
4501                attempt: attempt.id,
4502                outcome: kind,
4503            });
4504        Ok(())
4505    }
4506
4507    fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4508        if attempt.state() != AttemptState::Destroying {
4509            attempt
4510                .begin_destroy(self.ports.clock.now())
4511                .map_err(|_| LifecycleError::Transition)?;
4512            self.record(attempt)?;
4513        }
4514        let provider = self.ports.processes.as_ref();
4515        let allocation_resources = provider.enumerate_owned(self.host_id);
4516        let matching_allocation = allocation_resources
4517            .iter()
4518            .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
4519            .collect::<Vec<_>>();
4520        if matching_allocation.len() > 1
4521            || matching_allocation
4522                .iter()
4523                .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
4524        {
4525            self.ports
4526                .events
4527                .emit(AttemptEvent::QuarantinedOwnedResource {
4528                    attempt: attempt.id,
4529                });
4530            tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
4531            return Err(self.defer_environment_cleanup(attempt)?);
4532        }
4533        let has_identity = matches!(
4534            attempt.execution(),
4535            AttemptExecution::Isolated {
4536                environment_id: Some(_),
4537                ..
4538            }
4539        );
4540        if !has_identity
4541            && allocation_resources
4542                .iter()
4543                .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
4544        {
4545            return Err(self.defer_environment_cleanup(attempt)?);
4546        }
4547        let current = if has_identity {
4548            provider.inspect(attempt)
4549        } else {
4550            // A prepare effect may have succeeded before its identity could be
4551            // journalled. Empty enumeration alone is not proof of absence.
4552            provider.recover(attempt)
4553        };
4554        let current = match current {
4555            Ok(state) => state,
4556            Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
4557        };
4558        if !has_identity && current != EnvironmentState::Missing {
4559            return Err(self.defer_environment_cleanup(attempt)?);
4560        }
4561        if current != EnvironmentState::Missing {
4562            if !matches!(provider.owns(attempt), Ok(true)) {
4563                self.ports
4564                    .events
4565                    .emit(AttemptEvent::QuarantinedOwnedResource {
4566                        attempt: attempt.id,
4567                    });
4568                tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4569                return Err(self.defer_environment_cleanup(attempt)?);
4570            }
4571            if matches!(
4572                current,
4573                EnvironmentState::Starting | EnvironmentState::Running
4574            ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
4575            {
4576                return Err(self.defer_environment_cleanup(attempt)?);
4577            }
4578            if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
4579                return Err(self.defer_environment_cleanup(attempt)?);
4580            }
4581            if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
4582                return Err(self.defer_environment_cleanup(attempt)?);
4583            }
4584        }
4585        if provider
4586            .enumerate_owned(self.host_id)
4587            .iter()
4588            .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
4589        {
4590            return Err(self.defer_environment_cleanup(attempt)?);
4591        }
4592        let outcome = attempt
4593            .outcome()
4594            .cloned()
4595            .ok_or(LifecycleError::Transition)?;
4596        // Provider diagnostics are a closed vocabulary and bounded before they
4597        // can enter tracing, even if an adapter returns an unbounded vector.
4598        for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
4599            tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
4600        }
4601        self.preserve_diagnostics(attempt, &outcome)?;
4602        self.scrub_workspace(attempt)?;
4603        self.ports
4604            .packages
4605            .release(attempt.id)
4606            .map_err(LifecycleError::Failed)?;
4607        attempt
4608            .clean_isolated(self.ports.clock.now())
4609            .map_err(|_| LifecycleError::Transition)?;
4610        self.record(attempt)?;
4611        self.ports.events.emit(AttemptEvent::Cleaned {
4612            attempt: attempt.id,
4613            outcome: OutcomeKind::of(&outcome),
4614        });
4615        self.ports
4616            .reconcile_events
4617            .emit(LifecycleEvent::AttemptCleaned {
4618                policy: attempt.policy_id,
4619                attempt: attempt.id,
4620                outcome: OutcomeKind::of(&outcome),
4621            });
4622        Ok(())
4623    }
4624
4625    fn defer_environment_cleanup(
4626        &self,
4627        attempt: &mut RunnerAttempt,
4628    ) -> Result<LifecycleError, LifecycleError> {
4629        attempt
4630            .defer_cleanup(self.ports.clock.now())
4631            .map_err(|_| LifecycleError::Transition)?;
4632        self.record(attempt)?;
4633        Ok(LifecycleError::EnvironmentCleanupDeferred)
4634    }
4635
4636    /// Undo an attempt's placement by the algorithm its journalled workspace
4637    /// kind makes legal (`02-target-architecture.md`, "Cleanup and recovery").
4638    ///
4639    /// The dispatch is on the *journal*, never on what the directory looks like
4640    /// now. A slot whose `_work` was replaced by a junction is still scrubbed as
4641    /// a slot rather than removed whole, and a disposable directory that happens
4642    /// to contain a `_work` still goes whole rather than being spared: the
4643    /// workspace kind is immutable precisely so that the shape of a directory a
4644    /// workflow can write to cannot choose the algorithm applied to it.
4645    fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4646        #[cfg(test)]
4647        {
4648            // The two contamination mutants `f1` drives the security gates with.
4649            // They sit in front of the dispatch rather than inside one arm so
4650            // that a skipped cleanup is equally observable in both modes.
4651            if matches!(
4652                std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
4653                Ok("skip_workspace_cleanup" | "reuse_job_workspace")
4654            ) {
4655                return Ok(());
4656            }
4657        }
4658        match attempt.workspace() {
4659            AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
4660                Ok(()) => Ok(()),
4661                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4662                Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
4663            },
4664            AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
4665        }
4666    }
4667
4668    /// Retain exactly `_work` and prove everything else is gone.
4669    ///
4670    /// The seven ordered checks of `04-security-recovery.md`, "Safe path
4671    /// handling": the journalled root and slot, a surviving policy's agreement,
4672    /// lexical and canonical containment, a literal enumeration, the one-entry
4673    /// allowlist, and a verification pass before the caller releases the package
4674    /// lease and marks the attempt cleaned. Any of them may refuse, and a
4675    /// refusal removes nothing after it.
4676    ///
4677    /// A slot directory that is already gone is not a refusal. There is nothing
4678    /// to scrub and nothing to prove absent, which is the same tolerance the
4679    /// disposable arm has always had for a runtime that vanished under it.
4680    fn scrub_persistent_slot(
4681        &self,
4682        attempt: &RunnerAttempt,
4683        slot: NonZeroU16,
4684    ) -> Result<(), LifecycleError> {
4685        // The policy is read from the journal, never inferred from the
4686        // directory tree, and its absence is legal: `Reconciler` cleans every
4687        // concluded attempt whether or not its policy still exists, and this
4688        // one's root is already journalled on the attempt itself.
4689        let configured = self
4690            .ports
4691            .store
4692            .policy(attempt.policy_id)
4693            .map_err(|_| LifecycleError::Journal)?
4694            .and_then(|policy| match policy.workspace_policy() {
4695                WorkspacePolicy::Persistent { root } => Some(root.clone()),
4696                WorkspacePolicy::Ephemeral => None,
4697            });
4698        let runtime = attempt.runtime_path();
4699        self.quarantine_on_refusal(
4700            attempt,
4701            verify_journalled_slot(runtime, slot, configured.as_ref())
4702                .and_then(|()| slot_is_present(runtime))
4703                .and_then(|present| {
4704                    if present {
4705                        scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
4706                    } else {
4707                        Ok(())
4708                    }
4709                }),
4710        )
4711    }
4712
4713    /// Turn a slot refusal into the error that keeps the attempt uncleaned.
4714    ///
4715    /// The warning is emitted here, at the one place a quarantine is minted, so
4716    /// that both routes out of cleanup carry it: the reconciler's terminal sweep,
4717    /// which receives the error through the launcher port, and
4718    /// [`Self::clean_or_quarantine`], which swallows it to keep the pass alive.
4719    /// Everything logged is either a path this product configured or a constant
4720    /// from this module — see [`SlotQuarantine`] for why that matters.
4721    fn quarantine_on_refusal(
4722        &self,
4723        attempt: &RunnerAttempt,
4724        outcome: Result<(), SlotQuarantine>,
4725    ) -> Result<(), LifecycleError> {
4726        let Err(quarantine) = outcome else {
4727            return Ok(());
4728        };
4729        let detail = quarantine.to_string();
4730        tracing::warn!(
4731            attempt = %attempt.id,
4732            policy = %attempt.policy_id,
4733            slot = attempt.workspace().slot_number(),
4734            refusal = quarantine.refusal.class(),
4735            "{detail}"
4736        );
4737        Err(LifecycleError::SlotQuarantined {
4738            class: quarantine.refusal.class(),
4739            detail,
4740        })
4741    }
4742
4743    fn preserve_diagnostics(
4744        &self,
4745        attempt: &RunnerAttempt,
4746        outcome: &AttemptOutcome,
4747    ) -> Result<(), LifecycleError> {
4748        fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
4749            LifecycleError::Failed(FailureReason::Other(
4750                "diagnostics directory could not be created".into(),
4751            ))
4752        })?;
4753        // Intentionally constructed from typed local facts, not runner output.
4754        // Raw child output can contain workflow secrets and is never copied.
4755        let reason_kind = match outcome {
4756            AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4757            _ => "none",
4758        };
4759        let diagnostic = format!(
4760            "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4761            attempt.id,
4762            attempt.policy_id,
4763            OutcomeKind::of(outcome).as_str(),
4764            reason_kind,
4765        );
4766        fs::write(
4767            self.diagnostics_root.join(format!("{}.log", attempt.id)),
4768            diagnostic,
4769        )
4770        .map_err(|_| {
4771            LifecycleError::Failed(FailureReason::Other(
4772                "redacted diagnostics could not be preserved".into(),
4773            ))
4774        })
4775    }
4776
4777    async fn materialize_with_retry(
4778        &self,
4779        policy: &ScalePolicy,
4780        attempt: &RunnerAttempt,
4781        timings: &mut PackageTimings,
4782    ) -> Result<RunnerVersion, FailureReason> {
4783        let mut issued = 0_u32;
4784        loop {
4785            issued = issued.saturating_add(1);
4786            match self.ports.packages.materialize(attempt, timings).await {
4787                Ok(version) => return Ok(version),
4788                Err(reason)
4789                    if package_failure_is_terminal(&reason)
4790                        || issued >= self.retry.max_attempts.max(1) =>
4791                {
4792                    return Err(reason);
4793                }
4794                Err(reason) => {
4795                    if !self.ports.demand.persists(policy.id).await {
4796                        return Err(reason);
4797                    }
4798                    let delay = self.retry.delay(issued);
4799                    self.ports.events.emit(AttemptEvent::Retry {
4800                        attempt: attempt.id,
4801                        operation: "package_materialization",
4802                        delay,
4803                    });
4804                    self.ports.delay.wait(delay).await;
4805                    if !self.ports.demand.persists(policy.id).await {
4806                        return Err(reason);
4807                    }
4808                }
4809            }
4810        }
4811    }
4812
4813    async fn register_with_retry(
4814        &self,
4815        policy: &ScalePolicy,
4816        attempt: AttemptId,
4817        request: &JitRunnerRequest,
4818    ) -> Result<JitRegistration, LifecycleError> {
4819        let mut issued = 0_u32;
4820        loop {
4821            issued = issued.saturating_add(1);
4822            match self
4823                .ports
4824                .github
4825                .register(&policy.target, request, &self.cancel)
4826                .await
4827            {
4828                Ok(registration) => return Ok(registration),
4829                Err(error) if error.terminal => {
4830                    return Err(LifecycleError::Failed(error.reason));
4831                }
4832                Err(error) => {
4833                    if issued >= self.retry.max_attempts.max(1)
4834                        || !self.ports.demand.persists(policy.id).await
4835                    {
4836                        return Err(LifecycleError::Failed(error.reason));
4837                    }
4838                    let delay = error
4839                        .retry_after
4840                        .unwrap_or_else(|| self.retry.delay(issued));
4841                    self.ports.events.emit(AttemptEvent::Retry {
4842                        attempt,
4843                        operation: "jit_request",
4844                        delay,
4845                    });
4846                    self.ports.delay.wait(delay).await;
4847                    if !self.ports.demand.persists(policy.id).await {
4848                        return Err(LifecycleError::Failed(error.reason));
4849                    }
4850                }
4851            }
4852        }
4853    }
4854
4855    /// Where one attempt's files go, decided while the host allocation lock is
4856    /// held and before anything external happens.
4857    ///
4858    /// The branch is on the *repository's configured* workspace policy, so an
4859    /// organization policy and an ephemeral repository never reach slot
4860    /// selection at all: a persistent policy is unrepresentable for an
4861    /// organization target (D7, refused by `WorkspacePolicy::permitted_for` in
4862    /// both the constructor and the loader), and an ephemeral repository takes
4863    /// the disposable arm that existed before slots did.
4864    fn allocate_workspace(
4865        &self,
4866        policy: &ScalePolicy,
4867        id: AttemptId,
4868    ) -> Result<Placement, LifecycleError> {
4869        let placement = match policy.workspace_policy() {
4870            // Precedence (`02-target-architecture.md`): the repository's
4871            // persistent root is selected *before* the host root, which is why
4872            // this arm is first and why it never makes resolving the host
4873            // default a precondition of its own success.
4874            WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4875            WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4876        };
4877        // Here rather than inside the two arms, so that every path a root can
4878        // accept clears the record and none can be forgotten.
4879        if placement.is_ok() {
4880            self.root_accepted(policy.id);
4881        }
4882        placement
4883    }
4884
4885    /// `Host.runner_root_override`, read from the journal.
4886    ///
4887    /// Separated from [`Self::effective_host_root`] so that the two failures it
4888    /// folds together stay apart: an unreadable or missing host row is a journal
4889    /// problem and is always fatal, while an unresolvable *platform default* is
4890    /// only fatal to a placement that actually needs the host root.
4891    fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4892        let host = self
4893            .ports
4894            .store
4895            .host(self.host_id)
4896            .map_err(|_| LifecycleError::Journal)?
4897            .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4898        Ok(host.runner_root_override.clone())
4899    }
4900
4901    /// `Host.runner_root_override`, or the platform default when that resolves;
4902    /// `None` when neither does. Unlike [`Self::effective_host_root`] it records
4903    /// no refusal, for callers to whom an unresolvable root is not a failure.
4904    fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4905        Ok(self
4906            .configured_host_root()?
4907            .or_else(|| default_runner_root(&self.app_paths).ok()))
4908    }
4909
4910    /// `Host.runner_root_override`, or the platform default standing in for it.
4911    ///
4912    /// Takes the policy because an unresolvable default is a refusal like any
4913    /// other, and the record it leaves is that policy's.
4914    fn effective_host_root(
4915        &self,
4916        policy: &ScalePolicy,
4917    ) -> Result<LocalAbsolutePath, LifecycleError> {
4918        match self.configured_host_root()? {
4919            Some(configured) => Ok(configured),
4920            None => default_runner_root(&self.app_paths).map_err(|error| {
4921                // No root resolved, so there is no path to name but the one the
4922                // platform would have produced.
4923                self.root_refused(policy.id, "the platform default runner root", error)
4924            }),
4925        }
4926    }
4927
4928    /// Turns a runner-root refusal into a failure, and leaves the sentence
4929    /// somewhere an operator can read it.
4930    ///
4931    /// Per policy, because the policies on a host do not share a fate: one with
4932    /// its own persistent root places runners while another on a withheld
4933    /// volume places none, and a host-wide record would have the first clear
4934    /// the second's on the same pass.
4935    ///
4936    /// The recording is best-effort and its failure is deliberately swallowed:
4937    /// this runs on the path that is already failing, and a host that cannot
4938    /// write a diagnostic file must still report the refusal it came to report.
4939    /// `service status` says so itself when the file is unreadable.
4940    fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4941        let _ = runner_manager_platform::service::record_runner_root_refusal(
4942            &self.app_paths,
4943            &policy.to_string(),
4944            self.ports.clock.now(),
4945            error.kind(),
4946            root,
4947            &error.to_string(),
4948        );
4949        root_failure(error)
4950    }
4951
4952    /// Clears that policy's record, because its root accepted a placement.
4953    ///
4954    /// Called on every successful placement rather than only after a failure:
4955    /// the daemon that recovers is often not the process that failed -- a
4956    /// self-update restarts it -- so "clear it if we wrote it" would leave a
4957    /// stale note on `service status` for as long as the host ran.
4958    fn root_accepted(&self, policy: PolicyId) {
4959        let _ = runner_manager_platform::service::clear_runner_root_refusal(
4960            &self.app_paths,
4961            &policy.to_string(),
4962        );
4963    }
4964
4965    /// D3's disposable placement: a unique child of the effective host root,
4966    /// removed whole on cleanup. `c1`'s behaviour, moved behind the branch.
4967    fn allocate_disposable(
4968        &self,
4969        policy: &ScalePolicy,
4970        id: AttemptId,
4971    ) -> Result<Placement, LifecycleError> {
4972        let effective_root = self.effective_host_root(policy)?;
4973        RootPreflight::new(&self.app_paths)
4974            .check(&RootOwner::Host, &effective_root)
4975            .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4976        let runtime = effective_root.as_path().join({
4977            #[cfg(test)]
4978            {
4979                if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4980                    == Ok("reuse_job_workspace")
4981                {
4982                    "mutant-shared-workspace".to_owned()
4983                } else {
4984                    workspace_name(id)
4985                }
4986            }
4987            #[cfg(not(test))]
4988            {
4989                workspace_name(id)
4990            }
4991        });
4992        fs::create_dir_all(&runtime)
4993            .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4994        Ok(Placement {
4995            runtime,
4996            workspace: AttemptWorkspace::Ephemeral,
4997        })
4998    }
4999
5000    /// D4/D5's persistent placement: the lowest free `sN` under the repository's
5001    /// configured root.
5002    ///
5003    /// Steps 1 to 6 of `02-target-architecture.md`, "Slot allocation", in order;
5004    /// step 7 is the journal write [`Self::record_allocation`] owns. All of them
5005    /// run under the host allocation lock, because [`Self::reserve_attempt`] is
5006    /// reachable only through a `LaunchRequest` and that carries the guard.
5007    ///
5008    /// **The filesystem is never consulted to decide which slots are taken**
5009    /// (invariant 6). The leases come from the journal; the directory is
5010    /// inspected only to decide whether *this* slot is safe to reuse.
5011    fn allocate_persistent_slot(
5012        &self,
5013        policy: &ScalePolicy,
5014        root: &LocalAbsolutePath,
5015    ) -> Result<Placement, LifecycleError> {
5016        // 1-4. The lowest positive slot no uncleaned attempt holds, refused
5017        // above the policy ceiling.
5018        let ceiling = policy.max_capacity().ok_or_else(|| {
5019            LifecycleError::Failed(FailureReason::Other(
5020                "a persistent workspace needs the policy's max_capacity to bound its slots"
5021                    .to_string(),
5022            ))
5023        })?;
5024        let leases = self
5025            .ports
5026            .store
5027            .slot_leases_for_policy(policy.id)
5028            .map_err(|_| LifecycleError::Journal)?;
5029        let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
5030            LifecycleError::Failed(FailureReason::Other(format!(
5031                "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
5032                 not been cleaned, so no slot is free; raise the repository's max capacity, or \
5033                 finish cleaning a concluded attempt",
5034                policy.target
5035            )))
5036        })?;
5037        let workspace = AttemptWorkspace::persistent_slot(slot);
5038        let name = workspace
5039            .slot_directory_name()
5040            .expect("a persistent allocation names its slot directory");
5041
5042        // The operational preflight, for the reasons the host root gets one: a
5043        // root that is remote, unwritable, or overlapping application data has
5044        // to fail before a directory is created rather than after. The host
5045        // root is registered only as something *not* to overlap; a host default
5046        // that cannot be resolved is a host-root problem and does not block a
5047        // repository that configured a root of its own.
5048        //
5049        // Only *that* failure is tolerated. An unreadable host row is a journal
5050        // failure and propagates, because silently continuing would drop the
5051        // overlap check entirely and accept a repository root that sits inside
5052        // the host root — the pair `RootPreflight` exists to refuse.
5053        let host_root = self.resolvable_host_root()?;
5054        let mut preflight = RootPreflight::new(&self.app_paths);
5055        if let Some(host_root) = host_root {
5056            preflight = preflight.against(RootOwner::Host, host_root);
5057        }
5058        let checked = preflight
5059            .check(&RootOwner::Repository(policy.target.to_string()), root)
5060            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
5061        if let Some(leaf) = checked.leaf_to_create() {
5062            fs::create_dir(leaf).map_err(|source| {
5063                LifecycleError::Failed(FailureReason::Other(format!(
5064                    "the persistent workspace root {} could not be created: {source}",
5065                    leaf.display()
5066                )))
5067            })?;
5068        }
5069
5070        // 5-6. `<root>/sN`, contained lexically by construction, then created or
5071        // validated, then contained canonically now that it resolves, and only
5072        // then accepted for reuse.
5073        let slot_path = runner_root::derive_child(root, &name)
5074            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
5075        create_or_validate_slot(slot_path.as_path())?;
5076        runner_root::verify_containment(root, &slot_path)
5077            .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
5078        accept_reusable_slot(slot_path.as_path())?;
5079        Ok(Placement {
5080            runtime: slot_path.as_path().to_path_buf(),
5081            workspace,
5082        })
5083    }
5084
5085    /// The first journal write of an attempt, where a duplicate slot lease is
5086    /// still possible and has to be reported as itself.
5087    ///
5088    /// [`Self::record`] flattens every store failure into
5089    /// [`LifecycleError::Journal`], which is right for a state transition and
5090    /// wrong here: the partial unique index
5091    /// `one_uncleaned_persistent_attempt_per_slot` is the final race fence
5092    /// (`04-security-recovery.md`, "two attempts use one slot concurrently"),
5093    /// and an operator who reaches it needs to read that rather than "attempt
5094    /// journal operation failed". Nothing was written, so the caller returns
5095    /// without concluding an attempt that is not in the journal.
5096    fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
5097        match self.ports.store.record_attempt(attempt) {
5098            Ok(()) => {
5099                self.ports.events.emit(AttemptEvent::State {
5100                    attempt: attempt.id,
5101                    state: attempt.state(),
5102                });
5103                Ok(())
5104            }
5105            Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
5106                FailureReason::Other(error.to_string()),
5107            )),
5108            Err(_) => Err(LifecycleError::Journal),
5109        }
5110    }
5111
5112    /// Under the allocation lock: what no other launch may decide at the same
5113    /// time. The rest is [`Self::finish_attempt`], outside it.
5114    fn reserve_attempt(
5115        &self,
5116        policy: &ScalePolicy,
5117        allocation_guard: &AllocationGuard,
5118    ) -> Result<RunnerAttempt, LifecycleError> {
5119        // What a prune leaves costs only disk, and a tree it cannot remove is one
5120        // the launch's install may yet repair: not a reason to refuse the launch.
5121        if let Err(error) = self.prune_under_allocation_lock(allocation_guard) {
5122            tracing::warn!(
5123                event = "package_prune_failed",
5124                reason = failure_reason_kind(&error.reason()),
5125            );
5126        }
5127        self.allocate_attempt(policy)
5128    }
5129
5130    async fn finish_attempt(
5131        &self,
5132        policy: &ScalePolicy,
5133        attempt: RunnerAttempt,
5134        waits: ReservationWaits,
5135    ) -> Result<RunnerAttempt, LifecycleError> {
5136        let id = attempt.id;
5137        let _bringing_up = BroughtUpOnDrop {
5138            attempts: &self.bringing_up,
5139            id,
5140        };
5141        let mut stopwatch = LaunchStopwatch::new(waits);
5142        let result = self.launch_steps(policy, attempt, &mut stopwatch).await;
5143        stopwatch.finish();
5144        self.ports.events.emit(AttemptEvent::Launched {
5145            attempt: id,
5146            started: result.is_ok(),
5147            timings: stopwatch.timings,
5148        });
5149        result
5150    }
5151
5152    /// Place and journal a new attempt: everything before its first external
5153    /// effect, and so before there is anything to time.
5154    fn allocate_attempt(&self, policy: &ScalePolicy) -> Result<RunnerAttempt, LifecycleError> {
5155        if !*self
5156            .recovery_complete
5157            .lock()
5158            .map_err(|_| LifecycleError::Journal)?
5159        {
5160            return Err(LifecycleError::RecoveryIncomplete);
5161        }
5162        let capability = self.ports.processes.probe(policy);
5163        if capability != ProviderCapability::Ready {
5164            return Err(LifecycleError::Failed(capability.refusal()));
5165        }
5166        let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
5167            LifecycleError::Failed(if policy.execution_policy().is_native() {
5168                reason
5169            } else {
5170                safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
5171            })
5172        })?;
5173        if policy.execution_policy().is_native() != resolved.is_none() {
5174            return Err(LifecycleError::Failed(FailureReason::Other(
5175                "provider resolution mismatch".into(),
5176            )));
5177        }
5178        if let Some(resolution) = &resolved {
5179            if resolution.provider_kind == Backend::Auto {
5180                return Err(LifecycleError::Failed(FailureReason::Other(
5181                    "provider resolution mismatch".into(),
5182                )));
5183            }
5184            AttemptExecution::Isolated {
5185                provider_kind: resolution.provider_kind,
5186                environment_id: None,
5187                resolved_image: resolution.image.clone(),
5188                generation: "generation-check".into(),
5189            }
5190            .validate()
5191            .map_err(|_| {
5192                LifecycleError::Failed(FailureReason::Other(
5193                    "resolved image is incompatible".into(),
5194                ))
5195            })?;
5196        }
5197        policy
5198            .routing_labels()
5199            .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
5200        let id = AttemptId::new_random();
5201        let placement = self.allocate_workspace(policy, id)?;
5202        let mut attempt = RunnerAttempt::allocate_in(
5203            id,
5204            policy.id,
5205            placement.runtime,
5206            placement.workspace,
5207            self.ports.clock.now(),
5208        );
5209        if let Some(resolution) = &resolved {
5210            attempt
5211                .allocate_execution(AttemptExecution::Isolated {
5212                    provider_kind: resolution.provider_kind,
5213                    environment_id: None,
5214                    resolved_image: resolution.image.clone(),
5215                    generation: uuid::Uuid::new_v4().to_string(),
5216                })
5217                .map_err(|_| LifecycleError::Transition)?;
5218        }
5219        // This is deliberately the first effect after directory allocation, and
5220        // for a persistent attempt it is also what makes the slot lease durable
5221        // before any package or GitHub effect
5222        // (`02-target-architecture.md`, "Slot allocation", step 7).
5223        self.bringing_up
5224            .lock()
5225            .map_err(|_| LifecycleError::Journal)?
5226            .insert(id);
5227        if let Err(error) = self.record_allocation(&attempt) {
5228            self.bringing_up
5229                .lock()
5230                .map_err(|_| LifecycleError::Journal)?
5231                .remove(&id);
5232            return Err(error);
5233        }
5234        Ok(attempt)
5235    }
5236
5237    async fn launch_steps(
5238        &self,
5239        policy: &ScalePolicy,
5240        mut attempt: RunnerAttempt,
5241        stopwatch: &mut LaunchStopwatch,
5242    ) -> Result<RunnerAttempt, LifecycleError> {
5243        let id = attempt.id;
5244        let isolated = !attempt.execution().is_native();
5245        let Some(labels) = policy.routing_labels() else {
5246            return self.fail_launch(&mut attempt, FailureReason::JitRequestFailed);
5247        };
5248        stopwatch.begin(LaunchStep::Turn);
5249        // Never closed; held through the preparation below.
5250        let turn = self.turns.acquire().await.ok();
5251        stopwatch.begin(LaunchStep::Materialize);
5252        let package_timings = &mut stopwatch.timings.package;
5253        let version = match self
5254            .materialize_with_retry(policy, &attempt, package_timings)
5255            .await
5256        {
5257            Ok(version) => version,
5258            Err(reason) => return self.fail_launch(&mut attempt, reason),
5259        };
5260        self.versions
5261            .lock()
5262            .map_err(|_| LifecycleError::Journal)?
5263            .insert(id, version);
5264
5265        if isolated {
5266            attempt
5267                .begin_prepare(self.ports.clock.now())
5268                .map_err(|_| LifecycleError::Transition)?;
5269            self.record(&attempt)?;
5270        }
5271        stopwatch.begin(LaunchStep::Prepare);
5272        // Off the async thread: a provider may pull an image, and every target
5273        // of this host shares that thread.
5274        let preparing = {
5275            let (processes, attempt, policy) = (
5276                Arc::clone(&self.ports.processes),
5277                attempt.clone(),
5278                policy.clone(),
5279            );
5280            tokio::task::spawn_blocking(move || processes.prepare(&attempt, &policy)).await
5281        };
5282        drop(turn);
5283        let prepared = match preparing.unwrap_or(Err(FailureReason::ProcessStartFailed)) {
5284            Ok(prepared) => prepared,
5285            Err(reason) => {
5286                let safe_reason = if isolated {
5287                    safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
5288                } else {
5289                    reason
5290                };
5291                return self.fail_launch(&mut attempt, safe_reason);
5292            }
5293        };
5294        if isolated {
5295            let Some(identity) = prepared.identity() else {
5296                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5297                    IsolationProviderFailure::OwnershipMismatch,
5298                )));
5299            };
5300            if !identity_matches_intent(self.host_id, &attempt, identity) {
5301                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5302                    IsolationProviderFailure::OwnershipMismatch,
5303                )));
5304            }
5305            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
5306                unreachable!()
5307            };
5308            attempt
5309                .prepared_environment(environment_id.clone())
5310                .map_err(|_| LifecycleError::Transition)?;
5311            attempt
5312                .mark_prepared(self.ports.clock.now())
5313                .map_err(|_| LifecycleError::Transition)?;
5314            self.record(&attempt)?;
5315        }
5316
5317        stopwatch.begin(LaunchStep::Register);
5318        let jit_request =
5319            JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
5320        let registration = match self.register_with_retry(policy, id, &jit_request).await {
5321            Ok(registration) => registration,
5322            Err(error) => return self.fail_launch(&mut attempt, error.reason()),
5323        };
5324        stopwatch.begin(LaunchStep::Spawn);
5325        let runner_id = registration.runner().id;
5326        write_runner_id(attempt.runtime_path(), runner_id)?;
5327        attempt
5328            .jit_received(self.ports.clock.now())
5329            .map_err(|_| LifecycleError::Transition)?;
5330        self.record(&attempt)?;
5331        let config = registration.into_config();
5332        if isolated {
5333            let started = self
5334                .ports
5335                .processes
5336                .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
5337                .map_err(|failure| {
5338                    LifecycleError::Failed(safe_isolation_reason(
5339                        failure.reason,
5340                        IsolationProviderFailure::RuntimeOperationFailed,
5341                    ))
5342                })?;
5343            if !identity_matches_intent(self.host_id, &attempt, &started) {
5344                return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5345                    IsolationProviderFailure::OwnershipMismatch,
5346                )));
5347            }
5348            attempt
5349                .started_isolated(self.ports.clock.now())
5350                .map_err(|_| LifecycleError::Transition)?;
5351            self.record(&attempt)?;
5352            return Ok(attempt);
5353        }
5354        let mut issued = 0_u32;
5355        let pid = loop {
5356            issued = issued.saturating_add(1);
5357            match self.ports.processes.start(
5358                prepared.clone(),
5359                &attempt,
5360                OneTimeJitHandoff::new(&config),
5361            ) {
5362                Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
5363                Ok(EnvironmentIdentity::Isolated { .. }) => {
5364                    // No isolated launch sequence is wired in this task. Keep
5365                    // the nonterminal JIT journal as a capacity fence rather
5366                    // than treating an unexpected resource as a native PID.
5367                    return Err(LifecycleError::Failed(FailureReason::Other(
5368                        "unexpected isolated environment identity".into(),
5369                    )));
5370                }
5371                Err(error) => {
5372                    if let Some(pid) = error.live_pid {
5373                        attempt
5374                            .started(pid, self.ports.clock.now())
5375                            .map_err(|_| LifecycleError::Transition)?;
5376                        self.record(&attempt)?;
5377                        return Err(LifecycleError::Failed(error.reason));
5378                    }
5379                    if !error.retryable
5380                        || issued >= self.retry.max_attempts.max(1)
5381                        || !self.ports.demand.persists(policy.id).await
5382                    {
5383                        return self.fail_launch(&mut attempt, error.reason);
5384                    }
5385                    let delay = self.retry.delay(issued);
5386                    self.ports.events.emit(AttemptEvent::Retry {
5387                        attempt: attempt.id,
5388                        operation: "process_start",
5389                        delay,
5390                    });
5391                    self.ports.delay.wait(delay).await;
5392                    if !self.ports.demand.persists(policy.id).await {
5393                        return self.fail_launch(&mut attempt, error.reason);
5394                    }
5395                }
5396            }
5397        };
5398        attempt
5399            .started(pid, self.ports.clock.now())
5400            .map_err(|_| LifecycleError::Transition)?;
5401        self.record(&attempt)?;
5402        Ok(attempt)
5403    }
5404
5405    fn fail_launch<T>(
5406        &self,
5407        attempt: &mut RunnerAttempt,
5408        reason: FailureReason,
5409    ) -> Result<T, LifecycleError> {
5410        self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
5411        Err(LifecycleError::Failed(reason))
5412    }
5413
5414    /// e2's prune guard is invoked only with e1's allocation guard borrowed.
5415    /// The otherwise-unused argument is a compile-time witness of the ordering.
5416    fn prune_under_allocation_lock(&self, guard: &AllocationGuard) -> Result<(), LifecycleError> {
5417        let attempts = self
5418            .ports
5419            .store
5420            .attempts()
5421            .map_err(|_| LifecycleError::Journal)?;
5422        self.ports
5423            .packages
5424            .prune_obsolete_guarded(PruneAuthority::from_launch_request(guard), &attempts)
5425            .map_err(LifecycleError::Failed)?;
5426        #[cfg(target_os = "macos")]
5427        self.prune_abandoned_seeds(&attempts);
5428        Ok(())
5429    }
5430
5431    /// Remove the package seed from every runner root this host once placed a
5432    /// runtime in and no longer does: a changed host root override, a removed
5433    /// persistent policy, or one that moved its workspace.
5434    ///
5435    /// The roots ever used are the parents of the journal's runtime paths; the
5436    /// roots in use are the effective host root and every persistent policy's
5437    /// root. When the effective host root cannot be resolved nothing is
5438    /// removed, because a seed still in use cannot then be told apart. No
5439    /// launch clones from a seed under a root no policy uses, and it is
5440    /// best-effort: a seed left behind costs disk, never a launch.
5441    #[cfg(target_os = "macos")]
5442    fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
5443        let Ok(policies) = self.ports.store.policies() else {
5444            return;
5445        };
5446        let Ok(Some(host_root)) = self.resolvable_host_root() else {
5447            return;
5448        };
5449        let in_use: BTreeSet<PathBuf> = policies
5450            .iter()
5451            .filter_map(|policy| match policy.workspace_policy() {
5452                WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
5453                WorkspacePolicy::Ephemeral => None,
5454            })
5455            .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
5456            .collect();
5457        // Distinct parents first: the journal keeps every attempt ever made.
5458        let used: BTreeSet<&Path> = attempts
5459            .iter()
5460            .filter_map(|attempt| attempt.runtime_path().parent())
5461            .collect();
5462        for root in used.into_iter().map(canonical_or_raw) {
5463            if !in_use.contains(&root) {
5464                let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
5465            }
5466        }
5467    }
5468}
5469
5470#[cfg(target_os = "macos")]
5471fn canonical_or_raw(path: &Path) -> PathBuf {
5472    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
5473}
5474
5475#[async_trait]
5476impl RunnerLauncher for LifecycleLauncher {
5477    async fn host_capacity(
5478        &self,
5479        host: &runner_manager_domain::model::Host,
5480    ) -> Result<std::num::NonZeroU16, crate::reconcile::HostCapacityError> {
5481        self.ports
5482            .store
5483            .host(host.id)
5484            .map_err(|_| crate::reconcile::HostCapacityError::JournalUnreadable)?
5485            .map(|current| current.host_capacity)
5486            .ok_or(crate::reconcile::HostCapacityError::MissingHost)
5487    }
5488
5489    async fn supervise(
5490        &self,
5491        policy: &ScalePolicy,
5492    ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
5493        LifecycleLauncher::supervise(self, policy)
5494            .await
5495            .map_err(|error| LaunchFailure::new(error.reason()))
5496    }
5497
5498    async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
5499        self.ports.store.attempts().map_err(|_| {
5500            LaunchFailure::new(FailureReason::Other(
5501                "attempt journal could not be read".into(),
5502            ))
5503        })
5504    }
5505
5506    async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
5507        self.reserve_attempt(request.policy, request.allocation_guard)
5508            .map_err(|error| LaunchFailure::new(error.reason()))
5509    }
5510
5511    async fn finish_launch(
5512        &self,
5513        policy: &ScalePolicy,
5514        attempt: RunnerAttempt,
5515        waits: ReservationWaits,
5516    ) -> Result<RunnerAttempt, LaunchFailure> {
5517        self.finish_attempt(policy, attempt, waits)
5518            .await
5519            .map_err(|error| LaunchFailure::new(error.reason()))
5520    }
5521
5522    async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
5523        let mut attempt = self
5524            .ports
5525            .store
5526            .attempt(id)
5527            .map_err(|_| {
5528                LaunchFailure::new(FailureReason::Other(
5529                    "attempt journal could not be read".into(),
5530                ))
5531            })?
5532            .ok_or_else(|| {
5533                LaunchFailure::new(FailureReason::Other(
5534                    "attempt disappeared from the journal".into(),
5535                ))
5536            })?;
5537        self.clean_attempt(&mut attempt)
5538            .map_err(|error| LaunchFailure::new(error.reason()))?;
5539        self.cleanup_retries().succeeded(id);
5540        Ok(())
5541    }
5542}
5543
5544/// The name an attempt's runner registers with GitHub, which a
5545/// `workflow_job` webhook's `runner_name` repeats.
5546#[must_use]
5547pub fn runner_name(attempt: AttemptId) -> String {
5548    format!("runner-manager-{attempt}")
5549}
5550
5551/// How long reading a runner's diagnostics may take.
5552const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
5553
5554/// What a native runner's own diagnostics say about whether it ran a job.
5555///
5556/// `Runner.Listener` writes `_diag/Runner_*.log` when it starts, and starts
5557/// `Runner.Worker` -- which writes `_diag/Worker_*.log` -- only for a job it has
5558/// acquired. So a worker log is proof a job ran, and a listener log without one
5559/// is proof none did. With neither, the directory is not where the listener
5560/// wrote, and nothing is concluded from it. `_diag` is scrubbed with the rest of
5561/// the runtime after every attempt, persistent slots included, so a log found
5562/// here belongs to this attempt.
5563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5564enum JobEvidence {
5565    RanAJob,
5566    NoJob,
5567    Unknown,
5568}
5569
5570impl JobEvidence {
5571    fn of(runtime: &Path) -> Self {
5572        let diag = runtime.join("_diag");
5573        // Bounded: the daemon's loop runs on one thread, and a runtime on a
5574        // volume that does not answer (a hung disk, a pending macOS question)
5575        // must not stall it for longer than this.
5576        if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
5577            != runner_manager_platform::host_fitness::Responsiveness::Responds
5578        {
5579            return Self::Unknown;
5580        }
5581        let Ok(entries) = fs::read_dir(diag) else {
5582            return Self::Unknown;
5583        };
5584        let mut listener = false;
5585        for entry in entries.flatten() {
5586            let name = entry.file_name();
5587            let name = name.to_string_lossy();
5588            if !name.ends_with(".log") {
5589                continue;
5590            }
5591            if name.starts_with("Worker_") {
5592                return Self::RanAJob;
5593            }
5594            listener |= name.starts_with("Runner_");
5595        }
5596        if listener { Self::NoJob } else { Self::Unknown }
5597    }
5598}
5599
5600/// The attempt's GitHub runner id: journalled, observed, or from the sidecar
5601/// written at registration.
5602fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
5603    attempt
5604        .github_runner_id()
5605        .or(observed)
5606        .or_else(|| read_runner_id(attempt.runtime_path()))
5607}
5608
5609fn read_runner_id(runtime: &Path) -> Option<u64> {
5610    fs::read_to_string(runtime.join(RUNNER_ID_FILE))
5611        .ok()?
5612        .trim()
5613        .parse()
5614        .ok()
5615}
5616
5617fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
5618    let target = runtime.join(RUNNER_ID_FILE);
5619    if let Some(existing) = read_runner_id(runtime) {
5620        return (existing == runner_id)
5621            .then_some(())
5622            .ok_or(LifecycleError::Journal);
5623    }
5624    let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
5625    write_durable_file(&temporary, runner_id.to_string().as_bytes())
5626        .map_err(|_| LifecycleError::Journal)?;
5627    match fs::rename(&temporary, &target) {
5628        Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
5629        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
5630            let _ = fs::remove_file(&temporary);
5631            (read_runner_id(runtime) == Some(runner_id))
5632                .then_some(())
5633                .ok_or(LifecycleError::Journal)
5634        }
5635        Err(_) => {
5636            let _ = fs::remove_file(&temporary);
5637            Err(LifecycleError::Journal)
5638        }
5639    }
5640}
5641
5642fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
5643    let mut options = fs::OpenOptions::new();
5644    options.create(true).truncate(true).write(true);
5645    // Never through a link: the attempt directory belongs to the runner's
5646    // account when that is not this process's, and a link a job left at this
5647    // name would otherwise turn the write onto any file this process can reach.
5648    #[cfg(unix)]
5649    std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NOFOLLOW);
5650    let mut file = options.open(path)?;
5651    file.write_all(bytes)?;
5652    file.sync_all()?;
5653    let parent = path.parent().ok_or_else(|| {
5654        std::io::Error::new(
5655            std::io::ErrorKind::InvalidInput,
5656            "file has no parent directory",
5657        )
5658    })?;
5659    sync_directory(parent)
5660}
5661
5662#[cfg(unix)]
5663fn sync_directory(path: &Path) -> std::io::Result<()> {
5664    fs::File::open(path)?.sync_all()
5665}
5666
5667#[cfg(windows)]
5668fn sync_directory(path: &Path) -> std::io::Result<()> {
5669    use std::os::windows::fs::OpenOptionsExt;
5670
5671    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
5672    const FILE_SHARE_ALL: u32 = 0x0000_0007;
5673    const GENERIC_WRITE: u32 = 0x4000_0000;
5674    fs::OpenOptions::new()
5675        .access_mode(GENERIC_WRITE)
5676        .share_mode(FILE_SHARE_ALL)
5677        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
5678        .open(path)?
5679        .sync_all()
5680}
5681
5682#[cfg(test)]
5683mod tests {
5684    use super::*;
5685    use std::collections::BTreeSet;
5686    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5687
5688    use crate::reconcile::{AllocationLock, InProcessAllocationLock};
5689    use runner_manager_domain::model::{Elapsed, TargetScope};
5690    use runner_manager_domain::store::SqliteStore;
5691    use runner_manager_github::jit::JitRunner;
5692    use runner_manager_testkit::clock::FakeClock;
5693    use runner_manager_testkit::fixtures;
5694
5695    #[test]
5696    fn production_provider_set_routes_every_implemented_isolated_backend() {
5697        let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
5698        assert!(providers.isolated_provider(Backend::Auto).is_some());
5699        assert!(providers.isolated_provider(Backend::Oci).is_some());
5700        assert!(
5701            providers
5702                .isolated_provider(Backend::WindowsHyperVContainer)
5703                .is_some()
5704        );
5705        assert!(
5706            providers
5707                .isolated_provider(Backend::VirtualMachine)
5708                .is_none()
5709        );
5710    }
5711
5712    /// One event's fields, in the order they were recorded.
5713    type CapturedFields = Vec<(String, String)>;
5714
5715    /// Keeps the `(name, value)` fields of every `tracing` event emitted while
5716    /// it is installed.
5717    ///
5718    /// The names are kept and not just the rendered line, so a test can hold the
5719    /// event to `crate::logging`'s two rules -- the field allow-list and the
5720    /// value scrub -- instead of asserting that some string was passed to a
5721    /// macro.
5722    #[derive(Clone, Default)]
5723    struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
5724
5725    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
5726        fn on_event(
5727            &self,
5728            event: &tracing::Event<'_>,
5729            _context: tracing_subscriber::layer::Context<'_, S>,
5730        ) {
5731            struct Collect(Vec<(String, String)>);
5732            impl tracing::field::Visit for Collect {
5733                fn record_debug(
5734                    &mut self,
5735                    field: &tracing::field::Field,
5736                    value: &dyn std::fmt::Debug,
5737                ) {
5738                    // `{value:?}` on a `&str` field would keep the quotes, and
5739                    // the redaction rules are about the value, not its literal.
5740                    self.0.push((
5741                        field.name().to_owned(),
5742                        format!("{value:?}").trim_matches('"').to_owned(),
5743                    ));
5744                }
5745            }
5746            let mut collected = Collect(Vec::new());
5747            event.record(&mut collected);
5748            self.0
5749                .lock()
5750                .expect("the capture mutex is not poisoned")
5751                .push(collected.0);
5752        }
5753    }
5754
5755    /// The regression behind a three-hour outage that showed nothing an operator
5756    /// could act on.
5757    ///
5758    /// A root the daemon cannot use refuses the launch *before*
5759    /// `record_allocation`, so no attempt row is ever written: `b2` has nothing
5760    /// to carry the failure on and `g2` has nothing to show it from. The
5761    /// lifecycle event keeps only the variant -- `failure_reason_kind` allows no
5762    /// free text on an event -- so the whole failure read
5763    /// `runner_start_failed reason=other`, once per poll, for hours.
5764    ///
5765    /// The assertion is deliberately made against the **production redaction
5766    /// rules** and not merely against what was emitted. An earlier attempt at
5767    /// this fix logged the rendered error on a `detail` field and passed a test
5768    /// exactly like this one, while shipping `detail="[redacted]"`: the field
5769    /// was not allow-listed, and had it been, the value is mostly paths and
5770    /// would have been scrubbed to `[path]`. A test that does not ask
5771    /// `crate::logging` what survives is a test that proves nothing.
5772    #[test]
5773    fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
5774        use runner_manager_platform::logging;
5775        use tracing_subscriber::layer::SubscriberExt as _;
5776
5777        let captured = CapturedEvents::default();
5778        let error = RunnerRootError::DeniedByPrivacyPolicy {
5779            requested: PathBuf::from("/Volumes/NVME/runners"),
5780            refused: PathBuf::from("/Volumes/NVME"),
5781            remediation: RootOwner::Host.remediation(),
5782        };
5783        let kind = error.kind();
5784
5785        let failure = tracing::subscriber::with_default(
5786            tracing_subscriber::registry().with(captured.clone()),
5787            || root_failure(error),
5788        );
5789
5790        // The reason still carries the whole sentence. Nothing on *this* path
5791        // renders it -- there is no attempt row, and the event carries only the
5792        // variant -- so this is a guard against a refactor that drops the detail
5793        // before some future surface can show it, and not a claim that one does.
5794        assert!(
5795            matches!(
5796                &failure,
5797                LifecycleError::Failed(FailureReason::Other(detail))
5798                    if detail.contains("/Volumes/NVME/runners")
5799                        && detail.contains("Full Disk Access")
5800            ),
5801            "the reason must still carry the detail: {failure:?}"
5802        );
5803
5804        let events = captured
5805            .0
5806            .lock()
5807            .expect("the capture mutex is not poisoned")
5808            .clone();
5809        let event = events
5810            .iter()
5811            .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
5812            .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
5813
5814        // Held to the rules the real sink applies, by name and by value. An
5815        // earlier fix put the rendered error on an unlisted `detail` field and
5816        // shipped `[redacted]`; asserting only that something was emitted would
5817        // have passed then too.
5818        for (name, value) in event {
5819            assert!(
5820                logging::is_field_allowed(name),
5821                "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5822                logging::REDACTION
5823            );
5824            assert_eq!(
5825                &logging::redact(value),
5826                value,
5827                "`{name}` does not survive value-shape scrubbing: {event:?}"
5828            );
5829        }
5830    }
5831
5832    /// A slot number, for the tests that name one.
5833    fn nz(slot: u16) -> NonZeroU16 {
5834        NonZeroU16::new(slot).expect("a positive slot")
5835    }
5836
5837    const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5838
5839    #[derive(Debug, Default)]
5840    struct FakeGithubLifecycle {
5841        registration_failures: Mutex<VecDeque<bool>>,
5842        observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5843        registrations: AtomicUsize,
5844        remaining_runners: AtomicUsize,
5845        /// Every runner id `deregister` was asked to remove, in order. A count
5846        /// would not do: the assertions worth making are that the *right*
5847        /// registration was deleted and that it was deleted once.
5848        deregistrations: Mutex<Vec<u64>>,
5849        /// Set to make `deregister` answer `false`, standing for a GitHub that
5850        /// could not be reached at the moment the attempt concluded.
5851        deregistration_fails: AtomicBool,
5852        /// The journal to read *during* a registration, for the ordering
5853        /// assertion `02-target-architecture.md` makes: the slot lease is
5854        /// written "before package or GitHub effects". Reading it afterwards
5855        /// would pass even if the write happened second.
5856        journal: Mutex<Option<Arc<SqliteStore>>>,
5857        /// One entry per registration, in order.
5858        registration_facts: Mutex<Vec<RegistrationFact>>,
5859    }
5860
5861    /// What one JIT registration saw of the world at the moment it was issued.
5862    ///
5863    /// `runner_name` is what ties the other two fields to *one* attempt: with
5864    /// two allocators racing, "some slot was journalled" is a much weaker claim
5865    /// than "the slot this very request belongs to was journalled", and only the
5866    /// name distinguishes them.
5867    #[derive(Debug, Clone)]
5868    struct RegistrationFact {
5869        /// The persistent slots the journal already held.
5870        leased_slots: Vec<u16>,
5871        /// The `work_folder` the request carried.
5872        work_folder: String,
5873        /// The runner name the request carried, i.e. [`runner_name`] of the
5874        /// registering attempt.
5875        runner_name: String,
5876        journal_states: Vec<AttemptState>,
5877    }
5878
5879    impl FakeGithubLifecycle {
5880        fn fail(mut self, terminal: bool) -> Self {
5881            self.registration_failures
5882                .get_mut()
5883                .expect("unpoisoned")
5884                .push_back(terminal);
5885            self
5886        }
5887
5888        fn watch_journal(&self, store: Arc<SqliteStore>) {
5889            *self.journal.lock().unwrap() = Some(store);
5890        }
5891
5892        fn registration_facts(&self) -> Vec<RegistrationFact> {
5893            self.registration_facts.lock().unwrap().clone()
5894        }
5895
5896        fn observe(&self, observation: GithubRunnerObservation) {
5897            let observation = match observation {
5898                GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5899                GithubRunnerObservation::NotRegistered => {
5900                    LifecycleGithubObservation::not_registered()
5901                }
5902                GithubRunnerObservation::Registered { busy } => {
5903                    LifecycleGithubObservation::registered(73, busy)
5904                }
5905            };
5906            self.observations.lock().unwrap().push_back(observation);
5907        }
5908    }
5909
5910    #[async_trait]
5911    impl LifecycleGithub for FakeGithubLifecycle {
5912        async fn register(
5913            &self,
5914            _target: &ScaleTarget,
5915            request: &JitRunnerRequest,
5916            _cancel: &CancelToken,
5917        ) -> Result<JitRegistration, JitRequestFailure> {
5918            self.registrations.fetch_add(1, Ordering::SeqCst);
5919            if let Some(store) = self.journal.lock().unwrap().as_ref() {
5920                let attempts = store.attempts().expect("the journal is readable");
5921                let slots = attempts
5922                    .iter()
5923                    .filter_map(|attempt| attempt.workspace().slot_number())
5924                    .collect();
5925                self.registration_facts
5926                    .lock()
5927                    .unwrap()
5928                    .push(RegistrationFact {
5929                        leased_slots: slots,
5930                        work_folder: request.work_folder().to_string(),
5931                        runner_name: request.name().to_string(),
5932                        journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5933                    });
5934            }
5935            if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5936                return Err(JitRequestFailure {
5937                    terminal,
5938                    reason: if terminal {
5939                        FailureReason::Other("GitHub refused JIT registration with 403".into())
5940                    } else {
5941                        FailureReason::JitRequestFailed
5942                    },
5943                    retry_after: None,
5944                });
5945            }
5946            self.remaining_runners.store(1, Ordering::SeqCst);
5947            Ok(JitRegistration::new(
5948                EncodedJitConfig::new(JIT),
5949                JitRunner {
5950                    id: 73,
5951                    name: request.name().to_string(),
5952                    os: "windows".into(),
5953                    status: "offline".into(),
5954                    busy: false,
5955                    runner_group_id: Some(1),
5956                    labels: request.labels().to_vec(),
5957                },
5958            ))
5959        }
5960
5961        async fn observe(
5962            &self,
5963            _target: &ScaleTarget,
5964            _attempt: AttemptId,
5965            _cancel: &CancelToken,
5966        ) -> LifecycleGithubObservation {
5967            let observation = self
5968                .observations
5969                .lock()
5970                .unwrap()
5971                .pop_front()
5972                .unwrap_or(LifecycleGithubObservation::not_registered());
5973            if observation.status == GithubRunnerObservation::NotRegistered {
5974                self.remaining_runners.store(0, Ordering::SeqCst);
5975            }
5976            observation
5977        }
5978
5979        async fn deregister(
5980            &self,
5981            _target: &ScaleTarget,
5982            runner_id: u64,
5983            _cancel: &CancelToken,
5984        ) -> bool {
5985            self.deregistrations.lock().unwrap().push(runner_id);
5986            if self.deregistration_fails.load(Ordering::SeqCst) {
5987                return false;
5988            }
5989            self.remaining_runners.store(0, Ordering::SeqCst);
5990            true
5991        }
5992    }
5993
5994    #[derive(Debug)]
5995    struct FakePackages {
5996        version: RunnerVersion,
5997        leases: Mutex<BTreeSet<AttemptId>>,
5998        materializations: AtomicUsize,
5999        materializing: AtomicUsize,
6000        peak_materializing: AtomicUsize,
6001        materialization_failures: AtomicUsize,
6002        release_failures: AtomicUsize,
6003        releases: AtomicUsize,
6004        prunes: AtomicUsize,
6005    }
6006
6007    impl Default for FakePackages {
6008        fn default() -> Self {
6009            Self {
6010                version: RunnerVersion::parse("2.330.0").unwrap(),
6011                leases: Mutex::new(BTreeSet::new()),
6012                materializations: AtomicUsize::new(0),
6013                materializing: AtomicUsize::new(0),
6014                peak_materializing: AtomicUsize::new(0),
6015                materialization_failures: AtomicUsize::new(0),
6016                release_failures: AtomicUsize::new(0),
6017                releases: AtomicUsize::new(0),
6018                prunes: AtomicUsize::new(0),
6019            }
6020        }
6021    }
6022
6023    impl FakePackages {
6024        fn fail_materializations(&self, count: usize) {
6025            self.materialization_failures.store(count, Ordering::SeqCst);
6026        }
6027
6028        fn fail_releases(&self, count: usize) {
6029            self.release_failures.store(count, Ordering::SeqCst);
6030        }
6031    }
6032
6033    #[async_trait]
6034    impl RuntimePackages for FakePackages {
6035        async fn materialize(
6036            &self,
6037            attempt: &RunnerAttempt,
6038            _timings: &mut PackageTimings,
6039        ) -> Result<RunnerVersion, FailureReason> {
6040            self.materializations.fetch_add(1, Ordering::SeqCst);
6041            let now = self.materializing.fetch_add(1, Ordering::SeqCst) + 1;
6042            self.peak_materializing.fetch_max(now, Ordering::SeqCst);
6043            for _ in 0..5 {
6044                tokio::task::yield_now().await;
6045            }
6046            self.materializing.fetch_sub(1, Ordering::SeqCst);
6047            if self
6048                .materialization_failures
6049                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
6050                    if left > 0 { Some(left - 1) } else { None }
6051                })
6052                .is_ok()
6053            {
6054                return Err(FailureReason::Other(
6055                    "runner package materialization failed transiently".into(),
6056                ));
6057            }
6058            fs::create_dir_all(attempt.runtime_path()).unwrap();
6059            fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
6060            self.leases.lock().unwrap().insert(attempt.id);
6061            Ok(self.version.clone())
6062        }
6063
6064        fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
6065            if self
6066                .release_failures
6067                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
6068                    if left > 0 { Some(left - 1) } else { None }
6069                })
6070                .is_ok()
6071            {
6072                return Err(FailureReason::Other(
6073                    "runner package lease could not be released".into(),
6074                ));
6075            }
6076            self.leases.lock().unwrap().remove(&attempt);
6077            self.releases.fetch_add(1, Ordering::SeqCst);
6078            Ok(())
6079        }
6080
6081        fn prune_obsolete_guarded(
6082            &self,
6083            _authority: PruneAuthority<'_>,
6084            _attempts: &[RunnerAttempt],
6085        ) -> Result<(), FailureReason> {
6086            self.prunes.fetch_add(1, Ordering::SeqCst);
6087            Ok(())
6088        }
6089    }
6090
6091    #[derive(Debug, Default)]
6092    struct FakeProcesses {
6093        alive: AtomicBool,
6094        completed_successfully: AtomicBool,
6095        spawns: AtomicUsize,
6096        spawn_failures: AtomicUsize,
6097        live_spawn_failure: AtomicBool,
6098        terminations: AtomicUsize,
6099        intent: AtomicBool,
6100        intent_failure: AtomicBool,
6101        actions: Mutex<Vec<&'static str>>,
6102        saw_secret: AtomicBool,
6103    }
6104
6105    impl FakeProcesses {
6106        fn fail_spawns(&self, count: usize) {
6107            self.spawn_failures.store(count, Ordering::SeqCst);
6108        }
6109
6110        fn fail_spawn_with_live_child(&self) {
6111            self.live_spawn_failure.store(true, Ordering::SeqCst);
6112        }
6113
6114        fn set_alive(&self, alive: bool) {
6115            self.alive.store(alive, Ordering::SeqCst);
6116        }
6117
6118        fn finish_successfully(&self) {
6119            self.completed_successfully.store(true, Ordering::SeqCst);
6120            self.alive.store(false, Ordering::SeqCst);
6121        }
6122
6123        fn fail_intent(&self) {
6124            self.intent_failure.store(true, Ordering::SeqCst);
6125        }
6126    }
6127
6128    impl ProcessSupervisor for FakeProcesses {
6129        fn spawn(
6130            &self,
6131            attempt: &RunnerAttempt,
6132            config: &EncodedJitConfig,
6133        ) -> Result<u32, ProcessStartFailure> {
6134            self.spawns.fetch_add(1, Ordering::SeqCst);
6135            // Model the production handoff on both paths: the sensitive file is
6136            // scoped to this call and absent when it returns.
6137            let handoff = RestrictiveHandoff::create(
6138                attempt.runtime_path(),
6139                SecretString::from(config.expose().to_owned()),
6140            )
6141            .unwrap();
6142            self.saw_secret
6143                .store(config.expose() == JIT, Ordering::SeqCst);
6144            let handoff_path = handoff.path().to_path_buf();
6145            let failing = self
6146                .spawn_failures
6147                .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
6148                    if left > 0 { Some(left - 1) } else { None }
6149                })
6150                .is_ok();
6151            drop(handoff);
6152            assert!(!handoff_path.exists(), "handoff must be absent on return");
6153            if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
6154                self.alive.store(true, Ordering::SeqCst);
6155                return Err(ProcessStartFailure::after_spawn_live(4242));
6156            }
6157            if failing {
6158                return Err(ProcessStartFailure::before_spawn(
6159                    FailureReason::ProcessStartFailed,
6160                ));
6161            }
6162            self.alive.store(true, Ordering::SeqCst);
6163            Ok(4242)
6164        }
6165
6166        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6167            self.actions.lock().unwrap().push("observe_process");
6168            Ok(self.alive.load(Ordering::SeqCst))
6169        }
6170
6171        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
6172            Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
6173        }
6174
6175        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
6176            self.completed_successfully.load(Ordering::SeqCst)
6177        }
6178
6179        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6180            self.actions.lock().unwrap().push("terminate_intent");
6181            if self.intent_failure.load(Ordering::SeqCst) {
6182                return Err(FailureReason::Other(
6183                    "terminate intent directory sync failed".into(),
6184                ));
6185            }
6186            self.intent.store(true, Ordering::SeqCst);
6187            Ok(())
6188        }
6189
6190        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
6191            self.intent.load(Ordering::SeqCst)
6192        }
6193
6194        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6195            assert!(
6196                self.intent.load(Ordering::SeqCst),
6197                "the durable intent must exist before signalling"
6198            );
6199            self.actions.lock().unwrap().push("terminate");
6200            self.terminations.fetch_add(1, Ordering::SeqCst);
6201            self.alive.store(false, Ordering::SeqCst);
6202            Ok(())
6203        }
6204    }
6205
6206    #[derive(Debug, Default)]
6207    struct FakeIsolatedProvider {
6208        resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
6209        extra_resources: Mutex<Vec<EnvironmentIdentity>>,
6210        unsupported_host: AtomicBool,
6211        permission_denied: AtomicBool,
6212        incompatible_image: AtomicBool,
6213        prepare_failure: AtomicBool,
6214        recovery_unavailable: AtomicBool,
6215        destroy_deferred: AtomicBool,
6216        handoffs: AtomicUsize,
6217        completed_successfully: AtomicBool,
6218        native_calls: AtomicUsize,
6219        actions: Mutex<Vec<&'static str>>,
6220    }
6221
6222    impl FakeIsolatedProvider {
6223        fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
6224            let AttemptExecution::Isolated {
6225                provider_kind,
6226                resolved_image,
6227                generation,
6228                ..
6229            } = attempt.execution()
6230            else {
6231                panic!("isolated intent required")
6232            };
6233            EnvironmentIdentity::Isolated {
6234                host,
6235                attempt: attempt.id,
6236                provider_kind: *provider_kind,
6237                environment_id: format!("environment-{}", attempt.id),
6238                resolved_image: resolved_image.clone(),
6239                generation: generation.clone(),
6240            }
6241        }
6242
6243        fn resource_count(&self) -> usize {
6244            self.resources.lock().unwrap().len()
6245        }
6246    }
6247
6248    impl ExecutionProvider for FakeIsolatedProvider {
6249        fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
6250            if self.unsupported_host.load(Ordering::SeqCst) {
6251                ProviderCapability::Unsupported
6252            } else if self.permission_denied.load(Ordering::SeqCst) {
6253                ProviderCapability::PermissionDenied
6254            } else if policy.execution_policy().is_native() {
6255                ProviderCapability::Unsupported
6256            } else {
6257                ProviderCapability::Ready
6258            }
6259        }
6260
6261        fn resolve(
6262            &self,
6263            policy: &ScalePolicy,
6264        ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
6265            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6266                policy.execution_policy()
6267            else {
6268                return Err(FailureReason::Other("isolated policy required".into()));
6269            };
6270            Ok(Some(ResolvedEnvironment {
6271                provider_kind: Backend::Oci,
6272                image: if self.incompatible_image.load(Ordering::SeqCst) {
6273                    ImageReference::new("vm-version:wrong-provider")
6274                        .expect("valid but incompatible")
6275                } else {
6276                    image.clone()
6277                },
6278            }))
6279        }
6280
6281        fn prepare(
6282            &self,
6283            attempt: &RunnerAttempt,
6284            policy: &ScalePolicy,
6285        ) -> Result<PreparedEnvironment, FailureReason> {
6286            assert_eq!(
6287                attempt.state(),
6288                AttemptState::Preparing,
6289                "intent must be durable before provider effect"
6290            );
6291            self.actions.lock().unwrap().push("prepare");
6292            let identity = Self::identity(attempt, policy.host_id);
6293            self.resources
6294                .lock()
6295                .unwrap()
6296                .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
6297            if self.prepare_failure.swap(false, Ordering::SeqCst) {
6298                return Err(FailureReason::Other("provider-secret-needle".into()));
6299            }
6300            Ok(PreparedEnvironment::isolated(attempt.id, identity))
6301        }
6302
6303        fn start(
6304            &self,
6305            prepared: PreparedEnvironment,
6306            attempt: &RunnerAttempt,
6307            handoff: OneTimeJitHandoff<'_>,
6308        ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
6309            assert_eq!(prepared.attempt, attempt.id);
6310            assert_eq!(attempt.state(), AttemptState::JitReceived);
6311            assert_eq!(handoff.consume().expose(), JIT);
6312            self.handoffs.fetch_add(1, Ordering::SeqCst);
6313            self.actions.lock().unwrap().push("start");
6314            let mut resources = self.resources.lock().unwrap();
6315            let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
6316            *state = EnvironmentState::Running;
6317            Ok(identity.clone())
6318        }
6319
6320        fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6321            self.actions.lock().unwrap().push("inspect");
6322            Ok(self
6323                .resources
6324                .lock()
6325                .unwrap()
6326                .get(&attempt.id)
6327                .map_or(EnvironmentState::Missing, |(_, state)| *state))
6328        }
6329
6330        fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6331            if self.recovery_unavailable.load(Ordering::SeqCst) {
6332                return Err(FailureReason::Other(
6333                    "provider discovery unavailable".into(),
6334                ));
6335            }
6336            self.inspect(attempt)
6337        }
6338
6339        fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6340            Ok(self
6341                .resources
6342                .lock()
6343                .unwrap()
6344                .get(&attempt.id)
6345                .is_some_and(|(identity, _)| {
6346                    identity_matches_intent(attempt_host(identity), attempt, identity)
6347                }))
6348        }
6349
6350        fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
6351            assert!(self.owns(attempt)?);
6352            self.actions.lock().unwrap().push("stop");
6353            if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
6354                *state = EnvironmentState::Exited;
6355            }
6356            Ok(ProviderStop::Stopped)
6357        }
6358
6359        fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
6360            assert!(self.owns(attempt)?);
6361            self.actions.lock().unwrap().push("destroy");
6362            if self.destroy_deferred.load(Ordering::SeqCst) {
6363                return Ok(ProviderDestroy::Deferred("provider busy"));
6364            }
6365            self.resources.lock().unwrap().remove(&attempt.id);
6366            Ok(ProviderDestroy::Destroyed)
6367        }
6368
6369        fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
6370            let mut owned = self
6371                .resources
6372                .lock()
6373                .unwrap()
6374                .values()
6375                .filter(|(identity, _)| attempt_host(identity) == host)
6376                .map(|(identity, _)| identity.clone())
6377                .collect::<Vec<_>>();
6378            owned.extend(
6379                self.extra_resources
6380                    .lock()
6381                    .unwrap()
6382                    .iter()
6383                    .filter(|identity| attempt_host(identity) == host)
6384                    .cloned(),
6385            );
6386            owned
6387        }
6388
6389        fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
6390            vec![ProviderDiagnostic::CleanupDeferred; 40]
6391        }
6392
6393        fn spawn(
6394            &self,
6395            _attempt: &RunnerAttempt,
6396            _config: &EncodedJitConfig,
6397        ) -> Result<u32, ProcessStartFailure> {
6398            self.native_calls.fetch_add(1, Ordering::SeqCst);
6399            panic!("isolated provider must never spawn natively")
6400        }
6401        fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6402            panic!("native fallback")
6403        }
6404        fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
6405            panic!("native fallback")
6406        }
6407        fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
6408            self.completed_successfully.load(Ordering::SeqCst)
6409        }
6410        fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6411            panic!("native fallback")
6412        }
6413        fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
6414            false
6415        }
6416        fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6417            panic!("native fallback")
6418        }
6419    }
6420
6421    fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
6422        let EnvironmentIdentity::Isolated { host, .. } = identity else {
6423            panic!("isolated identity")
6424        };
6425        *host
6426    }
6427
6428    #[derive(Debug, Default)]
6429    struct FakeDemand {
6430        answers: Mutex<VecDeque<bool>>,
6431    }
6432
6433    impl FakeDemand {
6434        fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
6435            Self {
6436                answers: Mutex::new(answers.into_iter().collect()),
6437            }
6438        }
6439    }
6440
6441    #[async_trait]
6442    impl DemandPersistence for FakeDemand {
6443        async fn persists(&self, _policy: PolicyId) -> bool {
6444            self.answers.lock().unwrap().pop_front().unwrap_or(true)
6445        }
6446    }
6447
6448    #[derive(Debug, Default)]
6449    struct FakeDelay(Mutex<Vec<Duration>>);
6450
6451    #[async_trait]
6452    impl RetryDelay for FakeDelay {
6453        async fn wait(&self, duration: Duration) {
6454            self.0.lock().unwrap().push(duration);
6455        }
6456    }
6457
6458    #[tokio::test]
6459    async fn live_capacity_reads_the_updated_sqlite_host_and_rejects_a_missing_host() {
6460        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6461        let path = harness._root.path().join("live-capacity.sqlite3");
6462        harness.store = Arc::new(SqliteStore::open(&path).unwrap());
6463        harness.store.put_host(&harness.host).unwrap();
6464        harness.launcher = harness.restart();
6465        assert_eq!(
6466            harness.launcher.host_capacity(&harness.host).await.unwrap(),
6467            harness.host.host_capacity
6468        );
6469        // A separate CLI/TUI connection updates the journal while the existing
6470        // launcher keeps its original store connection and startup host.
6471        let writer = SqliteStore::open(&path).unwrap();
6472        let mut updated = harness.host.clone();
6473        updated.host_capacity = std::num::NonZeroU16::new(12).unwrap();
6474        writer.put_host(&updated).unwrap();
6475        assert_eq!(
6476            harness
6477                .launcher
6478                .host_capacity(&harness.host)
6479                .await
6480                .unwrap()
6481                .get(),
6482            12
6483        );
6484
6485        updated.host_capacity = std::num::NonZeroU16::new(2).unwrap();
6486        writer.put_host(&updated).unwrap();
6487        assert_eq!(
6488            harness
6489                .launcher
6490                .host_capacity(&harness.host)
6491                .await
6492                .unwrap()
6493                .get(),
6494            2
6495        );
6496
6497        let mut missing = harness.host.clone();
6498        missing.id = HostId::from_u128(999);
6499        assert_eq!(
6500            harness.launcher.host_capacity(&missing).await,
6501            Err(crate::reconcile::HostCapacityError::MissingHost)
6502        );
6503    }
6504
6505    struct Harness {
6506        _root: tempfile::TempDir,
6507        app_paths: runner_manager_platform::paths::AppPaths,
6508        launcher: LifecycleLauncher,
6509        demand: Arc<dyn DemandPersistence>,
6510        store: Arc<SqliteStore>,
6511        github: Arc<FakeGithubLifecycle>,
6512        packages: Arc<FakePackages>,
6513        processes: Arc<FakeProcesses>,
6514        clock: Arc<FakeClock>,
6515        events: Arc<AttemptEventLog>,
6516        reconcile_events: Arc<crate::reconcile::EventLog>,
6517        delay: Arc<FakeDelay>,
6518        host: runner_manager_domain::model::Host,
6519        policy: ScalePolicy,
6520        allocation_lock: InProcessAllocationLock,
6521        /// The repository persistent root, once one is configured.
6522        workspace_root: Option<LocalAbsolutePath>,
6523    }
6524
6525    impl Harness {
6526        fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
6527            let root = tempfile::tempdir().unwrap();
6528            let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
6529            paths.create_all().unwrap();
6530            let policy = fixtures::policy()
6531                .repository("octo/repo")
6532                .autoscale("home", 2)
6533                .active()
6534                .build();
6535            // A unit harness must never inspect or write the workstation's
6536            // production runner root. On Windows that is C:\rman, which is
6537            // intentionally inaccessible to an ordinary account after a boot
6538            // service is installed. Using it made the same suite pass on a
6539            // clean CI image and fail on a correctly secured developer host.
6540            let host_root = root.path().join("host-root");
6541            fs::create_dir_all(&host_root).unwrap();
6542            let mut host = fixtures::host().build();
6543            host.runner_root_override = Some(
6544                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6545                    .expect("a local absolute host root"),
6546            );
6547            let store = Arc::new(SqliteStore::open_in_memory().unwrap());
6548            store.put_host(&host).unwrap();
6549            let github = Arc::new(github);
6550            let packages = Arc::new(FakePackages::default());
6551            let processes = Arc::new(FakeProcesses::default());
6552            let clock = Arc::new(FakeClock::default());
6553            let events = Arc::new(AttemptEventLog::default());
6554            let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
6555            let delay = Arc::new(FakeDelay::default());
6556            let ports = LifecyclePorts {
6557                store: Arc::clone(&store) as Arc<dyn Store>,
6558                github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
6559                packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
6560                processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
6561                clock: Arc::clone(&clock) as Arc<dyn Clock>,
6562                demand: Arc::clone(&demand),
6563                delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
6564                events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
6565                reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
6566            };
6567            let launcher = Self::launcher_over(policy.host_id, &paths, ports);
6568            Self {
6569                _root: root,
6570                app_paths: paths,
6571                launcher,
6572                demand,
6573                store,
6574                github,
6575                packages,
6576                processes,
6577                clock,
6578                events,
6579                reconcile_events,
6580                delay,
6581                host,
6582                policy,
6583                allocation_lock: InProcessAllocationLock::new(),
6584                workspace_root: None,
6585            }
6586        }
6587
6588        /// Put disposable attempts under a host root of this harness's own.
6589        ///
6590        /// Without it the launcher resolves the *platform* default, which on
6591        /// Windows is `%SystemDrive%\rman` — a real directory on the machine
6592        /// running the suite. Every test added by `c2` places its files inside
6593        /// its own temporary directory instead.
6594        fn with_host_runner_root(mut self) -> Self {
6595            let host_root = self.host_root();
6596            fs::create_dir_all(&host_root).unwrap();
6597            self.host.runner_root_override = Some(
6598                LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6599                    .expect("a local absolute host root"),
6600            );
6601            self.store.put_host(&self.host).unwrap();
6602            self
6603        }
6604
6605        /// Opt this harness's repository into a persistent workspace (D4).
6606        fn with_persistent_workspace(mut self, capacity: u16) -> Self {
6607            self = self.with_host_runner_root();
6608            let root = self._root.path().join("persist");
6609            let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
6610                .expect("a local absolute workspace root");
6611            self.policy = fixtures::policy()
6612                .repository("octo/repo")
6613                .autoscale("home", capacity)
6614                .active()
6615                .build();
6616            self.policy
6617                .set_workspace_policy(
6618                    WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
6619                        .expect("a repository may be persistent"),
6620                )
6621                .expect("a repository may be persistent");
6622            self.workspace_root = Some(root);
6623            // The journal's copy, so that cleanup's cross-check against a
6624            // surviving policy is exercised rather than skipped.
6625            self.store.insert_policy(&self.policy).unwrap();
6626            self
6627        }
6628
6629        fn workspace_root(&self) -> &LocalAbsolutePath {
6630            self.workspace_root
6631                .as_ref()
6632                .expect("this harness configured a persistent workspace")
6633        }
6634
6635        fn slot_path(&self, slot: u16) -> PathBuf {
6636            self.workspace_root().as_path().join(format!("s{slot}"))
6637        }
6638
6639        fn host_root(&self) -> PathBuf {
6640            self._root.path().join("host-root")
6641        }
6642
6643        fn attempt(&self, id: AttemptId) -> RunnerAttempt {
6644            self.store
6645                .attempt(id)
6646                .unwrap()
6647                .expect("the attempt is journalled")
6648        }
6649
6650        /// Conclude an attempt and run the real cleanup over it.
6651        fn conclude(&self, id: AttemptId) -> RunnerAttempt {
6652            let mut attempt = self.attempt(id);
6653            attempt
6654                .conclude(
6655                    AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
6656                    self.clock.now(),
6657                )
6658                .unwrap();
6659            self.store.record_attempt(&attempt).unwrap();
6660            attempt
6661        }
6662
6663        async fn cleanup_retaining_work(&self, id: AttemptId) {
6664            self.conclude(id);
6665            self.launcher
6666                .clean(id)
6667                .await
6668                .expect("the slot is scrubbed and the lease released");
6669        }
6670
6671        /// The launcher configuration every launcher in this harness shares, so
6672        /// that the one a restart mints cannot drift from the original.
6673        fn launcher_over(
6674            host: HostId,
6675            paths: &runner_manager_platform::paths::AppPaths,
6676            ports: LifecyclePorts,
6677        ) -> LifecycleLauncher {
6678            LifecycleLauncher::new(
6679                host,
6680                paths.clone(),
6681                paths.logs_dir(),
6682                1,
6683                RecoveryTimeouts::new(
6684                    Elapsed::seconds(10),
6685                    Elapsed::seconds(10),
6686                    Elapsed::seconds(10),
6687                ),
6688                RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
6689                ports,
6690            )
6691        }
6692
6693        /// The same journal, the same directories, a launcher that remembers
6694        /// nothing — which is what a daemon restart is.
6695        fn restart(&self) -> LifecycleLauncher {
6696            Self::launcher_over(
6697                self.policy.host_id,
6698                &self.app_paths,
6699                LifecyclePorts {
6700                    store: Arc::clone(&self.store) as Arc<dyn Store>,
6701                    github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
6702                    packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
6703                    processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
6704                    clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
6705                    demand: Arc::clone(&self.demand),
6706                    delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
6707                    events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
6708                    reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
6709                },
6710            )
6711        }
6712
6713        async fn ready(&self) {
6714            self.launcher
6715                .recover_startup(std::slice::from_ref(&self.policy))
6716                .await
6717                .unwrap();
6718        }
6719
6720        async fn launch(&self) -> RunnerAttempt {
6721            self.launch_result().await.unwrap()
6722        }
6723
6724        async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
6725            self.launch_after_waiting(Duration::ZERO).await
6726        }
6727
6728        async fn launch_after_waiting(
6729            &self,
6730            lock_wait: Duration,
6731        ) -> Result<RunnerAttempt, LaunchFailure> {
6732            let reserved = self.reserve().await?;
6733            self.launcher
6734                .finish_launch(
6735                    &self.policy,
6736                    reserved,
6737                    ReservationWaits {
6738                        lock: lock_wait,
6739                        order: Duration::from_secs(2),
6740                    },
6741                )
6742                .await
6743        }
6744
6745        /// A whole launch through `launcher`, as a reconciliation pass makes it.
6746        async fn launch_through(
6747            &self,
6748            launcher: &LifecycleLauncher,
6749        ) -> Result<RunnerAttempt, LaunchFailure> {
6750            let reserved = self.reserve_through(launcher).await?;
6751            launcher
6752                .finish_launch(&self.policy, reserved, ReservationWaits::default())
6753                .await
6754        }
6755
6756        async fn reserve(&self) -> Result<RunnerAttempt, LaunchFailure> {
6757            self.reserve_through(&self.launcher).await
6758        }
6759
6760        async fn reserve_through(
6761            &self,
6762            launcher: &LifecycleLauncher,
6763        ) -> Result<RunnerAttempt, LaunchFailure> {
6764            let guard = self.allocation_lock.acquire().await.unwrap();
6765            launcher
6766                .launch(LaunchRequest {
6767                    host: &self.host,
6768                    policy: &self.policy,
6769                    allocation_guard: &guard,
6770                })
6771                .await
6772        }
6773
6774        fn only_attempt(&self) -> RunnerAttempt {
6775            self.store.attempts().unwrap().into_iter().next().unwrap()
6776        }
6777    }
6778
6779    fn isolated_harness() -> Harness {
6780        use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
6781        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6782            .with_host_runner_root();
6783        let image =
6784            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6785                .expect("pinned image");
6786        harness
6787            .policy
6788            .set_execution_policy(ExecutionPolicy::Isolated {
6789                backend: Backend::Oci,
6790                image,
6791                resources: ResourceLimits {
6792                    cpu_millis: 1000,
6793                    memory_mib: 1024,
6794                    disk_mib: 4096,
6795                },
6796            })
6797            .expect("isolated policy");
6798        harness.github.watch_journal(Arc::clone(&harness.store));
6799        harness
6800    }
6801
6802    fn launcher_with_isolated(
6803        harness: &Harness,
6804        provider: Arc<FakeIsolatedProvider>,
6805    ) -> LifecycleLauncher {
6806        Harness::launcher_over(
6807            harness.policy.host_id,
6808            &harness.app_paths,
6809            LifecyclePorts {
6810                store: Arc::clone(&harness.store) as Arc<dyn Store>,
6811                github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
6812                packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
6813                processes: provider as Arc<dyn ExecutionProvider>,
6814                clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
6815                demand: Arc::clone(&harness.demand),
6816                delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
6817                events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
6818                reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
6819            },
6820        )
6821    }
6822
6823    #[tokio::test]
6824    async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
6825        use runner_manager_domain::execution::{
6826            AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6827        };
6828
6829        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6830            .with_host_runner_root();
6831        let image =
6832            ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6833                .expect("pinned image");
6834        harness
6835            .policy
6836            .set_execution_policy(ExecutionPolicy::Isolated {
6837                backend: Backend::Oci,
6838                image: image.clone(),
6839                resources: ResourceLimits {
6840                    cpu_millis: 1000,
6841                    memory_mib: 1024,
6842                    disk_mib: 4096,
6843                },
6844            })
6845            .expect("valid isolated profile");
6846
6847        harness.ready().await;
6848        assert_eq!(
6849            harness.processes.probe(&harness.policy),
6850            ProviderCapability::Unsupported
6851        );
6852        let failure = harness
6853            .launch_result()
6854            .await
6855            .expect_err("provider is unavailable");
6856        assert_eq!(
6857            failure.reason,
6858            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6859        );
6860
6861        let runtime = harness.host_root().join("isolated-owned-environment");
6862        fs::create_dir_all(&runtime).expect("provider resource fixture");
6863        fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
6864        let mut attempt = RunnerAttempt::allocate(
6865            AttemptId::from_u128(0x123),
6866            harness.policy.id,
6867            runtime.clone(),
6868            harness.clock.now(),
6869        );
6870        attempt
6871            .allocate_execution(AttemptExecution::Isolated {
6872                provider_kind: Backend::Oci,
6873                environment_id: None,
6874                resolved_image: image,
6875                generation: "generation-123".into(),
6876            })
6877            .expect("isolated intent");
6878        attempt
6879            .prepared_environment("environment-123".into())
6880            .expect("prepared identity");
6881        harness
6882            .store
6883            .record_attempt(&attempt)
6884            .expect("durable isolated journal");
6885
6886        harness
6887            .launcher
6888            .recover_startup(std::slice::from_ref(&harness.policy))
6889            .await
6890            .expect("unsupported isolated cleanup is profile-local");
6891        let failure = harness
6892            .launch_result()
6893            .await
6894            .expect_err("provider unavailable for this policy");
6895        assert_eq!(
6896            failure.reason,
6897            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6898        );
6899        harness
6900            .launcher
6901            .supervise(&harness.policy)
6902            .await
6903            .expect("unavailable attempt is deferred");
6904        assert!(harness.launcher.clean(attempt.id).await.is_err());
6905        assert_eq!(
6906            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6907            AttemptState::CleanupDeferred
6908        );
6909        assert!(matches!(
6910            harness.launcher.recover_startup(&[]).await,
6911            Err(LifecycleError::RecoveryIncomplete)
6912        ));
6913        assert!(
6914            runtime.join("owned-marker").is_file(),
6915            "native cleanup touched the isolated environment"
6916        );
6917        assert!(
6918            harness.processes.actions.lock().unwrap().is_empty(),
6919            "native recovery inspected an isolated identity"
6920        );
6921        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6922        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6923        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6924    }
6925
6926    #[tokio::test]
6927    async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6928        let harness = isolated_harness();
6929        let provider = Arc::new(FakeIsolatedProvider::default());
6930        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6931        launcher
6932            .recover_startup(std::slice::from_ref(&harness.policy))
6933            .await
6934            .expect("ready");
6935        let attempt = harness
6936            .launch_through(&launcher)
6937            .await
6938            .expect("isolated launch");
6939        assert_eq!(attempt.state(), AttemptState::Starting);
6940        assert_eq!(provider.resource_count(), 1);
6941        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6942        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6943        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6944        let fact = harness
6945            .github
6946            .registration_facts()
6947            .pop()
6948            .expect("JIT registration");
6949        assert_eq!(
6950            fact.journal_states,
6951            vec![AttemptState::Prepared],
6952            "provider readiness preceded GitHub JIT"
6953        );
6954        assert_eq!(
6955            harness
6956                .events
6957                .events()
6958                .into_iter()
6959                .filter_map(|event| match event {
6960                    AttemptEvent::State { state, .. } => Some(state),
6961                    _ => None,
6962                })
6963                .collect::<Vec<_>>(),
6964            vec![
6965                AttemptState::Allocated,
6966                AttemptState::Preparing,
6967                AttemptState::Prepared,
6968                AttemptState::JitReceived,
6969                AttemptState::Starting
6970            ]
6971        );
6972        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6973        restarted
6974            .recover_startup(std::slice::from_ref(&harness.policy))
6975            .await
6976            .expect("adopt live sandbox");
6977        assert_eq!(provider.resource_count(), 1);
6978        assert_eq!(
6979            harness.github.registrations.load(Ordering::SeqCst),
6980            1,
6981            "restart cannot issue duplicate JIT"
6982        );
6983        harness
6984            .github
6985            .observe(GithubRunnerObservation::Registered { busy: true });
6986        restarted
6987            .supervise(&harness.policy)
6988            .await
6989            .expect("job observed");
6990        assert_eq!(
6991            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6992            AttemptState::Busy
6993        );
6994        provider
6995            .resources
6996            .lock()
6997            .unwrap()
6998            .get_mut(&attempt.id)
6999            .unwrap()
7000            .1 = EnvironmentState::Exited;
7001        harness
7002            .github
7003            .observe(GithubRunnerObservation::NotRegistered);
7004        restarted
7005            .supervise(&harness.policy)
7006            .await
7007            .expect("terminal cleanup");
7008        assert_eq!(provider.resource_count(), 0);
7009        assert_eq!(
7010            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7011            AttemptState::Cleaned
7012        );
7013    }
7014
7015    #[tokio::test]
7016    async fn attached_isolated_job_exit_is_recorded_as_completed() {
7017        let harness = isolated_harness();
7018        let provider = Arc::new(FakeIsolatedProvider::default());
7019        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7020        launcher
7021            .recover_startup(std::slice::from_ref(&harness.policy))
7022            .await
7023            .unwrap();
7024        let attempt = harness.launch_through(&launcher).await.unwrap();
7025        harness
7026            .github
7027            .observe(GithubRunnerObservation::Registered { busy: true });
7028        launcher.supervise(&harness.policy).await.unwrap();
7029        provider
7030            .resources
7031            .lock()
7032            .unwrap()
7033            .get_mut(&attempt.id)
7034            .unwrap()
7035            .1 = EnvironmentState::Exited;
7036        provider
7037            .completed_successfully
7038            .store(true, Ordering::SeqCst);
7039        harness
7040            .github
7041            .observe(GithubRunnerObservation::NotRegistered);
7042        launcher.supervise(&harness.policy).await.unwrap();
7043        let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
7044        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7045        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7046    }
7047
7048    #[tokio::test]
7049    async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
7050        let harness = isolated_harness();
7051        let provider = Arc::new(FakeIsolatedProvider::default());
7052        provider.prepare_failure.store(true, Ordering::SeqCst);
7053        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7054        launcher
7055            .recover_startup(std::slice::from_ref(&harness.policy))
7056            .await
7057            .expect("ready");
7058        let failure = harness
7059            .launch_through(&launcher)
7060            .await
7061            .expect_err("prepare failed after creating resource");
7062        assert_eq!(
7063            failure.reason,
7064            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
7065        );
7066        assert_eq!(provider.resource_count(), 1);
7067        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7068        assert!(
7069            !harness
7070                .store
7071                .dump_text()
7072                .unwrap()
7073                .contains("provider-secret-needle")
7074        );
7075        let id = harness.store.attempts().unwrap()[0].id;
7076        assert_eq!(
7077            harness.store.attempt(id).unwrap().unwrap().outcome(),
7078            Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
7079                IsolationProviderFailure::RuntimeOperationFailed
7080            )))
7081        );
7082        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
7083        restarted
7084            .recover_startup(std::slice::from_ref(&harness.policy))
7085            .await
7086            .expect("profile-local recovery");
7087        assert_eq!(
7088            provider.resource_count(),
7089            0,
7090            "the exact owned resource was destroyed once"
7091        );
7092        assert_eq!(
7093            harness.store.attempt(id).unwrap().unwrap().state(),
7094            AttemptState::Cleaned
7095        );
7096        let diagnostic =
7097            fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
7098        assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
7099        assert!(!diagnostic.contains("provider-secret-needle"));
7100        assert_eq!(
7101            provider
7102                .actions
7103                .lock()
7104                .unwrap()
7105                .iter()
7106                .filter(|action| **action == "destroy")
7107                .count(),
7108            1
7109        );
7110        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7111    }
7112
7113    #[test]
7114    fn isolation_capability_and_adapter_failures_keep_closed_categories() {
7115        let cases = [
7116            (
7117                ProviderCapability::NotInstalled,
7118                IsolationProviderFailure::NotInstalled,
7119            ),
7120            (
7121                ProviderCapability::PermissionDenied,
7122                IsolationProviderFailure::PermissionDenied,
7123            ),
7124            (
7125                ProviderCapability::ImageUnavailableOrIncompatible,
7126                IsolationProviderFailure::ImageUnavailableOrIncompatible,
7127            ),
7128            (
7129                ProviderCapability::DiskQuotaUnavailable,
7130                IsolationProviderFailure::DiskQuotaUnavailable,
7131            ),
7132            (
7133                ProviderCapability::Degraded,
7134                IsolationProviderFailure::Degraded,
7135            ),
7136        ];
7137        for (capability, category) in cases {
7138            assert_eq!(
7139                capability.refusal(),
7140                FailureReason::IsolationProvider(category)
7141            );
7142        }
7143        let raw = FailureReason::Other("ghp_secret_from_provider".into());
7144        let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
7145        assert_eq!(
7146            safe,
7147            FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
7148        );
7149        assert!(!safe.to_string().contains("ghp_secret"));
7150        assert_eq!(
7151            safe_isolation_reason(
7152                FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
7153                IsolationProviderFailure::RuntimeOperationFailed,
7154            ),
7155            FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
7156        );
7157    }
7158
7159    #[tokio::test]
7160    async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
7161        for image_failure in [false, true] {
7162            let harness = isolated_harness();
7163            let provider = Arc::new(FakeIsolatedProvider::default());
7164            provider
7165                .permission_denied
7166                .store(!image_failure, Ordering::SeqCst);
7167            provider
7168                .incompatible_image
7169                .store(image_failure, Ordering::SeqCst);
7170            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7171            launcher
7172                .recover_startup(std::slice::from_ref(&harness.policy))
7173                .await
7174                .unwrap();
7175            let error = harness
7176                .launch_through(&launcher)
7177                .await
7178                .expect_err("pre-JIT refusal");
7179            let expected = if image_failure {
7180                "resolved image is incompatible"
7181            } else {
7182                "permission denied"
7183            };
7184            assert!(error.reason.to_string().contains(expected));
7185            assert!(
7186                harness.store.attempts().unwrap().is_empty(),
7187                "no allocation was journalled"
7188            );
7189            assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7190            assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
7191            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7192        }
7193    }
7194
7195    #[tokio::test]
7196    async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
7197        let harness = isolated_harness();
7198        let provider = Arc::new(FakeIsolatedProvider::default());
7199        provider.unsupported_host.store(true, Ordering::SeqCst);
7200        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7201        launcher
7202            .recover_startup(std::slice::from_ref(&harness.policy))
7203            .await
7204            .unwrap();
7205        let error = harness
7206            .launch_through(&launcher)
7207            .await
7208            .expect_err("unsupported host must be refused before JIT");
7209
7210        assert_eq!(
7211            error.reason,
7212            FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
7213        );
7214        assert!(harness.store.attempts().unwrap().is_empty());
7215        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7216        assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
7217        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7218        assert!(provider.actions.lock().unwrap().is_empty());
7219    }
7220
7221    #[tokio::test]
7222    async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
7223        let harness = isolated_harness();
7224        let provider = Arc::new(FakeIsolatedProvider::default());
7225        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7226        let runtime = harness.host_root().join("prepare-without-identity");
7227        fs::create_dir_all(&runtime).unwrap();
7228        let mut attempt = RunnerAttempt::allocate(
7229            AttemptId::from_u128(0x9191),
7230            harness.policy.id,
7231            runtime,
7232            harness.clock.now(),
7233        );
7234        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7235            harness.policy.execution_policy()
7236        else {
7237            unreachable!()
7238        };
7239        attempt
7240            .allocate_execution(AttemptExecution::Isolated {
7241                provider_kind: Backend::Oci,
7242                environment_id: None,
7243                resolved_image: image.clone(),
7244                generation: "prepare-generation".into(),
7245            })
7246            .unwrap();
7247        attempt.begin_prepare(harness.clock.now()).unwrap();
7248        attempt
7249            .conclude(
7250                AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
7251                harness.clock.now(),
7252            )
7253            .unwrap();
7254        harness.store.record_attempt(&attempt).unwrap();
7255
7256        provider.recovery_unavailable.store(true, Ordering::SeqCst);
7257        launcher
7258            .clean(attempt.id)
7259            .await
7260            .expect_err("absence unproven");
7261        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7262        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7263        assert!(deferred.counts_against_capacity());
7264
7265        provider.recovery_unavailable.store(false, Ordering::SeqCst);
7266        launcher.clean(attempt.id).await.expect("absence proven");
7267        assert_eq!(
7268            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7269            AttemptState::Cleaned
7270        );
7271    }
7272
7273    #[tokio::test]
7274    async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
7275        let harness = isolated_harness();
7276        let provider = Arc::new(FakeIsolatedProvider::default());
7277        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7278        launcher
7279            .recover_startup(std::slice::from_ref(&harness.policy))
7280            .await
7281            .expect("ready");
7282        let mut attempt = harness.launch_through(&launcher).await.expect("launch");
7283        attempt
7284            .conclude(
7285                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7286                harness.clock.now(),
7287            )
7288            .unwrap();
7289        harness.store.record_attempt(&attempt).unwrap();
7290        provider.destroy_deferred.store(true, Ordering::SeqCst);
7291        launcher
7292            .clean(attempt.id)
7293            .await
7294            .expect_err("provider deferred destroy");
7295        let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7296        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7297        assert!(deferred.counts_against_capacity());
7298        assert_eq!(provider.resource_count(), 1);
7299        provider.destroy_deferred.store(false, Ordering::SeqCst);
7300        let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
7301        restarted
7302            .recover_startup(std::slice::from_ref(&harness.policy))
7303            .await
7304            .expect("retry cleanup");
7305        assert_eq!(provider.resource_count(), 0);
7306        assert!(
7307            !harness
7308                .store
7309                .attempt(attempt.id)
7310                .unwrap()
7311                .unwrap()
7312                .counts_against_capacity()
7313        );
7314
7315        // A reused runtime name with a different generation is not ours to delete.
7316        let second_id = AttemptId::from_u128(0x9999);
7317        let second_runtime = harness.host_root().join("generation-mismatch");
7318        fs::create_dir_all(&second_runtime).unwrap();
7319        let mut terminal = RunnerAttempt::allocate(
7320            second_id,
7321            harness.policy.id,
7322            second_runtime,
7323            harness.clock.now(),
7324        );
7325        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7326            harness.policy.execution_policy()
7327        else {
7328            unreachable!()
7329        };
7330        terminal
7331            .allocate_execution(AttemptExecution::Isolated {
7332                provider_kind: Backend::Oci,
7333                environment_id: None,
7334                resolved_image: image.clone(),
7335                generation: "expected-generation".into(),
7336            })
7337            .unwrap();
7338        let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
7339        let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
7340            unreachable!()
7341        };
7342        terminal.begin_prepare(harness.clock.now()).unwrap();
7343        terminal
7344            .prepared_environment(environment_id.clone())
7345            .unwrap();
7346        terminal.mark_prepared(harness.clock.now()).unwrap();
7347        terminal.jit_received(harness.clock.now()).unwrap();
7348        terminal.started_isolated(harness.clock.now()).unwrap();
7349        terminal
7350            .conclude(
7351                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7352                harness.clock.now(),
7353            )
7354            .unwrap();
7355        harness.store.record_attempt(&terminal).unwrap();
7356        let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
7357            unreachable!()
7358        };
7359        *generation = "another-generation".into();
7360        provider
7361            .resources
7362            .lock()
7363            .unwrap()
7364            .insert(second_id, (stranger, EnvironmentState::Running));
7365        restarted
7366            .clean(terminal.id)
7367            .await
7368            .expect_err("ownership mismatch quarantined");
7369        assert_eq!(provider.resource_count(), 1);
7370        assert_eq!(
7371            harness.store.attempt(terminal.id).unwrap().unwrap().state(),
7372            AttemptState::CleanupDeferred
7373        );
7374        assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7375    }
7376
7377    #[tokio::test]
7378    async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
7379        for stage in [
7380            AttemptState::Allocated,
7381            AttemptState::Preparing,
7382            AttemptState::Prepared,
7383            AttemptState::JitReceived,
7384            AttemptState::Starting,
7385            AttemptState::Idle,
7386            AttemptState::Busy,
7387            AttemptState::Finished,
7388            AttemptState::Failed,
7389            AttemptState::Orphaned,
7390            AttemptState::Destroying,
7391            AttemptState::CleanupDeferred,
7392        ] {
7393            let harness = isolated_harness();
7394            let provider = Arc::new(FakeIsolatedProvider::default());
7395            let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7396            let id = AttemptId::from_u128(0x1000 + stage as u128);
7397            let runtime = harness.host_root().join(format!("phase-{stage}"));
7398            fs::create_dir_all(&runtime).unwrap();
7399            let mut attempt =
7400                RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7401            let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7402                harness.policy.execution_policy()
7403            else {
7404                unreachable!()
7405            };
7406            attempt
7407                .allocate_execution(AttemptExecution::Isolated {
7408                    provider_kind: Backend::Oci,
7409                    environment_id: None,
7410                    resolved_image: image.clone(),
7411                    generation: format!("generation-{stage}"),
7412                })
7413                .unwrap();
7414            if stage != AttemptState::Allocated {
7415                attempt.begin_prepare(harness.clock.now()).unwrap();
7416            }
7417            let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7418            if stage != AttemptState::Allocated {
7419                let resource_state = if matches!(
7420                    stage,
7421                    AttemptState::Finished
7422                        | AttemptState::Failed
7423                        | AttemptState::Orphaned
7424                        | AttemptState::Destroying
7425                        | AttemptState::CleanupDeferred
7426                ) {
7427                    EnvironmentState::Exited
7428                } else {
7429                    EnvironmentState::Running
7430                };
7431                provider
7432                    .resources
7433                    .lock()
7434                    .unwrap()
7435                    .insert(id, (identity.clone(), resource_state));
7436            }
7437            if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
7438                let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
7439                    unreachable!()
7440                };
7441                attempt
7442                    .prepared_environment(environment_id.clone())
7443                    .unwrap();
7444                attempt.mark_prepared(harness.clock.now()).unwrap();
7445            }
7446            if matches!(
7447                stage,
7448                AttemptState::JitReceived
7449                    | AttemptState::Starting
7450                    | AttemptState::Idle
7451                    | AttemptState::Busy
7452                    | AttemptState::Finished
7453                    | AttemptState::Failed
7454                    | AttemptState::Orphaned
7455                    | AttemptState::Destroying
7456                    | AttemptState::CleanupDeferred
7457            ) {
7458                attempt.jit_received(harness.clock.now()).unwrap();
7459            }
7460            if matches!(
7461                stage,
7462                AttemptState::Starting
7463                    | AttemptState::Idle
7464                    | AttemptState::Busy
7465                    | AttemptState::Finished
7466                    | AttemptState::Failed
7467                    | AttemptState::Orphaned
7468                    | AttemptState::Destroying
7469                    | AttemptState::CleanupDeferred
7470            ) {
7471                attempt.started_isolated(harness.clock.now()).unwrap();
7472            }
7473            if matches!(
7474                stage,
7475                AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
7476            ) {
7477                attempt.registered_idle(73, harness.clock.now()).unwrap();
7478            }
7479            if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
7480                attempt.assigned_job(73, harness.clock.now()).unwrap();
7481            }
7482            if matches!(
7483                stage,
7484                AttemptState::Finished
7485                    | AttemptState::Failed
7486                    | AttemptState::Orphaned
7487                    | AttemptState::Destroying
7488                    | AttemptState::CleanupDeferred
7489            ) {
7490                let outcome = match stage {
7491                    AttemptState::Finished => AttemptOutcome::CompletedJob,
7492                    AttemptState::Orphaned => AttemptOutcome::Orphaned,
7493                    _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7494                };
7495                attempt.conclude(outcome, harness.clock.now()).unwrap();
7496            }
7497            if matches!(
7498                stage,
7499                AttemptState::Destroying | AttemptState::CleanupDeferred
7500            ) {
7501                attempt.begin_destroy(harness.clock.now()).unwrap();
7502            }
7503            if stage == AttemptState::CleanupDeferred {
7504                attempt.defer_cleanup(harness.clock.now()).unwrap();
7505            }
7506            harness.store.record_attempt(&attempt).unwrap();
7507            launcher
7508                .recover_startup(std::slice::from_ref(&harness.policy))
7509                .await
7510                .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
7511            let resumed = harness.store.attempt(id).unwrap().unwrap();
7512            if matches!(
7513                stage,
7514                AttemptState::Allocated
7515                    | AttemptState::Preparing
7516                    | AttemptState::Prepared
7517                    | AttemptState::Finished
7518                    | AttemptState::Failed
7519                    | AttemptState::Orphaned
7520                    | AttemptState::Destroying
7521                    | AttemptState::CleanupDeferred
7522            ) {
7523                assert_eq!(
7524                    resumed.state(),
7525                    AttemptState::Cleaned,
7526                    "{stage} must retire after restart"
7527                );
7528            } else {
7529                assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
7530                assert_eq!(provider.resource_count(), 1);
7531                provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
7532                    EnvironmentState::Exited;
7533                launcher.supervise(&harness.policy).await.unwrap();
7534                assert_eq!(
7535                    harness.store.attempt(id).unwrap().unwrap().state(),
7536                    AttemptState::Cleaned
7537                );
7538            }
7539            assert_eq!(
7540                provider.resource_count(),
7541                0,
7542                "{stage} resource cleaned exactly once"
7543            );
7544            let destroyed = provider
7545                .actions
7546                .lock()
7547                .unwrap()
7548                .iter()
7549                .filter(|action| **action == "destroy")
7550                .count();
7551            assert_eq!(
7552                destroyed,
7553                usize::from(stage != AttemptState::Allocated),
7554                "{stage} destroy count"
7555            );
7556            assert_eq!(
7557                harness.github.registrations.load(Ordering::SeqCst),
7558                0,
7559                "restart from {stage} cannot duplicate JIT"
7560            );
7561            assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7562        }
7563    }
7564
7565    #[tokio::test]
7566    async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
7567        let harness = isolated_harness();
7568        let provider = Arc::new(FakeIsolatedProvider::default());
7569        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7570        let id = AttemptId::from_u128(0x7777);
7571        let runtime = harness.host_root().join("duplicate-provider-ownership");
7572        fs::create_dir_all(&runtime).unwrap();
7573        let mut attempt =
7574            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7575        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7576            harness.policy.execution_policy()
7577        else {
7578            unreachable!()
7579        };
7580        attempt
7581            .allocate_execution(AttemptExecution::Isolated {
7582                provider_kind: Backend::Oci,
7583                environment_id: None,
7584                resolved_image: image.clone(),
7585                generation: "duplicate-generation".into(),
7586            })
7587            .unwrap();
7588        attempt.begin_prepare(harness.clock.now()).unwrap();
7589        harness.store.record_attempt(&attempt).unwrap();
7590        let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7591        let mut second = first.clone();
7592        let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
7593            unreachable!()
7594        };
7595        *environment_id = "another-environment".into();
7596        provider
7597            .resources
7598            .lock()
7599            .unwrap()
7600            .insert(id, (first, EnvironmentState::Running));
7601        provider.extra_resources.lock().unwrap().push(second);
7602        launcher
7603            .recover_startup(std::slice::from_ref(&harness.policy))
7604            .await
7605            .expect("duplicate is local");
7606        let deferred = harness.store.attempt(id).unwrap().unwrap();
7607        assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7608        assert!(deferred.counts_against_capacity());
7609        assert_eq!(provider.resource_count(), 1);
7610        assert_eq!(
7611            provider
7612                .actions
7613                .lock()
7614                .unwrap()
7615                .iter()
7616                .filter(|action| **action == "destroy")
7617                .count(),
7618            0
7619        );
7620        provider.extra_resources.lock().unwrap().clear();
7621        // A tolerated cleanup failure is retried after its backoff delay.
7622        harness.clock.advance_secs(31);
7623        launcher
7624            .recover_startup(std::slice::from_ref(&harness.policy))
7625            .await
7626            .expect("single identity recovered");
7627        assert_eq!(provider.resource_count(), 0);
7628        assert_eq!(
7629            harness.store.attempt(id).unwrap().unwrap().state(),
7630            AttemptState::Cleaned
7631        );
7632    }
7633
7634    #[tokio::test]
7635    async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
7636        let harness = isolated_harness();
7637        let provider = Arc::new(FakeIsolatedProvider::default());
7638        let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7639        let id = AttemptId::from_u128(0x8888);
7640        let runtime = harness.host_root().join("orphan-provider-resource");
7641        fs::create_dir_all(&runtime).unwrap();
7642        let mut attempt =
7643            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7644        let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7645            harness.policy.execution_policy()
7646        else {
7647            unreachable!()
7648        };
7649        attempt
7650            .allocate_execution(AttemptExecution::Isolated {
7651                provider_kind: Backend::Oci,
7652                environment_id: None,
7653                resolved_image: image.clone(),
7654                generation: "orphan-generation".into(),
7655            })
7656            .unwrap();
7657        let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7658        provider
7659            .resources
7660            .lock()
7661            .unwrap()
7662            .insert(id, (identity, EnvironmentState::Running));
7663        launcher
7664            .recover_startup(std::slice::from_ref(&harness.policy))
7665            .await
7666            .expect("orphan cannot block host");
7667        assert_eq!(provider.resource_count(), 1);
7668        assert!(
7669            harness
7670                .events
7671                .events()
7672                .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
7673        );
7674        assert_eq!(
7675            provider
7676                .actions
7677                .lock()
7678                .unwrap()
7679                .iter()
7680                .filter(|action| **action == "destroy")
7681                .count(),
7682            0
7683        );
7684
7685        let EnvironmentIdentity::Isolated { environment_id, .. } = provider
7686            .resources
7687            .lock()
7688            .unwrap()
7689            .get(&id)
7690            .unwrap()
7691            .0
7692            .clone()
7693        else {
7694            unreachable!()
7695        };
7696        attempt.begin_prepare(harness.clock.now()).unwrap();
7697        attempt.prepared_environment(environment_id).unwrap();
7698        attempt.mark_prepared(harness.clock.now()).unwrap();
7699        attempt.jit_received(harness.clock.now()).unwrap();
7700        attempt.started_isolated(harness.clock.now()).unwrap();
7701        attempt
7702            .conclude(
7703                AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7704                harness.clock.now(),
7705            )
7706            .unwrap();
7707        attempt.begin_destroy(harness.clock.now()).unwrap();
7708        attempt.clean_isolated(harness.clock.now()).unwrap();
7709        harness.store.record_attempt(&attempt).unwrap();
7710        launcher
7711            .recover_startup(std::slice::from_ref(&harness.policy))
7712            .await
7713            .expect("cleaned row cannot authorize deletion");
7714        assert_eq!(provider.resource_count(), 1);
7715        assert_eq!(
7716            provider
7717                .actions
7718                .lock()
7719                .unwrap()
7720                .iter()
7721                .filter(|action| **action == "destroy")
7722                .count(),
7723            0
7724        );
7725    }
7726
7727    /// The wiring the three-hour outage needed and did not have.
7728    ///
7729    /// A root that refuses a launch does so before `record_allocation`, so no
7730    /// attempt row carries it, and the daemon's log scrubs the paths out of the
7731    /// sentence. The record this asserts is the only surface left, and
7732    /// `service status` reads it -- so if this wiring is ever dropped, the
7733    /// failure goes back to reading `runner_start_failed reason=other` once per
7734    /// poll and nothing else.
7735    #[tokio::test]
7736    async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
7737        use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
7738
7739        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7740            .with_host_runner_root();
7741        harness.ready().await;
7742
7743        // A root under two directories that do not exist: refused by the
7744        // preflight, for a reason that has nothing to do with this platform, so
7745        // the assertion holds on all three.
7746        let unusable = harness
7747            ._root
7748            .path()
7749            .join("absent")
7750            .join("deeper")
7751            .join("runners");
7752        let mut host = harness.host.clone();
7753        host.runner_root_override = Some(
7754            LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
7755                .expect("a local absolute host root"),
7756        );
7757        harness.store.put_host(&host).unwrap();
7758
7759        let failure = harness
7760            .launch_result()
7761            .await
7762            .expect_err("a root whose parents are missing cannot hold a runner");
7763        assert!(
7764            matches!(failure.reason, FailureReason::Other(_)),
7765            "{failure:?}"
7766        );
7767
7768        let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
7769        let refusal = refusals
7770            .first()
7771            .expect("the refusal reached the one surface that can hold it");
7772        assert_eq!(refusal.policy, harness.policy.id.to_string());
7773        assert_eq!(refusal.kind, "missing_parents");
7774        assert!(
7775            refusal.root.contains("runners") && refusal.detail.contains("runners"),
7776            "the directory must be named in full: {refusal:?}"
7777        );
7778
7779        // And a root that works clears it, so a host that has been fixed stops
7780        // reporting a fault it no longer has.
7781        harness.store.put_host(&harness.host).unwrap();
7782        harness.launch().await;
7783        assert!(
7784            runner_root_refusals(&harness.app_paths)
7785                .expect("readable")
7786                .is_empty(),
7787            "a successful placement clears that policy's record"
7788        );
7789
7790        clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
7791            .expect("cleanup");
7792    }
7793
7794    #[tokio::test]
7795    async fn a_launch_reports_how_long_each_step_took() {
7796        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7797        harness.ready().await;
7798        let started = harness.launch().await;
7799        let events = harness.events.events();
7800        let launched = events
7801            .iter()
7802            .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
7803            .expect("a successful launch reports its step timings");
7804        let starting = events
7805            .iter()
7806            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
7807            .unwrap();
7808        assert!(starting < launched, "timings follow the start: {events:?}");
7809    }
7810
7811    #[tokio::test]
7812    async fn a_failed_launch_still_reports_its_step_timings() {
7813        let harness = Harness::new(
7814            FakeGithubLifecycle::default().fail(true),
7815            Arc::new(PersistentDemand),
7816        );
7817        harness.ready().await;
7818        let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
7819        assert!(failed.is_err());
7820
7821        let timings = harness
7822            .events
7823            .events()
7824            .into_iter()
7825            .find_map(|event| match event {
7826                AttemptEvent::Launched {
7827                    started: false,
7828                    timings,
7829                    ..
7830                } => Some(timings),
7831                _ => None,
7832            })
7833            .expect("a launch that failed at registration reports its timings");
7834        assert_eq!(timings.lock_wait, Duration::from_secs(90));
7835        assert_eq!(timings.order_wait, Duration::from_secs(2));
7836        assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
7837        assert!(timings.total() >= Duration::from_secs(92));
7838    }
7839
7840    #[cfg(target_os = "macos")]
7841    #[tokio::test]
7842    async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
7843        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7844        harness.ready().await;
7845
7846        // A runtime this host once placed under a root it no longer uses.
7847        let old_root = harness._root.path().join("old-root");
7848        let old_runtime = old_root.join("abandoned");
7849        fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
7850        fs::create_dir_all(&old_runtime).unwrap();
7851        let old = RunnerAttempt::allocate(
7852            AttemptId::new_random(),
7853            harness.policy.id,
7854            &old_runtime,
7855            harness.clock.now(),
7856        );
7857        harness.store.record_attempt(&old).unwrap();
7858        let kept = harness.host_root().join(PACKAGE_SEED_DIR);
7859        fs::create_dir_all(kept.join("2.336.0")).unwrap();
7860
7861        harness.launch().await;
7862
7863        assert!(
7864            !old_root.join(PACKAGE_SEED_DIR).exists(),
7865            "the abandoned root's seed is removed"
7866        );
7867        assert!(old_runtime.exists(), "only the seed is touched");
7868        assert!(kept.exists(), "the host root's own seed is kept");
7869    }
7870
7871    #[tokio::test]
7872    async fn a_job_walks_every_state_and_cleans_every_artifact() {
7873        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7874        harness.ready().await;
7875        let started = harness.launch().await;
7876        assert_eq!(started.state(), AttemptState::Starting);
7877        assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7878
7879        harness
7880            .github
7881            .observe(GithubRunnerObservation::Registered { busy: false });
7882        harness.launcher.supervise(&harness.policy).await.unwrap();
7883        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7884
7885        harness
7886            .github
7887            .observe(GithubRunnerObservation::Registered { busy: true });
7888        harness.launcher.supervise(&harness.policy).await.unwrap();
7889        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7890
7891        harness.processes.finish_successfully();
7892        harness
7893            .github
7894            .observe(GithubRunnerObservation::NotRegistered);
7895        harness.launcher.supervise(&harness.policy).await.unwrap();
7896        let cleaned = harness.only_attempt();
7897        assert_eq!(cleaned.state(), AttemptState::Cleaned);
7898        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7899        assert!(!started.runtime_path().exists());
7900        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7901        assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7902
7903        let states: Vec<_> = harness
7904            .events
7905            .events()
7906            .into_iter()
7907            .filter_map(|event| match event {
7908                AttemptEvent::State { state, .. } => Some(state),
7909                _ => None,
7910            })
7911            .collect();
7912        assert_eq!(
7913            states,
7914            vec![
7915                AttemptState::Allocated,
7916                AttemptState::JitReceived,
7917                AttemptState::Starting,
7918                AttemptState::Idle,
7919                AttemptState::Busy,
7920                AttemptState::Finished,
7921                AttemptState::Cleaned,
7922            ]
7923        );
7924    }
7925
7926    #[tokio::test]
7927    async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7928        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7929        harness.ready().await;
7930        let started = harness.launch().await;
7931        let runtime = started.runtime_path().to_path_buf();
7932
7933        harness
7934            .github
7935            .observe(GithubRunnerObservation::Registered { busy: true });
7936        harness.launcher.supervise(&harness.policy).await.unwrap();
7937        harness.processes.finish_successfully();
7938        harness
7939            .github
7940            .observe(GithubRunnerObservation::NotRegistered);
7941        harness.launcher.supervise(&harness.policy).await.unwrap();
7942
7943        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7944        assert!(!runtime.exists());
7945        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7946
7947        // Reproduce a late orphan creating output after the successful removal
7948        // and journal transition. The next ordinary policy supervision must
7949        // remove it without replaying package release or another state change.
7950        let residue = runtime.join("_work").join("late-node-process");
7951        fs::create_dir_all(&residue).unwrap();
7952        fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7953
7954        harness.launcher.supervise(&harness.policy).await.unwrap();
7955
7956        assert!(!runtime.exists(), "late ephemeral residue is reaped");
7957        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7958        assert_eq!(
7959            harness.packages.releases.load(Ordering::SeqCst),
7960            1,
7961            "reaping residue does not release the package lease twice"
7962        );
7963    }
7964
7965    #[tokio::test]
7966    async fn locked_late_residue_never_blocks_startup_recovery() {
7967        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7968        harness.ready().await;
7969        let started = harness.launch().await;
7970        let runtime = started.runtime_path().to_path_buf();
7971
7972        harness
7973            .github
7974            .observe(GithubRunnerObservation::Registered { busy: true });
7975        harness.launcher.supervise(&harness.policy).await.unwrap();
7976        harness.processes.finish_successfully();
7977        harness
7978            .github
7979            .observe(GithubRunnerObservation::NotRegistered);
7980        harness.launcher.supervise(&harness.policy).await.unwrap();
7981        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7982
7983        let held = runtime.join("_work").join("late-node-process");
7984        fs::create_dir_all(&held).unwrap();
7985        fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7986        let Some(block) = BlockedDeletion::inject(&held) else {
7987            eprintln!(
7988                "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7989            );
7990            return;
7991        };
7992
7993        let restarted = harness.restart();
7994        restarted
7995            .recover_startup(std::slice::from_ref(&harness.policy))
7996            .await
7997            .expect("late residue cannot take the daemon offline");
7998        assert!(runtime.exists(), "the locked residue remains for a retry");
7999        assert!(
8000            harness
8001                .reconcile_events
8002                .events()
8003                .iter()
8004                .any(|event| matches!(
8005                    event,
8006                    LifecycleEvent::AttemptCleanFailed {
8007                        attempt,
8008                        reason: "late_ephemeral_workspace_could_not_be_removed",
8009                        ..
8010                    } if *attempt == started.id
8011                )),
8012            "the non-blocking cleanup failure remains visible"
8013        );
8014
8015        block.release();
8016        // A tolerated cleanup failure is retried after its backoff delay.
8017        harness.clock.advance_secs(31);
8018        restarted
8019            .supervise(&harness.policy)
8020            .await
8021            .expect("the ordinary retry succeeds after the lock is released");
8022        assert!(!runtime.exists(), "the retry removes the late residue");
8023        assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
8024        assert_eq!(
8025            harness.packages.releases.load(Ordering::SeqCst),
8026            1,
8027            "retrying residue never releases the package lease twice"
8028        );
8029    }
8030
8031    #[tokio::test]
8032    async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
8033        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8034        harness.ready().await;
8035
8036        // The first attempt is terminal but its initial cleanup cannot finish.
8037        // This is the state left by the original failed job on the affected
8038        // workstation.
8039        let terminal = harness.launch().await;
8040        let terminal_runtime = terminal.runtime_path().to_path_buf();
8041        harness.conclude(terminal.id);
8042        let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
8043        else {
8044            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
8045            return;
8046        };
8047
8048        // The second attempt was cleaned successfully, then a child recreated
8049        // part of `_work`. Both real-world residue shapes existed together in
8050        // the incident, and neither may short-circuit recovery of the other.
8051        let cleaned = harness.launch().await;
8052        let cleaned_runtime = cleaned.runtime_path().to_path_buf();
8053        harness.conclude(cleaned.id);
8054        harness
8055            .launcher
8056            .clean(cleaned.id)
8057            .await
8058            .expect("the second attempt initially cleans");
8059        let Some(cleaned_block) =
8060            BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
8061        else {
8062            terminal_block.release();
8063            eprintln!("skipped: this account cannot inject the two independent deletion refusals");
8064            return;
8065        };
8066
8067        let restarted = harness.restart();
8068        for _ in 0..2 {
8069            restarted
8070                .recover_startup(std::slice::from_ref(&harness.policy))
8071                .await
8072                .expect("repeated recovery remains ready while both residues are locked");
8073        }
8074        assert_ne!(
8075            harness.attempt(terminal.id).state(),
8076            AttemptState::Cleaned,
8077            "the journal does not claim the terminal workspace was removed"
8078        );
8079        assert_eq!(
8080            harness.attempt(cleaned.id).state(),
8081            AttemptState::Cleaned,
8082            "late residue does not undo an already durable cleaned transition"
8083        );
8084        assert!(terminal_runtime.exists());
8085        assert!(cleaned_runtime.exists());
8086        assert_eq!(
8087            harness.packages.releases.load(Ordering::SeqCst),
8088            1,
8089            "repeated recovery neither loses nor duplicates package leases"
8090        );
8091
8092        terminal_block.release();
8093        cleaned_block.release();
8094        // A tolerated cleanup failure is retried after its backoff delay.
8095        harness.clock.advance_secs(31);
8096        restarted
8097            .supervise(&harness.policy)
8098            .await
8099            .expect("one ordinary pass clears both residues after their locks disappear");
8100        assert!(!terminal_runtime.exists());
8101        assert!(!cleaned_runtime.exists());
8102        assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
8103        assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
8104        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
8105    }
8106
8107    #[tokio::test]
8108    async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
8109        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8110        harness.ready().await;
8111        let attempt = harness.launch().await;
8112        let runtime = attempt.runtime_path().to_path_buf();
8113        harness.conclude(attempt.id);
8114        fs::remove_dir_all(&runtime).unwrap();
8115
8116        harness
8117            .restart()
8118            .recover_startup(std::slice::from_ref(&harness.policy))
8119            .await
8120            .expect("an already absent disposable runtime is successful cleanup");
8121
8122        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
8123        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8124    }
8125
8126    #[tokio::test]
8127    async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
8128        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8129        harness.ready().await;
8130        let attempt = harness.launch().await;
8131        let runtime = attempt.runtime_path().to_path_buf();
8132        harness.conclude(attempt.id);
8133        harness.packages.fail_releases(1);
8134
8135        let restarted = harness.restart();
8136        let failure = restarted
8137            .recover_startup(std::slice::from_ref(&harness.policy))
8138            .await
8139            .expect_err("a package-accounting failure is not safe to downgrade to residue");
8140        assert!(
8141            failure
8142                .reason()
8143                .to_string()
8144                .contains("package lease could not be released")
8145        );
8146        assert!(
8147            !runtime.exists(),
8148            "workspace removal completed before release failed"
8149        );
8150        assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
8151
8152        restarted
8153            .recover_startup(std::slice::from_ref(&harness.policy))
8154            .await
8155            .expect("the same journal entry retries safely after the transient failure");
8156        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
8157        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8158    }
8159
8160    #[tokio::test]
8161    async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
8162        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
8163            .with_persistent_workspace(1);
8164        harness.ready().await;
8165        let attempt = harness.launch().await;
8166        let retained = attempt.runtime_path().join("_work").join("checkout-marker");
8167        fs::create_dir_all(retained.parent().unwrap()).unwrap();
8168        fs::write(&retained, b"persistent checkout").unwrap();
8169        harness.cleanup_retaining_work(attempt.id).await;
8170
8171        harness
8172            .restart()
8173            .recover_startup(std::slice::from_ref(&harness.policy))
8174            .await
8175            .expect("a cleaned persistent slot is already reconciled");
8176
8177        assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
8178        assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
8179    }
8180
8181    #[tokio::test]
8182    async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
8183        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8184        harness.ready().await;
8185        let started = harness.launch().await;
8186        harness
8187            .github
8188            .observe(GithubRunnerObservation::Registered { busy: false });
8189        harness.launcher.supervise(&harness.policy).await.unwrap();
8190        harness.clock.advance_secs(11);
8191        harness.processes.set_alive(false);
8192        harness
8193            .github
8194            .observe(GithubRunnerObservation::NotRegistered);
8195        harness.launcher.supervise(&harness.policy).await.unwrap();
8196
8197        let cleaned = harness.only_attempt();
8198        assert!(cleaned.outcome().unwrap().is_idle_exit());
8199        assert!(!cleaned.outcome().unwrap().is_failure());
8200        assert!(!started.runtime_path().exists());
8201        assert!(
8202            harness
8203                .reconcile_events
8204                .events()
8205                .iter()
8206                .any(|event| matches!(
8207                    event,
8208                    LifecycleEvent::AttemptCleaned {
8209                        outcome: OutcomeKind::IdleExit,
8210                        ..
8211                    }
8212                ))
8213        );
8214        assert!(!harness.events.events().iter().any(|event| matches!(
8215            event,
8216            AttemptEvent::Concluded {
8217                outcome: OutcomeKind::Failed,
8218                ..
8219            }
8220        )));
8221    }
8222
8223    #[tokio::test]
8224    async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
8225        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8226        harness.processes.fail_spawns(2);
8227        harness.ready().await;
8228        let attempt = harness.launch().await;
8229        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
8230        assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
8231        let names: Vec<_> = fs::read_dir(attempt.runtime_path())
8232            .unwrap()
8233            .map(|entry| entry.unwrap().file_name())
8234            .collect();
8235        assert!(
8236            names.iter().all(|name| {
8237                !name
8238                    .to_string_lossy()
8239                    .starts_with(RestrictiveHandoff::NAME_PREFIX)
8240            }),
8241            "JIT artifact survived: {names:?}"
8242        );
8243        assert_eq!(
8244            *harness.delay.0.lock().unwrap(),
8245            vec![Duration::from_millis(10), Duration::from_millis(20)]
8246        );
8247    }
8248
8249    #[tokio::test]
8250    async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
8251        let gone = Harness::new(
8252            FakeGithubLifecycle::default().fail(false),
8253            Arc::new(FakeDemand::answering([false])),
8254        );
8255        gone.ready().await;
8256        assert!(gone.launch_result().await.is_err());
8257        assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
8258        assert!(gone.delay.0.lock().unwrap().is_empty());
8259
8260        let forbidden = Harness::new(
8261            FakeGithubLifecycle::default().fail(true),
8262            Arc::new(PersistentDemand),
8263        );
8264        forbidden.ready().await;
8265        assert!(forbidden.launch_result().await.is_err());
8266        assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
8267        assert!(forbidden.delay.0.lock().unwrap().is_empty());
8268        assert!(matches!(
8269            forbidden.only_attempt().outcome(),
8270            Some(AttemptOutcome::Failed {
8271                reason: FailureReason::Other(action)
8272            }) if action.contains("403")
8273        ));
8274
8275        let transient = Harness::new(
8276            FakeGithubLifecycle::default().fail(false).fail(false),
8277            Arc::new(PersistentDemand),
8278        );
8279        transient.ready().await;
8280        transient.launch().await;
8281        assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
8282        assert_eq!(
8283            *transient.delay.0.lock().unwrap(),
8284            vec![Duration::from_millis(10), Duration::from_millis(20)]
8285        );
8286    }
8287
8288    /// The layout has to leave room for what the runner writes underneath it.
8289    ///
8290    /// Windows refuses a path over `MAX_PATH`, and this product's own CI hit
8291    /// that: 264 characters against a limit of 260, failing three checkout
8292    /// retries with `Filename too long`. The two identifiers in the old layout
8293    /// cost 74 characters between them for no benefit -- an attempt id is
8294    /// unique on its own.
8295    #[test]
8296    fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
8297        const MAX_PATH: usize = 260;
8298        // The real root on the machine this was found on.
8299        let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
8300        // What `actions/checkout` writes at its deepest: the work directory,
8301        // the repository named twice, and a pack keep-file with a 40-character
8302        // object name.
8303        let repo = "GitHub-Runner-Scaler-UI";
8304        let deepest = format!(
8305            r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
8306            "0".repeat(40)
8307        );
8308
8309        let name = workspace_name(AttemptId::new_random());
8310        assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
8311        assert!(
8312            name.chars().all(|c| c.is_ascii_hexdigit()),
8313            "a directory name must not carry the identifier's dashes: {name}"
8314        );
8315
8316        let full = format!(r"{root}\{name}\{deepest}");
8317        assert!(
8318            full.len() < MAX_PATH,
8319            "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
8320            full.len()
8321        );
8322
8323        // The discriminator: the layout this replaced does not fit, so a test
8324        // that passed for both would be proving nothing.
8325        let old = format!(
8326            r"{root}\{}\{}\{deepest}",
8327            PolicyId::new_random(),
8328            AttemptId::new_random()
8329        );
8330        assert!(
8331            old.len() > MAX_PATH,
8332            "the old layout is supposed to be the thing that did not fit: {} characters",
8333            old.len()
8334        );
8335    }
8336
8337    #[tokio::test]
8338    async fn two_attempts_never_share_a_workspace_even_after_failure() {
8339        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8340        harness.ready().await;
8341        let first = harness.launch().await;
8342        fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
8343        harness
8344            .github
8345            .observe(GithubRunnerObservation::Registered { busy: false });
8346        harness.launcher.supervise(&harness.policy).await.unwrap();
8347        harness.clock.advance_secs(11);
8348        harness.processes.set_alive(false);
8349        harness
8350            .github
8351            .observe(GithubRunnerObservation::NotRegistered);
8352        harness.launcher.supervise(&harness.policy).await.unwrap();
8353        assert!(!first.runtime_path().exists());
8354
8355        let second = harness.launch().await;
8356        assert_ne!(first.runtime_path(), second.runtime_path());
8357        assert!(!second.runtime_path().join("hostile-leftover").exists());
8358
8359        fs::write(
8360            second.runtime_path().join("hostile-on-failure"),
8361            b"second job",
8362        )
8363        .unwrap();
8364        harness.processes.set_alive(false);
8365        harness
8366            .github
8367            .observe(GithubRunnerObservation::NotRegistered);
8368        harness.launcher.supervise(&harness.policy).await.unwrap();
8369        assert!(
8370            !second.runtime_path().exists(),
8371            "failed workspace was retained"
8372        );
8373    }
8374
8375    #[tokio::test]
8376    async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
8377        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8378        harness.ready().await;
8379        let attempt = harness.launch().await;
8380
8381        // Registered and waiting, which is where it stays: the fake keeps
8382        // answering the same observation, exactly as GitHub does for a runner
8383        // nobody assigns work to.
8384        harness
8385            .github
8386            .observe(GithubRunnerObservation::Registered { busy: false });
8387        harness.launcher.supervise(&harness.policy).await.unwrap();
8388        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8389
8390        // One second inside the ten-second idle timeout nothing happens, which
8391        // is what keeps this from being a test that would pass on any clock.
8392        harness.clock.advance_secs(9);
8393        harness
8394            .github
8395            .observe(GithubRunnerObservation::Registered { busy: false });
8396        let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
8397        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8398        assert!(none_yet.is_empty());
8399        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8400
8401        // Past it, the agent ends the runner itself.
8402        harness.clock.advance_secs(1);
8403        harness
8404            .github
8405            .observe(GithubRunnerObservation::Registered { busy: false });
8406        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8407
8408        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8409        assert_eq!(
8410            concluded.outcome(),
8411            Some(&AttemptOutcome::ExitedIdleWithoutWork),
8412            "a surplus runner did not fail; recording one as a failure sends an operator \
8413             hunting a fault that does not exist"
8414        );
8415        assert_eq!(concluded.state(), AttemptState::Cleaned);
8416        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8417        assert!(!attempt.runtime_path().exists());
8418
8419        // The registration goes with it. Without this the runner stays listed
8420        // in the target's runner settings after the process it named is gone.
8421        assert_eq!(
8422            *harness.github.deregistrations.lock().unwrap(),
8423            vec![73],
8424            "the attempt's own runner id, deleted exactly once"
8425        );
8426
8427        // And nothing is started in its place: the work it was launched for went
8428        // elsewhere, so a replacement would rebuild it every idle timeout.
8429        assert!(
8430            replacements.is_empty(),
8431            "a surplus exit must not request a replacement"
8432        );
8433    }
8434
8435    #[tokio::test]
8436    async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
8437        // The delete is best-effort by construction: the process is gone and the
8438        // slot has to come back. Holding the conclusion until GitHub cooperates
8439        // would leak a capacity slot on every unreachable moment.
8440        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8441        harness.ready().await;
8442        let attempt = harness.launch().await;
8443        harness
8444            .github
8445            .observe(GithubRunnerObservation::Registered { busy: false });
8446        harness.launcher.supervise(&harness.policy).await.unwrap();
8447
8448        harness
8449            .github
8450            .deregistration_fails
8451            .store(true, Ordering::SeqCst);
8452        harness.clock.advance_secs(11);
8453        harness
8454            .github
8455            .observe(GithubRunnerObservation::Registered { busy: false });
8456        harness.launcher.supervise(&harness.policy).await.unwrap();
8457
8458        assert_eq!(
8459            *harness.github.deregistrations.lock().unwrap(),
8460            vec![73],
8461            "the delete was attempted"
8462        );
8463        let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8464        assert_eq!(
8465            concluded.outcome(),
8466            Some(&AttemptOutcome::ExitedIdleWithoutWork),
8467            "the attempt concluded anyway"
8468        );
8469        assert_eq!(concluded.state(), AttemptState::Cleaned);
8470        assert!(!attempt.runtime_path().exists());
8471    }
8472
8473    #[tokio::test]
8474    async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
8475        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8476        harness.ready().await;
8477        let first = harness.launch().await;
8478        harness.processes.set_alive(false);
8479        harness
8480            .github
8481            .observe(GithubRunnerObservation::NotRegistered);
8482        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8483        let failed = harness.store.attempt(first.id).unwrap().unwrap();
8484        assert!(matches!(
8485            failed.outcome(),
8486            Some(AttemptOutcome::Failed {
8487                reason: FailureReason::ProcessExitedUnexpectedly
8488            })
8489        ));
8490        assert!(!first.runtime_path().exists());
8491
8492        assert_eq!(
8493            replacements,
8494            vec![ReplacementIntent {
8495                policy: harness.policy.id,
8496                previous_attempt: first.id,
8497                operation: "exit_before_acceptance_replacement",
8498            }]
8499        );
8500        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8501        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8502        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8503        assert!(harness.delay.0.lock().unwrap().is_empty());
8504    }
8505
8506    /// Writes the runner's own diagnostics: always the listener's log, and the
8507    /// job worker's when `ran_a_job`. `Runner.Listener` starts `Runner.Worker`
8508    /// only for a job it has acquired.
8509    fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
8510        let diag = runtime.join("_diag");
8511        fs::create_dir_all(&diag).unwrap();
8512        fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
8513        if ran_a_job {
8514            fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
8515        }
8516    }
8517
8518    /// The macOS field case: a job of a few seconds starts and ends between two
8519    /// passes, so GitHub was never seen reporting the runner busy -- the attempt
8520    /// is still `starting` -- and the registration is already gone. It used to be
8521    /// recorded `process_exited_unexpectedly`, with a replacement runner.
8522    #[tokio::test]
8523    async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
8524        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8525        harness.ready().await;
8526        let started = harness.launch().await;
8527        assert_eq!(started.state(), AttemptState::Starting);
8528        write_runner_diagnostics(started.runtime_path(), true);
8529        harness.processes.finish_successfully();
8530        harness
8531            .github
8532            .observe(GithubRunnerObservation::NotRegistered);
8533
8534        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8535
8536        let cleaned = harness.only_attempt();
8537        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8538        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8539        assert_eq!(cleaned.github_runner_id(), Some(73));
8540        assert!(
8541            replacements.is_empty(),
8542            "a finished job needs no replacement"
8543        );
8544        let busy_seen = harness.events.events().into_iter().any(|event| {
8545            matches!(
8546                event,
8547                AttemptEvent::State {
8548                    state: AttemptState::Busy,
8549                    ..
8550                }
8551            )
8552        });
8553        assert!(busy_seen, "the journal walks the diagram to `busy` first");
8554    }
8555
8556    /// The same from `idle`: seen registered and waiting once, then gone.
8557    #[tokio::test]
8558    async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
8559        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8560        harness.ready().await;
8561        let started = harness.launch().await;
8562        harness
8563            .github
8564            .observe(GithubRunnerObservation::Registered { busy: false });
8565        harness.launcher.supervise(&harness.policy).await.unwrap();
8566        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8567
8568        write_runner_diagnostics(started.runtime_path(), true);
8569        harness.processes.finish_successfully();
8570        harness
8571            .github
8572            .observe(GithubRunnerObservation::NotRegistered);
8573        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8574
8575        assert_eq!(
8576            harness.only_attempt().outcome(),
8577            Some(&AttemptOutcome::CompletedJob)
8578        );
8579        assert!(replacements.is_empty());
8580    }
8581
8582    /// The reverse error, from the same sampling: GitHub showed the runner
8583    /// assigned, the runner never started the job (no worker log), and it exited
8584    /// 0. That is not a completed job.
8585    #[tokio::test]
8586    async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
8587        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8588        harness.ready().await;
8589        let started = harness.launch().await;
8590        harness
8591            .github
8592            .observe(GithubRunnerObservation::Registered { busy: true });
8593        harness.launcher.supervise(&harness.policy).await.unwrap();
8594        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8595
8596        write_runner_diagnostics(started.runtime_path(), false);
8597        harness.processes.finish_successfully();
8598        harness
8599            .github
8600            .observe(GithubRunnerObservation::NotRegistered);
8601        harness.launcher.supervise(&harness.policy).await.unwrap();
8602
8603        let cleaned = harness.only_attempt();
8604        assert_eq!(
8605            cleaned.outcome(),
8606            Some(&AttemptOutcome::ExitedIdleWithoutWork)
8607        );
8608        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8609    }
8610
8611    /// The mirror case: the runner really ran its job and exited 0 before GitHub
8612    /// took it off the list. It used to be recorded `orphaned`.
8613    #[tokio::test]
8614    async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
8615        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8616        harness.ready().await;
8617        let started = harness.launch().await;
8618        harness
8619            .github
8620            .observe(GithubRunnerObservation::Registered { busy: true });
8621        harness.launcher.supervise(&harness.policy).await.unwrap();
8622
8623        write_runner_diagnostics(started.runtime_path(), true);
8624        harness.processes.finish_successfully();
8625        harness
8626            .github
8627            .observe(GithubRunnerObservation::Registered { busy: true });
8628        harness.launcher.supervise(&harness.policy).await.unwrap();
8629
8630        assert_eq!(
8631            harness.only_attempt().outcome(),
8632            Some(&AttemptOutcome::CompletedJob)
8633        );
8634        assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
8635    }
8636
8637    /// A live runner journalled `busy` whose assignment GitHub withdrew goes
8638    /// back to `idle`, where the idle timeout holds it again.
8639    #[tokio::test]
8640    async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
8641        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8642        harness.ready().await;
8643        let started = harness.launch().await;
8644        harness
8645            .github
8646            .observe(GithubRunnerObservation::Registered { busy: true });
8647        harness.launcher.supervise(&harness.policy).await.unwrap();
8648        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8649
8650        write_runner_diagnostics(started.runtime_path(), false);
8651        harness
8652            .github
8653            .observe(GithubRunnerObservation::Registered { busy: false });
8654        harness.launcher.supervise(&harness.policy).await.unwrap();
8655        assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8656
8657        // A runner that did start its job keeps `busy` whatever GitHub says.
8658        harness
8659            .github
8660            .observe(GithubRunnerObservation::Registered { busy: true });
8661        harness.launcher.supervise(&harness.policy).await.unwrap();
8662        write_runner_diagnostics(started.runtime_path(), true);
8663        harness
8664            .github
8665            .observe(GithubRunnerObservation::Registered { busy: false });
8666        harness.launcher.supervise(&harness.policy).await.unwrap();
8667        assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8668    }
8669
8670    /// While GitHub cannot be reached nothing is concluded, so a registration
8671    /// still listed there is removed once it answers.
8672    #[tokio::test]
8673    async fn a_clean_exit_waits_for_github_to_answer() {
8674        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8675        harness.ready().await;
8676        let started = harness.launch().await;
8677        write_runner_diagnostics(started.runtime_path(), true);
8678        harness.processes.finish_successfully();
8679        harness.github.observe(GithubRunnerObservation::Unreachable);
8680        harness.launcher.supervise(&harness.policy).await.unwrap();
8681        assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
8682
8683        harness
8684            .github
8685            .observe(GithubRunnerObservation::NotRegistered);
8686        harness.launcher.supervise(&harness.policy).await.unwrap();
8687        assert_eq!(
8688            harness.only_attempt().outcome(),
8689            Some(&AttemptOutcome::CompletedJob)
8690        );
8691    }
8692
8693    /// A runner id nobody can name is not a reason to stop supervising the
8694    /// policy: the attempt is concluded the way it was before, without one.
8695    #[tokio::test]
8696    async fn a_short_job_with_no_runner_id_still_concludes() {
8697        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8698        harness.ready().await;
8699        let started = harness.launch().await;
8700        fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
8701        write_runner_diagnostics(started.runtime_path(), true);
8702        harness.processes.finish_successfully();
8703        harness
8704            .github
8705            .observe(GithubRunnerObservation::NotRegistered);
8706        harness
8707            .launcher
8708            .supervise(&harness.policy)
8709            .await
8710            .expect("supervision goes on");
8711        assert!(harness.only_attempt().is_terminal());
8712    }
8713
8714    /// Without the runner's diagnostics nothing changes: a clean exit seen only
8715    /// at `starting` is still a runner that exited before taking a job.
8716    #[tokio::test]
8717    async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
8718        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8719        harness.ready().await;
8720        harness.launch().await;
8721        harness.processes.finish_successfully();
8722        harness
8723            .github
8724            .observe(GithubRunnerObservation::NotRegistered);
8725        harness.launcher.supervise(&harness.policy).await.unwrap();
8726        assert!(matches!(
8727            harness.only_attempt().outcome(),
8728            Some(AttemptOutcome::Failed {
8729                reason: FailureReason::ProcessExitedUnexpectedly
8730            })
8731        ));
8732    }
8733
8734    #[tokio::test]
8735    async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
8736        let harness = Harness::new(
8737            FakeGithubLifecycle::default(),
8738            Arc::new(FakeDemand::answering([false])),
8739        );
8740        let id = AttemptId::new_random();
8741        let runtime = harness
8742            .launcher
8743            .app_paths
8744            .runtime_dir()
8745            .join(harness.policy.id.to_string())
8746            .join(id.to_string());
8747        fs::create_dir_all(&runtime).unwrap();
8748        let mut attempt =
8749            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8750        attempt.jit_received(harness.clock.now()).unwrap();
8751        harness.store.record_attempt(&attempt).unwrap();
8752        harness.clock.advance_secs(11);
8753        let replacements = harness
8754            .launcher
8755            .recover_startup(std::slice::from_ref(&harness.policy))
8756            .await
8757            .unwrap();
8758        assert_eq!(
8759            replacements,
8760            vec![ReplacementIntent {
8761                policy: harness.policy.id,
8762                previous_attempt: id,
8763                operation: "jit_expired_replacement",
8764            }]
8765        );
8766
8767        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8768        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8769        assert!(matches!(
8770            cleaned.outcome(),
8771            Some(AttemptOutcome::Failed {
8772                reason: FailureReason::JitExpired
8773            })
8774        ));
8775        assert!(!runtime.exists());
8776        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8777        assert!(harness.delay.0.lock().unwrap().is_empty());
8778    }
8779
8780    #[tokio::test]
8781    async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
8782        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8783        let id = AttemptId::new_random();
8784        let runtime = harness
8785            .launcher
8786            .app_paths
8787            .runtime_dir()
8788            .join("expired-with-demand");
8789        fs::create_dir_all(&runtime).unwrap();
8790        let mut attempt =
8791            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8792        attempt.jit_received(harness.clock.now()).unwrap();
8793        harness.store.record_attempt(&attempt).unwrap();
8794        harness.clock.advance_secs(11);
8795        let replacements = harness
8796            .launcher
8797            .recover_startup(std::slice::from_ref(&harness.policy))
8798            .await
8799            .unwrap();
8800
8801        let attempts = harness.store.attempts().unwrap();
8802        assert_eq!(attempts.len(), 1);
8803        assert_eq!(
8804            attempts
8805                .iter()
8806                .find(|attempt| attempt.id == id)
8807                .unwrap()
8808                .state(),
8809            AttemptState::Cleaned
8810        );
8811        assert_eq!(
8812            replacements,
8813            vec![ReplacementIntent {
8814                policy: harness.policy.id,
8815                previous_attempt: id,
8816                operation: "jit_expired_replacement",
8817            }]
8818        );
8819        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8820        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8821        assert!(harness.delay.0.lock().unwrap().is_empty());
8822    }
8823
8824    #[tokio::test]
8825    async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
8826        let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8827        persistent.packages.fail_materializations(2);
8828        persistent.ready().await;
8829        persistent.launch().await;
8830        assert_eq!(
8831            persistent.packages.materializations.load(Ordering::SeqCst),
8832            3
8833        );
8834        assert_eq!(
8835            *persistent.delay.0.lock().unwrap(),
8836            vec![Duration::from_millis(10), Duration::from_millis(20)]
8837        );
8838
8839        let gone_before_wait = Harness::new(
8840            FakeGithubLifecycle::default(),
8841            Arc::new(FakeDemand::answering([false])),
8842        );
8843        gone_before_wait.packages.fail_materializations(3);
8844        gone_before_wait.ready().await;
8845        assert!(gone_before_wait.launch_result().await.is_err());
8846        assert_eq!(
8847            gone_before_wait
8848                .packages
8849                .materializations
8850                .load(Ordering::SeqCst),
8851            1
8852        );
8853        assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
8854
8855        let gone_during_wait = Harness::new(
8856            FakeGithubLifecycle::default(),
8857            Arc::new(FakeDemand::answering([true, false])),
8858        );
8859        gone_during_wait.packages.fail_materializations(3);
8860        gone_during_wait.ready().await;
8861        assert!(gone_during_wait.launch_result().await.is_err());
8862        assert_eq!(
8863            gone_during_wait
8864                .packages
8865                .materializations
8866                .load(Ordering::SeqCst),
8867            1
8868        );
8869        assert_eq!(
8870            *gone_during_wait.delay.0.lock().unwrap(),
8871            vec![Duration::from_millis(10)]
8872        );
8873    }
8874
8875    #[tokio::test]
8876    async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8877        let harness = Harness::new(
8878            FakeGithubLifecycle::default(),
8879            Arc::new(FakeDemand::answering([true, false])),
8880        );
8881        harness.ready().await;
8882        let first = harness.launch().await;
8883        harness.processes.set_alive(false);
8884        harness
8885            .github
8886            .observe(GithubRunnerObservation::NotRegistered);
8887        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8888
8889        assert_eq!(harness.store.attempts().unwrap().len(), 1);
8890        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8891        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8892        assert!(harness.delay.0.lock().unwrap().is_empty());
8893        assert_eq!(
8894            replacements,
8895            vec![ReplacementIntent {
8896                policy: harness.policy.id,
8897                previous_attempt: first.id,
8898                operation: "exit_before_acceptance_replacement",
8899            }]
8900        );
8901        assert_eq!(
8902            harness.store.attempt(first.id).unwrap().unwrap().state(),
8903            AttemptState::Cleaned
8904        );
8905    }
8906
8907    #[tokio::test]
8908    async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8909        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8910        let before = harness.launch_result().await;
8911        assert!(before.is_err());
8912        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8913
8914        let id = AttemptId::new_random();
8915        let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8916        fs::create_dir_all(&runtime).unwrap();
8917        let mut attempt =
8918            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8919        attempt.jit_received(harness.clock.now()).unwrap();
8920        attempt.started(4242, harness.clock.now()).unwrap();
8921        harness.store.record_attempt(&attempt).unwrap();
8922        harness.processes.set_alive(true);
8923        harness
8924            .github
8925            .observe(GithubRunnerObservation::NotRegistered);
8926        let replacements = harness
8927            .launcher
8928            .recover_startup(std::slice::from_ref(&harness.policy))
8929            .await
8930            .unwrap();
8931        assert!(replacements.is_empty());
8932        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8933        assert!(
8934            harness
8935                .events
8936                .events()
8937                .contains(&AttemptEvent::Adopted { attempt: id })
8938        );
8939    }
8940
8941    #[tokio::test]
8942    async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8943        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8944        let id = AttemptId::new_random();
8945        let runtime = harness
8946            .launcher
8947            .app_paths
8948            .runtime_dir()
8949            .join("spawn-before-starting");
8950        fs::create_dir_all(&runtime).unwrap();
8951        let mut attempt =
8952            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8953        attempt.jit_received(harness.clock.now()).unwrap();
8954        harness.store.record_attempt(&attempt).unwrap();
8955        harness.processes.set_alive(true);
8956        harness
8957            .github
8958            .observe(GithubRunnerObservation::Registered { busy: true });
8959
8960        let replacements = harness
8961            .launcher
8962            .recover_startup(std::slice::from_ref(&harness.policy))
8963            .await
8964            .unwrap();
8965        assert!(replacements.is_empty());
8966        let recovered = harness.store.attempt(id).unwrap().unwrap();
8967        assert_eq!(recovered.state(), AttemptState::Busy);
8968        assert_eq!(recovered.process_id(), Some(4242));
8969        assert_eq!(recovered.github_runner_id(), Some(73));
8970        let events = harness.events.events();
8971        let starting = events
8972            .iter()
8973            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8974            .unwrap();
8975        let busy = events
8976            .iter()
8977            .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8978            .unwrap();
8979        assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8980
8981        harness.processes.finish_successfully();
8982        harness
8983            .github
8984            .observe(GithubRunnerObservation::NotRegistered);
8985        assert!(
8986            harness
8987                .launcher
8988                .supervise(&harness.policy)
8989                .await
8990                .unwrap()
8991                .is_empty()
8992        );
8993        let cleaned = harness.store.attempt(id).unwrap().unwrap();
8994        assert_eq!(cleaned.state(), AttemptState::Cleaned);
8995        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8996        assert!(!runtime.exists());
8997    }
8998
8999    #[tokio::test]
9000    async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
9001        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9002        harness.processes.fail_spawn_with_live_child();
9003        harness.ready().await;
9004        assert!(harness.launch_result().await.is_err());
9005
9006        let attempt = harness.only_attempt();
9007        assert_eq!(attempt.state(), AttemptState::Starting);
9008        assert_eq!(attempt.process_id(), Some(4242));
9009        assert!(attempt.outcome().is_none());
9010        assert!(attempt.state().counts_against_capacity());
9011        assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
9012        assert!(harness.delay.0.lock().unwrap().is_empty());
9013
9014        harness.processes.set_alive(false);
9015        harness
9016            .github
9017            .observe(GithubRunnerObservation::NotRegistered);
9018        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
9019        assert_eq!(replacements.len(), 1);
9020        assert_eq!(
9021            harness.store.attempt(attempt.id).unwrap().unwrap().state(),
9022            AttemptState::Cleaned
9023        );
9024    }
9025
9026    #[tokio::test]
9027    async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
9028        for sidecar_already_present in [false, true] {
9029            let harness = Harness::new(
9030                FakeGithubLifecycle::default(),
9031                Arc::new(FakeDemand::answering([false])),
9032            );
9033            let id = AttemptId::new_random();
9034            let runtime =
9035                harness
9036                    .launcher
9037                    .app_paths
9038                    .runtime_dir()
9039                    .join(if sidecar_already_present {
9040                        "after-id-sidecar"
9041                    } else {
9042                        "before-id-sidecar"
9043                    });
9044            fs::create_dir_all(&runtime).unwrap();
9045            let mut attempt =
9046                RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9047            if sidecar_already_present {
9048                write_runner_id(&runtime, 73).unwrap();
9049                attempt.jit_received(harness.clock.now()).unwrap();
9050            }
9051            harness.store.record_attempt(&attempt).unwrap();
9052            harness.processes.set_alive(true);
9053            harness
9054                .github
9055                .observe(GithubRunnerObservation::Registered { busy: false });
9056            harness
9057                .launcher
9058                .recover_startup(std::slice::from_ref(&harness.policy))
9059                .await
9060                .unwrap();
9061
9062            assert_eq!(read_runner_id(&runtime), Some(73));
9063            assert!(
9064                harness
9065                    .store
9066                    .attempt(id)
9067                    .unwrap()
9068                    .unwrap()
9069                    .outcome()
9070                    .is_none()
9071            );
9072            let events = harness.events.events();
9073            let recovered = events.iter().position(|event| {
9074                matches!(
9075                    event,
9076                    AttemptEvent::RemoteIdentityRecovered {
9077                        attempt,
9078                        runner_id: 73
9079                    } if *attempt == id
9080                )
9081            });
9082            assert_eq!(recovered.is_some(), !sidecar_already_present);
9083            if let Some(recovered) = recovered {
9084                let adopted = events
9085                    .iter()
9086                    .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
9087                    .unwrap();
9088                assert!(
9089                    recovered < adopted,
9090                    "identity was not durable before adoption: {events:?}"
9091                );
9092            }
9093            assert!(runtime.exists());
9094        }
9095    }
9096
9097    #[tokio::test]
9098    async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
9099        let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9100        let unknown_attempt = RunnerAttempt::allocate(
9101            AttemptId::new_random(),
9102            PolicyId::from_u128(0xfeed),
9103            unknown
9104                .launcher
9105                .app_paths
9106                .runtime_dir()
9107                .join("unknown-policy"),
9108            unknown.clock.now(),
9109        );
9110        unknown.store.record_attempt(&unknown_attempt).unwrap();
9111        let expired_id = AttemptId::new_random();
9112        let expired_runtime = unknown
9113            .launcher
9114            .app_paths
9115            .runtime_dir()
9116            .join("expired-beside-unknown");
9117        fs::create_dir_all(&expired_runtime).unwrap();
9118        let mut expired = RunnerAttempt::allocate(
9119            expired_id,
9120            unknown.policy.id,
9121            expired_runtime,
9122            unknown.clock.now(),
9123        );
9124        expired.jit_received(unknown.clock.now()).unwrap();
9125        unknown.store.record_attempt(&expired).unwrap();
9126        unknown.clock.advance_secs(11);
9127        assert!(matches!(
9128            unknown
9129                .launcher
9130                .recover_startup(std::slice::from_ref(&unknown.policy))
9131                .await,
9132            Err(LifecycleError::RecoveryIncomplete)
9133        ));
9134        assert!(unknown.launch_result().await.is_err());
9135        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
9136        let recovered_policy = fixtures::policy()
9137            .id(PolicyId::from_u128(0xfeed))
9138            .repository("octo/repo")
9139            .autoscale("home", 2)
9140            .active()
9141            .build();
9142        let pending = unknown
9143            .launcher
9144            .recover_startup(&[unknown.policy.clone(), recovered_policy])
9145            .await
9146            .unwrap();
9147        assert_eq!(
9148            pending,
9149            vec![ReplacementIntent {
9150                policy: unknown.policy.id,
9151                previous_attempt: expired_id,
9152                operation: "jit_expired_replacement",
9153            }]
9154        );
9155        assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
9156
9157        let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9158        let id = AttemptId::new_random();
9159        let runtime = unreachable
9160            .launcher
9161            .app_paths
9162            .runtime_dir()
9163            .join("unreachable");
9164        fs::create_dir_all(&runtime).unwrap();
9165        unreachable
9166            .store
9167            .record_attempt(&RunnerAttempt::allocate(
9168                id,
9169                unreachable.policy.id,
9170                runtime,
9171                unreachable.clock.now(),
9172            ))
9173            .unwrap();
9174        unreachable
9175            .github
9176            .observe(GithubRunnerObservation::Unreachable);
9177        assert!(matches!(
9178            unreachable
9179                .launcher
9180                .recover_startup(std::slice::from_ref(&unreachable.policy))
9181                .await,
9182            Err(LifecycleError::RecoveryIncomplete)
9183        ));
9184        assert!(unreachable.launch_result().await.is_err());
9185        assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
9186    }
9187
9188    #[tokio::test]
9189    async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
9190        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9191        let id = AttemptId::new_random();
9192        let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
9193        fs::create_dir_all(&runtime).unwrap();
9194        let mut attempt =
9195            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9196        attempt.jit_received(harness.clock.now()).unwrap();
9197        attempt.started(4242, harness.clock.now()).unwrap();
9198        attempt.assigned_job(73, harness.clock.now()).unwrap();
9199        harness.store.record_attempt(&attempt).unwrap();
9200        harness.processes.set_alive(false);
9201        harness
9202            .github
9203            .observe(GithubRunnerObservation::NotRegistered);
9204        harness
9205            .launcher
9206            .recover_startup(std::slice::from_ref(&harness.policy))
9207            .await
9208            .unwrap();
9209        let cleaned = harness.store.attempt(id).unwrap().unwrap();
9210        assert_eq!(cleaned.state(), AttemptState::Cleaned);
9211        assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
9212        assert!(!runtime.exists());
9213    }
9214
9215    #[tokio::test]
9216    async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
9217        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9218        harness.ready().await;
9219        let id = AttemptId::new_random();
9220        let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
9221        fs::create_dir_all(&runtime).unwrap();
9222        let mut attempt =
9223            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9224        attempt.jit_received(harness.clock.now()).unwrap();
9225        attempt.started(4242, harness.clock.now()).unwrap();
9226        harness.store.record_attempt(&attempt).unwrap();
9227        harness.clock.advance_secs(11);
9228        harness.processes.set_alive(true);
9229        harness
9230            .github
9231            .observe(GithubRunnerObservation::NotRegistered);
9232        let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
9233        assert_eq!(
9234            replacements,
9235            vec![ReplacementIntent {
9236                policy: harness.policy.id,
9237                previous_attempt: id,
9238                operation: "registration_timeout_replacement",
9239            }]
9240        );
9241
9242        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
9243        assert!(!harness.processes.alive.load(Ordering::SeqCst));
9244        let actions = harness.processes.actions.lock().unwrap().clone();
9245        let intent = actions
9246            .iter()
9247            .position(|action| *action == "terminate_intent")
9248            .unwrap();
9249        let signal = actions
9250            .iter()
9251            .position(|action| *action == "terminate")
9252            .unwrap();
9253        assert!(
9254            intent < signal,
9255            "intent was not durable before signal: {actions:?}"
9256        );
9257
9258        let cleaned = harness.store.attempt(id).unwrap().unwrap();
9259        assert!(matches!(
9260            cleaned.outcome(),
9261            Some(AttemptOutcome::Failed {
9262                reason: FailureReason::TerminatedAfterRegistrationTimeout
9263            })
9264        ));
9265        let events = harness.events.events();
9266        let intent = events
9267            .iter()
9268            .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
9269            .unwrap();
9270        let stopped = events
9271            .iter()
9272            .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
9273            .unwrap();
9274        let concluded = events
9275            .iter()
9276            .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
9277            .unwrap();
9278        assert!(intent < stopped && stopped < concluded, "{events:?}");
9279    }
9280
9281    #[tokio::test]
9282    async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
9283        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9284        let id = AttemptId::new_random();
9285        let runtime = harness
9286            .launcher
9287            .app_paths
9288            .runtime_dir()
9289            .join("timeout-after-crash");
9290        fs::create_dir_all(&runtime).unwrap();
9291        let mut attempt =
9292            RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9293        attempt.jit_received(harness.clock.now()).unwrap();
9294        attempt.started(4242, harness.clock.now()).unwrap();
9295        harness.store.record_attempt(&attempt).unwrap();
9296        harness.processes.intent.store(true, Ordering::SeqCst);
9297        harness.processes.set_alive(false);
9298        harness
9299            .github
9300            .observe(GithubRunnerObservation::NotRegistered);
9301
9302        let replacements = harness
9303            .launcher
9304            .recover_startup(std::slice::from_ref(&harness.policy))
9305            .await
9306            .unwrap();
9307        assert_eq!(
9308            replacements,
9309            vec![ReplacementIntent {
9310                policy: harness.policy.id,
9311                previous_attempt: id,
9312                operation: "registration_timeout_replacement",
9313            }]
9314        );
9315        let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9316            .await
9317            .unwrap();
9318        assert_eq!(consumed, replacements);
9319        assert!(
9320            RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9321                .await
9322                .unwrap()
9323                .is_empty(),
9324            "startup replacement evidence must be consumed exactly once by e1"
9325        );
9326        assert!(matches!(
9327            harness.store.attempt(id).unwrap().unwrap().outcome(),
9328            Some(AttemptOutcome::Failed {
9329                reason: FailureReason::TerminatedAfterRegistrationTimeout
9330            })
9331        ));
9332    }
9333
9334    #[tokio::test]
9335    async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
9336        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9337        let id = AttemptId::new_random();
9338        let runtime = harness
9339            .launcher
9340            .app_paths
9341            .runtime_dir()
9342            .join("timeout-sync-failure");
9343        fs::create_dir_all(&runtime).unwrap();
9344        let mut attempt =
9345            RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9346        attempt.jit_received(harness.clock.now()).unwrap();
9347        attempt.started(4242, harness.clock.now()).unwrap();
9348        harness.store.record_attempt(&attempt).unwrap();
9349        harness.clock.advance_secs(11);
9350        harness.processes.set_alive(true);
9351        harness.processes.fail_intent();
9352        harness
9353            .github
9354            .observe(GithubRunnerObservation::NotRegistered);
9355
9356        assert!(
9357            harness
9358                .launcher
9359                .recover_startup(std::slice::from_ref(&harness.policy))
9360                .await
9361                .is_err()
9362        );
9363        assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
9364        assert!(harness.processes.alive.load(Ordering::SeqCst));
9365        assert_eq!(
9366            harness.store.attempt(id).unwrap().unwrap().state(),
9367            AttemptState::Starting
9368        );
9369        assert!(!harness.events.events().iter().any(|event| matches!(
9370            event,
9371            AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
9372                if *attempt == id
9373        )));
9374    }
9375
9376    #[tokio::test]
9377    async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
9378        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9379        harness.ready().await;
9380        let attempt = harness.launch().await;
9381        harness
9382            .github
9383            .observe(GithubRunnerObservation::Registered { busy: false });
9384        harness.launcher.supervise(&harness.policy).await.unwrap();
9385        harness.clock.advance_secs(11);
9386        harness.processes.set_alive(false);
9387        harness
9388            .github
9389            .observe(GithubRunnerObservation::NotRegistered);
9390        harness.launcher.supervise(&harness.policy).await.unwrap();
9391        let diagnostic = fs::read_to_string(
9392            harness
9393                .launcher
9394                .diagnostics_root
9395                .join(format!("{}.log", attempt.id)),
9396        )
9397        .unwrap();
9398        assert!(diagnostic.contains("exited_idle_without_work"));
9399        assert!(!diagnostic.contains(JIT));
9400        assert!(!diagnostic.contains("ghp_"));
9401        assert!(!attempt.runtime_path().exists());
9402    }
9403
9404    #[test]
9405    fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
9406        let root = tempfile::tempdir().unwrap();
9407        let policy = fixtures::policy()
9408            .repository("octo/repo")
9409            .autoscale("home", 1)
9410            .active()
9411            .build();
9412        let runtime = root.path().join("successful");
9413        fs::create_dir_all(&runtime).unwrap();
9414        let processes = NativeProcesses::new();
9415        let config = EncodedJitConfig::new(JIT);
9416        let handoff =
9417            RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
9418                .unwrap();
9419        let mut child = native_inspection_spec()
9420            .spawn_runner_with_handoff(&handoff)
9421            .expect("native child starts");
9422        let pid = child.pid();
9423        handoff.delete().unwrap();
9424        let command_line = native_command_line(pid);
9425        assert!(
9426            !command_line.contains(JIT),
9427            "the encoded JIT configuration appeared in the native process listing"
9428        );
9429        assert_no_jit_file(&runtime);
9430        child
9431            .stop(Duration::from_secs(1))
9432            .expect("native child stops");
9433
9434        let failed_runtime = root.path().join("failed");
9435        fs::create_dir_all(&failed_runtime).unwrap();
9436        let failed = RunnerAttempt::allocate(
9437            AttemptId::new_random(),
9438            policy.id,
9439            &failed_runtime,
9440            FakeClock::default().now(),
9441        );
9442        assert!(
9443            processes
9444                .spawn(&failed, &EncodedJitConfig::new(JIT))
9445                .is_err(),
9446            "a runtime with no runner executable must fail"
9447        );
9448        assert_no_jit_file(&failed_runtime);
9449        processes
9450            .record_terminate_intent(&failed)
9451            .expect("the intent file and its directory entry are durably synced");
9452        assert_eq!(
9453            fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
9454            b"registration-timeout\n"
9455        );
9456    }
9457
9458    #[test]
9459    fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
9460        let root = tempfile::tempdir().unwrap();
9461        let policy = fixtures::policy()
9462            .repository("octo/repo")
9463            .autoscale("home", 1)
9464            .active()
9465            .build();
9466        let processes = NativeProcesses::new();
9467        processes.use_long_lived_test_listener();
9468        for (index, boundary) in [
9469            PostSpawnBoundary::HandoffDelete,
9470            PostSpawnBoundary::IdentitySerialize,
9471            PostSpawnBoundary::IdentityWrite,
9472            PostSpawnBoundary::ChildMapInsert,
9473        ]
9474        .into_iter()
9475        .enumerate()
9476        {
9477            let runtime = root.path().join(format!("post-spawn-{index}"));
9478            let bin = runtime.join("bin");
9479            fs::create_dir_all(&bin).unwrap();
9480            #[cfg(windows)]
9481            let listener = bin.join("Runner.Listener.exe");
9482            #[cfg(not(windows))]
9483            let listener = bin.join("Runner.Listener");
9484            fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9485            let attempt = RunnerAttempt::allocate(
9486                AttemptId::new_random(),
9487                policy.id,
9488                &runtime,
9489                FakeClock::default().now(),
9490            );
9491            processes.fail_post_spawn_at(boundary);
9492            let failure = processes
9493                .spawn(&attempt, &EncodedJitConfig::new(JIT))
9494                .expect_err("fault must cross the post-spawn cleanup path");
9495            assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
9496            assert!(
9497                !processes.is_alive(&attempt).unwrap(),
9498                "{boundary:?} left a child"
9499            );
9500            assert!(!NativeProcesses::identity_path(&attempt).exists());
9501            assert_no_jit_file(&runtime);
9502        }
9503        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9504
9505        let runtime = root.path().join("identity-and-stop-fail");
9506        let bin = runtime.join("bin");
9507        fs::create_dir_all(&bin).unwrap();
9508        #[cfg(windows)]
9509        let listener = bin.join("Runner.Listener.exe");
9510        #[cfg(not(windows))]
9511        let listener = bin.join("Runner.Listener");
9512        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9513        let attempt = RunnerAttempt::allocate(
9514            AttemptId::new_random(),
9515            policy.id,
9516            &runtime,
9517            FakeClock::default().now(),
9518        );
9519        // The first fault rejects the normal identity write; the second rejects
9520        // its retry after the first stop fails. The fallback sidecar must make
9521        // the live-child result durable without an unbounded reap loop.
9522        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9523        processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9524        processes.fail_next_post_spawn_stop();
9525        let failure = processes
9526            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9527            .expect_err("the identity boundary must fail closed");
9528        assert!(failure.live_pid.is_some());
9529        assert_long_lived_listener_ready(&processes, &attempt);
9530        assert!(processes.is_alive(&attempt).unwrap());
9531        assert!(!NativeProcesses::identity_path(&attempt).exists());
9532        assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
9533        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9534        processes.terminate(&attempt).unwrap();
9535
9536        let runtime = root.path().join("persistent-stop-and-identity-failures");
9537        let bin = runtime.join("bin");
9538        fs::create_dir_all(&bin).unwrap();
9539        #[cfg(windows)]
9540        let listener = bin.join("Runner.Listener.exe");
9541        #[cfg(not(windows))]
9542        let listener = bin.join("Runner.Listener");
9543        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9544        let mut unresolved = RunnerAttempt::allocate(
9545            AttemptId::new_random(),
9546            policy.id,
9547            &runtime,
9548            FakeClock::default().now(),
9549        );
9550        for _ in 0..3 {
9551            processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9552        }
9553        processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
9554        let failure = processes
9555            .spawn(&unresolved, &EncodedJitConfig::new(JIT))
9556            .expect_err("bounded cleanup must return even when every stop errors");
9557        let pid = failure
9558            .live_pid
9559            .expect("the owned child remains supervised in this invocation");
9560        assert!(matches!(failure.reason, FailureReason::Other(_)));
9561        assert_long_lived_listener_ready(&processes, &unresolved);
9562        unresolved.jit_received(FakeClock::default().now()).unwrap();
9563        unresolved.started(pid, FakeClock::default().now()).unwrap();
9564        let journal = SqliteStore::open_in_memory().unwrap();
9565        journal.record_attempt(&unresolved).unwrap();
9566        let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
9567        assert_eq!(recovered.process_id(), Some(pid));
9568        assert_eq!(recovered.state(), AttemptState::Starting);
9569        assert!(processes.is_alive(&unresolved).unwrap());
9570        assert!(!NativeProcesses::identity_path(&unresolved).exists());
9571        assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
9572        assert_eq!(
9573            fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
9574            pid.to_string(),
9575            "bounded cleanup must leave durable unresolved-process evidence before returning"
9576        );
9577        assert!(
9578            NativeProcesses::new().is_alive(&recovered).is_err(),
9579            "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
9580        );
9581        processes.terminate(&unresolved).unwrap();
9582
9583        let runtime = root.path().join("post-spawn-stop-failed");
9584        let bin = runtime.join("bin");
9585        fs::create_dir_all(&bin).unwrap();
9586        #[cfg(windows)]
9587        let listener = bin.join("Runner.Listener.exe");
9588        #[cfg(not(windows))]
9589        let listener = bin.join("Runner.Listener");
9590        fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9591        let attempt = RunnerAttempt::allocate(
9592            AttemptId::new_random(),
9593            policy.id,
9594            &runtime,
9595            FakeClock::default().now(),
9596        );
9597        processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
9598        processes.fail_next_post_spawn_stop();
9599        let failure = processes
9600            .spawn(&attempt, &EncodedJitConfig::new(JIT))
9601            .expect_err("the injected stop failure must preserve supervision");
9602        let live_pid = failure
9603            .live_pid
9604            .expect("live PID is returned to the journal");
9605        assert!(!failure.retryable);
9606        assert_long_lived_listener_ready(&processes, &attempt);
9607        assert!(NativeProcesses::identity_path(&attempt).is_file());
9608        assert_eq!(
9609            NativeProcesses::new()
9610                .read_identity(&attempt)
9611                .unwrap()
9612                .runner()
9613                .unwrap()
9614                .pid(),
9615            live_pid
9616        );
9617        assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9618        processes.terminate(&attempt).unwrap();
9619    }
9620
9621    #[test]
9622    #[ignore = "spawned only as the platform-stable native listener fixture"]
9623    fn long_lived_native_listener_helper() {
9624        let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
9625            .map(PathBuf::from)
9626            .expect("the parent supplies the readiness path");
9627        // Atomically: the parent polls for the file and must never read it
9628        // created but not yet written.
9629        runner_manager_platform::host_fitness::write_atomically(&ready, b"ready\n")
9630            .expect("the listener publishes readiness");
9631        std::thread::sleep(Duration::from_secs(30));
9632    }
9633
9634    fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
9635        let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
9636        let deadline = std::time::Instant::now() + Duration::from_secs(5);
9637        loop {
9638            if ready.is_file() {
9639                assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
9640                return;
9641            }
9642            assert!(
9643                processes.is_alive(attempt).unwrap(),
9644                "the native listener exited before publishing readiness"
9645            );
9646            assert!(
9647                std::time::Instant::now() < deadline,
9648                "the native listener stayed alive but never published readiness"
9649            );
9650            std::thread::sleep(Duration::from_millis(10));
9651        }
9652    }
9653
9654    #[tokio::test]
9655    async fn a_reservation_is_journalled_before_any_effect_and_holds_its_slot() {
9656        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9657        harness.ready().await;
9658
9659        let reserved = harness.reserve().await.unwrap();
9660
9661        assert_eq!(reserved.state(), AttemptState::Allocated);
9662        assert!(reserved.counts_against_capacity());
9663        let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9664        assert_eq!(journalled.state(), AttemptState::Allocated);
9665        assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
9666        assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
9667
9668        let started = harness
9669            .launcher
9670            .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9671            .await
9672            .unwrap();
9673        assert_eq!(started.state(), AttemptState::Starting);
9674    }
9675
9676    /// A launch runs beside its target's pass: the pass does not read a runner
9677    /// still coming up as one that is gone. It has no process and no registration
9678    /// yet, and the bring-up is writing its journal entry.
9679    #[tokio::test]
9680    async fn a_pass_leaves_a_runner_coming_up_alone_and_supervises_it_once_up() {
9681        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9682        harness.ready().await;
9683        let reserved = harness.reserve().await.unwrap();
9684        let looks_left = || harness.github.observations.lock().unwrap().len();
9685        harness
9686            .github
9687            .observations
9688            .lock()
9689            .unwrap()
9690            .push_back(LifecycleGithubObservation::not_registered());
9691
9692        harness.launcher.supervise(&harness.policy).await.unwrap();
9693        assert_eq!(looks_left(), 1, "it did not look");
9694        let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9695        assert_eq!(journalled.state(), AttemptState::Allocated);
9696
9697        harness
9698            .launcher
9699            .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9700            .await
9701            .unwrap();
9702        assert!(harness.launcher.bringing_up.lock().unwrap().is_empty());
9703
9704        harness.launcher.supervise(&harness.policy).await.unwrap();
9705        assert_eq!(looks_left(), 0, "up, it is this pass's to supervise");
9706    }
9707
9708    /// A daemon that dies between the reservation and the process: the
9709    /// journalled attempt is what recovery finds, and the slot comes back.
9710    #[tokio::test]
9711    async fn a_crash_between_reservation_and_spawn_recovers_the_slot() {
9712        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9713        harness.ready().await;
9714        let reserved = harness.reserve().await.unwrap();
9715        harness.clock.advance_secs(31);
9716
9717        harness
9718            .launcher
9719            .recover_startup(std::slice::from_ref(&harness.policy))
9720            .await
9721            .unwrap();
9722
9723        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9724        assert!(
9725            !recovered.counts_against_capacity(),
9726            "the slot is free again: {:?}",
9727            recovered.state()
9728        );
9729    }
9730
9731    /// A crash once the package is there, before the process: same result.
9732    #[tokio::test]
9733    async fn a_crash_after_the_package_is_copied_recovers_the_slot() {
9734        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9735        harness.ready().await;
9736        let reserved = harness.reserve().await.unwrap();
9737        harness
9738            .packages
9739            .materialize(&reserved, &mut PackageTimings::default())
9740            .await
9741            .unwrap();
9742        harness.clock.advance_secs(31);
9743
9744        harness
9745            .launcher
9746            .recover_startup(std::slice::from_ref(&harness.policy))
9747            .await
9748            .unwrap();
9749
9750        let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9751        assert!(!recovered.counts_against_capacity());
9752    }
9753
9754    #[tokio::test]
9755    async fn runners_prepare_at_once_within_the_bound() {
9756        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9757        harness.ready().await;
9758        let turns = harness.launcher.turns.available_permits();
9759        harness.launcher.turns.forget_permits(turns - 2);
9760        let mut reserved = Vec::new();
9761        for _ in 0..4 {
9762            reserved.push(harness.reserve().await.unwrap());
9763        }
9764
9765        let finished = futures::future::join_all(reserved.into_iter().map(|attempt| {
9766            harness
9767                .launcher
9768                .finish_launch(&harness.policy, attempt, ReservationWaits::default())
9769        }))
9770        .await;
9771
9772        assert!(finished.iter().all(Result::is_ok));
9773        assert_eq!(
9774            harness.packages.peak_materializing.load(Ordering::SeqCst),
9775            2
9776        );
9777    }
9778
9779    #[tokio::test]
9780    async fn every_production_launch_prunes_under_the_same_allocation_guard() {
9781        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9782        harness.ready().await;
9783        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
9784        harness.launch().await;
9785        assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
9786    }
9787
9788    fn copy_package_tree(
9789        source: &Path,
9790        destination: &Path,
9791        seeds: &TrustedTrees,
9792    ) -> std::io::Result<()> {
9793        let origin = copy_origin(source, destination, seeds);
9794        copy_package_from(source, &origin, destination, seeds)
9795    }
9796
9797    fn assert_no_jit_file(runtime: &Path) {
9798        for entry in fs::read_dir(runtime).unwrap() {
9799            let path = entry.unwrap().path();
9800            if path.is_file() {
9801                let bytes = fs::read(&path).unwrap();
9802                assert!(
9803                    !bytes
9804                        .windows(JIT.len())
9805                        .any(|window| window == JIT.as_bytes()),
9806                    "a JIT payload survived in a runtime file"
9807                );
9808            }
9809        }
9810    }
9811
9812    #[test]
9813    fn production_listener_command_uses_the_supported_jit_contract() {
9814        let runtime = Path::new("runtime");
9815        let spec = runner_listener_spec(
9816            PathBuf::from("Runner.Listener"),
9817            runtime,
9818            Vec::new(),
9819            &RunnerEnv::default(),
9820        );
9821        let arguments: Vec<_> = spec
9822            .arguments()
9823            .iter()
9824            .map(|argument| argument.to_string_lossy().into_owned())
9825            .collect();
9826
9827        assert_eq!(arguments, ["run"]);
9828        assert!(
9829            !arguments
9830                .iter()
9831                .any(|argument| argument == "--jit-config-file"),
9832            "the obsolete file option would be rejected by Runner.Listener 2.336.0"
9833        );
9834    }
9835
9836    /// What a production runner starts with: the platform defaults, the host's
9837    /// `runner.env` over them, the attempt's own temporary directory, and
9838    /// normal scheduling.
9839    #[test]
9840    fn production_listener_layers_runner_env_over_the_platform_defaults() {
9841        let root = tempfile::tempdir().unwrap();
9842        let runtime = root.path().join("attempt");
9843        let host_env =
9844            RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
9845                .unwrap();
9846        let caches = vec![
9847            (
9848                "ELECTRON_CACHE",
9849                std::ffi::OsString::from("/rman/_cache/o/r/electron"),
9850            ),
9851            (
9852                "npm_config_cache",
9853                std::ffi::OsString::from("/rman/_cache/o/r/npm"),
9854            ),
9855        ];
9856        let spec = runner_listener_spec(
9857            PathBuf::from("Runner.Listener"),
9858            &runtime,
9859            caches,
9860            &host_env,
9861        );
9862        let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
9863
9864        assert!(spec.expects_normal_scheduling());
9865        assert_eq!(
9866            env("ELECTRON_CACHE"),
9867            Some(PathBuf::from("/cache/electron")),
9868            "runner.env must win over a dependency cache of the same name"
9869        );
9870        assert_eq!(
9871            env("npm_config_cache"),
9872            Some(PathBuf::from("/rman/_cache/o/r/npm"))
9873        );
9874        assert_eq!(
9875            env("HOME"),
9876            Some(PathBuf::from("/operator/chose/this")),
9877            "runner.env must win over a platform default of the same name"
9878        );
9879        for tmp in ["TMPDIR", "TEMP", "TMP"] {
9880            assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
9881        }
9882
9883        let home = runtime.join(runner_env::RUNNER_HOME_DIR);
9884        if cfg!(windows) {
9885            // Each runner's own profile, created inside its attempt, so that
9886            // concurrent jobs stop sharing the service account's.
9887            assert_eq!(env("USERPROFILE"), Some(home.clone()));
9888            assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
9889            assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
9890            assert!(home.join(r"AppData\Roaming").is_dir());
9891            assert!(home.join(r"AppData\Local").is_dir());
9892        } else {
9893            // Unix keeps the account's own profile.
9894            assert_eq!(env("USERPROFILE"), None);
9895            assert!(!home.exists());
9896        }
9897    }
9898
9899    /// Every step shell a runner starts inherits PowerShell's telemetry
9900    /// opt-out from the listener, unless the daemon or `runner.env` decided
9901    /// otherwise; see [`runner_env::POWERSHELL_TELEMETRY_OPTOUT`].
9902    #[test]
9903    fn production_listener_opts_powershell_out_of_telemetry() {
9904        let root = tempfile::tempdir().unwrap();
9905        let runtime = root.path().join("attempt");
9906        let listener = |host_env: &RunnerEnv| {
9907            runner_listener_spec(
9908                PathBuf::from("Runner.Listener"),
9909                &runtime,
9910                Vec::new(),
9911                host_env,
9912            )
9913        };
9914        let optout = |spec: &SpawnSpec| {
9915            spec.configured_env(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9916                .map(OsStr::to_os_string)
9917        };
9918
9919        let spec = listener(&RunnerEnv::default());
9920        if std::env::var_os(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9921            .is_some_and(|value| !value.is_empty())
9922        {
9923            // The daemon's own value is inherited as it is.
9924            assert_eq!(optout(&spec), None);
9925        } else {
9926            assert_eq!(
9927                optout(&spec),
9928                Some(runner_env::POWERSHELL_TELEMETRY_OPTOUT_VALUE.into())
9929            );
9930        }
9931
9932        let operator = RunnerEnv::parse("POWERSHELL_TELEMETRY_OPTOUT=0\n").unwrap();
9933        assert_eq!(
9934            optout(&listener(&operator)),
9935            Some("0".into()),
9936            "runner.env must win over the telemetry opt-out"
9937        );
9938    }
9939
9940    #[test]
9941    fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
9942        let root = tempfile::tempdir().unwrap();
9943        let path = runner_env::path_in(root.path());
9944        fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
9945        let processes = NativeProcesses::new().with_runner_env_file(path.clone());
9946        let failure = processes.host_env().unwrap_err().to_string();
9947        assert!(
9948            failure.contains("runner.env") && failure.contains("line 2"),
9949            "{failure}"
9950        );
9951        assert!(
9952            !failure.contains("ghp_"),
9953            "the failure echoed a value: {failure}"
9954        );
9955
9956        // `prepare` runs before the JIT registration, so a broken file is
9957        // refused there, before GitHub is asked for anything.
9958        let policy = fixtures::policy()
9959            .repository("octo/repo")
9960            .autoscale("home", 1)
9961            .active()
9962            .build();
9963        let attempt = RunnerAttempt::allocate(
9964            AttemptId::new_random(),
9965            policy.id,
9966            root.path(),
9967            FakeClock::default().now(),
9968        );
9969        assert!(processes.prepare(&attempt, &policy).is_err());
9970
9971        fs::write(&path, "GOOD=1\n").unwrap();
9972        assert!(processes.prepare(&attempt, &policy).is_ok());
9973        assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
9974        assert!(NativeProcesses::new().host_env().unwrap().is_empty());
9975    }
9976
9977    /// The wiring from `prepare` (which sees the policy) to `spawn` (which
9978    /// does not): the policy's repository decides the namespace, and a broken
9979    /// `caches.toml` is refused before GitHub is asked for anything.
9980    #[test]
9981    fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
9982        let root = tempfile::tempdir().unwrap();
9983        let runner_root = root.path().join("rman");
9984        let runtime = runner_root.join("a1");
9985        fs::create_dir_all(runtime.join("bin")).unwrap();
9986        let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
9987        let source_root = runner_root.clone();
9988        let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
9989            config.clone(),
9990            Arc::new(move || Some(source_root.clone())),
9991            None,
9992        ));
9993        let policy = fixtures::policy()
9994            .repository("Octo/Repo")
9995            .autoscale("home", 1)
9996            .active()
9997            .build();
9998        let attempt = RunnerAttempt::allocate(
9999            AttemptId::new_random(),
10000            policy.id,
10001            &runtime,
10002            FakeClock::default().now(),
10003        );
10004        assert!(
10005            processes
10006                .cache_env(&attempt, None, &RunnerEnv::default())
10007                .is_empty(),
10008            "a launch with no prepared target, such as a bare `spawn`, has no cache"
10009        );
10010        let prepared = processes.prepare(&attempt, &policy).unwrap();
10011        let variables = processes.cache_env(
10012            &attempt,
10013            prepared.cache_target.as_ref(),
10014            &RunnerEnv::default(),
10015        );
10016        let npm = variables
10017            .iter()
10018            .find(|(name, _)| *name == "npm_config_cache")
10019            .map(|(_, value)| PathBuf::from(value));
10020        assert_eq!(
10021            npm,
10022            Some(
10023                runner_root
10024                    .join("_cache")
10025                    .join("octo")
10026                    .join("repo")
10027                    .join("npm")
10028            )
10029        );
10030
10031        fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
10032        let refused = processes
10033            .prepare(&attempt, &policy)
10034            .unwrap_err()
10035            .to_string();
10036        assert!(refused.contains("caches.toml"), "{refused}");
10037    }
10038
10039    #[cfg(windows)]
10040    fn native_inspection_spec() -> SpawnSpec {
10041        SpawnSpec::new("powershell.exe").args([
10042            "-NoProfile",
10043            "-NonInteractive",
10044            "-Command",
10045            "Start-Sleep -Seconds 30",
10046        ])
10047    }
10048
10049    #[cfg(unix)]
10050    fn native_inspection_spec() -> SpawnSpec {
10051        SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
10052    }
10053
10054    #[cfg(windows)]
10055    fn native_command_line(pid: u32) -> String {
10056        let output = std::process::Command::new("powershell.exe")
10057            .args([
10058                "-NoProfile",
10059                "-NonInteractive",
10060                "-Command",
10061                &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
10062            ])
10063            .output()
10064            .expect("PowerShell can inspect the native child");
10065        assert!(output.status.success(), "native process inspection failed");
10066        String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
10067    }
10068
10069    #[cfg(target_os = "linux")]
10070    fn native_command_line(pid: u32) -> String {
10071        fs::read(format!("/proc/{pid}/cmdline"))
10072            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
10073            .expect("/proc exposes the native child command line")
10074    }
10075
10076    #[cfg(target_os = "macos")]
10077    fn native_command_line(pid: u32) -> String {
10078        let output = std::process::Command::new("ps")
10079            .args(["-o", "command=", "-p", &pid.to_string()])
10080            .output()
10081            .expect("ps can inspect the native child");
10082        assert!(output.status.success(), "native process inspection failed");
10083        String::from_utf8(output.stdout).expect("the command line is UTF-8")
10084    }
10085
10086    // -- c2: persistent slot allocation -------------------------------------
10087
10088    #[tokio::test]
10089    async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
10090        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10091            .with_persistent_workspace(2);
10092        harness.github.watch_journal(Arc::clone(&harness.store));
10093        harness.ready().await;
10094
10095        let attempt = harness.launch().await;
10096
10097        assert_eq!(
10098            attempt.workspace(),
10099            AttemptWorkspace::persistent_slot(nz(1)),
10100            "the lowest free slot is leased"
10101        );
10102        assert_eq!(attempt.runtime_path(), harness.slot_path(1));
10103        assert!(attempt.holds_slot_lease());
10104        // The exact runtime path is journalled, not re-derived later.
10105        assert_eq!(
10106            harness.attempt(attempt.id).runtime_path(),
10107            harness.slot_path(1)
10108        );
10109
10110        // Step 7 of "Slot allocation": the lease exists before GitHub is asked
10111        // for anything, and the runner's work folder stays the relative `_work`
10112        // the slot root is laid out around.
10113        let facts = harness.github.registration_facts();
10114        assert_eq!(facts.len(), 1);
10115        assert_eq!(
10116            facts[0].leased_slots,
10117            vec![1],
10118            "the lease was journalled first"
10119        );
10120        assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
10121    }
10122
10123    #[tokio::test]
10124    async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
10125        let harness = Harness::new(
10126            FakeGithubLifecycle::default().fail(true),
10127            Arc::new(PersistentDemand),
10128        )
10129        .with_persistent_workspace(2);
10130        harness.ready().await;
10131
10132        // A terminal JIT refusal concludes the attempt without cleaning it.
10133        harness.launch_result().await.unwrap_err();
10134        let first = harness.store.attempts().unwrap().remove(0);
10135        assert_eq!(first.state(), AttemptState::Failed);
10136        assert!(
10137            !first.state().counts_against_capacity(),
10138            "a concluded attempt is invisible to host capacity"
10139        );
10140        assert!(
10141            first.holds_slot_lease(),
10142            "and still owns its directory, so its slot is not free"
10143        );
10144
10145        let second = harness.launch().await;
10146        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10147        assert_eq!(second.runtime_path(), harness.slot_path(2));
10148        assert_eq!(
10149            harness
10150                .store
10151                .slot_leases_for_policy(harness.policy.id)
10152                .unwrap()
10153                .len(),
10154            2
10155        );
10156    }
10157
10158    #[tokio::test]
10159    async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
10160        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10161            .with_persistent_workspace(1);
10162        harness.ready().await;
10163
10164        let first = harness.launch().await;
10165        assert_eq!(first.runtime_path(), harness.slot_path(1));
10166
10167        // What a job leaves behind, at the path the runner writes it to.
10168        let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
10169        fs::create_dir_all(&checkout).unwrap();
10170        fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
10171
10172        harness.cleanup_retaining_work(first.id).await;
10173
10174        let second = harness.launch().await;
10175        assert_ne!(second.id, first.id);
10176        assert_eq!(
10177            second.workspace(),
10178            AttemptWorkspace::persistent_slot(nz(1)),
10179            "a released slot is leased again rather than skipped"
10180        );
10181        assert_eq!(
10182            second.runtime_path(),
10183            first.runtime_path(),
10184            "the same slot is the same exact path"
10185        );
10186        assert_eq!(
10187            fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
10188            "from the first job",
10189            "the retained job workspace survived the second allocation"
10190        );
10191        // The attempt's own runner material was recreated for this attempt.
10192        assert!(harness.slot_path(1).join("runner-package").exists());
10193    }
10194
10195    #[tokio::test]
10196    async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
10197        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10198            .with_persistent_workspace(2);
10199        harness.ready().await;
10200
10201        let first = harness.launch().await;
10202        let second = harness.launch().await;
10203        assert_eq!(second.runtime_path(), harness.slot_path(2));
10204        let kept = harness
10205            .slot_path(2)
10206            .join(DEFAULT_WORK_FOLDER)
10207            .join("kept.txt");
10208        fs::create_dir_all(kept.parent().unwrap()).unwrap();
10209        fs::write(&kept, b"s2 was here").unwrap();
10210        harness.cleanup_retaining_work(second.id).await;
10211
10212        // The operator lowers the ceiling while s1 is still leased.
10213        harness.policy.set_max_capacity(nz(1)).unwrap();
10214        let refusal = harness.launch_result().await.unwrap_err().to_string();
10215        assert!(
10216            refusal.contains("s1 to s1"),
10217            "the refusal names the ceiling it reached: {refusal}"
10218        );
10219        assert!(
10220            harness.slot_path(2).exists() && kept.exists(),
10221            "lowering capacity deletes nothing; the higher slot is merely unusable"
10222        );
10223
10224        // Raising it again makes the free higher slot available.
10225        harness.policy.set_max_capacity(nz(2)).unwrap();
10226        let third = harness.launch().await;
10227        assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10228        assert_eq!(third.runtime_path(), harness.slot_path(2));
10229        assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
10230        assert!(first.holds_slot_lease(), "s1 was never disturbed");
10231    }
10232
10233    #[tokio::test]
10234    async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
10235        for policy in [
10236            fixtures::policy()
10237                .organization("octo")
10238                .autoscale("home", 2)
10239                .active()
10240                .build(),
10241            fixtures::policy()
10242                .repository("octo/repo")
10243                .autoscale("home", 2)
10244                .active()
10245                .build(),
10246        ] {
10247            let mut harness =
10248                Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10249                    .with_host_runner_root();
10250            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
10251            harness.policy = policy;
10252            harness.ready().await;
10253
10254            let attempt = harness.launch().await;
10255            assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10256            assert_eq!(attempt.workspace().slot_number(), None);
10257            assert!(!attempt.holds_slot_lease());
10258            assert_eq!(
10259                attempt.runtime_path().parent().unwrap(),
10260                harness.host_root(),
10261                "a disposable attempt is a child of the host root, never of a slot"
10262            );
10263            assert!(
10264                harness
10265                    .store
10266                    .slot_leases_for_policy(harness.policy.id)
10267                    .unwrap()
10268                    .is_empty()
10269            );
10270        }
10271    }
10272
10273    #[tokio::test]
10274    async fn two_concurrent_allocations_never_share_a_slot() {
10275        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10276            .with_persistent_workspace(2);
10277        harness.github.watch_journal(Arc::clone(&harness.store));
10278        harness.ready().await;
10279
10280        // Both allocators race for the same host allocation lock, which is what
10281        // orders slot *selection*; each one then journals its lease before it
10282        // asks GitHub for anything.
10283        let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
10284        let first = first.unwrap();
10285        let second = second.unwrap();
10286
10287        let slots: BTreeSet<u16> = [&first, &second]
10288            .iter()
10289            .map(|attempt| {
10290                attempt
10291                    .workspace()
10292                    .slot_number()
10293                    .expect("a persistent attempt leases a slot")
10294            })
10295            .collect();
10296        assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
10297        assert_ne!(first.runtime_path(), second.runtime_path());
10298        assert_eq!(
10299            harness
10300                .store
10301                .slot_leases_for_policy(harness.policy.id)
10302                .unwrap()
10303                .len(),
10304            2
10305        );
10306
10307        // Every registration saw *its own* lease already in the journal. Reading
10308        // the whole journal and asking only that it be non-empty would pass on
10309        // the other allocator's lease, which is precisely the ordering bug this
10310        // test exists to exclude.
10311        let facts = harness.github.registration_facts();
10312        assert_eq!(facts.len(), 2);
10313        for fact in facts {
10314            let attempt = [&first, &second]
10315                .into_iter()
10316                .find(|attempt| runner_name(attempt.id) == fact.runner_name)
10317                .expect("every registration belongs to one of the two attempts");
10318            let slot = attempt
10319                .workspace()
10320                .slot_number()
10321                .expect("a persistent attempt leases a slot");
10322            assert!(
10323                fact.leased_slots.contains(&slot),
10324                "a JIT request never precedes its own lease: s{slot} not in {:?}",
10325                fact.leased_slots
10326            );
10327            assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
10328        }
10329    }
10330
10331    #[tokio::test]
10332    async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
10333        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10334            .with_persistent_workspace(2);
10335        harness.ready().await;
10336        let first = harness.launch().await;
10337
10338        // What a second allocator that lost the race would write: the lock
10339        // orders selection, and this index is what catches a writer the lock
10340        // could not see.
10341        let clash = RunnerAttempt::allocate_in(
10342            AttemptId::new_random(),
10343            harness.policy.id,
10344            first.runtime_path(),
10345            AttemptWorkspace::persistent_slot(nz(1)),
10346            harness.clock.now(),
10347        );
10348        assert!(matches!(
10349            harness.store.record_attempt(&clash).unwrap_err(),
10350            StoreError::SlotAlreadyLeased { slot: 1, .. }
10351        ));
10352
10353        // And the launcher reports it as itself rather than as a generic
10354        // journal failure, so the operator reads what actually happened.
10355        let error = harness.launcher.record_allocation(&clash).unwrap_err();
10356        let rendered = error.to_string();
10357        assert!(rendered.contains("slot s1"), "{rendered}");
10358        assert!(rendered.contains("nothing was written"), "{rendered}");
10359        assert_eq!(
10360            harness.store.attempts().unwrap().len(),
10361            1,
10362            "the losing allocator journalled nothing"
10363        );
10364    }
10365
10366    #[test]
10367    fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
10368        let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
10369            slots
10370                .iter()
10371                .map(|slot| {
10372                    RunnerAttempt::allocate_in(
10373                        AttemptId::new_random(),
10374                        fixtures::POLICY_ID,
10375                        format!("/srv/rman/acme/s{slot}"),
10376                        AttemptWorkspace::persistent_slot(nz(*slot)),
10377                        fixtures::created_at(),
10378                    )
10379                })
10380                .collect()
10381        };
10382
10383        assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
10384        assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
10385        // The gap a released middle slot leaves is filled before the tail.
10386        assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
10387        // The ceiling is a refusal, never a reason to allocate past it.
10388        assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
10389        assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
10390        // An ephemeral attempt holds no slot and cannot block one.
10391        let ephemeral = vec![RunnerAttempt::allocate(
10392            AttemptId::new_random(),
10393            fixtures::POLICY_ID,
10394            "/srv/rman/host/abc",
10395            fixtures::created_at(),
10396        )];
10397        assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
10398    }
10399
10400    #[test]
10401    fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
10402        let root = tempfile::tempdir().unwrap();
10403        let slot = root.path().join("s1");
10404        fs::create_dir(&slot).unwrap();
10405        accept_reusable_slot(&slot).expect("an empty slot is reusable");
10406
10407        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10408        accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
10409
10410        // Runner material a previous attempt left behind is refused rather than
10411        // reused or removed: deciding those bytes are safe is cleanup's job.
10412        fs::create_dir(slot.join("bin")).unwrap();
10413        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10414        let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
10415        assert!(refusal.contains("bin"), "{refusal}");
10416        assert!(refusal.contains(".github-runner-id"), "{refusal}");
10417
10418        // A `_work` that is not a real directory is not a job workspace.
10419        let file_work = root.path().join("s2");
10420        fs::create_dir(&file_work).unwrap();
10421        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10422        assert!(accept_reusable_slot(&file_work).is_err());
10423    }
10424
10425    #[cfg(unix)]
10426    #[test]
10427    fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
10428        // Windows needs a privilege to create either kind of link, so the
10429        // link-shaped cases are asserted here; the rule itself is
10430        // platform-independent because it is `symlink_metadata`'s answer.
10431        let root = tempfile::tempdir().unwrap();
10432        let elsewhere = root.path().join("elsewhere");
10433        fs::create_dir(&elsewhere).unwrap();
10434
10435        let slot = root.path().join("s1");
10436        fs::create_dir(&slot).unwrap();
10437        std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10438        assert!(accept_reusable_slot(&slot).is_err());
10439
10440        let linked_slot = root.path().join("s2");
10441        std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
10442        assert!(create_or_validate_slot(&linked_slot).is_err());
10443    }
10444
10445    #[test]
10446    fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
10447        let root = tempfile::tempdir().unwrap();
10448        let occupied = root.path().join("s1");
10449        fs::write(&occupied, b"an operator's file").unwrap();
10450        let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
10451        assert!(refusal.contains("is not a directory"), "{refusal}");
10452        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10453
10454        let fresh = root.path().join("s2");
10455        create_or_validate_slot(&fresh).expect("a missing slot is created");
10456        assert!(fresh.is_dir());
10457        create_or_validate_slot(&fresh).expect("an existing directory is accepted");
10458    }
10459
10460    #[test]
10461    fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
10462        assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
10463        assert!(!is_work_folder(OsStr::new("_work2")));
10464        // A Windows filesystem is case-insensitive, so `_Work` *is* the retained
10465        // job workspace there and must never be removed as a leftover; on a
10466        // case-sensitive filesystem it is a different directory entirely.
10467        assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
10468    }
10469
10470    #[test]
10471    fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
10472        let root = tempfile::tempdir().unwrap();
10473        let package = root.path().join("package");
10474        fs::create_dir_all(package.join("bin")).unwrap();
10475        fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
10476        // A nested `_work` inside the package's own tree is an ordinary name.
10477        fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
10478
10479        let slot = root.path().join("s1");
10480        let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
10481        fs::create_dir_all(&retained).unwrap();
10482        fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
10483
10484        copy_package_tree(&package, &slot, &TrustedTrees::default())
10485            .expect("the package lays out around `_work`");
10486        assert!(slot.join("bin").join("Runner.Listener").exists());
10487        assert!(
10488            slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
10489            "the guard is top-level only"
10490        );
10491        assert_eq!(
10492            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10493            "from the first job"
10494        );
10495
10496        // A package that ever grew a top-level `_work` is refused, not merged.
10497        fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
10498        let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
10499        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
10500        assert_eq!(
10501            fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10502            "from the first job"
10503        );
10504    }
10505
10506    #[test]
10507    fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
10508        let root = tempfile::tempdir().unwrap();
10509
10510        let slot = root.path().join("s1");
10511        let retained = slot.join(DEFAULT_WORK_FOLDER);
10512        fs::create_dir_all(retained.join("repo")).unwrap();
10513        fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
10514        fs::create_dir_all(slot.join("bin")).unwrap();
10515        fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10516        let persistent = RunnerAttempt::allocate_in(
10517            AttemptId::new_random(),
10518            fixtures::POLICY_ID,
10519            &slot,
10520            AttemptWorkspace::persistent_slot(nz(1)),
10521            fixtures::created_at(),
10522        );
10523
10524        remove_materialized_package(&persistent).unwrap();
10525        assert!(slot.is_dir(), "the slot itself is not removed");
10526        assert!(!slot.join("bin").exists());
10527        assert!(!slot.join(".github-runner-id").exists());
10528        assert_eq!(
10529            fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
10530            "kept"
10531        );
10532
10533        let disposable_path = root.path().join("abcdef012345");
10534        fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
10535        let disposable = RunnerAttempt::allocate(
10536            AttemptId::new_random(),
10537            fixtures::POLICY_ID,
10538            &disposable_path,
10539            fixtures::created_at(),
10540        );
10541        remove_materialized_package(&disposable).unwrap();
10542        assert!(
10543            !disposable_path.exists(),
10544            "a disposable directory is still removed whole"
10545        );
10546    }
10547
10548    // -- c3: persistent cleanup and recovery --------------------------------
10549
10550    /// The runner state one attempt leaves at a slot root, as a real attempt
10551    /// leaves it: binaries, registration identity, a JIT handoff that outlived
10552    /// its process, and this agent's own lifecycle sidecars.
10553    ///
10554    /// Driven by [`SENSITIVE_SLOT_ENTRIES`] rather than by a second copy of it,
10555    /// so a name added to the thing cleanup must prove absent is a name every
10556    /// test here starts leaving behind.
10557    fn litter_the_slot(slot: &Path) {
10558        for directory in ["bin", "externals", "_diag"] {
10559            fs::create_dir_all(slot.join(directory)).unwrap();
10560        }
10561        fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
10562        for file in SENSITIVE_SLOT_ENTRIES
10563            .iter()
10564            .filter(|entry| !slot.join(entry).is_dir())
10565        {
10566            fs::write(slot.join(file), b"runner state").unwrap();
10567        }
10568        // A handoff whose owning process died before `Drop` could delete it.
10569        fs::write(
10570            slot.join(format!(
10571                "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
10572                RestrictiveHandoff::NAME_PREFIX
10573            )),
10574            JIT.as_bytes(),
10575        )
10576        .unwrap();
10577    }
10578
10579    /// A marker under `_work` of the kind a job leaves for the next one.
10580    fn retain_under_work(slot: &Path) -> PathBuf {
10581        let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
10582        fs::create_dir_all(&checkout).unwrap();
10583        let marker = checkout.join("build-output.bin");
10584        fs::write(&marker, RETAINED).unwrap();
10585        marker
10586    }
10587
10588    /// What a job leaves under `_work` for the next job to reuse.
10589    const RETAINED: &str = "a Git-ignored build output the next job reuses";
10590
10591    /// Every direct entry of a directory, sorted, as plain strings.
10592    fn entries_of(directory: &Path) -> Vec<String> {
10593        let mut names: Vec<String> = fs::read_dir(directory)
10594            .unwrap()
10595            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
10596            .collect();
10597        names.sort();
10598        names
10599    }
10600
10601    /// The one entry a cleaned slot is allowed to hold.
10602    fn only_the_job_workspace() -> Vec<String> {
10603        vec![DEFAULT_WORK_FOLDER.to_owned()]
10604    }
10605
10606    #[cfg(unix)]
10607    #[test]
10608    fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
10609        use std::sync::mpsc;
10610
10611        let temporary = tempfile::tempdir().unwrap();
10612        let tree = temporary.path().join("attempt");
10613        let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
10614        fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
10615        assert!(
10616            std::process::Command::new("mkfifo")
10617                .arg(&diagnostic)
10618                .status()
10619                .unwrap()
10620                .success()
10621        );
10622
10623        let (finished, result) = mpsc::channel();
10624        std::thread::spawn(move || {
10625            let removed = remove_runtime_tree(&tree);
10626            let _ = finished.send(removed);
10627        });
10628
10629        result
10630            .recv_timeout(Duration::from_secs(2))
10631            .expect("runtime deletion must not wait for a FIFO peer")
10632            .unwrap();
10633        assert!(!diagnostic.exists());
10634    }
10635
10636    /// The junction WinINet leaves in a job's profile, reproduced: its own DACL
10637    /// denies `Everyone` the right to list it.
10638    #[cfg(windows)]
10639    #[test]
10640    fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
10641        let temporary = tempfile::tempdir().unwrap();
10642        let tree = temporary.path().join("attempt");
10643        let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
10644        let outside = temporary.path().join("outside");
10645        fs::create_dir_all(&cache).unwrap();
10646        fs::create_dir_all(&outside).unwrap();
10647        fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
10648        fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
10649        let junction = cache.join("Content.IE5");
10650        let created = std::process::Command::new("cmd")
10651            .arg("/C")
10652            .arg("mklink")
10653            .arg("/J")
10654            .arg(&junction)
10655            .arg(&outside)
10656            .output()
10657            .unwrap();
10658        assert!(created.status.success(), "{created:?}");
10659        // `/L` puts the entry on the junction itself, as WinINet does, rather
10660        // than on the directory it points at. `*S-1-1-0` is `Everyone` in every
10661        // display language.
10662        let denied = std::process::Command::new("icacls")
10663            .arg(&junction)
10664            .args(["/L", "/deny", "*S-1-1-0:(RD)"])
10665            .output()
10666            .unwrap();
10667        assert!(denied.status.success(), "{denied:?}");
10668
10669        // The control: without the fix the tree cannot be removed at all.
10670        let unfixed = remove_dir_all::remove_dir_all(&tree)
10671            .expect_err("the fixture no longer reproduces the WinINet junction");
10672        assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
10673        assert!(junction.symlink_metadata().is_ok());
10674
10675        remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
10676        assert!(!tree.exists(), "the attempt tree survived");
10677        assert_eq!(
10678            fs::read(outside.join("keep.txt")).unwrap(),
10679            b"not the attempt's",
10680            "removing the junction followed it into the directory it points at"
10681        );
10682    }
10683
10684    /// One slot entry that refuses to be removed, and the undo that lets the
10685    /// temporary directory be torn down afterwards.
10686    ///
10687    /// The two operating systems refuse for different reasons and there is no
10688    /// portable third. Windows will not open a file for deletion while a handle
10689    /// with share mode zero is held on it; Unix will not unlink from a directory
10690    /// the caller cannot write. Both are states a real machine reaches -- a
10691    /// scanner holding a file open, a job that left a directory read-only -- so
10692    /// the injection is a filesystem fact rather than a seam cut into the
10693    /// product for a test to pull.
10694    ///
10695    /// The Unix half is a permission, and permissions do not apply to `root`.
10696    /// [`Self::inject`] proves the block on a throwaway directory before
10697    /// claiming it, so a suite running as `root` says it could not inject rather
10698    /// than asserting nothing and passing.
10699    struct BlockedDeletion {
10700        directory: PathBuf,
10701        #[cfg(windows)]
10702        _handle: fs::File,
10703    }
10704
10705    impl BlockedDeletion {
10706        const HELD: &'static str = "held-open";
10707
10708        /// Fill `directory` with a file that cannot be removed, or answer `None`
10709        /// when this account cannot be stopped from removing anything.
10710        fn inject(directory: &Path) -> Option<Self> {
10711            #[cfg(unix)]
10712            if !Self::refusal_is_possible() {
10713                return None;
10714            }
10715            fs::create_dir_all(directory).unwrap();
10716            fs::write(
10717                directory.join(Self::HELD),
10718                b"a file the scrub cannot remove",
10719            )
10720            .unwrap();
10721            #[cfg(windows)]
10722            let handle = {
10723                use std::os::windows::fs::OpenOptionsExt;
10724
10725                fs::OpenOptions::new()
10726                    .read(true)
10727                    .share_mode(0)
10728                    .open(directory.join(Self::HELD))
10729                    .expect("the blocking handle opens")
10730            };
10731            #[cfg(unix)]
10732            Self::set_mode(directory, 0o555);
10733            Some(Self {
10734                directory: directory.to_path_buf(),
10735                #[cfg(windows)]
10736                _handle: handle,
10737            })
10738        }
10739
10740        fn release(self) {
10741            drop(self);
10742        }
10743
10744        #[cfg(unix)]
10745        fn refusal_is_possible() -> bool {
10746            let probe = tempfile::tempdir().unwrap();
10747            let directory = probe.path().join("probe");
10748            fs::create_dir(&directory).unwrap();
10749            fs::write(directory.join("file"), b"probe").unwrap();
10750            Self::set_mode(&directory, 0o555);
10751            let refused = fs::remove_dir_all(&directory).is_err();
10752            Self::set_mode(&directory, 0o755);
10753            refused
10754        }
10755
10756        #[cfg(unix)]
10757        fn set_mode(directory: &Path, mode: u32) {
10758            use std::os::unix::fs::PermissionsExt;
10759
10760            let mut permissions = fs::metadata(directory).unwrap().permissions();
10761            permissions.set_mode(mode);
10762            fs::set_permissions(directory, permissions).unwrap();
10763        }
10764    }
10765
10766    impl Drop for BlockedDeletion {
10767        fn drop(&mut self) {
10768            #[cfg(unix)]
10769            Self::set_mode(&self.directory, 0o755);
10770            #[cfg(not(unix))]
10771            let _ = &self.directory;
10772        }
10773    }
10774
10775    #[tokio::test]
10776    async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
10777        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10778            .with_persistent_workspace(1);
10779        harness.ready().await;
10780
10781        let first = harness.launch().await;
10782        let slot = harness.slot_path(1);
10783        assert_eq!(first.runtime_path(), slot);
10784        assert_eq!(
10785            read_runner_id(&slot),
10786            Some(73),
10787            "the attempt registered, so its identity is on disk"
10788        );
10789        let marker = retain_under_work(&slot);
10790        litter_the_slot(&slot);
10791
10792        harness.cleanup_retaining_work(first.id).await;
10793
10794        // The allowlist is exactly one entry, so this assertion is the security
10795        // property in full: what is retained, and that nothing else is.
10796        assert_eq!(entries_of(&slot), only_the_job_workspace());
10797        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10798        assert_eq!(
10799            read_runner_id(&slot),
10800            None,
10801            "the first attempt's registration identity is gone before the second starts"
10802        );
10803        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10804        assert!(!harness.attempt(first.id).holds_slot_lease());
10805
10806        let second = harness.launch().await;
10807        assert_ne!(second.id, first.id);
10808        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
10809        assert_eq!(
10810            second.runtime_path(),
10811            slot,
10812            "the same slot, so the same retained `_work`"
10813        );
10814        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10815    }
10816
10817    #[tokio::test]
10818    async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
10819        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10820            .with_persistent_workspace(1);
10821        harness.ready().await;
10822        let attempt = harness.launch().await;
10823        let slot = harness.slot_path(1);
10824        let marker = retain_under_work(&slot);
10825        litter_the_slot(&slot);
10826        harness.conclude(attempt.id);
10827
10828        // A repository removed from the product between the attempt concluding
10829        // and the sweep reaching it. The journalled runtime path and slot are
10830        // the only facts left, and `04-security-recovery.md` requires them to be
10831        // enough: the alternative is scanning a root to work out which
10832        // directories were ours, which invariant 6 forbids.
10833        harness
10834            .store
10835            .remove_policy(harness.policy.id, harness.policy.revision())
10836            .unwrap();
10837        assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
10838
10839        harness
10840            .launcher
10841            .clean(attempt.id)
10842            .await
10843            .expect("journal facts alone are enough to clean the slot");
10844
10845        assert_eq!(entries_of(&slot), only_the_job_workspace());
10846        assert!(marker.exists());
10847        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10848    }
10849
10850    #[tokio::test]
10851    async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
10852        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10853            .with_persistent_workspace(2);
10854        harness.ready().await;
10855        let first = harness.launch().await;
10856        let slot = harness.slot_path(1);
10857        let marker = retain_under_work(&slot);
10858        litter_the_slot(&slot);
10859        harness.conclude(first.id);
10860
10861        let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
10862            eprintln!(
10863                "skipped: this account cannot be refused a deletion, so no partial deletion can \
10864                 be injected"
10865            );
10866            return;
10867        };
10868
10869        let refusal = harness
10870            .launcher
10871            .clean(first.id)
10872            .await
10873            .expect_err("a deletion that failed may not report a cleaned slot");
10874        let rendered = refusal.reason.to_string();
10875        assert!(rendered.contains("could not be removed"), "{rendered}");
10876
10877        let held = harness.attempt(first.id);
10878        assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
10879        assert!(held.holds_slot_lease(), "so the slot is still leased");
10880        assert!(
10881            !held.state().counts_against_capacity(),
10882            "and a concluded attempt still costs the host no capacity"
10883        );
10884
10885        // The same journal and the same directories, under a launcher that
10886        // remembers nothing. Recovery must complete: a host that can launch
10887        // nothing at all because one slot is stuck is not what "does not count
10888        // as active host capacity" means.
10889        let restarted = harness.restart();
10890        restarted
10891            .recover_startup(std::slice::from_ref(&harness.policy))
10892            .await
10893            .expect("one quarantined slot does not stop the host recovering");
10894        assert_eq!(
10895            harness.attempt(first.id).state(),
10896            AttemptState::Failed,
10897            "the quarantine survived the restart"
10898        );
10899        assert!(
10900            harness
10901                .reconcile_events
10902                .events()
10903                .iter()
10904                .any(|event| matches!(
10905                    event,
10906                    LifecycleEvent::AttemptCleanFailed {
10907                        reason: "slot_entry_could_not_be_removed",
10908                        ..
10909                    }
10910                )),
10911            "the refusal is reported rather than retried in silence"
10912        );
10913
10914        // Capacity two, slot one quarantined: the next attempt goes to s2 and
10915        // never to the slot still holding runner state.
10916        let second = harness
10917            .launch_through(&restarted)
10918            .await
10919            .expect("the host can still launch");
10920        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10921
10922        // And the same cleanup succeeds once the obstruction is gone, which is
10923        // what "retry through normal recovery" has to mean.
10924        block.release();
10925        restarted
10926            .clean(first.id)
10927            .await
10928            .expect("the retried cleanup completes");
10929        assert_eq!(entries_of(&slot), only_the_job_workspace());
10930        assert!(marker.exists());
10931        assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10932    }
10933
10934    /// The disposable half of the same injection, which had no test of its own.
10935    ///
10936    /// `scrub_workspace`'s ephemeral arm turns a failed `remove_dir_all` into
10937    /// `"attempt workspace could not be removed"`, and until now that branch was
10938    /// only reachable in theory: every injected-deletion test drove a persistent
10939    /// slot. The property is the same one and matters for the same reason --
10940    /// `04-security-recovery.md`'s "Cleanup partly fails and the slot is reused
10941    /// anyway" -- but the disposable guarantee is stronger, so a cleanup that
10942    /// reported success over a directory it had not removed would be the
10943    /// contamination gate failing silently rather than a slot being held.
10944    #[tokio::test]
10945    async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
10946        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10947        harness.ready().await;
10948        let attempt = harness.launch().await;
10949        let runtime = attempt.runtime_path().to_path_buf();
10950        assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10951        harness.conclude(attempt.id);
10952
10953        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10954            eprintln!(
10955                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10956            );
10957            return;
10958        };
10959
10960        let refusal = harness
10961            .launcher
10962            .clean(attempt.id)
10963            .await
10964            .expect_err("a deletion that failed may not report a removed workspace");
10965        let rendered = refusal.reason.to_string();
10966        assert!(
10967            rendered.contains("could not be removed"),
10968            "the refusal names what happened: {rendered}"
10969        );
10970        assert_ne!(
10971            harness.attempt(attempt.id).state(),
10972            AttemptState::Cleaned,
10973            "an attempt whose directory is still on disk is not cleaned"
10974        );
10975        assert!(
10976            runtime.is_dir(),
10977            "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
10978        );
10979
10980        let restarted = harness.restart();
10981        restarted
10982            .recover_startup(std::slice::from_ref(&harness.policy))
10983            .await
10984            .expect("one locked ephemeral workspace does not stop startup recovery");
10985        assert!(
10986            harness
10987                .reconcile_events
10988                .events()
10989                .iter()
10990                .any(|event| matches!(
10991                    event,
10992                    LifecycleEvent::AttemptCleanFailed {
10993                        attempt: failed_attempt,
10994                        reason: "ephemeral_workspace_could_not_be_removed",
10995                        ..
10996                    } if *failed_attempt == attempt.id
10997                )),
10998            "the deferred cleanup remains visible while the daemon keeps running"
10999        );
11000
11001        // And the ordinary retry -- the reconciler's next terminal sweep --
11002        // finishes it once the obstruction is gone.
11003        block.release();
11004        harness
11005            .launcher
11006            .clean(attempt.id)
11007            .await
11008            .expect("the retried cleanup completes");
11009        assert!(!runtime.exists(), "the whole attempt directory goes");
11010        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
11011    }
11012
11013    /// Supervision retries a workspace it cannot remove with per-attempt
11014    /// backoff, so a stuck workspace is reported once per delay rather than on
11015    /// every pass.
11016    #[tokio::test]
11017    async fn supervision_backs_off_a_workspace_it_cannot_remove() {
11018        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
11019        harness.ready().await;
11020        let attempt = harness.launch().await;
11021        let runtime = attempt.runtime_path().to_path_buf();
11022        harness.conclude(attempt.id);
11023        let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
11024            eprintln!(
11025                "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
11026            );
11027            return;
11028        };
11029        let failures = || {
11030            harness
11031                .reconcile_events
11032                .events()
11033                .iter()
11034                .filter(|event| {
11035                    matches!(
11036                        event,
11037                        LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
11038                            if *failed == attempt.id
11039                    )
11040                })
11041                .count()
11042        };
11043        let supervise = async || {
11044            harness
11045                .launcher
11046                .supervise(&harness.policy)
11047                .await
11048                .expect("a stuck workspace does not fail supervision");
11049        };
11050
11051        supervise().await;
11052        assert_eq!(failures(), 1);
11053        supervise().await;
11054        assert_eq!(
11055            failures(),
11056            1,
11057            "the failed cleanup was retried on the very next pass"
11058        );
11059        harness.clock.advance_secs(31);
11060        supervise().await;
11061        assert_eq!(
11062            failures(),
11063            2,
11064            "the cleanup was not retried once its delay passed"
11065        );
11066
11067        block.release();
11068        harness.clock.advance_secs(61);
11069        supervise().await;
11070        assert!(
11071            !runtime.exists(),
11072            "the retry removes the workspace once it can"
11073        );
11074        assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
11075    }
11076
11077    #[tokio::test]
11078    async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
11079        let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11080            .with_persistent_workspace(1);
11081        harness.ready().await;
11082        let first = harness.launch().await;
11083        let slot = harness.slot_path(1);
11084        let marker = retain_under_work(&slot);
11085        harness.cleanup_retaining_work(first.id).await;
11086
11087        // Every attempt for this policy is cleaned, so the mutation is allowed
11088        // (`04-security-recovery.md`, "Recovery rules"). What it must not do is
11089        // move or delete anything the operator still owns.
11090        harness
11091            .policy
11092            .set_workspace_policy(WorkspacePolicy::Ephemeral)
11093            .unwrap();
11094
11095        let second = harness.launch().await;
11096        assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
11097        assert_eq!(
11098            second.runtime_path().parent().unwrap(),
11099            harness.host_root(),
11100            "a disposable attempt is a child of the host root"
11101        );
11102        assert!(slot.is_dir(), "the old slot is left where it stands");
11103        assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
11104
11105        // And cleaning the disposable attempt removes its own directory whole
11106        // without reaching the retained slot beside it.
11107        harness.conclude(second.id);
11108        harness.launcher.clean(second.id).await.unwrap();
11109        assert!(!second.runtime_path().exists());
11110        assert!(marker.exists());
11111    }
11112
11113    #[tokio::test]
11114    async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
11115        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11116            .with_persistent_workspace(1);
11117        harness.ready().await;
11118        let slot = harness.slot_path(1);
11119        fs::create_dir_all(&slot).unwrap();
11120        let marker = retain_under_work(&slot);
11121        litter_the_slot(&slot);
11122
11123        let id = AttemptId::new_random();
11124        let mut attempt = RunnerAttempt::allocate_in(
11125            id,
11126            harness.policy.id,
11127            &slot,
11128            AttemptWorkspace::persistent_slot(nz(1)),
11129            harness.clock.now(),
11130        );
11131        attempt.jit_received(harness.clock.now()).unwrap();
11132        attempt.started(4242, harness.clock.now()).unwrap();
11133        harness.store.record_attempt(&attempt).unwrap();
11134        harness.clock.advance_secs(11);
11135        harness.processes.set_alive(true);
11136        harness
11137            .github
11138            .observe(GithubRunnerObservation::NotRegistered);
11139
11140        harness.launcher.supervise(&harness.policy).await.unwrap();
11141
11142        // The identity and termination ordering `e3` established is unchanged by
11143        // the workspace kind: the intent is durable before the signal, and the
11144        // slot is scrubbed only once the process is gone.
11145        let actions = harness.processes.actions.lock().unwrap().clone();
11146        let intent = actions
11147            .iter()
11148            .position(|action| *action == "terminate_intent")
11149            .unwrap();
11150        let signal = actions
11151            .iter()
11152            .position(|action| *action == "terminate")
11153            .unwrap();
11154        assert!(intent < signal, "{actions:?}");
11155        assert!(!harness.processes.alive.load(Ordering::SeqCst));
11156
11157        let cleaned = harness.attempt(id);
11158        assert_eq!(cleaned.state(), AttemptState::Cleaned);
11159        assert!(matches!(
11160            cleaned.outcome(),
11161            Some(AttemptOutcome::Failed {
11162                reason: FailureReason::TerminatedAfterRegistrationTimeout
11163            })
11164        ));
11165        assert_eq!(entries_of(&slot), only_the_job_workspace());
11166        assert!(marker.exists());
11167    }
11168
11169    #[test]
11170    fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
11171        let root = tempfile::tempdir().unwrap();
11172        let slot = root.path().join("s1");
11173        fs::create_dir(&slot).unwrap();
11174        let marker = retain_under_work(&slot);
11175        litter_the_slot(&slot);
11176        fs::write(slot.join("runner-package"), b"verified").unwrap();
11177
11178        scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
11179        verify_slot_scrubbed(&slot).expect("and proves it afterwards");
11180
11181        assert_eq!(entries_of(&slot), only_the_job_workspace());
11182        assert!(marker.exists());
11183    }
11184
11185    #[test]
11186    fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
11187        let slot = Path::new("/runners/s1");
11188
11189        // The ordinary case: the listing counted, so the count is the fact and
11190        // the published names qualify it.
11191        let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
11192        assert!(counted.contains("2 entries other than"), "{counted}");
11193        assert!(counted.contains("including `bin`"), "{counted}");
11194        assert_eq!(
11195            residue_detail(slot, 1, &[]),
11196            format!(
11197                "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
11198                slot.display()
11199            )
11200        );
11201
11202        // The race the second pass exists for: the listing saw nothing and the
11203        // filesystem answered otherwise. Saying "0 entries survived" here would
11204        // state the under-count as the fact and contradict the rest of the
11205        // sentence.
11206        let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
11207        assert!(!raced.contains('0'), "{raced}");
11208        assert!(raced.contains("reported nothing but"), "{raced}");
11209        assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
11210    }
11211
11212    #[test]
11213    fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
11214        let root = tempfile::tempdir().unwrap();
11215        let slot = root.path().join("s1");
11216        fs::create_dir(&slot).unwrap();
11217        fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11218        verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
11219
11220        // Runner binaries, registration identity, process identity and this
11221        // agent's lifecycle marks, one at a time, so a scrub that skipped
11222        // exactly one is still caught.
11223        for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
11224            fs::write(slot.join(survivor), b"left behind").unwrap();
11225            let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11226            assert_eq!(quarantine.refusal, SlotRefusal::Residue);
11227            assert!(
11228                quarantine.detail.contains(&format!("`{survivor}`")),
11229                "{quarantine}"
11230            );
11231            fs::remove_file(slot.join(survivor)).unwrap();
11232        }
11233
11234        // A handoff is named by its published prefix, never by the UUID that
11235        // follows it, and never by the payload it holds.
11236        let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
11237        fs::write(&handoff, JIT.as_bytes()).unwrap();
11238        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11239        assert!(
11240            quarantine.detail.contains("an encoded JIT handoff"),
11241            "{quarantine}"
11242        );
11243        assert!(!quarantine.detail.contains(JIT), "{quarantine}");
11244        fs::remove_file(&handoff).unwrap();
11245
11246        // A name a workflow chose is counted and never echoed: a slot root is
11247        // writable by the job, so a file named after a secret would be published
11248        // by any message that repeated the listing.
11249        fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
11250        let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11251        assert!(
11252            quarantine.detail.contains("1 entry other than"),
11253            "{quarantine}"
11254        );
11255        assert!(
11256            !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
11257            "{quarantine}"
11258        );
11259    }
11260
11261    #[test]
11262    fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
11263        let root = tempfile::tempdir().unwrap();
11264        let configured =
11265            LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
11266        let slot = configured.as_path().join("s1");
11267        fs::create_dir(&slot).unwrap();
11268
11269        verify_journalled_slot(&slot, nz(1), Some(&configured))
11270            .expect("the journalled slot agrees");
11271        verify_journalled_slot(&slot, nz(1), None)
11272            .expect("and a policy that is gone removes a check, not the ability to clean");
11273
11274        // The journalled slot number is what names the directory. `s1` recorded
11275        // as slot two is corrupt state, not a slot to clean.
11276        assert_eq!(
11277            verify_journalled_slot(&slot, nz(2), None)
11278                .unwrap_err()
11279                .refusal,
11280            SlotRefusal::NotTheJournalledSlot
11281        );
11282        for stray in ["s1/nested", "not-a-slot", "s01"] {
11283            let path = configured.as_path().join(stray);
11284            assert_eq!(
11285                verify_journalled_slot(&path, nz(1), None)
11286                    .unwrap_err()
11287                    .refusal,
11288                SlotRefusal::NotTheJournalledSlot,
11289                "{}",
11290                path.display()
11291            );
11292        }
11293
11294        // A surviving policy that names a different root does not get to have
11295        // its disagreement resolved by deleting something.
11296        let elsewhere = tempfile::tempdir().unwrap();
11297        let other =
11298            LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
11299        assert_eq!(
11300            verify_journalled_slot(&slot, nz(1), Some(&other))
11301                .unwrap_err()
11302                .refusal,
11303            SlotRefusal::PolicyRootDisagrees
11304        );
11305    }
11306
11307    #[cfg(unix)]
11308    #[test]
11309    fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
11310        // Windows needs a privilege to create a junction or a symlink, so the
11311        // substitution is made here; the rule is platform-independent because it
11312        // is `symlink_metadata`'s answer plus the reparse attribute.
11313        let root = tempfile::tempdir().unwrap();
11314        let outside = root.path().join("operator-data");
11315        fs::create_dir(&outside).unwrap();
11316        let sentinel = outside.join("do-not-delete.txt");
11317        fs::write(
11318            &sentinel,
11319            b"an operator's data, outside every approved root",
11320        )
11321        .unwrap();
11322
11323        let slot = root.path().join("s1");
11324        fs::create_dir(&slot).unwrap();
11325        fs::create_dir(slot.join("bin")).unwrap();
11326        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11327
11328        let quarantine = scrub_slot_entries(&slot).unwrap_err();
11329        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11330        assert!(
11331            sentinel.exists(),
11332            "the deletion followed the link out of the slot"
11333        );
11334        assert!(outside.is_dir());
11335        assert!(
11336            slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
11337            "the substituted link is left for the operator, never unlinked as if it were ours"
11338        );
11339
11340        // A `_work` that is a plain file is the same refusal for the same
11341        // reason: it is not a job workspace, and this is not the code that
11342        // decides what to do about it.
11343        let file_work = root.path().join("s2");
11344        fs::create_dir(&file_work).unwrap();
11345        fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
11346        assert_eq!(
11347            scrub_slot_entries(&file_work).unwrap_err().refusal,
11348            SlotRefusal::WorkNotADirectory
11349        );
11350    }
11351
11352    #[cfg(unix)]
11353    #[test]
11354    fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
11355        let root = tempfile::tempdir().unwrap();
11356        let outside = root.path().join("operator-data");
11357        fs::create_dir(&outside).unwrap();
11358        let sentinel = outside.join("do-not-delete.txt");
11359        fs::write(
11360            &sentinel,
11361            b"an operator's data, outside every approved root",
11362        )
11363        .unwrap();
11364
11365        // The lexical half of containment passes -- the name is right and the
11366        // parent is right -- and canonical resolution is what catches it.
11367        let inside = root.path().join("inside");
11368        fs::create_dir(&inside).unwrap();
11369        let slot = inside.join("s1");
11370        std::os::unix::fs::symlink(&outside, &slot).unwrap();
11371
11372        assert_eq!(
11373            verify_journalled_slot(&slot, nz(1), None)
11374                .unwrap_err()
11375                .refusal,
11376            SlotRefusal::Containment
11377        );
11378        assert!(sentinel.exists());
11379        assert!(
11380            slot.symlink_metadata().is_ok(),
11381            "the link is left for the operator rather than removed as if it were ours"
11382        );
11383    }
11384
11385    /// The Windows half of the case above.
11386    ///
11387    /// Containment was proven only on Unix, and a symbolic link is the wrong
11388    /// instrument to prove it with on Windows: creating one needs a privilege an
11389    /// ordinary workflow does not have, so it is not the substitution an
11390    /// attacker would reach for. A **junction** needs none, which makes it both
11391    /// the realistic attack and the one this repository must refuse -- and
11392    /// `04-security-recovery.md` names it in the same breath as the symlink for
11393    /// exactly that reason.
11394    #[cfg(windows)]
11395    #[test]
11396    fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
11397        let root = tempfile::tempdir().unwrap();
11398        let outside = root.path().join("operator-data");
11399        fs::create_dir(&outside).unwrap();
11400        let sentinel = outside.join("do-not-delete.txt");
11401        fs::write(
11402            &sentinel,
11403            b"an operator's data, outside every approved root",
11404        )
11405        .unwrap();
11406
11407        // The lexical half of containment passes -- `s1` under the root the
11408        // journal names -- and canonical resolution is what catches it.
11409        let inside = root.path().join("inside");
11410        fs::create_dir(&inside).unwrap();
11411        let slot = inside.join("s1");
11412        let Some(()) = plant_junction(&slot, &outside) else {
11413            eprintln!("skipped: this machine would not create a directory junction");
11414            return;
11415        };
11416
11417        assert_eq!(
11418            verify_journalled_slot(&slot, nz(1), None)
11419                .unwrap_err()
11420                .refusal,
11421            SlotRefusal::Containment
11422        );
11423        assert!(
11424            sentinel.exists(),
11425            "the refusal resolved the junction and reached the operator's data"
11426        );
11427        assert!(
11428            slot.symlink_metadata().is_ok(),
11429            "the junction is left for the operator rather than removed as if it were ours"
11430        );
11431    }
11432
11433    /// Plant a directory junction at `link` pointing at `target`.
11434    ///
11435    /// A junction is the Windows substitution this has to refuse, and unlike a
11436    /// symbolic link it needs no privilege — which is exactly why it is the one
11437    /// an unprivileged workflow would reach for. `mklink` is a `cmd` builtin, so
11438    /// there is no binary to find and nothing to install; `None` means this
11439    /// machine would not make one and the caller says so rather than asserting
11440    /// nothing.
11441    #[cfg(windows)]
11442    fn plant_junction(link: &Path, target: &Path) -> Option<()> {
11443        let made = std::process::Command::new("cmd")
11444            .arg("/C")
11445            .arg("mklink")
11446            .arg("/J")
11447            .arg(link)
11448            .arg(target)
11449            .output()
11450            .ok()?;
11451        (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
11452    }
11453
11454    /// The dependency caches live beside attempts, under `<runner root>/_cache`,
11455    /// and jobs link into them (pnpm hard-links from its store; a workflow may
11456    /// symlink or junction a cache into its workspace). Removing an ephemeral
11457    /// attempt must take the links and never what they point at.
11458    #[test]
11459    fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
11460        let root = tempfile::tempdir().unwrap();
11461        let cache = root.path().join("_cache").join("o").join("r").join("npm");
11462        fs::create_dir_all(&cache).unwrap();
11463        let cached = cache.join("package.tgz");
11464        fs::write(&cached, b"kept between jobs").unwrap();
11465        let attempt = root.path().join("0123456789abcdef");
11466        let work = attempt.join("_work").join("repo");
11467        fs::create_dir_all(&work).unwrap();
11468        fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
11469        #[cfg(unix)]
11470        std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
11471        #[cfg(windows)]
11472        if plant_junction(&work.join("linked-cache"), &cache).is_none() {
11473            eprintln!("this machine would not create a junction; the hard link is still checked");
11474        }
11475
11476        remove_runtime_tree(&attempt).unwrap();
11477        assert!(!attempt.exists());
11478        assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
11479    }
11480
11481    #[cfg(windows)]
11482    #[test]
11483    fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
11484        let root = tempfile::tempdir().unwrap();
11485        let outside = root.path().join("operator-data");
11486        fs::create_dir(&outside).unwrap();
11487        let sentinel = outside.join("do-not-delete.txt");
11488        fs::write(
11489            &sentinel,
11490            b"an operator's data, outside every approved root",
11491        )
11492        .unwrap();
11493
11494        let slot = root.path().join("s1");
11495        fs::create_dir(&slot).unwrap();
11496        fs::create_dir(slot.join("bin")).unwrap();
11497        let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
11498            eprintln!("skipped: this machine would not create a directory junction");
11499            return;
11500        };
11501
11502        // The reparse point is what `is_link_like` answers on, so a junction is
11503        // refused for the same reason a symbolic link is and neither is
11504        // descended into.
11505        let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11506        assert!(is_link_like(&work), "a junction is a reparse point");
11507        let quarantine = scrub_slot_entries(&slot).unwrap_err();
11508        assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11509        assert!(
11510            sentinel.exists(),
11511            "the deletion followed the junction out of the slot"
11512        );
11513        assert!(outside.is_dir());
11514
11515        // A junction standing where an ordinary entry was is unlinked rather
11516        // than followed, so the removal still cannot reach through it.
11517        let elsewhere = root.path().join("s2");
11518        fs::create_dir(&elsewhere).unwrap();
11519        fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
11520        if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
11521            scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
11522            verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
11523            assert!(sentinel.exists(), "the junction was followed, not unlinked");
11524            assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
11525        }
11526    }
11527
11528    #[cfg(unix)]
11529    #[tokio::test]
11530    async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
11531        let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11532            .with_persistent_workspace(2);
11533        harness.ready().await;
11534        let first = harness.launch().await;
11535        let slot = harness.slot_path(1);
11536        harness.conclude(first.id);
11537
11538        let outside = harness._root.path().join("operator-data");
11539        fs::create_dir_all(&outside).unwrap();
11540        let sentinel = outside.join("do-not-delete.txt");
11541        fs::write(&sentinel, b"outside every approved root").unwrap();
11542        std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11543
11544        harness
11545            .launcher
11546            .clean(first.id)
11547            .await
11548            .expect_err("a slot whose `_work` was substituted is quarantined");
11549        assert!(sentinel.exists());
11550
11551        let held = harness.attempt(first.id);
11552        assert_eq!(held.state(), AttemptState::Failed);
11553        assert!(held.holds_slot_lease());
11554
11555        // The quarantined slot is not silently chosen again.
11556        let second = harness.launch().await;
11557        assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
11558    }
11559
11560    #[test]
11561    fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
11562        let root = tempfile::tempdir().unwrap();
11563        let occupied = root.path().join("s1");
11564        fs::write(&occupied, b"an operator's file").unwrap();
11565        // The journal check passes -- it is the right name under the right root
11566        // -- and the shape check is what refuses.
11567        verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
11568        assert_eq!(
11569            slot_is_present(&occupied).unwrap_err().refusal,
11570            SlotRefusal::SlotNotADirectory
11571        );
11572        assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
11573
11574        // A directory that is simply not there leaves nothing to remove and
11575        // nothing to prove absent, so it is not a refusal.
11576        assert!(!slot_is_present(&root.path().join("s2")).unwrap());
11577        let present = root.path().join("s3");
11578        fs::create_dir(&present).unwrap();
11579        assert!(slot_is_present(&present).unwrap());
11580    }
11581
11582    #[test]
11583    fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
11584        let root = tempfile::tempdir().unwrap();
11585
11586        // A disposable directory that happens to contain a `_work` still goes
11587        // whole: the workspace kind is immutable so that the shape of a
11588        // directory a workflow can write to cannot choose its own algorithm.
11589        let disposable = root.path().join("abcdef012345");
11590        fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11591        let ephemeral = RunnerAttempt::allocate(
11592            AttemptId::new_random(),
11593            fixtures::POLICY_ID,
11594            &disposable,
11595            fixtures::created_at(),
11596        );
11597        remove_materialized_package(&ephemeral).unwrap();
11598        assert!(!disposable.exists());
11599
11600        // And a slot keeps its `_work` with the same contents beneath it.
11601        let slot = root.path().join("s1");
11602        fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11603        fs::create_dir_all(slot.join("bin")).unwrap();
11604        let persistent = RunnerAttempt::allocate_in(
11605            AttemptId::new_random(),
11606            fixtures::POLICY_ID,
11607            &slot,
11608            AttemptWorkspace::persistent_slot(nz(1)),
11609            fixtures::created_at(),
11610        );
11611        remove_materialized_package(&persistent).unwrap();
11612        assert_eq!(entries_of(&slot), only_the_job_workspace());
11613        assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
11614    }
11615
11616    #[test]
11617    fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
11618        let refusals = [
11619            SlotRefusal::NotTheJournalledSlot,
11620            SlotRefusal::PolicyRootDisagrees,
11621            SlotRefusal::Containment,
11622            SlotRefusal::SlotNotADirectory,
11623            SlotRefusal::Enumeration,
11624            SlotRefusal::WorkNotADirectory,
11625            SlotRefusal::Deletion,
11626            SlotRefusal::Residue,
11627        ];
11628        let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
11629        assert_eq!(
11630            classes.len(),
11631            refusals.len(),
11632            "an event class shared by two refusals tells an operator less than it appears to"
11633        );
11634        for refusal in refusals {
11635            // The event field is a closed vocabulary, so it has to look like
11636            // one: `d1`'s sink allows the name verbatim.
11637            assert!(
11638                refusal
11639                    .class()
11640                    .chars()
11641                    .all(|c| c.is_ascii_lowercase() || c == '_'),
11642                "{}",
11643                refusal.class()
11644            );
11645            assert!(
11646                refusal.remediation().contains("slot lease"),
11647                "every refusal has to say the lease is still held: {}",
11648                refusal.class()
11649            );
11650        }
11651    }
11652
11653    #[test]
11654    fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
11655        let root = tempfile::tempdir().unwrap();
11656        let source = root.path().join("source with spaces");
11657        let dest = root.path().join("dest with spaces");
11658
11659        fs::create_dir_all(&source).unwrap();
11660        fs::write(source.join("file1.txt"), b"hello").unwrap();
11661
11662        let nested = source.join("nested dir");
11663        fs::create_dir_all(&nested).unwrap();
11664        fs::write(nested.join("file2.txt"), b"world").unwrap();
11665
11666        // This is not a top-level `_work`, so it should be allowed
11667        let nested_work = nested.join(DEFAULT_WORK_FOLDER);
11668        fs::create_dir_all(&nested_work).unwrap();
11669        fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
11670
11671        copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
11672
11673        assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
11674        assert_eq!(
11675            fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
11676            "world"
11677        );
11678        assert_eq!(
11679            fs::read_to_string(
11680                dest.join("nested dir")
11681                    .join(DEFAULT_WORK_FOLDER)
11682                    .join("allowed.txt")
11683            )
11684            .unwrap(),
11685            "allowed"
11686        );
11687    }
11688
11689    #[test]
11690    fn every_field_name_the_attempt_sink_emits_is_allowed() {
11691        use runner_manager_platform::logging::is_field_allowed;
11692
11693        // Kept beside the sink rather than derived from it, as in `reconcile`.
11694        for field in [
11695            "event",
11696            "attempt_id",
11697            "reason",
11698            "retry_in_ms",
11699            "duration_ms",
11700            "order_wait_ms",
11701            "lock_wait_ms",
11702            "turn_wait_ms",
11703            "materialize_ms",
11704            "package_wait_ms",
11705            "package_install_ms",
11706            "package_verify_ms",
11707            "package_copy_ms",
11708            "package_downloaded",
11709            "prepare_ms",
11710            "register_ms",
11711            "spawn_ms",
11712        ] {
11713            assert!(
11714                is_field_allowed(field),
11715                "`{field}` would be logged as `[redacted]`"
11716            );
11717        }
11718    }
11719
11720    #[cfg(unix)]
11721    fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
11722        let entry = root.join("packages").join(version);
11723        fs::create_dir_all(entry.join("bin")).unwrap();
11724        fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
11725        fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
11726        entry
11727    }
11728
11729    /// The seed's binary's inode: a rebuilt seed holds a new file.
11730    #[cfg(unix)]
11731    fn listener_inode(seed: &Path) -> u64 {
11732        use std::os::unix::fs::MetadataExt as _;
11733        fs::metadata(seed.join("bin").join("Runner.Listener"))
11734            .unwrap()
11735            .ino()
11736    }
11737
11738    #[cfg(unix)]
11739    #[test]
11740    fn a_package_seed_is_built_once_and_reused_while_untouched() {
11741        let root = tempfile::tempdir().unwrap();
11742        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11743        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11744        let trusted = TrustedTrees::default();
11745
11746        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11747        assert_eq!(seed, seeds.join("2.336.0"));
11748        assert_eq!(
11749            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11750            "2.336.0"
11751        );
11752
11753        let built = listener_inode(&seed);
11754        assert_eq!(
11755            refresh_package_seed(&source, &seeds, &trusted).unwrap(),
11756            seed
11757        );
11758        assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
11759    }
11760
11761    #[cfg(unix)]
11762    #[test]
11763    fn a_tampered_or_unrecorded_seed_is_rebuilt() {
11764        let root = tempfile::tempdir().unwrap();
11765        let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11766        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11767        let trusted = TrustedTrees::default();
11768        let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11769
11770        // A job rewrites the binary in place and leaves the manifest alone.
11771        fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
11772        refresh_package_seed(&source, &seeds, &trusted).unwrap();
11773        assert_eq!(
11774            fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11775            "2.336.0",
11776            "a seed whose print moved is rebuilt from the cache entry"
11777        );
11778
11779        // A restarted daemon has no print of a seed it did not build.
11780        // Keep the old inode allocated until the replacement is complete;
11781        // otherwise the filesystem may immediately reuse its number.
11782        let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
11783        let built = listener_inode(&seed);
11784        refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
11785        assert_ne!(
11786            listener_inode(&seed),
11787            built,
11788            "an unrecorded seed is rebuilt"
11789        );
11790        drop(old_listener);
11791    }
11792
11793    #[cfg(unix)]
11794    #[test]
11795    fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
11796        let root = tempfile::tempdir().unwrap();
11797        let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11798        let trusted = TrustedTrees::default();
11799        let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
11800        refresh_package_seed(&old, &seeds, &trusted).unwrap();
11801        fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
11802        fs::write(seeds.join("stray"), b"").unwrap();
11803
11804        let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
11805        let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
11806
11807        let mut left: Vec<_> = fs::read_dir(&seeds)
11808            .unwrap()
11809            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
11810            .collect();
11811        left.sort();
11812        assert_eq!(left, ["2.336.0"]);
11813
11814        // A reinstalled entry of the same version has a different manifest.
11815        fs::write(
11816            new.join(crate::package::MANIFEST_FILE),
11817            "{\"digest\":\"again\"}",
11818        )
11819        .unwrap();
11820        refresh_package_seed(&new, &seeds, &trusted).unwrap();
11821        assert_eq!(
11822            fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
11823            "{\"digest\":\"again\"}",
11824            "a stale seed is rebuilt from the changed entry"
11825        );
11826    }
11827
11828    #[cfg(unix)]
11829    #[test]
11830    fn a_package_on_the_runtime_volume_is_its_own_seed() {
11831        let root = tempfile::tempdir().unwrap();
11832        let source = package_entry(root.path(), "2.336.0", "{}");
11833        let runtime = root.path().join("runners").join("attempt");
11834        fs::create_dir_all(&runtime).unwrap();
11835
11836        let trusted = TrustedTrees::default();
11837        assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
11838        copy_package_tree(&source, &runtime, &trusted).unwrap();
11839        assert!(runtime.join("bin").join("Runner.Listener").exists());
11840        assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
11841    }
11842
11843    #[test]
11844    fn copy_package_tree_refuses_top_level_work_folder() {
11845        let root = tempfile::tempdir().unwrap();
11846        let source = root.path().join("source");
11847        let dest = root.path().join("dest");
11848
11849        fs::create_dir_all(&source).unwrap();
11850        fs::write(source.join("file1.txt"), b"hello").unwrap();
11851
11852        // Top-level `_work` should be refused
11853        let top_work = source.join(DEFAULT_WORK_FOLDER);
11854        fs::create_dir_all(&top_work).unwrap();
11855
11856        let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
11857        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
11858    }
11859}